refactor: Migrate the project functionality and optimize the code structure

This commit is contained in:
hayden
2024-10-12 17:31:11 +08:00
committed by hayden
parent d96aff80c2
commit c1747a79f3
71 changed files with 6741 additions and 1320 deletions

41
src/App.vue Normal file
View File

@@ -0,0 +1,41 @@
<template>
<DialogManager></DialogManager>
<DialogDownload></DialogDownload>
<GlobalToast></GlobalToast>
<ConfirmDialog></ConfirmDialog>
<GlobalLoading></GlobalLoading>
</template>
<script setup lang="ts">
import GlobalToast from 'components/GlobalToast.vue'
import DialogManager from 'components/DialogManager.vue'
import DialogDownload from 'components/DialogDownload.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
import ConfirmDialog from 'primevue/confirmdialog'
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStoreProvider } from 'hooks/store'
const { t } = useI18n()
const { dialogManager } = useStoreProvider()
onMounted(() => {
app.ui?.menuContainer?.appendChild(
$el('button', {
id: 'comfyui-model-manager-button',
textContent: t('modelManager'),
onclick: () => dialogManager.toggle(),
}),
)
app.menu?.settingsGroup.append(
new ComfyButton({
icon: 'folder-search',
tooltip: t('openModelManager'),
content: t('modelManager'),
action: () => dialogManager.toggle(),
}),
)
})
</script>

View File

@@ -0,0 +1,160 @@
<template>
<Dialog
v-model:visible="visible"
:header="$t('parseModelUrl')"
:modal="true"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
pt:root:class="max-h-full"
pt:content:class="px-0"
@after-hide="clearContent"
>
<div class="flex h-full flex-col gap-4 px-5">
<ResponseInput
v-model="modelUrl"
:allow-clear="true"
:placeholder="$t('pleaseInputModelUrl')"
@keypress.enter="searchModelsByUrl"
>
<template #suffix>
<span
class="pi pi-search pi-inputicon"
@click="searchModelsByUrl"
></span>
</template>
</ResponseInput>
<div v-show="data.length > 0">
<ResponseSelect
v-model="current"
:items="data"
:type="isMobile ? 'drop' : 'button'"
>
<template #prefix>
<span>version:</span>
</template>
</ResponseSelect>
</div>
<ResponseScrollArea class="-mx-5 h-full">
<div class="px-5">
<ModelContent
v-for="{ item } in data"
v-show="current == item.id"
:key="item.id"
:model="item"
:editable="true"
@submit="createDownTask"
>
<template #action>
<Button
icon="pi pi-download"
:label="$t('download')"
type="submit"
></Button>
</template>
</ModelContent>
<div v-show="data.length === 0">
<div class="flex flex-col items-center gap-4 py-8">
<i class="pi pi-box text-3xl"></i>
<div>No Models Found</div>
</div>
</div>
</div>
</ResponseScrollArea>
</div>
<DialogResizer :min-width="390"></DialogResizer>
</Dialog>
</template>
<script setup lang="ts">
import DialogResizer from 'components/DialogResizer.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import ModelContent from 'components/ModelContent.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useModelSearch } from 'hooks/download'
import { ref } from 'vue'
import { previewUrlToFile } from 'utils/common'
import { useLoading } from 'hooks/loading'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import { useConfig } from 'hooks/config'
const visible = defineModel<boolean>('visible')
const { isMobile } = useConfig()
const { toast } = useToast()
const loading = useLoading()
const modelUrl = ref<string>()
const { current, data, search } = useModelSearch()
const searchModelsByUrl = async () => {
if (modelUrl.value) {
await search(modelUrl.value)
}
}
const clearContent = () => {
modelUrl.value = undefined
data.value = []
}
const createDownTask = async (data: VersionModel) => {
const formData = new FormData()
loading.show()
// set base info
formData.append('type', data.type)
formData.append('pathIndex', data.pathIndex.toString())
formData.append('fullname', data.fullname)
// set preview
const previewFile = await previewUrlToFile(data.preview as string).catch(
() => {
loading.hide()
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to download preview',
life: 15000,
})
throw new Error('Failed to download preview')
},
)
formData.append('previewFile', previewFile)
// set description
formData.append('description', data.description)
// set model download info
formData.append('downloadPlatform', data.downloadPlatform)
formData.append('downloadUrl', data.downloadUrl)
formData.append('sizeBytes', data.sizeBytes.toString())
formData.append('hashes', JSON.stringify(data.hashes))
await request('/model', {
method: 'POST',
body: formData,
})
.then(() => {
visible.value = false
})
.catch((e) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: e.message ?? 'Failed to create download task',
life: 15000,
})
})
.finally(() => {
loading.hide()
})
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<Dialog
v-model:visible="visible"
:modal="true"
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
pt:root:class="max-h-full"
pt:content:class="px-0"
>
<template #header>
<div class="flex flex-1 items-center justify-between pr-2">
<span class="p-dialog-title select-none">
{{ $t('downloadList') }}
</span>
<div class="p-dialog-header-actions">
<Button
icon="pi pi-refresh"
severity="secondary"
text
rounded
@click="refresh"
></Button>
</div>
</div>
</template>
<div class="flex h-full flex-col gap-4">
<div class="whitespace-nowrap px-4 @container">
<div class="flex gap-4 @sm:justify-end">
<Button
class="w-full @sm:w-auto"
:label="$t('createDownloadTask')"
@click="toggleCreateTask"
></Button>
</div>
</div>
<ResponseScrollArea>
<div class="w-full px-4">
<ul class="m-0 flex list-none flex-col gap-4 p-0">
<li
v-for="item in data"
:key="item.taskId"
class="rounded-lg border border-gray-500 p-4"
>
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
<div class="h-18 preview-aspect">
<img :src="item.preview" />
</div>
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
<div class="flex items-center gap-3 overflow-hidden">
<span class="flex-1 overflow-hidden text-ellipsis">
{{ item.fullname }}
</span>
<span v-show="item.status === 'waiting'" class="h-4">
<i class="pi pi-spinner pi-spin"></i>
</span>
<span
v-show="item.status === 'doing'"
class="h-4 cursor-pointer"
@click="item.pauseTask"
>
<i class="pi pi-pause-circle"></i>
</span>
<span
v-show="item.status === 'pause'"
class="h-4 cursor-pointer"
@click="item.resumeTask"
>
<i class="pi pi-play-circle"></i>
</span>
<span class="h-4 cursor-pointer" @click="item.deleteTask">
<i class="pi pi-trash text-red-400"></i>
</span>
</div>
<div class="h-2 overflow-hidden rounded bg-gray-200">
<div
class="h-full bg-blue-500 transition-[width]"
:style="{ width: `${item.progress}%` }"
></div>
</div>
<div class="flex justify-between">
<div>{{ item.downloadProgress }}</div>
<div v-show="item.status === 'doing'">
{{ item.downloadSpeed }}
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- <ul class="m-0 flex list-none flex-col gap-4 p-0 px-4 pb-0">
<li
v-for="item in data"
:key="item.taskId"
class="flex flex-row gap-3 overflow-hidden rounded-lg border border-gray-500 p-4"
>
<div class="h-18 preview-aspect">
<img
:src="`/model-manager/preview/download/${item.preview}`"
alt=""
/>
</div>
<div class="flex flex-1 flex-col gap-3">
<div class="flex items-center justify-between gap-4">
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ item.fullname }}
</div>
<div class="flex items-center gap-4">
<i v-show="item.status === 'waiting'">
{{ $t('waiting') }}...
</i>
<i
v-show="item.status === 'doing'"
class="pi pi-pause-circle"
@click="item.pauseTask"
></i>
<i
v-show="item.status === 'pause'"
class="pi pi-play-circle"
@click="item.resumeTask"
></i>
<i
class="pi pi-trash text-red-400"
@click="item.deleteTask"
></i>
</div>
</div>
<div class="flex items-center gap-2">
<div class="h-2 flex-1 overflow-hidden rounded bg-gray-200">
<div class="h-full *:h-full *:bg-blue-500 *:transition-all">
<div :style="{ width: `${item.progress}%` }"></div>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ item.downloadProgress }}</div>
<div v-show="item.status === 'doing'">
{{ item.downloadSpeed }}
</div>
</div>
</div>
</li>
</ul> -->
</ResponseScrollArea>
</div>
<DialogResizer :min-width="390" :min-height="390"></DialogResizer>
</Dialog>
<DialogCreateTask v-model:visible="openCreateTask"></DialogCreateTask>
</template>
<script setup lang="ts">
import DialogCreateTask from 'components/DialogCreateTask.vue'
import DialogResizer from 'components/DialogResizer.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useDownload } from 'hooks/download'
import { useBoolean } from 'hooks/utils'
const { visible, data, refresh } = useDownload()
const [openCreateTask, toggleCreateTask] = useBoolean()
</script>

View File

@@ -0,0 +1,223 @@
<template>
<Dialog
:visible="visible"
@update:visible="updateVisible"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open }]"
pt:root:class="max-h-full group-[:not(.open)]:!hidden"
pt:content:class="px-0"
>
<template #header>
<div class="flex flex-1 items-center justify-between pr-2">
<span class="p-dialog-title select-none">{{ $t('modelManager') }}</span>
<div class="p-dialog-header-actions">
<Button
icon="pi pi-refresh"
severity="secondary"
text
rounded
@click="refreshModels"
></Button>
<Button
icon="pi pi-download"
severity="secondary"
text
rounded
@click="download.toggle"
></Button>
</div>
</div>
</template>
<div
class="flex h-full flex-col gap-4 overflow-hidden @container/content"
:style="{
['--card-width']: `${cardWidth}px`,
['--gutter']: `${gutter}px`,
}"
>
<div
:class="[
'grid grid-cols-1 justify-center gap-4 px-8',
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
>
<div class="col-span-full @container/toolbar">
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
:allow-clear="true"
suffix-icon="pi pi-search"
></ResponseInput>
<div
class="flex items-center justify-between gap-4 overflow-hidden"
>
<ResponseSelect
v-model="currentType"
:items="typeOptions"
:type="isMobile ? 'drop' : 'button'"
></ResponseSelect>
<ResponseSelect
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
</div>
</div>
</div>
</div>
<ResponseScrollArea class="h-full">
<div
:class="[
'-mt-8 grid grid-cols-1 justify-center gap-8 px-8',
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:-mt-[var(--gutter)]',
'@lg/content:px-4',
]"
>
<div class="col-span-full"></div>
<div v-for="model in list" v-show="model.visible" :key="model.id">
<DialogModelCard
:key="`${model.type}:${model.pathIndex}:${model.fullname}`"
:model="model"
></DialogModelCard>
</div>
</div>
<div v-show="noneDisplayModel" class="flex justify-center pt-20">
<div class="select-none text-lg font-bold">No models found</div>
</div>
</ResponseScrollArea>
</div>
<DialogResizer
:min-width="cardWidth * 2 + gutter + 42"
:min-height="cardWidth * aspect * 0.5 + 162"
></DialogResizer>
</Dialog>
</template>
<script setup lang="ts" name="manager-dialog">
import { useConfig } from 'hooks/config'
import { useDialogManager } from 'hooks/manager'
import { useModels } from 'hooks/model'
import DialogResizer from 'components/DialogResizer.vue'
import DialogModelCard from 'components/DialogModelCard.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useToast } from 'hooks/toast'
import { useDownload } from 'hooks/download'
import { useI18n } from 'vue-i18n'
const { isMobile, cardWidth, gutter, aspect, refreshSetting } = useConfig()
const download = useDownload()
const { visible, updateVisible, open } = useDialogManager()
const { data, refresh } = useModels()
const { toast } = useToast()
const { t } = useI18n()
const searchContent = ref<string>()
const currentType = ref('all')
const typeOptions = ref(
[
{ label: 'ALL', value: 'all' },
{ label: 'Checkpoint', value: 'checkpoints' },
{ label: 'embedding', value: 'embeddings' },
{ label: 'Hypernetwork', value: 'hypernetworks' },
{ label: 'Lora', value: 'loras' },
{ label: 'VAE', value: 'vae' },
{ label: 'VAE approx', value: 'vae_approx' },
{ label: 'Controlnet', value: 'controlnet' },
{ label: 'Clip', value: 'clip' },
{ label: 'Clip Vision', value: 'clip_vision' },
{ label: 'Diffusers', value: 'diffusers' },
{ label: 'Gligen', value: 'gligen' },
{ label: 'Photomaker', value: 'photomaker' },
{ label: 'Style Models', value: 'style_models' },
{ label: 'Unet', value: 'unet' },
].map((item) => {
return {
...item,
command: () => {
currentType.value = item.value
},
}
}),
)
const sortOrder = ref('name')
const sortOrderOptions = ref(
['name', 'size', 'created', 'modified'].map((key) => {
return {
label: t(`sort.${key}`),
value: key,
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
command: () => {
sortOrder.value = key
},
}
}),
)
const list = computed(() => {
const filterList = data.value.map((model) => {
const showAllModel = currentType.value === 'all'
const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname
.toLowerCase()
.includes(searchContent.value?.toLowerCase() || '')
model.visible = matchType && matchName
return model
})
let sortStrategy = (a: Model, b: Model) => 0
switch (sortOrder.value) {
case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
break
case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
break
case 'created':
sortStrategy = (a, b) => b.createdAt - a.createdAt
break
case 'modified':
sortStrategy = (a, b) => b.updatedAt - a.updatedAt
break
default:
break
}
return filterList.sort(sortStrategy)
})
const noneDisplayModel = computed(() => {
return !list.value.some((model) => model.visible)
})
const refreshModels = async () => {
await Promise.all([refresh(), refreshSetting()])
toast.add({
severity: 'success',
summary: 'Refreshed Models',
life: 2000,
})
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div
class="group/card relative w-full cursor-pointer select-none preview-aspect"
@click.stop.prevent="toggle"
>
<div class="h-full overflow-hidden rounded-lg">
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
<img class="h-full w-full object-cover" :src="preview" />
</div>
</div>
<div
data-draggable-overlay
class="absolute left-0 top-0 h-full w-full"
draggable="true"
@dragend.stop="dragToAddModelNode"
></div>
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
<div class="relative h-full w-full text-white">
<div class="absolute bottom-0 left-0">
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg">
{{ model.basename }}
</div>
</div>
</div>
<div class="absolute left-0 top-0 w-full">
<div class="flex flex-row items-start justify-between">
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
<div class="font-bold @lg:text-xs">
{{ displayType }}
</div>
</div>
<div class="duration-300 group-hover/card:opacity-100">
<div class="flex flex-col gap-4 *:pointer-events-auto">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow"
></Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<DialogModelDetail
v-model:visible="visible"
:model="model"
></DialogModelDetail>
</template>
<script setup lang="ts">
import { useBoolean } from 'hooks/utils'
import DialogModelDetail from 'components/DialogModelDetail.vue'
import Button from 'primevue/button'
import { resolveModelType } from 'utils/model'
import { computed } from 'vue'
import { useModelNodeAction } from 'hooks/model'
interface Props {
model: Model
}
const props = defineProps<Props>()
const [visible, toggle] = useBoolean()
const displayType = computed(() => resolveModelType(props.model.type).display)
const preview = computed(() =>
Array.isArray(props.model.preview)
? props.model.preview[0]
: props.model.preview,
)
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction(props.model)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Dialog
v-model:visible="visible"
:header="filename"
:maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
pt:title:class="whitespace-nowrap text-ellipsis overflow-hidden"
pt:root:class="max-h-full"
pt:content:class="px-0"
@after-hide="handleCancel"
>
<ResponseScrollArea class="h-full">
<div class="px-8">
<ModelContent
v-model:editable="editable"
:model="model"
@submit="handleSave"
@reset="handleCancel"
>
<template #action="{ metadata }">
<template v-if="editable">
<Button :label="$t('cancel')" type="reset"></Button>
<Button :label="$t('save')" type="submit"></Button>
</template>
<template v-else>
<Button
v-show="metadata.modelPage"
icon="pi pi-eye"
@click="openModelPage(metadata.modelPage)"
></Button>
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button>
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
@click.stop="loadPreviewWorkflow"
></Button>
<Button
icon="pi pi-pen-to-square"
@click="editable = true"
></Button>
<Button
severity="danger"
icon="pi pi-trash"
@click="handleDelete"
></Button>
</template>
</template>
</ModelContent>
</div>
</ResponseScrollArea>
<DialogResizer :min-width="390"></DialogResizer>
</Dialog>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import ModelContent from 'components/ModelContent.vue'
import DialogResizer from 'components/DialogResizer.vue'
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
import { useConfig } from 'hooks/config'
import { computed, ref } from 'vue'
import { useModelNodeAction, useModels } from 'hooks/model'
const visible = defineModel<boolean>('visible')
interface Props {
model: Model
}
const props = defineProps<Props>()
const { isMobile } = useConfig()
const { remove, update } = useModels()
const editable = ref(false)
const filename = computed(() => {
const basename = props.model.fullname.split('/').pop()!
return basename.replace(props.model.extension, '')
})
const handleCancel = () => {
editable.value = false
}
const handleSave = async (data: BaseModel) => {
editable.value = false
await update(props.model, data)
}
const handleDelete = async () => {
await remove(props.model)
}
const openModelPage = (url: string) => {
window.open(url, '_blank')
}
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
props.model,
)
</script>

