[New Feature] sub directories support (#135)
* feat: add close all dialog * feat: add new ui toggle setting * feat: add tree display ui * feat: add search and sort * feat: change model data structure * pref: Optimize model data structure * feat: set sub folder by choose
This commit is contained in:
@@ -124,41 +124,43 @@ class ModelManager:
|
||||
if not prefix_path.endswith("/"):
|
||||
prefix_path = f"{prefix_path}/"
|
||||
|
||||
fullname = utils.normalize_path(entry.path).replace(prefix_path, "")
|
||||
basename = os.path.splitext(fullname)[0]
|
||||
extension = os.path.splitext(fullname)[1]
|
||||
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
|
||||
sub_folder = os.path.dirname(relative_path)
|
||||
filename = os.path.basename(relative_path)
|
||||
basename = os.path.splitext(filename)[0]
|
||||
extension = os.path.splitext(filename)[1]
|
||||
|
||||
if extension not in folder_paths.supported_pt_extensions:
|
||||
is_file = entry.is_file()
|
||||
if is_file and extension not in folder_paths.supported_pt_extensions:
|
||||
return None
|
||||
|
||||
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp"
|
||||
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, '.webp')}"
|
||||
|
||||
stat = entry.stat()
|
||||
return {
|
||||
"fullname": fullname,
|
||||
"type": folder if is_file else "folder",
|
||||
"subFolder": sub_folder,
|
||||
"basename": basename,
|
||||
"extension": extension,
|
||||
"type": folder,
|
||||
"pathIndex": path_index,
|
||||
"sizeBytes": stat.st_size,
|
||||
"preview": model_preview,
|
||||
"sizeBytes": stat.st_size if is_file else 0,
|
||||
"preview": model_preview if is_file else None,
|
||||
"createdAt": round(stat.st_ctime_ns / 1000000),
|
||||
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
||||
}
|
||||
|
||||
def get_all_files_entry(directory: str):
|
||||
files = []
|
||||
entries: list[os.DirEntry[str]] = []
|
||||
with os.scandir(directory) as it:
|
||||
for entry in it:
|
||||
# Skip hidden files
|
||||
if not include_hidden_files:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
entries.append(entry)
|
||||
if entry.is_dir():
|
||||
files.extend(get_all_files_entry(entry.path))
|
||||
elif entry.is_file():
|
||||
files.append(entry)
|
||||
return files
|
||||
entries.extend(get_all_files_entry(entry.path))
|
||||
return entries
|
||||
|
||||
for path_index, base_path in enumerate(folders):
|
||||
if not os.path.exists(base_path):
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogDownload from 'components/DialogDownload.vue'
|
||||
import DialogExplorer from 'components/DialogExplorer.vue'
|
||||
import DialogManager from 'components/DialogManager.vue'
|
||||
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
|
||||
import GlobalLoading from 'components/GlobalLoading.vue'
|
||||
@@ -50,7 +51,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
const openManagerDialog = () => {
|
||||
const { cardWidth, gutter, aspect } = config
|
||||
const { cardWidth, gutter, aspect, flat } = config
|
||||
|
||||
if (firstOpenManager.value) {
|
||||
models.refresh(true)
|
||||
@@ -60,7 +61,7 @@ onMounted(() => {
|
||||
dialog.open({
|
||||
key: 'model-manager',
|
||||
title: t('modelManager'),
|
||||
content: DialogManager,
|
||||
content: flat.value ? DialogManager : DialogExplorer,
|
||||
keepAlive: true,
|
||||
headerButtons: [
|
||||
{
|
||||
|
||||
258
src/components/DialogExplorer.vue
Normal file
258
src/components/DialogExplorer.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full select-none flex-col overflow-hidden">
|
||||
<div class="flex w-full gap-4 overflow-hidden px-4 pb-4">
|
||||
<div :class="['flex gap-4 overflow-hidden', showToolbar || 'flex-1']">
|
||||
<div class="flex overflow-hidden">
|
||||
<Button
|
||||
icon="pi pi-arrow-up"
|
||||
text
|
||||
rounded
|
||||
severity="secondary"
|
||||
:disabled="folderPaths.length < 2"
|
||||
@click="handleGoBackParentFolder"
|
||||
></Button>
|
||||
</div>
|
||||
|
||||
<ResponseBreadcrumb
|
||||
v-show="!showToolbar"
|
||||
class="h-10 flex-1"
|
||||
:items="folderPaths"
|
||||
@item-click="(item, index) => openFolder(index, item.name, item.icon)"
|
||||
></ResponseBreadcrumb>
|
||||
</div>
|
||||
|
||||
<div :class="['flex gap-4', showToolbar && 'flex-1']">
|
||||
<ResponseInput
|
||||
v-model="searchContent"
|
||||
:placeholder="$t('searchModels')"
|
||||
></ResponseInput>
|
||||
|
||||
<div
|
||||
v-show="showToolbar"
|
||||
class="flex flex-1 items-center justify-end gap-2"
|
||||
>
|
||||
<ResponseSelect
|
||||
v-model="sortOrder"
|
||||
:items="sortOrderOptions"
|
||||
></ResponseSelect>
|
||||
<ResponseSelect
|
||||
v-model="cardSizeFlag"
|
||||
:items="cardSizeOptions"
|
||||
></ResponseSelect>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:icon="`mdi mdi-menu-${showToolbar ? 'close' : 'open'}`"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="toggleToolbar"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="contentContainer" class="relative flex-1 overflow-hidden px-2">
|
||||
<ResponseScroll :items="renderedList" :item-size="itemSize">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
class="grid h-full justify-center"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(auto-fit, ${cardSize.width}px)`,
|
||||
columnGap: `${gutter.x}px`,
|
||||
rowGap: `${gutter.y}px`,
|
||||
}"
|
||||
>
|
||||
<ModelCard
|
||||
:model="rowItem"
|
||||
v-for="rowItem in item.row"
|
||||
:key="genModelKey(rowItem)"
|
||||
:style="{
|
||||
width: `${cardSize.width}px`,
|
||||
height: `${cardSize.height}px`,
|
||||
}"
|
||||
@dblclick="openItem(rowItem)"
|
||||
></ModelCard>
|
||||
<div class="col-span-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
</ResponseScroll>
|
||||
</div>
|
||||
|
||||
<!-- bottom status bar -->
|
||||
<div class="flex justify-between px-4 py-2 text-sm">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import ModelCard from 'components/ModelCard.vue'
|
||||
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
|
||||
import { chunk } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import { genModelKey } from 'utils/model'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gutter = {
|
||||
x: 4,
|
||||
y: 32,
|
||||
}
|
||||
|
||||
const { dataTreeList, folderPaths, findFolder, openFolder, openModelDetail } =
|
||||
useModelExplorer()
|
||||
const { cardSize, cardSizeMap, cardSizeFlag, dialog: settings } = useConfig()
|
||||
|
||||
const showToolbar = ref(false)
|
||||
const toggleToolbar = () => {
|
||||
showToolbar.value = !showToolbar.value
|
||||
}
|
||||
|
||||
const contentContainer = ref<HTMLElement | null>(null)
|
||||
const contentSize = useElementSize(contentContainer)
|
||||
|
||||
const itemSize = computed(() => {
|
||||
return cardSize.value.height + gutter.y
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
const containerWidth = contentSize.width.value + gutter.x
|
||||
const itemWidth = cardSize.value.width + gutter.x
|
||||
|
||||
return Math.floor(containerWidth / itemWidth)
|
||||
})
|
||||
|
||||
const searchContent = ref<string>()
|
||||
|
||||
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 currentDataList = computed(() => {
|
||||
let renderedList = dataTreeList.value
|
||||
for (const folderItem of folderPaths.value) {
|
||||
const found = findFolder(renderedList, folderItem.name)
|
||||
renderedList = found?.children || []
|
||||
}
|
||||
|
||||
if (searchContent.value) {
|
||||
const filterItems: ModelTreeNode[] = []
|
||||
|
||||
const searchList = [...renderedList]
|
||||
|
||||
while (searchList.length) {
|
||||
const item = searchList.pop()!
|
||||
const children = (item as any).children ?? []
|
||||
searchList.push(...children)
|
||||
|
||||
if (
|
||||
item.basename
|
||||
.toLocaleLowerCase()
|
||||
.includes(searchContent.value.toLocaleLowerCase())
|
||||
) {
|
||||
filterItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
renderedList = filterItems
|
||||
}
|
||||
|
||||
if (folderPaths.value.length > 1) {
|
||||
const folderItems: ModelTreeNode[] = []
|
||||
const modelItems: ModelTreeNode[] = []
|
||||
|
||||
for (const item of renderedList) {
|
||||
if (item.type === 'folder') {
|
||||
folderItems.push(item)
|
||||
} else {
|
||||
modelItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
folderItems.sort((a, b) => {
|
||||
return a.basename.localeCompare(b.basename)
|
||||
})
|
||||
modelItems.sort((a, b) => {
|
||||
const sortFieldMap = {
|
||||
name: 'basename',
|
||||
size: 'sizeBytes',
|
||||
created: 'createdAt',
|
||||
modified: 'updatedAt',
|
||||
}
|
||||
const sortField = sortFieldMap[sortOrder.value]
|
||||
|
||||
const aValue = a[sortField]
|
||||
const bValue = b[sortField]
|
||||
|
||||
const result =
|
||||
typeof aValue === 'string'
|
||||
? aValue.localeCompare(bValue)
|
||||
: aValue - bValue
|
||||
|
||||
return result
|
||||
})
|
||||
renderedList = [...folderItems, ...modelItems]
|
||||
}
|
||||
|
||||
return renderedList
|
||||
})
|
||||
|
||||
const renderedList = computed(() => {
|
||||
return chunk(currentDataList.value, cols.value).map((row) => {
|
||||
return { key: row.map((o) => o.basename).join('#'), row }
|
||||
})
|
||||
})
|
||||
|
||||
const cardSizeOptions = computed(() => {
|
||||
const customSize = 'size.custom'
|
||||
|
||||
const customOptionMap = {
|
||||
...cardSizeMap.value,
|
||||
[customSize]: 'custom',
|
||||
}
|
||||
|
||||
return Object.keys(customOptionMap).map((key) => {
|
||||
return {
|
||||
label: t(key),
|
||||
value: key,
|
||||
command: () => {
|
||||
if (key === customSize) {
|
||||
settings.showCardSizeSetting()
|
||||
} else {
|
||||
cardSizeFlag.value = key
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const openItem = (item: ModelTreeNode) => {
|
||||
if (item.type === 'folder') {
|
||||
openFolder(folderPaths.value.length, item.basename)
|
||||
} else {
|
||||
openModelDetail(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoBackParentFolder = () => {
|
||||
folderPaths.value.pop()
|
||||
}
|
||||
</script>
|
||||
@@ -49,7 +49,56 @@
|
||||
v-for="model in item.row"
|
||||
:key="genModelKey(model)"
|
||||
:model="model"
|
||||
></ModelCard>
|
||||
:style="{
|
||||
width: `${cardSize.width}px`,
|
||||
height: `${cardSize.height}px`,
|
||||
}"
|
||||
class="group/card cursor-pointer !p-0"
|
||||
@click="openModelDetail(model)"
|
||||
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
|
||||
>
|
||||
<template #name>
|
||||
<div
|
||||
v-show="showModelName"
|
||||
class="absolute top-0 h-full w-full p-2"
|
||||
>
|
||||
<div class="flex h-full flex-col justify-end text-lg">
|
||||
<div class="line-clamp-3 break-all font-bold text-shadow">
|
||||
{{ model.basename }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<div
|
||||
v-show="showModeAction"
|
||||
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click.stop="addModelNode(model)"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click.stop="copyModelNode(model)"
|
||||
></Button>
|
||||
<Button
|
||||
v-show="model.preview"
|
||||
icon="pi pi-file-import"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click.stop="loadPreviewWorkflow(model)"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ModelCard>
|
||||
<div class="col-span-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,8 +121,9 @@ import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import { configSetting, useConfig } from 'hooks/config'
|
||||
import { useContainerQueries } from 'hooks/container'
|
||||
import { useModels } from 'hooks/model'
|
||||
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||
import { chunk } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
import { Model } from 'types/typings'
|
||||
import { genModelKey } from 'utils/model'
|
||||
@@ -89,7 +139,7 @@ const {
|
||||
dialog: settings,
|
||||
} = useConfig()
|
||||
|
||||
const { data, folders } = useModels()
|
||||
const { data, folders, openModelDetail } = useModels()
|
||||
const { t } = useI18n()
|
||||
|
||||
const toolbarContainer = ref<HTMLElement | null>(null)
|
||||
@@ -165,12 +215,15 @@ const cols = computed(() => {
|
||||
|
||||
const list = computed(() => {
|
||||
const mergedList = Object.values(data.value).flat()
|
||||
const pureModels = mergedList.filter((item) => {
|
||||
return item.type !== 'folder'
|
||||
})
|
||||
|
||||
const filterList = mergedList.filter((model) => {
|
||||
const filterList = pureModels.filter((model) => {
|
||||
const showAllModel = currentType.value === allType
|
||||
|
||||
const matchType = showAllModel || model.type === currentType.value
|
||||
const matchName = model.fullname
|
||||
const matchName = model.basename
|
||||
.toLowerCase()
|
||||
.includes(searchContent.value?.toLowerCase() || '')
|
||||
|
||||
@@ -180,7 +233,7 @@ const list = computed(() => {
|
||||
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
||||
switch (sortOrder.value) {
|
||||
case 'name':
|
||||
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
|
||||
sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
|
||||
break
|
||||
case 'size':
|
||||
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
|
||||
@@ -231,4 +284,15 @@ const cardSizeOptions = computed(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const showModelName = computed(() => {
|
||||
return cardSize.value.width > 120 && cardSize.value.height > 160
|
||||
})
|
||||
|
||||
const showModeAction = computed(() => {
|
||||
return cardSize.value.width > 120 && cardSize.value.height > 160
|
||||
})
|
||||
|
||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||
useModelNodeAction()
|
||||
</script>
|
||||
|
||||
@@ -18,12 +18,18 @@
|
||||
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
|
||||
icon="pi pi-plus"
|
||||
@click.stop="addModelNode(model)"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
@click.stop="copyModelNode(model)"
|
||||
></Button>
|
||||
<Button
|
||||
v-show="model.preview"
|
||||
icon="pi pi-file-import"
|
||||
@click.stop="loadPreviewWorkflow"
|
||||
@click.stop="loadPreviewWorkflow(model)"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-pen-to-square"
|
||||
@@ -44,7 +50,7 @@
|
||||
<script setup lang="ts">
|
||||
import ModelContent from 'components/ModelContent.vue'
|
||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||
import { genModelUrl, useModelNodeAction, useModels } from 'hooks/model'
|
||||
import { useRequest } from 'hooks/request'
|
||||
import Button from 'primevue/button'
|
||||
import { BaseModel, Model, WithResolved } from 'types/typings'
|
||||
@@ -59,7 +65,7 @@ const { remove, update } = useModels()
|
||||
|
||||
const editable = ref(false)
|
||||
|
||||
const modelDetailUrl = `/model/${props.model.type}/${props.model.pathIndex}/${props.model.fullname}`
|
||||
const modelDetailUrl = genModelUrl(props.model)
|
||||
const { data: extraInfo } = useRequest(modelDetailUrl, {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -85,7 +91,6 @@ const openModelPage = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
|
||||
props.model,
|
||||
)
|
||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||
useModelNodeAction()
|
||||
</script>
|
||||
|
||||
@@ -7,13 +7,56 @@
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
|
||||
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
|
||||
</ResponseSelect>
|
||||
<div class="flex gap-2 overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden rounded bg-gray-500/30">
|
||||
<div class="flex h-full items-center justify-end">
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap px-2">
|
||||
{{ renderedModelFolder }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-folder" @click="handleSelectFolder"></Button>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="folderSelectVisible"
|
||||
:header="$t('folder')"
|
||||
:auto-z-index="false"
|
||||
:pt:mask:style="{ zIndex }"
|
||||
:pt:root:style="{ height: '50vh', maxWidth: '50vw' }"
|
||||
pt:content:class="flex-1"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<ResponseScroll>
|
||||
<Tree
|
||||
class="h-full"
|
||||
v-model:selection-keys="modelFolder"
|
||||
:value="pathOptions"
|
||||
selectionMode="single"
|
||||
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
|
||||
></Tree>
|
||||
</ResponseScroll>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
:label="$t('cancel')"
|
||||
severity="secondary"
|
||||
@click="handleCancelSelectFolder"
|
||||
></Button>
|
||||
<Button
|
||||
:label="$t('select')"
|
||||
@click="handleConfirmSelectFolder"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<ResponseInput
|
||||
v-model.trim="basename"
|
||||
v-model.trim.valid="basename"
|
||||
class="-mr-2 text-right"
|
||||
update-trigger="blur"
|
||||
:validate="validateBasename"
|
||||
>
|
||||
<template #suffix>
|
||||
<span class="text-base opacity-60">
|
||||
@@ -48,14 +91,30 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import { useModelBaseInfo } from 'hooks/model'
|
||||
import { computed } from 'vue'
|
||||
import { useDialog } from 'hooks/dialog'
|
||||
import { useModelBaseInfo, useModelFolder } from 'hooks/model'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import Button from 'primevue/button'
|
||||
import { usePrimeVue } from 'primevue/config'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Tree from 'primevue/tree'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
|
||||
const { baseInfo, pathIndex, basename, extension, type, modelFolders } =
|
||||
useModelBaseInfo()
|
||||
const { toast } = useToast()
|
||||
|
||||
const {
|
||||
baseInfo,
|
||||
pathIndex,
|
||||
subFolder,
|
||||
basename,
|
||||
extension,
|
||||
type,
|
||||
modelFolders,
|
||||
} = useModelBaseInfo()
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
return Object.keys(modelFolders.value).map((curr) => {
|
||||
@@ -70,25 +129,104 @@ const typeOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
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) {
|
||||
const hiddenKeys = ['fullname', 'pathIndex']
|
||||
const hiddenKeys = ['basename', 'pathIndex']
|
||||
return !hiddenKeys.includes(row.key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const validateBasename = (val: string | undefined) => {
|
||||
if (!val) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
detail: 'basename is required',
|
||||
life: 3000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
const invalidChart = /[\\/:*?"<>|]/
|
||||
if (invalidChart.test(val)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
detail: 'basename is invalid, \\/:*?"<>|',
|
||||
life: 3000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const folderSelectVisible = ref(false)
|
||||
|
||||
const { stack } = useDialog()
|
||||
const { config } = usePrimeVue()
|
||||
const zIndex = computed(() => {
|
||||
const baseZIndex = config.zIndex?.modal ?? 1100
|
||||
return baseZIndex + stack.value.length + 1
|
||||
})
|
||||
|
||||
const handleSelectFolder = () => {
|
||||
if (!type.value) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Please select model type first',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
folderSelectVisible.value = true
|
||||
}
|
||||
|
||||
const { pathOptions } = useModelFolder({ type })
|
||||
|
||||
const selectedModelFolder = ref<string>()
|
||||
|
||||
const modelFolder = computed({
|
||||
get: () => {
|
||||
const folderPath = baseInfo.value.pathIndex.display
|
||||
const selectedKey = selectedModelFolder.value ?? folderPath
|
||||
return { [selectedKey]: true }
|
||||
},
|
||||
set: (val) => {
|
||||
const folderPath = Object.keys(val)[0]
|
||||
selectedModelFolder.value = folderPath
|
||||
},
|
||||
})
|
||||
|
||||
const renderedModelFolder = computed(() => {
|
||||
return baseInfo.value.pathIndex.display
|
||||
})
|
||||
|
||||
const handleCancelSelectFolder = () => {
|
||||
selectedModelFolder.value = undefined
|
||||
folderSelectVisible.value = false
|
||||
}
|
||||
|
||||
const handleConfirmSelectFolder = () => {
|
||||
const folderPath = Object.keys(modelFolder.value)[0]
|
||||
|
||||
const folders = modelFolders.value[type.value]
|
||||
pathIndex.value = folders.findIndex((item) => folderPath.includes(item))
|
||||
if (pathIndex.value < 0) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
detail: 'Folder not found',
|
||||
life: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const prefixPath = folders[pathIndex.value]
|
||||
subFolder.value = folderPath.replace(prefixPath, '')
|
||||
if (subFolder.value.startsWith('/')) {
|
||||
subFolder.value = subFolder.value.replace('/', '')
|
||||
}
|
||||
|
||||
selectedModelFolder.value = undefined
|
||||
folderSelectVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,135 +1,94 @@
|
||||
<template>
|
||||
<div
|
||||
class="group/card relative cursor-pointer select-none"
|
||||
:style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }"
|
||||
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
|
||||
@click.stop="openDetailDialog"
|
||||
ref="container"
|
||||
class="relative h-full select-none rounded-lg hover:bg-gray-500/40"
|
||||
>
|
||||
<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 data-card-main class="flex h-full w-full flex-col">
|
||||
<div data-card-preview class="flex-1 overflow-hidden">
|
||||
<div v-if="model.type === 'folder'" class="h-full w-full">
|
||||
<svg
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<path
|
||||
d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z"
|
||||
fill="#FFA000"
|
||||
></path>
|
||||
<path
|
||||
d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z"
|
||||
fill="#FFCA28"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="h-full w-full p-1 hover:p-0">
|
||||
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="name">
|
||||
<div class="flex justify-center overflow-hidden px-1">
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.basename }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="model.type !== 'folder'"
|
||||
data-draggable-overlay
|
||||
class="absolute left-0 top-0 h-full w-full"
|
||||
draggable="true"
|
||||
@dragend.stop="dragToAddModelNode"
|
||||
@dragend.stop="dragToAddModelNode(model, $event)"
|
||||
></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 v-show="showModelName" 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 font-bold',
|
||||
$lg('text-lg', 'text-2xl'),
|
||||
]"
|
||||
>
|
||||
{{ model.basename }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-0 top-0 w-full">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div
|
||||
v-show="showModelType"
|
||||
class="flex items-center rounded-full bg-black/30 px-3 py-2"
|
||||
>
|
||||
<div :class="['font-bold', $lg('text-xs')]">
|
||||
{{ model.type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="showToolButton"
|
||||
class="opacity-0 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
|
||||
v-if="model.type !== 'folder'"
|
||||
data-mode-type
|
||||
class="pointer-events-none absolute left-2 top-2"
|
||||
:style="{
|
||||
transform: `scale(${typeLabelScale})`,
|
||||
transformOrigin: 'left top',
|
||||
}"
|
||||
>
|
||||
<div class="rounded-full bg-black/50 px-3 py-1">
|
||||
<span>{{ model.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { useContainerQueries } from 'hooks/container'
|
||||
import { useDialog } from 'hooks/dialog'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { useModelNodeAction } from 'hooks/model'
|
||||
import Button from 'primevue/button'
|
||||
import { Model } from 'types/typings'
|
||||
import { genModelKey } from 'utils/model'
|
||||
import { computed } from 'vue'
|
||||
import { BaseModel } from 'types/typings'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
model: Model
|
||||
model: BaseModel
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { cardSize } = useConfig()
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
const openDetailDialog = () => {
|
||||
const basename = props.model.fullname.split('/').pop()!
|
||||
const filename = basename.replace(props.model.extension, '')
|
||||
|
||||
dialog.open({
|
||||
key: genModelKey(props.model),
|
||||
title: filename,
|
||||
content: DialogModelDetail,
|
||||
contentProps: { model: props.model },
|
||||
})
|
||||
}
|
||||
|
||||
const preview = computed(() =>
|
||||
Array.isArray(props.model.preview)
|
||||
? props.model.preview[0]
|
||||
: props.model.preview,
|
||||
)
|
||||
|
||||
const showToolButton = computed(() => {
|
||||
return cardSize.value.width >= 180 && cardSize.value.height >= 240
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
|
||||
const { width } = useElementSize(container)
|
||||
|
||||
const typeLabelScale = computed(() => {
|
||||
return width.value / 200
|
||||
})
|
||||
|
||||
const showModelName = computed(() => {
|
||||
return cardSize.value.width >= 160 && cardSize.value.height >= 120
|
||||
})
|
||||
|
||||
const showModelType = computed(() => {
|
||||
return cardSize.value.width >= 120
|
||||
})
|
||||
|
||||
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||
useModelNodeAction(props.model)
|
||||
|
||||
const { $lg } = useContainerQueries()
|
||||
const { dragToAddModelNode } = useModelNodeAction()
|
||||
</script>
|
||||
|
||||
163
src/components/ResponseBreadcrumb.vue
Normal file
163
src/components/ResponseBreadcrumb.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div ref="container" class="breadcrumb-container">
|
||||
<div v-if="firstItem" class="breadcrumb-item">
|
||||
<span class="breadcrumb-label" @click="firstItem.onClick">
|
||||
<i v-if="firstItem.icon" :class="firstItem.icon"></i>
|
||||
<i v-else class="breadcrumb-name">{{ firstItem.name }}</i>
|
||||
</span>
|
||||
<ResponseSelect
|
||||
v-if="!!firstItem.children?.length"
|
||||
:items="firstItem.children"
|
||||
>
|
||||
<template #target="{ toggle, overlayVisible }">
|
||||
<span class="breadcrumb-split" @click="toggle">
|
||||
<i
|
||||
class="pi pi-angle-right transition-all"
|
||||
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
|
||||
></i>
|
||||
</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
</div>
|
||||
|
||||
<div v-if="!!renderedItems.collapsed.length" class="breadcrumb-item">
|
||||
<ResponseSelect :items="renderedItems.collapsed">
|
||||
<template #target="{ toggle }">
|
||||
<span class="breadcrumb-split" @click="toggle">
|
||||
<i class="pi pi-ellipsis-h"></i>
|
||||
</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(item, index) in renderedItems.tail"
|
||||
:key="`${index}-${item.name}`"
|
||||
class="breadcrumb-item"
|
||||
>
|
||||
<span class="breadcrumb-label" @click="item.onClick">
|
||||
<i v-if="item.icon" :class="item.icon"></i>
|
||||
<i v-else class="breadcrumb-name">{{ item.name }}</i>
|
||||
</span>
|
||||
<ResponseSelect v-if="!!item.children?.length" :items="item.children">
|
||||
<template #target="{ toggle, overlayVisible }">
|
||||
<span class="breadcrumb-split" @click="toggle">
|
||||
<i
|
||||
class="pi pi-angle-right transition-all"
|
||||
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
|
||||
></i>
|
||||
</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import { SelectOptions } from 'types/typings'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string
|
||||
icon?: string
|
||||
onClick?: () => void
|
||||
children?: SelectOptions[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { width } = useElementSize(container)
|
||||
|
||||
const firstItem = computed<BreadcrumbItem | null>(() => {
|
||||
return props.items[0]
|
||||
})
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
const [, ...items] = props.items
|
||||
|
||||
const lastItem = items.pop()
|
||||
items.reverse()
|
||||
|
||||
const separatorWidth = 32
|
||||
const calculateItemWidth = (item: BreadcrumbItem | undefined) => {
|
||||
if (!item) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')!
|
||||
context.font = '16px Arial'
|
||||
|
||||
const text = item.name
|
||||
return context.measureText(text).width + 16 + separatorWidth
|
||||
}
|
||||
|
||||
const firstItemEL = container.value?.querySelector('div')
|
||||
const firstItemWidth = firstItemEL?.getBoundingClientRect().width ?? 0
|
||||
|
||||
const lastItemWidth = calculateItemWidth(lastItem)
|
||||
|
||||
const collapseWidth = separatorWidth
|
||||
|
||||
let totalWidth = firstItemWidth + collapseWidth + lastItemWidth
|
||||
const containerWidth = width.value - 18
|
||||
const collapsed: SelectOptions[] = []
|
||||
const tail: BreadcrumbItem[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemWidth = calculateItemWidth(item)
|
||||
totalWidth += itemWidth
|
||||
|
||||
if (totalWidth < containerWidth) {
|
||||
tail.unshift(item)
|
||||
} else {
|
||||
collapsed.unshift({
|
||||
value: item.name,
|
||||
label: item.name,
|
||||
command: () => {
|
||||
item.onClick?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (lastItem) {
|
||||
tail.push(lastItem)
|
||||
}
|
||||
|
||||
return { collapsed, tail }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-container {
|
||||
@apply flex overflow-hidden rounded-lg bg-gray-500/30 px-2 py-1;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
@apply flex h-full overflow-hidden rounded border border-transparent hover:border-gray-500/30;
|
||||
}
|
||||
|
||||
.breadcrumb-item:nth-of-type(-n + 2) {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.breadcrumb-label {
|
||||
@apply flex h-full min-w-8 items-center overflow-hidden px-2 hover:bg-gray-500/30;
|
||||
}
|
||||
|
||||
.breadcrumb-name {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap not-italic;
|
||||
}
|
||||
|
||||
.breadcrumb-split {
|
||||
@apply flex aspect-square h-full min-w-8 items-center justify-center hover:bg-gray-500/30;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
|
||||
:pt:mask:class="['group', { open: visible }]"
|
||||
:pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
|
||||
pt:content:class="px-0 flex-1"
|
||||
pt:content:class="p-0 flex-1"
|
||||
:base-z-index="1000"
|
||||
:auto-z-index="isNil(zIndex)"
|
||||
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="innerValue"
|
||||
v-model="inputValue"
|
||||
class="flex-1 border-none bg-transparent text-base outline-none"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
@@ -47,14 +47,23 @@ interface Props {
|
||||
placeholder?: string
|
||||
allowClear?: boolean
|
||||
updateTrigger?: string
|
||||
validate?: (value: string | undefined) => boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const [content, modifiers] = defineModel<string, 'trim'>()
|
||||
const [content, modifiers] = defineModel<string, 'trim' | 'valid'>()
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
const innerValue = ref(content)
|
||||
const innerValue = ref<string>()
|
||||
const inputValue = computed({
|
||||
get: () => {
|
||||
return innerValue.value ?? content.value
|
||||
},
|
||||
set: (val) => {
|
||||
innerValue.value = val
|
||||
},
|
||||
})
|
||||
const trigger = computed(() => props.updateTrigger ?? 'change')
|
||||
const updateContent = () => {
|
||||
let value = innerValue.value
|
||||
@@ -63,6 +72,16 @@ const updateContent = () => {
|
||||
value = innerValue.value?.trim()
|
||||
}
|
||||
|
||||
if (modifiers.valid) {
|
||||
const isValid = props.validate?.(value) ?? true
|
||||
console.log({ isValid, value })
|
||||
if (!isValid) {
|
||||
innerValue.value = content.value
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
innerValue.value = undefined
|
||||
content.value = value
|
||||
inputRef.value.value = value
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="group/scroll relative overflow-hidden">
|
||||
<div class="group/scroll relative h-full overflow-hidden">
|
||||
<div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
|
||||
<div ref="content">
|
||||
<slot name="default">
|
||||
@@ -40,13 +40,13 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
<script setup lang="ts" generic="T extends { key: string }">
|
||||
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
|
||||
import { clamp } from 'lodash'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface ScrollAreaProps {
|
||||
items?: (T & { key: string })[]
|
||||
items?: T[]
|
||||
itemSize?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<slot
|
||||
v-if="type === 'drop'"
|
||||
name="target"
|
||||
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
|
||||
v-bind="{
|
||||
toggle,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
currentLabel,
|
||||
current,
|
||||
overlayVisible,
|
||||
}"
|
||||
>
|
||||
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
|
||||
<Button
|
||||
@@ -197,6 +204,10 @@ const toggle = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const overlayVisible = computed(() => {
|
||||
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
|
||||
})
|
||||
|
||||
// Select Button Type
|
||||
const scrollArea = ref<HTMLElement | null>(null)
|
||||
const contentArea = ref()
|
||||
|
||||
@@ -24,6 +24,8 @@ export const useConfig = defineStore('config', (store) => {
|
||||
window.removeEventListener('resize', checkDeviceType)
|
||||
})
|
||||
|
||||
const flatLayout = ref(false)
|
||||
|
||||
const defaultCardSizeMap = readonly({
|
||||
'size.extraLarge': '240x320',
|
||||
'size.large': '180x240',
|
||||
@@ -64,6 +66,7 @@ export const useConfig = defineStore('config', (store) => {
|
||||
})
|
||||
},
|
||||
},
|
||||
flat: flatLayout,
|
||||
}
|
||||
|
||||
watch(cardSizeFlag, (val) => {
|
||||
@@ -172,6 +175,18 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
|
||||
},
|
||||
})
|
||||
|
||||
app.ui?.settings.addSetting({
|
||||
id: 'ModelManager.UI.Flat',
|
||||
category: [t('modelManager'), t('setting.ui'), 'Flat'],
|
||||
name: t('setting.useFlatUI'),
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
onChange(value) {
|
||||
store.dialog.closeAll()
|
||||
store.config.flat.value = value
|
||||
},
|
||||
})
|
||||
|
||||
// Scan information
|
||||
app.ui?.settings.addSetting({
|
||||
id: 'ModelManager.ScanFiles.Full',
|
||||
|
||||
@@ -63,7 +63,11 @@ export const useDialog = defineStore('dialog', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { stack, open, close, rise }
|
||||
const closeAll = () => {
|
||||
stack.value = []
|
||||
}
|
||||
|
||||
return { stack, open, close, closeAll, rise }
|
||||
})
|
||||
|
||||
declare module 'hooks/store' {
|
||||
|
||||
147
src/hooks/explorer.ts
Normal file
147
src/hooks/explorer.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { genModelFullName, useModels } from 'hooks/model'
|
||||
import { cloneDeep, filter, find } from 'lodash'
|
||||
import { BaseModel, Model, SelectOptions } from 'types/typings'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
export interface FolderPathItem {
|
||||
name: string
|
||||
icon?: string
|
||||
onClick: () => void
|
||||
children: SelectOptions[]
|
||||
}
|
||||
|
||||
export type ModelFolder = BaseModel & {
|
||||
type: 'folder'
|
||||
children: ModelTreeNode[]
|
||||
}
|
||||
|
||||
export type ModelItem = Model
|
||||
|
||||
export type ModelTreeNode = BaseModel & {
|
||||
children?: ModelTreeNode[]
|
||||
}
|
||||
|
||||
export type TreeItemNode = ModelTreeNode & {
|
||||
onDbClick: () => void
|
||||
onContextMenu: () => void
|
||||
}
|
||||
|
||||
export const useModelExplorer = () => {
|
||||
const { data, folders, ...modelRest } = useModels()
|
||||
|
||||
const folderPaths = ref<FolderPathItem[]>([])
|
||||
|
||||
const genFolderItem = (basename: string, subFolder: string): ModelFolder => {
|
||||
return {
|
||||
id: basename,
|
||||
basename: basename,
|
||||
subFolder: subFolder,
|
||||
pathIndex: 0,
|
||||
sizeBytes: 0,
|
||||
extension: '',
|
||||
description: '',
|
||||
metadata: {},
|
||||
preview: '',
|
||||
type: 'folder',
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
const dataTreeList = computed<ModelTreeNode[]>(() => {
|
||||
const rootChildren: ModelTreeNode[] = []
|
||||
|
||||
for (const folder in folders.value) {
|
||||
if (Object.prototype.hasOwnProperty.call(folders.value, folder)) {
|
||||
const folderItem = genFolderItem(folder, '')
|
||||
|
||||
const folderModels = cloneDeep(data.value[folder]) ?? []
|
||||
|
||||
const pathMap: Record<string, ModelTreeNode> = Object.fromEntries(
|
||||
folderModels.map((item) => [
|
||||
`${item.pathIndex}-${genModelFullName(item)}`,
|
||||
item,
|
||||
]),
|
||||
)
|
||||
|
||||
for (const item of folderModels) {
|
||||
const key = genModelFullName(item)
|
||||
const parentKey = key.split('/').slice(0, -1).join('/')
|
||||
|
||||
if (parentKey === '') {
|
||||
folderItem.children.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const parentItem = pathMap[`${item.pathIndex}-${parentKey}`]
|
||||
if (parentItem) {
|
||||
parentItem.children ??= []
|
||||
parentItem.children.push(item)
|
||||
}
|
||||
}
|
||||
rootChildren.push(folderItem)
|
||||
}
|
||||
}
|
||||
|
||||
const root: ModelTreeNode = genFolderItem('root', '')
|
||||
root.children = rootChildren
|
||||
return [root]
|
||||
})
|
||||
|
||||
function findFolder(list: ModelTreeNode[], name: string) {
|
||||
return find(list, { type: 'folder', basename: name }) as
|
||||
| ModelFolder
|
||||
| undefined
|
||||
}
|
||||
|
||||
function findFolders(list: ModelTreeNode[]) {
|
||||
return filter(list, { type: 'folder' }) as ModelFolder[]
|
||||
}
|
||||
|
||||
async function openFolder(level: number, name: string, icon?: string) {
|
||||
if (folderPaths.value.length >= level) {
|
||||
folderPaths.value.splice(level)
|
||||
}
|
||||
|
||||
let currentLevel = dataTreeList.value
|
||||
for (const folderItem of folderPaths.value) {
|
||||
const found = findFolder(currentLevel, folderItem.name)
|
||||
currentLevel = found?.children || []
|
||||
}
|
||||
|
||||
const folderItem = findFolder(currentLevel, name)
|
||||
const folderItemChildren = folderItem?.children ?? []
|
||||
const subFolders = findFolders(folderItemChildren)
|
||||
|
||||
folderPaths.value.push({
|
||||
name,
|
||||
icon,
|
||||
onClick: () => {
|
||||
openFolder(level, name, icon)
|
||||
},
|
||||
children: subFolders.map((item) => {
|
||||
const name = item.basename
|
||||
return {
|
||||
value: name,
|
||||
label: name,
|
||||
command: () => openFolder(level + 1, name),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (Object.keys(folders.value).length > 0 && folderPaths.value.length < 2) {
|
||||
openFolder(0, 'root', 'pi pi-desktop')
|
||||
}
|
||||
}, {})
|
||||
|
||||
return {
|
||||
folders,
|
||||
folderPaths,
|
||||
dataTreeList,
|
||||
...modelRest,
|
||||
findFolder: findFolder,
|
||||
findFolders: findFolders,
|
||||
openFolder: openFolder,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { useMarkdown } from 'hooks/markdown'
|
||||
import { request } from 'hooks/request'
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { castArray, cloneDeep } from 'lodash'
|
||||
import { TreeNode } from 'primevue/treenode'
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
|
||||
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
|
||||
@@ -12,11 +14,14 @@ import { genModelKey, resolveModelTypeLoader } from 'utils/model'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
InjectionKey,
|
||||
type InjectionKey,
|
||||
MaybeRefOrGetter,
|
||||
onMounted,
|
||||
provide,
|
||||
type Ref,
|
||||
ref,
|
||||
toRaw,
|
||||
toValue,
|
||||
unref,
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -24,7 +29,20 @@ import { configSetting } from './config'
|
||||
|
||||
type ModelFolder = Record<string, string[]>
|
||||
|
||||
const modelFolderProvideKey = Symbol('modelFolder')
|
||||
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
|
||||
Ref<ModelFolder>
|
||||
>
|
||||
|
||||
export const genModelFullName = (model: BaseModel) => {
|
||||
return [model.subFolder, `${model.basename}${model.extension}`]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
export const genModelUrl = (model: BaseModel) => {
|
||||
const fullname = genModelFullName(model)
|
||||
return `/model/${model.type}/${model.pathIndex}/${fullname}`
|
||||
}
|
||||
|
||||
export const useModels = defineStore('models', (store) => {
|
||||
const { toast, confirm } = useToast()
|
||||
@@ -32,6 +50,7 @@ export const useModels = defineStore('models', (store) => {
|
||||
const loading = useLoading()
|
||||
|
||||
const folders = ref<ModelFolder>({})
|
||||
|
||||
const refreshFolders = async () => {
|
||||
return request('/models').then((resData) => {
|
||||
folders.value = resData
|
||||
@@ -65,7 +84,7 @@ export const useModels = defineStore('models', (store) => {
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean) ?? []
|
||||
return forceRefresh.then(() =>
|
||||
await forceRefresh.then(() =>
|
||||
Promise.allSettled(
|
||||
Object.keys(folders.value)
|
||||
.filter((folder) => !customBlackList.includes(folder))
|
||||
@@ -102,13 +121,13 @@ export const useModels = defineStore('models', (store) => {
|
||||
|
||||
// Check current name and pathIndex
|
||||
if (
|
||||
model.fullname !== data.fullname ||
|
||||
model.subFolder !== data.subFolder ||
|
||||
model.pathIndex !== data.pathIndex
|
||||
) {
|
||||
oldKey = genModelKey(model)
|
||||
updateData.set('type', data.type)
|
||||
updateData.set('pathIndex', data.pathIndex.toString())
|
||||
updateData.set('fullname', data.fullname)
|
||||
updateData.set('fullname', genModelFullName(data as BaseModel))
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
@@ -117,7 +136,8 @@ export const useModels = defineStore('models', (store) => {
|
||||
}
|
||||
|
||||
loading.show()
|
||||
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
||||
|
||||
await request(genModelUrl(model), {
|
||||
method: 'PUT',
|
||||
body: updateData,
|
||||
})
|
||||
@@ -160,14 +180,14 @@ export const useModels = defineStore('models', (store) => {
|
||||
accept: () => {
|
||||
const dialogKey = genModelKey(model)
|
||||
loading.show()
|
||||
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
||||
request(genModelUrl(model), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: `${model.fullname} Deleted`,
|
||||
detail: `${model.basename} Deleted`,
|
||||
life: 2000,
|
||||
})
|
||||
store.dialog.close({ key: dialogKey })
|
||||
@@ -195,12 +215,24 @@ export const useModels = defineStore('models', (store) => {
|
||||
})
|
||||
}
|
||||
|
||||
function openModelDetail(model: BaseModel) {
|
||||
const filename = model.basename.replace(model.extension, '')
|
||||
|
||||
store.dialog.open({
|
||||
key: genModelKey(model),
|
||||
title: filename,
|
||||
content: DialogModelDetail,
|
||||
contentProps: { model: model },
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
folders: folders,
|
||||
data: models,
|
||||
refresh: refreshAllModels,
|
||||
remove: deleteModel,
|
||||
update: updateModel,
|
||||
openModelDetail: openModelDetail,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -267,9 +299,9 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
|
||||
>
|
||||
|
||||
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
||||
const { formData: model, modelData } = formInstance
|
||||
const { formData: model } = formInstance
|
||||
|
||||
const provideModelFolders = inject<any>(modelFolderProvideKey)
|
||||
const provideModelFolders = inject(modelFolderProvideKey)
|
||||
const modelFolders = computed<ModelFolder>(() => {
|
||||
return provideModelFolders?.value ?? {}
|
||||
})
|
||||
@@ -292,16 +324,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
||||
},
|
||||
})
|
||||
|
||||
const subFolder = computed({
|
||||
get: () => {
|
||||
return model.value.subFolder
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.subFolder = val
|
||||
},
|
||||
})
|
||||
|
||||
const extension = computed(() => {
|
||||
return model.value.extension
|
||||
})
|
||||
|
||||
const basename = computed({
|
||||
get: () => {
|
||||
return model.value.fullname.replace(model.value.extension, '')
|
||||
return model.value.basename
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.fullname = `${val ?? ''}${model.value.extension}`
|
||||
model.value.basename = val
|
||||
},
|
||||
})
|
||||
|
||||
@@ -321,22 +362,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
||||
{
|
||||
key: 'type',
|
||||
formatter: () =>
|
||||
modelData.value.type in modelFolders.value
|
||||
? modelData.value.type
|
||||
: undefined,
|
||||
model.value.type in modelFolders.value ? model.value.type : undefined,
|
||||
},
|
||||
{
|
||||
key: 'pathIndex',
|
||||
formatter: () => {
|
||||
const modelType = modelData.value.type
|
||||
const pathIndex = modelData.value.pathIndex
|
||||
const modelType = model.value.type
|
||||
const pathIndex = model.value.pathIndex
|
||||
if (!modelType) {
|
||||
return undefined
|
||||
}
|
||||
const folders = modelFolders.value[modelType] ?? []
|
||||
return `${folders[pathIndex]}`
|
||||
return [`${folders[pathIndex]}`, model.value.subFolder]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'fullname',
|
||||
formatter: (val) => val,
|
||||
key: 'basename',
|
||||
formatter: (val) => `${val}${model.value.extension}`,
|
||||
},
|
||||
{
|
||||
key: 'sizeBytes',
|
||||
@@ -371,6 +415,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
||||
baseInfo,
|
||||
basename,
|
||||
extension,
|
||||
subFolder,
|
||||
pathIndex,
|
||||
modelFolders,
|
||||
}
|
||||
@@ -384,6 +429,74 @@ export const useModelBaseInfo = () => {
|
||||
return inject(baseInfoKey)!
|
||||
}
|
||||
|
||||
export const useModelFolder = (
|
||||
option: {
|
||||
type?: MaybeRefOrGetter<string>
|
||||
} = {},
|
||||
) => {
|
||||
const { data: models, folders: modelFolders } = useModels()
|
||||
|
||||
const pathOptions = computed(() => {
|
||||
const type = toValue(option.type)
|
||||
|
||||
if (!type) {
|
||||
return []
|
||||
}
|
||||
|
||||
const folderItems = cloneDeep(models.value[type]) ?? []
|
||||
const pureFolders = folderItems.filter((item) => item.type === 'folder')
|
||||
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
|
||||
|
||||
const folders = modelFolders.value[type] ?? []
|
||||
|
||||
const root: TreeNode[] = []
|
||||
|
||||
for (const [index, folder] of folders.entries()) {
|
||||
const pathIndexItem: TreeNode = {
|
||||
key: folder,
|
||||
label: folder,
|
||||
children: [],
|
||||
}
|
||||
|
||||
const items = pureFolders
|
||||
.filter((item) => item.pathIndex === index)
|
||||
.map((item) => {
|
||||
const node: TreeNode = {
|
||||
key: `${folder}/${genModelFullName(item)}`,
|
||||
label: item.basename,
|
||||
data: item,
|
||||
}
|
||||
return node
|
||||
})
|
||||
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
|
||||
|
||||
for (const item of items) {
|
||||
const key = item.key
|
||||
const parentKey = key.split('/').slice(0, -1).join('/')
|
||||
|
||||
if (parentKey === folder) {
|
||||
pathIndexItem.children!.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const parentItem = itemMap[parentKey]
|
||||
if (parentItem) {
|
||||
parentItem.children ??= []
|
||||
parentItem.children.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
root.push(pathIndexItem)
|
||||
}
|
||||
|
||||
return root
|
||||
})
|
||||
|
||||
return {
|
||||
pathOptions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable preview image.
|
||||
*
|
||||
@@ -552,11 +665,11 @@ export const useModelMetadata = () => {
|
||||
return inject(metadataKey)!
|
||||
}
|
||||
|
||||
export const useModelNodeAction = (model: BaseModel) => {
|
||||
export const useModelNodeAction = () => {
|
||||
const { t } = useI18n()
|
||||
const { toast, wrapperToastError } = useToast()
|
||||
|
||||
const createNode = (options: Record<string, any> = {}) => {
|
||||
const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
|
||||
const nodeType = resolveModelTypeLoader(model.type)
|
||||
if (!nodeType) {
|
||||
throw new Error(t('unSupportedModelType', [model.type]))
|
||||
@@ -565,50 +678,52 @@ export const useModelNodeAction = (model: BaseModel) => {
|
||||
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
|
||||
node.widgets[widgetIndex].value = genModelFullName(model)
|
||||
}
|
||||
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
|
||||
const dragToAddModelNode = wrapperToastError(
|
||||
(model: BaseModel, 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,
|
||||
)
|
||||
})
|
||||
ModelGrid.dragAddModel(
|
||||
event,
|
||||
model.type,
|
||||
genModelFullName(model),
|
||||
removeEmbeddingExtension,
|
||||
strictDragToAdd,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const addModelNode = wrapperToastError(() => {
|
||||
const addModelNode = wrapperToastError((model: BaseModel) => {
|
||||
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 })
|
||||
const node = createNode(model, { pos })
|
||||
app.graph.add(node)
|
||||
app.canvas.selectNode(node)
|
||||
})
|
||||
|
||||
const copyModelNode = wrapperToastError(() => {
|
||||
const node = createNode()
|
||||
const copyModelNode = wrapperToastError((model: BaseModel) => {
|
||||
const node = createNode(model)
|
||||
app.canvas.copyToClipboard([node])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@@ -618,13 +733,13 @@ export const useModelNodeAction = (model: BaseModel) => {
|
||||
})
|
||||
})
|
||||
|
||||
const loadPreviewWorkflow = wrapperToastError(async () => {
|
||||
const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
|
||||
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 })
|
||||
const file = new File([data], `${model.basename}.${extension}`, { type })
|
||||
app.handleFile(file)
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const messages = {
|
||||
info: {
|
||||
type: 'Model Type',
|
||||
pathIndex: 'Directory',
|
||||
fullname: 'File Name',
|
||||
basename: 'File Name',
|
||||
sizeBytes: 'File Size',
|
||||
createdAt: 'Created At',
|
||||
updatedAt: 'Updated At',
|
||||
@@ -62,6 +62,7 @@ const messages = {
|
||||
excludeScanTypes: 'Exclude scan types (separate with commas)',
|
||||
ui: 'UI',
|
||||
cardSize: 'Card Size',
|
||||
useFlatUI: 'Flat Layout',
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
@@ -108,7 +109,7 @@ const messages = {
|
||||
info: {
|
||||
type: '类型',
|
||||
pathIndex: '目录',
|
||||
fullname: '文件名',
|
||||
basename: '文件名',
|
||||
sizeBytes: '文件大小',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间',
|
||||
@@ -124,6 +125,7 @@ const messages = {
|
||||
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
|
||||
ui: '外观',
|
||||
cardSize: '卡片尺寸',
|
||||
useFlatUI: '展平布局',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
3
src/types/typings.d.ts
vendored
3
src/types/typings.d.ts
vendored
@@ -3,11 +3,11 @@ export type ContainerPosition = { left: number; top: number }
|
||||
|
||||
export interface BaseModel {
|
||||
id: number | string
|
||||
fullname: string
|
||||
basename: string
|
||||
extension: string
|
||||
sizeBytes: number
|
||||
type: string
|
||||
subFolder: string
|
||||
pathIndex: number
|
||||
preview: string | string[]
|
||||
description: string
|
||||
@@ -17,6 +17,7 @@ export interface BaseModel {
|
||||
export interface Model extends BaseModel {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
children?: Model[]
|
||||
}
|
||||
|
||||
export interface VersionModel extends BaseModel {
|
||||
|
||||
@@ -25,5 +25,5 @@ export const resolveModelTypeLoader = (type: string) => {
|
||||
}
|
||||
|
||||
export const genModelKey = (model: BaseModel) => {
|
||||
return `${model.type}:${model.pathIndex}:${model.fullname}`
|
||||
return `${model.type}:${model.pathIndex}:${model.subFolder}:${model.basename}${model.extension}`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export default {
|
||||
plugins: [
|
||||
plugin(({ addUtilities }) => {
|
||||
addUtilities({
|
||||
'.text-shadow': {
|
||||
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
'.scrollbar-none': {
|
||||
'scrollbar-width': 'none',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user