Implement subgraph publishing (#5139)

* Implement subgraph publishing

* Add missing null check

* Fix subgraph blueprint display in workflows tab

* Fix demotion of subgraph blueprints on reload

* Update locales [skip ci]

* Update blueprint def on save, cleanup

* Fix skipped tracking on subgraph publish

When a subgraph is first published, it previously was not added to the
subgraphCache. This would cause deletion to fail until a reload occurred.

* Fix failing vite tests

A couple of tests that were mocking classes broke SubgraphBlueprint
inheritance. Since they aren't testing anythign related to subgraph
blueprints, the subgraph store is mocked as well.

* Make blueprint breadcrumb badge clickable

* Add confirmation for overwrite on publish

* Simplify blueprint badge naming

* Swap to promise.allSettled when fetching subgraphs

* Navigate into subgraph on blueprint edit

* Revert mission of value in blueprint breadcrumb

This was causing the blueprint badge to always display

* Misc code quality fixes

* Set subgraphNode title on blueprint add.

When a subgraph blueprint is added to the graph, the title of the
subgraphNode is now set to be the title of the blueprint.

NOTE: The name of the subgraph node when a blueprint is edited is left
unchanged. This may cause minor user confusion.

* Add "Delete Blueprint" option to breadcrumb

When editing a blueprint, the options provided for the root graph of the
breadcrumb included a Delete Workflow option. This still functioned for
deleting the current blueprint when selected, but didn't make sense. It
has been updated to instead describe that it deletes the current
blueprint

* Extract subgraph load code as function

* Fix subgraphs appearing in library after refresh

Subgraph nodes were hidden from the node library and context menu by
setting skip_list to true. Unfortunately, this causes them to be
mistakenly be caught and registered as vue nodes when a refresh is
performed. This is fixed by adding a check for skip_list.

* Add delete button and confirmation for deletion

* Use more specific warning for blueprint deletion

* At success toast on subgraph publish

Will return later to potentially add a node library link to the toast

* Don't apply subgraph context menu to normal nodes

Subgraph blueprints have a right click -> delete option in the node
library.  This was incorrectly being dislplayed on non blueprint nodes.

* Remove hardcoded subgraphs path

Rather happy with this change. Rather than trying to introduce a
recursive import to pass a magic string, this solution is both
sufficient AND allows potential future extensions with less breakage.

* Fix nodeDef update on save

Wait to update the node def cache until after a blueprint has been
saved. Before, changes to links weren't actually being made visisble.

* Fix SaveAs with subgraph blueprints

* Remove ugly serialize/deserialize

Thought I had already tested this, and found that the mere existence of
proxies was causing issues, but simply adding a correct annotation is
sufficient now.

* Improve error specificity

* Framework for user defined blueprint descriptions

BlueprintDescription can be added to a workflows extra field to provide
more useful information about a blueprint's purpose

Actually hooking this up in a way that is user accessible is out of
scope for right now, but this will simplify future implementation.

* Cleanup breadcrumb dropdown options

Removes Dupliate for blueprints, adds a publish subgraph option.

The publish subgraph button currently routes through the save as logic.
Unforunately, this results in the prompt for name referencing workflows.
The cleanest way to resolve this is still being considered

* Move blueprint renaming into blueprint load

Blueprints should automatically set the name of the added node to the
filename when added. This mostly worked, but created uglier edgecases:
The subgraph itself wasn't renamed, and it would need to be
reimplemented to apply when editing a blueprint.

Instead, this is now applied when a subgraphBlueprint is first loaded.
This keeps all the logic routed through a single point

* Move saveAs prompt into workflow class

Ensures that the correct publish text is displayed when editing
blueprints without making an awful mess of imports

* Fix tests by making subgraphBlueprint internal

This has the added benefit of forcing better organization.

Reverts the useWorkflowThumbnail patch as it is no longer required.

* Add tests for subgraph blueprints

* Rewrite confirmation dialog

* Fix overwrite on publish new subgraph

1 is used as a placeholder size as -1 indicates the baking userFile is
temporary, not persisted, and therefore, not able to overwrite when
saved.

* When editing blueprint, tint background blue

* Fix blueprint tint at low LOD

* Set node source for blueprints to Blueprint

* Fix publish test

Making subgraph blueprints non temporary on publish made it so the
following load actually occurs. A mock has been added for this load.

* Fix multiple nits

* Further cleanup: error handling, and comments

* Fixing failing test cases

This also moves the bg tinting to a property of the workflow,
which makes things more extensible in the future.

* Fix temporary marking on publish.

The prior fix to allow overwrite of an existing blueprint on publish was
misguided. By marking a not-yet-loaded file as non-temporary, the load
performed prior to saving was actually fetching the file off disk and
discarding the existing changes. This additionally entirely prevented
publishing when a blueprint did not already exist with the current name.

To fix this, the blueprint is not marked as non-temporary until after
the load occurs. Note that this load is still required as it initializes
the change tracker state required for saving.

* Block unloading subgraph blueprints

Will need to be revisited if lazy loading is implemented, but this
requires solving some ugly sync/async issues.

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
AustinMroz
2025-09-06 21:27:04 -07:00
committed by GitHub
parent 20d4fe709c
commit fc8d5621ac
39 changed files with 812 additions and 31 deletions

View File

@@ -40,6 +40,7 @@ import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbIt
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
@@ -52,6 +53,9 @@ const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
@@ -89,6 +93,7 @@ const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -16,6 +16,7 @@
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
@@ -48,6 +49,7 @@
import InputText from 'primevue/inputtext'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -121,7 +123,7 @@ const menuItems = computed<MenuItem[]>(() => {
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot
visible: isRoot && !props.item.isBlueprint
},
{
separator: true,
@@ -153,12 +155,26 @@ const menuItems = computed<MenuItem[]>(() => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
label: t('subgraphStore.publish'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: t('breadcrumbsMenu.deleteWorkflow'),
label: props.item.isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)

View File

@@ -16,6 +16,21 @@
{{ hint }}
</Message>
<div class="flex gap-4 justify-end">
<div
v-if="type === 'overwriteBlueprint'"
class="flex gap-4 justify-start"
>
<Checkbox
v-model="doNotAskAgain"
class="flex gap-4 justify-start"
input-id="doNotAskAgain"
binary
/>
<label for="doNotAskAgain" severity="secondary">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<Button
:label="$t('g.cancel')"
icon="pi pi-undo"
@@ -38,7 +53,7 @@
@click="onConfirm"
/>
<Button
v-else-if="type === 'overwrite'"
v-else-if="type === 'overwrite' || type === 'overwriteBlueprint'"
:label="$t('g.overwrite')"
severity="warn"
icon="pi pi-save"
@@ -74,10 +89,14 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useSettingStore } from '@/stores/settingStore'
const props = defineProps<{
message: string
@@ -87,14 +106,20 @@ const props = defineProps<{
hint?: string
}>()
const { t } = useI18n()
const onCancel = () => useDialogStore().closeDialog()
const doNotAskAgain = ref(false)
const onDeny = () => {
props.onConfirm(false)
useDialogStore().closeDialog()
}
const onConfirm = () => {
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
props.onConfirm(true)
useDialogStore().closeDialog()
}

View File

@@ -21,6 +21,7 @@
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<PublishSubgraphButton />
<DeleteButton />
<RefreshSelectionButton />
<ExtensionCommandButton
@@ -49,6 +50,7 @@ import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewer
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'

View File

@@ -0,0 +1,37 @@
<template>
<Button
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_PublishSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.PublishSubgraph')"
>
<template #icon>
<i-lucide:book-open />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="container" class="node-lib-node-container">
<TreeExplorerTreeNode :node="node">
<TreeExplorerTreeNode :node="node" @contextmenu="handleContextMenu">
<template #before-label>
<Tag
v-if="nodeDef.experimental"
@@ -13,7 +13,30 @@
severity="danger"
/>
</template>
<template #actions>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<Button
size="small"
icon="pi pi-trash"
text
severity="danger"
@click.stop="deleteBlueprint"
>
</Button>
<Button
size="small"
text
severity="secondary"
@click.stop="editBlueprint"
>
<template #icon>
<i-lucide:square-pen />
</template>
</Button>
</template>
<template v-else #actions>
<Button
class="bookmark-button"
size="small"
@@ -40,10 +63,13 @@
</div>
</teleport>
</div>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import {
CSSProperties,
@@ -53,14 +79,18 @@ import {
onUnmounted,
ref
} from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const { t } = useI18n()
const props = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
@@ -80,6 +110,33 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const toggleBookmark = async () => {
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
const editBlueprint = async () => {
if (!props.node.data)
throw new Error(
'Failed to edit subgraph blueprint lacking backing node data'
)
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
severity: 'error',
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
}
const previewRef = ref<InstanceType<typeof NodePreview> | null>(null)
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -28,6 +28,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useToastStore } from '@/stores/toastStore'
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -111,6 +112,15 @@ export function useCoreCommands(): ComfyCommand[] {
await workflowService.saveWorkflow(workflow)
}
},
{
id: 'Comfy.PublishSubgraph',
icon: 'pi pi-save',
label: 'Publish Subgraph',
menubarLabel: 'Publish',
function: async () => {
await useSubgraphStore().publishSubgraph()
}
},
{
id: 'Comfy.SaveWorkflowAs',
icon: 'pi pi-save',

View File

@@ -186,6 +186,12 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Workflow.WarnBlueprintOverwrite',
name: 'Require confirmation to overwrite an existing subgraph blueprint',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Graph.ZoomSpeed',
category: ['LiteGraph', 'Canvas', 'ZoomSpeed'],

View File

@@ -667,6 +667,7 @@ export class LGraphCanvas
_bg_img?: HTMLImageElement
_pattern?: CanvasPattern
_pattern_img?: HTMLImageElement
bg_tint?: string | CanvasGradient | CanvasPattern
// TODO: This looks like another panel thing
prompt_box?: PromptDialog | null
search_box?: HTMLDivElement
@@ -3754,13 +3755,7 @@ export class LGraphCanvas
e.stopImmediatePropagation()
}
}
/**
* Copies canvas items to an internal, app-specific clipboard backed by local storage.
* When called without parameters, it copies {@link selectedItems}.
* @param items The items to copy. If nullish, all selected items are copied.
*/
copyToClipboard(items?: Iterable<Positionable>): void {
_serializeItems(items?: Iterable<Positionable>): ClipboardItems {
const serialisable: Required<ClipboardItems> = {
nodes: [],
groups: [],
@@ -3818,10 +3813,18 @@ export class LGraphCanvas
const cloned = subgraph.clone(true).asSerialisable()
serialisable.subgraphs.push(cloned)
}
return serialisable
}
/**
* Copies canvas items to an internal, app-specific clipboard backed by local storage.
* When called without parameters, it copies {@link selectedItems}.
* @param items The items to copy. If nullish, all selected items are copied.
*/
copyToClipboard(items?: Iterable<Positionable>): void {
localStorage.setItem(
'litegrapheditor_clipboard',
JSON.stringify(serialisable)
JSON.stringify(this._serializeItems(items))
)
}
@@ -3853,6 +3856,14 @@ export class LGraphCanvas
*/
_pasteFromClipboard(
options: IPasteFromClipboardOptions = {}
): ClipboardPasteResult | undefined {
const data = localStorage.getItem('litegrapheditor_clipboard')
if (!data) return
return this._deserializeItems(JSON.parse(data), options)
}
_deserializeItems(
parsed: ClipboardItems,
options: IPasteFromClipboardOptions
): ClipboardPasteResult | undefined {
const { connectInputs = false, position = this.graph_mouse } = options
@@ -3863,15 +3874,11 @@ export class LGraphCanvas
)
return
const data = localStorage.getItem('litegrapheditor_clipboard')
if (!data) return
const { graph } = this
if (!graph) throw new NullGraphError()
graph.beforeChange()
// Parse & initialise
const parsed: ClipboardItems = JSON.parse(data)
parsed.nodes ??= []
parsed.groups ??= []
parsed.reroutes ??= []
@@ -5093,6 +5100,16 @@ export class LGraphCanvas
ctx.globalAlpha = 1.0
ctx.imageSmoothingEnabled = true
}
if (this.bg_tint) {
ctx.fillStyle = this.bg_tint
ctx.fillRect(
this.visible_area[0],
this.visible_area[1],
this.visible_area[2],
this.visible_area[3]
)
ctx.fillStyle = 'transparent'
}
// groups
if (this.graph._groups.length) {

View File

@@ -197,6 +197,9 @@
"Comfy_OpenWorkflow": {
"label": "فتح سير عمل"
},
"Comfy_PublishSubgraph": {
"label": "نشر الرسم البياني الفرعي"
},
"Comfy_QueuePrompt": {
"label": "إضافة الأمر إلى قائمة الانتظار"
},

View File

@@ -813,6 +813,7 @@
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
"Previous Opened Workflow": "سير العمل السابق المفتوح",
"Publish": "نشر",
"Queue Panel": "لوحة الانتظار",
"Queue Prompt": "قائمة انتظار التعليمات",
"Queue Prompt (Front)": "قائمة انتظار التعليمات (أمامي)",
@@ -1255,6 +1256,10 @@
},
"workflows": "سير العمل"
},
"subgraphStore": {
"blueprintName": "اسم المخطط الفرعي",
"saveBlueprint": "احفظ المخطط الفرعي في المكتبة"
},
"tabMenu": {
"addToBookmarks": "إضافة إلى العلامات",
"closeOtherTabs": "إغلاق التبويبات الأخرى",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "Open Workflow"
},
"Comfy_PublishSubgraph": {
"label": "Publish Subgraph"
},
"Comfy_QueuePrompt": {
"label": "Queue Prompt"
},

View File

@@ -949,6 +949,18 @@
"enterFilename": "Enter the filename",
"saveWorkflow": "Save workflow"
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",
"confirmDelete": "This action will permanently remove the blueprint from your library",
"saveBlueprint": "Save Subgraph to Library",
"overwriteBlueprintTitle": "Overwrite existing blueprint?",
"overwriteBlueprint": "Saving will overwrite the current blueprint with your changes",
"blueprintName": "Subgraph name",
"publish": "Publish Subgraph",
"publishSuccess": "Saved to Nodes Library",
"publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"",
"loadFailure": "Failed to load subgraph blueprints"
},
"electronFileDownload": {
"inProgress": "In Progress",
"pause": "Pause Download",
@@ -1078,6 +1090,7 @@
"Clipspace": "Clipspace",
"Manager": "Manager",
"Open": "Open",
"Publish": "Publish",
"Queue Prompt": "Queue Prompt",
"Queue Prompt (Front)": "Queue Prompt (Front)",
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
@@ -1760,6 +1773,7 @@
"duplicate": "Duplicate",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
"enterNewName": "Enter new name"
},
"shortcuts": {
@@ -1783,4 +1797,4 @@
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
}
}
}

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "Abrir Flujo de Trabajo"
},
"Comfy_PublishSubgraph": {
"label": "Publicar subgrafo"
},
"Comfy_QueuePrompt": {
"label": "Prompt de Cola"
},