View File

@@ -0,0 +1,303 @@
<template>
<div v-if="allowResize" data-dialog-resizer>
<div
v-if="allow?.x"
data-resize-pos="left"
class="absolute -left-1 top-0 h-full w-2 cursor-ew-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x"
data-resize-pos="right"
class="absolute -right-1 top-0 h-full w-2 cursor-ew-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.y"
data-resize-pos="top"
class="absolute -top-1 left-0 h-2 w-full cursor-ns-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.y"
data-resize-pos="bottom"
class="absolute -bottom-1 left-0 h-2 w-full cursor-ns-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="top-left"
class="absolute -left-1 -top-1 h-2 w-2 cursor-se-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="top-right"
class="absolute -right-1 -top-1 h-2 w-2 cursor-sw-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="bottom-left"
class="absolute -bottom-1 -left-1 h-2 w-2 cursor-sw-resize"
@mousedown="startResize"
></div>
<div
v-if="allow?.x && allow?.y"
data-resize-pos="bottom-right"
class="absolute -bottom-1 -right-1 h-2 w-2 cursor-se-resize"
@mousedown="startResize"
></div>
</div>
</template>
<script setup lang="ts">
import { clamp } from 'lodash'
import { useConfig } from 'hooks/config'
import {
computed,
getCurrentInstance,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue'
type ContainerSize = { width: number; height: number }
type ContainerPosition = { left: number; top: number }
interface ResizableProps {
defaultSize?: Partial<ContainerSize>
defaultMobileSize?: Partial<ContainerSize>
allow?: { x?: boolean; y?: boolean }
minWidth?: number
maxWidth?: number
minHeight?: number
maxHeight?: number
}
const props = withDefaults(defineProps<ResizableProps>(), {
allow: () => ({ x: true, y: true }),
})
const config = useConfig()
const allowResize = computed(() => {
return !config.isMobile.value
})
const instance = getCurrentInstance()
const resizeDirection = ref<string[]>([])
const getContainer = () => {
return instance!.parent!.vnode.el as HTMLDivElement
}
const minWidth = computed(() => {
const defaultMinWidth = 100
return props.minWidth ?? defaultMinWidth
})
const maxWidth = computed(() => {
const defaultMaxWidth = window.innerWidth
return props.maxWidth ?? defaultMaxWidth
})
const minHeight = computed(() => {
const defaultMinHeight = 100
return props.minHeight ?? defaultMinHeight
})
const maxHeight = computed(() => {
const defaultMaxHeight = window.innerHeight
return props.maxHeight ?? defaultMaxHeight
})
const isResizing = ref(false)
const defaultWidth = window.innerWidth * 0.6
const defaultHeight = window.innerHeight * 0.8
const containerSize = ref({
width:
props.defaultSize?.width ??
clamp(defaultWidth, minWidth.value, maxWidth.value),
height:
props.defaultSize?.height ??
clamp(defaultHeight, minHeight.value, maxHeight.value),
})
const containerPosition = ref<ContainerPosition>({ left: 0, top: 0 })
const updateContainerSize = (size: ContainerSize) => {
const container = getContainer()
container.style.width = `${size.width}px`
container.style.height = `${size.height}px`
}
const updateContainerPosition = (position: ContainerPosition) => {
const container = getContainer()
container.style.left = `${position.left}px`
container.style.top = `${position.top}px`
}
const recordContainerPosition = () => {
const container = getContainer()
containerPosition.value = {
left: container.offsetLeft,
top: container.offsetTop,
}
}
const updateGlobalStyle = (direction?: string) => {
let cursor = ''
let select = ''
switch (direction) {
case 'left':
case 'right':
cursor = 'ew-resize'
select = 'none'
break
case 'top':
case 'bottom':
cursor = 'ns-resize'
select = 'none'
break
case 'top-left':
case 'bottom-right':
cursor = 'se-resize'
select = 'none'
break
case 'top-right':
case 'bottom-left':
cursor = 'sw-resize'
select = 'none'
break
default:
break
}
document.body.style.cursor = cursor
document.body.style.userSelect = select
}
const resize = (event: MouseEvent) => {
if (isResizing.value) {
const container = getContainer()
for (const direction of resizeDirection.value) {
if (direction === 'left') {
if (event.clientX > 0) {
containerSize.value.width = clamp(
container.offsetLeft + container.offsetWidth - event.clientX,
minWidth.value,
maxWidth.value,
)
}
if (
containerSize.value.width > minWidth.value &&
containerSize.value.width < maxWidth.value
) {
containerPosition.value.left = clamp(
event.clientX,
0,
window.innerWidth - containerSize.value.width,
)
}
}
if (direction === 'right') {
containerSize.value.width = clamp(
event.clientX - container.offsetLeft,
minWidth.value,
maxWidth.value,
)
}
if (direction === 'top') {
if (event.clientY > 0) {
containerSize.value.height = clamp(
container.offsetTop + container.offsetHeight - event.clientY,
minHeight.value,
maxHeight.value,
)
}
if (
containerSize.value.height > minHeight.value &&
containerSize.value.height < maxHeight.value
) {
containerPosition.value.top = clamp(
event.clientY,
0,
window.innerHeight - containerSize.value.height,
)
}
}
if (direction === 'bottom') {
containerSize.value.height = clamp(
event.clientY - container.offsetTop,
minHeight.value,
maxHeight.value,
)
}
}
updateContainerSize(containerSize.value)
updateContainerPosition(containerPosition.value)
}
}
const stopResize = () => {
isResizing.value = false
resizeDirection.value = []
document.removeEventListener('mousemove', resize)
document.removeEventListener('mouseup', stopResize)
updateGlobalStyle()
}
const startResize = (event: MouseEvent) => {
isResizing.value = true
const direction =
(event.target as HTMLElement).getAttribute('data-resize-pos') ?? ''
resizeDirection.value = direction.split('-')
recordContainerPosition()
updateGlobalStyle(direction)
document.addEventListener('mousemove', resize)
document.addEventListener('mouseup', stopResize)
}
onMounted(() => {
if (allowResize.value) {
updateContainerSize(containerSize.value)
} else {
updateContainerSize({
width: props.defaultMobileSize?.width ?? window.innerWidth,
height: props.defaultMobileSize?.height ?? window.innerHeight,
})
}
recordContainerPosition()
updateContainerPosition(containerPosition.value)
getContainer().style.position = 'fixed'
})
onBeforeUnmount(() => {
stopResize()
})
watch(allowResize, (allowResize) => {
if (allowResize) {
updateContainerSize(containerSize.value)
updateContainerPosition(containerPosition.value)
} else {
updateContainerSize({
width: props.defaultMobileSize?.width ?? window.innerWidth,
height: props.defaultMobileSize?.height ?? window.innerHeight,
})
updateContainerPosition({ left: 0, top: 0 })
}
})
defineExpose({
updateContainerSize,
updateContainerPosition,
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<form @submit="handleSubmit" @reset="handleReset">
<slot name="default"></slot>
</form>
</template>
<script setup lang="ts">
const emits = defineEmits(['submit', 'reset'])
const handleReset = () => {
emits('reset')
}
const handleSubmit = async () => {
emits('submit')
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div v-show="loading">
<div class="fixed left-0 top-0 h-full w-full" style="z-index: 9999">
<div class="flex h-full w-full items-center justify-center bg-black/30">
<i class="pi pi-spinner pi-spin text-3xl opacity-30"></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useGlobalLoading } from 'hooks/loading'
const { loading } = useGlobalLoading()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<Toast :position="position" :style="style"></Toast>
</template>
<script setup lang="ts">
import { useConfig } from 'hooks/config'
import Toast from 'primevue/toast'
import { computed } from 'vue'
const config = useConfig()
const position = computed(() => {
return config.isMobile.value ? 'top-center' : 'top-right'
})
const style = computed(() => {
if (config.isMobile.value) {
return { width: '80vw' }
}
return {}
})
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div class="flex flex-col gap-4">
<div v-if="editable" class="flex flex-col gap-4">
<ResponseSelect v-if="!baseInfo.type" v-model="type" :items="typeOptions">
<template #prefix>
<span>{{ $t('modelType') }}</span>
</template>
</ResponseSelect>
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
</ResponseSelect>
<ResponseInput
v-model.trim="basename"
class="-mr-2 text-right"
update-trigger="blur"
>
<template #suffix>
<span class="pi-inputicon">
{{ extension }}
</span>
</template>
</ResponseInput>
</div>
<table class="w-full table-fixed border-collapse border">
<colgroup>
<col class="w-32" />
<col />
</colgroup>
<tbody>
<tr v-for="item in information" class="h-8 border-b">
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ $t(`info.${item.key}`) }}
</td>
<td class="break-all px-4">{{ item.display }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { useModelBaseInfo } from 'hooks/model'
import { resolveModelType } from 'utils/model'
import { computed } from 'vue'
const editable = defineModel<boolean>('editable')
const { modelFolders } = useConfig()
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
const typeOptions = computed(() => {
return Object.keys(modelFolders.value).map((curr) => {
return {
value: curr,
label: resolveModelType(curr).display,
command: () => {
type.value = curr
pathIndex.value = 0
},
}
})
})
const pathOptions = computed(() => {
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
return {
value: index,
label: folder,
command: () => {
pathIndex.value = index
},
}
})
})
const information = computed(() => {
return Object.values(baseInfo.value).filter((row) => {
if (editable.value) {
return row.key !== 'fullname'
}
return true
})
})
</script>

View File

