feat: wire essentials_category for Essentials tab display (#9091)

## Summary

Wire `essentials_category` through from backend to the Essentials tab
UI. Creates a single source of truth for node categorization and
ordering.

### Changes

**New file — `src/constants/essentialsNodes.ts`:**
- Single source of truth: `ESSENTIALS_NODES` (ordered nodes per
category), `ESSENTIALS_CATEGORIES` (folder display order),
`ESSENTIALS_CATEGORY_MAP` (flat lookup), `TOOLKIT_NOVEL_NODE_NAMES`
(telemetry), `TOOLKIT_BLUEPRINT_MODULES`

**Refactored files:**
- `src/types/nodeSource.ts`: Removed inline `ESSENTIALS_CATEGORY_MOCK`,
imports `ESSENTIALS_CATEGORY_MAP` from centralized constants
- `src/services/nodeOrganizationService.ts`: Removed inline
`NODE_ORDER_BY_FOLDER`, imports `ESSENTIALS_NODES` and
`ESSENTIALS_CATEGORIES`
- `src/constants/toolkitNodes.ts`: Re-exports from `essentialsNodes.ts`
instead of maintaining a separate list

**Subgraph passthrough:**
- `src/stores/subgraphStore.ts`: Passes `essentials_category` from
`GlobalSubgraphData` and extracts it from `definitions.subgraphs[0]` as
fallback
- `src/platform/workflow/validation/schemas/workflowSchema.ts`: Added
`essentials_category` to `SubgraphDefinitionBase` and
`zSubgraphDefinition`

**Tests:**
- `src/constants/essentialsNodes.test.ts`: 6 tests validating no
duplicates, complete coverage, basics exclusion
- `src/stores/subgraphStore.test.ts`: 2 tests for essentials_category
passthrough

All 43 relevant tests pass. Typecheck, lint, format clean.

**Depends on:** Comfy-Org/ComfyUI#12573

Fixes COM-15221

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9091-feat-wire-essentials_category-for-Essentials-tab-display-30f6d73d3650814ab3d4c06b451c273b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2026-02-26 18:40:15 -08:00
committed by GitHub
parent 54b710b239
commit 0698ec23c0
11 changed files with 347 additions and 135 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_MAP,
ESSENTIALS_NODES,
TOOLKIT_BLUEPRINT_MODULES,
TOOLKIT_NOVEL_NODE_NAMES
} from './essentialsNodes'
describe('essentialsNodes', () => {
it('has no duplicate node names across categories', () => {
const seen = new Map<string, string>()
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
for (const node of nodes) {
expect(
seen.has(node),
`"${node}" duplicated in "${category}" and "${seen.get(node)}"`
).toBe(false)
seen.set(node, category)
}
}
})
it('ESSENTIALS_CATEGORY_MAP covers every node in ESSENTIALS_NODES', () => {
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
for (const node of nodes) {
expect(ESSENTIALS_CATEGORY_MAP[node]).toBe(category)
}
}
})
it('TOOLKIT_NOVEL_NODE_NAMES excludes basics nodes', () => {
for (const basicNode of ESSENTIALS_NODES.basics) {
expect(TOOLKIT_NOVEL_NODE_NAMES.has(basicNode)).toBe(false)
}
})
it('TOOLKIT_NOVEL_NODE_NAMES excludes SubgraphBlueprint-prefixed nodes', () => {
for (const name of TOOLKIT_NOVEL_NODE_NAMES) {
expect(name.startsWith('SubgraphBlueprint.')).toBe(false)
}
})
it('ESSENTIALS_NODES keys match ESSENTIALS_CATEGORIES', () => {
const nodeKeys = Object.keys(ESSENTIALS_NODES)
expect(nodeKeys).toEqual([...ESSENTIALS_CATEGORIES])
})
it('TOOLKIT_BLUEPRINT_MODULES contains comfy_essentials', () => {
expect(TOOLKIT_BLUEPRINT_MODULES.has('comfy_essentials')).toBe(true)
})
it('ESSENTIALS_CATEGORY_CANONICAL maps every category case-insensitively', () => {
for (const category of ESSENTIALS_CATEGORIES) {
expect(ESSENTIALS_CATEGORY_CANONICAL.get(category.toLowerCase())).toBe(
category
)
}
})
})

View File

