mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
105 Commits
v1.22.2-su
...
v1.23.2-su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df20a3055 | ||
|
|
c462d356c1 | ||
|
|
6b3d89ee93 | ||
|
|
9bc4f66982 | ||
|
|
6926d449bc | ||
|
|
11b71bb820 | ||
|
|
586314e0da | ||
|
|
5acfe4ad98 | ||
|
|
aefc5eb078 | ||
|
|
cf9af94fac | ||
|
|
ee93e36ee2 | ||
|
|
9f0b22a5d8 | ||
|
|
76d6911ffa | ||
|
|
b8740c6ac3 | ||
|
|
27d33c24de | ||
|
|
9b488da973 | ||
|
|
c3065ff07b | ||
|
|
a5a1f8cf8a | ||
|
|
1aac2d52ac | ||
|
|
d2369c8c49 | ||
|
|
2afd295ad9 | ||
|
|
66fbdad4d2 | ||
|
|
b02408f517 | ||
|
|
2cdf547fdd | ||
|
|
d755210d03 | ||
|
|
6e89b196c2 | ||
|
|
97faee8879 | ||
|
|
7d568e13e5 | ||
|
|
c57c391659 | ||
|
|
cb91c3770c | ||
|
|
b0fc8efa6b | ||
|
|
842ec58511 | ||
|
|
060540ae80 | ||
|
|
962834e19c | ||
|
|
d018a69356 | ||
|
|
c84218d6bb | ||
|
|
6f9c481b38 | ||
|
|
bdc1ac1004 | ||
|
|
12e1508203 | ||
|
|
98f5216ddf | ||
|
|
b2550f6351 | ||
|
|
b3b0b95646 | ||
|
|
4ca5a92108 | ||
|
|
0a40c11b7d | ||
|
|
9d4537e803 | ||
|
|
4388cbe4a4 | ||
|
|
518faebf69 | ||
|
|
5685cb6748 | ||
|
|
fc191a1e03 | ||
|
|
f73be5d72a | ||
|
|
c76635ce7f | ||
|
|
20833e5090 | ||
|
|
359e9288ec | ||
|
|
4232e0503b | ||
|
|
a3615b3824 | ||
|
|
0fe0519531 | ||
|
|
2cd315a2bf | ||
|
|
7623711b40 | ||
|
|
dba8716fce | ||
|
|
15a2b37c93 | ||
|
|
bbd1ca234d | ||
|
|
b0fc736260 | ||
|
|
b65440e1c2 | ||
|
|
6918aa830b | ||
|
|
a5d0bc3198 | ||
|
|
f41ae1d408 | ||
|
|
1300a1351b | ||
|
|
5129cfa5a7 | ||
|
|
99c7ecfa82 | ||
|
|
77968fed6d | ||
|
|
09d17fec14 | ||
|
|
bff802eeeb | ||
|
|
71bbca613f | ||
|
|
6f8a91b0c1 | ||
|
|
f95f014fde | ||
|
|
d88a227e7c | ||
|
|
0dd308d885 | ||
|
|
31ab027da8 | ||
|
|
9620f833aa | ||
|
|
b3042d346a | ||
|
|
e17ca7ce71 | ||
|
|
77d2cae301 | ||
|
|
164a4c4c25 | ||
|
|
47145ce4b8 | ||
|
|
6cf77a9814 | ||
|
|
886e4908d4 | ||
|
|
24cbc41832 | ||
|
|
a80a939324 | ||
|
|
8e2d7cabba | ||
|
|
e8dd26ff59 | ||
|
|
3a1bd1829a | ||
|
|
2f9dcd1669 | ||
|
|
e23547dd5a | ||
|
|
f0f40bc39b | ||
|
|
4b32786ef5 | ||
|
|
9942b17388 | ||
|
|
b99214bf5e | ||
|
|
2ef760c599 | ||
|
|
429ab6c365 | ||
|
|
b7693ae9f5 | ||
|
|
ebedf1074d | ||
|
|
0832347f47 | ||
|
|
c745af0f25 | ||
|
|
8c05266b83 | ||
|
|
fa14ec52f4 |
10
.github/workflows/test-ui.yaml
vendored
10
.github/workflows/test-ui.yaml
vendored
@@ -46,8 +46,8 @@ jobs:
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache setup
|
||||
uses: actions/cache@v3
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
@@ -62,9 +62,13 @@ jobs:
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
steps:
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.22.2-sub.9",
|
||||
"version": "1.23.2-sub.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.22.2-sub.9",
|
||||
"version": "1.23.2-sub.12",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.14",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.16",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -948,9 +948,9 @@
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.16.0-sub.14",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0-sub.14.tgz",
|
||||
"integrity": "sha512-Krh1wXm497bkMdwaXwyh+qKGDQ4g5AEuxGxJgTR5lKfmI2JKLzNUSN0W9/ixhtzhKSrui2o8bZaTfY67Azy8DA==",
|
||||
"version": "0.16.0-sub.16",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0-sub.16.tgz",
|
||||
"integrity": "sha512-r0Uya3nTG0btM4vN4faLVCTFjp/kcWGpBqyGBEBXkVvX+SZORqTVL+gMR1LdUxScuwC25w8Gbbu3f+5wC3JFkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.2-sub.9",
|
||||
"version": "1.23.2-sub.12",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -76,7 +76,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.14",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.16",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -71,8 +71,15 @@ useEventListener(document, 'keydown', (event) => {
|
||||
.subgraph-breadcrumb {
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
color: #d26565;
|
||||
user-select: none;
|
||||
text-shadow:
|
||||
1px 1px 0 #000,
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
0 0 0.375rem #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<template>
|
||||
<hr
|
||||
<div
|
||||
:class="{
|
||||
'm-0': true,
|
||||
'border-t': orientation === 'horizontal',
|
||||
'border-l': orientation === 'vertical',
|
||||
'h-full': orientation === 'vertical',
|
||||
'w-full': orientation === 'horizontal'
|
||||
'content-divider': true,
|
||||
'content-divider--horizontal': orientation === 'horizontal',
|
||||
'content-divider--vertical': orientation === 'vertical'
|
||||
}"
|
||||
:style="{
|
||||
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
|
||||
borderWidth: `${width}px !important`
|
||||
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -29,3 +26,25 @@ const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-divider {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-divider--horizontal {
|
||||
width: 100%;
|
||||
height: v-bind('width + "px"');
|
||||
}
|
||||
|
||||
.content-divider--vertical {
|
||||
height: 100%;
|
||||
width: v-bind('width + "px"');
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,12 +92,21 @@ whenever(
|
||||
const updateItemSize = () => {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
|
||||
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
itemHeight.value = firstItem.clientHeight
|
||||
}
|
||||
if (itemWidth.value !== firstItem.clientWidth) {
|
||||
itemWidth.value = firstItem.clientWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
})
|
||||
|
||||
@@ -50,4 +50,17 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
title="Missing Node Types"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
option-label="label"
|
||||
@@ -31,6 +32,12 @@
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<PackInstallButton
|
||||
:disabled="isLoading || !!error || missingNodePacks.length === 0"
|
||||
:node-packs="missingNodePacks"
|
||||
variant="black"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +48,9 @@ import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
@@ -52,6 +62,10 @@ const props = defineProps<{
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
|
||||
95
src/components/dialog/content/MissingCoreNodesMessage.vue
Normal file
95
src/components/dialog/content/MissingCoreNodesMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<Message
|
||||
v-if="hasMissingCoreNodes"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
class="my-2 mx-2"
|
||||
:pt="{
|
||||
root: { class: 'flex-col' },
|
||||
text: { class: 'flex-1' }
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
{{
|
||||
currentComfyUIVersion
|
||||
? $t('loadWorkflowWarning.outdatedVersion', {
|
||||
version: currentComfyUIVersion
|
||||
})
|
||||
: $t('loadWorkflowWarning.outdatedVersionGeneric')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="[version, nodes] in sortedMissingCoreNodes"
|
||||
:key="version"
|
||||
class="ml-4"
|
||||
>
|
||||
<div
|
||||
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
|
||||
>
|
||||
{{
|
||||
$t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || 'unknown'
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
|
||||
{{ getUniqueNodeNames(nodes).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
const currentComfyUIVersion = ref<string | null>(null)
|
||||
whenever(
|
||||
hasMissingCoreNodes,
|
||||
async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
currentComfyUIVersion.value =
|
||||
systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
|
||||
return nodes
|
||||
.reduce<string[]>((acc, node) => {
|
||||
if (node.type && !acc.includes(node.type)) {
|
||||
acc.push(node.type)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort()
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
|
||||
class="h-full flex flex-col mx-auto overflow-hidden"
|
||||
:aria-label="$t('manager.title')"
|
||||
>
|
||||
<ContentDivider :width="0.3" />
|
||||
<Button
|
||||
v-if="isSmallScreen"
|
||||
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
||||
text
|
||||
severity="secondary"
|
||||
filled
|
||||
class="absolute top-1/2 -translate-y-1/2 z-10"
|
||||
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
|
||||
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
@@ -18,20 +20,19 @@
|
||||
:tabs="tabs"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto pr-80"
|
||||
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
|
||||
:class="{
|
||||
'transition-all duration-300': isSmallScreen,
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
'transition-all duration-300': isSmallScreen
|
||||
}"
|
||||
>
|
||||
<div class="px-6 pt-6 flex flex-col h-full">
|
||||
<div class="px-6 flex flex-col h-full">
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
:sort-options="sortOptions"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
@@ -58,7 +59,7 @@
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:buffer-rows="4"
|
||||
:grid-style="GRID_STYLE"
|
||||
@approach-end="onApproachEnd"
|
||||
>
|
||||
@@ -76,9 +77,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
|
||||
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
|
||||
<ContentDivider orientation="vertical" :width="0.2" />
|
||||
<div class="flex-1 flex flex-col isolate">
|
||||
<div class="w-full flex flex-col isolate">
|
||||
<InfoPanel
|
||||
v-if="!hasMultipleSelections && selectedNodePack"
|
||||
:node-pack="selectedNodePack"
|
||||
@@ -218,10 +219,6 @@ const {
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
whenever(selectedTab, () => {
|
||||
pageNumber.value = 0
|
||||
})
|
||||
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
@@ -467,9 +464,10 @@ let gridContainer: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
gridContainer = document.getElementById('results-grid')
|
||||
})
|
||||
watch(searchQuery, () => {
|
||||
watch([searchQuery, selectedTab], () => {
|
||||
gridContainer ??= document.getElementById('results-grid')
|
||||
if (gridContainer) {
|
||||
pageNumber.value = 0
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
</div>
|
||||
<ContentDivider :width="0.3" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<aside
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
|
||||
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ScrollPanel class="w-80 mt-7">
|
||||
<ScrollPanel class="flex-1">
|
||||
<Listbox
|
||||
v-model="selectedTab"
|
||||
:options="tabs"
|
||||
@@ -10,20 +10,20 @@
|
||||
list-style="max-height:unset"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-5' },
|
||||
option: { class: 'px-8 py-3 text-lg rounded-xl' },
|
||||
list: { class: 'p-3 gap-2' },
|
||||
option: { class: 'px-4 py-2 text-lg rounded-lg' },
|
||||
optionGroup: { class: 'p-0 text-left text-inherit' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="text-left flex items-center">
|
||||
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
|
||||
<span class="text-lg">{{ slotProps.option.label }}</span>
|
||||
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
|
||||
<span class="text-sm">{{ slotProps.option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<ContentDivider orientation="vertical" />
|
||||
<ContentDivider orientation="vertical" :width="0.3" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="m-0 p-0 rounded-lg border-neutral-700"
|
||||
:class="{
|
||||
'w-full': fullWidth,
|
||||
'w-min-content': !fullWidth
|
||||
}"
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3">
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
@@ -27,12 +29,14 @@ import Button from 'primevue/button'
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
"
|
||||
severity="secondary"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@@ -27,8 +29,10 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<template v-if="nodePack">
|
||||
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
|
||||
<div class="flex flex-col h-full z-40 overflow-hidden relative">
|
||||
<div class="top-0 z-10 px-6 pt-6 w-full">
|
||||
<InfoPanelHeader :node-packs="[nodePack]" />
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
|
||||
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -51,7 +51,11 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
|
||||
if (!pack.latest_version?.version) return []
|
||||
const nodeDefs = await getNodeDefs.call({
|
||||
packId: pack.id,
|
||||
version: pack.latest_version?.version
|
||||
version: pack.latest_version?.version,
|
||||
// Fetch all nodes.
|
||||
// TODO: Render all nodes previews and handle pagination.
|
||||
// For determining length, use the `totalNumberOfPages` field of response
|
||||
limit: 8192
|
||||
})
|
||||
return nodeDefs?.comfy_nodes ?? []
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
<template>
|
||||
<div class="relative w-full p-6">
|
||||
<div class="flex items-center w-full">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-5/12 rounded-2xl'
|
||||
<div class="h-12 flex items-center gap-1 justify-between">
|
||||
<div class="flex items-center w-5/12">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
variant="black"
|
||||
:disabled="isLoading || !!error"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -55,7 +64,9 @@ import AutoComplete, {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import {
|
||||
type SearchOption,
|
||||
SortableAlgoliaField
|
||||
@@ -71,6 +82,7 @@ const { searchResults, sortOptions } = defineProps<{
|
||||
searchResults?: components['schemas']['Node'][]
|
||||
suggestions?: QuerySuggestion[]
|
||||
sortOptions?: SortableField[]
|
||||
isMissingTab?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
@@ -81,6 +93,9 @@ const sortField = defineModel<string>('sortField', {
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
)
|
||||
|
||||
@@ -3,15 +3,29 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
interface ImageUploadFormFields {
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
type: ResultItemType
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean,
|
||||
formFields: Partial<ImageUploadFormFields> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -36,6 +50,11 @@ interface ImageUploadOptions {
|
||||
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
||||
*/
|
||||
accept?: string
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
folder?: ResultItemType
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +72,9 @@ export const useNodeImageUpload = (
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
const path = await uploadFile(file, isPastedFile(file), {
|
||||
type: options.folder
|
||||
})
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
|
||||
@@ -18,6 +19,16 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
const startFetchInstalled = async () => {
|
||||
await comfyManagerStore.refreshInstalledList()
|
||||
await startFetch()
|
||||
}
|
||||
|
||||
// When installedPackIds changes, we need to update the nodePacks
|
||||
whenever(installedPackIds, async () => {
|
||||
await startFetch()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -27,7 +38,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
isLoading,
|
||||
isReady,
|
||||
installedPacks: nodePacks,
|
||||
startFetchInstalled: startFetch,
|
||||
startFetchInstalled,
|
||||
filterInstalledPack
|
||||
}
|
||||
}
|
||||
|
||||
76
src/composables/nodePack/useMissingNodes.ts
Normal file
76
src/composables/nodePack/useMissingNodes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { NodeProperty } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
import { groupBy } from 'lodash'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
/**
|
||||
* Composable to find missing NodePacks from workflow
|
||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
||||
* Automatically fetches workflow pack data when initialized
|
||||
*/
|
||||
export const useMissingNodes = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||
useWorkflowPacks()
|
||||
|
||||
// Same filtering logic as ManagerDialogContent.vue
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
// Filter only uninstalled packs from workflow packs
|
||||
const missingNodePacks = computed(() => {
|
||||
if (!workflowPacks.value.length) return []
|
||||
return filterMissingPacks(workflowPacks.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
|
||||
* @param packId - The id of the pack to check
|
||||
* @returns True if the pack is the comfy-core pack, false otherwise
|
||||
*/
|
||||
const isCorePack = (packId: NodeProperty) => {
|
||||
return packId === 'comfy-core'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a missing core node
|
||||
* A missing core node is a node that is in the workflow and originates from
|
||||
* the comfy-core pack (pre-installed) but not registered in the node def
|
||||
* store (the node def was not found on the server)
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a missing core node, false otherwise
|
||||
*/
|
||||
const isMissingCoreNode = (node: LGraphNode) => {
|
||||
const packId = node.properties?.cnr_id
|
||||
if (packId === undefined || !isCorePack(packId)) return false
|
||||
const nodeName = node.type
|
||||
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
|
||||
return !isRegisteredNodeDef
|
||||
}
|
||||
|
||||
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
|
||||
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
|
||||
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
|
||||
})
|
||||
|
||||
// Automatically fetch workflow pack data when composable is used
|
||||
onMounted(async () => {
|
||||
if (!workflowPacks.value.length && !isLoading.value) {
|
||||
await startFetchWorkflowPacks()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
missingNodePacks,
|
||||
missingCoreNodes,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export function useErrorHandling() {
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
const wrapWithErrorHandling =
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -42,6 +42,7 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
const inputOptions = inputData[1]
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const folder: ResultItemType | undefined = image_folder
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
@@ -75,6 +76,7 @@ export const useImageUploadWidget = () => {
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
|
||||
@@ -2,7 +2,9 @@ import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IWidget } from '@comfyorg/litegraph'
|
||||
import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -220,6 +222,46 @@ export function useRemoteWidget<
|
||||
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add auto-refresh toggle widget and execution success listener
|
||||
*/
|
||||
function addAutoRefreshToggle() {
|
||||
let autoRefreshEnabled = false
|
||||
|
||||
// Handler for execution success
|
||||
const handleExecutionSuccess = () => {
|
||||
if (autoRefreshEnabled && widget.refresh) {
|
||||
widget.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle widget
|
||||
const autoRefreshWidget = node.addWidget(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
(value: boolean) => {
|
||||
autoRefreshEnabled = value
|
||||
},
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
|
||||
// Register event listener
|
||||
api.addEventListener('execution_success', handleExecutionSuccess)
|
||||
|
||||
// Cleanup on node removal
|
||||
node.onRemoved = useChainCallback(node.onRemoved, function () {
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess)
|
||||
})
|
||||
|
||||
return autoRefreshWidget
|
||||
}
|
||||
|
||||
// Always add auto-refresh toggle for remote widgets
|
||||
addAutoRefreshToggle()
|
||||
|
||||
return {
|
||||
getCachedValue,
|
||||
getValue,
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"lastUpdated": "Last Updated",
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All Missing Nodes",
|
||||
"packsSelected": "Packs Selected",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
@@ -1195,6 +1196,11 @@
|
||||
"missingModels": "Missing Models",
|
||||
"missingModelsMessage": "When loading the graph, the following models were not found"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
|
||||
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
|
||||
"coreNodesFromVersion": "Requires ComfyUI {version}:"
|
||||
},
|
||||
"errorDialog": {
|
||||
"defaultTitle": "An error occurred",
|
||||
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "Subir imagen de fondo",
|
||||
"uploadTexture": "Subir textura"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
|
||||
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
|
||||
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Ninguno",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "En Flujo de Trabajo",
|
||||
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
|
||||
"installAllMissingNodes": "Instalar todos los nodos faltantes",
|
||||
"installSelected": "Instalar Seleccionado",
|
||||
"installationQueue": "Cola de Instalación",
|
||||
"lastUpdated": "Última Actualización",
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "Télécharger l'image de fond",
|
||||
"uploadTexture": "Télécharger Texture"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
|
||||
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
|
||||
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Aucun",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "Dans le flux de travail",
|
||||
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
|
||||
"installAllMissingNodes": "Installer tous les nœuds manquants",
|
||||
"installSelected": "Installer sélectionné",
|
||||
"installationQueue": "File d'attente d'installation",
|
||||
"lastUpdated": "Dernière mise à jour",
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "背景画像をアップロード",
|
||||
"uploadTexture": "テクスチャをアップロード"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
|
||||
"outdatedVersion": "一部のノードはより新しいバージョンのComfyUIが必要です(現在のバージョン:{version})。すべてのノードを使用するにはアップデートしてください。",
|
||||
"outdatedVersionGeneric": "一部のノードはより新しいバージョンのComfyUIが必要です。すべてのノードを使用するにはアップデートしてください。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "ワークフロー内",
|
||||
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
|
||||
"installAllMissingNodes": "すべての不足しているノードをインストール",
|
||||
"installSelected": "選択したものをインストール",
|
||||
"installationQueue": "インストールキュー",
|
||||
"lastUpdated": "最終更新日",
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "배경 이미지 업로드",
|
||||
"uploadTexture": "텍스처 업로드"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
|
||||
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
|
||||
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
"OK": "확인",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "워크플로우 내",
|
||||
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
|
||||
"installAllMissingNodes": "모든 누락된 노드 설치",
|
||||
"installSelected": "선택한 항목 설치",
|
||||
"installationQueue": "설치 대기열",
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "Загрузить фоновое изображение",
|
||||
"uploadTexture": "Загрузить текстуру"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
|
||||
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
|
||||
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "В рабочем процессе",
|
||||
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
|
||||
"installAllMissingNodes": "Установить все отсутствующие узлы",
|
||||
"installSelected": "Установить выбранное",
|
||||
"installationQueue": "Очередь установки",
|
||||
"lastUpdated": "Последнее обновление",
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "上传背景图片",
|
||||
"uploadTexture": "上传纹理"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "需要 ComfyUI {version}:",
|
||||
"outdatedVersion": "某些节点需要更高版本的 ComfyUI(当前版本:{version})。请更新以使用所有节点。",
|
||||
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
"OK": "确定",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "在工作流中",
|
||||
"infoPanelEmpty": "点击一个项目查看信息",
|
||||
"installAllMissingNodes": "安装所有缺失节点",
|
||||
"installSelected": "安装选定",
|
||||
"installationQueue": "安装队列",
|
||||
"lastUpdated": "最后更新",
|
||||
@@ -1038,9 +1044,9 @@
|
||||
"Extension": "扩展",
|
||||
"General": "常规",
|
||||
"Graph": "画面",
|
||||
"Group": "组节点",
|
||||
"Group": "组",
|
||||
"Keybinding": "快捷键",
|
||||
"Light": "浅色",
|
||||
"Light": "光照",
|
||||
"Link": "连线",
|
||||
"LinkRelease": "释放链接",
|
||||
"LiteGraph": "画面",
|
||||
|
||||
@@ -46,22 +46,26 @@ export function transformNodeDefV1ToV2(
|
||||
const outputs: OutputSpecV2[] = []
|
||||
|
||||
if (nodeDefV1.output) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
if (Array.isArray(nodeDefV1.output)) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
} else {
|
||||
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the V2 node definition
|
||||
|
||||
@@ -137,8 +137,17 @@ export const useDialogService = () => {
|
||||
dialogComponentProps: {
|
||||
closable: true,
|
||||
pt: {
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class:
|
||||
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
|
||||
}
|
||||
},
|
||||
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
|
||||
content: {
|
||||
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
|
||||
},
|
||||
root: { class: 'manager-dialog' }
|
||||
}
|
||||
},
|
||||
props
|
||||
@@ -154,6 +163,7 @@ export const useDialogService = () => {
|
||||
headerComponent: ManagerProgressHeader,
|
||||
footerComponent: ManagerProgressFooter,
|
||||
props: options?.props,
|
||||
priority: 2,
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
modal: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type IContextMenuValue,
|
||||
LGraphBadge,
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
@@ -92,6 +93,13 @@ export const useLitegraphService = () => {
|
||||
this.#setInitialSize()
|
||||
this.serialize_widgets = true
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
this.badges.push(
|
||||
new LGraphBadge({
|
||||
text: '⇌',
|
||||
fgColor: '#dad0de',
|
||||
bgColor: '#b3b'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,7 +42,13 @@ const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
|
||||
'latest_version_status',
|
||||
'comfy_node_extract_status',
|
||||
'id',
|
||||
'icon_url'
|
||||
'icon_url',
|
||||
'github_stars',
|
||||
'supported_os',
|
||||
'supported_comfyui_version',
|
||||
'supported_comfyui_frontend_version',
|
||||
'supported_accelerators',
|
||||
'banner_url'
|
||||
]
|
||||
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
@@ -74,7 +80,9 @@ const toRegistryPublisher = (
|
||||
* Convert from node pack in Algolia format to Comfy Registry format
|
||||
*/
|
||||
const toRegistryPack = memoize(
|
||||
(algoliaNode: AlgoliaNodePack): RegistryNodePack => {
|
||||
(
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack & { comfy_nodes: string[] } => {
|
||||
return {
|
||||
id: algoliaNode.id ?? algoliaNode.objectID,
|
||||
name: algoliaNode.name,
|
||||
@@ -86,9 +94,18 @@ const toRegistryPack = memoize(
|
||||
icon: algoliaNode.icon_url,
|
||||
latest_version: toRegistryLatestVersion(algoliaNode),
|
||||
publisher: toRegistryPublisher(algoliaNode),
|
||||
// @ts-expect-error comfy_nodes also not in node info
|
||||
comfy_nodes: algoliaNode.comfy_nodes,
|
||||
create_time: algoliaNode.create_time
|
||||
created_at: algoliaNode.create_time,
|
||||
category: algoliaNode.category,
|
||||
author: algoliaNode.author,
|
||||
tags: algoliaNode.tags,
|
||||
github_stars: algoliaNode.github_stars,
|
||||
supported_os: algoliaNode.supported_os,
|
||||
supported_comfyui_version: algoliaNode.supported_comfyui_version,
|
||||
supported_comfyui_frontend_version:
|
||||
algoliaNode.supported_comfyui_frontend_version,
|
||||
supported_accelerators: algoliaNode.supported_accelerators,
|
||||
banner_url: algoliaNode.banner_url,
|
||||
comfy_nodes: algoliaNode.comfy_nodes
|
||||
}
|
||||
},
|
||||
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
||||
@@ -187,9 +204,7 @@ export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
|
||||
case SortableAlgoliaField.Downloads:
|
||||
return pack.downloads ?? 0
|
||||
case SortableAlgoliaField.Created: {
|
||||
// TODO: add create time to backend return type
|
||||
// @ts-expect-error create_time is not in the RegistryNodePack type
|
||||
const createTime = pack.create_time
|
||||
const createTime = pack.created_at
|
||||
return createTime ? new Date(createTime).getTime() : 0
|
||||
}
|
||||
case SortableAlgoliaField.Updated:
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
|
||||
search: isNodeSearch ? undefined : query,
|
||||
comfy_node_search: isNodeSearch ? query : undefined,
|
||||
limit: pageSize,
|
||||
offset: pageNumber * pageSize
|
||||
page: pageNumber + 1 // Registry API uses 1-based pagination
|
||||
}
|
||||
|
||||
const searchResult = await registryStore.search.call(searchParams)
|
||||
|
||||
@@ -213,6 +213,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
isPackInstalled: isInstalledPackId,
|
||||
isPackEnabled: isEnabledPackId,
|
||||
getInstalledPackVersion,
|
||||
refreshInstalledList,
|
||||
|
||||
// Pack actions
|
||||
installPack,
|
||||
|
||||
@@ -40,6 +40,7 @@ interface DialogInstance {
|
||||
contentProps: Record<string, any>
|
||||
footerComponent?: Component
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ShowDialogOptions {
|
||||
@@ -50,6 +51,12 @@ export interface ShowDialogOptions {
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
/**
|
||||
* Optional priority for dialog stacking.
|
||||
* A dialog will never be shown above a dialog with a higher priority.
|
||||
* @default 1
|
||||
*/
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
@@ -57,13 +64,29 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
|
||||
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
/**
|
||||
* Inserts a dialog into the stack at the correct position based on priority.
|
||||
* Higher priority dialogs are placed before lower priority ones.
|
||||
*/
|
||||
function insertDialogByPriority(dialog: DialogInstance) {
|
||||
const insertIndex = dialogStack.value.findIndex(
|
||||
(d) => d.priority <= dialog.priority
|
||||
)
|
||||
|
||||
dialogStack.value.splice(
|
||||
insertIndex === -1 ? dialogStack.value.length : insertIndex,
|
||||
0,
|
||||
dialog
|
||||
)
|
||||
}
|
||||
|
||||
function riseDialog(options: { key: string }) {
|
||||
const dialogKey = options.key
|
||||
|
||||
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
|
||||
if (index !== -1) {
|
||||
const dialogs = dialogStack.value.splice(index, 1)
|
||||
dialogStack.value.push(...dialogs)
|
||||
const [dialog] = dialogStack.value.splice(index, 1)
|
||||
insertDialogByPriority(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +108,13 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
priority?: number
|
||||
}) {
|
||||
if (dialogStack.value.length >= 10) {
|
||||
dialogStack.value.shift()
|
||||
}
|
||||
|
||||
const dialog = {
|
||||
const dialog: DialogInstance = {
|
||||
key: options.key,
|
||||
visible: true,
|
||||
title: options.title,
|
||||
@@ -102,6 +126,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
: undefined,
|
||||
component: markRaw(options.component),
|
||||
contentProps: { ...options.props },
|
||||
priority: options.priority ?? 1,
|
||||
dialogComponentProps: {
|
||||
maximizable: false,
|
||||
modal: true,
|
||||
@@ -110,6 +135,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
dismissableMask: true,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
// @ts-expect-error TODO: fix this
|
||||
onMaximize: () => {
|
||||
dialog.dialogComponentProps.maximized = true
|
||||
},
|
||||
@@ -128,7 +154,8 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
dialogStack.value.push(dialog)
|
||||
|
||||
insertDialogByPriority(dialog)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface AlgoliaNodePack {
|
||||
'comfy_node_extract_status'
|
||||
>
|
||||
icon_url: RegistryNodePack['icon']
|
||||
category: RegistryNodePack['category']
|
||||
author: RegistryNodePack['author']
|
||||
tags: RegistryNodePack['tags']
|
||||
github_stars: RegistryNodePack['github_stars']
|
||||
supported_os: RegistryNodePack['supported_os']
|
||||
supported_comfyui_version: RegistryNodePack['supported_comfyui_version']
|
||||
supported_comfyui_frontend_version: RegistryNodePack['supported_comfyui_frontend_version']
|
||||
supported_accelerators: RegistryNodePack['supported_accelerators']
|
||||
banner_url: RegistryNodePack['banner_url']
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -896,6 +896,23 @@ export interface paths {
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/nodes/update-github-stars': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get?: never
|
||||
put?: never
|
||||
/** Update GitHub stars for nodes */
|
||||
post: operations['updateGithubStars']
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/nodes': {
|
||||
parameters: {
|
||||
query?: never
|
||||
@@ -1078,6 +1095,30 @@ export interface paths {
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/releases': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
/**
|
||||
* Get release notes
|
||||
* @description Fetch release notes from Strapi with caching
|
||||
*/
|
||||
get: operations['getReleaseNotes']
|
||||
put?: never
|
||||
/**
|
||||
* Process Github release webhook
|
||||
* @description Webhook endpoint to process Github release events and generate release notes
|
||||
*/
|
||||
post: operations['processReleaseWebhook']
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/security-scan': {
|
||||
parameters: {
|
||||
query?: never
|
||||
@@ -3207,6 +3248,13 @@ export interface components {
|
||||
preempted_comfy_node_names?: string[]
|
||||
/** @description URL to the node's banner. */
|
||||
banner_url?: string
|
||||
/** @description Number of stars on the GitHub repository. */
|
||||
github_stars?: number
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description The date and time when the node was created
|
||||
*/
|
||||
created_at?: string
|
||||
}
|
||||
NodeVersion: {
|
||||
id?: string
|
||||
@@ -3345,6 +3393,8 @@ export interface components {
|
||||
stripe_id?: string
|
||||
/** @description The Metronome customer ID */
|
||||
metronome_id?: string
|
||||
/** @description Whether the user has funds */
|
||||
has_fund?: boolean
|
||||
}
|
||||
AuditLog: {
|
||||
/** @description the type of the event */
|
||||
@@ -8692,6 +8742,292 @@ export interface components {
|
||||
MoonvalleyUploadResponse: {
|
||||
access_url?: string
|
||||
}
|
||||
/** @description GitHub release webhook payload based on official webhook documentation */
|
||||
GithubReleaseWebhook: {
|
||||
/**
|
||||
* @description The action performed on the release
|
||||
* @enum {string}
|
||||
*/
|
||||
action:
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'created'
|
||||
| 'edited'
|
||||
| 'deleted'
|
||||
| 'prereleased'
|
||||
| 'released'
|
||||
/** @description The release object */
|
||||
release: {
|
||||
/** @description The ID of the release */
|
||||
id: number
|
||||
/** @description The node ID of the release */
|
||||
node_id: string
|
||||
/** @description The API URL of the release */
|
||||
url: string
|
||||
/** @description The HTML URL of the release */
|
||||
html_url: string
|
||||
/** @description The URL to the release assets */
|
||||
assets_url?: string
|
||||
/** @description The URL to upload release assets */
|
||||
upload_url?: string
|
||||
/** @description The tag name of the release */
|
||||
tag_name: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
/** @description The name of the release */
|
||||
name?: string | null
|
||||
/** @description The release notes/body */
|
||||
body?: string | null
|
||||
/** @description Whether the release is a draft */
|
||||
draft: boolean
|
||||
/** @description Whether the release is a prerelease */
|
||||
prerelease: boolean
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release was published
|
||||
*/
|
||||
published_at?: string | null
|
||||
author: components['schemas']['GithubUser']
|
||||
/** @description URL to the tarball */
|
||||
tarball_url: string
|
||||
/** @description URL to the zipball */
|
||||
zipball_url: string
|
||||
/** @description Array of release assets */
|
||||
assets: components['schemas']['GithubReleaseAsset'][]
|
||||
}
|
||||
repository: components['schemas']['GithubRepository']
|
||||
sender: components['schemas']['GithubUser']
|
||||
organization?: components['schemas']['GithubOrganization']
|
||||
installation?: components['schemas']['GithubInstallation']
|
||||
enterprise?: components['schemas']['GithubEnterprise']
|
||||
}
|
||||
/** @description A GitHub user */
|
||||
GithubUser: {
|
||||
/** @description The user's login name */
|
||||
login: string
|
||||
/** @description The user's ID */
|
||||
id: number
|
||||
/** @description The user's node ID */
|
||||
node_id: string
|
||||
/** @description URL to the user's avatar */
|
||||
avatar_url: string
|
||||
/** @description The user's gravatar ID */
|
||||
gravatar_id?: string | null
|
||||
/** @description The API URL of the user */
|
||||
url: string
|
||||
/** @description The HTML URL of the user */
|
||||
html_url: string
|
||||
/**
|
||||
* @description The type of user
|
||||
* @enum {string}
|
||||
*/
|
||||
type: 'Bot' | 'User' | 'Organization'
|
||||
/** @description Whether the user is a site admin */
|
||||
site_admin: boolean
|
||||
}
|
||||
/** @description A GitHub repository */
|
||||
GithubRepository: {
|
||||
/** @description The repository ID */
|
||||
id: number
|
||||
/** @description The repository node ID */
|
||||
node_id: string
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description Whether the repository is private */
|
||||
private: boolean
|
||||
owner: components['schemas']['GithubUser']
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The repository description */
|
||||
description?: string | null
|
||||
/** @description Whether the repository is a fork */
|
||||
fork: boolean
|
||||
/** @description The API URL of the repository */
|
||||
url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: string
|
||||
/** @description The git URL of the repository */
|
||||
git_url: string
|
||||
/** @description The SSH URL of the repository */
|
||||
ssh_url: string
|
||||
/** @description The default branch of the repository */
|
||||
default_branch: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last pushed to
|
||||
*/
|
||||
pushed_at: string
|
||||
}
|
||||
/** @description A GitHub release asset */
|
||||
GithubReleaseAsset: {
|
||||
/** @description The asset ID */
|
||||
id: number
|
||||
/** @description The asset node ID */
|
||||
node_id: string
|
||||
/** @description The name of the asset */
|
||||
name: string
|
||||
/** @description The label of the asset */
|
||||
label?: string | null
|
||||
/** @description The content type of the asset */
|
||||
content_type: string
|
||||
/**
|
||||
* @description The state of the asset
|
||||
* @enum {string}
|
||||
*/
|
||||
state: 'uploaded' | 'open'
|
||||
/** @description The size of the asset in bytes */
|
||||
size: number
|
||||
/** @description The number of downloads */
|
||||
download_count: number
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The browser download URL */
|
||||
browser_download_url: string
|
||||
uploader: components['schemas']['GithubUser']
|
||||
}
|
||||
/** @description A GitHub organization */
|
||||
GithubOrganization: {
|
||||
/** @description The organization's login name */
|
||||
login: string
|
||||
/** @description The organization ID */
|
||||
id: number
|
||||
/** @description The organization node ID */
|
||||
node_id: string
|
||||
/** @description The API URL of the organization */
|
||||
url: string
|
||||
/** @description The API URL of the organization's repositories */
|
||||
repos_url: string
|
||||
/** @description The API URL of the organization's events */
|
||||
events_url: string
|
||||
/** @description The API URL of the organization's hooks */
|
||||
hooks_url: string
|
||||
/** @description The API URL of the organization's issues */
|
||||
issues_url: string
|
||||
/** @description The API URL of the organization's members */
|
||||
members_url: string
|
||||
/** @description The API URL of the organization's public members */
|
||||
public_members_url: string
|
||||
/** @description URL to the organization's avatar */
|
||||
avatar_url: string
|
||||
/** @description The organization description */
|
||||
description?: string | null
|
||||
}
|
||||
/** @description A GitHub App installation */
|
||||
GithubInstallation: {
|
||||
/** @description The installation ID */
|
||||
id: number
|
||||
account: components['schemas']['GithubUser']
|
||||
/**
|
||||
* @description Repository selection for the installation
|
||||
* @enum {string}
|
||||
*/
|
||||
repository_selection: 'selected' | 'all'
|
||||
/** @description The API URL for access tokens */
|
||||
access_tokens_url: string
|
||||
/** @description The API URL for repositories */
|
||||
repositories_url: string
|
||||
/** @description The HTML URL of the installation */
|
||||
html_url: string
|
||||
/** @description The GitHub App ID */
|
||||
app_id: number
|
||||
/** @description The target ID */
|
||||
target_id: number
|
||||
/** @description The target type */
|
||||
target_type: string
|
||||
/** @description The installation permissions */
|
||||
permissions: Record<string, never>
|
||||
/** @description The events the installation subscribes to */
|
||||
events: string[]
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The single file name if applicable */
|
||||
single_file_name?: string | null
|
||||
}
|
||||
/** @description A GitHub enterprise */
|
||||
GithubEnterprise: {
|
||||
/** @description The enterprise ID */
|
||||
id: number
|
||||
/** @description The enterprise slug */
|
||||
slug: string
|
||||
/** @description The enterprise name */
|
||||
name: string
|
||||
/** @description The enterprise node ID */
|
||||
node_id: string
|
||||
/** @description URL to the enterprise avatar */
|
||||
avatar_url: string
|
||||
/** @description The enterprise description */
|
||||
description?: string | null
|
||||
/** @description The enterprise website URL */
|
||||
website_url?: string | null
|
||||
/** @description The HTML URL of the enterprise */
|
||||
html_url: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
}
|
||||
ReleaseNote: {
|
||||
/** @description Unique identifier for the release note */
|
||||
id: number
|
||||
/**
|
||||
* @description The project this release note belongs to
|
||||
* @enum {string}
|
||||
*/
|
||||
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
|
||||
/** @description The version of the release */
|
||||
version: string
|
||||
/**
|
||||
* @description The attention level for this release
|
||||
* @enum {string}
|
||||
*/
|
||||
attention: 'low' | 'medium' | 'high'
|
||||
/** @description The content of the release note in markdown format */
|
||||
content: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release note was published
|
||||
*/
|
||||
published_at: string
|
||||
}
|
||||
}
|
||||
responses: never
|
||||
parameters: {
|
||||
@@ -10795,8 +11131,6 @@ export interface operations {
|
||||
query?: {
|
||||
/** @description Maximum number of nodes to send to algolia at a time */
|
||||
max_batch?: number
|
||||
/** @description Minimum interval from the last time the nodes were indexed to algolia */
|
||||
min_age?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
@@ -10831,6 +11165,52 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
updateGithubStars: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of nodes to update in one batch */
|
||||
max_batch?: number
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description Update GithubStars request triggered successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Bad request. */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
listAllNodes: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -10852,6 +11232,8 @@ export interface operations {
|
||||
sort?: string[]
|
||||
/** @description node_id to use as filter */
|
||||
node_id?: string[]
|
||||
/** @description Comfy UI version */
|
||||
comfyui_version?: string
|
||||
/** @description The platform requesting the nodes */
|
||||
form_factor?: string
|
||||
}
|
||||
@@ -11418,6 +11800,113 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
getReleaseNotes: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description The project to get release notes for */
|
||||
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
|
||||
/** @description The current version to filter release notes */
|
||||
current_version?: string
|
||||
/** @description The platform requesting the release notes */
|
||||
form_factor?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description Release notes retrieved successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ReleaseNote'][]
|
||||
}
|
||||
}
|
||||
/** @description Bad request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
processReleaseWebhook: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header: {
|
||||
/** @description The name of the event that triggered the delivery */
|
||||
'X-GitHub-Event': 'release'
|
||||
/** @description A globally unique identifier (GUID) to identify the event */
|
||||
'X-GitHub-Delivery': string
|
||||
/** @description The unique identifier of the webhook */
|
||||
'X-GitHub-Hook-ID': string
|
||||
/** @description HMAC hex digest of the request body using SHA-256 hash function */
|
||||
'X-Hub-Signature-256'?: string
|
||||
/** @description The type of resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-Type'?: string
|
||||
/** @description The unique identifier of the resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-ID'?: string
|
||||
}
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GithubReleaseWebhook']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description Webhook processed successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Bad request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Validation failed or endpoint has been spammed */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
securityScan: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
17
src/types/litegraph-augmentation.d.ts
vendored
17
src/types/litegraph-augmentation.d.ts
vendored
@@ -60,6 +60,7 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
|
||||
* ComfyUI extensions of litegraph
|
||||
*/
|
||||
declare module '@comfyorg/litegraph' {
|
||||
import type { ExecutableLGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
@@ -75,22 +76,6 @@ declare module '@comfyorg/litegraph' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface BaseWidget extends IBaseWidget {}
|
||||
|
||||
/** Actual members required for execution. */
|
||||
type ExecutableLGraphNode = Pick<
|
||||
LGraphNode,
|
||||
| 'id'
|
||||
| 'type'
|
||||
| 'comfyClass'
|
||||
| 'title'
|
||||
| 'mode'
|
||||
| 'inputs'
|
||||
| 'widgets'
|
||||
| 'isVirtualNode'
|
||||
| 'applyToGraph'
|
||||
| 'getInputNode'
|
||||
| 'getInputLink'
|
||||
>
|
||||
|
||||
interface LGraphNode {
|
||||
constructor: LGraphNodeConstructor
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LGraph, LGraphNode, NodeId } from '@comfyorg/litegraph'
|
||||
import { LGraphEventMode } from '@comfyorg/litegraph'
|
||||
import type { LGraph, NodeId } from '@comfyorg/litegraph'
|
||||
import {
|
||||
ExecutableNodeDTO,
|
||||
LGraphEventMode,
|
||||
SubgraphNode
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
@@ -74,20 +78,31 @@ export const graphToPrompt = async (
|
||||
workflow.extra ??= {}
|
||||
workflow.extra.frontendVersion = __COMFYUI_FRONTEND_VERSION__
|
||||
|
||||
const computedNodeDtos = graph
|
||||
.computeExecutionOrder(false)
|
||||
.map(
|
||||
(node) =>
|
||||
new ExecutableNodeDTO(
|
||||
node,
|
||||
[],
|
||||
node instanceof SubgraphNode ? node : undefined
|
||||
)
|
||||
)
|
||||
|
||||
let output: ComfyApiWorkflow = {}
|
||||
// Process nodes in order of execution
|
||||
for (const outerNode of graph.computeExecutionOrder(false)) {
|
||||
const skipNode =
|
||||
for (const outerNode of computedNodeDtos) {
|
||||
// Don't serialize muted nodes
|
||||
if (
|
||||
outerNode.mode === LGraphEventMode.NEVER ||
|
||||
outerNode.mode === LGraphEventMode.BYPASS
|
||||
const innerNodes =
|
||||
!skipNode && outerNode.getInnerNodes
|
||||
? outerNode.getInnerNodes()
|
||||
: [outerNode]
|
||||
for (const node of innerNodes) {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const node of outerNode.getInnerNodes()) {
|
||||
if (
|
||||
node.isVirtualNode ||
|
||||
// Don't serialize muted nodes
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
) {
|
||||
@@ -120,58 +135,14 @@ export const graphToPrompt = async (
|
||||
|
||||
// Store all node links
|
||||
for (const [i, input] of node.inputs.entries()) {
|
||||
let parent: LGraphNode | null | undefined = node.getInputNode(i)
|
||||
if (!parent) continue
|
||||
const resolvedInput = node.resolveInput(i)
|
||||
if (!resolvedInput) continue
|
||||
|
||||
let link = node.getInputLink(i)
|
||||
while (
|
||||
parent?.mode === LGraphEventMode.BYPASS ||
|
||||
parent?.isVirtualNode
|
||||
) {
|
||||
if (!link) break
|
||||
|
||||
if (parent.isVirtualNode) {
|
||||
link = parent.getInputLink(link.origin_slot)
|
||||
if (!link) break
|
||||
|
||||
parent = parent.isSubgraphNode()
|
||||
? parent.resolveSubgraphOutputLink(link.origin_slot)?.outputNode
|
||||
: parent.getInputNode(link.target_slot)
|
||||
|
||||
if (!parent) break
|
||||
} else if (!parent.inputs) {
|
||||
// Maintains existing behaviour if parent.getInputLink is overriden
|
||||
break
|
||||
} else if (parent.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const parentInputIndexes = Object.keys(parent.inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [link.origin_slot].concat(parentInputIndexes)
|
||||
|
||||
const matchingIndex = indexes.find(
|
||||
(index) => parent?.inputs[index]?.type === input.type
|
||||
)
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) break
|
||||
|
||||
link = parent.getInputLink(matchingIndex)
|
||||
if (link) parent = parent.getInputNode(matchingIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (parent?.updateLink) {
|
||||
// Subgraph node / groupNode callback; deprecated, should be replaced
|
||||
link = parent.updateLink(link)
|
||||
}
|
||||
if (link) {
|
||||
inputs[input.name] = [
|
||||
String(link.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(link.origin_slot)
|
||||
]
|
||||
}
|
||||
}
|
||||
inputs[input.name] = [
|
||||
String(resolvedInput.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(resolvedInput.origin_slot)
|
||||
]
|
||||
}
|
||||
|
||||
output[String(node.id)] = {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ColorOption, LGraph, Reroute } from '@comfyorg/litegraph'
|
||||
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
|
||||
import type { ISerialisedGraph } from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisableNodeInput,
|
||||
ISerialisedGraph
|
||||
} from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
@@ -167,12 +171,11 @@ export function fixLinkInputSlots(graph: LGraph) {
|
||||
* This should match the serialization format of legacy widget conversion.
|
||||
*
|
||||
* @param graph - The graph to compress widget input slots for.
|
||||
* @throws If an infinite loop is detected.
|
||||
*/
|
||||
export function compressWidgetInputSlots(graph: ISerialisedGraph) {
|
||||
for (const node of graph.nodes) {
|
||||
node.inputs = node.inputs?.filter(
|
||||
(input) => !(input.widget && input.link === null)
|
||||
)
|
||||
node.inputs = node.inputs?.filter(matchesLegacyApi)
|
||||
|
||||
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
|
||||
if (input.link) {
|
||||
@@ -183,4 +186,44 @@ export function compressWidgetInputSlots(graph: ISerialisedGraph) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compressSubgraphWidgetInputSlots(graph.definitions?.subgraphs)
|
||||
}
|
||||
|
||||
function matchesLegacyApi(input: ISerialisableNodeInput) {
|
||||
return !(input.widget && input.link === null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplication to handle the legacy link arrays in the root workflow.
|
||||
* @see compressWidgetInputSlots
|
||||
* @param subgraph The subgraph to compress widget input slots for.
|
||||
*/
|
||||
function compressSubgraphWidgetInputSlots(
|
||||
subgraphs: ExportedSubgraph[] | undefined,
|
||||
visited = new WeakSet<ExportedSubgraph>()
|
||||
) {
|
||||
if (!subgraphs) return
|
||||
|
||||
for (const subgraph of subgraphs) {
|
||||
if (visited.has(subgraph)) throw new Error('Infinite loop detected')
|
||||
visited.add(subgraph)
|
||||
|
||||
if (subgraph.nodes) {
|
||||
for (const node of subgraph.nodes) {
|
||||
node.inputs = node.inputs?.filter(matchesLegacyApi)
|
||||
|
||||
if (!subgraph.links) continue
|
||||
|
||||
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
|
||||
if (input.link) {
|
||||
const link = subgraph.links.find((link) => link.id === input.link)
|
||||
if (link) link.target_slot = inputIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compressSubgraphWidgetInputSlots(subgraph.definitions?.subgraphs, visited)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn()
|
||||
}))
|
||||
|
||||
const createMockNode = (type: string, version?: string): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: 'comfy-core', ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
describe('MissingCoreNodesMessage', () => {
|
||||
const mockSystemStatsStore = {
|
||||
systemStats: null as { system?: { comfyui_version?: string } } | null,
|
||||
fetchSystemStats: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the mock store state
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.fetchSystemStats = vi.fn()
|
||||
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
|
||||
// The actual store has more properties, but we only need these for our tests.
|
||||
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(MissingCoreNodesMessage, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Message },
|
||||
mocks: {
|
||||
$t: (key: string, params?: { version?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
|
||||
'loadWorkflowWarning.outdatedVersionGeneric':
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
|
||||
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
missingCoreNodes: {},
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('does not render when there are no missing core nodes', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders message when there are missing core nodes', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('fetches and displays current ComfyUI version', async () => {
|
||||
// Start with no systemStats to trigger fetch
|
||||
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => {
|
||||
// Simulate the fetch setting the systemStats
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: { comfyui_version: '1.0.0' }
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for all async operations
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays generic message when version is unavailable', async () => {
|
||||
// Mock fetchSystemStats to resolve without setting systemStats
|
||||
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for the async operations to complete
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups nodes by version and displays them', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('NodeA', '1.2.0'),
|
||||
createMockNode('NodeB', '1.2.0')
|
||||
],
|
||||
'1.3.0': [createMockNode('NodeC', '1.3.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Requires ComfyUI 1.3.0:')
|
||||
expect(text).toContain('NodeC')
|
||||
expect(text).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(text).toContain('NodeA, NodeB')
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.1.0': [createMockNode('Node1', '1.1.0')],
|
||||
'1.3.0': [createMockNode('Node3', '1.3.0')],
|
||||
'1.2.0': [createMockNode('Node2', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
const version13Index = text.indexOf('1.3.0')
|
||||
const version12Index = text.indexOf('1.2.0')
|
||||
const version11Index = text.indexOf('1.1.0')
|
||||
|
||||
expect(version13Index).toBeLessThan(version12Index)
|
||||
expect(version12Index).toBeLessThan(version11Index)
|
||||
})
|
||||
|
||||
it('removes duplicate node names within the same version', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('UniqueNode', '1.2.0')
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
// Should only appear once in the sorted list
|
||||
expect(text).toContain('DuplicateNode, UniqueNode')
|
||||
// Count occurrences of 'DuplicateNode' - should be only 1
|
||||
const matches = text.match(/DuplicateNode/g) || []
|
||||
expect(matches.length).toBe(1)
|
||||
})
|
||||
|
||||
it('handles nodes with missing version info', async () => {
|
||||
const missingCoreNodes = {
|
||||
'': [createMockNode('NoVersionNode')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
385
tests-ui/tests/composables/useMissingNodes.test.ts
Normal file
385
tests-ui/tests/composables/useMissingNodes.test.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
// Mock Vue's onMounted to execute immediately for testing
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue')>('vue')
|
||||
return {
|
||||
...actual,
|
||||
onMounted: (cb: () => void) => cb()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/composables/nodePack/useWorkflowPacks', () => ({
|
||||
useWorkflowPacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
nodes: []
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
|
||||
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
|
||||
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
|
||||
|
||||
describe('useMissingNodes', () => {
|
||||
const mockWorkflowPacks = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack 1',
|
||||
latest_version: { version: '1.0.0' }
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
name: 'Test Pack 2',
|
||||
latest_version: { version: '2.0.0' }
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
name: 'Installed Pack',
|
||||
latest_version: { version: '1.5.0' }
|
||||
}
|
||||
]
|
||||
|
||||
const mockStartFetchWorkflowPacks = vi.fn()
|
||||
const mockIsPackInstalled = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default setup: pack-3 is installed, others are not
|
||||
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
|
||||
|
||||
// @ts-expect-error - Mocking partial ComfyManagerStore for testing.
|
||||
// We only need isPackInstalled method for these tests.
|
||||
mockUseComfyManagerStore.mockReturnValue({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Reset node def store mock
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
// We only need nodeDefsByName for these tests.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
// Reset app.graph.nodes
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = []
|
||||
})
|
||||
|
||||
describe('core filtering logic', () => {
|
||||
it('filters out installed packs correctly', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Should only include packs that are not installed (pack-1, pack-2)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
expect(missingNodePacks.value[0].id).toBe('pack-1')
|
||||
expect(missingNodePacks.value[1].id).toBe('pack-2')
|
||||
expect(
|
||||
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns empty array when all packs are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Mock all packs as installed
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all packs when none are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Mock no packs as installed
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toHaveLength(3)
|
||||
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
|
||||
})
|
||||
|
||||
it('returns empty array when no workflow packs exist', () => {
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('automatic data fetching', () => {
|
||||
it('fetches workflow packs automatically when none exist', async () => {
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not fetch when packs already exist', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch when already loading', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state management', () => {
|
||||
it('exposes loading state from useWorkflowPacks', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { isLoading } = useMissingNodes()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes error state from useWorkflowPacks', () => {
|
||||
const testError = 'Failed to fetch workflow packs'
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(testError),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { error } = useMissingNodes()
|
||||
|
||||
expect(error.value).toBe(testError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactivity', () => {
|
||||
it('updates when workflow packs change', async () => {
|
||||
const workflowPacksRef = ref([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Initially empty
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
|
||||
// Update workflow packs
|
||||
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
|
||||
workflowPacksRef.value = mockWorkflowPacks
|
||||
await nextTick()
|
||||
|
||||
// Should update missing packs (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
const createMockNode = (
|
||||
type: string,
|
||||
packId?: string,
|
||||
version?: string
|
||||
): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: packId, ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
it('identifies missing core nodes not in nodeDefStore', () => {
|
||||
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
|
||||
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
|
||||
const registeredNode = createMockNode(
|
||||
'RegisteredNode',
|
||||
'comfy-core',
|
||||
'1.0.0'
|
||||
)
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [coreNode1, coreNode2, registeredNode]
|
||||
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
// Only including required properties for our test assertions.
|
||||
RegisteredNode: { name: 'RegisteredNode' }
|
||||
}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
|
||||
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
|
||||
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
|
||||
})
|
||||
|
||||
it('groups missing core nodes by version', () => {
|
||||
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
|
||||
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
|
||||
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [node120, node130, nodeNoVer]
|
||||
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores non-core nodes', () => {
|
||||
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
|
||||
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
|
||||
const noPackNode = createMockNode('NoPackNode')
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [coreNode, customNode, noPackNode]
|
||||
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
|
||||
})
|
||||
|
||||
it('returns empty object when no core nodes are missing', () => {
|
||||
const registeredNode1 = createMockNode(
|
||||
'RegisteredNode1',
|
||||
'comfy-core',
|
||||
'1.0.0'
|
||||
)
|
||||
const registeredNode2 = createMockNode(
|
||||
'RegisteredNode2',
|
||||
'comfy-core',
|
||||
'1.1.0'
|
||||
)
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [registeredNode1, registeredNode2]
|
||||
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
// Only including required properties for our test assertions.
|
||||
RegisteredNode1: { name: 'RegisteredNode1' },
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
RegisteredNode2: { name: 'RegisteredNode2' }
|
||||
}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,22 @@ vi.mock('@/stores/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/functional/useChainCallback', () => ({
|
||||
useChainCallback: vi.fn((original, ...callbacks) => {
|
||||
return function (this: any, ...args: any[]) {
|
||||
original?.apply(this, args)
|
||||
callbacks.forEach((cb: any) => cb.apply(this, args))
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
const DEFAULT_VALUE = 'Loading...'
|
||||
|
||||
@@ -40,7 +56,9 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
|
||||
const createMockOptions = (inputOverrides = {}) => ({
|
||||
remoteConfig: createMockConfig(inputOverrides),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: {} as any,
|
||||
node: {
|
||||
addWidget: vi.fn()
|
||||
} as any,
|
||||
widget: {} as any
|
||||
})
|
||||
|
||||
@@ -499,4 +517,168 @@ describe('useRemoteWidget', () => {
|
||||
expect(data2).toEqual(DEFAULT_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-refresh on task completion', () => {
|
||||
it('should add auto-refresh toggle widget', () => {
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Should add auto-refresh toggle widget
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
expect.any(Function),
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should register event listener when enabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Event listener should be registered immediately
|
||||
expect(api.addEventListener).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh widget when workflow completes successfully', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Get the toggle callback and enable auto-refresh
|
||||
const toggleCallback = mockNode.addWidget.mock.calls.find(
|
||||
(call) => call[0] === 'toggle'
|
||||
)?.[3]
|
||||
toggleCallback?.(true)
|
||||
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh when toggle is disabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Toggle is disabled by default
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cleanup event listener on node removal', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: [],
|
||||
onRemoved: undefined as any
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Simulate node removal
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(api.removeEventListener).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
executionSuccessHandler
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -108,8 +108,17 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
id: 'publisher-1',
|
||||
name: 'publisher-1'
|
||||
},
|
||||
create_time: '2024-01-01T00:00:00Z',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage']
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage'],
|
||||
category: undefined,
|
||||
author: undefined,
|
||||
tags: undefined,
|
||||
github_stars: undefined,
|
||||
supported_os: undefined,
|
||||
supported_comfyui_version: undefined,
|
||||
supported_comfyui_frontend_version: undefined,
|
||||
supported_accelerators: undefined,
|
||||
banner_url: undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,7 +262,7 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
create_time: '2024-01-01T10:00:00Z'
|
||||
created_at: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
it('should return correct values for each sort field', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('useComfyRegistrySearchProvider', () => {
|
||||
search: 'test',
|
||||
comfy_node_search: undefined,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
page: 1
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
@@ -68,7 +68,7 @@ describe('useComfyRegistrySearchProvider', () => {
|
||||
search: undefined,
|
||||
comfy_node_search: 'LoadImage',
|
||||
limit: 20,
|
||||
offset: 20
|
||||
page: 2
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
})
|
||||
|
||||
175
tests-ui/tests/store/dialogStore.test.ts
Normal file
175
tests-ui/tests/store/dialogStore.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const MockComponent = defineComponent({
|
||||
name: 'MockComponent',
|
||||
template: '<div>Mock</div>'
|
||||
})
|
||||
|
||||
describe('dialogStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('priority system', () => {
|
||||
it('should create dialogs in correct priority order', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create dialogs with different priorities
|
||||
store.showDialog({
|
||||
key: 'low-priority',
|
||||
component: MockComponent,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'high-priority',
|
||||
component: MockComponent,
|
||||
priority: 10
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'medium-priority',
|
||||
component: MockComponent,
|
||||
priority: 5
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'no-priority',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
// Check order: high (2) -> medium (1) -> low (0)
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'high-priority',
|
||||
'medium-priority',
|
||||
'no-priority',
|
||||
'low-priority'
|
||||
])
|
||||
})
|
||||
|
||||
it('should maintain priority order when rising dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create dialogs with different priorities
|
||||
store.showDialog({
|
||||
key: 'priority-2',
|
||||
component: MockComponent,
|
||||
priority: 2
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'priority-1',
|
||||
component: MockComponent,
|
||||
priority: 1
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'priority-0',
|
||||
component: MockComponent,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
// Try to rise the lowest priority dialog
|
||||
store.riseDialog({ key: 'priority-0' })
|
||||
|
||||
// Should still be at the bottom because of its priority
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'priority-2',
|
||||
'priority-1',
|
||||
'priority-0'
|
||||
])
|
||||
|
||||
// Rise the medium priority dialog
|
||||
store.riseDialog({ key: 'priority-1' })
|
||||
|
||||
// Should be above priority-0 but below priority-2
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'priority-2',
|
||||
'priority-1',
|
||||
'priority-0'
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep high priority dialogs on top when creating new lower priority dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create a high priority dialog (like manager progress)
|
||||
store.showDialog({
|
||||
key: 'manager-progress',
|
||||
component: MockComponent,
|
||||
priority: 10
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-2',
|
||||
component: MockComponent,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-3',
|
||||
component: MockComponent
|
||||
// Default priority is 1
|
||||
})
|
||||
|
||||
// Manager progress should still be on top
|
||||
expect(store.dialogStack[0].key).toBe('manager-progress')
|
||||
|
||||
// Check full order
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'manager-progress', // priority 2
|
||||
'dialog-3', // priority 1 (default)
|
||||
'dialog-2' // priority 0
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('basic dialog operations', () => {
|
||||
it('should show and close dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'test-dialog',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
expect(store.isDialogOpen('test-dialog')).toBe(true)
|
||||
|
||||
store.closeDialog({ key: 'test-dialog' })
|
||||
|
||||
expect(store.dialogStack).toHaveLength(0)
|
||||
expect(store.isDialogOpen('test-dialog')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reuse existing dialog when showing with same key', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reusable-dialog',
|
||||
component: MockComponent,
|
||||
title: 'Original Title'
|
||||
})
|
||||
|
||||
// First call should create the dialog
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
|
||||
// Second call with same key should reuse the dialog
|
||||
store.showDialog({
|
||||
key: 'reusable-dialog',
|
||||
component: MockComponent,
|
||||
title: 'New Title' // This should be ignored
|
||||
})
|
||||
|
||||
// Should still have only one dialog with original title
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
expect(store.dialogStack[0].key).toBe('reusable-dialog')
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,10 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
include: [
|
||||
'tests-ui/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
|
||||
'src/components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
|
||||
],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user