@@ -0,0 +1,96 @@
<template>
<form
class="@container"
@submit.prevent="handleSubmit"
@reset.prevent="handleReset"
>
<div class="mx-auto w-full max-w-[50rem]">
<div class="relative flex flex-col gap-4 overflow-hidden @xl:flex-row">
<ModelPreview
class="shrink-0"
v-model:editable="editable"
></ModelPreview>
<div class="flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-end gap-4">
<slot name="action" :metadata="formInstance.metadata.value"></slot>
</div>
<ModelBaseInfo v-model:editable="editable"></ModelBaseInfo>
</div>
</div>
<Tabs value="0" class="mt-4">
<TabList>
<Tab value="0">Description</Tab>
<Tab value="1">Metadata</Tab>
</TabList>
<TabPanels pt:root:class="p-0 py-4">
<TabPanel value="0">
<ModelDescription v-model:editable="editable"></ModelDescription>
</TabPanel>
<TabPanel value="1">
<ModelMetadata></ModelMetadata>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</form>
</template>
<script setup lang="ts">
import ModelPreview from 'components/ModelPreview.vue'
import ModelBaseInfo from 'components/ModelBaseInfo.vue'
import ModelDescription from 'components/ModelDescription.vue'
import ModelMetadata from 'components/ModelMetadata.vue'
import Tab from 'primevue/tab'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import {
useModelBaseInfoEditor,
useModelDescriptionEditor,
useModelFormData,
useModelMetadataEditor,
useModelPreviewEditor,
} from 'hooks/model'
import { toRaw, watch } from 'vue'
import { cloneDeep } from 'lodash'
interface Props {
model: BaseModel
}
const props = defineProps<Props>()
const editable = defineModel<boolean>('editable')
const emits = defineEmits<{
submit: [formData: BaseModel]
reset: []
}>()
const formInstance = useModelFormData(() => cloneDeep(toRaw(props.model)))
useModelBaseInfoEditor(formInstance)
useModelPreviewEditor(formInstance)
useModelDescriptionEditor(formInstance)
useModelMetadataEditor(formInstance)
const handleReset = () => {
formInstance.reset()
emits('reset')
}
const handleSubmit = async () => {
const data = formInstance.submit()
emits('submit', data)
}
watch(
() => props.model,
() => {
handleReset()
},
)
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div class="relative">
<textarea
ref="textareaRef"
v-show="active"
:class="[
'w-full resize-none overflow-hidden px-3 py-2 outline-none',
'rounded-lg border',
'border-[var(--p-form-field-border-color)]',
'focus:border-[var(--p-form-field-focus-border-color)]',
'relative z-10',
]"
v-model="innerValue"
@input="resizeTextarea"
@blur="exitEditMode"
></textarea>
<div v-show="!active">
<div v-show="editable" class="flex items-center gap-2 text-gray-600">
<i class="pi pi-info-circle"></i>
<span>
{{ $t('tapToChange') }}
</span>
</div>
<div class="relative">
<div
v-if="renderedDescription"
class="markdown-it"
v-html="renderedDescription"
></div>
<div v-else class="flex flex-col items-center gap-2 py-5">
<i class="pi pi-info-circle text-lg"></i>
<div>no description</div>
</div>
<div
v-show="editable"
class="absolute left-0 top-0 h-full w-full cursor-pointer"
@click="entryEditMode"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useModelDescription } from 'hooks/model'
import { nextTick, ref, watch } from 'vue'
const editable = defineModel<boolean>('editable')
const active = ref(false)
const { description, renderedDescription } = useModelDescription()
const textareaRef = ref<HTMLTextAreaElement>()
const innerValue = ref<string>()
watch(
description,
(value) => {
innerValue.value = value
},
{ immediate: true },
)
const resizeTextarea = () => {
const textarea = textareaRef.value!
textarea.style.height = 'auto'
const scrollHeight = textarea.scrollHeight
textarea.style.height = scrollHeight + 'px'
textarea.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}
const entryEditMode = async () => {
active.value = true
await nextTick()
resizeTextarea()
textareaRef.value!.focus()
}
const exitEditMode = () => {
description.value = innerValue.value!
active.value = false
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<table v-if="dataSource.length" class="w-full border-collapse border">
<tbody>
<tr v-for="item in dataSource" class="h-8 border-b">
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ item.key }}
</td>
<td class="break-all px-4">{{ item.value }}</td>
</tr>
</tbody>
</table>
<div v-else class="flex flex-col items-center gap-2 py-5">
<i class="pi pi-info-circle text-lg"></i>
<div>no metadata</div>
</div>
</template>
<script setup lang="ts">
import { useModelMetadata } from 'hooks/model'
import { computed } from 'vue'
const { metadata } = useModelMetadata()
const dataSource = computed(() => {
const dataSource: { key: string; value: any }[] = []
for (const key in metadata.value) {
if (Object.prototype.hasOwnProperty.call(metadata.value, key)) {
const value = metadata.value[key]
dataSource.push({ key, value })
}
}
return dataSource
})
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div
class="flex flex-col gap-4"
:style="{ ['--preview-width']: `${cardWidth}px` }"
>
<div>
<div
:class="[
'relative mx-auto w-full',
'@sm:w-[var(--preview-width)]',
'overflow-hidden rounded-lg preview-aspect',
]"
>
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage>
<Carousel
v-if="defaultContent.length > 1"
v-show="currentType === 'default'"
class="absolute top-0 h-full w-full"
:value="defaultContent"
v-model:page="defaultContentPage"
:circular="true"
:show-navigators="true"
:show-indicators="false"
pt:contentcontainer:class="h-full"
pt:content:class="h-full"
pt:itemlist:class="h-full"
:prev-button-props="{
class: 'absolute left-4 z-10',
rounded: true,
severity: 'secondary',
}"
:next-button-props="{
class: 'absolute right-4 z-10',
rounded: true,
severity: 'secondary',
}"
>
<template #item="slotProps">
<ResponseImage
:src="slotProps.data"
:error="noPreviewContent"
></ResponseImage>
</template>
</Carousel>
</div>
</div>
<div v-if="editable" class="flex flex-col gap-4 whitespace-nowrap">
<div class="h-10"></div>
<div
:class="[
'flex h-10 items-center gap-4',
'absolute left-1/2 -translate-x-1/2',
'@xl:left-0 @xl:translate-x-0',
]"
>
<Button
v-for="type in typeOptions"
:key="type"
:severity="currentType === type ? undefined : 'secondary'"
:label="$t(type)"
@click="currentType = type"
></Button>
</div>
<div v-show="currentType === 'network'">
<div class="absolute left-0 w-full">
<ResponseInput
v-model="networkContent"
prefix-icon="pi pi-globe"
:allow-clear="true"
></ResponseInput>
</div>
<div class="h-10"></div>
</div>
<div v-show="currentType === 'local'">
<ResponseFileUpload
class="absolute left-0 h-24 w-full"
@select="updateLocalContent"
>
</ResponseFileUpload>
<div class="h-24"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ResponseImage from 'components/ResponseImage.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
import Button from 'primevue/button'
import Carousel from 'primevue/carousel'
import { useModelPreview } from 'hooks/model'
import { useConfig } from 'hooks/config'
const editable = defineModel<boolean>('editable')
const { cardWidth } = useConfig()
const {
preview,
typeOptions,
currentType,
defaultContent,
defaultContentPage,
networkContent,
updateLocalContent,
noPreviewContent,
} = useModelPreview()
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div
class="rounded-lg border border-gray-500 p-4 text-gray-500"
@dragenter.stop.prevent
@dragover.stop.prevent
@dragleave.stop.prevent
@drop.stop.prevent="handleDropFile"
@click="handleClick"
>
<slot name="default">
<div class="flex h-full flex-col items-center justify-center gap-2">
<i class="pi pi-cloud-upload text-2xl"></i>
<p class="m-0 select-none overflow-hidden text-ellipsis">
{{ $t('uploadFile') }}
</p>
</div>
</slot>
</div>
</template>
<script setup lang="ts">
const emits = defineEmits<{
select: [event: SelectEvent]
}>()
const covertFileList = (fileList: FileList) => {
const files: SelectFile[] = []
for (const file of fileList) {
const selectFile = file as SelectFile
selectFile.objectURL = URL.createObjectURL(file)
files.push(selectFile)
}
return files
}
const handleDropFile = (event: DragEvent) => {
const files = event.dataTransfer?.files
if (files) {
emits('select', { originalEvent: event, files: covertFileList(files) })
}
}
const handleClick = (event: MouseEvent) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const files = input.files
if (files) {
emits('select', { originalEvent: event, files: covertFileList(files) })
}
}
input.click()
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<span class="relative">
<img :src="src" :alt="alt" v-bind="$attrs" @error="onError" />
<img v-if="error" v-show="loadError" :src="error" class="absolute top-0" />
</span>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
src?: string
alt?: string
error?: string
}
const props = defineProps<Props>()
defineOptions({
inheritAttrs: false,
})
const loadError = ref(false)
watch(
() => props.src,
() => {
loadError.value = !props.src
},
{ immediate: true },
)
const onError = () => {
loadError.value = true
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="p-component p-inputtext flex items-center gap-2">
<slot name="prefix">
<span v-if="prefixIcon" :class="[prefixIcon, 'pi-inputicon']"></span>
</slot>
<input
ref="inputRef"
v-model="innerValue"
class="flex-1 border-none bg-transparent text-base outline-none"
type="text"
:placeholder="placeholder"
@paste.stop
v-bind="$attrs"
@[trigger]="updateContent"
/>
<span
v-if="allowClear"
v-show="content"
class="pi pi-times pi-inputicon"
@click="clearContent"
></span>
<slot name="suffix">
<span v-if="suffixIcon" :class="[suffixIcon, 'pi-inputicon']"></span>
</slot>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
interface Props {
prefixIcon?: string
suffixIcon?: string
placeholder?: string
allowClear?: boolean
updateTrigger?: string
}
const props = defineProps<Props>()
const [content, modifiers] = defineModel<string, 'trim'>()
const inputRef = ref()
const innerValue = ref(content)
const trigger = computed(() => props.updateTrigger ?? 'input')
const updateContent = () => {
let value = innerValue.value
if (modifiers.trim) {
value = innerValue.value?.trim()
}
content.value = value
inputRef.value.value = value
}
defineOptions({
inheritAttrs: false,
})
const clearContent = () => {
content.value = undefined
inputRef.value?.focus()
}
</script>
<style>
.p-inputtext:focus-within {
border-color: var(--p-inputtext-focus-border-color);
box-shadow: var(--p-inputtext-focus-ring-shadow);
outline: var(--p-inputtext-focus-ring-width)
var(--p-inputtext-focus-ring-style) var(--p-inputtext-focus-ring-color);
outline-offset: var(--p-inputtext-focus-ring-offset);
}
.p-inputtext .pi-inputicon {
font-size: 1rem;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<div data-scroll-area class="group/scroll relative overflow-hidden">
<div
ref="viewport"
data-scroll-viewport
class="h-full w-full overflow-auto scrollbar-none"
@scroll="onContentScroll"
v-resize="onContainerResize"
>
<div data-scroll-content style="min-width: 100%">
<slot name="default"></slot>
</div>
</div>
<div
v-for="scroll in scrollbars"
:key="scroll.direction"
v-show="scroll.visible"
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
:class="[
'pointer-events-none absolute z-auto h-full w-full rounded-full',
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
]"
>
<div
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
:class="[
'pointer-events-auto absolute h-full w-full rounded-full',
'cursor-pointer bg-black dark:bg-white',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
]"
:style="{
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
opacity: isDragging ? 0.1 : '',
}"
@mousedown="startDragThumb"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onUnmounted, ref } from 'vue'
import { clamp, throttle } from 'lodash'
interface ScrollAreaProps {
scrollbar?: boolean
}
const props = withDefaults(defineProps<ScrollAreaProps>(), {
scrollbar: true,
})
const emit = defineEmits(['scroll', 'resize'])
type ScrollbarDirection = 'horizontal' | 'vertical'
interface Scrollbar {
direction: ScrollbarDirection
visible: boolean
size: number
offset: number
}
interface ScrollbarAttribute {
clientSize: string
scrollOffset: string
pagePosition: string
offset: string
size: string
}
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
horizontal: {
clientSize: 'clientWidth',
scrollOffset: 'scrollLeft',
pagePosition: 'pageX',
offset: 'left',
size: 'width',
},
vertical: {
clientSize: 'clientHeight',
scrollOffset: 'scrollTop',
pagePosition: 'pageY',
offset: 'top',
size: 'height',
},
}
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
horizontal: {
direction: 'horizontal',
visible: props.scrollbar,
size: 0,
offset: 0,
},
vertical: {
direction: 'vertical',
visible: props.scrollbar,
size: 0,
offset: 0,
},
})
const isDragging = ref(false)
const onContainerResize: ResizeObserverCallback = throttle((entries) => {
emit('resize', entries)
if (isDragging.value) return
const entry = entries[0]
const container = entry.target as HTMLElement
const content = container.querySelector('[data-scroll-content]')!
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize: number = container[attr.clientSize]
const contentSize: number = content[attr.clientSize]
item.visible = props.scrollbar && contentSize > containerSize
item.size = Math.pow(containerSize, 2) / contentSize
}
nextTick(() => {
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
})
const onContentScroll = throttle((event: Event) => {
emit('scroll', event)
if (isDragging.value) return
const container = event.target as HTMLDivElement
const content = container.querySelector('[data-scroll-content]')!
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
const scrollOffset = container[attr.scrollOffset]
item.offset =
(scrollOffset / (contentSize - containerSize)) *
(containerSize - item.size)
}
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
const viewport = ref<HTMLElement>()
const draggingDirection = ref<ScrollbarDirection>()
const prevDraggingEvent = ref<MouseEvent>()
const moveThumb = throttle((event: MouseEvent) => {
if (isDragging.value) {
const container = viewport.value!
const content = container.querySelector('[data-scroll-content]')!
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
// Resolve thumb position
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
const currPagePos = event[attr.pagePosition]
const offset = currPagePos - prevPagePos
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
// Resolve scroll position
const scrollOffset = containerSize - item.size
const offsetSize = contentSize - containerSize
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
}
const scrollDirection = draggingDirection.value!
resolveOffset(
scrollbars.value[scrollDirection],
scrollbarAttrs[scrollDirection],
)
prevDraggingEvent.value = event
}
})
const stopMoveThumb = () => {
isDragging.value = false
draggingDirection.value = undefined
prevDraggingEvent.value = undefined
document.removeEventListener('mousemove', moveThumb)
document.removeEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
const startDragThumb = (event: MouseEvent) => {
isDragging.value = true
const target = event.target as HTMLElement
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
prevDraggingEvent.value = event
document.addEventListener('mousemove', moveThumb)
document.addEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'default'
}
onUnmounted(() => {
stopMoveThumb()
})
defineExpose({
viewport,
})
</script>

View File

@@ -0,0 +1,234 @@
<template>
<slot
v-if="type === 'drop'"
name="target"
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
>
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
<Button
v-bind="{ rounded, text, severity, size }"
class="w-full whitespace-nowrap"
>
<slot name="prefix">
<span v-if="prefixIcon" class="p-button-icon p-button-icon-left">
<i :class="prefixIcon"></i>
</span>
</slot>
<span class="flex-1 overflow-scroll text-right scrollbar-none">
<slot name="label">{{ currentLabel }}</slot>
</span>
<slot name="suffix">
<span v-if="suffixIcon" class="p-button-icon p-button-icon-right">
<i :class="suffixIcon"></i>
</span>
</slot>
</Button>
</div>
</slot>
<div v-else class="relative flex-1 overflow-hidden">
<div
ref="scrollArea"
class="h-full w-full overflow-auto scrollbar-none"
v-resize="checkScrollPosition"
@scroll="checkScrollPosition"
>
<div ref="contentArea" class="table max-w-full">
<div
v-show="showControlButton && scrollPosition !== 'left'"
:class="[
'pointer-events-none absolute left-0 top-1/2 z-10',
'-translate-y-1/2 bg-gradient-to-r from-current to-transparent pr-16',
]"
style="color: var(--p-dialog-background)"
>
<Button
icon="pi pi-angle-left"
class="pointer-events-auto border-none bg-transparent"
severity="secondary"
@click="scrollTo('prev')"
:size="size"
></Button>
</div>
<div class="flex h-10 items-center gap-2">
<Button
v-for="item in items"
severity="secondary"
:key="item.value"
:data-active="current === item.value"
:active="current === item.value"
class="data-[active=true]:bg-blue-500 data-[active=true]:text-white"
:size="size"
@click="item.command"
>
<span class="whitespace-nowrap">{{ item.label }}</span>
</Button>
</div>
<div
v-show="showControlButton && scrollPosition !== 'right'"
:class="[
'pointer-events-none absolute right-0 top-1/2 z-10',
'-translate-y-1/2 bg-gradient-to-l from-current to-transparent pl-16',
]"
style="color: var(--p-dialog-background)"
>
<Button
:size="size"
icon="pi pi-angle-right"
class="pointer-events-auto border-none bg-transparent"
severity="secondary"
@click="scrollTo('next')"
></Button>
</div>
</div>
</div>
</div>
<slot v-if="isMobile" name="mobile">
<Drawer
v-model:visible="visible"
position="bottom"
style="height: auto; max-height: 80%"
>
<template #container>
<slot name="container">
<slot name="mobile:container">
<div class="h-full overflow-scroll scrollbar-none">
<Menu
:model="items"
pt:root:class="border-0 px-4 py-5"
:pt:list:onClick="toggle"
>
<template #item="{ item }">
<slot name="item" :item="item">
<slot name="mobile:container:item" :item="item">
<a class="p-menu-item-link justify-between">
<span
class="p-menu-item-label overflow-hidden break-words"
>
{{ item.label }}
</span>
<span v-show="current === item.value">
<i class="pi pi-check text-blue-400"></i>
</span>
</a>
</slot>
</slot>
</template>
</Menu>
</div>
</slot>
</slot>
</template>
</Drawer>
</slot>
<slot v-else name="desktop">
<slot name="container">
<slot name="desktop:container">
<Menu ref="menu" :model="items" :popup="true">
<template #item="{ item }">
<slot name="item" :item="item">
<slot name="desktop:container:item" :item="item">
<a class="p-menu-item-link justify-between">
<span class="p-menu-item-label">{{ item.label }}</span>
<span v-show="current === item.value">
<i class="pi pi-check text-blue-400"></i>
</span>
</a>
</slot>
</slot>
</template>
</Menu>
</slot>
</slot>
</slot>
</template>
<script setup lang="ts">
import { useConfig } from 'hooks/config'
import Button, { ButtonProps } from 'primevue/button'
import Drawer from 'primevue/drawer'
import Menu from 'primevue/menu'
import { computed, ref } from 'vue'
const current = defineModel()
interface Props {
items?: SelectOptions[]
rounded?: boolean
text?: boolean
severity?: ButtonProps['severity']
size?: ButtonProps['size']
type?: 'button' | 'drop'
}
const props = withDefaults(defineProps<Props>(), {
severity: 'secondary',
type: 'drop',
})
const suffixIcon = ref('pi pi-angle-down')
const prefixIcon = computed(() => {
return props.items?.find((item) => item.value === current.value)?.icon
})
const currentLabel = computed(() => {
return props.items?.find((item) => item.value === current.value)?.label
})
const menu = ref()
const visible = ref(false)
const { isMobile } = useConfig()
const toggle = (event: MouseEvent) => {
if (isMobile.value) {
visible.value = !visible.value
} else {
menu.value.toggle(event)
}
}
// Select Button Type
const scrollArea = ref()
const contentArea = ref()
type ScrollPosition = 'left' | 'right'
const scrollPosition = ref<ScrollPosition | undefined>('left')
const showControlButton = ref<boolean>(true)
const scrollTo = (type: 'prev' | 'next') => {
const container = scrollArea.value as HTMLDivElement
const scrollLeft = container.scrollLeft
const direction = type === 'prev' ? -1 : 1
const distance = (container.clientWidth / 3) * 2
container.scrollTo({
left: scrollLeft + direction * distance,
behavior: 'smooth',
})
}
const checkScrollPosition = () => {
const container = scrollArea.value as HTMLDivElement
const content = contentArea.value as HTMLDivElement
const scrollLeft = container.scrollLeft
const containerWidth = container.clientWidth
const contentWidth = content.clientWidth
let position: ScrollPosition | undefined = undefined
if (scrollLeft === 0) {
position = 'left'
}
if (Math.ceil(scrollLeft) >= contentWidth - containerWidth) {
position = 'right'
}
scrollPosition.value = position
showControlButton.value = contentWidth > containerWidth
}
</script>

