mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
Add support for search aliases on subgraphs (#8608)
## Summary - add commands for setting search aliases and description when in subgraph - in future we can add these fields to the dialog when publishing a subgraph - map workflow extra metadata on save/load from from/to subgraph node to allow access via `canvas.subgraph.extra` ## Changes **What**: - new core commands for Comfy.Subgraph.SetSearchAliases & Comfy.Subgraph.SetDescription to be called when in a subgraph context - update Publish command to allow command metadata arg for name - update test executeCommand to allow passing metadata arg ## Review Focus - When saving a subgraph, the outer workflow "wrapper" is created at the point of publishing. So unlike a normal workflow `extra` property that is available at any point, for a subgraph this is not accessible. To workaround this, the `extra` property that exists on the inner subgraph node is copied to the top level on save, and restored on load so extra properties can be set via `canvas.subgraph.extra`. - I have kept the existing naming format matching `BlueprintDescription` for `BlueprintSearchAliases` but i'm not sure if the description was ever used before ## Screenshots https://github.com/user-attachments/assets/4d4df9c1-2281-4589-aa56-ab07cdecd353 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8608-Add-support-for-search-aliases-on-subgraphs-2fd6d73d365081d083caebd6befcacdd) by [Unito](https://www.unito.io) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Set subgraph search aliases (comma-separated) and descriptions; aliases enable discovery by alternative names. * Publish subgraphs using a provided name. * Node definitions now support search aliases so nodes can be found by alternate names. * UI strings added for entering descriptions and search aliases. * **Tests** * Added end-to-end and unit tests covering aliases, descriptions, search behavior, publishing, and persistence. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await this.page.evaluate((id: string) => {
|
||||
return window.app!.extensionManager.command.execute(id)
|
||||
}, commandId)
|
||||
async executeCommand(
|
||||
commandId: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ commandId, metadata }) => {
|
||||
return window['app'].extensionManager.command.execute(commandId, {
|
||||
metadata
|
||||
})
|
||||
},
|
||||
{ commandId, metadata }
|
||||
)
|
||||
}
|
||||
|
||||
async registerCommand(
|
||||
|
||||
109
browser_tests/tests/subgraphSearchAliases.spec.ts
Normal file
109
browser_tests/tests/subgraphSearchAliases.spec.ts
Normal file
@@ -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<ReturnType<typeof createSubgraphAndNavigateInto>>,
|
||||
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<string, unknown>)?.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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
} as Partial<typeof app.canvas.subgraph> 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const subgraph = canvas.subgraph
|
||||
if (!subgraph) return
|
||||
|
||||
const extra = (subgraph.extra ??= {}) as Record<string, unknown>
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>
|
||||
|
||||
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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof zExtra> | null
|
||||
: z.output<typeof zExtra> | null
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
|
||||
@@ -234,6 +234,7 @@ export type GlobalSubgraphData = {
|
||||
info: {
|
||||
node_pack: string
|
||||
category?: string
|
||||
search_aliases?: string[]
|
||||
}
|
||||
data: string | Promise<string>
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, InputSpecV2>
|
||||
|
||||
@@ -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<ExportedSubgraph> as ExportedSubgraph
|
||||
return {
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [serializedSubgraph]
|
||||
}
|
||||
})
|
||||
|
||||
let savedWorkflowData: Record<string, unknown> | 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<string, unknown> }>
|
||||
}
|
||||
const subgraphExtra = definitions.subgraphs[0]?.extra
|
||||
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
|
||||
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
for (const [key, value] of Object.entries(st.extra)) {
|
||||
if (key === 'workflowRendererVersion') continue
|
||||
sgExtra[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return loaded
|
||||
}
|
||||
override async promptSave(): Promise<string | null> {
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user