Compare commits

..

105 Commits

Author SHA1 Message Date
filtered
4df20a3055 Subgraph testing release v1.23.2-sub.12 (#4273) 2025-06-25 04:35:40 -07:00
filtered
c462d356c1 Subgraph testing release v1.22.2-sub.11 (#4272) 2025-06-25 04:26:31 -07:00
filtered
6b3d89ee93 [chore] Update litegraph 0.16.0-sub.16 2025-06-25 04:26:17 -07:00
filtered
9bc4f66982 Upstream graph execution traversal logic to litegraph
Initial impl; inefficient & can be optimised with a map (#4270).
2025-06-25 04:26:17 -07:00
filtered
6926d449bc [TS] Fix implicit any in augmented type 2025-06-25 04:26:17 -07:00
filtered
11b71bb820 [TS] Remove unnecessary type redeclaration 2025-06-25 04:26:17 -07:00
filtered
586314e0da Fix error discarded by error toast handler 2025-06-25 04:26:17 -07:00
filtered
5acfe4ad98 Subgraph testing release v1.22.2-sub.10 (#4248) 2025-06-25 04:26:17 -07:00
filtered
aefc5eb078 [chore] Update litegraph 0.16.0-sub.15 2025-06-25 04:25:55 -07:00
filtered
cf9af94fac Add breadcrumb text outline + shadow 2025-06-25 04:25:55 -07:00
filtered
ee93e36ee2 Add default badge for subgraph nodes 2025-06-25 04:25:55 -07:00
filtered
9f0b22a5d8 Subgraph testing release v1.22.2-sub.9 (#4226) 2025-06-25 04:25:55 -07:00
filtered
76d6911ffa [chore] Update litegraph 0.16.0-sub.14 2025-06-25 04:25:42 -07:00
filtered
b8740c6ac3 Update to match upstream litegraph change 2025-06-25 04:25:42 -07:00
filtered
27d33c24de Subgraph testing release v1.22.2-sub.8 (#4212)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-25 04:25:42 -07:00
filtered
9b488da973 [chore] Update litegraph 0.16.0-sub.13 2025-06-25 04:25:00 -07:00
filtered
c3065ff07b Fix execution fails when slot numbers don't match 2025-06-25 04:25:00 -07:00
filtered
a5a1f8cf8a Fix subgraphs linked to each other corrupt execution 2025-06-25 04:25:00 -07:00
filtered
1aac2d52ac [chore] Update litegraph 0.16.0-sub.12 2025-06-25 04:25:00 -07:00
filtered
d2369c8c49 [chore] Update litegraph 0.16.0-sub.11 2025-06-25 04:24:59 -07:00
filtered
2afd295ad9 [chore] Update litegraph 0.16.0-sub.10 2025-06-25 04:24:59 -07:00
filtered
66fbdad4d2 Add subgraph functionality to execution store 2025-06-25 04:24:59 -07:00
filtered
b02408f517 [Refactor] Prefer canvas store over app.canvas 2025-06-25 04:24:59 -07:00
filtered
2cdf547fdd [TS] Remove type assertion 2025-06-25 04:24:59 -07:00
filtered
d755210d03 [chore] Update litegraph 0.16.0-sub.9 2025-06-25 04:24:59 -07:00
filtered
6e89b196c2 [chore] Update litegraph 0.16.0-sub.8 2025-06-25 04:24:59 -07:00
Blake
97faee8879 [chore] Update litegraph 0.16.0-sub.7 2025-06-25 04:24:59 -07:00
Blake
7d568e13e5 [Test] Remove failing test (auto-generated) 2025-06-25 04:24:59 -07:00
Blake
c57c391659 Fix convert to subgraph shown on IO node 2025-06-25 04:24:59 -07:00
filtered
cb91c3770c Fix Vue unwrap using markRaw
Involves minor runtime change.
2025-06-25 04:24:59 -07:00
filtered
b0fc8efa6b [Test] Revert invalid test generation 2025-06-25 04:24:59 -07:00
filtered
842ec58511 [chore] Update litegraph 0.16.0-sub.6 2025-06-25 04:24:59 -07:00
filtered
060540ae80 Reimpl. escape key handling in frontend 2025-06-25 04:24:59 -07:00
filtered
962834e19c Add subgraph nodes to before/after exec 2025-06-25 04:24:59 -07:00
filtered
d018a69356 nit 2025-06-25 04:24:59 -07:00
filtered
c84218d6bb Remove deprecated group node conversion menu 2025-06-25 04:24:59 -07:00
filtered
6f9c481b38 Add convert to subgraph toolbox button 2025-06-25 04:24:59 -07:00
filtered
bdc1ac1004 [chore] Update litegraph 0.16.0-sub.5 2025-06-25 04:24:59 -07:00
filtered
12e1508203 Fix delete button shown for io nodes 2025-06-25 04:24:59 -07:00
filtered
98f5216ddf Fix edit mask button shown when non-nodes selected 2025-06-25 04:24:59 -07:00
filtered
b2550f6351 Fix breadcrumbs overlap 2nd row tabs 2025-06-25 04:24:59 -07:00
filtered
b3b0b95646 Fix active subgraph requires breadcrumb component 2025-06-25 04:24:59 -07:00
filtered
4ca5a92108 Fix unwarranted workflow validation warning 2025-06-25 04:24:59 -07:00
filtered
0a40c11b7d [chore] Update litegraph 0.16.0-sub.4 2025-06-25 04:24:59 -07:00
filtered
9d4537e803 [Cleanup] Remove redundant LinkConnector call 2025-06-25 04:24:58 -07:00
filtered
4388cbe4a4 Update node create to use active subgraph 2025-06-25 04:24:58 -07:00
filtered
518faebf69 [chore] Update litegraph 0.16.0-sub.3 2025-06-25 04:24:58 -07:00
filtered
5685cb6748 Fix invalid links in nested subgraph conversion 2025-06-25 04:24:58 -07:00
filtered
fc191a1e03 Update litegraph 0.16.0-sub.2 2025-06-25 04:24:58 -07:00
filtered
f73be5d72a Fix crash on graph load - reactive proxy leak 2025-06-25 04:24:58 -07:00
filtered
c76635ce7f Update litegraph 0.16.0-sub.1 2025-06-25 04:24:58 -07:00
filtered
20833e5090 Fix breadcrumb reactivity 2025-06-25 04:24:58 -07:00
github-actions
359e9288ec Update locales [skip ci] 2025-06-25 04:24:58 -07:00
filtered
4232e0503b Use subgraph npm 2025-06-25 04:24:58 -07:00
filtered
a3615b3824 Add simpler interface for active DOM widgets 2025-06-25 04:24:58 -07:00
filtered
0fe0519531 [Test] Update expectations 2025-06-25 04:24:58 -07:00
filtered
2cd315a2bf Keep subgraph nav state when swapping workflows 2025-06-25 04:24:58 -07:00
filtered
7623711b40 Clear DOM widgets instead of trying to manage refs 2025-06-25 04:24:58 -07:00
filtered
dba8716fce [Debug] Include more items in graph diff output 2025-06-25 04:24:58 -07:00
filtered
15a2b37c93 Add convert to subgraph command 2025-06-25 04:24:58 -07:00
filtered
bbd1ca234d Fix DOM widgets disappear 2025-06-25 04:24:58 -07:00
filtered
b0fc736260 nit 2025-06-25 04:24:58 -07:00
filtered
b65440e1c2 Track subgraph open history for breadcrumbs 2025-06-25 04:24:58 -07:00
filtered
6918aa830b [TS] Replace ts-ignore w/error 2025-06-25 04:24:58 -07:00
filtered
a5d0bc3198 nit 2025-06-25 04:24:58 -07:00
filtered
f41ae1d408 Fix desync of litegraph type and nodedefs 2025-06-25 04:24:58 -07:00
filtered
1300a1351b [TS] Remove unnecessary assertion 2025-06-25 04:24:58 -07:00
filtered
5129cfa5a7 Prune DOM widgets outside of current subgraph 2025-06-25 04:24:58 -07:00
filtered
99c7ecfa82 Remove unnecessary copy of litegraph objects 2025-06-25 04:24:58 -07:00
filtered
77968fed6d Fix nested subgraph breadcrumbs 2025-06-25 04:24:58 -07:00
filtered
09d17fec14 nit - prevent duplicate subgraph registrations 2025-06-25 04:24:57 -07:00
filtered
bff802eeeb Fix DOM widgets after rebase 2025-06-25 04:24:57 -07:00
filtered
71bbca613f [PARTIAL] Add Subgraph 2025-06-25 04:24:57 -07:00
filtered
6f8a91b0c1 Fix corruption when app.graph is changed 2025-06-25 04:24:57 -07:00
filtered
f95f014fde [TS] Remove unintended type assertion 2025-06-25 04:24:57 -07:00
filtered
d88a227e7c Add subgraph workflow fields to change tracker 2025-06-25 04:24:57 -07:00
filtered
0dd308d885 Add i18n for nothing selected notification 2025-06-25 04:24:57 -07:00
filtered
31ab027da8 [Refactor] Generic TS type dedupe 2025-06-25 04:24:57 -07:00
filtered
9620f833aa [Nope] Zod schema recursive II 2025-06-25 04:24:57 -07:00
filtered
b3042d346a [Nope] Add Zod recursive schema requirements 2025-06-25 04:24:57 -07:00
Jin Yi
e17ca7ce71 fix: node migration TypeError (#4260) 2025-06-25 03:01:40 -07:00
Comfy Org PR Bot
77d2cae301 1.23.2 (#4266)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-25 00:48:39 +00:00
Comfy Org PR Bot
164a4c4c25 [chore] Update Comfy Registry API types from comfy-api@af72ba5 (#4264)
Co-authored-by: bmcomfy <214909599+bmcomfy@users.noreply.github.com>
2025-06-24 14:57:41 -07:00
Jin Yi
47145ce4b8 [Manager] Modal UI Adjustment (Align with Design) (#4222) 2025-06-23 21:30:56 -07:00
Christian Byrne
6cf77a9814 [Manager] Fix bug: installed packs metadata not re-fetched after installations (#4254) 2025-06-23 04:37:50 -07:00
Christian Byrne
886e4908d4 [Manager] Fix flush timing issue when switching tabs (#4253) 2025-06-23 03:49:47 -07:00
Christian Byrne
24cbc41832 [Manager] Fix bug: opening modal when last focused tab was 'Installed' always shows empty list (#4252) 2025-06-23 02:41:15 -07:00
Christian Byrne
a80a939324 Fix: virtual grid scrolling bug when container is rendered with emtpy items (switching tabs) (#4251) 2025-06-23 00:13:46 -07:00
Christian Byrne
8e2d7cabba Fix bug: drag-and-drop, copy-paste, and upload don't work in nodes that specify upload folder that isn't 'input' (#4186) 2025-06-22 20:18:36 -07:00
Christian Byrne
e8dd26ff59 [Manager] Fix: When using registry search provider, results not properly paginated' (#4249) 2025-06-22 20:05:37 -07:00
Christian Byrne
3a1bd1829a [feat] Add auto-refresh on task completion for RemoteWidget nodes (#4191)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-22 17:30:24 -07:00
ComfyUI Wiki
2f9dcd1669 Fix: fix typo in Lite Graph settings (#4245) 2025-06-22 08:32:09 +00:00
filtered
e23547dd5a [TS] Remove expect-error (type fix) (#4235) 2025-06-21 20:52:35 -07:00
Comfy Org PR Bot
f0f40bc39b 1.23.1 (#4234)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-21 18:37:51 +00:00
Christian Byrne
4b32786ef5 [Manager] Update Algolia mappings (#4230)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-21 11:09:14 -07:00
Comfy Org PR Bot
9942b17388 [chore] Update Comfy Registry API types from comfy-api@4286a10 (#4229)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-06-20 15:47:04 -07:00
Christian Byrne
b99214bf5e [feat] Show version-specific missing core nodes in workflow warnings (#4227)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-20 15:33:47 -07:00
Christian Byrne
2ef760c599 [Manager] Keep progress dialog on top using priority system (#4225) 2025-06-20 15:22:42 -07:00
Christian Byrne
429ab6c365 [Manager] Fix "total nodes" count when selecting multiple packs (#4228) 2025-06-20 15:20:26 -07:00
ComfyUI Wiki
b7693ae9f5 Fix typo in 3D settings (#4224) 2025-06-20 13:26:40 -07:00
filtered
ebedf1074d [CI] Fix intermittent actions/cache errors (#4220) 2025-06-18 03:55:05 -07:00
filtered
0832347f47 [CI] Fix intermittent failure when using actions/cache (#4219) 2025-06-18 01:24:42 -07:00
filtered
c745af0f25 [Test] Fix vitest scope overlaps playwright tests (#4218) 2025-06-18 01:08:30 -07:00
Comfy Org PR Bot
8c05266b83 1.23.0 (#4217)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-18 00:32:54 -07:00
Jin Yi
fa14ec52f4 [Manager] Impletent “Install All” button (#4196)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: comfy-waifu <comfywaifu.ai@gmail.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
2025-06-18 10:52:24 +09:00
49 changed files with 2108 additions and 211 deletions

View File

@@ -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
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
})

View File

@@ -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>

View File

@@ -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

View 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>

View File

@@ -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
}
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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<{

View File

@@ -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))

View File

@@ -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>

View File

@@ -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 ?? []
}

View File

@@ -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
)

View File

@@ -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) {

View File

@@ -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
}
}

View 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
}
}

View File

@@ -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 =

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "最終更新日",

View File

@@ -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": "마지막 업데이트",

View File

@@ -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": "Последнее обновление",

View File

@@ -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": "画面",

View File

@@ -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

View File

@@ -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,

View File

@@ -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'
})
)
}
/**

View File

@@ -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:

View File

@@ -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)

View File

@@ -213,6 +213,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
refreshInstalledList,
// Pack actions
installPack,

View File

@@ -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
}

View File

@@ -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']
}
/**

View File

@@ -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?: {

View File

@@ -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

View File

@@ -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)] = {

View File

@@ -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)
}
}

View File

@@ -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')
})
})

View 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)
})
})
})

View File

@@ -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
)
})
})
})

View File

@@ -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', () => {

View File

@@ -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)
})

View 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')
})
})
})

View File

@@ -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']
}