View File

@@ -1223,6 +1223,10 @@
},
"workflows": "Flujos de trabajo"
},
"subgraphStore": {
"blueprintName": "Nombre del subgrafo",
"saveBlueprint": "Guardar subgrafo en la biblioteca"
},
"tabMenu": {
"addToBookmarks": "Agregar a marcadores",
"closeOtherTabs": "Cerrar otras pestañas",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "Ouvrir le flux de travail"
},
"Comfy_PublishSubgraph": {
"label": "Publier le sous-graphe"
},
"Comfy_QueuePrompt": {
"label": "Invite de file d'attente"
},

View File

@@ -1224,6 +1224,10 @@
},
"workflows": "Flux de travail"
},
"subgraphStore": {
"blueprintName": "Nom du sous-graphe",
"saveBlueprint": "Enregistrer le sous-graphe dans la bibliothèque"
},
"tabMenu": {
"addToBookmarks": "Ajouter aux Favoris",
"closeOtherTabs": "Fermer les autres onglets",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "ワークフローを開く"
},
"Comfy_PublishSubgraph": {
"label": "サブグラフを公開"
},
"Comfy_QueuePrompt": {
"label": "キュープロンプト"
},

View File

@@ -1222,6 +1222,10 @@
},
"workflows": "ワークフロー"
},
"subgraphStore": {
"blueprintName": "サブグラフ名",
"saveBlueprint": "サブグラフをライブラリに保存"
},
"tabMenu": {
"addToBookmarks": "ブックマークに追加",
"closeOtherTabs": "他のタブを閉じる",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "워크플로 열기"
},
"Comfy_PublishSubgraph": {
"label": "서브그래프 게시"
},
"Comfy_QueuePrompt": {
"label": "실행 큐에 프롬프트 추가"
},