69
src/hooks/config.ts Normal file
View File

@@ -0,0 +1,69 @@
import { useRequest } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { app } from 'scripts/comfyAPI'
import { onMounted, onUnmounted, ref } from 'vue'
export const useConfig = defineStore('config', () => {
const mobileDeviceBreakPoint = 759
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
type ModelFolder = Record<string, string[]>
const { data: modelFolders, refresh: refreshModelFolders } =
useRequest<ModelFolder>('/base-folders')
const checkDeviceType = () => {
isMobile.value = window.innerWidth < mobileDeviceBreakPoint
}
onMounted(() => {
window.addEventListener('resize', checkDeviceType)
})
onUnmounted(() => {
window.removeEventListener('resize', checkDeviceType)
})
const refreshSetting = async () => {
return Promise.all([refreshModelFolders()])
}
const config = {
isMobile,
gutter: 16,
cardWidth: 240,
aspect: 7 / 9,
modelFolders,
refreshSetting,
}
useAddConfigSettings(config)
return config
})
type Config = ReturnType<typeof useConfig>
declare module 'hooks/store' {
interface StoreProvider {
config: Config
}
}
function useAddConfigSettings(config: Config) {
onMounted(() => {
// API keys
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.HuggingFace',
name: 'HuggingFace API Key',
type: 'text',
defaultValue: undefined,
})
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.Civitai',
name: 'Civitai API Key',
type: 'text',
defaultValue: undefined,
})
})
}

423
src/hooks/download.ts Normal file
View File

@@ -0,0 +1,423 @@
import { useLoading } from 'hooks/loading'
import { MarkdownTool, useMarkdown } from 'hooks/markdown'
import { socket } from 'hooks/socket'
import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { useBoolean } from 'hooks/utils'
import { bytesToSize } from 'utils/common'
import { onBeforeMount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
export const useDownload = defineStore('download', (store) => {
const [visible, toggle] = useBoolean()
const { toast, confirm } = useToast()
const { t } = useI18n()
const taskList = ref<DownloadTask[]>([])
const refresh = () => {
socket.send('downloadTaskList', null)
}
const createTaskItem = (item: DownloadTaskOptions) => {
const { downloadedSize, totalSize, bps, ...rest } = item
const task: DownloadTask = {
...rest,
preview: `/model-manager/preview/download/${item.preview}`,
downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`,
downloadSpeed: `${bytesToSize(bps)}/s`,
pauseTask() {
socket.send('pauseDownloadTask', item.taskId)
},
resumeTask: () => {
socket.send('resumeDownloadTask', item.taskId)
},
deleteTask: () => {
confirm.require({
message: t('deleteAsk', [t('downloadTask').toLowerCase()]),
header: 'Danger',
icon: 'pi pi-info-circle',
rejectProps: {
label: t('cancel'),
severity: 'secondary',
outlined: true,
},
acceptProps: {
label: t('delete'),
severity: 'danger',
},
accept: () => {
socket.send('deleteDownloadTask', item.taskId)
},
reject: () => {},
})
},
}
return task
}
onBeforeMount(() => {
socket.addEventListener('reconnected', () => {
refresh()
})
socket.addEventListener('downloadTaskList', (event) => {
const data = event.detail as DownloadTaskOptions[]
taskList.value = data.map((item) => {
return createTaskItem(item)
})
})
socket.addEventListener('createDownloadTask', (event) => {
const item = event.detail as DownloadTaskOptions
taskList.value.unshift(createTaskItem(item))
})
socket.addEventListener('updateDownloadTask', (event) => {
const item = event.detail as DownloadTaskOptions
for (const task of taskList.value) {
if (task.taskId === item.taskId) {
if (item.error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: item.error,
life: 15000,
})
item.error = undefined
}
Object.assign(task, createTaskItem(item))
}
}
})
socket.addEventListener('deleteDownloadTask', (event) => {
const taskId = event.detail as string
taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
})
socket.addEventListener('completeDownloadTask', (event) => {
const taskId = event.detail as string
const task = taskList.value.find((item) => item.taskId === taskId)
taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
toast.add({
severity: 'success',
summary: 'Success',
detail: `${task?.fullname} Download completed`,
life: 2000,
})
store.models.refresh()
})
})
onMounted(() => {
refresh()
})
return { visible, toggle, data: taskList, refresh }
})
declare module 'hooks/store' {
interface StoreProvider {
download: ReturnType<typeof useDownload>
}
}
abstract class ModelSearch {
constructor(readonly md: MarkdownTool) {}
abstract search(pathname: string): Promise<VersionModel[]>
}
class Civitai extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
const { pathname, searchParams } = new URL(searchUrl)
const [, modelId] = pathname.match(/^\/models\/(\d*)/) ?? []
const versionId = searchParams.get('modelVersionId')
if (!modelId) {
return Promise.resolve([])
}
return fetch(`https://civitai.com/api/v1/models/${modelId}`)
.then((response) => response.json())
.then((resData) => {
const modelVersions: any[] = resData.modelVersions.filter(
(version: any) => {
if (versionId) {
return version.id == versionId
}
return true
},
)
const models: VersionModel[] = []
for (const version of modelVersions) {
const modelFiles: any[] = version.files.filter(
(file: any) => file.type === 'Model',
)
const shortname = modelFiles.length > 0 ? version.name : undefined
for (const file of modelFiles) {
const fullname = file.name
const extension = `.${fullname.split('.').pop()}`
const basename = fullname.replace(extension, '')
models.push({
id: file.id,
shortname: shortname ?? basename,
fullname: fullname,
basename: basename,
extension: extension,
preview: version.images.map((i: any) => i.url),
sizeBytes: file.sizeKB * 1024,
type: this.resolveType(resData.type),
pathIndex: 0,
description: [
'---',
`website: Civitai`,
``,
`modelPage: https://civitai.com/models/${modelId}?modelVersionId=${version.id}`,
'---',
'',
'# Trigger Words',
`\n${(version.trainedWords ?? ['No trigger words']).join(', ')}\n`,
'# About this version',
this.resolveDescription(
version.description,
'\nNo description about this version\n',
),
`# ${resData.name}`,
this.resolveDescription(
resData.description,
'No description about this model',
),
].join('\n'),
metadata: file.metadata,
downloadPlatform: 'civitai',
downloadUrl: file.downloadUrl,
hashes: file.hashes,
})
}
}
return models
})
}
private resolveType(type: string) {
const mapLegacy = {
TextualInversion: 'embeddings',
LoCon: 'loras',
DoRA: 'loras',
Controlnet: 'controlnet',
Upscaler: 'upscale_models',
VAE: 'vae',
}
return mapLegacy[type] ?? `${type.toLowerCase()}s`
}
private resolveDescription(content: string, defaultContent: string) {
const mdContent = this.md.parse(content ?? '').trim()
return mdContent || defaultContent
}
}
class Huggingface extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
const { pathname } = new URL(searchUrl)
const [, space, name, ...restPaths] = pathname.split('/')
if (!space || !name) {
return Promise.resolve([])
}
const modelId = `${space}/${name}`
const restPathname = restPaths.join('/')
return fetch(`https://huggingface.co/api/models/${modelId}`)
.then((response) => response.json())
.then((resData) => {
const siblingFiles: string[] = resData.siblings.map(
(item: any) => item.rfilename,
)
const modelFiles: string[] = this.filterTreeFiles(
this.filterModelFiles(siblingFiles),
restPathname,
)
const images: string[] = this.filterTreeFiles(
this.filterImageFiles(siblingFiles),
restPathname,
).map((filename) => {
return `https://huggingface.co/${modelId}/resolve/main/${filename}`
})
const models: VersionModel[] = []
for (const filename of modelFiles) {
const fullname = filename.split('/').pop()!
const extension = `.${fullname.split('.').pop()}`
const basename = fullname.replace(extension, '')
models.push({
id: filename,
shortname: filename,
fullname: fullname,
basename: basename,
extension: extension,
preview: images,
sizeBytes: 0,
type: 'unknown',
pathIndex: 0,
description: [
'---',
`website: HuggingFace`,
`author: ${resData.author}`,
`modelPage: https://huggingface.co/${modelId}`,
'---',
'',
'# Trigger Words',
'\nNo trigger words\n',
'# About this version',
'\nNo description about this version\n',
`# ${resData.modelId}`,
'\nNo description about this model\n',
].join('\n'),
metadata: {},
downloadPlatform: 'huggingface',
downloadUrl: `https://huggingface.co/${modelId}/resolve/main/${filename}?download=true`,
})
}
return models
})
}
private filterTreeFiles(files: string[], pathname: string) {
const [target, , ...paths] = pathname.split('/')
if (!target) return files
if (target !== 'tree' && target !== 'blob') return files
const pathPrefix = paths.join('/')
return files.filter((file) => {
return file.startsWith(pathPrefix)
})
}
private filterModelFiles(files: string[]) {
const extension = [
'.bin',
'.ckpt',
'.gguf',
'.onnx',
'.pt',
'.pth',
'.safetensors',
]
return files.filter((file) => {
const ext = file.split('.').pop()
return ext ? extension.includes(`.${ext}`) : false
})
}
private filterImageFiles(files: string[]) {
const extension = [
'.png',
'.webp',
'.jpeg',
'.jpg',
'.jfif',
'.gif',
'.apng',
]
return files.filter((file) => {
const ext = file.split('.').pop()
return ext ? extension.includes(`.${ext}`) : false
})
}
}
class UnknownWebsite extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
return Promise.reject(
new Error(
'Unknown Website, please input a URL from huggingface.co or civitai.com.',
),
)
}
}
export const useModelSearch = () => {
const loading = useLoading()
const md = useMarkdown()
const { toast } = useToast()
const data = ref<(SelectOptions & { item: VersionModel })[]>([])
const current = ref<string | number>()
const handleSearchByUrl = async (url: string) => {
if (!url) {
return Promise.resolve([])
}
let instance: ModelSearch = new UnknownWebsite(md)
const { hostname } = new URL(url ?? '')
if (hostname === 'civitai.com') {
instance = new Civitai(md)
}
if (hostname === 'huggingface.co') {
instance = new Huggingface(md)
}
loading.show()
return instance
.search(url)
.then((resData) => {
data.value = resData.map((item) => ({
label: item.shortname,
value: item.id,
item,
command() {
current.value = item.id
},
}))
current.value = data.value[0]?.value
if (resData.length === 0) {
toast.add({
severity: 'warn',
summary: 'No Model Found',
detail: `No model found for ${url}`,
life: 3000,
})
}
return resData
})
.catch((err) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: err.message,
life: 15000,
})
return []
})
.finally(() => loading.hide())
}
return { data, current, search: handleSearchByUrl }
}

