[backport cloud/1.38] feat: add category support for blueprints and protect global blueprints (#8466)

Backport of #8378 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8466-backport-cloud-1-38-feat-add-category-support-for-blueprints-and-protect-global-bluepr-2f86d73d3650814da4b4d8d773d2ffe5)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
This commit is contained in:
Comfy Org PR Bot
2026-01-30 12:15:47 +09:00
committed by GitHub
parent a5577a7f45
commit 1631f22efb
6 changed files with 86 additions and 31 deletions

View File

@@ -13,10 +13,7 @@
severity="danger" severity="danger"
/> />
</template> </template>
<template <template v-if="isUserBlueprint" #actions>
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<Button <Button
variant="destructive" variant="destructive"
size="icon-sm" size="icon-sm"
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
await useSubgraphStore().editBlueprint(props.node.data.name) await useSubgraphStore().editBlueprint(props.node.data.name)
} }
const menu = ref<InstanceType<typeof ContextMenu> | null>(null) const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const subgraphStore = useSubgraphStore()
const isUserBlueprint = computed(() => {
const name = nodeDef.value.name
if (!name.startsWith(subgraphStore.typePrefix)) return false
return !subgraphStore.isGlobalBlueprint(
name.slice(subgraphStore.typePrefix.length)
)
})
const menuItems = computed<MenuItem[]>(() => { const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [ if (!isUserBlueprint.value) return []
return [
{ {
label: t('g.delete'), label: t('g.delete'),
icon: 'pi pi-trash', icon: 'pi pi-trash',
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
command: deleteBlueprint command: deleteBlueprint
} }
] ]
return items
}) })
function handleContextMenu(event: Event) { function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return if (!isUserBlueprint.value) return
menu.value?.show(event) menu.value?.show(event)
} }
function deleteBlueprint() { function deleteBlueprint() {
if (!props.node.data) return if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name) void subgraphStore.deleteBlueprint(props.node.data.name)
} }
const nodePreviewStyle = ref<CSSProperties>({ const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -993,7 +993,8 @@
"showAll": "Show all", "showAll": "Show all",
"hidden": "Hidden / nested parameters", "hidden": "Hidden / nested parameters",
"hideAll": "Hide all", "hideAll": "Hide all",
"showRecommended": "Show recommended widgets" "showRecommended": "Show recommended widgets",
"cannotDeleteGlobal": "Cannot delete installed blueprints"
}, },
"electronFileDownload": { "electronFileDownload": {
"inProgress": "In Progress", "inProgress": "In Progress",

View File

@@ -394,6 +394,7 @@ interface SubgraphDefinitionBase<
id: string id: string
revision: number revision: number
name: string name: string
category?: string
inputNode: T extends ComfyWorkflow1BaseInput inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode> ? z.input<typeof zExportedSubgraphIONode>
@@ -425,6 +426,7 @@ const zSubgraphDefinition = zComfyWorkflow1
id: z.string().uuid(), id: z.string().uuid(),
revision: z.number(), revision: z.number(),
name: z.string(), name: z.string(),
category: z.string().optional(),
inputNode: zExportedSubgraphIONode, inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode, outputNode: zExportedSubgraphIONode,

View File

@@ -231,7 +231,10 @@ type ComplexApiEvents = keyof NeverNever<ApiEventTypes>
export type GlobalSubgraphData = { export type GlobalSubgraphData = {
name: string name: string
info: { node_pack: string } info: {
node_pack: string
category?: string
}
data: string | Promise<string> data: string | Promise<string>
} }

View File

@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema' import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app' import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService' import { useLitegraphService } from '@/services/litegraphService'
@@ -24,6 +25,7 @@ vi.mock('@/scripts/api', () => ({
getUserData: vi.fn(), getUserData: vi.fn(),
storeUserData: vi.fn(), storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(), listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
apiURL: vi.fn(), apiURL: vi.fn(),
addEventListener: vi.fn() addEventListener: vi.fn()
} }
@@ -59,7 +61,10 @@ const mockGraph = {
describe('useSubgraphStore', () => { describe('useSubgraphStore', () => {
let store: ReturnType<typeof useSubgraphStore> let store: ReturnType<typeof useSubgraphStore>
const mockFetch = async (filenames: Record<string, unknown>) => { async function mockFetch(
filenames: Record<string, unknown>,
globalSubgraphs: Record<string, GlobalSubgraphData> = {}
) {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue( vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
Object.keys(filenames).map((filename) => ({ Object.keys(filenames).map((filename) => ({
path: filename, path: filename,
@@ -67,13 +72,13 @@ describe('useSubgraphStore', () => {
size: 1 // size !== -1 for remote workflows size: 1 // size !== -1 for remote workflows
})) }))
) )
vi.mocked(api).getUserData = vi.fn( vi.mocked(api).getUserData = vi.fn((f) =>
(f) => Promise.resolve({
({ status: 200,
status: 200, text: () => Promise.resolve(JSON.stringify(filenames[f.slice(10)]))
text: () => JSON.stringify(filenames[f.slice(10)]) } as Response)
}) as any
) )
vi.mocked(api.getGlobalSubgraphs).mockResolvedValue(globalSubgraphs)
return await store.fetchSubgraphs() return await store.fetchSubgraphs()
} }
@@ -113,7 +118,7 @@ describe('useSubgraphStore', () => {
await mockFetch({ 'test.json': mockGraph }) await mockFetch({ 'test.json': mockGraph })
expect( expect(
useNodeDefStore().nodeDefs.filter( useNodeDefStore().nodeDefs.filter(
(d) => d.category == 'Subgraph Blueprints' (d) => d.category === 'Subgraph Blueprints/User'
) )
).toHaveLength(1) ).toHaveLength(1)
}) })
@@ -131,4 +136,25 @@ describe('useSubgraphStore', () => {
} as ComfyNodeDefV1) } as ComfyNodeDefV1)
expect(res).toBeTruthy() expect(res).toBeTruthy()
}) })
it('should identify user blueprints as non-global', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('test')).toBe(false)
})
it('should identify global blueprints loaded from getGlobalSubgraphs', async () => {
await mockFetch(
{},
{
global_test: {
name: 'Global Test Blueprint',
info: { node_pack: 'comfy_essentials' },
data: JSON.stringify(mockGraph)
}
}
)
expect(store.isGlobalBlueprint('global_test')).toBe(true)
})
it('should return false for non-existent blueprints', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
})
}) })

