mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 15:10:06 +00:00
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:
@@ -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>({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user