55
src/hooks/loading.ts Normal file
View File

@@ -0,0 +1,55 @@
import { defineStore } from 'hooks/store'
import { useBoolean } from 'hooks/utils'
import { Ref, ref } from 'vue'
class GlobalLoading {
loading: Ref<boolean>
loadingStack = 0
bind(loading: Ref<boolean>) {
this.loading = loading
}
show() {
this.loadingStack++
this.loading.value = true
}
hide() {
this.loadingStack--
if (this.loadingStack <= 0) this.loading.value = false
}
}
export const globalLoading = new GlobalLoading()
export const useGlobalLoading = defineStore('loading', () => {
const [loading] = useBoolean()
globalLoading.bind(loading)
return { loading }
})
export const useLoading = () => {
const timer = ref<NodeJS.Timeout>()
const show = () => {
timer.value = setTimeout(() => {
timer.value = undefined
globalLoading.show()
}, 200)
}
const hide = () => {
if (timer.value) {
clearTimeout(timer.value)
timer.value = undefined
} else {
globalLoading.hide()
}
}
return { show, hide }
}

27
src/hooks/manager.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineStore } from 'hooks/store'
import { useBoolean } from 'hooks/utils'
import { ref, watch } from 'vue'
export const useDialogManager = defineStore('dialogManager', () => {
const [visible, toggle] = useBoolean()
const mounted = ref(false)
const open = ref(false)
watch(visible, (visible) => {
open.value = visible
mounted.value = true
})
const updateVisible = (val: boolean) => {
visible.value = val
}
return { visible: mounted, open, updateVisible, toggle }
})
declare module 'hooks/store' {
interface StoreProvider {
dialogManager: ReturnType<typeof useDialogManager>
}
}

49
src/hooks/markdown.ts Normal file
View File

@@ -0,0 +1,49 @@
import MarkdownIt from 'markdown-it'
import metadata_block from 'markdown-it-metadata-block'
import TurndownService from 'turndown'
import yaml from 'yaml'
interface MarkdownOptions {
metadata?: Record<string, any>
}
export const useMarkdown = (opts?: MarkdownOptions) => {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
md.use(metadata_block, {
parseMetadata: yaml.parse,
meta: opts?.metadata ?? {},
})
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const aIndex = tokens[idx].attrIndex('target')
if (aIndex < 0) {
tokens[idx].attrPush(['target', '_blank'])
} else {
tokens[idx].attrs![aIndex][1] = '_blank'
}
return self.renderToken(tokens, idx, options)
}
const turndown = new TurndownService({
headingStyle: 'atx',
bulletListMarker: '-',
})
turndown.addRule('paragraph', {
filter: 'p',
replacement: function (content) {
return `\n\n${content}`
},
})
return { render: md.render.bind(md), parse: turndown.turndown.bind(turndown) }
}
export type MarkdownTool = ReturnType<typeof useMarkdown>

547
src/hooks/model.ts Normal file
View File

@@ -0,0 +1,547 @@
import { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown'
import { request, useRequest } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { cloneDeep } from 'lodash'
import { app } from 'scripts/comfyAPI'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
import { ModelGrid } from 'utils/legacy'
import { resolveModelType } from 'utils/model'
// import {}
import {
computed,
inject,
InjectionKey,
onMounted,
provide,
ref,
toRaw,
unref,
} from 'vue'
import { useI18n } from 'vue-i18n'
export const useModels = defineStore('models', () => {
const { data, refresh } = useRequest<(Model & { visible?: boolean })[]>(
'/models',
{ defaultValue: [] },
)
const { toast, confirm } = useToast()
const { t } = useI18n()
const loading = useLoading()
const updateModel = async (model: BaseModel, data: BaseModel) => {
const formData = new FormData()
// Check current preview
if (model.preview !== data.preview) {
const previewFile = await previewUrlToFile(data.preview as string)
formData.append('previewFile', previewFile)
}
// Check current description
if (model.description !== data.description) {
formData.append('description', data.description)
}
// Check current name and pathIndex
if (
model.fullname !== data.fullname ||
model.pathIndex !== data.pathIndex
) {
formData.append('type', data.type)
formData.append('pathIndex', data.pathIndex.toString())
formData.append('fullname', data.fullname)
}
if (formData.keys().next().done) {
return
}
loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
method: 'PUT',
body: formData,
})
.catch(() => {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to update model',
life: 15000,
})
})
.finally(() => {
loading.hide()
})
await refresh()
}
const deleteModel = async (model: BaseModel) => {
return new Promise((resolve) => {
confirm.require({
message: t('deleteAsk', [t('model').toLowerCase()]),
header: 'Danger',
icon: 'pi pi-info-circle',
rejectProps: {
label: t('cancel'),
severity: 'secondary',
outlined: true,
},
acceptProps: {
label: t('delete'),
severity: 'danger',
},
accept: () => {
loading.show()
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
method: 'DELETE',
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Success',
detail: `${model.fullname} Deleted`,
life: 2000,
})
return refresh()
})
.then(() => {
resolve(void 0)
})
.catch((e) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: e.message ?? 'Failed to delete model',
life: 15000,
})
})
.finally(() => {
loading.hide()
})
},
reject: () => {},
})
})
}
return { data, refresh, remove: deleteModel, update: updateModel }
})
declare module 'hooks/store' {
interface StoreProvider {
models: ReturnType<typeof useModels>
}
}
export const useModelFormData = (getFormData: () => BaseModel) => {
const formData = ref<BaseModel>(getFormData())
const modelData = ref<BaseModel>(getFormData())
type ResetCallback = () => void
const resetCallback = ref<ResetCallback[]>([])
const registerReset = (callback: ResetCallback) => {
resetCallback.value.push(callback)
}
const reset = () => {
formData.value = getFormData()
modelData.value = getFormData()
for (const callback of resetCallback.value) {
callback()
}
}
type SubmitCallback = (data: BaseModel) => void
const submitCallback = ref<SubmitCallback[]>([])
const registerSubmit = (callback: SubmitCallback) => {
submitCallback.value.push(callback)
}
const submit = () => {
const data = cloneDeep(toRaw(unref(formData)))
for (const callback of submitCallback.value) {
callback(data)
}
return data
}
const metadata = ref<Record<string, any>>({})
return {
formData,
modelData,
registerReset,
reset,
registerSubmit,
submit,
metadata,
}
}
type ModelFormInstance = ReturnType<typeof useModelFormData>
/**
* Model base info
*/
const baseInfoKey = Symbol('baseInfo') as InjectionKey<
ReturnType<typeof useModelBaseInfoEditor>
>
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance
const type = computed({
get: () => {
return model.value.type
},
set: (val) => {
model.value.type = val
},
})
const pathIndex = computed({
get: () => {
return model.value.pathIndex
},
set: (val) => {
model.value.pathIndex = val
},
})
const extension = computed(() => {
return model.value.extension
})
const basename = computed({
get: () => {
return model.value.fullname.replace(model.value.extension, '')
},
set: (val) => {
model.value.fullname = `${val ?? ''}${model.value.extension}`
},
})
interface BaseInfoItem {
key: string
display: string
value: any
}
interface FieldsItem {
key: keyof Model
formatter: (val: any) => string
}
const baseInfo = computed(() => {
const fields: FieldsItem[] = [
{
key: 'type',
formatter: () => resolveModelType(modelData.value.type).display,
},
{
key: 'fullname',
formatter: (val) => val,
},
{
key: 'sizeBytes',
formatter: (val) => (val == 0 ? 'Unknown' : bytesToSize(val)),
},
{
key: 'createdAt',
formatter: (val) => val && formatDate(val),
},
{
key: 'updatedAt',
formatter: (val) => val && formatDate(val),
},
]
const information: Record<string, BaseInfoItem> = {}
for (const item of fields) {
const key = item.key
const value = model.value[key]
const display = item.formatter(value)
if (display) {
information[key] = { key, value, display }
}
}
return information
})
const result = {
type,
baseInfo,
basename,
extension,
pathIndex,
}
provide(baseInfoKey, result)
return result
}
export const useModelBaseInfo = () => {
return inject(baseInfoKey)!
}
/**
* Editable preview image.
*
* In edit mode, there are 4 methods for setting a preview picture:
* 1. default value, which is the default image of the model type
* 2. network picture
* 3. local file
* 4. no preview
*/
const previewKey = Symbol('preview') as InjectionKey<
ReturnType<typeof useModelPreviewEditor>
>
export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
const { formData: model, registerReset, registerSubmit } = formInstance
const typeOptions = ref(['default', 'network', 'local', 'none'])
const currentType = ref('default')
/**
* Default images
*/
const defaultContent = computed(() => {
return Array.isArray(model.value.preview)
? model.value.preview
: [model.value.preview]
})
const defaultContentPage = ref(0)
/**
* Network picture url
*/
const networkContent = ref<string>()
/**
* Local file url
*/
const localContent = ref<string>()
const updateLocalContent = async (event: SelectEvent) => {
const { files } = event
localContent.value = files[0].objectURL
}
/**
* No preview
*/
const noPreviewContent = computed(() => {
return `/model-manager/preview/${model.value.type}/0/no-preview.png`
})
const preview = computed(() => {
let content: string | undefined
switch (currentType.value) {
case 'default':
content = defaultContent.value[defaultContentPage.value]
break
case 'network':
content = networkContent.value
break
case 'local':
content = localContent.value
break
default:
content = noPreviewContent.value
break
}
return content
})
onMounted(() => {
registerReset(() => {
currentType.value = 'default'
defaultContentPage.value = 0
networkContent.value = undefined
localContent.value = undefined
})
registerSubmit((data) => {
data.preview = preview.value ?? noPreviewContent.value
})
})
const result = {
preview,
typeOptions,
currentType,
// default value
defaultContent,
defaultContentPage,
// network picture
networkContent,
// local file
localContent,
updateLocalContent,
// no preview
noPreviewContent,
}
provide(previewKey, result)
return result
}
export const useModelPreview = () => {
return inject(previewKey)!
}
/**
* Model description
*/
const descriptionKey = Symbol('description') as InjectionKey<
ReturnType<typeof useModelDescriptionEditor>
>
export const useModelDescriptionEditor = (formInstance: ModelFormInstance) => {
const { formData: model, metadata } = formInstance
const md = useMarkdown({ metadata: metadata.value })
const description = computed({
get: () => {
return model.value.description
},
set: (val) => {
model.value.description = val
},
})
const renderedDescription = computed(() => {
return description.value ? md.render(description.value) : undefined
})
const result = { renderedDescription, description }
provide(descriptionKey, result)
return result
}
export const useModelDescription = () => {
return inject(descriptionKey)!
}
/**
* Model metadata
*/
const metadataKey = Symbol('metadata') as InjectionKey<
ReturnType<typeof useModelMetadataEditor>
>
export const useModelMetadataEditor = (formInstance: ModelFormInstance) => {
const { formData: model } = formInstance
const metadata = computed(() => {
return model.value.metadata
})
const result = { metadata }
provide(metadataKey, result)
return result
}
export const useModelMetadata = () => {
return inject(metadataKey)!
}
export const useModelNodeAction = (model: BaseModel) => {
const { t } = useI18n()
const { toast, wrapperToastError } = useToast()
const createNode = (options: Record<string, any> = {}) => {
const nodeType = resolveModelType(model.type).loader
if (!nodeType) {
throw new Error(t('unSupportedModelType', [model.type]))
}
const node = window.LiteGraph.createNode(nodeType, null, options)
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
if (widgetIndex > -1) {
node.widgets[widgetIndex].value = model.fullname
}
return node
}
const dragToAddModelNode = wrapperToastError((event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
ModelGrid.dragAddModel(
event,
model.type,
model.fullname,
removeEmbeddingExtension,
strictDragToAdd,
)
})
const addModelNode = wrapperToastError(() => {
const selectedNodes = app.canvas.selected_nodes
const firstSelectedNode = Object.values(selectedNodes)[0]
const offset = 25
const pos = firstSelectedNode
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
: app.canvas.canvas_mouse
const node = createNode({ pos })
app.graph.add(node)
app.canvas.selectNode(node)
})
const copyModelNode = wrapperToastError(() => {
const node = createNode()
app.canvas.copyToClipboard([node])
toast.add({
severity: 'success',
summary: 'Success',
detail: t('modelCopied'),
life: 2000,
})
})
const loadPreviewWorkflow = wrapperToastError(async () => {
const previewUrl = model.preview as string
const response = await fetch(previewUrl)
const data = await response.blob()
const type = data.type
const extension = type.split('/').pop()
const file = new File([data], `${model.fullname}.${extension}`, { type })
app.handleFile(file)
})
return {
addModelNode,
dragToAddModelNode,
copyModelNode,
loadPreviewWorkflow,
}
}

