Compare commits
110 Commits
v1.22.2-su
...
v1.23.2-su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
622d97ff48 | ||
|
|
47729c1a08 | ||
|
|
55cf65ec41 | ||
|
|
683d81885a | ||
|
|
586f882423 | ||
|
|
4df20a3055 | ||
|
|
c462d356c1 | ||
|
|
6b3d89ee93 | ||
|
|
9bc4f66982 | ||
|
|
6926d449bc | ||
|
|
11b71bb820 | ||
|
|
586314e0da | ||
|
|
5acfe4ad98 | ||
|
|
aefc5eb078 | ||
|
|
cf9af94fac | ||
|
|
ee93e36ee2 | ||
|
|
9f0b22a5d8 | ||
|
|
76d6911ffa | ||
|
|
b8740c6ac3 | ||
|
|
27d33c24de | ||
|
|
9b488da973 | ||
|
|
c3065ff07b | ||
|
|
a5a1f8cf8a | ||
|
|
1aac2d52ac | ||
|
|
d2369c8c49 | ||
|
|
2afd295ad9 | ||
|
|
66fbdad4d2 | ||
|
|
b02408f517 | ||
|
|
2cdf547fdd | ||
|
|
d755210d03 | ||
|
|
6e89b196c2 | ||
|
|
97faee8879 | ||
|
|
7d568e13e5 | ||
|
|
c57c391659 | ||
|
|
cb91c3770c | ||
|
|
b0fc8efa6b | ||
|
|
842ec58511 | ||
|
|
060540ae80 | ||
|
|
962834e19c | ||
|
|
d018a69356 | ||
|
|
c84218d6bb | ||
|
|
6f9c481b38 | ||
|
|
bdc1ac1004 | ||
|
|
12e1508203 | ||
|
|
98f5216ddf | ||
|
|
b2550f6351 | ||
|
|
b3b0b95646 | ||
|
|
4ca5a92108 | ||
|
|
0a40c11b7d | ||
|
|
9d4537e803 | ||
|
|
4388cbe4a4 | ||
|
|
518faebf69 | ||
|
|
5685cb6748 | ||
|
|
fc191a1e03 | ||
|
|
f73be5d72a | ||
|
|
c76635ce7f | ||
|
|
20833e5090 | ||
|
|
359e9288ec | ||
|
|
4232e0503b | ||
|
|
a3615b3824 | ||
|
|
0fe0519531 | ||
|
|
2cd315a2bf | ||
|
|
7623711b40 | ||
|
|
dba8716fce | ||
|
|
15a2b37c93 | ||
|
|
bbd1ca234d | ||
|
|
b0fc736260 | ||
|
|
b65440e1c2 | ||
|
|
6918aa830b | ||
|
|
a5d0bc3198 | ||
|
|
f41ae1d408 | ||
|
|
1300a1351b | ||
|
|
5129cfa5a7 | ||
|
|
99c7ecfa82 | ||
|
|
77968fed6d | ||
|
|
09d17fec14 | ||
|
|
bff802eeeb | ||
|
|
71bbca613f | ||
|
|
6f8a91b0c1 | ||
|
|
f95f014fde | ||
|
|
d88a227e7c | ||
|
|
0dd308d885 | ||
|
|
31ab027da8 | ||
|
|
9620f833aa | ||
|
|
b3042d346a | ||
|
|
e17ca7ce71 | ||
|
|
77d2cae301 | ||
|
|
164a4c4c25 | ||
|
|
47145ce4b8 | ||
|
|
6cf77a9814 | ||
|
|
886e4908d4 | ||
|
|
24cbc41832 | ||
|
|
a80a939324 | ||
|
|
8e2d7cabba | ||
|
|
e8dd26ff59 | ||
|
|
3a1bd1829a | ||
|
|
2f9dcd1669 | ||
|
|
e23547dd5a | ||
|
|
f0f40bc39b | ||
|
|
4b32786ef5 | ||
|
|
9942b17388 | ||
|
|
b99214bf5e | ||
|
|
2ef760c599 | ||
|
|
429ab6c365 | ||
|
|
b7693ae9f5 | ||
|
|
ebedf1074d | ||
|
|
0832347f47 | ||
|
|
c745af0f25 | ||
|
|
8c05266b83 | ||
|
|
fa14ec52f4 |
10
.github/workflows/test-ui.yaml
vendored
@@ -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
|
||||
|
||||
@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can be added to canvas using node library sidebar', async ({
|
||||
test.skip('Can be added to canvas using node library sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
|
||||
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
test.skip('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const makeGroup = async (name, type1, type2) => {
|
||||
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
|
||||
expect(visibleInputCount).toBe(2)
|
||||
})
|
||||
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe('Canvas Right Click Menu', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
test.skip('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.rightClickCanvas()
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
12
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.22.2",
|
||||
"version": "1.23.2-sub.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.22.2",
|
||||
"version": "1.23.2-sub.14",
|
||||
"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.15.15",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.18",
|
||||
"@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.15.15",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.15.tgz",
|
||||
"integrity": "sha512-otOKgTxNPV6gEa6PW1fHGMMF8twjnZkP0vWQhGsRISK4vN8tPfX8O9sC9Hnq3nV8axaMv4/Ff49+7mMVcFEKeA==",
|
||||
"version": "0.16.0-sub.18",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0-sub.18.tgz",
|
||||
"integrity": "sha512-Au7I6zc9XbdsINBPNq9qIuccaBdlN7MMh6LUCUaJBjSgKDWCLfK1lpn1jF4B5cFTfEt/tzRkwLLUK6odV8A3gA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.2",
|
||||
"version": "1.23.2-sub.14",
|
||||
"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.15.15",
|
||||
"@comfyorg/litegraph": "^0.16.0-sub.18",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
@@ -14,28 +11,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
}
|
||||
}))
|
||||
})
|
||||
@@ -43,7 +42,7 @@ const items = computed(() => {
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: async () => {
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -55,22 +54,32 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.subgraph-breadcrumb {
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
color: #d26565;
|
||||
user-select: none;
|
||||
text-shadow:
|
||||
1px 1px 0 #000,
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
0 0 0.375rem #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<template>
|
||||
<hr
|
||||
<div
|
||||
:class="{
|
||||
'm-0': true,
|
||||
'border-t': orientation === 'horizontal',
|
||||
'border-l': orientation === 'vertical',
|
||||
'h-full': orientation === 'vertical',
|
||||
'w-full': orientation === 'horizontal'
|
||||
'content-divider': true,
|
||||
'content-divider--horizontal': orientation === 'horizontal',
|
||||
'content-divider--vertical': orientation === 'vertical'
|
||||
}"
|
||||
:style="{
|
||||
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
|
||||
borderWidth: `${width}px !important`
|
||||
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -29,3 +26,25 @@ const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-divider {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-divider--horizontal {
|
||||
width: 100%;
|
||||
height: v-bind('width + "px"');
|
||||
}
|
||||
|
||||
.content-divider--vertical {
|
||||
height: 100%;
|
||||
width: v-bind('width + "px"');
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,12 +92,21 @@ whenever(
|
||||
const updateItemSize = () => {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
|
||||
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
itemHeight.value = firstItem.clientHeight
|
||||
}
|
||||
if (itemWidth.value !== firstItem.clientWidth) {
|
||||
itemWidth.value = firstItem.clientWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
})
|
||||
|
||||
@@ -50,4 +50,17 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
title="Missing Node Types"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
option-label="label"
|
||||
@@ -31,6 +32,12 @@
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<PackInstallButton
|
||||
:disabled="isLoading || !!error || missingNodePacks.length === 0"
|
||||
:node-packs="missingNodePacks"
|
||||
variant="black"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +48,9 @@ import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
@@ -52,6 +62,10 @@ const props = defineProps<{
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
|
||||
95
src/components/dialog/content/MissingCoreNodesMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<Message
|
||||
v-if="hasMissingCoreNodes"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
class="my-2 mx-2"
|
||||
:pt="{
|
||||
root: { class: 'flex-col' },
|
||||
text: { class: 'flex-1' }
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
{{
|
||||
currentComfyUIVersion
|
||||
? $t('loadWorkflowWarning.outdatedVersion', {
|
||||
version: currentComfyUIVersion
|
||||
})
|
||||
: $t('loadWorkflowWarning.outdatedVersionGeneric')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="[version, nodes] in sortedMissingCoreNodes"
|
||||
:key="version"
|
||||
class="ml-4"
|
||||
>
|
||||
<div
|
||||
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
|
||||
>
|
||||
{{
|
||||
$t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || 'unknown'
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
|
||||
{{ getUniqueNodeNames(nodes).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
const currentComfyUIVersion = ref<string | null>(null)
|
||||
whenever(
|
||||
hasMissingCoreNodes,
|
||||
async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
currentComfyUIVersion.value =
|
||||
systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
|
||||
return nodes
|
||||
.reduce<string[]>((acc, node) => {
|
||||
if (node.type && !acc.includes(node.type)) {
|
||||
acc.push(node.type)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort()
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
|
||||
class="h-full flex flex-col mx-auto overflow-hidden"
|
||||
:aria-label="$t('manager.title')"
|
||||
>
|
||||
<ContentDivider :width="0.3" />
|
||||
<Button
|
||||
v-if="isSmallScreen"
|
||||
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
||||
text
|
||||
severity="secondary"
|
||||
filled
|
||||
class="absolute top-1/2 -translate-y-1/2 z-10"
|
||||
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
|
||||
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
@@ -18,20 +20,19 @@
|
||||
:tabs="tabs"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto pr-80"
|
||||
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
|
||||
:class="{
|
||||
'transition-all duration-300': isSmallScreen,
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
'transition-all duration-300': isSmallScreen
|
||||
}"
|
||||
>
|
||||
<div class="px-6 pt-6 flex flex-col h-full">
|
||||
<div class="px-6 flex flex-col h-full">
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
:sort-options="sortOptions"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
@@ -58,7 +59,7 @@
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:buffer-rows="4"
|
||||
:grid-style="GRID_STYLE"
|
||||
@approach-end="onApproachEnd"
|
||||
>
|
||||
@@ -76,9 +77,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
|
||||
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
|
||||
<ContentDivider orientation="vertical" :width="0.2" />
|
||||
<div class="flex-1 flex flex-col isolate">
|
||||
<div class="w-full flex flex-col isolate">
|
||||
<InfoPanel
|
||||
v-if="!hasMultipleSelections && selectedNodePack"
|
||||
:node-pack="selectedNodePack"
|
||||
@@ -218,10 +219,6 @@ const {
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
whenever(selectedTab, () => {
|
||||
pageNumber.value = 0
|
||||
})
|
||||
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
@@ -467,9 +464,10 @@ let gridContainer: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
gridContainer = document.getElementById('results-grid')
|
||||
})
|
||||
watch(searchQuery, () => {
|
||||
watch([searchQuery, selectedTab], () => {
|
||||
gridContainer ??= document.getElementById('results-grid')
|
||||
if (gridContainer) {
|
||||
pageNumber.value = 0
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
</div>
|
||||
<ContentDivider :width="0.3" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<aside
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
|
||||
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ScrollPanel class="w-80 mt-7">
|
||||
<ScrollPanel class="flex-1">
|
||||
<Listbox
|
||||
v-model="selectedTab"
|
||||
:options="tabs"
|
||||
@@ -10,20 +10,20 @@
|
||||
list-style="max-height:unset"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-5' },
|
||||
option: { class: 'px-8 py-3 text-lg rounded-xl' },
|
||||
list: { class: 'p-3 gap-2' },
|
||||
option: { class: 'px-4 py-2 text-lg rounded-lg' },
|
||||
optionGroup: { class: 'p-0 text-left text-inherit' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="text-left flex items-center">
|
||||
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
|
||||
<span class="text-lg">{{ slotProps.option.label }}</span>
|
||||
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
|
||||
<span class="text-sm">{{ slotProps.option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<ContentDivider orientation="vertical" />
|
||||
<ContentDivider orientation="vertical" :width="0.3" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="m-0 p-0 rounded-lg border-neutral-700"
|
||||
:class="{
|
||||
'w-full': fullWidth,
|
||||
'w-min-content': !fullWidth
|
||||
}"
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3">
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
@@ -27,12 +29,14 @@ import Button from 'primevue/button'
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
"
|
||||
severity="secondary"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@@ -27,8 +29,10 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<template v-if="nodePack">
|
||||
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
|
||||
<div class="flex flex-col h-full z-40 overflow-hidden relative">
|
||||
<div class="top-0 z-10 px-6 pt-6 w-full">
|
||||
<InfoPanelHeader :node-packs="[nodePack]" />
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
|
||||
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -51,7 +51,11 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
|
||||
if (!pack.latest_version?.version) return []
|
||||
const nodeDefs = await getNodeDefs.call({
|
||||
packId: pack.id,
|
||||
version: pack.latest_version?.version
|
||||
version: pack.latest_version?.version,
|
||||
// Fetch all nodes.
|
||||
// TODO: Render all nodes previews and handle pagination.
|
||||
// For determining length, use the `totalNumberOfPages` field of response
|
||||
limit: 8192
|
||||
})
|
||||
return nodeDefs?.comfy_nodes ?? []
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
<template>
|
||||
<div class="relative w-full p-6">
|
||||
<div class="flex items-center w-full">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-5/12 rounded-2xl'
|
||||
<div class="h-12 flex items-center gap-1 justify-between">
|
||||
<div class="flex items-center w-5/12">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
variant="black"
|
||||
:disabled="isLoading || !!error"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -55,7 +64,9 @@ import AutoComplete, {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import {
|
||||
type SearchOption,
|
||||
SortableAlgoliaField
|
||||
@@ -71,6 +82,7 @@ const { searchResults, sortOptions } = defineProps<{
|
||||
searchResults?: components['schemas']['Node'][]
|
||||
suggestions?: QuerySuggestion[]
|
||||
sortOptions?: SortableField[]
|
||||
isMissingTab?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
@@ -81,6 +93,9 @@ const sortField = defineModel<string>('sortField', {
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
)
|
||||
|
||||
@@ -21,16 +21,14 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(() =>
|
||||
Array.from(domWidgetStore.widgetStates.values())
|
||||
)
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
@@ -39,12 +41,11 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -84,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -192,10 +194,10 @@ watch(
|
||||
// Update the progress of the executing node
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
|
||||
NodeId | null,
|
||||
number | null
|
||||
],
|
||||
[
|
||||
executionStore.executingNodeId,
|
||||
executionStore.executingNodeProgress
|
||||
] satisfies [NodeId | null, number | null],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
@@ -334,6 +336,16 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -28,6 +29,7 @@ import { computed } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-box"
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
canvasStore.nodeSelected
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isDeletable"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
@@ -13,10 +14,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,8 +25,9 @@ const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
||||
return nodes.length === 1 && nodes.some(isImageNode)
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
})
|
||||
|
||||
const openMaskEditor = () => {
|
||||
|
||||
@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
if (disconnectOnReset) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
}
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<div class="w-auto max-w-full">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,15 +3,29 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
interface ImageUploadFormFields {
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
type: ResultItemType
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean,
|
||||
formFields: Partial<ImageUploadFormFields> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -36,6 +50,11 @@ interface ImageUploadOptions {
|
||||
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
||||
*/
|
||||
accept?: string
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
folder?: ResultItemType
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +72,9 @@ export const useNodeImageUpload = (
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
const path = await uploadFile(file, isPastedFile(file), {
|
||||
type: options.folder
|
||||
})
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
|
||||
@@ -18,6 +19,16 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
const startFetchInstalled = async () => {
|
||||
await comfyManagerStore.refreshInstalledList()
|
||||
await startFetch()
|
||||
}
|
||||
|
||||
// When installedPackIds changes, we need to update the nodePacks
|
||||
whenever(installedPackIds, async () => {
|
||||
await startFetch()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -27,7 +38,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
isLoading,
|
||||
isReady,
|
||||
installedPacks: nodePacks,
|
||||
startFetchInstalled: startFetch,
|
||||
startFetchInstalled,
|
||||
filterInstalledPack
|
||||
}
|
||||
}
|
||||
|
||||
76
src/composables/nodePack/useMissingNodes.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -37,6 +37,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -718,6 +719,30 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Move Selected Nodes Right',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useErrorHandling() {
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
const wrapWithErrorHandling =
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -42,6 +42,7 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
const inputOptions = inputData[1]
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const folder: ResultItemType | undefined = image_folder
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
@@ -75,6 +76,7 @@ export const useImageUploadWidget = () => {
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
|
||||
@@ -2,7 +2,9 @@ import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IWidget } from '@comfyorg/litegraph'
|
||||
import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -220,6 +222,46 @@ export function useRemoteWidget<
|
||||
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add auto-refresh toggle widget and execution success listener
|
||||
*/
|
||||
function addAutoRefreshToggle() {
|
||||
let autoRefreshEnabled = false
|
||||
|
||||
// Handler for execution success
|
||||
const handleExecutionSuccess = () => {
|
||||
if (autoRefreshEnabled && widget.refresh) {
|
||||
widget.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle widget
|
||||
const autoRefreshWidget = node.addWidget(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
(value: boolean) => {
|
||||
autoRefreshEnabled = value
|
||||
},
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
|
||||
// Register event listener
|
||||
api.addEventListener('execution_success', handleExecutionSuccess)
|
||||
|
||||
// Cleanup on node removal
|
||||
node.onRemoved = useChainCallback(node.onRemoved, function () {
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess)
|
||||
})
|
||||
|
||||
return autoRefreshWidget
|
||||
}
|
||||
|
||||
// Always add auto-refresh toggle for remote widgets
|
||||
addAutoRefreshToggle()
|
||||
|
||||
return {
|
||||
getCachedValue,
|
||||
getValue,
|
||||
|
||||
@@ -173,5 +173,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
},
|
||||
commandId: 'Comfy.Graph.ConvertToSubgraph'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
@@ -1583,57 +1583,6 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
function addConvertToGroupOptions() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addConvertOption(options, index) {
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const disabled =
|
||||
selected.length < 2 ||
|
||||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
options.splice(index, null, {
|
||||
content: `Convert to Group Node`,
|
||||
disabled,
|
||||
callback: convertSelectedNodesToGroupNode
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addManageOption(options, index) {
|
||||
const groups = app.graph.extra?.groupNodes
|
||||
const disabled = !groups || !Object.keys(groups).length
|
||||
options.splice(index, null, {
|
||||
content: `Manage Group Nodes`,
|
||||
disabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
}
|
||||
|
||||
// Add to canvas
|
||||
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getCanvasMenuOptions.apply(this, arguments)
|
||||
const index = options.findIndex((o) => o?.content === 'Add Group')
|
||||
const insertAt = index === -1 ? options.length - 1 : index + 2
|
||||
addConvertOption(options, insertAt)
|
||||
addManageOption(options, insertAt + 1)
|
||||
return options
|
||||
}
|
||||
|
||||
// Add to nodes
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getNodeMenuOptions.apply(this, arguments)
|
||||
if (!GroupNodeHandler.isGroupNode(node)) {
|
||||
const index = options.findIndex((o) => o?.content === 'Properties')
|
||||
const insertAt = index === -1 ? options.length - 1 : index
|
||||
addConvertOption(options, insertAt)
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||
@@ -1723,9 +1672,6 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
}
|
||||
],
|
||||
setup() {
|
||||
addConvertToGroupOptions()
|
||||
},
|
||||
async beforeConfigureGraph(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: string[]
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Give Feedback"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convert Selection to Subgraph"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Fit Group To Contents"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -816,6 +817,7 @@
|
||||
"Export": "Export",
|
||||
"Export (API)": "Export (API)",
|
||||
"Give Feedback": "Give Feedback",
|
||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||
"Fit Group To Contents": "Fit Group To Contents",
|
||||
"Group Selected Nodes": "Group Selected Nodes",
|
||||
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
||||
@@ -1194,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",
|
||||
@@ -1296,7 +1303,9 @@
|
||||
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
|
||||
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
|
||||
"nothingSelected": "Nothing selected"
|
||||
"nothingSelected": "Nothing selected",
|
||||
"cannotCreateSubgraph": "Cannot create subgraph",
|
||||
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Dar retroalimentación"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir selección en subgrafo"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajustar grupo al contenido"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -693,6 +699,7 @@
|
||||
"ComfyUI Forum": "Foro de ComfyUI",
|
||||
"ComfyUI Issues": "Problemas de ComfyUI",
|
||||
"Contact Support": "Contactar soporte",
|
||||
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
|
||||
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
|
||||
"Delete Selected Items": "Eliminar elementos seleccionados",
|
||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||
@@ -1361,6 +1368,7 @@
|
||||
"title": "Comienza con una Plantilla"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "No se puede crear el subgrafo",
|
||||
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
|
||||
"dropFileError": "No se puede procesar el elemento soltado: {error}",
|
||||
"emptyCanvas": "Lienzo vacío",
|
||||
@@ -1369,6 +1377,7 @@
|
||||
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
|
||||
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
|
||||
"failedToApplyTexture": "Error al aplicar textura",
|
||||
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
|
||||
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
|
||||
"failedToDownloadFile": "Error al descargar el archivo",
|
||||
"failedToExportModel": "Error al exportar modelo como {format}",
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Retour d'information"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir la sélection en sous-graphe"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajuster le groupe au contenu"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -693,6 +699,7 @@
|
||||
"ComfyUI Forum": "Forum ComfyUI",
|
||||
"ComfyUI Issues": "Problèmes de ComfyUI",
|
||||
"Contact Support": "Contacter le support",
|
||||
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
|
||||
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
|
||||
"Delete Selected Items": "Supprimer les éléments sélectionnés",
|
||||
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
||||
@@ -1361,6 +1368,7 @@
|
||||
"title": "Commencez avec un modèle"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
|
||||
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
|
||||
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
|
||||
"emptyCanvas": "Toile vide",
|
||||
@@ -1369,6 +1377,7 @@
|
||||
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
|
||||
"failedToApplyTexture": "Échec de l'application de la texture",
|
||||
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
|
||||
"failedToCreateCustomer": "Échec de la création du client : {error}",
|
||||
"failedToDownloadFile": "Échec du téléchargement du fichier",
|
||||
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "フィードバック"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "選択範囲をサブグラフに変換"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "グループを内容に合わせて調整"
|
||||
},
|
||||
|
||||
@@ -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": "最終更新日",
|
||||
@@ -693,6 +699,7 @@
|
||||
"ComfyUI Forum": "ComfyUI フォーラム",
|
||||
"ComfyUI Issues": "ComfyUIの問題",
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
@@ -1361,6 +1368,7 @@
|
||||
"title": "テンプレートを利用して開始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "サブグラフを作成できません",
|
||||
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
|
||||
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
|
||||
"emptyCanvas": "キャンバスが空です",
|
||||
@@ -1369,6 +1377,7 @@
|
||||
"errorSaveSetting": "設定{id}の保存エラー: {err}",
|
||||
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
|
||||
"failedToApplyTexture": "テクスチャの適用に失敗しました",
|
||||
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
|
||||
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
|
||||
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
|
||||
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "피드백"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "선택 영역을 서브그래프로 변환"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "그룹을 내용에 맞게 맞추기"
|
||||
},
|
||||
|
||||
@@ -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": "마지막 업데이트",
|
||||
@@ -693,6 +699,7 @@
|
||||
"ComfyUI Forum": "ComfyUI 포럼",
|
||||
"ComfyUI Issues": "ComfyUI 이슈 페이지",
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
@@ -1361,6 +1368,7 @@
|
||||
"title": "템플릿으로 시작하기"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
|
||||
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
|
||||
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
|
||||
"emptyCanvas": "빈 캔버스",
|
||||
@@ -1369,6 +1377,7 @@
|
||||
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
|
||||
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
|
||||
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
|
||||
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
|
||||
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
|
||||
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
|
||||
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Обратная связь"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Преобразовать выделенное в подграф"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Подогнать группу к содержимому"
|
||||
},
|
||||
|
||||
@@ -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": "Последнее обновление",
|
||||
@@ -693,6 +699,7 @@
|
||||
"ComfyUI Forum": "Форум ComfyUI",
|
||||
"ComfyUI Issues": "Проблемы ComfyUI",
|
||||
"Contact Support": "Связаться с поддержкой",
|
||||
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
|
||||
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||
@@ -1361,6 +1368,7 @@
|
||||
"title": "Начните с шаблона"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Невозможно создать подграф",
|
||||
"couldNotDetermineFileType": "Не удалось определить тип файла",
|
||||
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
|
||||
"emptyCanvas": "Пустой холст",
|
||||
@@ -1369,6 +1377,7 @@
|
||||
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
|
||||
"failedToApplyTexture": "Не удалось применить текстуру",
|
||||
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
|
||||
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
|
||||
"failedToDownloadFile": "Не удалось скачать файл",
|
||||
"failedToExportModel": "Не удалось экспортировать модель как {format}",
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "反馈"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "将选区转换为子图"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "适应节点框到内容"
|
||||
},
|
||||
|
||||
@@ -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": "最后更新",
|
||||
@@ -693,6 +699,7 @@
|
||||
"ComfyUI Forum": "ComfyUI 论坛",
|
||||
"ComfyUI Issues": "ComfyUI 问题",
|
||||
"Contact Support": "联系支持",
|
||||
"Convert Selection to Subgraph": "将选中内容转换为子图",
|
||||
"Convert selected nodes to group node": "将选中节点转换为组节点",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
"Desktop User Guide": "桌面端用户指南",
|
||||
@@ -1037,9 +1044,9 @@
|
||||
"Extension": "扩展",
|
||||
"General": "常规",
|
||||
"Graph": "画面",
|
||||
"Group": "组节点",
|
||||
"Group": "组",
|
||||
"Keybinding": "快捷键",
|
||||
"Light": "浅色",
|
||||
"Light": "光照",
|
||||
"Link": "连线",
|
||||
"LinkRelease": "释放链接",
|
||||
"LiteGraph": "画面",
|
||||
@@ -1361,6 +1368,7 @@
|
||||
"title": "从模板开始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "无法创建子图",
|
||||
"couldNotDetermineFileType": "无法确定文件类型",
|
||||
"dropFileError": "无法处理掉落的项目:{error}",
|
||||
"emptyCanvas": "画布为空",
|
||||
@@ -1369,6 +1377,7 @@
|
||||
"errorSaveSetting": "保存设置 {id} 出错:{err}",
|
||||
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
|
||||
"failedToApplyTexture": "应用纹理失败",
|
||||
"failedToConvertToSubgraph": "无法将项目转换为子图",
|
||||
"failedToCreateCustomer": "创建客户失败:{error}",
|
||||
"failedToDownloadFile": "文件下载失败",
|
||||
"failedToExportModel": "无法将模型导出为 {format}",
|
||||
|
||||
@@ -41,10 +41,10 @@ const zModelFile = z.object({
|
||||
|
||||
const zGraphState = z
|
||||
.object({
|
||||
lastGroupid: z.number().optional(),
|
||||
lastNodeId: z.number().optional(),
|
||||
lastLinkId: z.number().optional(),
|
||||
lastRerouteId: z.number().optional()
|
||||
lastGroupId: z.number(),
|
||||
lastNodeId: z.number(),
|
||||
lastLinkId: z.number(),
|
||||
lastRerouteId: z.number()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -214,6 +214,32 @@ const zComfyNode = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zSubgraphIO = zNodeInput.extend({
|
||||
/** Slot ID (internal; never changes once instantiated). */
|
||||
id: z.string().uuid(),
|
||||
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
|
||||
type: z.string(),
|
||||
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
|
||||
linkIds: z.array(z.number()).optional()
|
||||
})
|
||||
|
||||
const zSubgraphInstance = z
|
||||
.object({
|
||||
id: zNodeId,
|
||||
type: z.string().uuid(),
|
||||
pos: zVector2,
|
||||
size: zVector2,
|
||||
flags: zFlags,
|
||||
order: z.number(),
|
||||
mode: z.number(),
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
widgets_values: zWidgetValues.optional(),
|
||||
color: z.string().optional(),
|
||||
bgcolor: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zGroup = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
@@ -248,9 +274,22 @@ const zExtra = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zGraphDefinitions = z.object({
|
||||
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
|
||||
})
|
||||
|
||||
export const zBaseExportableGraph = z.object({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
config: zConfig.optional().nullable(),
|
||||
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
|
||||
subgraphs: z.array(zSubgraphInstance).optional()
|
||||
})
|
||||
|
||||
/** Schema version 0.4 */
|
||||
export const zComfyWorkflow = z
|
||||
.object({
|
||||
export const zComfyWorkflow = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
last_node_id: zNodeId,
|
||||
@@ -262,13 +301,47 @@ export const zComfyWorkflow = z
|
||||
config: zConfig.optional().nullable(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
version: z.number(),
|
||||
models: z.array(zModelFile).optional()
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: zGraphDefinitions.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/** Required for recursive definition of subgraphs. */
|
||||
interface ComfyWorkflow1BaseType {
|
||||
id?: string
|
||||
revision?: number
|
||||
version: 1
|
||||
models?: z.infer<typeof zModelFile>[]
|
||||
state: z.infer<typeof zGraphState>
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.input<typeof zGroup>[]
|
||||
nodes: z.input<typeof zComfyNode>[]
|
||||
links?: z.input<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.input<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.input<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.output<typeof zGroup>[]
|
||||
nodes: z.output<typeof zComfyNode>[]
|
||||
links?: z.output<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.output<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.output<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema version 1 */
|
||||
export const zComfyWorkflow1 = z
|
||||
.object({
|
||||
export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
version: z.literal(1),
|
||||
@@ -280,7 +353,96 @@ export const zComfyWorkflow1 = z
|
||||
floatingLinks: z.array(zComfyLinkObject).optional(),
|
||||
reroutes: z.array(zReroute).optional(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
models: z.array(zModelFile).optional()
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => z.array(zSubgraphDefinition)
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zExportedSubgraphIONode = z.object({
|
||||
id: zNodeId,
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
pinned: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zExposedWidget = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
interface SubgraphDefinitionBase<
|
||||
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
|
||||
> {
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
outputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExposedWidget>[]
|
||||
: z.output<typeof zExposedWidget>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<T>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A subgraph definition `worfklow.definitions.subgraphs` */
|
||||
export const zSubgraphDefinition = zComfyWorkflow1
|
||||
.extend({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets: z.array(zExposedWidget).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => zSubgraphDefinition.array()
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,9 +39,11 @@ import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -72,7 +74,6 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
import { defaultGraph } from './defaultGraph'
|
||||
import { pruneWidgets } from './domWidget'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
@@ -715,25 +716,23 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
#addAfterConfigureHandler() {
|
||||
const app = this
|
||||
const onConfigure = app.graph.onConfigure
|
||||
app.graph.onConfigure = function (this: LGraph, ...args) {
|
||||
const { graph } = this
|
||||
const { onConfigure } = graph
|
||||
graph.onConfigure = function (...args) {
|
||||
fixLinkInputSlots(this)
|
||||
|
||||
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
||||
for (const node of app.graph.nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
node.onGraphConfigured?.()
|
||||
}
|
||||
|
||||
const r = onConfigure?.apply(this, args)
|
||||
|
||||
// Fire after onConfigure, used by primitives to generate widget using input nodes config
|
||||
for (const node of app.graph.nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
node.onAfterGraphConfigured?.()
|
||||
}
|
||||
|
||||
pruneWidgets(this.nodes)
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -765,6 +764,21 @@ export class ComfyApp {
|
||||
|
||||
this.#graph = new LGraph()
|
||||
|
||||
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
|
||||
this.graph.events.addEventListener('subgraph-created', (e) => {
|
||||
try {
|
||||
const { subgraph, data } = e.detail
|
||||
useSubgraphService().registerNewSubgraph(subgraph, data)
|
||||
} catch (err) {
|
||||
console.error('Failed to register subgraph', err)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: 'Failed to register subgraph',
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.#addAfterConfigureHandler()
|
||||
|
||||
this.canvas = new LGraphCanvas(canvasEl, this.graph)
|
||||
@@ -777,6 +791,30 @@ export class ComfyApp {
|
||||
LiteGraph.alt_drag_do_clone_nodes = true
|
||||
LiteGraph.macGesturesRequireMac = false
|
||||
|
||||
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
||||
'litegraph:set-graph',
|
||||
(e) => {
|
||||
// Assertion: Not yet defined in litegraph.
|
||||
const { newGraph } = e.detail
|
||||
|
||||
const nodeSet = new Set(newGraph.nodes)
|
||||
const widgetStore = useDomWidgetStore()
|
||||
|
||||
// Assertions: UnwrapRef
|
||||
for (const { widget } of widgetStore.activeWidgetStates) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
widgetStore.deactivateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { widget } of widgetStore.inactiveWidgetStates) {
|
||||
if (nodeSet.has(widget.node)) {
|
||||
widgetStore.activateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
this.graph.start()
|
||||
|
||||
// Ensure the canvas fills the window
|
||||
@@ -1013,6 +1051,7 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
useSubgraphService().loadSubgraphs(graphData)
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingModels: ModelFile[] = []
|
||||
@@ -1210,6 +1249,9 @@ export class ComfyApp {
|
||||
// Allow widgets to run callbacks before a prompt has been queued
|
||||
// e.g. random seed before every gen
|
||||
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
|
||||
}
|
||||
|
||||
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
|
||||
try {
|
||||
@@ -1252,9 +1294,13 @@ export class ComfyApp {
|
||||
executeWidgetsCallback(
|
||||
p.workflow.nodes
|
||||
.map((n) => this.graph.getNodeById(n.id))
|
||||
.filter((n) => !!n) as LGraphNode[],
|
||||
.filter((n) => !!n),
|
||||
'afterQueued'
|
||||
)
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
|
||||
}
|
||||
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
@@ -1650,6 +1696,8 @@ export class ComfyApp {
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
|
||||
useDomWidgetStore().clear()
|
||||
}
|
||||
|
||||
clientPosToCanvasPos(pos: Vector2): Vector2 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import log from 'loglevel'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import { api } from './api'
|
||||
@@ -37,6 +38,10 @@ export class ChangeTracker {
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
|
||||
private subgraphState?: {
|
||||
navigation: string[]
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The workflow that this change tracker is tracking
|
||||
@@ -67,6 +72,8 @@ export class ChangeTracker {
|
||||
scale: app.canvas.ds.scale,
|
||||
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
|
||||
}
|
||||
const navigation = useSubgraphNavigationStore().exportState()
|
||||
this.subgraphState = navigation.length ? { navigation } : undefined
|
||||
}
|
||||
|
||||
restore() {
|
||||
@@ -77,6 +84,16 @@ export class ChangeTracker {
|
||||
if (this.nodeOutputs) {
|
||||
app.nodeOutputs = this.nodeOutputs
|
||||
}
|
||||
if (this.subgraphState) {
|
||||
const { navigation } = this.subgraphState
|
||||
useSubgraphNavigationStore().restoreState(navigation)
|
||||
|
||||
const activeId = navigation.at(-1)
|
||||
if (activeId) {
|
||||
const subgraph = app.graph.subgraphs.get(activeId)
|
||||
if (subgraph) app.canvas.setGraph(subgraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateModified() {
|
||||
@@ -376,7 +393,14 @@ export class ChangeTracker {
|
||||
return false
|
||||
|
||||
// Compare other properties normally
|
||||
for (const key of ['links', 'floatingLinks', 'reroutes', 'groups']) {
|
||||
for (const key of [
|
||||
'links',
|
||||
'floatingLinks',
|
||||
'reroutes',
|
||||
'groups',
|
||||
'definitions',
|
||||
'subgraphs'
|
||||
]) {
|
||||
if (!_.isEqual(a[key], b[key])) {
|
||||
return false
|
||||
}
|
||||
@@ -392,7 +416,12 @@ export class ChangeTracker {
|
||||
function sortGraphNodes(graph: ComfyWorkflowJSON) {
|
||||
return {
|
||||
links: graph.links,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes,
|
||||
groups: graph.groups,
|
||||
extra: graph.extra,
|
||||
definitions: graph.definitions,
|
||||
subgraphs: graph.subgraphs,
|
||||
nodes: graph.nodes.sort((a, b) => {
|
||||
if (typeof a.id === 'number' && typeof b.id === 'number') {
|
||||
return a.id - b.id
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
export interface BaseDOMWidget<V extends object | string>
|
||||
export interface BaseDOMWidget<V extends object | string = object | string>
|
||||
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
|
||||
// ICustomWidget properties
|
||||
type: string
|
||||
@@ -330,9 +330,8 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
export const pruneWidgets = (nodes: LGraphNode[]) => {
|
||||
const nodeSet = new Set(nodes)
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
const widget = widgetState.widget
|
||||
if (!nodeSet.has(widget.node as LGraphNode)) {
|
||||
for (const { widget } of domWidgetStore.widgetStates.values()) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
domWidgetStore.unregisterWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +137,17 @@ export const useDialogService = () => {
|
||||
dialogComponentProps: {
|
||||
closable: true,
|
||||
pt: {
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class:
|
||||
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
|
||||
}
|
||||
},
|
||||
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
|
||||
content: {
|
||||
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
|
||||
},
|
||||
root: { class: 'manager-dialog' }
|
||||
}
|
||||
},
|
||||
props
|
||||
@@ -154,6 +163,7 @@ export const useDialogService = () => {
|
||||
headerComponent: ManagerProgressHeader,
|
||||
footerComponent: ManagerProgressFooter,
|
||||
props: options?.props,
|
||||
priority: 2,
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
modal: false,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import {
|
||||
type IContextMenuValue,
|
||||
LGraphBadge,
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
RenderShape,
|
||||
type Subgraph,
|
||||
SubgraphNode,
|
||||
type Vector2,
|
||||
createBounds
|
||||
} from '@comfyorg/litegraph'
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISerialisableNodeInput,
|
||||
ISerialisableNodeOutput,
|
||||
ISerialisedNode
|
||||
@@ -35,6 +39,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
isImageNode,
|
||||
@@ -56,6 +61,267 @@ export const useLitegraphService = () => {
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
function registerSubgraphNodeDef(
|
||||
nodeDefV1: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
instanceData: ExportedSubgraphInstance
|
||||
) {
|
||||
const node = class ComfyNode extends SubgraphNode {
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(app.graph, subgraph, instanceData)
|
||||
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
this.serialize_widgets = true
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
this.badges.push(
|
||||
new LGraphBadge({
|
||||
text: '⇌',
|
||||
fgColor: '#dad0de',
|
||||
bgColor: '#b3b'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
if (this.id == app.runningNodeId) {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
for (const inputSpec of Object.values(inputs))
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of Object.values(inputs))
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
*/
|
||||
override configure(data: ISerialisedNode): void {
|
||||
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
|
||||
|
||||
// Note: input name is unique in a node definition, so we can lookup
|
||||
// input by name.
|
||||
const inputByName = new Map<string, ISerialisableNodeInput>(
|
||||
data.inputs?.map((input) => [input.name, input]) ?? []
|
||||
)
|
||||
// Inputs defined by the node definition.
|
||||
const definedInputNames = new Set(
|
||||
this.inputs.map((input) => input.name)
|
||||
)
|
||||
const definedInputs = this.inputs.map((input) => {
|
||||
const inputData = inputByName.get(input.name)
|
||||
return inputData
|
||||
? {
|
||||
...inputData,
|
||||
// Whether the input has associated widget follows the
|
||||
// original node definition.
|
||||
..._.pick(input, RESERVED_KEYS.concat('widget'))
|
||||
}
|
||||
: input
|
||||
})
|
||||
// Extra inputs that potentially dynamically added by custom js logic.
|
||||
const extraInputs = data.inputs?.filter(
|
||||
(input) => !definedInputNames.has(input.name)
|
||||
)
|
||||
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
|
||||
|
||||
// Note: output name is not unique, so we cannot lookup output by name.
|
||||
// Use index instead.
|
||||
data.outputs = _.zip(this.outputs, data.outputs).map(
|
||||
([output, outputData]) => {
|
||||
// If there are extra outputs in the serialised node, use them directly.
|
||||
// There are currently custom nodes that dynamically add outputs via
|
||||
// js logic.
|
||||
if (!output) return outputData as ISerialisableNodeOutput
|
||||
|
||||
return outputData
|
||||
? {
|
||||
...outputData,
|
||||
..._.pick(output, RESERVED_KEYS)
|
||||
}
|
||||
: output
|
||||
}
|
||||
)
|
||||
|
||||
data.widgets_values = migrateWidgetsValues(
|
||||
ComfyNode.nodeData.inputs,
|
||||
this.widgets ?? [],
|
||||
data.widgets_values ?? []
|
||||
)
|
||||
|
||||
super.configure(data)
|
||||
}
|
||||
}
|
||||
|
||||
addNodeContextMenuHandler(node)
|
||||
addDrawBackgroundHandler(node)
|
||||
addNodeKeyHandler(node)
|
||||
// Note: Some extensions expects node.comfyClass to be set in
|
||||
// `beforeRegisterNodeDef`.
|
||||
node.prototype.comfyClass = nodeDefV1.name
|
||||
node.comfyClass = nodeDefV1.name
|
||||
|
||||
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
|
||||
node.nodeData = nodeDef
|
||||
LiteGraph.registerNodeType(subgraph.id, node)
|
||||
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
||||
// because `registerNodeType` will overwrite the assignments.
|
||||
node.category = nodeDef.category
|
||||
node.title = nodeDef.display_name || nodeDef.name
|
||||
}
|
||||
|
||||
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
|
||||
const node = class ComfyNode extends LGraphNode {
|
||||
static comfyClass: string
|
||||
@@ -622,8 +888,10 @@ export const useLitegraphService = () => {
|
||||
options
|
||||
)
|
||||
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
app.graph.add(node)
|
||||
graph.add(node)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return node
|
||||
}
|
||||
@@ -665,6 +933,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
return {
|
||||
registerNodeDef,
|
||||
registerSubgraphNodeDef,
|
||||
addNodeOnGraph,
|
||||
getCanvasCenter,
|
||||
goToNode,
|
||||
|
||||
@@ -42,7 +42,13 @@ const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
|
||||
'latest_version_status',
|
||||
'comfy_node_extract_status',
|
||||
'id',
|
||||
'icon_url'
|
||||
'icon_url',
|
||||
'github_stars',
|
||||
'supported_os',
|
||||
'supported_comfyui_version',
|
||||
'supported_comfyui_frontend_version',
|
||||
'supported_accelerators',
|
||||
'banner_url'
|
||||
]
|
||||
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
@@ -74,7 +80,9 @@ const toRegistryPublisher = (
|
||||
* Convert from node pack in Algolia format to Comfy Registry format
|
||||
*/
|
||||
const toRegistryPack = memoize(
|
||||
(algoliaNode: AlgoliaNodePack): RegistryNodePack => {
|
||||
(
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack & { comfy_nodes: string[] } => {
|
||||
return {
|
||||
id: algoliaNode.id ?? algoliaNode.objectID,
|
||||
name: algoliaNode.name,
|
||||
@@ -86,9 +94,18 @@ const toRegistryPack = memoize(
|
||||
icon: algoliaNode.icon_url,
|
||||
latest_version: toRegistryLatestVersion(algoliaNode),
|
||||
publisher: toRegistryPublisher(algoliaNode),
|
||||
// @ts-expect-error comfy_nodes also not in node info
|
||||
comfy_nodes: algoliaNode.comfy_nodes,
|
||||
create_time: algoliaNode.create_time
|
||||
created_at: algoliaNode.create_time,
|
||||
category: algoliaNode.category,
|
||||
author: algoliaNode.author,
|
||||
tags: algoliaNode.tags,
|
||||
github_stars: algoliaNode.github_stars,
|
||||
supported_os: algoliaNode.supported_os,
|
||||
supported_comfyui_version: algoliaNode.supported_comfyui_version,
|
||||
supported_comfyui_frontend_version:
|
||||
algoliaNode.supported_comfyui_frontend_version,
|
||||
supported_accelerators: algoliaNode.supported_accelerators,
|
||||
banner_url: algoliaNode.banner_url,
|
||||
comfy_nodes: algoliaNode.comfy_nodes
|
||||
}
|
||||
},
|
||||
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
||||
@@ -187,9 +204,7 @@ export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
|
||||
case SortableAlgoliaField.Downloads:
|
||||
return pack.downloads ?? 0
|
||||
case SortableAlgoliaField.Created: {
|
||||
// TODO: add create time to backend return type
|
||||
// @ts-expect-error create_time is not in the RegistryNodePack type
|
||||
const createTime = pack.create_time
|
||||
const createTime = pack.created_at
|
||||
return createTime ? new Date(createTime).getTime() : 0
|
||||
}
|
||||
case SortableAlgoliaField.Updated:
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
|
||||
search: isNodeSearch ? undefined : query,
|
||||
comfy_node_search: isNodeSearch ? query : undefined,
|
||||
limit: pageSize,
|
||||
offset: pageNumber * pageSize
|
||||
page: pageNumber + 1 // Registry API uses 1-based pagination
|
||||
}
|
||||
|
||||
const searchResult = await registryStore.search.call(searchParams)
|
||||
|
||||
91
src/services/subgraphService.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
type ExportedSubgraph,
|
||||
type ExportedSubgraphInstance,
|
||||
type Subgraph
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useLitegraphService } from './litegraphService'
|
||||
|
||||
export const useSubgraphService = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
/** Loads a single subgraph definition and registers it with the node def store */
|
||||
function registerLitegraphNode(
|
||||
nodeDef: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
) {
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: -1,
|
||||
type: exportedSubgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
useLitegraphService().registerSubgraphNodeDef(
|
||||
nodeDef,
|
||||
subgraph,
|
||||
instanceData
|
||||
)
|
||||
}
|
||||
|
||||
function createNodeDef(exportedSubgraph: ExportedSubgraph) {
|
||||
const { id, name } = exportedSubgraph
|
||||
|
||||
const nodeDef: ComfyNodeDefV1 = {
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_tooltips: [],
|
||||
name: id,
|
||||
display_name: name,
|
||||
description: `Subgraph node for ${name}`,
|
||||
category: 'subgraph',
|
||||
output_node: false,
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
nodeDefStore.addNodeDef(nodeDef)
|
||||
return nodeDef
|
||||
}
|
||||
|
||||
/** Loads all exported subgraph definitions from workflow */
|
||||
function loadSubgraphs(graphData: ComfyWorkflowJSON) {
|
||||
const subgraphs = graphData.definitions?.subgraphs
|
||||
if (!subgraphs) return
|
||||
|
||||
// Assertion: overriding Zod schema
|
||||
for (const subgraphData of subgraphs as ExportedSubgraph[]) {
|
||||
const subgraph =
|
||||
comfyApp.graph.subgraphs.get(subgraphData.id) ??
|
||||
comfyApp.graph.createSubgraph(subgraphData)
|
||||
|
||||
registerNewSubgraph(subgraph, subgraphData)
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a new subgraph (e.g. user converted from nodes) */
|
||||
function registerNewSubgraph(
|
||||
subgraph: Subgraph,
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
) {
|
||||
const nodeDef = createNodeDef(exportedSubgraph)
|
||||
registerLitegraphNode(nodeDef, subgraph, exportedSubgraph)
|
||||
}
|
||||
|
||||
return {
|
||||
loadSubgraphs,
|
||||
registerNewSubgraph
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -20,6 +21,7 @@ export const useWorkflowService = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
@@ -285,11 +287,8 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const beforeLoadNewGraph = () => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker.store()
|
||||
}
|
||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
||||
domWidgetStore.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,8 +344,7 @@ export const useWorkflowService = () => {
|
||||
options: { position?: Vector2 } = {}
|
||||
) => {
|
||||
const loadedWorkflow = await workflow.load()
|
||||
const data = loadedWorkflow.initialState
|
||||
const workflowJSON = data
|
||||
const workflowJSON = toRaw(loadedWorkflow.initialState)
|
||||
const old = localStorage.getItem('litegrapheditor_clipboard')
|
||||
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
|
||||
// serialisation schema.
|
||||
|
||||
@@ -213,6 +213,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
isPackInstalled: isInstalledPackId,
|
||||
isPackEnabled: isEnabledPackId,
|
||||
getInstalledPackVersion,
|
||||
refreshInstalledList,
|
||||
|
||||
// Pack actions
|
||||
installPack,
|
||||
|
||||
@@ -40,6 +40,7 @@ interface DialogInstance {
|
||||
contentProps: Record<string, any>
|
||||
footerComponent?: Component
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ShowDialogOptions {
|
||||
@@ -50,6 +51,12 @@ export interface ShowDialogOptions {
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
/**
|
||||
* Optional priority for dialog stacking.
|
||||
* A dialog will never be shown above a dialog with a higher priority.
|
||||
* @default 1
|
||||
*/
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
@@ -57,13 +64,29 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
|
||||
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
/**
|
||||
* Inserts a dialog into the stack at the correct position based on priority.
|
||||
* Higher priority dialogs are placed before lower priority ones.
|
||||
*/
|
||||
function insertDialogByPriority(dialog: DialogInstance) {
|
||||
const insertIndex = dialogStack.value.findIndex(
|
||||
(d) => d.priority <= dialog.priority
|
||||
)
|
||||
|
||||
dialogStack.value.splice(
|
||||
insertIndex === -1 ? dialogStack.value.length : insertIndex,
|
||||
0,
|
||||
dialog
|
||||
)
|
||||
}
|
||||
|
||||
function riseDialog(options: { key: string }) {
|
||||
const dialogKey = options.key
|
||||
|
||||
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
|
||||
if (index !== -1) {
|
||||
const dialogs = dialogStack.value.splice(index, 1)
|
||||
dialogStack.value.push(...dialogs)
|
||||
const [dialog] = dialogStack.value.splice(index, 1)
|
||||
insertDialogByPriority(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +108,13 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
priority?: number
|
||||
}) {
|
||||
if (dialogStack.value.length >= 10) {
|
||||
dialogStack.value.shift()
|
||||
}
|
||||
|
||||
const dialog = {
|
||||
const dialog: DialogInstance = {
|
||||
key: options.key,
|
||||
visible: true,
|
||||
title: options.title,
|
||||
@@ -102,6 +126,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
: undefined,
|
||||
component: markRaw(options.component),
|
||||
contentProps: { ...options.props },
|
||||
priority: options.priority ?? 1,
|
||||
dialogComponentProps: {
|
||||
maximizable: false,
|
||||
modal: true,
|
||||
@@ -110,6 +135,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
dismissableMask: true,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
// @ts-expect-error TODO: fix this
|
||||
onMaximize: () => {
|
||||
dialog.dialogComponentProps.maximized = true
|
||||
},
|
||||
@@ -128,7 +154,8 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
dialogStack.value.push(dialog)
|
||||
|
||||
insertDialogByPriority(dialog)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Stores all DOM widgets that are used in the canvas.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, markRaw, ref } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref } from 'vue'
|
||||
|
||||
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
@@ -13,11 +13,20 @@ export interface DomWidgetState extends PositionConfig {
|
||||
visible: boolean
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
/** If the widget belongs to the current graph/subgraph. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
|
||||
|
||||
const activeWidgetStates = computed(() =>
|
||||
[...widgetStates.value.values()].filter((state) => state.active)
|
||||
)
|
||||
const inactiveWidgetStates = computed(() =>
|
||||
[...widgetStates.value.values()].filter((state) => !state.active)
|
||||
)
|
||||
|
||||
// Register a widget with the store
|
||||
const registerWidget = <V extends object | string>(
|
||||
widget: BaseDOMWidget<V>
|
||||
@@ -28,7 +37,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
readonly: false,
|
||||
zIndex: 0,
|
||||
pos: [0, 0],
|
||||
size: [0, 0]
|
||||
size: [0, 0],
|
||||
active: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,9 +47,28 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
widgetStates.value.delete(widgetId)
|
||||
}
|
||||
|
||||
const activateWidget = (widgetId: string) => {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = true
|
||||
}
|
||||
|
||||
const deactivateWidget = (widgetId: string) => {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = false
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
widgetStates.value.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
widgetStates,
|
||||
activeWidgetStates,
|
||||
inactiveWidgetStates,
|
||||
registerWidget,
|
||||
unregisterWidget
|
||||
unregisterWidget,
|
||||
activateWidget,
|
||||
deactivateWidget,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LGraph, Subgraph } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -20,9 +21,9 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
import { ComfyWorkflow } from './workflowStore'
|
||||
import { useCanvasStore } from './graphStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
|
||||
|
||||
export interface QueuedPrompt {
|
||||
/**
|
||||
@@ -37,6 +38,9 @@ export interface QueuedPrompt {
|
||||
}
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const clientId = ref<string | null>(null)
|
||||
const activePromptId = ref<string | null>(null)
|
||||
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
|
||||
@@ -54,12 +58,64 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (!canvasState) return null
|
||||
|
||||
return (
|
||||
canvasState.nodes.find(
|
||||
(n: ComfyNode) => String(n.id) === executingNodeId.value
|
||||
) ?? null
|
||||
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get the subgraph objects for the given subgraph instance IDs
|
||||
* @param currentGraph The current graph
|
||||
* @param subgraphNodeIds The instance IDs
|
||||
* @param subgraphs The subgraphs
|
||||
* @returns The subgraphs that correspond to each of the instance IDs.
|
||||
*/
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
// Last segment is the node portion; nothing to do.
|
||||
if (subgraphNodeIds.length === 1) return subgraphs
|
||||
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = workflowStore.activeSubgraph
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the hierarchical ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
subgraph.rootGraph,
|
||||
subgraphNodeIds
|
||||
)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the progress of the currently executing node, if any
|
||||
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
|
||||
const executingNodeProgress = computed(() =>
|
||||
@@ -132,7 +188,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
activePrompt.value.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>) {
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>): void {
|
||||
// Clear the current node progress when a new node starts executing
|
||||
_executingNodeProgress.value = null
|
||||
|
||||
@@ -142,12 +198,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// Seems sometimes nodes that are cached fire executing but not executed
|
||||
activePrompt.value.nodes[executingNodeId.value] = true
|
||||
}
|
||||
executingNodeId.value = e.detail
|
||||
if (executingNodeId.value === null) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
if (typeof e.detail === 'string') {
|
||||
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
|
||||
} else {
|
||||
executingNodeId.value = e.detail
|
||||
if (executingNodeId.value === null) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activePromptId.value = null
|
||||
}
|
||||
activePromptId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,19 +228,31 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
lastExecutionError.value = e.detail
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
const nodeIdStr = String(nodeId)
|
||||
return nodeIdStr.includes(':')
|
||||
? workflowStore.executionIdToCurrentId(nodeIdStr)
|
||||
: nodeIdStr
|
||||
}
|
||||
|
||||
function handleProgressText(e: CustomEvent<ProgressTextWsMessage>) {
|
||||
const { nodeId, text } = e.detail
|
||||
if (!text || !nodeId) return
|
||||
|
||||
const node = app.graph.getNodeById(nodeId)
|
||||
// Handle hierarchical node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
|
||||
useNodeProgressText().showTextPreview(node, text)
|
||||
}
|
||||
|
||||
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
|
||||
const { node_id, component, props = {} } = e.detail
|
||||
const node = app.graph.getNodeById(node_id)
|
||||
const { node_id: nodeId, component, props = {} } = e.detail
|
||||
|
||||
// Handle hierarchical node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
|
||||
if (component === 'ChatHistoryWidget') {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
||||
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
|
||||
|
||||
export const useTitleEditorStore = defineStore('titleEditor', () => {
|
||||
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
|
||||
@@ -31,6 +31,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
|
||||
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
|
||||
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
|
||||
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas.value) throw new Error('getCanvas: canvas is null')
|
||||
@@ -42,6 +43,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
selectedItems,
|
||||
nodeSelected,
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
updateSelectedItems,
|
||||
getCanvas
|
||||
}
|
||||
|
||||
@@ -306,8 +306,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
}
|
||||
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
|
||||
// Frontend-only nodes don't have nodeDef
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Optional chaining used in index
|
||||
// @ts-expect-error Optional chaining used in index
|
||||
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
|
||||
}
|
||||
|
||||
|
||||
87
src/stores/subgraphNavigationStore.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, shallowRef, watch } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isNonNullish } from '@/utils/typeGuardUtil'
|
||||
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
|
||||
/**
|
||||
* Stores the current subgraph navigation state; a stack representing subgraph
|
||||
* navigation history from the root graph to the subgraph that is currently
|
||||
* open.
|
||||
*/
|
||||
export const useSubgraphNavigationStore = defineStore(
|
||||
'subgraphNavigation',
|
||||
() => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
/** The currently opened subgraph. */
|
||||
const activeSubgraph = shallowRef<Subgraph>()
|
||||
|
||||
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
|
||||
const idStack = shallowReactive<string[]>([])
|
||||
|
||||
/**
|
||||
* A stack representing subgraph navigation history from the root graph to
|
||||
* the current opened subgraph.
|
||||
*/
|
||||
const navigationStack = computed(() =>
|
||||
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore the navigation stack from a list of subgraph IDs.
|
||||
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
|
||||
* @see exportState
|
||||
*/
|
||||
const restoreState = (subgraphIds: string[]) => {
|
||||
idStack.length = 0
|
||||
for (const id of subgraphIds) idStack.push(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the navigation stack as a list of subgraph IDs.
|
||||
* @returns The list of subgraph IDs, ending with the currently active subgraph.
|
||||
* @see restoreState
|
||||
*/
|
||||
const exportState = () => [...idStack]
|
||||
|
||||
// Reset on workflow change
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
() => (idStack.length = 0)
|
||||
)
|
||||
|
||||
// Update navigation stack when opened subgraph changes
|
||||
watch(
|
||||
() => workflowStore.activeSubgraph,
|
||||
(subgraph) => {
|
||||
// Navigated back to the root graph
|
||||
if (!subgraph) {
|
||||
idStack.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const index = idStack.lastIndexOf(subgraph.id)
|
||||
const lastIndex = idStack.length - 1
|
||||
|
||||
if (index === -1) {
|
||||
// Opened a new subgraph
|
||||
idStack.push(subgraph.id)
|
||||
} else if (index !== lastIndex) {
|
||||
// Navigated to a different subgraph
|
||||
idStack.splice(index + 1, lastIndex - index)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
activeSubgraph,
|
||||
navigationStack,
|
||||
restoreState,
|
||||
exportState
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LGraph, Subgraph } from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, watch } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -156,12 +157,12 @@ export interface WorkflowStore {
|
||||
syncWorkflows: (dir?: string) => Promise<void>
|
||||
reorderWorkflows: (from: number, to: number) => void
|
||||
|
||||
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
|
||||
subgraphNamePath: string[]
|
||||
/** `true` if any subgraph is currently being viewed. */
|
||||
isSubgraphActive: boolean
|
||||
activeSubgraph: Subgraph | undefined
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
executionIdToCurrentId: (id: string) => any
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
@@ -427,24 +428,61 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** @see WorkflowStore.subgraphNamePath */
|
||||
const subgraphNamePath = ref<string[]>([])
|
||||
/** @see WorkflowStore.isSubgraphActive */
|
||||
const isSubgraphActive = ref(false)
|
||||
|
||||
/** @see WorkflowStore.activeSubgraph */
|
||||
const activeSubgraph = shallowRef<Raw<Subgraph>>()
|
||||
|
||||
/** @see WorkflowStore.updateActiveGraph */
|
||||
const updateActiveGraph = () => {
|
||||
const subgraph = comfyApp.canvas?.subgraph
|
||||
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
|
||||
if (!comfyApp.canvas) return
|
||||
|
||||
const { subgraph } = comfyApp.canvas
|
||||
isSubgraphActive.value = isSubgraph(subgraph)
|
||||
}
|
||||
|
||||
if (subgraph) {
|
||||
const [, ...pathFromRoot] = subgraph.pathToRootGraph
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
|
||||
} else {
|
||||
subgraphNamePath.value = []
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (subgraph === undefined) throw new Error('Subgraph not found')
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = activeSubgraph.value
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the hierarchical ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// Start from the root graph
|
||||
const { graph } = comfyApp
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,9 +511,10 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
|
||||
subgraphNamePath,
|
||||
isSubgraphActive,
|
||||
updateActiveGraph
|
||||
activeSubgraph,
|
||||
updateActiveGraph,
|
||||
executionIdToCurrentId
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface AlgoliaNodePack {
|
||||
'comfy_node_extract_status'
|
||||
>
|
||||
icon_url: RegistryNodePack['icon']
|
||||
category: RegistryNodePack['category']
|
||||
author: RegistryNodePack['author']
|
||||
tags: RegistryNodePack['tags']
|
||||
github_stars: RegistryNodePack['github_stars']
|
||||
supported_os: RegistryNodePack['supported_os']
|
||||
supported_comfyui_version: RegistryNodePack['supported_comfyui_version']
|
||||
supported_comfyui_frontend_version: RegistryNodePack['supported_comfyui_frontend_version']
|
||||
supported_accelerators: RegistryNodePack['supported_accelerators']
|
||||
banner_url: RegistryNodePack['banner_url']
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -896,6 +896,23 @@ export interface paths {
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/nodes/update-github-stars': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get?: never
|
||||
put?: never
|
||||
/** Update GitHub stars for nodes */
|
||||
post: operations['updateGithubStars']
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/nodes': {
|
||||
parameters: {
|
||||
query?: never
|
||||
@@ -1078,6 +1095,30 @@ export interface paths {
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/releases': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
/**
|
||||
* Get release notes
|
||||
* @description Fetch release notes from Strapi with caching
|
||||
*/
|
||||
get: operations['getReleaseNotes']
|
||||
put?: never
|
||||
/**
|
||||
* Process Github release webhook
|
||||
* @description Webhook endpoint to process Github release events and generate release notes
|
||||
*/
|
||||
post: operations['processReleaseWebhook']
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/security-scan': {
|
||||
parameters: {
|
||||
query?: never
|
||||
@@ -3207,6 +3248,13 @@ export interface components {
|
||||
preempted_comfy_node_names?: string[]
|
||||
/** @description URL to the node's banner. */
|
||||
banner_url?: string
|
||||
/** @description Number of stars on the GitHub repository. */
|
||||
github_stars?: number
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description The date and time when the node was created
|
||||
*/
|
||||
created_at?: string
|
||||
}
|
||||
NodeVersion: {
|
||||
id?: string
|
||||
@@ -3345,6 +3393,8 @@ export interface components {
|
||||
stripe_id?: string
|
||||
/** @description The Metronome customer ID */
|
||||
metronome_id?: string
|
||||
/** @description Whether the user has funds */
|
||||
has_fund?: boolean
|
||||
}
|
||||
AuditLog: {
|
||||
/** @description the type of the event */
|
||||
@@ -8692,6 +8742,292 @@ export interface components {
|
||||
MoonvalleyUploadResponse: {
|
||||
access_url?: string
|
||||
}
|
||||
/** @description GitHub release webhook payload based on official webhook documentation */
|
||||
GithubReleaseWebhook: {
|
||||
/**
|
||||
* @description The action performed on the release
|
||||
* @enum {string}
|
||||
*/
|
||||
action:
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'created'
|
||||
| 'edited'
|
||||
| 'deleted'
|
||||
| 'prereleased'
|
||||
| 'released'
|
||||
/** @description The release object */
|
||||
release: {
|
||||
/** @description The ID of the release */
|
||||
id: number
|
||||
/** @description The node ID of the release */
|
||||
node_id: string
|
||||
/** @description The API URL of the release */
|
||||
url: string
|
||||
/** @description The HTML URL of the release */
|
||||
html_url: string
|
||||
/** @description The URL to the release assets */
|
||||
assets_url?: string
|
||||
/** @description The URL to upload release assets */
|
||||
upload_url?: string
|
||||
/** @description The tag name of the release */
|
||||
tag_name: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
/** @description The name of the release */
|
||||
name?: string | null
|
||||
/** @description The release notes/body */
|
||||
body?: string | null
|
||||
/** @description Whether the release is a draft */
|
||||
draft: boolean
|
||||
/** @description Whether the release is a prerelease */
|
||||
prerelease: boolean
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release was published
|
||||
*/
|
||||
published_at?: string | null
|
||||
author: components['schemas']['GithubUser']
|
||||
/** @description URL to the tarball */
|
||||
tarball_url: string
|
||||
/** @description URL to the zipball */
|
||||
zipball_url: string
|
||||
/** @description Array of release assets */
|
||||
assets: components['schemas']['GithubReleaseAsset'][]
|
||||
}
|
||||
repository: components['schemas']['GithubRepository']
|
||||
sender: components['schemas']['GithubUser']
|
||||
organization?: components['schemas']['GithubOrganization']
|
||||
installation?: components['schemas']['GithubInstallation']
|
||||
enterprise?: components['schemas']['GithubEnterprise']
|
||||
}
|
||||
/** @description A GitHub user */
|
||||
GithubUser: {
|
||||
/** @description The user's login name */
|
||||
login: string
|
||||
/** @description The user's ID */
|
||||
id: number
|
||||
/** @description The user's node ID */
|
||||
node_id: string
|
||||
/** @description URL to the user's avatar */
|
||||
avatar_url: string
|
||||
/** @description The user's gravatar ID */
|
||||
gravatar_id?: string | null
|
||||
/** @description The API URL of the user */
|
||||
url: string
|
||||
/** @description The HTML URL of the user */
|
||||
html_url: string
|
||||
/**
|
||||
* @description The type of user
|
||||
* @enum {string}
|
||||
*/
|
||||
type: 'Bot' | 'User' | 'Organization'
|
||||
/** @description Whether the user is a site admin */
|
||||
site_admin: boolean
|
||||
}
|
||||
/** @description A GitHub repository */
|
||||
GithubRepository: {
|
||||
/** @description The repository ID */
|
||||
id: number
|
||||
/** @description The repository node ID */
|
||||
node_id: string
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description Whether the repository is private */
|
||||
private: boolean
|
||||
owner: components['schemas']['GithubUser']
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The repository description */
|
||||
description?: string | null
|
||||
/** @description Whether the repository is a fork */
|
||||
fork: boolean
|
||||
/** @description The API URL of the repository */
|
||||
url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: string
|
||||
/** @description The git URL of the repository */
|
||||
git_url: string
|
||||
/** @description The SSH URL of the repository */
|
||||
ssh_url: string
|
||||
/** @description The default branch of the repository */
|
||||
default_branch: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last pushed to
|
||||
*/
|
||||
pushed_at: string
|
||||
}
|
||||
/** @description A GitHub release asset */
|
||||
GithubReleaseAsset: {
|
||||
/** @description The asset ID */
|
||||
id: number
|
||||
/** @description The asset node ID */
|
||||
node_id: string
|
||||
/** @description The name of the asset */
|
||||
name: string
|
||||
/** @description The label of the asset */
|
||||
label?: string | null
|
||||
/** @description The content type of the asset */
|
||||
content_type: string
|
||||
/**
|
||||
* @description The state of the asset
|
||||
* @enum {string}
|
||||
*/
|
||||
state: 'uploaded' | 'open'
|
||||
/** @description The size of the asset in bytes */
|
||||
size: number
|
||||
/** @description The number of downloads */
|
||||
download_count: number
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The browser download URL */
|
||||
browser_download_url: string
|
||||
uploader: components['schemas']['GithubUser']
|
||||
}
|
||||
/** @description A GitHub organization */
|
||||
GithubOrganization: {
|
||||
/** @description The organization's login name */
|
||||
login: string
|
||||
/** @description The organization ID */
|
||||
id: number
|
||||
/** @description The organization node ID */
|
||||
node_id: string
|
||||
/** @description The API URL of the organization */
|
||||
url: string
|
||||
/** @description The API URL of the organization's repositories */
|
||||
repos_url: string
|
||||
/** @description The API URL of the organization's events */
|
||||
events_url: string
|
||||
/** @description The API URL of the organization's hooks */
|
||||
hooks_url: string
|
||||
/** @description The API URL of the organization's issues */
|
||||
issues_url: string
|
||||
/** @description The API URL of the organization's members */
|
||||
members_url: string
|
||||
/** @description The API URL of the organization's public members */
|
||||
public_members_url: string
|
||||
/** @description URL to the organization's avatar */
|
||||
avatar_url: string
|
||||
/** @description The organization description */
|
||||
description?: string | null
|
||||
}
|
||||
/** @description A GitHub App installation */
|
||||
GithubInstallation: {
|
||||
/** @description The installation ID */
|
||||
id: number
|
||||
account: components['schemas']['GithubUser']
|
||||
/**
|
||||
* @description Repository selection for the installation
|
||||
* @enum {string}
|
||||
*/
|
||||
repository_selection: 'selected' | 'all'
|
||||
/** @description The API URL for access tokens */
|
||||
access_tokens_url: string
|
||||
/** @description The API URL for repositories */
|
||||
repositories_url: string
|
||||
/** @description The HTML URL of the installation */
|
||||
html_url: string
|
||||
/** @description The GitHub App ID */
|
||||
app_id: number
|
||||
/** @description The target ID */
|
||||
target_id: number
|
||||
/** @description The target type */
|
||||
target_type: string
|
||||
/** @description The installation permissions */
|
||||
permissions: Record<string, never>
|
||||
/** @description The events the installation subscribes to */
|
||||
events: string[]
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The single file name if applicable */
|
||||
single_file_name?: string | null
|
||||
}
|
||||
/** @description A GitHub enterprise */
|
||||
GithubEnterprise: {
|
||||
/** @description The enterprise ID */
|
||||
id: number
|
||||
/** @description The enterprise slug */
|
||||
slug: string
|
||||
/** @description The enterprise name */
|
||||
name: string
|
||||
/** @description The enterprise node ID */
|
||||
node_id: string
|
||||
/** @description URL to the enterprise avatar */
|
||||
avatar_url: string
|
||||
/** @description The enterprise description */
|
||||
description?: string | null
|
||||
/** @description The enterprise website URL */
|
||||
website_url?: string | null
|
||||
/** @description The HTML URL of the enterprise */
|
||||
html_url: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
}
|
||||
ReleaseNote: {
|
||||
/** @description Unique identifier for the release note */
|
||||
id: number
|
||||
/**
|
||||
* @description The project this release note belongs to
|
||||
* @enum {string}
|
||||
*/
|
||||
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
|
||||
/** @description The version of the release */
|
||||
version: string
|
||||
/**
|
||||
* @description The attention level for this release
|
||||
* @enum {string}
|
||||
*/
|
||||
attention: 'low' | 'medium' | 'high'
|
||||
/** @description The content of the release note in markdown format */
|
||||
content: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release note was published
|
||||
*/
|
||||
published_at: string
|
||||
}
|
||||
}
|
||||
responses: never
|
||||
parameters: {
|
||||
@@ -10795,8 +11131,6 @@ export interface operations {
|
||||
query?: {
|
||||
/** @description Maximum number of nodes to send to algolia at a time */
|
||||
max_batch?: number
|
||||
/** @description Minimum interval from the last time the nodes were indexed to algolia */
|
||||
min_age?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
@@ -10831,6 +11165,52 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
updateGithubStars: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of nodes to update in one batch */
|
||||
max_batch?: number
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description Update GithubStars request triggered successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Bad request. */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
listAllNodes: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -10852,6 +11232,8 @@ export interface operations {
|
||||
sort?: string[]
|
||||
/** @description node_id to use as filter */
|
||||
node_id?: string[]
|
||||
/** @description Comfy UI version */
|
||||
comfyui_version?: string
|
||||
/** @description The platform requesting the nodes */
|
||||
form_factor?: string
|
||||
}
|
||||
@@ -11418,6 +11800,113 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
getReleaseNotes: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description The project to get release notes for */
|
||||
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
|
||||
/** @description The current version to filter release notes */
|
||||
current_version?: string
|
||||
/** @description The platform requesting the release notes */
|
||||
form_factor?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
/** @description Release notes retrieved successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ReleaseNote'][]
|
||||
}
|
||||
}
|
||||
/** @description Bad request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
processReleaseWebhook: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header: {
|
||||
/** @description The name of the event that triggered the delivery */
|
||||
'X-GitHub-Event': 'release'
|
||||
/** @description A globally unique identifier (GUID) to identify the event */
|
||||
'X-GitHub-Delivery': string
|
||||
/** @description The unique identifier of the webhook */
|
||||
'X-GitHub-Hook-ID': string
|
||||
/** @description HMAC hex digest of the request body using SHA-256 hash function */
|
||||
'X-Hub-Signature-256'?: string
|
||||
/** @description The type of resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-Type'?: string
|
||||
/** @description The unique identifier of the resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-ID'?: string
|
||||
}
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GithubReleaseWebhook']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description Webhook processed successfully */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Bad request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Validation failed or endpoint has been spammed */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
securityScan: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
6
src/types/litegraph-augmentation.d.ts
vendored
@@ -60,6 +60,7 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
|
||||
* ComfyUI extensions of litegraph
|
||||
*/
|
||||
declare module '@comfyorg/litegraph' {
|
||||
import type { ExecutableLGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
@@ -88,7 +89,10 @@ declare module '@comfyorg/litegraph' {
|
||||
/** @deprecated groupNode */
|
||||
setInnerNodes?(nodes: LGraphNode[]): void
|
||||
/** Originally a group node API. */
|
||||
getInnerNodes?(): LGraphNode[]
|
||||
getInnerNodes?(
|
||||
nodes?: ExecutableLGraphNode[],
|
||||
subgraphs?: WeakSet<LGraphNode>
|
||||
): ExecutableLGraphNode[]
|
||||
/** @deprecated groupNode */
|
||||
convertToNodes?(): LGraphNode[]
|
||||
recreate?(): Promise<LGraphNode>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LGraph, NodeId } from '@comfyorg/litegraph'
|
||||
import { LGraphEventMode } 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,55 +135,14 @@ export const graphToPrompt = async (
|
||||
|
||||
// Store all node links
|
||||
for (const [i, input] of node.inputs.entries()) {
|
||||
let parent = 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.getInputNode(link.target_slot)
|
||||
if (!parent) break
|
||||
} else if (!parent.inputs) {
|
||||
// Maintains existing behaviour if parent.getInputLink is overriden
|
||||
break
|
||||
} else if (parent.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const parentInputIndexes = Object.keys(parent.inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [link.origin_slot].concat(parentInputIndexes)
|
||||
|
||||
const matchingIndex = indexes.find(
|
||||
(index) => parent?.inputs[index]?.type === input.type
|
||||
)
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) break
|
||||
|
||||
link = parent.getInputLink(matchingIndex)
|
||||
if (link) parent = parent.getInputNode(matchingIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (parent?.updateLink) {
|
||||
// Subgraph node / groupNode callback; deprecated, should be replaced
|
||||
link = parent.updateLink(link)
|
||||
}
|
||||
if (link) {
|
||||
inputs[input.name] = [
|
||||
String(link.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(link.origin_slot)
|
||||
]
|
||||
}
|
||||
}
|
||||
inputs[input.name] = [
|
||||
String(resolvedInput.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(resolvedInput.origin_slot)
|
||||
]
|
||||
}
|
||||
|
||||
output[String(node.id)] = {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ColorOption, LGraph } from '@comfyorg/litegraph'
|
||||
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
|
||||
@@ -50,6 +54,10 @@ export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
|
||||
return item instanceof LGraphGroup
|
||||
}
|
||||
|
||||
export const isReroute = (item: unknown): item is Reroute => {
|
||||
return item instanceof Reroute
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color option of all canvas items if they are all the same.
|
||||
* @param items - The items to get the color option of.
|
||||
@@ -163,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) {
|
||||
@@ -179,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,9 @@ export const isAbortError = (
|
||||
export const isSubgraph = (
|
||||
item: LGraph | Subgraph | undefined | null
|
||||
): item is Subgraph => item?.isRootGraph === false
|
||||
|
||||
/**
|
||||
* Check if an item is non-nullish.
|
||||
*/
|
||||
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
|
||||
item != null
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn()
|
||||
}))
|
||||
|
||||
const createMockNode = (type: string, version?: string): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: 'comfy-core', ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
describe('MissingCoreNodesMessage', () => {
|
||||
const mockSystemStatsStore = {
|
||||
systemStats: null as { system?: { comfyui_version?: string } } | null,
|
||||
fetchSystemStats: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the mock store state
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.fetchSystemStats = vi.fn()
|
||||
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
|
||||
// The actual store has more properties, but we only need these for our tests.
|
||||
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(MissingCoreNodesMessage, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Message },
|
||||
mocks: {
|
||||
$t: (key: string, params?: { version?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
|
||||
'loadWorkflowWarning.outdatedVersionGeneric':
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
|
||||
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
missingCoreNodes: {},
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('does not render when there are no missing core nodes', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders message when there are missing core nodes', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('fetches and displays current ComfyUI version', async () => {
|
||||
// Start with no systemStats to trigger fetch
|
||||
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => {
|
||||
// Simulate the fetch setting the systemStats
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: { comfyui_version: '1.0.0' }
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for all async operations
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays generic message when version is unavailable', async () => {
|
||||
// Mock fetchSystemStats to resolve without setting systemStats
|
||||
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for the async operations to complete
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups nodes by version and displays them', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('NodeA', '1.2.0'),
|
||||
createMockNode('NodeB', '1.2.0')
|
||||
],
|
||||
'1.3.0': [createMockNode('NodeC', '1.3.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Requires ComfyUI 1.3.0:')
|
||||
expect(text).toContain('NodeC')
|
||||
expect(text).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(text).toContain('NodeA, NodeB')
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.1.0': [createMockNode('Node1', '1.1.0')],
|
||||
'1.3.0': [createMockNode('Node3', '1.3.0')],
|
||||
'1.2.0': [createMockNode('Node2', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
const version13Index = text.indexOf('1.3.0')
|
||||
const version12Index = text.indexOf('1.2.0')
|
||||
const version11Index = text.indexOf('1.1.0')
|
||||
|
||||
expect(version13Index).toBeLessThan(version12Index)
|
||||
expect(version12Index).toBeLessThan(version11Index)
|
||||
})
|
||||
|
||||
it('removes duplicate node names within the same version', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('UniqueNode', '1.2.0')
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
// Should only appear once in the sorted list
|
||||
expect(text).toContain('DuplicateNode, UniqueNode')
|
||||
// Count occurrences of 'DuplicateNode' - should be only 1
|
||||
const matches = text.match(/DuplicateNode/g) || []
|
||||
expect(matches.length).toBe(1)
|
||||
})
|
||||
|
||||
it('handles nodes with missing version info', async () => {
|
||||
const missingCoreNodes = {
|
||||
'': [createMockNode('NoVersionNode')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
385
tests-ui/tests/composables/useMissingNodes.test.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
// Mock Vue's onMounted to execute immediately for testing
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue')>('vue')
|
||||
return {
|
||||
...actual,
|
||||
onMounted: (cb: () => void) => cb()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/composables/nodePack/useWorkflowPacks', () => ({
|
||||
useWorkflowPacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
nodes: []
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
|
||||
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
|
||||
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
|
||||
|
||||
describe('useMissingNodes', () => {
|
||||
const mockWorkflowPacks = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack 1',
|
||||
latest_version: { version: '1.0.0' }
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
name: 'Test Pack 2',
|
||||
latest_version: { version: '2.0.0' }
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
name: 'Installed Pack',
|
||||
latest_version: { version: '1.5.0' }
|
||||
}
|
||||
]
|
||||
|
||||
const mockStartFetchWorkflowPacks = vi.fn()
|
||||
const mockIsPackInstalled = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default setup: pack-3 is installed, others are not
|
||||
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
|
||||
|
||||
// @ts-expect-error - Mocking partial ComfyManagerStore for testing.
|
||||
// We only need isPackInstalled method for these tests.
|
||||
mockUseComfyManagerStore.mockReturnValue({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Reset node def store mock
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
// We only need nodeDefsByName for these tests.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
// Reset app.graph.nodes
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = []
|
||||
})
|
||||
|
||||
describe('core filtering logic', () => {
|
||||
it('filters out installed packs correctly', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Should only include packs that are not installed (pack-1, pack-2)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
expect(missingNodePacks.value[0].id).toBe('pack-1')
|
||||
expect(missingNodePacks.value[1].id).toBe('pack-2')
|
||||
expect(
|
||||
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns empty array when all packs are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Mock all packs as installed
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all packs when none are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Mock no packs as installed
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toHaveLength(3)
|
||||
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
|
||||
})
|
||||
|
||||
it('returns empty array when no workflow packs exist', () => {
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('automatic data fetching', () => {
|
||||
it('fetches workflow packs automatically when none exist', async () => {
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not fetch when packs already exist', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch when already loading', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state management', () => {
|
||||
it('exposes loading state from useWorkflowPacks', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { isLoading } = useMissingNodes()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes error state from useWorkflowPacks', () => {
|
||||
const testError = 'Failed to fetch workflow packs'
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(testError),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { error } = useMissingNodes()
|
||||
|
||||
expect(error.value).toBe(testError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactivity', () => {
|
||||
it('updates when workflow packs change', async () => {
|
||||
const workflowPacksRef = ref([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Initially empty
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
|
||||
// Update workflow packs
|
||||
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
|
||||
workflowPacksRef.value = mockWorkflowPacks
|
||||
await nextTick()
|
||||
|
||||
// Should update missing packs (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
const createMockNode = (
|
||||
type: string,
|
||||
packId?: string,
|
||||
version?: string
|
||||
): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: packId, ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
it('identifies missing core nodes not in nodeDefStore', () => {
|
||||
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
|
||||
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
|
||||
const registeredNode = createMockNode(
|
||||
'RegisteredNode',
|
||||
'comfy-core',
|
||||
'1.0.0'
|
||||
)
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [coreNode1, coreNode2, registeredNode]
|
||||
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
// Only including required properties for our test assertions.
|
||||
RegisteredNode: { name: 'RegisteredNode' }
|
||||
}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
|
||||
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
|
||||
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
|
||||
})
|
||||
|
||||
it('groups missing core nodes by version', () => {
|
||||
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
|
||||
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
|
||||
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [node120, node130, nodeNoVer]
|
||||
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores non-core nodes', () => {
|
||||
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
|
||||
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
|
||||
const noPackNode = createMockNode('NoPackNode')
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [coreNode, customNode, noPackNode]
|
||||
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
|
||||
})
|
||||
|
||||
it('returns empty object when no core nodes are missing', () => {
|
||||
const registeredNode1 = createMockNode(
|
||||
'RegisteredNode1',
|
||||
'comfy-core',
|
||||
'1.0.0'
|
||||
)
|
||||
const registeredNode2 = createMockNode(
|
||||
'RegisteredNode2',
|
||||
'comfy-core',
|
||||
'1.1.0'
|
||||
)
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [registeredNode1, registeredNode2]
|
||||
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
// Only including required properties for our test assertions.
|
||||
RegisteredNode1: { name: 'RegisteredNode1' },
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
RegisteredNode2: { name: 'RegisteredNode2' }
|
||||
}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,22 @@ vi.mock('@/stores/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/functional/useChainCallback', () => ({
|
||||
useChainCallback: vi.fn((original, ...callbacks) => {
|
||||
return function (this: any, ...args: any[]) {
|
||||
original?.apply(this, args)
|
||||
callbacks.forEach((cb: any) => cb.apply(this, args))
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
const DEFAULT_VALUE = 'Loading...'
|
||||
|
||||
@@ -40,7 +56,9 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
|
||||
const createMockOptions = (inputOverrides = {}) => ({
|
||||
remoteConfig: createMockConfig(inputOverrides),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: {} as any,
|
||||
node: {
|
||||
addWidget: vi.fn()
|
||||
} as any,
|
||||
widget: {} as any
|
||||
})
|
||||
|
||||
@@ -499,4 +517,168 @@ describe('useRemoteWidget', () => {
|
||||
expect(data2).toEqual(DEFAULT_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-refresh on task completion', () => {
|
||||
it('should add auto-refresh toggle widget', () => {
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Should add auto-refresh toggle widget
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
expect.any(Function),
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should register event listener when enabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Event listener should be registered immediately
|
||||
expect(api.addEventListener).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh widget when workflow completes successfully', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Get the toggle callback and enable auto-refresh
|
||||
const toggleCallback = mockNode.addWidget.mock.calls.find(
|
||||
(call) => call[0] === 'toggle'
|
||||
)?.[3]
|
||||
toggleCallback?.(true)
|
||||
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh when toggle is disabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Toggle is disabled by default
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cleanup event listener on node removal', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: [],
|
||||
onRemoved: undefined as any
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Simulate node removal
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(api.removeEventListener).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
executionSuccessHandler
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -108,8 +108,17 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
id: 'publisher-1',
|
||||
name: 'publisher-1'
|
||||
},
|
||||
create_time: '2024-01-01T00:00:00Z',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage']
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage'],
|
||||
category: undefined,
|
||||
author: undefined,
|
||||
tags: undefined,
|
||||
github_stars: undefined,
|
||||
supported_os: undefined,
|
||||
supported_comfyui_version: undefined,
|
||||
supported_comfyui_frontend_version: undefined,
|
||||
supported_accelerators: undefined,
|
||||
banner_url: undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,7 +262,7 @@ describe('useAlgoliaSearchProvider', () => {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
create_time: '2024-01-01T10:00:00Z'
|
||||
created_at: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
it('should return correct values for each sort field', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('useComfyRegistrySearchProvider', () => {
|
||||
search: 'test',
|
||||
comfy_node_search: undefined,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
page: 1
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
@@ -68,7 +68,7 @@ describe('useComfyRegistrySearchProvider', () => {
|
||||
search: undefined,
|
||||
comfy_node_search: 'LoadImage',
|
||||
limit: 20,
|
||||
offset: 20
|
||||
page: 2
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
})
|
||||
|
||||
175
tests-ui/tests/store/dialogStore.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -492,7 +492,7 @@ describe('useWorkflowStore', () => {
|
||||
// Assert
|
||||
console.debug(store.isSubgraphActive)
|
||||
expect(store.isSubgraphActive).toBe(false) // Should default to false
|
||||
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
|
||||
expect(store.activeSubgraph).toBeUndefined() // Should default to empty
|
||||
})
|
||||
|
||||
it('should correctly update state when the root graph is active', async () => {
|
||||
@@ -505,7 +505,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Assert: Check store state
|
||||
expect(store.isSubgraphActive).toBe(false)
|
||||
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
|
||||
expect(store.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should correctly update state when a subgraph is active', async () => {
|
||||
@@ -527,10 +527,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Assert: Check store state
|
||||
expect(store.isSubgraphActive).toBe(true)
|
||||
expect(store.subgraphNamePath).toEqual([
|
||||
'Level 1 Subgraph',
|
||||
'Level 2 Subgraph'
|
||||
]) // Path excludes the root
|
||||
expect(store.activeSubgraph).toEqual(mockSubgraph)
|
||||
})
|
||||
|
||||
it('should update automatically when activeWorkflow changes', async () => {
|
||||
@@ -548,7 +545,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Verify initial state
|
||||
expect(store.isSubgraphActive).toBe(true)
|
||||
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
|
||||
expect(store.activeSubgraph).toEqual(initialSubgraph)
|
||||
|
||||
// Act: Change the active workflow
|
||||
const workflow2 = store.createTemporary('workflow2.json')
|
||||
@@ -569,7 +566,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
|
||||
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
|
||||
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
|
||||
expect(store.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||