View File

@@ -1225,6 +1225,10 @@
},
"workflows": "워크플로"
},
"subgraphStore": {
"blueprintName": "서브그래프 이름",
"saveBlueprint": "서브그래프를 라이브러리에 저장"
},
"tabMenu": {
"addToBookmarks": "북마크에 추가",
"closeOtherTabs": "다른 탭 닫기",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "Открыть рабочий процесс"
},
"Comfy_PublishSubgraph": {
"label": "Опубликовать подграф"
},
"Comfy_QueuePrompt": {
"label": "Очередь запросов"
},

View File

@@ -1223,6 +1223,10 @@
},
"workflows": "Рабочие процессы"
},
"subgraphStore": {
"blueprintName": "Имя подграфа",
"saveBlueprint": "Сохранить подграф в библиотеку"
},
"tabMenu": {
"addToBookmarks": "Добавить в закладки",
"closeOtherTabs": "Закрыть другие вкладки",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "開啟工作流程"
},
"Comfy_PublishSubgraph": {
"label": "發布子圖"
},
"Comfy_QueuePrompt": {
"label": "將提示詞加入佇列"
},

View File

@@ -1211,6 +1211,10 @@
},
"workflows": "工作流程"
},
"subgraphStore": {
"blueprintName": "子圖名稱",
"saveBlueprint": "將子圖儲存到資料庫"
},
"tabMenu": {
"addToBookmarks": "加入書籤",
"closeOtherTabs": "關閉其他分頁",

View File

@@ -215,6 +215,9 @@
"Comfy_OpenWorkflow": {
"label": "打开工作流"
},
"Comfy_PublishSubgraph": {
"label": "发布子图"
},
"Comfy_QueuePrompt": {
"label": "执行提示词"
},

View File

@@ -814,6 +814,7 @@
"Pin/Unpin Selected Items": "固定/取消固定选定项目",
"Pin/Unpin Selected Nodes": "固定/取消固定选定节点",
"Previous Opened Workflow": "上一个打开的工作流",
"Publish": "发布",
"Queue Panel": "队列面板",
"Queue Prompt": "执行提示词",
"Queue Prompt (Front)": "执行提示词 (优先执行)",
@@ -1264,6 +1265,10 @@
},
"workflows": "工作流"
},
"subgraphStore": {
"blueprintName": "子图名称",
"saveBlueprint": "保存子图到库"
},
"tabMenu": {
"addToBookmarks": "添加到书签",
"closeOtherTabs": "关闭其他标签",

View File

@@ -367,6 +367,7 @@ const zSettings = z.object({
'Comfy.DevMode': z.boolean(),
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
'Comfy.DisableFloatRounding': z.boolean(),
'Comfy.DisableSliders': z.boolean(),
'Comfy.DOMClippingEnabled': z.boolean(),

View File

@@ -53,6 +53,7 @@ import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
@@ -797,6 +798,7 @@ export class ComfyApp {
this.resizeCanvas()
await useWorkspaceStore().workflow.syncWorkflows()
await useSubgraphStore().fetchSubgraphs()
await useExtensionService().loadExtensions()
this.#addProcessKeyHandler()
@@ -905,7 +907,7 @@ export class ComfyApp {
LiteGraph.registered_node_types
)) {
// Skip if we already have a backend definition or system definition
if (name in defs || name in SYSTEM_NODE_DEFS) {
if (name in defs || name in SYSTEM_NODE_DEFS || node.skip_list) {
continue
}

View File

@@ -35,6 +35,7 @@ import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
export type ConfirmationDialogType =
| 'default'
| 'overwrite'
| 'overwriteBlueprint'
| 'delete'
| 'dirtyClose'
| 'reinstall'

View File

@@ -41,6 +41,7 @@ import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkflowStore } from '@/stores/workflowStore'
@@ -949,6 +950,25 @@ export const useLitegraphService = () => {
): LGraphNode {
options.pos ??= getCanvasCenter()
if (nodeDef.name.startsWith(useSubgraphStore().typePrefix)) {
const canvas = canvasStore.getCanvas()
const bp = useSubgraphStore().getBlueprint(nodeDef.name)
const items: object = {
nodes: bp.nodes,
subgraphs: bp.definitions?.subgraphs
}
const results = canvas._deserializeItems(items, {
position: options.pos
})
if (!results) throw new Error('Failed to add subgraph blueprint')
const node = results.nodes.values().next().value
if (!node)
throw new Error(
'Subgraph blueprint was added, but failed to resolve a subgraph Node'
)
return node
}
const node = LiteGraph.createNode(
nodeDef.name,
nodeDef.display_name,

View File

@@ -34,7 +34,7 @@ export class NodeSearchService {
name: 'Input Type',
invokeSequence: 'i',
getItemOptions: (node) =>
Object.values(node.inputs).map((input) => input.type),
Object.values(node.inputs ?? []).map((input) => input.type),
fuseOptions
})

View File

@@ -82,15 +82,10 @@ export const useWorkflowService = () => {
* @param workflow The workflow to save
*/
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
const newFilename = await dialogService.prompt({
title: t('workflowService.saveWorkflow'),
message: t('workflowService.enterFilename') + ':',
defaultValue: workflow.filename
})
const newFilename = await workflow.promptSave()
if (!newFilename) return
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const newKey = newPath.substring(ComfyWorkflow.basePath.length)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow && !existingWorkflow.isTemporary) {
@@ -122,7 +117,7 @@ export const useWorkflowService = () => {
) as ComfyWorkflowJSON
state.id = id
const tempWorkflow = workflowStore.createTemporary(newKey, state)
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}

View File

@@ -16,6 +16,7 @@ import type {
ComfyOutputTypesSpec as ComfyOutputSpecV1
} from '@/schemas/nodeDefSchema'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useSubgraphStore } from '@/stores/subgraphStore'
import {
type NodeSource,
NodeSourceType,
@@ -291,8 +292,14 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
const showDeprecated = ref(false)
const showExperimental = ref(false)
const nodeDefFilters = ref<NodeDefFilter[]>([])
const subgraphStore = useSubgraphStore()
const nodeDefs = computed(() => Object.values(nodeDefsByName.value))
const nodeDefs = computed(() => {
return [
...Object.values(nodeDefsByName.value),
...subgraphStore.subgraphBlueprints
]
})
const nodeDataTypes = computed(() => {
const types = new Set<string>()
for (const nodeDef of nodeDefs.value) {
@@ -383,7 +390,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
})
// Subgraph nodes filter
// @todo Remove this filter when subgraph v2 is released
// Filter out litegraph typed subgraphs, saved blueprints are added in separately
registerNodeDefFilter({
id: 'core.subgraph',
name: 'Hide Subgraph Nodes',

321
src/stores/subgraphStore.ts Normal file
View File

@@ -0,0 +1,321 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeError } from '@/schemas/apiSchema'
import type {
ComfyNode,
ComfyWorkflowJSON,
NodeId
} from '@/schemas/comfyWorkflowSchema'
import type {
ComfyNodeDef as ComfyNodeDefV1,
InputSpec
} from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowService } from '@/services/workflowService'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { UserFile } from '@/stores/userFileStore'
import {
ComfyWorkflow,
LoadedComfyWorkflow,
useWorkflowStore
} from '@/stores/workflowStore'
async function confirmOverwrite(name: string): Promise<boolean | null> {
return await useDialogService().confirm({
title: t('subgraphStore.overwriteBlueprintTitle'),
type: 'overwriteBlueprint',
message: t('subgraphStore.overwriteBlueprint'),
itemList: [name]
})
}
export const useSubgraphStore = defineStore('subgraph', () => {
class SubgraphBlueprint extends ComfyWorkflow {
static override readonly basePath = 'subgraphs/'
override readonly tintCanvasBg = '#22227740'
hasPromptedSave: boolean = false
constructor(
options: { path: string; modified: number; size: number },
confirmFirstSave: boolean = false
) {
super(options)
this.hasPromptedSave = !confirmFirstSave
}
validateSubgraph() {
if (!this.activeState?.definitions)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
const { subgraphs } = this.activeState.definitions
const { nodes } = this.activeState
//Instanceof doesn't function as nodes are serialized
function isSubgraphNode(node: ComfyNode) {
return node && subgraphs.some((s) => s.id === node.type)
}
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
const errors: Record<NodeId, NodeError> = {}
//mark errors for all but first subgraph node
let firstSubgraphFound = false
for (let i = 0; i < nodes.length; i++) {
if (!firstSubgraphFound && isSubgraphNode(nodes[i])) {
firstSubgraphFound = true
continue
}
errors[nodes[i].id] = {
errors: [],
class_type: nodes[i].type,
dependent_outputs: []
}
}
useExecutionStore().lastNodeErrors = errors
useCanvasStore().getCanvas().draw(true, true)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
}
override async save(): Promise<UserFile> {
this.validateSubgraph()
if (
!this.hasPromptedSave &&
useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite')
) {
if (!(await confirmOverwrite(this.filename))) return this
this.hasPromptedSave = true
}
const ret = await super.save()
useSubgraphStore().updateDef(await this.load())
return ret
}
override async saveAs(path: string) {
this.validateSubgraph()
this.hasPromptedSave = true
const ret = await super.saveAs(path)
useSubgraphStore().updateDef(await this.load())
return ret
}
override async load({
force = false
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
if (!force && this.isLoaded) return await super.load({ force })
const loaded = await super.load({ force })
const st = loaded.activeState
const sg = (st.definitions?.subgraphs ?? []).find(
(sg) => sg.id == st.nodes[0].type
)
if (!sg)
throw new Error(
'Loaded subgraph blueprint does not contain valid subgraph'
)
sg.name = st.nodes[0].title = this.filename
return loaded
}
override async promptSave(): Promise<string | null> {
return await useDialogService().prompt({
title: t('subgraphStore.saveBlueprint'),
message: t('subgraphStore.blueprintName') + ':',
defaultValue: this.filename
})
}
override unload(): void {
//Skip unloading. Even if a workflow is closed after editing,
//it must remain loaded in order to be added to the graph
}
}
const subgraphCache: Record<string, LoadedComfyWorkflow> = {}
const typePrefix = 'SubgraphBlueprint.'
const subgraphDefCache = ref<Map<string, ComfyNodeDefImpl>>(new Map())
const canvasStore = useCanvasStore()
const subgraphBlueprints = computed(() => [
...subgraphDefCache.value.values()
])
async function fetchSubgraphs() {
async function loadBlueprint(options: {
path: string
modified: number
size: number
}): Promise<void> {
const name = options.path.slice(0, -'.json'.length)
options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp)
const nodeDef = convertToNodeDef(bp)
subgraphDefCache.value.set(name, nodeDef)
subgraphCache[name] = bp
}
const res = (
await api.listUserDataFullInfo(SubgraphBlueprint.basePath)
).filter((f) => f.path.endsWith('.json'))
const settled = await Promise.allSettled(res.map(loadBlueprint))
const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason)
errors.forEach((e) => console.error('Failed to load subgraph blueprint', e))
if (errors.length > 0) {
useToastStore().add({
severity: 'error',
summary: t('subgraphStore.loadFailure'),
detail: errors.length > 3 ? `x${errors.length}` : `${errors}`,
life: 6000
})
}
}
function convertToNodeDef(workflow: LoadedComfyWorkflow): ComfyNodeDefImpl {
const name = workflow.filename
const subgraphNode = workflow.changeTracker.initialState.nodes[0]
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
subgraphNode.inputs ??= []
subgraphNode.outputs ??= []
//NOTE: Types are cast to string. This is only used for input coloring on previews
const inputs = Object.fromEntries(
subgraphNode.inputs.map((i) => [
i.name,
[`${i.type}`, undefined] satisfies InputSpec
])
)
let description = 'User generated subgraph blueprint'
if (workflow.initialState.extra?.BlueprintDescription)
description = `${workflow.initialState.extra.BlueprintDescription}`
const nodedefv1: ComfyNodeDefV1 = {
input: { required: inputs },
output: subgraphNode.outputs.map((o) => `${o.type}`),
output_name: subgraphNode.outputs.map((o) => o.name),
name: typePrefix + name,
display_name: name,
description,
category: 'Subgraph Blueprints',
output_node: false,
python_module: 'blueprint'
}
const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1)
return nodeDefImpl
}
async function publishSubgraph() {
const canvas = canvasStore.getCanvas()
const subgraphNode = [...canvas.selectedItems][0]
if (
canvas.selectedItems.size !== 1 ||
!(subgraphNode instanceof SubgraphNode)
)
throw new TypeError('Must have single SubgraphNode selected to publish')
const { nodes = [], subgraphs = [] } = canvas._serializeItems([
subgraphNode
])
if (nodes.length != 1) {
throw new TypeError('Must have single SubgraphNode selected to publish')
}
//create minimal workflow
const workflowData = {
revision: 0,
last_node_id: subgraphNode.id,
last_link_id: 0,
nodes,
links: [],
version: 0.4,
definitions: { subgraphs }
}
//prompt name
const name = await useDialogService().prompt({
title: t('subgraphStore.saveBlueprint'),
message: t('subgraphStore.blueprintName') + ':',
defaultValue: subgraphNode.title
})
if (!name) return
if (subgraphDefCache.value.has(name) && !(await confirmOverwrite(name)))
//User has chosen not to overwrite.
return
//upload file
const path = SubgraphBlueprint.basePath + name + '.json'
const workflow = new SubgraphBlueprint({
path,
size: -1,
modified: Date.now()
})
workflow.originalContent = JSON.stringify(workflowData)
const loadedWorkflow = await workflow.load()
//Mark non-temporary
workflow.size = 1
await workflow.save()
//add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow)
subgraphDefCache.value.set(name, convertToNodeDef(loadedWorkflow))
subgraphCache[name] = loadedWorkflow
useToastStore().add({
severity: 'success',
summary: t('subgraphStore.publishSuccess'),
detail: t('subgraphStore.publishSuccessMessage'),
life: 4000
})
}
function updateDef(blueprint: LoadedComfyWorkflow) {
subgraphDefCache.value.set(blueprint.filename, convertToNodeDef(blueprint))
}
async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
useWorkflowStore().attachWorkflow(subgraphCache[name])
await useWorkflowService().openWorkflow(subgraphCache[name])
const canvas = useCanvasStore().getCanvas()
if (canvas.graph && 'subgraph' in canvas.graph.nodes[0])
canvas.setGraph(canvas.graph.nodes[0].subgraph)
}
function getBlueprint(nodeType: string): ComfyWorkflowJSON {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
return subgraphCache[name].changeTracker.initialState
}
async function deleteBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
if (
!(await useDialogService().confirm({
title: t('subgraphStore.confirmDeleteTitle'),
type: 'delete',
message: t('subgraphStore.confirmDelete'),
itemList: [name]
}))
)
return
await subgraphCache[name].delete()
delete subgraphCache[name]
subgraphDefCache.value.delete(name)
}
function isSubgraphBlueprint(
workflow: unknown
): workflow is SubgraphBlueprint {
return workflow instanceof SubgraphBlueprint
}
return {
deleteBlueprint,
editBlueprint,
fetchSubgraphs,
getBlueprint,
isSubgraphBlueprint,
publishSubgraph,
subgraphBlueprints,
typePrefix,
updateDef
}
})

View File

@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { t } from '@/i18n'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
@@ -10,6 +11,7 @@ import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import { useDialogService } from '@/services/dialogService'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
@@ -24,7 +26,8 @@ import { isSubgraph } from '@/utils/typeGuardUtil'
import { UserFile } from './userFileStore'
export class ComfyWorkflow extends UserFile {
static readonly basePath = 'workflows/'
static readonly basePath: string = 'workflows/'
readonly tintCanvasBg?: string
/**
* The change tracker for the workflow. Non-reactive raw object.
@@ -120,6 +123,14 @@ export class ComfyWorkflow extends UserFile {
this.content = JSON.stringify(this.activeState)
return await super.saveAs(path)
}
async promptSave(): Promise<string | null> {
return await useDialogService().prompt({
title: t('workflowService.saveWorkflow'),
message: t('workflowService.enterFilename') + ':',
defaultValue: this.filename
})
}
}
export interface LoadedComfyWorkflow extends ComfyWorkflow {
@@ -139,6 +150,7 @@ export interface LoadedComfyWorkflow extends ComfyWorkflow {
*/
export interface WorkflowStore {
activeWorkflow: LoadedComfyWorkflow | null
attachWorkflow: (workflow: ComfyWorkflow, openIndex?: number) => void
isActive: (workflow: ComfyWorkflow) => boolean
openWorkflows: ComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
@@ -290,6 +302,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
const loadedWorkflow = await workflow.load()
activeWorkflow.value = loadedWorkflow
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
console.debug('[workflowStore] open workflow', workflow.path)
return loadedWorkflow
}
@@ -304,11 +317,37 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
return newPath
}
const saveAs = (
existingWorkflow: ComfyWorkflow,
path: string
): ComfyWorkflow => {
const workflow: ComfyWorkflow = new (existingWorkflow.constructor as any)({
path,
modified: Date.now(),
size: -1
})
workflow.originalContent = workflow.content = existingWorkflow.content
workflowLookup.value[workflow.path] = workflow
return workflow
}
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
const fullPath = getUnconflictedPath(
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
)
const existingWorkflow = workflows.value.find((w) => w.fullFilename == path)
if (
path &&
workflowData &&
existingWorkflow?.changeTracker &&
!existingWorkflow.directory.startsWith(
ComfyWorkflow.basePath.slice(0, -1)
)
) {
existingWorkflow.changeTracker.reset(workflowData)
return existingWorkflow
}
const workflow = new ComfyWorkflow({
path: fullPath,
modified: Date.now(),
@@ -357,7 +396,10 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
const persistedWorkflows = computed(() =>
Array.from(workflows.value).filter((workflow) => workflow.isPersisted)
Array.from(workflows.value).filter(
(workflow) =>
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
)
)
const syncWorkflows = async (dir: string = '') => {
await syncEntities(
@@ -647,6 +689,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
return {
activeWorkflow,
attachWorkflow,
isActive,
openWorkflows,
openedWorkflowIndexShift,
@@ -658,6 +701,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
createTemporary,
renameWorkflow,
deleteWorkflow,
saveAs,
saveWorkflow,
reorderWorkflows,

View File

@@ -1,6 +1,7 @@
export enum NodeSourceType {
Core = 'core',
CustomNodes = 'custom_nodes',
Blueprint = 'blueprint',
Unknown = 'unknown'
}
@@ -36,6 +37,13 @@ export const getNodeSource = (python_module?: string): NodeSource => {
displayText: 'Comfy Core',
badgeText: '🦊'
}
} else if (modules[0] === 'blueprint') {
return {
type: NodeSourceType.Blueprint,
className: 'blueprint',
displayText: 'Blueprint',
badgeText: 'bp'
}
} else if (modules[0] === 'custom_nodes') {
const moduleName = modules[1]
// Custom nodes installed via ComfyNodeRegistry will be in the format of

View File

@@ -73,6 +73,10 @@ vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))

View File

@@ -0,0 +1,128 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/test/subgraph/fixtures/subgraphHelpers'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
prompt: () => 'testname',
confirm: () => true
}))
}))
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: () => comfyApp.canvas
}))
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
_deserializeItems: vi.fn((i) => i),
ds: { visible_area: [0, 0, 0, 0] },
selected_nodes: null
},
loadGraphData: vi.fn()
}
}))
const mockGraph = {
nodes: [{ type: '123' }],
definitions: { subgraphs: [{ id: '123' }] }
}
describe('useSubgraphStore', () => {
let store: ReturnType<typeof useSubgraphStore>
const mockFetch = async (filenames: Record<string, unknown>) => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
Object.keys(filenames).map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
vi.mocked(api).getUserData = vi.fn(
(f) =>
({
status: 200,
text: () => JSON.stringify(filenames[f.slice(10)])
}) as any
)
return await store.fetchSubgraphs()
}
beforeEach(() => {
setActivePinia(createPinia())
store = useSubgraphStore()
vi.clearAllMocks()
})
it('should allow publishing of a subgraph', async () => {
//mock canvas to provide a minimal subgraphNode
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [subgraphNode.serialize()],
subgraphs: [subgraph.serialize() as any]
}))
//mock saving of file
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'subgraphs/testname.json',
modified: Date.now(),
size: 2
})
} as Response)
await mockFetch({ 'testname.json': mockGraph })
//Dialogue service already mocked
await store.publishSubgraph()
expect(api.storeUserData).toHaveBeenCalled()
})
it('should display published nodes in the node library', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(
useNodeDefStore().nodeDefs.filter(
(d) => d.category == 'Subgraph Blueprints'
)
).toHaveLength(1)
})
it('should allow subgraphs to be edited', async () => {
await mockFetch({ 'test.json': mockGraph })
store.editBlueprint(store.typePrefix + 'test')
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})
it('should allow subgraphs to be added to graph', async () => {
//mock
await mockFetch({ 'test.json': mockGraph })
const res = useLitegraphService().addNodeOnGraph({
name: 'SubgraphBlueprint.test'
} as ComfyNodeDefV1)
expect(res).toBeTruthy()
})
})