85
src/hooks/request.ts Normal file
View File

@@ -0,0 +1,85 @@
import { useLoading } from 'hooks/loading'
import { api } from 'scripts/comfyAPI'
import { onMounted, ref } from 'vue'
export const request = async (url: string, options?: RequestInit) => {
return api
.fetchApi(`/model-manager${url}`, options)
.then((response) => response.json())
.then((resData) => {
if (resData.success) {
return resData.data
}
throw new Error(resData.error)
})
}
export interface RequestOptions<T> {
method?: RequestInit['method']
headers?: RequestInit['headers']
defaultParams?: Record<string, any>
defaultValue?: any
postData?: (data: T) => T
manual?: boolean
}
export const useRequest = <T = any>(
url: string,
options: RequestOptions<T> = {},
) => {
const loading = useLoading()
const postData = options.postData ?? ((data) => data)
const data = ref<T>(options.defaultValue)
const lastParams = ref()
const fetch = async (
params: Record<string, any> = options.defaultParams ?? {},
) => {
loading.show()
lastParams.value = params
let requestUrl = url
const requestOptions: RequestInit = {
method: options.method,
headers: options.headers,
}
const requestParams = { ...params }
const templatePattern = /\{(.*?)\}/g
const urlParamKeyMatches = requestUrl.matchAll(templatePattern)
for (const urlParamKey of urlParamKeyMatches) {
const [match, paramKey] = urlParamKey
if (paramKey in requestParams) {
const paramValue = requestParams[paramKey]
delete requestParams[paramKey]
requestUrl = requestUrl.replace(match, paramValue)
}
}
if (!requestOptions.method) {
requestOptions.method = 'GET'
}
if (requestOptions.method !== 'GET') {
requestOptions.body = JSON.stringify(requestParams)
}
return request(requestUrl, requestOptions)
.then((resData) => (data.value = postData(resData)))
.finally(() => loading.hide())
}
onMounted(() => {
if (!options.manual) {
fetch()
}
})
const refresh = async () => {
return fetch(lastParams.value)
}
return { data, refresh, fetch }
}

22
src/hooks/resize.ts Normal file
View File

@@ -0,0 +1,22 @@
import { throttle } from 'lodash'
import { Directive } from 'vue'
export const resizeDirective: Directive<HTMLElement, ResizeObserverCallback> = {
mounted: (el, binding) => {
const callback = binding.value ?? (() => {})
const observer = new ResizeObserver(callback)
observer.observe(el)
el['observer'] = observer
},
unmounted: (el) => {
const observer = el['observer']
observer.disconnect()
},
}
export const defineResizeCallback = (
callback: ResizeObserverCallback,
wait?: number,
) => {
return throttle(callback, wait ?? 100)
}

82
src/hooks/socket.ts Normal file
View File