View File

@@ -96,7 +96,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = true this.hasPromptedSave = true
} }
const ret = await super.save() const ret = await super.save()
useSubgraphStore().updateDef(await this.load()) registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret return ret
} }
@@ -104,7 +106,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.validateSubgraph() this.validateSubgraph()
this.hasPromptedSave = true this.hasPromptedSave = true
const ret = await super.saveAs(path) const ret = await super.saveAs(path)
useSubgraphStore().updateDef(await this.load()) registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret return ret
} }
override async load({ force = false }: { force?: boolean } = {}): Promise< override async load({ force = false }: { force?: boolean } = {}): Promise<
@@ -151,7 +155,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
options.path = SubgraphBlueprint.basePath + options.path options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load() const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp) useWorkflowStore().attachWorkflow(bp)
registerNodeDef(bp) registerNodeDef(bp, { category: 'Subgraph Blueprints/User' })
} }
async function loadInstalledBlueprints() { async function loadInstalledBlueprints() {
async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) { async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
@@ -165,11 +169,15 @@ export const useSubgraphStore = defineStore('subgraph', () => {
blueprint.filename = v.name blueprint.filename = v.name
useWorkflowStore().attachWorkflow(blueprint) useWorkflowStore().attachWorkflow(blueprint)
const loaded = await blueprint.load() const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
registerNodeDef( registerNodeDef(
loaded, loaded,
{ {
python_module: v.info.node_pack, python_module: v.info.node_pack,
display_name: v.name display_name: v.name,
category
}, },
k k
) )
@@ -284,7 +292,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
await workflow.save() await workflow.save()
//add to files list? //add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow) useWorkflowStore().attachWorkflow(loadedWorkflow)
registerNodeDef(loadedWorkflow)
useToastStore().add({ useToastStore().add({
severity: 'success', severity: 'success',
summary: t('subgraphStore.publishSuccess'), summary: t('subgraphStore.publishSuccess'),
@@ -292,9 +299,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
life: 4000 life: 4000
}) })
} }
function updateDef(blueprint: LoadedComfyWorkflow) {
registerNodeDef(blueprint)
}
async function editBlueprint(nodeType: string) { async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length) const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache)) if (!(name in subgraphCache))
@@ -315,9 +319,17 @@ export const useSubgraphStore = defineStore('subgraph', () => {
} }
async function deleteBlueprint(nodeType: string) { async function deleteBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length) const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache)) if (!(name in subgraphCache)) throw new Error('not yet loaded')
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded') if (isGlobalBlueprint(name)) {
useToastStore().add({
severity: 'warn',
summary: t('subgraphStore.cannotDeleteGlobal'),
life: 4000
})
return
}
if ( if (
!(await useDialogService().confirm({ !(await useDialogService().confirm({
title: t('subgraphStore.confirmDeleteTitle'), title: t('subgraphStore.confirmDeleteTitle'),
@@ -338,15 +350,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
return workflow instanceof SubgraphBlueprint return workflow instanceof SubgraphBlueprint
} }
function isGlobalBlueprint(name: string): boolean {
const nodeDef = subgraphDefCache.value.get(name)
return nodeDef !== undefined && nodeDef.python_module !== 'blueprint'
}
return { return {
deleteBlueprint, deleteBlueprint,
editBlueprint, editBlueprint,
fetchSubgraphs, fetchSubgraphs,
getBlueprint, getBlueprint,
isGlobalBlueprint,
isSubgraphBlueprint, isSubgraphBlueprint,
publishSubgraph, publishSubgraph,
subgraphBlueprints, subgraphBlueprints,
typePrefix, typePrefix
updateDef
} }
}) })