From 80fe51bb8cef7a98fe9dba291dd641fc497978e5 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:25:47 +0900 Subject: [PATCH] feat: show missing node packs in Errors Tab with install support (#9213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Surfaces missing node pack information in the Errors Tab, grouped by registry pack, with one-click install support via ComfyUI Manager. ## Changes - **What**: Errors Tab now groups missing nodes by their registry pack and shows a `MissingPackGroupRow` with pack name, node/pack counts, and an Install button that triggers Manager installation. A `MissingNodeCard` shows individual unresolvable nodes that have no associated pack. `useErrorGroups` was extended to resolve missing node types to their registry packs using the `/api/workflow/missing_nodes` endpoint. `executionErrorStore` was refactored to track missing node types separately from execution errors and expose them reactively. - **Breaking**: None ## Review Focus - `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches pack metadata and maps node types to pack IDs; edge cases around partial resolution (some nodes have a pack, some don't) produce both `MissingPackGroupRow` and `MissingNodeCard` entries - `executionErrorStore.ts` — the store now separates `missingNodeTypes` state from `errors`; the deferred-warnings path in `app.ts` now calls `setMissingNodeTypes` so the Errors Tab is populated even when a workflow loads without executing ## Screenshots (if applicable) https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8) by [Unito](https://www.unito.io) --- .../rightSidePanel/RightSidePanel.vue | 17 +- .../rightSidePanel/errors/ErrorNodeCard.vue | 38 +-- .../rightSidePanel/errors/MissingNodeCard.vue | 79 ++++++ .../errors/MissingPackGroupRow.vue | 250 ++++++++++++++++++ .../rightSidePanel/errors/TabErrors.test.ts | 3 +- .../rightSidePanel/errors/TabErrors.vue | 82 +++++- src/components/rightSidePanel/errors/types.ts | 13 +- .../rightSidePanel/errors/useErrorGroups.ts | 207 +++++++++++++-- .../layout/PropertiesAccordionItem.vue | 5 +- .../parameters/SectionWidgets.vue | 14 +- src/locales/en/main.json | 22 +- .../workflow/core/services/workflowService.ts | 15 +- .../validation/schemas/workflowSchema.ts | 1 - .../vueNodes/components/LGraphNode.vue | 3 +- src/scripts/app.ts | 95 +++++-- src/stores/executionErrorStore.ts | 242 ++++++++++++----- src/stores/executionStore.test.ts | 97 +++++++ src/types/comfy.ts | 2 + src/types/nodeIdentification.ts | 2 - .../utils/missingNodeErrorUtil.test.ts | 3 + .../manager/utils/missingNodeErrorUtil.ts | 36 ++- 21 files changed, 1075 insertions(+), 151 deletions(-) create mode 100644 src/components/rightSidePanel/errors/MissingNodeCard.vue create mode 100644 src/components/rightSidePanel/errors/MissingPackGroupRow.vue diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue index d55af6b2e1..cb46923e0d 100644 --- a/src/components/rightSidePanel/RightSidePanel.vue +++ b/src/components/rightSidePanel/RightSidePanel.vue @@ -40,7 +40,8 @@ const rightSidePanelStore = useRightSidePanelStore() const settingStore = useSettingStore() const { t } = useI18n() -const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore) +const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } = + storeToRefs(executionErrorStore) const { findParentGroup } = useGraphHierarchy() @@ -109,9 +110,21 @@ const hasContainerInternalError = computed(() => { }) }) +const hasMissingNodeSelected = computed( + () => + hasSelection.value && + selectedNodes.value.some((node) => + activeMissingNodeGraphIds.value.has(String(node.id)) + ) +) + const hasRelevantErrors = computed(() => { if (!hasSelection.value) return hasAnyError.value - return hasDirectNodeError.value || hasContainerInternalError.value + return ( + hasDirectNodeError.value || + hasContainerInternalError.value || + hasMissingNodeSelected.value + ) }) const tabs = computed(() => { diff --git a/src/components/rightSidePanel/errors/ErrorNodeCard.vue b/src/components/rightSidePanel/errors/ErrorNodeCard.vue index 6e62589d59..919996412e 100644 --- a/src/components/rightSidePanel/errors/ErrorNodeCard.vue +++ b/src/components/rightSidePanel/errors/ErrorNodeCard.vue @@ -17,24 +17,26 @@ > {{ card.nodeTitle }} - - +
+ + +
diff --git a/src/components/rightSidePanel/errors/MissingNodeCard.vue b/src/components/rightSidePanel/errors/MissingNodeCard.vue new file mode 100644 index 0000000000..a5e289fa6e --- /dev/null +++ b/src/components/rightSidePanel/errors/MissingNodeCard.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue new file mode 100644 index 0000000000..03bfa3468e --- /dev/null +++ b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue @@ -0,0 +1,250 @@ + + + diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts index f4e6087621..6c5222a492 100644 --- a/src/components/rightSidePanel/errors/TabErrors.test.ts +++ b/src/components/rightSidePanel/errors/TabErrors.test.ts @@ -18,7 +18,8 @@ vi.mock('@/scripts/app', () => ({ vi.mock('@/utils/graphTraversalUtil', () => ({ getNodeByExecutionId: vi.fn(), getRootParentNode: vi.fn(() => null), - forEachNode: vi.fn() + forEachNode: vi.fn(), + mapAllNodes: vi.fn(() => []) })) vi.mock('@/composables/useCopyToClipboard', () => ({ diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue index ea7a4ef378..c743eafca2 100644 --- a/src/components/rightSidePanel/errors/TabErrors.vue +++ b/src/components/rightSidePanel/errors/TabErrors.vue @@ -27,6 +27,7 @@ :key="group.title" :collapse="collapseState[group.title] ?? false" class="border-b border-interface-stroke" + :size="group.type === 'missing_node' ? 'lg' : 'default'" @update:collapse="collapseState[group.title] = $event" > - -
+ + + + +
missingNodePacks.value) const searchQuery = ref('') @@ -136,7 +181,9 @@ const { filteredGroups, collapseState, isSingleNodeSelected, - errorNodeCache + errorNodeCache, + missingNodeCache, + missingPackGroups } = useErrorGroups(searchQuery, t) /** @@ -151,11 +198,13 @@ watch( if (!graphNodeId) return const prefix = `${graphNodeId}:` for (const group of allErrorGroups.value) { - const hasMatch = group.cards.some( - (card) => - card.graphNodeId === graphNodeId || - (card.nodeId?.startsWith(prefix) ?? false) - ) + const hasMatch = + group.type === 'execution' && + group.cards.some( + (card) => + card.graphNodeId === graphNodeId || + (card.nodeId?.startsWith(prefix) ?? false) + ) collapseState[group.title] = !hasMatch } rightSidePanelStore.focusedErrorNodeId = null @@ -167,6 +216,19 @@ function handleLocateNode(nodeId: string) { focusNode(nodeId, errorNodeCache.value) } +function handleLocateMissingNode(nodeId: string) { + focusNode(nodeId, missingNodeCache.value) +} + +function handleOpenManagerInfo(packId: string) { + const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId) + if (isKnownToRegistry) { + openManager({ initialTab: ManagerTab.Missing, initialPackId: packId }) + } else { + openManager({ initialTab: ManagerTab.All, initialPackId: packId }) + } +} + function handleEnterSubgraph(nodeId: string) { enterSubgraph(nodeId, errorNodeCache.value) } diff --git a/src/components/rightSidePanel/errors/types.ts b/src/components/rightSidePanel/errors/types.ts index b5729fd032..718d0e9966 100644 --- a/src/components/rightSidePanel/errors/types.ts +++ b/src/components/rightSidePanel/errors/types.ts @@ -14,8 +14,11 @@ export interface ErrorCardData { errors: ErrorItem[] } -export interface ErrorGroup { - title: string - cards: ErrorCardData[] - priority: number -} +export type ErrorGroup = + | { + type: 'execution' + title: string + cards: ErrorCardData[] + priority: number + } + | { type: 'missing_node'; title: string; priority: number } diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts index ace8299d4f..610a56e720 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.ts @@ -1,11 +1,11 @@ -import { computed, reactive } from 'vue' +import { computed, reactive, ref, watch } from 'vue' import type { Ref } from 'vue' import Fuse from 'fuse.js' import type { IFuseOptions } from 'fuse.js' import { useExecutionErrorStore } from '@/stores/executionErrorStore' +import { useComfyRegistryStore } from '@/stores/comfyRegistryStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' - import { app } from '@/scripts/app' import { isCloud } from '@/platform/distribution/types' import { SubgraphNode } from '@/lib/litegraph/src/litegraph' @@ -20,6 +20,7 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil' import { isLGraphNode } from '@/utils/litegraphUtil' import { isGroupNode } from '@/utils/executableGroupNodeDto' import { st } from '@/i18n' +import type { MissingNodeType } from '@/types/comfy' import type { ErrorCardData, ErrorGroup, ErrorItem } from './types' import type { NodeExecutionId } from '@/types/nodeIdentification' import { isNodeExecutionId } from '@/types/nodeIdentification' @@ -32,7 +33,17 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([ 'server_error' ]) +/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */ +const RESOLVING = '__RESOLVING__' + +export interface MissingPackGroup { + packId: string | null + nodeTypes: MissingNodeType[] + isResolving: boolean +} + interface GroupEntry { + type: 'execution' priority: number cards: Map } @@ -76,7 +87,7 @@ function getOrCreateGroup( ): Map { let entry = groupsMap.get(title) if (!entry) { - entry = { priority, cards: new Map() } + entry = { type: 'execution', priority, cards: new Map() } groupsMap.set(title, entry) } return entry.cards @@ -137,6 +148,7 @@ function addCardErrorToGroup( function toSortedGroups(groupsMap: Map): ErrorGroup[] { return Array.from(groupsMap.entries()) .map(([title, groupData]) => ({ + type: 'execution' as const, title, cards: Array.from(groupData.cards.values()), priority: groupData.priority @@ -153,6 +165,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) { const searchableList: ErrorSearchItem[] = [] for (let gi = 0; gi < groups.length; gi++) { const group = groups[gi] + if (group.type !== 'execution') continue for (let ci = 0; ci < group.cards.length; ci++) { const card = group.cards[ci] searchableList.push({ @@ -160,8 +173,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) { cardIndex: ci, searchableNodeId: card.nodeId ?? '', searchableNodeTitle: card.nodeTitle ?? '', - searchableMessage: card.errors.map((e) => e.message).join(' '), - searchableDetails: card.errors.map((e) => e.details ?? '').join(' ') + searchableMessage: card.errors + .map((e: ErrorItem) => e.message) + .join(' '), + searchableDetails: card.errors + .map((e: ErrorItem) => e.details ?? '') + .join(' ') }) } } @@ -184,11 +201,16 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) { ) return groups - .map((group, gi) => ({ - ...group, - cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`)) - })) - .filter((group) => group.cards.length > 0) + .map((group, gi) => { + if (group.type !== 'execution') return group + return { + ...group, + cards: group.cards.filter((_: ErrorCardData, ci: number) => + matchedCardKeys.has(`${gi}:${ci}`) + ) + } + }) + .filter((group) => group.type !== 'execution' || group.cards.length > 0) } export function useErrorGroups( @@ -197,6 +219,7 @@ export function useErrorGroups( ) { const executionErrorStore = useExecutionErrorStore() const canvasStore = useCanvasStore() + const { inferPackFromNodeName } = useComfyRegistryStore() const collapseState = reactive>({}) const selectedNodeInfo = computed(() => { @@ -237,6 +260,19 @@ export function useErrorGroups( return map }) + const missingNodeCache = computed(() => { + const map = new Map() + const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? [] + for (const nodeType of nodeTypes) { + if (typeof nodeType === 'string') continue + if (nodeType.nodeId == null) continue + const nodeId = String(nodeType.nodeId) + const node = getNodeByExecutionId(app.rootGraph, nodeId) + if (node) map.set(nodeId, node) + } + return map + }) + function isErrorInSelection(executionNodeId: string): boolean { const nodeIds = selectedNodeInfo.value.nodeIds if (!nodeIds) return true @@ -343,6 +379,136 @@ export function useErrorGroups( ) } + // Async pack-ID resolution for missing node types that lack a cnrId + const asyncResolvedIds = ref>(new Map()) + + const pendingTypes = computed(() => + (executionErrorStore.missingNodesError?.nodeTypes ?? []).filter( + (n): n is Exclude => + typeof n !== 'string' && !n.cnrId + ) + ) + + watch( + pendingTypes, + async (pending, _, onCleanup) => { + const toResolve = pending.filter( + (n) => asyncResolvedIds.value.get(n.type) === undefined + ) + if (!toResolve.length) return + + const resolvingTypes = toResolve.map((n) => n.type) + let cancelled = false + onCleanup(() => { + cancelled = true + const next = new Map(asyncResolvedIds.value) + for (const type of resolvingTypes) { + if (next.get(type) === RESOLVING) next.delete(type) + } + asyncResolvedIds.value = next + }) + + const updated = new Map(asyncResolvedIds.value) + for (const type of resolvingTypes) updated.set(type, RESOLVING) + asyncResolvedIds.value = updated + + const results = await Promise.allSettled( + toResolve.map(async (n) => ({ + type: n.type, + packId: (await inferPackFromNodeName.call(n.type))?.id ?? null + })) + ) + if (cancelled) return + + const final = new Map(asyncResolvedIds.value) + for (const r of results) { + if (r.status === 'fulfilled') { + final.set(r.value.type, r.value.packId) + } + } + // Clear any remaining RESOLVING markers for failed lookups + for (const type of resolvingTypes) { + if (final.get(type) === RESOLVING) final.set(type, null) + } + asyncResolvedIds.value = final + }, + { immediate: true } + ) + + const missingPackGroups = computed(() => { + const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? [] + const map = new Map< + string | null, + { nodeTypes: MissingNodeType[]; isResolving: boolean } + >() + const resolvingKeys = new Set() + + for (const nodeType of nodeTypes) { + let packId: string | null + + if (typeof nodeType === 'string') { + packId = null + } else if (nodeType.cnrId) { + packId = nodeType.cnrId + } else { + const resolved = asyncResolvedIds.value.get(nodeType.type) + if (resolved === undefined || resolved === RESOLVING) { + packId = null + resolvingKeys.add(null) + } else { + packId = resolved + } + } + + const existing = map.get(packId) + if (existing) { + existing.nodeTypes.push(nodeType) + } else { + map.set(packId, { nodeTypes: [nodeType], isResolving: false }) + } + } + + for (const key of resolvingKeys) { + const group = map.get(key) + if (group) group.isResolving = true + } + + return Array.from(map.entries()) + .sort(([packIdA], [packIdB]) => { + // null (Unknown Pack) always goes last + if (packIdA === null) return 1 + if (packIdB === null) return -1 + return packIdA.localeCompare(packIdB) + }) + .map(([packId, { nodeTypes, isResolving }]) => ({ + packId, + nodeTypes: [...nodeTypes].sort((a, b) => { + const typeA = typeof a === 'string' ? a : a.type + const typeB = typeof b === 'string' ? b : b.type + const typeCmp = typeA.localeCompare(typeB) + if (typeCmp !== 0) return typeCmp + const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '') + const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '') + return idA.localeCompare(idB, undefined, { numeric: true }) + }), + isResolving + })) + }) + + /** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */ + function buildMissingNodeGroups(): ErrorGroup[] { + const error = executionErrorStore.missingNodesError + if (!error) return [] + + return [ + { + type: 'missing_node' as const, + title: error.message, + priority: 0 + } + ] + } + const allErrorGroups = computed(() => { const groupsMap = new Map() @@ -350,7 +516,7 @@ export function useErrorGroups( processNodeErrors(groupsMap) processExecutionError(groupsMap) - return toSortedGroups(groupsMap) + return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)] }) const tabErrorGroups = computed(() => { @@ -360,9 +526,11 @@ export function useErrorGroups( processNodeErrors(groupsMap, true) processExecutionError(groupsMap, true) - return isSingleNodeSelected.value + const executionGroups = isSingleNodeSelected.value ? toSortedGroups(regroupByErrorMessage(groupsMap)) : toSortedGroups(groupsMap) + + return [...buildMissingNodeGroups(), ...executionGroups] }) const filteredGroups = computed(() => { @@ -373,10 +541,15 @@ export function useErrorGroups( const groupedErrorMessages = computed(() => { const messages = new Set() for (const group of allErrorGroups.value) { - for (const card of group.cards) { - for (const err of card.errors) { - messages.add(err.message) + if (group.type === 'execution') { + for (const card of group.cards) { + for (const err of card.errors) { + messages.add(err.message) + } } + } else { + // Groups without cards (e.g. missing_node) surface their title as the message. + messages.add(group.title) } } return Array.from(messages) @@ -389,6 +562,8 @@ export function useErrorGroups( collapseState, isSingleNodeSelected, errorNodeCache, - groupedErrorMessages + missingNodeCache, + groupedErrorMessages, + missingPackGroups } } diff --git a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue index 0b1b88820c..27ba0541c3 100644 --- a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue +++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue @@ -10,12 +10,14 @@ const { label, enableEmptyState, tooltip, + size = 'default', class: className } = defineProps<{ disabled?: boolean label?: string enableEmptyState?: boolean tooltip?: string + size?: 'default' | 'lg' class?: string }>() @@ -39,7 +41,8 @@ const tooltipConfig = computed(() => { type="button" :class=" cn( - 'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3', + 'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3', + size === 'lg' ? 'min-h-16' : 'min-h-12', !disabled && 'cursor-pointer' ) " diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue index 3019838d55..e88b2f5b7c 100644 --- a/src/components/rightSidePanel/parameters/SectionWidgets.vue +++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue @@ -131,6 +131,12 @@ const nodeHasError = computed(() => { return hasDirectError.value || hasContainerInternalError.value }) +const showSeeError = computed( + () => + nodeHasError.value && + useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') +) + const parentGroup = computed(() => { if (!targetNode.value || !getNodeParentGroup) return null return getNodeParentGroup(targetNode.value) @@ -194,6 +200,7 @@ defineExpose({ :enable-empty-state :disabled="isEmpty" :tooltip + :size="showSeeError ? 'lg' : 'default'" >