@@ -0,0 +1,82 @@
import { globalToast } from 'hooks/toast'
import { readonly } from 'vue'
class WebSocketEvent extends EventTarget {
private socket: WebSocket | null
constructor() {
super()
this.createSocket()
}
private createSocket(isReconnect?: boolean) {
const api_host = location.host
const api_base = location.pathname.split('/').slice(0, -1).join('/')
let opened = false
let existingSession = window.name
if (existingSession) {
existingSession = '?clientId=' + existingSession
}
this.socket = readonly(
new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${api_host}${api_base}/model-manager/ws${existingSession}`,
),
)
this.socket.addEventListener('open', () => {
opened = true
if (isReconnect) {
this.dispatchEvent(new CustomEvent('reconnected'))
}
})
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
})
this.socket.addEventListener('close', (event) => {
setTimeout(() => {
this.socket = null
this.createSocket(true)
}, 300)
if (opened) {
this.dispatchEvent(new CustomEvent('status', { detail: null }))
this.dispatchEvent(new CustomEvent('reconnecting'))
}
})
this.socket.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'error') {
globalToast.value?.add({
severity: 'error',
summary: 'Error',
detail: msg.data,
life: 15000,
})
} else {
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }))
}
} catch (error) {
console.error(error)
}
})
}
addEventListener = (
type: string,
callback: CustomEventListener | null,
options?: AddEventListenerOptions | boolean,
) => {
super.addEventListener(type, callback, options)
}
send(type: string, data: any) {
this.socket?.send(JSON.stringify({ type, detail: data }))
}
}
export const socket = new WebSocketEvent()

51
src/hooks/store.ts Normal file
View File

@@ -0,0 +1,51 @@
import { inject, InjectionKey, provide } from 'vue'
const providerHooks = new Map<string, any>()
const storeEvent = {} as StoreProvider
export const useStoreProvider = () => {
// const storeEvent = {}
for (const [key, useHook] of providerHooks) {
storeEvent[key] = useHook()
}
return storeEvent
}
const storeKeys = new Map<string, Symbol>()
const getStoreKey = (key: string) => {
let storeKey = storeKeys.get(key)
if (!storeKey) {
storeKey = Symbol(key)
storeKeys.set(key, storeKey)
}
return storeKey
}
/**
* Using vue provide and inject to implement a simple store
*/
export const defineStore = <T = any>(
key: string,
useInitial: (event: StoreProvider) => T,
) => {
const storeKey = getStoreKey(key) as InjectionKey<T>
if (providerHooks.has(key) && !import.meta.hot) {
console.warn(`[defineStore] key: ${key} already exists.`)
} else {
providerHooks.set(key, () => {
const result = useInitial(storeEvent)
provide(storeKey, result ?? storeEvent[key])
return result
})
}
const useStore = () => {
return inject(storeKey)!
}
return useStore
}

45
src/hooks/toast.ts Normal file
View File

@@ -0,0 +1,45 @@
import { ToastServiceMethods } from 'primevue/toastservice'
import { useConfirm as usePrimeConfirm } from 'primevue/useconfirm'
import { useToast as usePrimeToast } from 'primevue/usetoast'
export const globalToast = { value: null } as unknown as {
value: ToastServiceMethods
}
export const useToast = () => {
const toast = usePrimeToast()
const confirm = usePrimeConfirm()
globalToast.value = toast
const wrapperToastError = <T extends Function>(callback: T): T => {
const showToast = (error: Error) => {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 15000,
})
}
const isAsync = callback.constructor.name === 'AsyncFunction'
let wrapperExec: any
if (isAsync) {
wrapperExec = (...args: any[]) => callback(...args).catch(showToast)
} else {
wrapperExec = (...args: any[]) => {
try {
return callback(...args)
} catch (error) {
showToast(error)
}
}
}
return wrapperExec
}
return { toast, wrapperToastError, confirm }
}

11
src/hooks/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
import { ref } from 'vue'
export const useBoolean = (defaultValue?: boolean) => {
const target = ref(defaultValue ?? false)
const toggle = (value?: any) => {
target.value = typeof value === 'boolean' ? value : !target.value
}
return [target, toggle] as const
}

88
src/i18n.ts Normal file
View File

@@ -0,0 +1,88 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
model: 'Model',
modelManager: 'Model Manager',
openModelManager: 'Open Model Manager',
searchModels: 'Search models',
modelCopied: 'Model Copied',
download: 'Download',
downloadList: 'Download List',
downloadTask: 'Download Task',
createDownloadTask: 'Create Download Task',
parseModelUrl: 'Parse Model URL',
pleaseInputModelUrl: 'Input a URL from civitai.com or huggingface.co',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
deleteAsk: 'Confirm delete this {0}',
modelType: 'Model Type',
default: 'Default',
network: 'Network',
local: 'Local',
none: 'None',
uploadFile: 'Upload File',
tapToChange: 'Tap description to change content',
sort: {
name: 'Name',
size: 'Largest',
created: 'Latest created',
modified: 'Latest modified',
},
info: {
type: 'Model Type',
fullname: 'File Name',
sizeBytes: 'File Size',
createdAt: 'Created At',
updatedAt: 'Updated At',
},
},
zh: {
model: '模型',
modelManager: '模型管理器',
openModelManager: '打开模型管理器',
searchModels: '搜索模型',
modelCopied: '模型节点已拷贝',
download: '下载',
downloadList: '下载列表',
downloadTask: '下载任务',
createDownloadTask: '创建下载任务',
parseModelUrl: '解析模型URL',
pleaseInputModelUrl: '输入 civitai.com 或 huggingface.co 的 URL',
cancel: '取消',
save: '保存',
delete: '删除',
deleteAsk: '确定要删除此{0}',
modelType: '模型类型',
default: '默认',
network: '网络',
local: '本地',
none: '无',
uploadFile: '上传文件',
tapToChange: '点击描述可更改内容',
sort: {
name: '名称',
size: '最大',
created: '最新创建',
modified: '最新修改',
},
info: {
type: '类型',
fullname: '文件名',
sizeBytes: '文件大小',
createdAt: '创建时间',
updatedAt: '更新时间',
},
},
}
export const i18n = createI18n({
legacy: false,
locale:
localStorage.getItem('Comfy.Settings.Comfy.Locale') ||
navigator.language.split('-')[0] ||
'en',
fallbackLocale: 'en',
messages,
})

55
src/main.ts Normal file
View File

@@ -0,0 +1,55 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import { resizeDirective } from 'hooks/resize'
import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { app } from 'scripts/comfyAPI'
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'
import './style.css'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
primary: Aura['primitive'].blue,
},
})
function createVueApp(rootContainer: string | HTMLElement) {
const app = createApp(App)
app.directive('tooltip', Tooltip)
app.directive('resize', resizeDirective)
app
.use(PrimeVue, {
theme: {
preset: ComfyUIPreset,
options: {
prefix: 'p',
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities',
},
// This is a workaround for the issue with the dark mode selector
// https://github.com/primefaces/primevue/issues/5515
darkModeSelector: '.dark-theme, :root:has(.dark-theme)',
},
},
})
.use(ToastService)
.use(ConfirmationService)
.use(i18n)
.mount(rootContainer)
}
app.registerExtension({
name: 'Comfy.ModelManager',
setup() {
const container = document.createElement('div')
container.id = 'comfyui-model-manager'
document.body.appendChild(container)
createVueApp(container)
},
})

7
src/scripts/comfyAPI.ts Normal file
View File

@@ -0,0 +1,7 @@
export const app = window.comfyAPI.app.app
export const api = window.comfyAPI.api.api
export const $el = window.comfyAPI.ui.$el
export const ComfyApp = window.comfyAPI.app.ComfyApp
export const ComfyButton = window.comfyAPI.button.ComfyButton

157
src/style.css Normal file
View File

@@ -0,0 +1,157 @@
@layer primevue, tailwind-utilities;
@layer tailwind-utilities {
@tailwind components;
@tailwind utilities;
:root {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
*.border,
*.border-x,
*.border-y,
*.border-l,
*.border-t,
*.border-r,
*.border-b {
border-style: solid;
}
table,
th,
tr,
td {
border-width: 0px;
}
}
.comfy-modal {
z-index: 3000;
}
.markdown-it {
font-family: theme('fontFamily.sans');
line-height: theme('lineHeight.relaxed');
word-break: break-word;
margin: 0;
h1 {
font-size: theme('fontSize.2xl');
font-weight: theme('fontWeight.bold');
border-bottom: 1px solid #ddd;
margin-top: theme('margin.4');
margin-bottom: theme('margin.4');
padding-bottom: theme('padding[2.5]');
}
h2 {
font-size: theme('fontSize.xl');
font-weight: theme('fontWeight.bold');
}
h3 {
font-size: theme('fontSize.lg');
}
a {
color: #1e8bc3;
text-decoration: none;
word-break: break-all;
}
a:hover {
text-decoration: underline;
}
p {
margin: 1em 0;
}
p img {
width: 100%;
height: 100%;
object-fit: cover;
}
ul,
ol {
margin: 1em 0;
padding-left: 2em;
}
li {
margin: 0.5em 0;
}
blockquote {
border-left: 5px solid #ddd;
padding: 10px 20px;
margin: 1.5em 0;
background: #f9f9f9;
}
code,
pre {
background: #f9f9f9;
padding: 3px 5px;
border: 1px solid #ddd;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
}
pre {
padding: 10px;
overflow-x: auto;
}
}

272
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,272 @@
declare namespace ComfyAPI {
namespace api {
class ComfyApi {
socket: WebSocket
fetchApi: (route: string, options?: RequestInit) => Promise<Response>
addEventListener: (
type: string,
callback: (event: CustomEvent) => void,
options?: AddEventListenerOptions,
) => void
}
const api: ComfyApi
}
namespace app {
interface ComfyExtension {
/**
* The name of the extension
*/
name: string
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance
*/
init?(app: ComfyApp): Promise<void> | void
/**
* Allows any additional setup, called after the application is fully set up and running
* @param app The ComfyUI app instance
*/
setup?(app: ComfyApp): Promise<void> | void
}
interface BaseSidebarTabExtension {
id: string
title: string
icon?: string
iconBadge?: string | (() => string | null)
order?: number
tooltip?: string
}
interface VueSidebarTabExtension extends BaseSidebarTabExtension {
type: 'vue'
component: import('vue').Component
}
interface CustomSidebarTabExtension extends BaseSidebarTabExtension {
type: 'custom'
render: (container: HTMLElement) => void
destroy?: () => void
}
type SidebarTabExtension =
| VueSidebarTabExtension
| CustomSidebarTabExtension
interface ExtensionManager {
// Sidebar tabs
registerSidebarTab(tab: SidebarTabExtension): void
unregisterSidebarTab(id: string): void
getSidebarTabs(): SidebarTabExtension[]
// Toast
toast: ToastManager
}
class ComfyApp {
ui?: ui.ComfyUI
menu?: index.ComfyAppMenu
graph: lightGraph.LGraph
canvas: lightGraph.LGraphCanvas
extensionManager: ExtensionManager
registerExtension: (extension: ComfyExtension) => void
addNodeOnGraph: (
nodeDef: lightGraph.ComfyNodeDef,
options?: Record<string, any>,
) => lightGraph.LGraphNode
getCanvasCenter: () => lightGraph.Vector2
clientPosToCanvasPos: (pos: lightGraph.Vector2) => lightGraph.Vector2
handleFile: (file: File) => void
}
const app: ComfyApp
}
namespace ui {
type Props = {
parent?: HTMLElement
$?: (el: HTMLElement) => void
dataset?: DOMStringMap
style?: Partial<CSSStyleDeclaration>
for?: string
textContent?: string
[key: string]: any
}
type Children = Element[] | Element | string | string[]
type ElementType<K extends string> = K extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[K]
: HTMLElement
const $el: <TTag extends string>(
tag: TTag,
propsOrChildren?: Children | Props,
children?: Children,
) => ElementType<TTag>
class ComfyUI {
app: app.ComfyApp
settings: ComfySettingsDialog
menuHamburger?: HTMLDivElement
menuContainer?: HTMLDivElement
}
type SettingInputType =
| 'boolean'
| 'number'
| 'slider'
| 'combo'
| 'text'
| 'hidden'
type SettingCustomRenderer = (
name: string,
setter: (v: any) => void,
value: any,
attrs: any,
) => HTMLElement
interface SettingOption {
text: string
value?: string
}
interface SettingParams {
id: string
name: string
type: SettingInputType | SettingCustomRenderer
defaultValue: any
onChange?: (newValue: any, oldValue?: any) => void
attrs?: any
tooltip?: string
options?:
| Array<string | SettingOption>
| ((value: any) => SettingOption[])
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.
// Note: Like id, category value need to be unique.
category?: string[]
experimental?: boolean
deprecated?: boolean
}
class ComfySettingsDialog {
addSetting: (params: SettingParams) => { value: any }
}
}
namespace index {
class ComfyAppMenu {
app: app.ComfyApp
logo: HTMLElement
actionsGroup: button.ComfyButtonGroup
settingsGroup: button.ComfyButtonGroup
viewGroup: button.ComfyButtonGroup
mobileMenuButton: ComfyButton
element: HTMLElement
}
}
namespace button {
type ComfyButtonProps = {
icon?: string
overIcon?: string
iconSize?: number
content?: string | HTMLElement
tooltip?: string
enabled?: boolean
action?: (e: Event, btn: ComfyButton) => void
classList?: ClassList
visibilitySetting?: { id: keyof Settings; showValue: boolean }
app?: app.ComfyApp
}
class ComfyButton {
constructor(props: ComfyButtonProps): ComfyButton
}
class ComfyButtonGroup {
insert(button: ComfyButton, index: number): void
append(button: ComfyButton): void
remove(indexOrButton: ComfyButton | number): void
update(): void
constructor(...buttons: (HTMLElement | ComfyButton)[]): ComfyButtonGroup
}
}
}
declare namespace lightGraph {
class LGraphNode implements ComfyNodeDef {
widgets: any[]
pos: Vector2
}
class LGraphGroup {}
class LGraph {
/**
* Adds a new node instance to this graph
* @param node the instance of the node
*/
add(node: LGraphNode | LGraphGroup, skip_compute_order?: boolean): void
/**
* Returns the top-most node in this position of the canvas
* @param x the x coordinate in canvas space
* @param y the y coordinate in canvas space
* @param nodes_list a list with all the nodes to search from, by default is all the nodes in the graph
* @return the node at this position or null
*/
getNodeOnPos<T extends LGraphNode = LGraphNode>(
x: number,
y: number,
node_list?: LGraphNode[],
margin?: number,
): T | null
}
class LGraphCanvas {
selected_nodes: Record<string, LGraphNode>
canvas_mouse: Vector2
selectNode: (node: LGraphNode) => void
copyToClipboard: (nodes: LGraphNode[]) => void
}
const LiteGraph: {
createNode: (
type: string,
title: string | null,
options: object,
) => LGraphNode
}
type ComfyNodeDef = {
input?: {
required?: Record<string, any>
optional?: Record<string, any>
hidden?: Record<string, any>
}
output?: (string | any[])[]
output_is_list?: boolean[]
output_name?: string[]
output_tooltips?: string[]
name?: string
display_name?: string
description?: string
category?: string
output_node?: boolean
python_module?: string
deprecated?: boolean
experimental?: boolean
}
type Vector2 = [number, number]
}
interface Window {
comfyAPI: typeof ComfyAPI
LiteGraph: typeof lightGraph.LiteGraph
}

11
src/types/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
export {}
declare module 'vue' {
interface ComponentCustomProperties {
vResize: (typeof import('hooks/resize'))['resizeDirective']
}
}
declare module 'hooks/store' {
interface StoreProvider {}
}

69
src/types/typings.d.ts vendored Normal file
View File

@@ -0,0 +1,69 @@
interface BaseModel {
id: number | string
fullname: string
basename: string
extension: string
sizeBytes: number
type: string
pathIndex: number
preview: string | string[]
description: string
metadata: Record<string, string>
}
interface Model extends BaseModel {
createdAt: number
updatedAt: number
}
interface VersionModel extends BaseModel {
shortname: string
downloadPlatform: string
downloadUrl: string
hashes?: Record<string, string>
}
type PassThrough<T = void> = T | object | undefined
interface SelectOptions {
label: string
value: any
icon?: string
command: () => void
}
interface SelectFile extends File {
objectURL: string
}
interface SelectEvent {
files: SelectFile[]
originalEvent: Event
}
interface DownloadTaskOptions {
taskId: string
type: string
fullname: string
preview: string
status: 'pause' | 'waiting' | 'doing'
progress: number
downloadedSize: number
totalSize: number
bps: number
error?: string
}
interface DownloadTask
extends Omit<
DownloadTaskOptions,
'downloadedSize' | 'totalSize' | 'bps' | 'error'
> {
downloadProgress: string
downloadSpeed: string
pauseTask: () => void
resumeTask: () => void
deleteTask: () => void
}
type CustomEventListener = (event: CustomEvent) => void

39
src/utils/common.ts Normal file
View File

@@ -0,0 +1,39 @@
import dayjs from 'dayjs'
export const bytesToSize = (
bytes: number | string | undefined | null,
decimals = 2,
) => {
if (typeof bytes === 'undefined' || bytes === null) {
bytes = 0
}
if (typeof bytes === 'string') {
bytes = Number(bytes)
}
if (Number.isNaN(bytes)) {
return 'Unknown'
}
if (bytes === 0) {
return '0 Bytes'
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export const formatDate = (date: number | string | Date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
export const previewUrlToFile = async (url: string) => {
return fetch(url)
.then((res) => res.blob())
.then((blob) => {
const type = blob.type
const extension = type.split('/')[1]
const file = new File([blob], `preview.${extension}`, { type })
return file
})
}

620
src/utils/legacy.ts Normal file
View File

@@ -0,0 +1,620 @@
// @ts-nocheck
import { app } from 'scripts/comfyAPI'
const LiteGraph = window.LiteGraph
const modelNodeType = {
checkpoints: 'CheckpointLoaderSimple',
clip: 'CLIPLoader',
clip_vision: 'CLIPVisionLoader',
controlnet: 'ControlNetLoader',
diffusers: 'DiffusersLoader',
embeddings: 'Embedding',
gligen: 'GLIGENLoader',
hypernetworks: 'HypernetworkLoader',
photomaker: 'PhotoMakerLoader',
loras: 'LoraLoader',
style_models: 'StyleModelLoader',
unet: 'UNETLoader',
upscale_models: 'UpscaleModelLoader',
vae: 'VAELoader',
vae_approx: undefined,
}
export class ModelGrid {
/**
* @param {string} nodeType
* @returns {int}
*/
static modelWidgetIndex(nodeType) {
return nodeType === undefined ? -1 : 0
}
/**
* @param {string} text
* @param {string} file
* @param {boolean} removeExtension
* @returns {string}
*/
static insertEmbeddingIntoText(text, file, removeExtension) {
let name = file
if (removeExtension) {
name = SearchPath.splitExtension(name)[0]
}
const sep = text.length === 0 || text.slice(-1).match(/\s/) ? '' : ' '
return text + sep + '(embedding:' + name + ':1.0)'
}
/**
* @param {Array} list
* @param {string} searchString
* @returns {Array}
*/
static #filter(list, searchString) {
/** @type {string[]} */
const keywords = searchString
//.replace("*", " ") // TODO: this is wrong for wildcards
.split(/(-?".*?"|[^\s"]+)+/g)
.map((item) =>
item
.trim()
.replace(/(?:")+/g, '')
.toLowerCase(),
)
.filter(Boolean)
const regexSHA256 = /^[a-f0-9]{64}$/gi
const fields = ['name', 'path']
return list.filter((element) => {
const text = fields
.reduce((memo, field) => memo + ' ' + element[field], '')
.toLowerCase()
return keywords.reduce((memo, target) => {
const excludeTarget = target[0] === '-'
if (excludeTarget && target.length === 1) {
return memo
}
const filteredTarget = excludeTarget ? target.slice(1) : target
if (
element['SHA256'] !== undefined &&
regexSHA256.test(filteredTarget)
) {
return (
memo && excludeTarget !== (filteredTarget === element['SHA256'])
)
} else {
return memo && excludeTarget !== text.includes(filteredTarget)
}
}, true)
})
}
/**
* In-place sort. Returns an array alias.
* @param {Array} list
* @param {string} sortBy
* @param {bool} [reverse=false]
* @returns {Array}
*/
static #sort(list, sortBy, reverse = false) {
let compareFn = null
switch (sortBy) {
case MODEL_SORT_DATE_NAME:
compareFn = (a, b) => {
return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME])
}
break
case MODEL_SORT_DATE_MODIFIED:
compareFn = (a, b) => {
return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED]
}
break
case MODEL_SORT_DATE_CREATED:
compareFn = (a, b) => {
return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]
}
break
case MODEL_SORT_SIZE_BYTES:
compareFn = (a, b) => {
return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES]
}
break
default:
console.warn("Invalid filter sort value: '" + sortBy + "'")
return list
}
const sorted = list.sort(compareFn)
return reverse ? sorted.reverse() : sorted
}
/**
* @param {Event} event
* @param {string} modelType
* @param {string} path
* @param {boolean} removeEmbeddingExtension
* @param {int} addOffset
*/
static #addModel(
event,
modelType,
path,
removeEmbeddingExtension,
addOffset,
) {
let success = false
if (modelType !== 'embeddings') {
const nodeType = modelNodeType[modelType]
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType)
let node = LiteGraph.createNode(nodeType, null, [])
if (widgetIndex !== -1 && node) {
node.widgets[widgetIndex].value = path
const selectedNodes = app.canvas.selected_nodes
let isSelectedNode = false
for (var i in selectedNodes) {
const selectedNode = selectedNodes[i]
node.pos[0] = selectedNode.pos[0] + addOffset
node.pos[1] = selectedNode.pos[1] + addOffset
isSelectedNode = true
break
}
if (!isSelectedNode) {
const graphMouse = app.canvas.graph_mouse
node.pos[0] = graphMouse[0]
node.pos[1] = graphMouse[1]
}
app.graph.add(node, { doProcessChange: true })
app.canvas.selectNode(node)
success = true
}
event.stopPropagation()
} else if (modelType === 'embeddings') {
const [embeddingDirectory, embeddingFile] = SearchPath.split(path)
const selectedNodes = app.canvas.selected_nodes
for (var i in selectedNodes) {
const selectedNode = selectedNodes[i]
const nodeType = modelNodeType[modelType]
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType)
const target = selectedNode?.widgets[widgetIndex]?.element
if (target && target.type === 'textarea') {
target.value = ModelGrid.insertEmbeddingIntoText(
target.value,
embeddingFile,
removeEmbeddingExtension,
)
success = true
}
}
if (!success) {
console.warn('Try selecting a node before adding the embedding.')
}
event.stopPropagation()
}
comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick')
}
static #getWidgetComboIndices(node, value) {
const widgetIndices = []
node?.widgets?.forEach((widget, index) => {
if (widget.type === 'combo' && widget.options.values?.includes(value)) {
widgetIndices.push(index)
}
})
return widgetIndices
}
/**
* @param {DragEvent} event
* @param {string} modelType
* @param {string} path
* @param {boolean} removeEmbeddingExtension
* @param {boolean} strictlyOnWidget
*/
static dragAddModel(
event,
modelType,
path,
removeEmbeddingExtension,
strictlyOnWidget,
) {
const target = document.elementFromPoint(event.clientX, event.clientY)
if (modelType !== 'embeddings' && target.id === 'graph-canvas') {
const pos = app.canvas.convertEventToCanvasOffset(event)
const node = app.graph.getNodeOnPos(
pos[0],
pos[1],
app.canvas.visible_nodes,
)
let widgetIndex = -1
if (widgetIndex === -1) {
const widgetIndices = this.#getWidgetComboIndices(node, path)
if (widgetIndices.length === 0) {
widgetIndex = -1
} else if (widgetIndices.length === 1) {
widgetIndex = widgetIndices[0]
if (strictlyOnWidget) {
const draggedWidget = app.canvas.processNodeWidgets(
node,
pos,
event,
)
const widget = node.widgets[widgetIndex]
if (draggedWidget != widget) {
// != check NOT same object
widgetIndex = -1
}
}
} else {
// ambiguous widget (strictlyOnWidget always true)
const draggedWidget = app.canvas.processNodeWidgets(node, pos, event)
widgetIndex = widgetIndices.findIndex((index) => {
return draggedWidget == node.widgets[index] // == check same object
})
}
}
if (widgetIndex !== -1) {
node.widgets[widgetIndex].value = path
app.canvas.selectNode(node)
} else {
const expectedNodeType = modelNodeType[modelType]
const newNode = LiteGraph.createNode(expectedNodeType, null, [])
let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType)
if (newWidgetIndex === -1) {
newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1
}
if (
newNode !== undefined &&
newNode !== null &&
newWidgetIndex !== -1
) {
newNode.pos[0] = pos[0]
newNode.pos[1] = pos[1]
newNode.widgets[newWidgetIndex].value = path
app.graph.add(newNode, { doProcessChange: true })
app.canvas.selectNode(newNode)
}
}
event.stopPropagation()
} else if (modelType === 'embeddings' && target.type === 'textarea') {
const pos = app.canvas.convertEventToCanvasOffset(event)
const nodeAtPos = app.graph.getNodeOnPos(
pos[0],
pos[1],
app.canvas.visible_nodes,
)
if (nodeAtPos) {
app.canvas.selectNode(nodeAtPos)
const [embeddingDirectory, embeddingFile] = SearchPath.split(path)
target.value = ModelGrid.insertEmbeddingIntoText(
target.value,
embeddingFile,
removeEmbeddingExtension,
)
event.stopPropagation()
}
}
}
/**
* @param {Event} event
* @param {string} modelType
* @param {string} path
* @param {boolean} removeEmbeddingExtension
*/
static #copyModelToClipboard(
event,
modelType,
path,
removeEmbeddingExtension,
) {
const nodeType = modelNodeType[modelType]
let success = false
if (nodeType === 'Embedding') {
if (navigator.clipboard) {
const [embeddingDirectory, embeddingFile] = SearchPath.split(path)
const embeddingText = ModelGrid.insertEmbeddingIntoText(
'',
embeddingFile,
removeEmbeddingExtension,
)
navigator.clipboard.writeText(embeddingText)
success = true
} else {
console.warn(
'Cannot copy the embedding to the system clipboard; Try dragging it instead.',
)
}
} else if (nodeType) {
const node = LiteGraph.createNode(nodeType, null, [])
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType)
if (widgetIndex !== -1) {
node.widgets[widgetIndex].value = path
app.canvas.copyToClipboard([node])
success = true
}
} else {
console.warn(`Unable to copy unknown model type '${modelType}.`)
}
comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick')
}
/**
* @param {Array} models
* @param {string} modelType
* @param {Object.<HTMLInputElement>} settingsElements
* @param {String} searchSeparator
* @param {String} systemSeparator
* @param {(searchPath: string) => Promise<void>} showModelInfo
* @returns {HTMLElement[]}
*/
static #generateInnerHtml(
models,
modelType,
settingsElements,
searchSeparator,
systemSeparator,
showModelInfo,
) {
// TODO: separate text and model logic; getting too messy
// TODO: fallback on button failure to copy text?
const canShowButtons = modelNodeType[modelType] !== undefined
const showAddButton =
canShowButtons && settingsElements['model-show-add-button'].checked
const showCopyButton =
canShowButtons && settingsElements['model-show-copy-button'].checked
const showLoadWorkflowButton =
canShowButtons &&
settingsElements['model-show-load-workflow-button'].checked
const strictDragToAdd =
settingsElements['model-add-drag-strict-on-field'].checked
const addOffset = parseInt(settingsElements['model-add-offset'].value)
const showModelExtension =
settingsElements['model-show-label-extensions'].checked
const modelInfoButtonOnLeft =
!settingsElements['model-info-button-on-left'].checked
const removeEmbeddingExtension =
!settingsElements['model-add-embedding-extension'].checked
const previewThumbnailFormat =
settingsElements['model-preview-thumbnail-type'].value
const previewThumbnailWidth = Math.round(
settingsElements['model-preview-thumbnail-width'].value / 0.75,
)
const previewThumbnailHeight = Math.round(
settingsElements['model-preview-thumbnail-height'].value / 0.75,
)
const buttonsOnlyOnHover =
settingsElements['model-buttons-only-on-hover'].checked
if (models.length > 0) {
const $overlay = IS_FIREFOX
? (modelType, path, removeEmbeddingExtension, strictDragToAdd) => {
return $el('div.model-preview-overlay', {
ondragstart: (e) => {
const data = {
modelType: modelType,
path: path,
removeEmbeddingExtension: removeEmbeddingExtension,
strictDragToAdd: strictDragToAdd,
}
e.dataTransfer.setData('manager-model', JSON.stringify(data))
e.dataTransfer.setData('text/plain', '')
},
draggable: true,
})
}
: (modelType, path, removeEmbeddingExtension, strictDragToAdd) => {
return $el('div.model-preview-overlay', {
ondragend: (e) =>
ModelGrid.dragAddModel(
e,
modelType,
path,
removeEmbeddingExtension,
strictDragToAdd,
),
draggable: true,
})
}
const forHiddingButtonsClass = buttonsOnlyOnHover
? 'model-buttons-hidden'
: 'model-buttons-visible'
return models.map((item) => {
const previewInfo = item.preview
const previewThumbnail = $el('img.model-preview', {
loading:
'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */,
src: imageUri(
previewInfo?.path,
previewInfo?.dateModified,
previewThumbnailWidth,
previewThumbnailHeight,
previewThumbnailFormat,
),
draggable: false,
})
const searchPath = item.path
const path = SearchPath.systemPath(
searchPath,
searchSeparator,
systemSeparator,
)
let actionButtons = []
if (showCopyButton) {
actionButtons.push(
new ComfyButton({
icon: 'content-copy',
tooltip: 'Copy model to clipboard',
classList: 'comfyui-button icon-button model-button',
action: (e) =>
ModelGrid.#copyModelToClipboard(
e,
modelType,
path,
removeEmbeddingExtension,
),
}).element,
)
}
if (
showAddButton &&
!(modelType === 'embeddings' && !navigator.clipboard)
) {
actionButtons.push(
new ComfyButton({
icon: 'plus-box-outline',
tooltip: 'Add model to node grid',
classList: 'comfyui-button icon-button model-button',
action: (e) =>
ModelGrid.#addModel(
e,
modelType,
path,
removeEmbeddingExtension,
addOffset,
),
}).element,
)
}
if (showLoadWorkflowButton) {
actionButtons.push(
new ComfyButton({
icon: 'arrow-bottom-left-bold-box-outline',
tooltip: 'Load preview workflow',
classList: 'comfyui-button icon-button model-button',
action: async (e) => {
const urlString = previewThumbnail.src
const url = new URL(urlString)
const urlSearchParams = url.searchParams
const uri = urlSearchParams.get('uri')
const v = urlSearchParams.get('v')
const urlFull =
urlString.substring(0, urlString.indexOf('?')) +
'?uri=' +
uri +
'&v=' +
v
await loadWorkflow(urlFull)
},
}).element,
)
}
const infoButtons = [
new ComfyButton({
icon: 'information-outline',
tooltip: 'View model information',
classList: 'comfyui-button icon-button model-button',
action: async () => {
await showModelInfo(searchPath)
},
}).element,
]
return $el('div.item', {}, [
previewThumbnail,
$overlay(modelType, path, removeEmbeddingExtension, strictDragToAdd),
$el(
'div.model-preview-top-right.' + forHiddingButtonsClass,
{
draggable: false,
},
modelInfoButtonOnLeft ? infoButtons : actionButtons,
),
$el(
'div.model-preview-top-left.' + forHiddingButtonsClass,
{
draggable: false,
},
modelInfoButtonOnLeft ? actionButtons : infoButtons,
),
$el(
'div.model-label',
{
draggable: false,
},
[
$el('p', [
showModelExtension
? item.name
: SearchPath.splitExtension(item.name)[0],
]),
],
),
])
})
} else {
return [$el('h2', ['No Models'])]
}
}
/**
* @param {HTMLDivElement} modelGrid
* @param {ModelData} modelData
* @param {HTMLSelectElement} modelSelect
* @param {Object.<{value: string}>} previousModelType
* @param {Object} settings
* @param {string} sortBy
* @param {boolean} reverseSort
* @param {Array} previousModelFilters
* @param {HTMLInputElement} modelFilter
* @param {(searchPath: string) => Promise<void>} showModelInfo
*/
static update(
modelGrid,
modelData,
modelSelect,
previousModelType,
settings,
sortBy,
reverseSort,
previousModelFilters,
modelFilter,
showModelInfo,
) {
const models = modelData.models
let modelType = modelSelect.value
if (models[modelType] === undefined) {
modelType = settings['model-default-browser-model-type'].value
}
if (models[modelType] === undefined) {
modelType = 'checkpoints' // panic fallback
}
if (modelType !== previousModelType.value) {
if (settings['model-persistent-search'].checked) {
previousModelFilters.splice(0, previousModelFilters.length) // TODO: make sure this actually worked!
} else {
// cache previous filter text
previousModelFilters[previousModelType.value] = modelFilter.value
// read cached filter text
modelFilter.value = previousModelFilters[modelType] ?? ''
}
previousModelType.value = modelType
}
let modelTypeOptions = []
for (const [key, value] of Object.entries(models)) {
const el = $el('option', [key])
modelTypeOptions.push(el)
}
modelSelect.innerHTML = ''
modelTypeOptions.forEach((option) => modelSelect.add(option))
modelSelect.value = modelType
const searchAppend = settings['model-search-always-append'].value
const searchText = modelFilter.value + ' ' + searchAppend
const modelList = ModelGrid.#filter(models[modelType], searchText)
ModelGrid.#sort(modelList, sortBy, reverseSort)
modelGrid.innerHTML = ''
const modelGridModels = ModelGrid.#generateInnerHtml(
modelList,
modelType,
settings,
modelData.searchSeparator,
modelData.systemSeparator,
showModelInfo,
)
modelGrid.append.apply(modelGrid, modelGridModels)
}
}

