feat: add category support for blueprints and protect global blueprints (#8378)

## Summary

This PR adds two related features for subgraph blueprints:

### 1. Protect Global Blueprints from Deletion
- Added `isGlobalBlueprint()` helper that distinguishes blueprints by
`python_module` field
- User blueprints have `python_module: 'blueprint'`
- Global (installed) blueprints have `python_module` set to the node
pack name
- Guarded `deleteBlueprint()` to show a warning toast for global
blueprints
- Hidden delete menu in node library for global blueprints

### 2. Category Support for Blueprints
- User blueprints now use category `Subgraph Blueprints/User`
- Global blueprints use `Subgraph Blueprints/{category}` if category is
provided, otherwise `Subgraph Blueprints`
- Extended `GlobalSubgraphData.info` type with optional `category` field
- Added `category` to `zSubgraphDefinition` schema

## Files Changed
- `src/stores/subgraphStore.ts` - Core logic for both features
- `src/stores/subgraphStore.test.ts` - Tests for `isGlobalBlueprint`
- `src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue` - Hide
delete menu for global blueprints
- `src/scripts/api.ts` - Extended `GlobalSubgraphData` type
- `src/platform/workflow/validation/schemas/workflowSchema.ts` - Added
category to schema
- `src/locales/en/main.json` - Added translation key

## Testing
-  `pnpm typecheck` passed
-  `pnpm lint` passed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8378-feat-add-category-support-for-blueprints-and-protect-global-blueprints-2f66d73d36508137aa67c2d88c358b69)
by [Unito](https://www.unito.io)

---------

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:
Christian Byrne
2026-01-28 23:30:48 -08:00
committed by GitHub
parent 0faf2220b8
commit 23a5baef43
6 changed files with 86 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -25,6 +26,7 @@ vi.mock('@/scripts/api', () => ({
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
@@ -60,7 +62,10 @@ const mockGraph = {
describe('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(
Object.keys(filenames).map((filename) => ({
path: filename,
@@ -68,13 +73,13 @@ describe('useSubgraphStore', () => {
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
vi.mocked(api).getUserData = vi.fn((f) =>
Promise.resolve({
status: 200,
text: () => Promise.resolve(JSON.stringify(filenames[f.slice(10)]))
} as Response)
)
vi.mocked(api.getGlobalSubgraphs).mockResolvedValue(globalSubgraphs)
return await store.fetchSubgraphs()
}
@@ -114,7 +119,7 @@ describe('useSubgraphStore', () => {
await mockFetch({ 'test.json': mockGraph })
expect(
useNodeDefStore().nodeDefs.filter(
(d) => d.category == 'Subgraph Blueprints'
(d) => d.category === 'Subgraph Blueprints/User'
)
).toHaveLength(1)
})
@@ -132,4 +137,25 @@ describe('useSubgraphStore', () => {
} as ComfyNodeDefV1)
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
}
const ret = await super.save()
useSubgraphStore().updateDef(await this.load())
registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret
}
@@ -104,7 +106,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.validateSubgraph()
this.hasPromptedSave = true
const ret = await super.saveAs(path)
useSubgraphStore().updateDef(await this.load())
registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret
}
override async load({ force = false }: { force?: boolean } = {}): Promise<
@@ -151,7 +155,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp)
registerNodeDef(bp)
registerNodeDef(bp, { category: 'Subgraph Blueprints/User' })
}
async function loadInstalledBlueprints() {
async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
@@ -165,11 +169,15 @@ export const useSubgraphStore = defineStore('subgraph', () => {
blueprint.filename = v.name
useWorkflowStore().attachWorkflow(blueprint)
const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
registerNodeDef(
loaded,
{
python_module: v.info.node_pack,
display_name: v.name
display_name: v.name,
category
},
k
)
@@ -284,7 +292,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
await workflow.save()
//add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow)
registerNodeDef(loadedWorkflow)
useToastStore().add({
severity: 'success',
summary: t('subgraphStore.publishSuccess'),
@@ -292,9 +299,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
life: 4000
})
}
function updateDef(blueprint: LoadedComfyWorkflow) {
registerNodeDef(blueprint)
}
async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
@@ -315,9 +319,17 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
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 (!(name in subgraphCache)) throw new Error('not yet loaded')
if (isGlobalBlueprint(name)) {
useToastStore().add({
severity: 'warn',
summary: t('subgraphStore.cannotDeleteGlobal'),
life: 4000
})
return
}
if (
!(await useDialogService().confirm({
title: t('subgraphStore.confirmDeleteTitle'),
@@ -338,15 +350,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
return workflow instanceof SubgraphBlueprint
}
function isGlobalBlueprint(name: string): boolean {
const nodeDef = subgraphDefCache.value.get(name)
return nodeDef !== undefined && nodeDef.python_module !== 'blueprint'
}
return {
deleteBlueprint,
editBlueprint,
fetchSubgraphs,
getBlueprint,
isGlobalBlueprint,
isSubgraphBlueprint,
publishSubgraph,
subgraphBlueprints,
typePrefix,
updateDef
typePrefix
}
})