From c2defb49c5cbc6b542c5fabf2d387241a0462fd3 Mon Sep 17 00:00:00 2001 From: jaeone94 Date: Wed, 25 Feb 2026 18:07:00 +0900 Subject: [PATCH 01/12] refactor: improve missing node error handling and add roadmap documentation - App & WorkflowService: Document the temporary coexistence of the Missing Nodes Modal and Errors Tab, noting that the modal will be removed once Node Replacement is implemented. - Error Handling: Collect `cnr_id` and `execution_id` when processing missing nodes to provide sufficient context for the Errors Tab. - ExecutionErrorStore: Enforce strict `NodeExecutionId` typing in `applyNodeError`. - Clean up obsolete comments and reorganize imports across error stores and app script. --- .../workflow/core/services/workflowService.ts | 10 + .../validation/schemas/workflowSchema.ts | 1 - src/scripts/app.ts | 55 ++++- src/stores/executionErrorStore.ts | 233 +++++++++++++----- src/types/comfy.ts | 2 + src/types/nodeIdentification.ts | 2 - 6 files changed, 229 insertions(+), 74 deletions(-) diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts index 28255518176..53900f6b74a 100644 --- a/src/platform/workflow/core/services/workflowService.ts +++ b/src/platform/workflow/core/services/workflowService.ts @@ -21,6 +21,7 @@ import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog' import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog' import { useDialogService } from '@/services/dialogService' import { useDomWidgetStore } from '@/stores/domWidgetStore' +import { useExecutionErrorStore } from '@/stores/executionErrorStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { appendJsonExt } from '@/utils/formatUtil' @@ -33,6 +34,7 @@ export const useWorkflowService = () => { const missingNodesDialog = useMissingNodesDialog() const workflowThumbnail = useWorkflowThumbnail() const domWidgetStore = useDomWidgetStore() + const executionErrorStore = useExecutionErrorStore() const workflowDraftStore = useWorkflowDraftStore() async function getFilename(defaultName: string): Promise { @@ -472,7 +474,15 @@ export const useWorkflowService = () => { settingStore.get('Comfy.Workflow.ShowMissingNodesWarning') ) { missingNodesDialog.show({ missingNodeTypes }) + + // For now, we'll make them coexist. + // Once the Node Replacement feature is implemented in TabErrors + // we'll remove the modal display and direct users to the error tab. + if (settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')) { + executionErrorStore.showErrorOverlay() + } } + if ( missingModels && settingStore.get('Comfy.Workflow.ShowMissingModelsWarning') diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts index e697181ed10..921a23b47d5 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.ts @@ -547,7 +547,6 @@ export type ComfyApiWorkflow = z.infer * where that definition is instantiated in the workflow. * * "def-A" → ["5", "10"] for each container node instantiating that subgraph definition. - * @knipIgnoreUsedByStackedPR */ export function buildSubgraphExecutionPaths( rootNodes: ComfyNode[], diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 90a367ce4dd..963ae6d60f6 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -32,7 +32,8 @@ import { type ComfyWorkflowJSON, type ModelFile, type NodeId, - isSubgraphDefinition + isSubgraphDefinition, + buildSubgraphExecutionPaths } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ExecutionErrorWsMessage, @@ -1099,6 +1100,15 @@ export class ComfyApp { private showMissingNodesError(missingNodeTypes: MissingNodeType[]) { if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) { useMissingNodesDialog().show({ missingNodeTypes }) + + // For now, we'll make them coexist. + // Once the Node Replacement feature is implemented in TabErrors + // we'll remove the modal display and direct users to the error tab. + const executionErrorStore = useExecutionErrorStore() + executionErrorStore.setMissingNodeTypes(missingNodeTypes) + if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) { + executionErrorStore.showErrorOverlay() + } } } @@ -1181,12 +1191,13 @@ export class ComfyApp { const collectMissingNodesAndModels = ( nodes: ComfyWorkflowJSON['nodes'], - path: string = '' + pathPrefix: string = '', + displayName: string = '' ) => { if (!Array.isArray(nodes)) { console.warn( 'Workflow nodes data is missing or invalid, skipping node processing', - { nodes, path } + { nodes, pathPrefix } ) return } @@ -1195,9 +1206,26 @@ export class ComfyApp { if (!(n.type in LiteGraph.registered_node_types)) { const replacement = nodeReplacementStore.getReplacementFor(n.type) + // To access missing node information in the error tab + // we collect the cnr_id and execution_id here. + let cnrId: string | undefined + if (typeof n.properties?.cnr_id === 'string') { + cnrId = n.properties.cnr_id + } else if (typeof n.properties?.aux_id === 'string') { + cnrId = n.properties.aux_id + } + + const executionId = pathPrefix + ? `${pathPrefix}:${n.id}` + : String(n.id) + missingNodeTypes.push({ type: n.type, - ...(path && { hint: `in subgraph '${path}'` }), + nodeId: executionId, + cnrId, + ...(displayName && { + hint: t('g.inSubgraph', { name: displayName }) + }), isReplaceable: replacement !== null, replacement: replacement ?? undefined }) @@ -1216,14 +1244,25 @@ export class ComfyApp { // Process nodes at the top level collectMissingNodesAndModels(graphData.nodes) + // Build map: subgraph definition UUID → full execution path prefix. + // Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14"). + const subgraphContainerIdMap = buildSubgraphExecutionPaths( + graphData.nodes, + graphData.definitions?.subgraphs ?? [] + ) + // Process nodes in subgraphs if (graphData.definitions?.subgraphs) { for (const subgraph of graphData.definitions.subgraphs) { if (isSubgraphDefinition(subgraph)) { - collectMissingNodesAndModels( - subgraph.nodes, - subgraph.name || subgraph.id - ) + const paths = subgraphContainerIdMap.get(subgraph.id) ?? [] + for (const pathPrefix of paths) { + collectMissingNodesAndModels( + subgraph.nodes, + pathPrefix, + subgraph.name || subgraph.id + ) + } } } } diff --git a/src/stores/executionErrorStore.ts b/src/stores/executionErrorStore.ts index e93335bbf9a..d539c876492 100644 --- a/src/stores/executionErrorStore.ts +++ b/src/stores/executionErrorStore.ts @@ -1,6 +1,8 @@ import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' +import { st } from '@/i18n' +import { isCloud } from '@/platform/distribution/types' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { app } from '@/scripts/app' @@ -10,8 +12,13 @@ import type { PromptError } from '@/schemas/apiSchema' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + getAncestorExecutionIds, + getParentExecutionIds +} from '@/types/nodeIdentification' import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' +import type { MissingNodeType } from '@/types/comfy' import { executionIdToNodeLocatorId, forEachNode, @@ -20,12 +27,52 @@ import { } from '@/utils/graphTraversalUtil' /** - * Store dedicated to execution error state management. - * - * Extracted from executionStore to separate error-related concerns - * (state, computed properties, graph flag propagation, overlay UI) - * from execution flow management (progress, queuing, events). + * @knipIgnoreUsedByStackedPR */ +interface MissingNodesError { + message: string + nodeTypes: MissingNodeType[] +} + +function clearAllNodeErrorFlags(rootGraph: LGraph): void { + forEachNode(rootGraph, (node) => { + node.has_errors = false + if (node.inputs) { + for (const slot of node.inputs) { + slot.hasErrors = false + } + } + }) +} + +function markNodeSlotErrors(node: LGraphNode, nodeError: NodeError): void { + if (!node.inputs) return + for (const error of nodeError.errors) { + const slotName = error.extra_info?.input_name + if (!slotName) continue + const slot = node.inputs.find((s) => s.name === slotName) + if (slot) slot.hasErrors = true + } +} + +function applyNodeError( + rootGraph: LGraph, + executionId: NodeExecutionId, + nodeError: NodeError +): void { + const node = getNodeByExecutionId(rootGraph, executionId) + if (!node) return + + node.has_errors = true + markNodeSlotErrors(node, nodeError) + + for (const parentId of getParentExecutionIds(executionId)) { + const parentNode = getNodeByExecutionId(rootGraph, parentId) + if (parentNode) parentNode.has_errors = true + } +} + +/** Execution error state: node errors, runtime errors, prompt errors, and missing nodes. */ export const useExecutionErrorStore = defineStore('executionError', () => { const workflowStore = useWorkflowStore() const canvasStore = useCanvasStore() @@ -33,6 +80,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => { const lastNodeErrors = ref | null>(null) const lastExecutionError = ref(null) const lastPromptError = ref(null) + const missingNodesError = ref(null) const isErrorOverlayOpen = ref(false) @@ -49,6 +97,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => { lastExecutionError.value = null lastPromptError.value = null lastNodeErrors.value = null + missingNodesError.value = null isErrorOverlayOpen.value = false } @@ -57,6 +106,40 @@ export const useExecutionErrorStore = defineStore('executionError', () => { lastPromptError.value = null } + function setMissingNodeTypes(types: MissingNodeType[]) { + if (!types.length) { + missingNodesError.value = null + return + } + const seen = new Set() + const uniqueTypes = types.filter((node) => { + // For string entries (group nodes), deduplicate by the string itself. + // For object entries, prefer nodeId so multiple instances of the same + // type are kept as separate rows; fall back to type if nodeId is absent. + const isString = typeof node === 'string' + let key: string + if (isString) { + key = node + } else if (node.nodeId != null) { + key = String(node.nodeId) + } else { + key = node.type + } + if (seen.has(key)) return false + seen.add(key) + return true + }) + missingNodesError.value = { + message: isCloud + ? st( + 'rightSidePanel.missingNodePacks.unsupportedTitle', + 'Unsupported Node Packs' + ) + : st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'), + nodeTypes: uniqueTypes + } + } + const lastExecutionErrorNodeLocatorId = computed(() => { const err = lastExecutionError.value if (!err) return null @@ -81,9 +164,18 @@ export const useExecutionErrorStore = defineStore('executionError', () => { () => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0 ) - /** Whether any error (node validation, runtime execution, or prompt-level) is present */ + /** Whether any missing node types are present in the current workflow + * @knipIgnoreUsedByStackedPR + */ + const hasMissingNodes = computed(() => !!missingNodesError.value) + + /** Whether any error (node validation, runtime execution, prompt-level, or missing nodes) is present */ const hasAnyError = computed( - () => hasExecutionError.value || hasPromptError.value || hasNodeError.value + () => + hasExecutionError.value || + hasPromptError.value || + hasNodeError.value || + hasMissingNodes.value ) const allErrorExecutionIds = computed(() => { @@ -116,13 +208,19 @@ export const useExecutionErrorStore = defineStore('executionError', () => { /** Count of runtime execution errors (0 or 1) */ const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0)) + /** Count of missing node errors (0 or 1) */ + const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0)) + /** Total count of all individual errors */ const totalErrorCount = computed( () => - promptErrorCount.value + nodeErrorCount.value + executionErrorCount.value + promptErrorCount.value + + nodeErrorCount.value + + executionErrorCount.value + + missingNodeCount.value ) - /** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */ + /** Graph node IDs (as strings) that have errors in the current graph scope. */ const activeGraphErrorNodeIds = computed>(() => { const ids = new Set() if (!app.rootGraph) return ids @@ -150,6 +248,44 @@ export const useExecutionErrorStore = defineStore('executionError', () => { return ids }) + /** + * Set of all execution ID prefixes derived from missing node execution IDs, + * including the missing nodes themselves. + * + * Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" } + */ + const missingAncestorExecutionIds = computed>(() => { + const ids = new Set() + const error = missingNodesError.value + if (!error) return ids + + for (const nodeType of error.nodeTypes) { + if (typeof nodeType === 'string') continue + if (nodeType.nodeId == null) continue + for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) { + ids.add(id) + } + } + + return ids + }) + + const activeMissingNodeGraphIds = computed>(() => { + const ids = new Set() + if (!app.rootGraph) return ids + + const activeGraph = canvasStore.currentGraph ?? app.rootGraph + + for (const executionId of missingAncestorExecutionIds.value) { + const graphNode = getNodeByExecutionId(app.rootGraph, executionId) + if (graphNode?.graph === activeGraph) { + ids.add(String(graphNode.id)) + } + } + + return ids + }) + /** Map of node errors indexed by locator ID. */ const nodeErrorsByLocatorId = computed>( () => { @@ -196,15 +332,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => { */ const errorAncestorExecutionIds = computed>(() => { const ids = new Set() - for (const executionId of allErrorExecutionIds.value) { - const parts = executionId.split(':') - // Add every prefix including the full ID (error leaf node itself) - for (let i = 1; i <= parts.length; i++) { - ids.add(parts.slice(0, i).join(':')) + for (const id of getAncestorExecutionIds(executionId)) { + ids.add(id) } } - return ids }) @@ -216,59 +348,28 @@ export const useExecutionErrorStore = defineStore('executionError', () => { return errorAncestorExecutionIds.value.has(execId) } - /** - * Update node and slot error flags when validation errors change. - * Propagates errors up subgraph chains. + /** True if the node has a missing node inside it at any nesting depth. + * @knipIgnoreUsedByStackedPR */ + function isContainerWithMissingNode(node: LGraphNode): boolean { + if (!app.rootGraph) return false + const execId = getExecutionIdByNode(app.rootGraph, node) + if (!execId) return false + return missingAncestorExecutionIds.value.has(execId) + } + watch(lastNodeErrors, () => { - if (!app.rootGraph) return - - // Clear all error flags - forEachNode(app.rootGraph, (node) => { - node.has_errors = false - if (node.inputs) { - for (const slot of node.inputs) { - slot.hasErrors = false - } - } - }) + const rootGraph = app.rootGraph + if (!rootGraph) return + + clearAllNodeErrorFlags(rootGraph) if (!lastNodeErrors.value) return - // Set error flags on nodes and slots for (const [executionId, nodeError] of Object.entries( lastNodeErrors.value )) { - const node = getNodeByExecutionId(app.rootGraph, executionId) - if (!node) continue - - node.has_errors = true - - // Mark input slots with errors - if (node.inputs) { - for (const error of nodeError.errors) { - const slotName = error.extra_info?.input_name - if (!slotName) continue - - const slot = node.inputs.find((s) => s.name === slotName) - if (slot) { - slot.hasErrors = true - } - } - } - - // Propagate errors to parent subgraph nodes - const parts = executionId.split(':') - for (let i = parts.length - 1; i > 0; i--) { - const parentExecutionId = parts.slice(0, i).join(':') - const parentNode = getNodeByExecutionId( - app.rootGraph, - parentExecutionId - ) - if (parentNode) { - parentNode.has_errors = true - } - } + applyNodeError(rootGraph, executionId, nodeError) } }) @@ -277,6 +378,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => { lastNodeErrors, lastExecutionError, lastPromptError, + missingNodesError, // Clearing clearAllErrors, @@ -291,16 +393,21 @@ export const useExecutionErrorStore = defineStore('executionError', () => { hasExecutionError, hasPromptError, hasNodeError, + hasMissingNodes, hasAnyError, allErrorExecutionIds, totalErrorCount, lastExecutionErrorNodeId, activeGraphErrorNodeIds, + activeMissingNodeGraphIds, + + // Missing node actions + setMissingNodeTypes, // Lookup helpers getNodeErrors, slotHasError, - errorAncestorExecutionIds, - isContainerWithInternalError + isContainerWithInternalError, + isContainerWithMissingNode } }) diff --git a/src/types/comfy.ts b/src/types/comfy.ts index f4965407c20..7249f64718c 100644 --- a/src/types/comfy.ts +++ b/src/types/comfy.ts @@ -90,6 +90,8 @@ export type MissingNodeType = // Primarily used by group nodes. | { type: string + nodeId?: string | number + cnrId?: string hint?: string action?: { text: string diff --git a/src/types/nodeIdentification.ts b/src/types/nodeIdentification.ts index 6ee14c46951..fd184fdeadb 100644 --- a/src/types/nodeIdentification.ts +++ b/src/types/nodeIdentification.ts @@ -126,7 +126,6 @@ export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId { * Returns all ancestor execution IDs for a given execution ID, including itself. * * Example: "65:70:63" → ["65", "65:70", "65:70:63"] - * @knipIgnoreUsedByStackedPR */ export function getAncestorExecutionIds( executionId: string | NodeExecutionId @@ -141,7 +140,6 @@ export function getAncestorExecutionIds( * Returns all ancestor execution IDs for a given execution ID, excluding itself. * * Example: "65:70:63" → ["65", "65:70"] - * @knipIgnoreUsedByStackedPR */ export function getParentExecutionIds( executionId: string | NodeExecutionId From a81307842bbfa138c4f784d48b6d0333413ed565 Mon Sep 17 00:00:00 2001 From: jaeone94 Date: Wed, 25 Feb 2026 19:48:26 +0900 Subject: [PATCH 02/12] fix(i18n): add g.inSubgraph locale key --- src/locales/en/main.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 5c3f2412afc..6ce304f320e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -7,6 +7,7 @@ "empty": "Empty", "noWorkflowsFound": "No workflows found.", "comingSoon": "Coming Soon", + "inSubgraph": "in subgraph {name}", "download": "Download", "downloadImage": "Download image", "downloadVideo": "Download video", From e7f0704abef59c0388590f509d00397e0e7cefdd Mon Sep 17 00:00:00 2001 From: jaeone94 Date: Wed, 25 Feb 2026 22:59:54 +0900 Subject: [PATCH 03/12] fix: call setMissingNodeTypes in deferred-warnings path --- src/platform/workflow/core/services/workflowService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts index 53900f6b74a..d8213a2e775 100644 --- a/src/platform/workflow/core/services/workflowService.ts +++ b/src/platform/workflow/core/services/workflowService.ts @@ -478,6 +478,7 @@ export const useWorkflowService = () => { // For now, we'll make them coexist. // Once the Node Replacement feature is implemented in TabErrors // we'll remove the modal display and direct users to the error tab. + executionErrorStore.setMissingNodeTypes(missingNodeTypes) if (settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')) { executionErrorStore.showErrorOverlay() } From 0a04f3bbe149ca831ce1096637e1b614be88be6a Mon Sep 17 00:00:00 2001 From: jaeone94 Date: Wed, 25 Feb 2026 23:35:40 +0900 Subject: [PATCH 04/12] feat: show missing node packs in the Errors Tab with install support - Add MissingPackGroupRow component to display missing node packs as grouped, collapsible rows with per-pack install and manager-search buttons. - Implement buildMissingNodeGroups() in useErrorGroups.ts to aggregate missing node types by cnrId and resolve pack info asynchronously. - Add locate-on-canvas support for individual missing node instances. - Fix deferred-warnings path: call setMissingNodeTypes before showErrorOverlay so the Errors Tab has data to display. - Add required i18n keys for missing node pack UI strings. --- .../rightSidePanel/RightSidePanel.vue | 17 +- .../rightSidePanel/errors/ErrorNodeCard.vue | 38 +-- .../rightSidePanel/errors/MissingNodeCard.vue | 79 ++++++ .../errors/MissingPackGroupRow.vue | 240 ++++++++++++++++++ .../rightSidePanel/errors/TabErrors.test.ts | 3 +- .../rightSidePanel/errors/TabErrors.vue | 64 ++++- src/components/rightSidePanel/errors/types.ts | 3 + .../rightSidePanel/errors/useErrorGroups.ts | 172 ++++++++++++- .../layout/PropertiesAccordionItem.vue | 5 +- .../parameters/SectionWidgets.vue | 14 +- src/locales/en/main.json | 20 +- .../vueNodes/components/LGraphNode.vue | 3 +- 12 files changed, 616 insertions(+), 42 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 d55af6b2e18..cb46923e0d4 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 6e62589d59e..919996412e7 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 00000000000..a5e289fa6e6 --- /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 00000000000..c236c466305 --- /dev/null +++ b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue @@ -0,0 +1,240 @@ + + + diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts index f4e60876215..6c5222a4921 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 ea7a4ef3785..5ab472b80a7 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 +177,9 @@ const { filteredGroups, collapseState, isSingleNodeSelected, - errorNodeCache + errorNodeCache, + missingNodeCache, + missingPackGroups } = useErrorGroups(searchQuery, t) /** @@ -167,6 +210,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 b5729fd0323..6796971070d 100644 --- a/src/components/rightSidePanel/errors/types.ts +++ b/src/components/rightSidePanel/errors/types.ts @@ -14,7 +14,10 @@ export interface ErrorCardData { errors: ErrorItem[] } +export type ErrorGroupType = 'execution' | 'missing_node' | 'missing_model' + export interface ErrorGroup { + type: ErrorGroupType title: string cards: ErrorCardData[] priority: number diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts index ace8299d4fb..6392babf754 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,7 +20,13 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil' import { isLGraphNode } from '@/utils/litegraphUtil' import { isGroupNode } from '@/utils/executableGroupNodeDto' import { st } from '@/i18n' -import type { ErrorCardData, ErrorGroup, ErrorItem } from './types' +import type { MissingNodeType } from '@/types/comfy' +import type { + ErrorCardData, + ErrorGroup, + ErrorGroupType, + ErrorItem +} from './types' import type { NodeExecutionId } from '@/types/nodeIdentification' import { isNodeExecutionId } from '@/types/nodeIdentification' @@ -32,7 +38,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: ErrorGroupType priority: number cards: Map } @@ -72,11 +88,12 @@ function resolveNodeInfo(nodeId: string) { function getOrCreateGroup( groupsMap: Map, title: string, - priority = 1 + priority = 1, + type: ErrorGroupType = 'execution' ): Map { let entry = groupsMap.get(title) if (!entry) { - entry = { priority, cards: new Map() } + entry = { type, priority, cards: new Map() } groupsMap.set(title, entry) } return entry.cards @@ -137,6 +154,7 @@ function addCardErrorToGroup( function toSortedGroups(groupsMap: Map): ErrorGroup[] { return Array.from(groupsMap.entries()) .map(([title, groupData]) => ({ + type: groupData.type, title, cards: Array.from(groupData.cards.values()), priority: groupData.priority @@ -197,6 +215,7 @@ export function useErrorGroups( ) { const executionErrorStore = useExecutionErrorStore() const canvasStore = useCanvasStore() + const { inferPackFromNodeName } = useComfyRegistryStore() const collapseState = reactive>({}) const selectedNodeInfo = computed(() => { @@ -237,6 +256,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 +375,126 @@ 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) => { + const toResolve = pending.filter( + (n) => !asyncResolvedIds.value.has(n.type) + ) + if (!toResolve.length) return + + const updated = new Map(asyncResolvedIds.value) + for (const nodeType of toResolve) { + updated.set(nodeType.type, RESOLVING) + } + asyncResolvedIds.value = updated + + for (const nodeType of toResolve) { + const pack = await inferPackFromNodeName.call(nodeType.type) + asyncResolvedIds.value = new Map(asyncResolvedIds.value).set( + nodeType.type, + pack?.id ?? null + ) + } + }, + { 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, + cards: [ + { + id: '__missing_nodes__', + title: error.message, + errors: [ + { + message: error.message + } + ] + } + ], + priority: 0 + } + ] + } + const allErrorGroups = computed(() => { const groupsMap = new Map() @@ -350,7 +502,7 @@ export function useErrorGroups( processNodeErrors(groupsMap) processExecutionError(groupsMap) - return toSortedGroups(groupsMap) + return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)] }) const tabErrorGroups = computed(() => { @@ -360,9 +512,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(() => { @@ -389,6 +543,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 0b1b88820c3..27ba0541c32 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 3019838d55b..e88b2f5b7cd 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'" > From d3512fa6f0b9f6ecb7b20870a6a359296b5775ad Mon Sep 17 00:00:00 2001 From: jaeone94 Date: Wed, 25 Feb 2026 23:58:55 +0900 Subject: [PATCH 06/12] feat: show node/pack count inline in missing node error UI --- .../rightSidePanel/errors/MissingPackGroupRow.vue | 4 +++- src/components/rightSidePanel/errors/TabErrors.vue | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue index 44486aade85..84700445350 100644 --- a/src/components/rightSidePanel/errors/MissingPackGroupRow.vue +++ b/src/components/rightSidePanel/errors/MissingPackGroupRow.vue @@ -19,7 +19,9 @@ {{ t('g.loading') }}... - {{ group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack') }} + {{ + `${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})` + }}