45
src/utils/model.ts Normal file
View File

@@ -0,0 +1,45 @@
const loader = {
checkpoints: 'CheckpointLoaderSimple',
clip: 'CLIPLoader',
clip_vision: 'CLIPVisionLoader',
controlnet: 'ControlNetLoader',
diffusers: 'DiffusersLoader',
diffusion_models: 'DiffusersLoader',
embeddings: 'Embedding',
gligen: 'GLIGENLoader',
hypernetworks: 'HypernetworkLoader',
photomaker: 'PhotoMakerLoader',
loras: 'LoraLoader',
style_models: 'StyleModelLoader',
unet: 'UNETLoader',
upscale_models: 'UpscaleModelLoader',
vae: 'VAELoader',
vae_approx: undefined,
}
const display = {
all: 'ALL',
checkpoints: 'Checkpoint',
clip: 'Clip',
clip_vision: 'Clip Vision',
controlnet: 'Controlnet',
diffusers: 'Diffusers',
diffusion_models: 'Diffusers',
embeddings: 'embedding',
gligen: 'Gligen',
hypernetworks: 'Hypernetwork',
photomaker: 'Photomaker',
loras: 'LoRA',
style_models: 'Style Model',
unet: 'Unet',
upscale_models: 'Upscale Model',
vae: 'VAE',
vae_approx: 'VAE approx',
}
export const resolveModelType = (type: string) => {
return {
display: display[type],
loader: loader[type],
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />