Files
ComfyUI_frontend/src/services/nodeOrganizationService.ts
Yourz 001916edf6 refactor: clean up essentials node organization logic (#10433)
## Summary

Refactor essentials tab node organization to eliminate duplicated logic
and restrict essentials to core nodes only.

## Changes

- **What**: 
- Extract `resolveEssentialsCategory` to centralize category resolution
(was duplicated between filter and pathExtractor).
- Add `isCoreNode` guard so third-party nodes never appear in
essentials.
- Replace `indexOf`-based sorting with precomputed rank maps
(`ESSENTIALS_CATEGORY_RANK`, `ESSENTIALS_NODE_RANK`).

<img width="589" height="769" alt="image"
src="https://github.com/user-attachments/assets/66f41f35-aef5-4e12-97d5-0f33baf0ac45"
/>


## Review Focus

- The `isCoreNode` guard in `resolveEssentialsCategory` — ensures only
core nodes can appear in essentials even if a custom node sets
`essentials_category`.
- Rank map precomputation vs previous `indexOf` — functionally
equivalent but O(1) lookup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10433-refactor-clean-up-essentials-node-organization-logic-32d6d73d36508193a4d1f7f9c18fcef7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-24 16:22:09 +08:00

414 lines
12 KiB
TypeScript

import { resolveBlueprintEssentialsCategory } from '@/constants/essentialsDisplayNames'
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_RANK,
ESSENTIALS_NODE_RANK
} from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
NodeSection,
NodeSortStrategy,
TabId
} from '@/types/nodeOrganizationTypes'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
const UNKNOWN_RANK = Number.MAX_SAFE_INTEGER
function resolveEssentialsCategory(
nodeDef: ComfyNodeDefImpl
): EssentialsCategory | undefined {
if (!nodeDef.isCoreNode) return undefined
if (nodeDef.essentials_category) {
return (
ESSENTIALS_CATEGORY_CANONICAL.get(
nodeDef.essentials_category.toLowerCase()
) ?? (nodeDef.essentials_category as EssentialsCategory)
)
}
return resolveBlueprintEssentialsCategory(nodeDef.name)
}
function sortByKnownOrder<T>(
items: T[],
getKey: (item: T) => string | undefined,
rankMap: ReadonlyMap<string, number>
): void {
items.sort(
(a, b) =>
(rankMap.get(getKey(a) ?? '') ?? UNKNOWN_RANK) -
(rankMap.get(getKey(b) ?? '') ?? UNKNOWN_RANK)
)
}
function categoryPathExtractor(nodeDef: ComfyNodeDefImpl): string[] {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
function isBlueprint(node: ComfyNodeDefImpl): boolean {
return (
node.nodeSource.type === NodeSourceType.Blueprint ||
!!node.python_module?.startsWith('blueprint')
)
}
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export const DEFAULT_TAB_ID = 'all' as const
class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
{
id: 'category',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
icon: 'pi pi-folder',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
getNodePath: categoryPathExtractor
},
{
id: 'module',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.module',
icon: 'pi pi-box',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.moduleDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const pythonModule = nodeDef.python_module || ''
if (!pythonModule) {
return ['unknown_module', nodeDef.name]
}
// Split the module path into components
const parts = pythonModule.split('.')
// Remove common prefixes and organize
if (parts[0] === 'nodes') {
// Core nodes - just use 'core'
return ['core', nodeDef.name]
} else if (parts[0] === 'custom_nodes') {
// Custom nodes - use the package name as the folder
if (parts.length > 1) {
// Return the custom node package name
return [parts[1], nodeDef.name]
}
return ['custom_nodes', nodeDef.name]
}
// For other modules, use the full path structure plus node name
return [...parts, nodeDef.name]
}
},
{
id: 'source',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.source',
icon: 'pi pi-server',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.sourceDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
if (nodeDef.api_node) {
return ['API nodes', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.Core) {
return ['Core', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.CustomNodes) {
return ['Custom nodes', nodeDef.name]
} else {
return ['Unknown', nodeDef.name]
}
}
}
]
private readonly sortingStrategies: NodeSortStrategy[] = [
{
id: 'original',
label: 'sideToolbar.nodeLibraryTab.sortBy.original',
icon: 'pi pi-sort-alt',
description: 'sideToolbar.nodeLibraryTab.sortBy.originalDesc',
compare: () => 0
},
{
id: 'alphabetical',
label: 'sideToolbar.nodeLibraryTab.sortBy.alphabetical',
icon: 'pi pi-sort-alpha-down',
description: 'sideToolbar.nodeLibraryTab.sortBy.alphabeticalDesc',
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) =>
(a.display_name ?? '').localeCompare(b.display_name ?? '')
}
]
getGroupingStrategies(): NodeGroupingStrategy[] {
return [...this.groupingStrategies]
}
getGroupingStrategy(id: string): NodeGroupingStrategy | undefined {
return this.groupingStrategies.find((strategy) => strategy.id === id)
}
getSortingStrategies(): NodeSortStrategy[] {
return [...this.sortingStrategies]
}
getSortingStrategy(id: string): NodeSortStrategy | undefined {
return this.sortingStrategies.find((strategy) => strategy.id === id)
}
organizeNodesByTab(
nodes: ComfyNodeDefImpl[],
tabId: TabId = DEFAULT_TAB_ID
): NodeSection[] {
switch (tabId) {
case 'essentials':
return this.organizeEssentials(nodes)
case 'blueprints':
return this.organizeBlueprints(nodes)
case 'all':
default:
return this.organizeAll(nodes)
}
}
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const categoryByNode = new Map<ComfyNodeDefImpl, EssentialsCategory>()
const essentialNodes = nodes.filter((node) => {
const category = resolveEssentialsCategory(node)
if (!category) return false
categoryByNode.set(node, category)
return true
})
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (node) => [categoryByNode.get(node)!, node.name]
})
this.sortEssentialsTree(tree)
return [{ tree }]
}
private sortEssentialsTree(tree: TreeNode): void {
if (!tree.children) return
sortByKnownOrder(
tree.children,
(node) => node.label,
ESSENTIALS_CATEGORY_RANK
)
for (const folder of tree.children) {
if (!folder.children) continue
const rankMap = ESSENTIALS_NODE_RANK[folder.label as EssentialsCategory]
if (!rankMap) continue
sortByKnownOrder(
folder.children,
(node) => node.data?.name ?? node.label,
rankMap
)
}
}
private organizeBlueprints(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const { myBlueprints, comfyBlueprints } = this.partitionBlueprints(nodes)
return [
{
title: 'sideToolbar.nodeLibraryTab.sections.myBlueprints',
tree: unwrapTreeRoot(
buildNodeDefTree(myBlueprints, {
pathExtractor: categoryPathExtractor
})
)
},
{
title: 'sideToolbar.nodeLibraryTab.sections.comfyBlueprints',
tree: unwrapTreeRoot(
buildNodeDefTree(comfyBlueprints, {
pathExtractor: categoryPathExtractor
})
)
}
]
}
private organizeAll(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const {
myBlueprints,
comfyBlueprints,
partnerNodes,
comfyNodes,
extensions
} = this.classifyNodes(nodes)
const blueprintTree = this.buildBlueprintTree(
myBlueprints,
comfyBlueprints,
categoryPathExtractor
)
const sections: NodeSection[] = []
if (blueprintTree.children?.length) {
sections.push({ category: 'blueprints', tree: blueprintTree })
}
if (partnerNodes.length > 0) {
sections.push({
category: 'partnerNodes',
tree: unwrapTreeRoot(
buildNodeDefTree(partnerNodes, {
pathExtractor: categoryPathExtractor
})
)
})
}
if (comfyNodes.length > 0) {
sections.push({
category: 'comfyNodes',
tree: buildNodeDefTree(comfyNodes, {
pathExtractor: categoryPathExtractor
})
})
}
if (extensions.length > 0) {
sections.push({
category: 'extensions',
tree: buildNodeDefTree(extensions, {
pathExtractor: categoryPathExtractor
})
})
}
return sections
}
private partitionBlueprints(nodes: ComfyNodeDefImpl[]): {
myBlueprints: ComfyNodeDefImpl[]
comfyBlueprints: ComfyNodeDefImpl[]
} {
const myBlueprints: ComfyNodeDefImpl[] = []
const comfyBlueprints: ComfyNodeDefImpl[] = []
for (const node of nodes) {
if (!isBlueprint(node)) continue
if (node.isGlobal) comfyBlueprints.push(node)
else myBlueprints.push(node)
}
return { myBlueprints, comfyBlueprints }
}
private classifyNodes(nodes: ComfyNodeDefImpl[]): {
myBlueprints: ComfyNodeDefImpl[]
comfyBlueprints: ComfyNodeDefImpl[]
partnerNodes: ComfyNodeDefImpl[]
comfyNodes: ComfyNodeDefImpl[]
extensions: ComfyNodeDefImpl[]
} {
const myBlueprints: ComfyNodeDefImpl[] = []
const comfyBlueprints: ComfyNodeDefImpl[] = []
const partnerNodes: ComfyNodeDefImpl[] = []
const comfyNodes: ComfyNodeDefImpl[] = []
const extensions: ComfyNodeDefImpl[] = []
for (const node of nodes) {
if (isBlueprint(node)) {
if (node.isGlobal) comfyBlueprints.push(node)
else myBlueprints.push(node)
} else if (node.api_node || node.category?.startsWith('api node')) {
partnerNodes.push(node)
} else if (
node.nodeSource.type === NodeSourceType.Core ||
node.nodeSource.type === NodeSourceType.Essentials
) {
comfyNodes.push(node)
} else {
extensions.push(node)
}
}
return {
myBlueprints,
comfyBlueprints,
partnerNodes,
comfyNodes,
extensions
}
}
private buildBlueprintTree(
myBlueprints: ComfyNodeDefImpl[],
comfyBlueprints: ComfyNodeDefImpl[],
pathExtractor: (nodeDef: ComfyNodeDefImpl) => string[]
): TreeNode {
const children: TreeNode[] = []
if (myBlueprints.length > 0) {
const tree = unwrapTreeRoot(
buildNodeDefTree(myBlueprints, { pathExtractor })
)
children.push({
key: 'root/my-blueprints',
label: t('sideToolbar.nodeLibraryTab.sections.myBlueprints'),
children: tree.children
})
}
if (comfyBlueprints.length > 0) {
const tree = unwrapTreeRoot(
buildNodeDefTree(comfyBlueprints, { pathExtractor })
)
children.push({
key: 'root/comfy-blueprints',
label: t('sideToolbar.nodeLibraryTab.sections.comfyBlueprints'),
children: tree.children
})
}
return { key: 'root', label: '', children }
}
organizeNodes(
nodes: ComfyNodeDefImpl[],
options: NodeOrganizationOptions = {}
): TreeNode {
const { groupBy = DEFAULT_GROUPING_ID, sortBy = DEFAULT_SORTING_ID } =
options
const groupingStrategy = this.getGroupingStrategy(groupBy)
const sortingStrategy = this.getSortingStrategy(sortBy)
if (!groupingStrategy) {
throw new Error(`Unknown grouping strategy: ${groupBy}`)
}
if (!sortingStrategy) {
throw new Error(`Unknown sorting strategy: ${sortBy}`)
}
const sortedNodes =
sortingStrategy.id === 'original'
? nodes
: [...nodes].sort(sortingStrategy.compare)
const tree = buildNodeDefTree(sortedNodes, {
pathExtractor: groupingStrategy.getNodePath
})
if (sortBy === 'alphabetical') {
return sortedTree(tree, { groupLeaf: true })
}
return tree
}
getGroupingIcon(groupingId: string): string {
const strategy = this.getGroupingStrategy(groupingId)
return strategy?.icon || DEFAULT_ICON
}
getSortingIcon(sortingId: string): string {
const strategy = this.getSortingStrategy(sortingId)
return strategy?.icon || DEFAULT_ICON
}
}
export const nodeOrganizationService = new NodeOrganizationService()