Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
db86114d7d feat: read category from blueprint subgraph definition
Read category from definitions.subgraphs[0].category in blueprint
JSON files as a fallback default. Overrides from info.category or
explicit category params still take precedence.

Amp-Thread-ID: https://ampcode.com/threads/T-019c6f43-6212-7308-bea6-bfc35a486cbf
2026-02-20 21:54:40 -08:00
bymyself
15c98676e9 feat: filter global blueprints by requiresCustomNodes and includeOnDistributions
Filter global subgraph blueprints before loading to avoid unnecessary
data fetches. Blueprints with requiresCustomNodes are hidden on
non-cloud distributions. Blueprints with includeOnDistributions are
only shown on matching distributions.

Amp-Thread-ID: https://ampcode.com/threads/T-019c6f43-6212-7308-bea6-bfc35a486cbf
2026-02-18 17:29:35 -08:00
3 changed files with 211 additions and 9 deletions

View File

@@ -11,9 +11,10 @@ import type {
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { IFuseOptions } from 'fuse.js'
import {
type TemplateInfo,
type WorkflowTemplates
import type {
TemplateIncludeOnDistributionEnum,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import type {
ComfyApiWorkflow,
@@ -235,6 +236,8 @@ export type GlobalSubgraphData = {
node_pack: string
category?: string
search_aliases?: string[]
requiresCustomNodes?: string[]
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
}
data: string | Promise<string>
}

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -16,6 +17,12 @@ import {
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { createTestingPinia } from '@pinia/testing'
const mockDistributionTypes = vi.hoisted(() => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
@@ -85,6 +92,8 @@ describe('useSubgraphStore', () => {
}
beforeEach(() => {
mockDistributionTypes.isCloud = false
mockDistributionTypes.isDesktop = false
setActivePinia(createTestingPinia({ stubActions: false }))
store = useSubgraphStore()
vi.clearAllMocks()
@@ -305,4 +314,174 @@ describe('useSubgraphStore', () => {
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
})
})
describe('subgraph definition category', () => {
it('should use category from subgraph definition as default', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch(
{},
{
categorized: {
name: 'Categorized Blueprint',
info: { node_pack: 'test_pack' },
data: JSON.stringify(mockGraphWithCategory)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.categorized'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/Image Processing')
})
it('should use User override for user blueprints even with definition category', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch({ 'user-bp.json': mockGraphWithCategory })
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.user-bp'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/User')
})
it('should fallback to bare Subgraph Blueprints when no category anywhere', async () => {
await mockFetch(
{},
{
no_cat_global: {
name: 'No Category Global',
info: { node_pack: 'test_pack' },
data: JSON.stringify(mockGraph)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.no_cat_global'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints')
})
it('should let overrides take precedence over definition category', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch(
{},
{
bp_override: {
name: 'Override Blueprint',
info: {
node_pack: 'test_pack',
category: 'Custom Category'
},
data: JSON.stringify(mockGraphWithCategory)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_override'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/Custom Category')
})
})
describe('global blueprint filtering', () => {
function globalBlueprint(
overrides: Partial<GlobalSubgraphData['info']> = {}
): GlobalSubgraphData {
return {
name: 'Filtered Blueprint',
info: { node_pack: 'test_pack', ...overrides },
data: JSON.stringify(mockGraph)
}
}
it('should exclude blueprints with requiresCustomNodes on non-cloud', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({ requiresCustomNodes: ['custom-node-pack'] })
}
)
expect(store.isGlobalBlueprint('bp')).toBe(false)
})
it('should include blueprints with requiresCustomNodes on cloud', async () => {
mockDistributionTypes.isCloud = true
await mockFetch(
{},
{
bp: globalBlueprint({ requiresCustomNodes: ['custom-node-pack'] })
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints with empty requiresCustomNodes everywhere', async () => {
await mockFetch({}, { bp: globalBlueprint({ requiresCustomNodes: [] }) })
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should exclude blueprints whose includeOnDistributions does not match', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(false)
})
it('should include blueprints whose includeOnDistributions matches current distribution', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Local]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints on desktop when includeOnDistributions has desktop', async () => {
mockDistributionTypes.isDesktop = true
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints with no filtering fields', async () => {
await mockFetch({}, { bp: globalBlueprint() })
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
})
})

View File

@@ -20,6 +20,8 @@ import type {
ComfyNodeDef as ComfyNodeDefV1,
InputSpec
} from '@/schemas/nodeDefSchema'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import type { GlobalSubgraphData } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
@@ -208,22 +210,35 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
: undefined
registerNodeDef(
loaded,
{
python_module: v.info.node_pack,
display_name: v.name,
category,
...(category && { category }),
search_aliases: v.info.search_aliases
},
k
)
}
const subgraphs = await api.getGlobalSubgraphs()
await Promise.allSettled(
Object.entries(subgraphs).map(loadGlobalBlueprint)
)
const currentDistribution: TemplateIncludeOnDistributionEnum = isCloud
? TemplateIncludeOnDistributionEnum.Cloud
: isDesktop
? TemplateIncludeOnDistributionEnum.Desktop
: TemplateIncludeOnDistributionEnum.Local
const filteredEntries = Object.entries(subgraphs).filter(([, v]) => {
if (!isCloud && (v.info.requiresCustomNodes?.length ?? 0) > 0)
return false
if (
(v.info.includeOnDistributions?.length ?? 0) > 0 &&
!v.info.includeOnDistributions!.includes(currentDistribution)
)
return false
return true
})
await Promise.allSettled(filteredEntries.map(loadGlobalBlueprint))
}
const userSubs = (
@@ -265,6 +280,11 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const description =
workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint'
const search_aliases = workflowExtra?.BlueprintSearchAliases
const subgraphDefCategory =
workflow.initialState.definitions?.subgraphs?.[0]?.category
const category = subgraphDefCategory
? `Subgraph Blueprints/${subgraphDefCategory}`
: 'Subgraph Blueprints'
const nodedefv1: ComfyNodeDefV1 = {
input: { required: inputs },
output: subgraphNode.outputs.map((o) => `${o.type}`),
@@ -272,7 +292,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
name: typePrefix + name,
display_name: name,
description,
category: 'Subgraph Blueprints',
category,
output_node: false,
python_module: 'blueprint',
search_aliases,