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

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
}