diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBox.ts b/browser_tests/fixtures/components/ComfyNodeSearchBox.ts index f2a96fa8f..8bc8949d5 100644 --- a/browser_tests/fixtures/components/ComfyNodeSearchBox.ts +++ b/browser_tests/fixtures/components/ComfyNodeSearchBox.ts @@ -80,4 +80,11 @@ export class ComfyNodeSearchBox { async removeFilter(index: number) { await this.filterChips.nth(index).locator('.p-chip-remove-icon').click() } + + /** + * Returns a locator for a search result containing the specified text. + */ + findResult(text: string): Locator { + return this.dropdown.locator('li').filter({ hasText: text }) + } } diff --git a/browser_tests/fixtures/helpers/CommandHelper.ts b/browser_tests/fixtures/helpers/CommandHelper.ts index 966f99b81..2d8420fd1 100644 --- a/browser_tests/fixtures/helpers/CommandHelper.ts +++ b/browser_tests/fixtures/helpers/CommandHelper.ts @@ -5,10 +5,18 @@ import type { KeyCombo } from '../../../src/platform/keybindings/types' export class CommandHelper { constructor(private readonly page: Page) {} - async executeCommand(commandId: string): Promise { - await this.page.evaluate((id: string) => { - return window.app!.extensionManager.command.execute(id) - }, commandId) + async executeCommand( + commandId: string, + metadata?: Record + ): Promise { + await this.page.evaluate( + ({ commandId, metadata }) => { + return window['app'].extensionManager.command.execute(commandId, { + metadata + }) + }, + { commandId, metadata } + ) } async registerCommand( diff --git a/browser_tests/tests/subgraphSearchAliases.spec.ts b/browser_tests/tests/subgraphSearchAliases.spec.ts new file mode 100644 index 000000000..a17e56dfd --- /dev/null +++ b/browser_tests/tests/subgraphSearchAliases.spec.ts @@ -0,0 +1,109 @@ +import { expect } from '@playwright/test' + +import type { ComfyPage } from '../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +async function createSubgraphAndNavigateInto(comfyPage: ComfyPage) { + await comfyPage.workflow.loadWorkflow('default') + await comfyPage.nextFrame() + + const ksampler = await comfyPage.nodeOps.getNodeRefById('3') + await ksampler.click('title') + await ksampler.convertToSubgraph() + await comfyPage.nextFrame() + + const subgraphNodes = + await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph') + expect(subgraphNodes.length).toBe(1) + const subgraphNode = subgraphNodes[0] + + await subgraphNode.navigateIntoSubgraph() + return subgraphNode +} + +async function exitSubgraphAndPublish( + comfyPage: ComfyPage, + subgraphNode: Awaited>, + blueprintName: string +) { + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + + await subgraphNode.click('title') + await comfyPage.command.executeCommand('Comfy.PublishSubgraph', { + name: blueprintName + }) + + await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 }) + await comfyPage.toast.closeToasts(1) +} + +async function searchAndExpectResult( + comfyPage: ComfyPage, + searchTerm: string, + expectedResult: string +) { + await comfyPage.command.executeCommand('Workspace.SearchBox.Toggle') + await expect(comfyPage.searchBox.input).toHaveCount(1) + await comfyPage.searchBox.input.fill(searchTerm) + await expect(comfyPage.searchBox.findResult(expectedResult)).toBeVisible({ + timeout: 10000 + }) +} + +test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default') + }) + + test('Can set search aliases on subgraph and find via search', async ({ + comfyPage + }) => { + const subgraphNode = await createSubgraphAndNavigateInto(comfyPage) + + await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', { + aliases: 'qwerty,unicorn' + }) + + const blueprintName = `test-aliases-${Date.now()}` + await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName) + await searchAndExpectResult(comfyPage, 'unicorn', blueprintName) + }) + + test('Can set description on subgraph', async ({ comfyPage }) => { + await createSubgraphAndNavigateInto(comfyPage) + + await comfyPage.command.executeCommand('Comfy.Subgraph.SetDescription', { + description: 'This is a test description' + }) + // Verify the description was set on the subgraph's extra + const description = await comfyPage.page.evaluate(() => { + const subgraph = window['app']!.canvas.subgraph + return (subgraph?.extra as Record)?.BlueprintDescription + }) + expect(description).toBe('This is a test description') + }) + + test('Search aliases persist after publish and reload', async ({ + comfyPage + }) => { + const subgraphNode = await createSubgraphAndNavigateInto(comfyPage) + + await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', { + aliases: 'dragon, fire breather' + }) + + const blueprintName = `test-persist-${Date.now()}` + await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName) + + // Reload the page to ensure aliases are persisted + await comfyPage.page.reload() + await comfyPage.page.waitForFunction( + () => window['app'] && window['app'].extensionManager + ) + await comfyPage.nextFrame() + + await searchAndExpectResult(comfyPage, 'dragon', blueprintName) + }) +}) diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index 6f1f3cfb6..5baa4a394 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -68,8 +68,11 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({ useWorkflowService: vi.fn(() => ({})) })) +const mockDialogService = vi.hoisted(() => ({ + prompt: vi.fn() +})) vi.mock('@/services/dialogService', () => ({ - useDialogService: vi.fn(() => ({})) + useDialogService: vi.fn(() => mockDialogService) })) vi.mock('@/services/litegraphService', () => ({ @@ -84,14 +87,31 @@ vi.mock('@/stores/toastStore', () => ({ useToastStore: vi.fn(() => ({})) })) +const mockChangeTracker = vi.hoisted(() => ({ + checkState: vi.fn() +})) +const mockWorkflowStore = vi.hoisted(() => ({ + activeWorkflow: { + changeTracker: mockChangeTracker + } +})) vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ - useWorkflowStore: vi.fn(() => ({})) + useWorkflowStore: vi.fn(() => mockWorkflowStore) })) vi.mock('@/stores/subgraphStore', () => ({ useSubgraphStore: vi.fn(() => ({})) })) +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: vi.fn(() => ({ + getCanvas: () => app.canvas + })), + useTitleEditorStore: vi.fn(() => ({ + titleEditorTarget: null + })) +})) + vi.mock('@/stores/workspace/colorPaletteStore', () => ({ useColorPaletteStore: vi.fn(() => ({})) })) @@ -155,11 +175,12 @@ describe('useCoreCommands', () => { findNodeById: vi.fn(), getNodeById: vi.fn(), setDirtyCanvas: vi.fn(), - sendActionToCanvas: vi.fn() + sendActionToCanvas: vi.fn(), + extra: {} as Record } as Partial as typeof app.canvas.subgraph } - const mockSubgraph = createMockSubgraph() + const mockSubgraph = createMockSubgraph()! function createMockSettingStore( getReturnValue: boolean @@ -270,4 +291,138 @@ describe('useCoreCommands', () => { expect(api.dispatchCustomEvent).not.toHaveBeenCalled() }) }) + + describe('Subgraph metadata commands', () => { + beforeEach(() => { + mockSubgraph.extra = {} + vi.clearAllMocks() + }) + + describe('SetDescription command', () => { + it('should do nothing when not in subgraph', async () => { + app.canvas.subgraph = undefined + + const commands = useCoreCommands() + const setDescCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetDescription' + )! + + await setDescCommand.function() + + expect(mockDialogService.prompt).not.toHaveBeenCalled() + }) + + it('should set description on subgraph.extra', async () => { + app.canvas.subgraph = mockSubgraph + mockDialogService.prompt.mockResolvedValue('Test description') + + const commands = useCoreCommands() + const setDescCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetDescription' + )! + + await setDescCommand.function() + + expect(mockDialogService.prompt).toHaveBeenCalled() + expect(mockSubgraph.extra.BlueprintDescription).toBe('Test description') + expect(mockChangeTracker.checkState).toHaveBeenCalled() + }) + + it('should not set description when user cancels', async () => { + app.canvas.subgraph = mockSubgraph + mockDialogService.prompt.mockResolvedValue(null) + + const commands = useCoreCommands() + const setDescCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetDescription' + )! + + await setDescCommand.function() + + expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined() + expect(mockChangeTracker.checkState).not.toHaveBeenCalled() + }) + }) + + describe('SetSearchAliases command', () => { + it('should do nothing when not in subgraph', async () => { + app.canvas.subgraph = undefined + + const commands = useCoreCommands() + const setAliasesCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetSearchAliases' + )! + + await setAliasesCommand.function() + + expect(mockDialogService.prompt).not.toHaveBeenCalled() + }) + + it('should set search aliases on subgraph.extra', async () => { + app.canvas.subgraph = mockSubgraph + mockDialogService.prompt.mockResolvedValue('alias1, alias2, alias3') + + const commands = useCoreCommands() + const setAliasesCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetSearchAliases' + )! + + await setAliasesCommand.function() + + expect(mockDialogService.prompt).toHaveBeenCalled() + expect(mockSubgraph.extra.BlueprintSearchAliases).toEqual([ + 'alias1', + 'alias2', + 'alias3' + ]) + expect(mockChangeTracker.checkState).toHaveBeenCalled() + }) + + it('should trim whitespace and filter empty strings', async () => { + app.canvas.subgraph = mockSubgraph + mockDialogService.prompt.mockResolvedValue(' alias1 , , alias2 , ') + + const commands = useCoreCommands() + const setAliasesCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetSearchAliases' + )! + + await setAliasesCommand.function() + + expect(mockSubgraph.extra.BlueprintSearchAliases).toEqual([ + 'alias1', + 'alias2' + ]) + }) + + it('should set undefined when empty input', async () => { + app.canvas.subgraph = mockSubgraph + mockDialogService.prompt.mockResolvedValue('') + + const commands = useCoreCommands() + const setAliasesCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetSearchAliases' + )! + + await setAliasesCommand.function() + + expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined() + }) + + it('should not set aliases when user cancels', async () => { + app.canvas.subgraph = mockSubgraph + mockDialogService.prompt.mockResolvedValue(null) + + const commands = useCoreCommands() + const setAliasesCommand = commands.find( + (cmd) => cmd.id === 'Comfy.Subgraph.SetSearchAliases' + )! + + await setAliasesCommand.function() + + expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined() + expect(mockChangeTracker.checkState).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index bb6257a6a..e1b26c47c 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -171,8 +171,9 @@ export function useCoreCommands(): ComfyCommand[] { icon: 'pi pi-save', label: 'Publish Subgraph', menubarLabel: 'Publish', - function: async () => { - await useSubgraphStore().publishSubgraph() + function: async (metadata?: Record) => { + const name = metadata?.name as string | undefined + await useSubgraphStore().publishSubgraph(name) } }, { @@ -1095,6 +1096,75 @@ export function useCoreCommands(): ComfyCommand[] { ) } }, + { + id: 'Comfy.Subgraph.SetDescription', + icon: 'pi pi-pencil', + label: 'Set Subgraph Description', + versionAdded: '1.39.7', + function: async (metadata?: Record) => { + const canvas = canvasStore.getCanvas() + const subgraph = canvas.subgraph + if (!subgraph) return + + const extra = (subgraph.extra ??= {}) as Record + const currentDescription = (extra.BlueprintDescription as string) ?? '' + + let description: string | null | undefined + const rawDescription = metadata?.description + if (rawDescription != null) { + description = + typeof rawDescription === 'string' + ? rawDescription + : String(rawDescription) + } + description ??= await dialogService.prompt({ + title: t('g.description'), + message: t('subgraphStore.enterDescription'), + defaultValue: currentDescription + }) + if (description === null) return + + extra.BlueprintDescription = description.trim() || undefined + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + }, + { + id: 'Comfy.Subgraph.SetSearchAliases', + icon: 'pi pi-search', + label: 'Set Subgraph Search Aliases', + versionAdded: '1.39.7', + function: async (metadata?: Record) => { + const canvas = canvasStore.getCanvas() + const subgraph = canvas.subgraph + if (!subgraph) return + + const parseAliases = (value: unknown): string[] => + (Array.isArray(value) ? value.map(String) : String(value).split(',')) + .map((s) => s.trim()) + .filter(Boolean) + + const extra = (subgraph.extra ??= {}) as Record + + let aliases: string[] + const rawAliases = metadata?.aliases + if (rawAliases == null) { + const input = await dialogService.prompt({ + title: t('subgraphStore.searchAliases'), + message: t('subgraphStore.enterSearchAliases'), + defaultValue: parseAliases(extra.BlueprintSearchAliases ?? '').join( + ', ' + ) + }) + if (input === null) return + aliases = parseAliases(input) + } else { + aliases = parseAliases(rawAliases) + } + + extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined + workflowStore.activeWorkflow?.changeTracker?.checkState() + } + }, { id: 'Comfy.Dev.ShowModelSelector', icon: 'pi pi-box', diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 9c612d65a..cadc5d24b 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1030,7 +1030,10 @@ "hidden": "Hidden / nested parameters", "hideAll": "Hide all", "showRecommended": "Show recommended widgets", - "cannotDeleteGlobal": "Cannot delete installed blueprints" + "cannotDeleteGlobal": "Cannot delete installed blueprints", + "enterDescription": "Enter a description", + "searchAliases": "Search Aliases", + "enterSearchAliases": "Enter search aliases (comma separated)" }, "electronFileDownload": { "inProgress": "In Progress", diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts index ee3a3aca3..0db21b8c8 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.ts @@ -275,7 +275,9 @@ const zExtra = z frontendVersion: z.string().optional(), linkExtensions: z.array(zComfyLinkExtension).optional(), reroutes: z.array(zReroute).optional(), - workflowRendererVersion: zRendererType.optional() + workflowRendererVersion: zRendererType.optional(), + BlueprintDescription: z.string().optional(), + BlueprintSearchAliases: z.array(z.string()).optional() }) .passthrough() @@ -395,6 +397,10 @@ interface SubgraphDefinitionBase< revision: number name: string category?: string + /** Custom metadata for the subgraph (description, searchAliases, etc.) */ + extra?: T extends ComfyWorkflow1BaseInput + ? z.input | null + : z.output | null inputNode: T extends ComfyWorkflow1BaseInput ? z.input diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 57c0724ec..98c7008a7 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -234,6 +234,7 @@ export type GlobalSubgraphData = { info: { node_pack: string category?: string + search_aliases?: string[] } data: string | Promise } diff --git a/src/services/nodeSearchService.test.ts b/src/services/nodeSearchService.test.ts index 75e409501..26d7e4d31 100644 --- a/src/services/nodeSearchService.test.ts +++ b/src/services/nodeSearchService.test.ts @@ -61,6 +61,54 @@ const EXAMPLE_NODE_DEFS: ComfyNodeDefImpl[] = ( return def }) +const NODE_DEFS_WITH_SEARCH_ALIASES: ComfyNodeDefImpl[] = ( + [ + { + input: { required: {} }, + output: ['MODEL'], + output_is_list: [false], + output_name: ['MODEL'], + name: 'CheckpointLoaderSimple', + display_name: 'Load Checkpoint', + description: '', + python_module: 'nodes', + category: 'loaders', + output_node: false, + search_aliases: ['ckpt', 'model loader', 'checkpoint'] + }, + { + input: { required: {} }, + output: ['IMAGE'], + output_is_list: [false], + output_name: ['IMAGE'], + name: 'LoadImage', + display_name: 'Load Image', + description: '', + python_module: 'nodes', + category: 'loaders', + output_node: false, + search_aliases: ['img', 'picture'] + }, + { + input: { required: {} }, + output: ['LATENT'], + output_is_list: [false], + output_name: ['LATENT'], + name: 'VAEEncode', + display_name: 'VAE Encode', + description: '', + python_module: 'nodes', + category: 'latent', + output_node: false + // No search_aliases + } + ] as ComfyNodeDef[] +).map((nodeDef: ComfyNodeDef) => { + const def = new ComfyNodeDefImpl(nodeDef) + def['postProcessSearchScores'] = (s) => s + return def +}) + describe('nodeSearchService', () => { it('searches with input filter', () => { const service = new NodeSearchService(EXAMPLE_NODE_DEFS) @@ -74,4 +122,52 @@ describe('nodeSearchService', () => { ).toHaveLength(2) expect(service.searchNode('L')).toHaveLength(2) }) + + describe('search_aliases', () => { + it('finds nodes by search_aliases', () => { + const service = new NodeSearchService(NODE_DEFS_WITH_SEARCH_ALIASES) + // Search by alias + const ckptResults = service.searchNode('ckpt') + expect(ckptResults).toHaveLength(1) + expect(ckptResults[0].name).toBe('CheckpointLoaderSimple') + }) + + it('finds nodes by partial alias match', () => { + const service = new NodeSearchService(NODE_DEFS_WITH_SEARCH_ALIASES) + // Search by partial alias "model" should match "model loader" alias + const modelResults = service.searchNode('model') + expect(modelResults.length).toBeGreaterThanOrEqual(1) + expect( + modelResults.some((r) => r.name === 'CheckpointLoaderSimple') + ).toBe(true) + }) + + it('finds nodes by display_name when no alias matches', () => { + const service = new NodeSearchService(NODE_DEFS_WITH_SEARCH_ALIASES) + // "VAE" should match by display_name since there are no aliases + const vaeResults = service.searchNode('VAE') + expect(vaeResults).toHaveLength(1) + expect(vaeResults[0].name).toBe('VAEEncode') + }) + + it('finds nodes by both alias and display_name', () => { + const service = new NodeSearchService(NODE_DEFS_WITH_SEARCH_ALIASES) + // "img" should match LoadImage by alias + const imgResults = service.searchNode('img') + expect(imgResults).toHaveLength(1) + expect(imgResults[0].name).toBe('LoadImage') + + // "Load" should match both checkpoint and image loaders by display_name + const loadResults = service.searchNode('Load') + expect(loadResults.length).toBeGreaterThanOrEqual(2) + }) + + it('handles nodes without search_aliases', () => { + const service = new NodeSearchService(NODE_DEFS_WITH_SEARCH_ALIASES) + // Ensure nodes without aliases are still searchable by name/display_name + const encodeResults = service.searchNode('Encode') + expect(encodeResults).toHaveLength(1) + expect(encodeResults[0].name).toBe('VAEEncode') + }) + }) }) diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 02aa6df31..b80eb671f 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -77,6 +77,11 @@ export class ComfyNodeDefImpl * and input connectivity. */ readonly price_badge?: PriceBadge + /** + * Alternative names for search. Useful for synonyms, abbreviations, + * or old names after renaming a node. + */ + readonly search_aliases?: string[] // V2 fields readonly inputs: Record diff --git a/src/stores/subgraphStore.test.ts b/src/stores/subgraphStore.test.ts index a92214d72..523823576 100644 --- a/src/stores/subgraphStore.test.ts +++ b/src/stores/subgraphStore.test.ts @@ -167,4 +167,142 @@ describe('useSubgraphStore', () => { await mockFetch({ 'test.json': mockGraph }) expect(store.isGlobalBlueprint('nonexistent')).toBe(false) }) + + describe('search_aliases support', () => { + it('should include search_aliases from workflow extra', async () => { + const mockGraphWithAliases = { + nodes: [{ type: '123' }], + definitions: { + subgraphs: [{ id: '123' }] + }, + extra: { + BlueprintSearchAliases: ['alias1', 'alias2', 'my workflow'] + } + } + await mockFetch({ 'test-with-aliases.json': mockGraphWithAliases }) + + const nodeDef = useNodeDefStore().nodeDefs.find( + (d) => d.name === 'SubgraphBlueprint.test-with-aliases' + ) + expect(nodeDef).toBeDefined() + expect(nodeDef?.search_aliases).toEqual([ + 'alias1', + 'alias2', + 'my workflow' + ]) + }) + + it('should include search_aliases from global blueprint info', async () => { + await mockFetch( + {}, + { + global_with_aliases: { + name: 'Global With Aliases', + info: { + node_pack: 'comfy_essentials', + search_aliases: ['global alias', 'test alias'] + }, + data: JSON.stringify(mockGraph) + } + } + ) + + const nodeDef = useNodeDefStore().nodeDefs.find( + (d) => d.name === 'SubgraphBlueprint.global_with_aliases' + ) + expect(nodeDef).toBeDefined() + expect(nodeDef?.search_aliases).toEqual(['global alias', 'test alias']) + }) + + it('should not have search_aliases if not provided', async () => { + await mockFetch({ 'test.json': mockGraph }) + + const nodeDef = useNodeDefStore().nodeDefs.find( + (d) => d.name === 'SubgraphBlueprint.test' + ) + expect(nodeDef).toBeDefined() + expect(nodeDef?.search_aliases).toBeUndefined() + }) + + it('should include description from workflow extra', async () => { + const mockGraphWithDescription = { + nodes: [{ type: '123' }], + definitions: { + subgraphs: [{ id: '123' }] + }, + extra: { + BlueprintDescription: 'This is a test blueprint' + } + } + await mockFetch({ + 'test-with-description.json': mockGraphWithDescription + }) + + const nodeDef = useNodeDefStore().nodeDefs.find( + (d) => d.name === 'SubgraphBlueprint.test-with-description' + ) + expect(nodeDef).toBeDefined() + expect(nodeDef?.description).toBe('This is a test blueprint') + }) + + it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + const graph = subgraphNode.graph! + graph.add(subgraphNode) + + // Set metadata on the subgraph's extra (as the commands do) + subgraph.extra = { + BlueprintDescription: 'Test description', + BlueprintSearchAliases: ['alias1', 'alias2'] + } + + vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode]) + vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => { + const serializedSubgraph = { + ...subgraph.serialize(), + links: [], + groups: [], + version: 1 + } as Partial as ExportedSubgraph + return { + nodes: [subgraphNode.serialize()], + subgraphs: [serializedSubgraph] + } + }) + + let savedWorkflowData: Record | null = null + vi.mocked(api.storeUserData).mockImplementation(async (_path, data) => { + savedWorkflowData = JSON.parse(data as string) + return { + status: 200, + json: () => + Promise.resolve({ + path: 'subgraphs/testname.json', + modified: Date.now(), + size: 2 + }) + } as Response + }) + + await mockFetch({ 'testname.json': mockGraph }) + await store.publishSubgraph() + + expect(savedWorkflowData).not.toBeNull() + + // Metadata should be in top-level extra + expect(savedWorkflowData!.extra).toEqual({ + BlueprintDescription: 'Test description', + BlueprintSearchAliases: ['alias1', 'alias2'] + }) + + // Metadata should NOT be in subgraph's extra + const definitions = savedWorkflowData!.definitions as { + subgraphs: Array<{ extra?: Record }> + } + const subgraphExtra = definitions.subgraphs[0]?.extra + expect(subgraphExtra?.BlueprintDescription).toBeUndefined() + expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined() + }) + }) }) diff --git a/src/stores/subgraphStore.ts b/src/stores/subgraphStore.ts index 59f4ac4ae..2e1be2ab5 100644 --- a/src/stores/subgraphStore.ts +++ b/src/stores/subgraphStore.ts @@ -95,18 +95,46 @@ export const useSubgraphStore = defineStore('subgraph', () => { if (!(await confirmOverwrite(this.filename))) return this this.hasPromptedSave = true } + // Extract metadata from subgraph.extra to workflow.extra before saving + this.extractMetadataToWorkflowExtra() const ret = await super.save() - registerNodeDef(await this.load(), { + // Force reload to update initialState with saved metadata + registerNodeDef(await this.load({ force: true }), { category: 'Subgraph Blueprints/User' }) return ret } + /** + * Moves all properties (except workflowRendererVersion) from subgraph.extra + * to workflow.extra, then removes from subgraph.extra to avoid duplication. + */ + private extractMetadataToWorkflowExtra(): void { + if (!this.activeState) return + const subgraph = this.activeState.definitions?.subgraphs?.[0] + if (!subgraph?.extra) return + + const sgExtra = subgraph.extra as Record + const workflowExtra = (this.activeState.extra ??= {}) as Record< + string, + unknown + > + + for (const key of Object.keys(sgExtra)) { + if (key === 'workflowRendererVersion') continue + workflowExtra[key] = sgExtra[key] + delete sgExtra[key] + } + } + override async saveAs(path: string) { this.validateSubgraph() this.hasPromptedSave = true + // Extract metadata from subgraph.extra to workflow.extra before saving + this.extractMetadataToWorkflowExtra() const ret = await super.saveAs(path) - registerNodeDef(await this.load(), { + // Force reload to update initialState with saved metadata + registerNodeDef(await this.load({ force: true }), { category: 'Subgraph Blueprints/User' }) return ret @@ -125,6 +153,17 @@ export const useSubgraphStore = defineStore('subgraph', () => { 'Loaded subgraph blueprint does not contain valid subgraph' ) sg.name = st.nodes[0].title = this.filename + + // Copy blueprint metadata from workflow extra to subgraph extra + // so it's available when editing via canvas.subgraph.extra + if (st.extra) { + const sgExtra = (sg.extra ??= {}) as Record + for (const [key, value] of Object.entries(st.extra)) { + if (key === 'workflowRendererVersion') continue + sgExtra[key] = value + } + } + return loaded } override async promptSave(): Promise { @@ -177,7 +216,8 @@ export const useSubgraphStore = defineStore('subgraph', () => { { python_module: v.info.node_pack, display_name: v.name, - category + category, + search_aliases: v.info.search_aliases }, k ) @@ -223,9 +263,10 @@ export const useSubgraphStore = defineStore('subgraph', () => { [`${i.type}`, undefined] satisfies InputSpec ]) ) - let description = 'User generated subgraph blueprint' - if (workflow.initialState.extra?.BlueprintDescription) - description = `${workflow.initialState.extra.BlueprintDescription}` + const workflowExtra = workflow.initialState.extra + const description = + workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint' + const search_aliases = workflowExtra?.BlueprintSearchAliases const nodedefv1: ComfyNodeDefV1 = { input: { required: inputs }, output: subgraphNode.outputs.map((o) => `${o.type}`), @@ -236,13 +277,14 @@ export const useSubgraphStore = defineStore('subgraph', () => { category: 'Subgraph Blueprints', output_node: false, python_module: 'blueprint', + search_aliases, ...overrides } const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1) subgraphDefCache.value.set(name, nodeDefImpl) subgraphCache[name] = workflow } - async function publishSubgraph() { + async function publishSubgraph(providedName?: string) { const canvas = canvasStore.getCanvas() const subgraphNode = [...canvas.selectedItems][0] if ( @@ -257,22 +299,25 @@ export const useSubgraphStore = defineStore('subgraph', () => { 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: [], + links: [] as never[], version: 0.4, definitions: { subgraphs } } //prompt name - const name = await useDialogService().prompt({ - title: t('subgraphStore.saveBlueprint'), - message: t('subgraphStore.blueprintNamePrompt'), - defaultValue: subgraphNode.title - }) + const name = + providedName ?? + (await useDialogService().prompt({ + title: t('subgraphStore.saveBlueprint'), + message: t('subgraphStore.blueprintNamePrompt'), + defaultValue: subgraphNode.title + })) if (!name) return if (subgraphDefCache.value.has(name) && !(await confirmOverwrite(name))) //User has chosen not to overwrite.