@@ -0,0 +1,115 @@
/**
* Single source of truth for Essentials tab node categorization and ordering.
*
* Adding a new node to the Essentials tab? Add it here and nowhere else.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
export const ESSENTIALS_CATEGORIES = [
'basics',
'text generation',
'image generation',
'video generation',
'image tools',
'video tools',
'audio',
'3D'
] as const
export type EssentialsCategory = (typeof ESSENTIALS_CATEGORIES)[number]
/**
* Ordered list of nodes per category.
* Array order = display order in the Essentials tab.
* Presence in a category = the node's essentials_category mock fallback.
*/
export const ESSENTIALS_NODES: Record<EssentialsCategory, readonly string[]> = {
basics: [
'LoadImage',
'LoadVideo',
'Load3D',
'SaveImage',
'SaveVideo',
'SaveGLB',
'PrimitiveStringMultiline',
'PreviewImage'
],
'text generation': ['OpenAIChatNode'],
'image generation': [
'LoraLoader',
'LoraLoaderModelOnly',
'ConditioningCombine'
],
'video generation': [
'SubgraphBlueprint.pose_to_video_ltx_2_0',
'SubgraphBlueprint.canny_to_video_ltx_2_0',
'KlingLipSyncAudioToVideoNode',
'KlingOmniProEditVideoNode'
],
'image tools': [
'ImageBatch',
'ImageCrop',
'ImageCropV2',
'ImageScale',
'ImageScaleBy',
'ImageRotate',
'ImageBlur',
'ImageBlend',
'ImageInvert',
'ImageCompare',
'Canny',
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'LoadImageMask',
'GLSLShader'
],
'video tools': ['GetVideoComponents', 'CreateVideo', 'Video Slice'],
audio: [
'LoadAudio',
'SaveAudio',
'SaveAudioMP3',
'StabilityTextToAudio',
'EmptyLatentAudio'
],
'3D': ['TencentTextToModelNode', 'TencentImageToModelNode']
}
/**
* Flat map: node name → category (derived from ESSENTIALS_NODES).
* Used as mock/fallback when backend doesn't provide essentials_category.
*/
export const ESSENTIALS_CATEGORY_MAP: Record<string, EssentialsCategory> =
Object.fromEntries(
Object.entries(ESSENTIALS_NODES).flatMap(([category, nodes]) =>
nodes.map((node) => [node, category])
)
) as Record<string, EssentialsCategory>
/**
* Case-insensitive lookup: lowercase category → canonical category.
* Used to normalize backend categories (which may be title-cased) to the
* canonical form used in ESSENTIALS_CATEGORIES.
*/
export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
string,
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.
*/
export const TOOLKIT_NOVEL_NODE_NAMES: ReadonlySet<string> = new Set(
Object.entries(ESSENTIALS_NODES)
.filter(([cat]) => cat !== 'basics')
.flatMap(([, nodes]) => nodes)
.filter((n) => !n.startsWith('SubgraphBlueprint.'))
)
/**
* python_module values that identify toolkit blueprint nodes.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])

View File

@@ -1,41 +1,10 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Re-exported from essentialsNodes.ts — the single source of truth.
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',
// Video Tools
'Video Slice',
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode',
// Shader Nodes
'GLSLShader'
])
/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])
export {
TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES,
TOOLKIT_BLUEPRINT_MODULES
} from './essentialsNodes'

View File

@@ -405,6 +405,7 @@ interface SubgraphDefinitionBase<
/** Optional description shown as tooltip when hovering over the subgraph node. */
description?: string
category?: string
essentials_category?: string
/** Custom metadata for the subgraph (description, searchAliases, etc.) */
extra?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExtra> | null
@@ -443,6 +444,7 @@ const zSubgraphDefinition = zComfyWorkflow1
/** Optional description shown as tooltip when hovering over the subgraph node. */
description: z.string().optional(),
category: z.string().optional(),
essentials_category: z.string().optional(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,

View File

@@ -1,3 +1,8 @@
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
} from '@/constants/essentialsNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
@@ -14,46 +19,6 @@ import { upperCase } from 'es-toolkit/string'
const DEFAULT_ICON = 'pi pi-sort'
const NODE_ORDER_BY_FOLDER = {
basics: [
'LoadImage',
'LoadVideo',
'Load3D',
'SaveImage',
'SaveVideo',
'SaveGLB',
'PrimitiveStringMultiline',
'PreviewImage'
],
'image tools': [
'ImageBatch',
'ImageCrop',
'ImageCropV2',
'ImageScale',
'ImageScaleBy',
'ImageRotate',
'ImageBlur',
'ImageBlend',
'ImageInvert',
'Canny',
'RecraftRemoveBackgroundNode',
'LoadImageMask'
],
'video tools': ['GetVideoComponents', 'CreateVideo'],
'image generation': [
'LoraLoader',
'LoraLoaderModelOnly',
'ConditioningCombine'
],
audio: [
'LoadAudio',
'SaveAudio',
'SaveAudioMP3',
'StabilityTextToAudio',
'EmptyLatentAudio'
]
} as const satisfies Record<string, readonly string[]>
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export const DEFAULT_TAB_ID = 'all' as const
@@ -178,34 +143,25 @@ class NodeOrganizationService {
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: essentialsPathExtractor
})
const folderOrder = [
'basics',
'text generation',
'image generation',
'video generation',
'image tools',
'video tools',
'audio',
'3D'
]
if (tree.children) {
const len = folderOrder.length
const len = ESSENTIALS_CATEGORIES.length
const originalIndex = new Map(
tree.children.map((child, i) => [child, i])
)
tree.children.sort((a, b) => {
const ai = folderOrder.indexOf(a.label ?? '')
const bi = folderOrder.indexOf(b.label ?? '')
const ai = ESSENTIALS_CATEGORIES.indexOf(
a.label as EssentialsCategory
)
const bi = ESSENTIALS_CATEGORIES.indexOf(
b.label as EssentialsCategory
)
const orderA = ai === -1 ? len + originalIndex.get(a)! : ai
const orderB = bi === -1 ? len + originalIndex.get(b)! : bi
return orderA - orderB
})
for (const folder of tree.children) {
if (!folder.children) continue
const order =
NODE_ORDER_BY_FOLDER[
folder.label as keyof typeof NODE_ORDER_BY_FOLDER
]
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
if (!order) continue
const nodeOrder: readonly string[] = order
const orderLen = nodeOrder.length

View File

@@ -515,6 +515,104 @@ describe('useSubgraphStore', () => {
})
})
describe('essentials_category passthrough', () => {
it('should prefer GlobalSubgraphData essentials_category over definition fallback', async () => {
const graphWithEssentials = {
...mockGraph,
definitions: {
subgraphs: [
{
...mockGraph.definitions?.subgraphs?.[0],
essentials_category: 'Image Tools'
}
]
}
}
await mockFetch(
{},
{
bp_precedence: {
name: 'Precedence Blueprint',
info: { node_pack: 'test_pack' },
data: JSON.stringify(graphWithEssentials),
essentials_category: 'Video Generation'
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_precedence'
)
expect(nodeDef?.essentials_category).toBe('video generation')
})
it('should pass essentials_category from GlobalSubgraphData to node def', async () => {
await mockFetch(
{},
{
bp_essentials: {
name: 'Test Essentials Blueprint',
info: { node_pack: 'test_pack', category: 'Test Category' },
data: JSON.stringify(mockGraph),
essentials_category: 'Image Generation'
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_essentials'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.essentials_category).toBe('image generation')
})
it('should extract essentials_category from subgraph definition as fallback', async () => {
const graphWithEssentials = {
...mockGraph,
definitions: {
subgraphs: [
{
...mockGraph.definitions?.subgraphs?.[0],
essentials_category: 'Image Tools'
}
]
}
}
await mockFetch(
{},
{
bp_fallback: {
name: 'Fallback Blueprint',
info: { node_pack: 'test_pack' },
data: JSON.stringify(graphWithEssentials)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_fallback'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.essentials_category).toBe('image tools')
})
it('should normalize title-cased essentials_category to canonical form', async () => {
await mockFetch(
{},
{
bp_3d: {
name: 'Test 3D Blueprint',
info: { node_pack: 'test_pack', category: 'Test Category' },
data: JSON.stringify(mockGraph),
essentials_category: '3d'
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_3d'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.essentials_category).toBe('3D')
})
})
describe('global blueprint filtering', () => {
function globalBlueprint(
overrides: Partial<GlobalSubgraphData['info']> = {}

View File

@@ -222,6 +222,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
{
display_name: v.name,
...(category && { category }),
...(v.essentials_category && {
essentials_category: v.essentials_category
}),
search_aliases: v.info.search_aliases,
isGlobal: true
},
@@ -291,6 +294,8 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const search_aliases = workflowExtra?.BlueprintSearchAliases
const subgraphDefCategory =
workflow.initialState.definitions?.subgraphs?.[0]?.category
const subgraphDefEssentialsCategory =
workflow.initialState.definitions?.subgraphs?.[0]?.essentials_category
const category = subgraphDefCategory
? `Subgraph Blueprints/${subgraphDefCategory}`
: 'Subgraph Blueprints'
@@ -305,6 +310,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
output_node: false,
python_module: 'blueprint',
search_aliases,
essentials_category: subgraphDefEssentialsCategory,
...overrides
}
const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1)

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import {
NodeSourceType,
getEssentialsCategory,
getNodeSource
} from '@/types/nodeSource'
describe('getNodeSource', () => {
it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => {
@@ -105,6 +109,36 @@ describe('getNodeSource', () => {
const result = getNodeSource('nodes.some_module', undefined, 'LoadImage')
expect(result.type).toBe(NodeSourceType.Essentials)
})
it('should normalize title-cased backend categories to canonical form', () => {
const result = getNodeSource(
'nodes.some_module',
'Image Generation',
'SomeNode'
)
expect(result.type).toBe(NodeSourceType.Essentials)
expect(getEssentialsCategory('SomeNode', 'Image Generation')).toBe(
'image generation'
)
})
})
describe('getEssentialsCategory', () => {
it('should normalize title-cased essentials_category to canonical form', () => {
expect(getEssentialsCategory('SomeNode', 'Image Generation')).toBe(
'image generation'
)
expect(getEssentialsCategory('SomeNode', '3d')).toBe('3D')
expect(getEssentialsCategory('SomeNode', '3D')).toBe('3D')
})
it('should fall back to ESSENTIALS_CATEGORY_MAP when no category provided', () => {
expect(getEssentialsCategory('LoadImage')).toBe('basics')
})
it('should return undefined for unknown node without category', () => {
expect(getEssentialsCategory('UnknownNode')).toBeUndefined()
})
})
describe('blueprint nodes', () => {

View File

@@ -1,3 +1,8 @@
import {
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_MAP
} from '@/constants/essentialsNodes'
export enum NodeSourceType {
Core = 'core',
CustomNodes = 'custom_nodes',
@@ -26,44 +31,6 @@ const shortenNodeName = (name: string) => {
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
}
// TODO: Remove this mock mapping once object_info/global_subgraphs returns essentials_category
const ESSENTIALS_CATEGORY_MOCK: Record<string, string> = {
// basics
LoadImage: 'basics',
SaveImage: 'basics',
LoadVideo: 'basics',
SaveVideo: 'basics',
Load3D: 'basics',
SaveGLB: 'basics',
PrimitiveStringMultiline: 'basics',
// image tools
ImageBatch: 'image tools',
ImageCrop: 'image tools',
ImageScale: 'image tools',
ImageRotate: 'image tools',
ImageBlur: 'image tools',
ImageInvert: 'image tools',
Canny: 'image tools',
RecraftRemoveBackgroundNode: 'image tools',
// video tools
GetVideoComponents: 'video tools',
// image gen
LoraLoader: 'image generation',
// video gen
'SubgraphBlueprint.pose_to_video_ltx_2_0': 'video generation',
'SubgraphBlueprint.canny_to_video_ltx_2_0': 'video generation',
KlingLipSyncAudioToVideoNode: 'video generation',
// text gen
OpenAIChatNode: 'text generation',
// 3d
TencentTextToModelNode: '3D',
TencentImageToModelNode: '3D',
// audio
LoadAudio: 'audio',
SaveAudio: 'audio',
StabilityTextToAudio: 'audio'
}
/**
* Get the essentials category for a node, falling back to mock data if not provided.
*/
@@ -71,9 +38,12 @@ export function getEssentialsCategory(
name?: string,
essentials_category?: string
): string | undefined {
return (
essentials_category ?? (name ? ESSENTIALS_CATEGORY_MOCK[name] : undefined)
)
const normalizedCategory = essentials_category?.trim().toLowerCase()
const canonical = normalizedCategory
? (ESSENTIALS_CATEGORY_CANONICAL.get(normalizedCategory) ??
normalizedCategory)
: undefined
return canonical ?? (name ? ESSENTIALS_CATEGORY_MAP[name] : undefined)
}
export const getNodeSource = (