Files
ComfyUI_frontend/src/components/rightSidePanel/errors/useErrorGroups.ts
jaeone94 46c40c755e feat: node-specific error tab with selection-aware grouping and error overlay (#8956)
## Summary
Enhances the error panel with node-specific views: single-node selection
shows errors grouped by message in compact mode, container nodes
(subgraph/group) expose child errors via a badge and "See Error" button,
and a floating ErrorOverlay appears after execution failure with a
deduplicated summary and quick navigation to the errors tab.

## Changes
- **Consolidate error tab**: Remove `TabError.vue`; merge all error
display into `TabErrors.vue` and drop the separate `error` tab type from
`rightSidePanelStore`
- **Selection-aware grouping**: Single-node selection regroups errors by
message (not `class_type`) and renders `ErrorNodeCard` in compact mode
- **Container node support**: Detect child-node errors in subgraph/group
nodes via execution ID prefix matching; show error badge and "See Error"
button in `SectionWidgets`
- **ErrorOverlay**: New floating card shown after execution failure with
deduplicated error messages, "Dismiss" and "See Errors" actions;
`isErrorOverlayOpen` / `showErrorOverlay` / `dismissErrorOverlay` added
to `executionStore`
- **Refactor**: Centralize error ID collection in `executionStore`
(`allErrorExecutionIds`, `hasInternalErrorForNode`); split `errorGroups`
into `allErrorGroups` (unfiltered) and `tabErrorGroups`
(selection-filtered); move `ErrorOverlay` business logic into
`useErrorGroups`

## Review Focus
- `useErrorGroups.ts`: split into `allErrorGroups` / `tabErrorGroups`
and the new `filterBySelection` parameter flow
- `executionStore.ts`: `hasInternalErrorForNode` helper and
`allErrorExecutionIds` computed
- `ErrorOverlay.vue`: integration with `executionStore` overlay state
and `useErrorGroups`

## Screenshots
<img width="853" height="461" alt="image"
src="https://github.com/user-attachments/assets/a49ab620-4209-4ae7-b547-fba13da0c633"
/>
<img width="854" height="203" alt="image"
src="https://github.com/user-attachments/assets/c119da54-cd78-4e7a-8b7a-456cfd348f1d"
/>
<img width="497" height="361" alt="image"
src="https://github.com/user-attachments/assets/74b16161-cf45-454b-ae60-24922fe36931"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-20 12:14:52 -08:00

387 lines
11 KiB
TypeScript

import { computed, reactive } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
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'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getRootParentNode
} from '@/utils/graphTraversalUtil'
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 { isNodeExecutionId } from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error'
])
interface GroupEntry {
priority: number
cards: Map<string, ErrorCardData>
}
interface ErrorSearchItem {
groupIndex: number
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableMessage: string
searchableDetails: string
}
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
*/
function resolveNodeInfo(nodeId: string) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
const parentNode = getRootParentNode(app.rootGraph, nodeId)
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
return {
title: isParentGroupNode
? parentNode?.title || ''
: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined,
isParentGroupNode
}
}
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
}
function createErrorCard(
nodeId: string,
classType: string,
idPrefix: string
): ErrorCardData {
const nodeInfo = resolveNodeInfo(nodeId)
return {
id: `${idPrefix}-${nodeId}`,
title: classType,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
errors: []
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
) {
const group = getOrCreateGroup(messageMap, error.message, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
group.get(card.id)?.errors.push(error)
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
})
}
function searchErrorGroups(groups: ErrorGroup[], query: string) {
if (!query) return groups
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
searchableList.push({
groupIndex: gi,
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query)
const matchedCardKeys = new Set(
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const canvasStore = useCanvasStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
const items = canvasStore.selectedItems
const nodeIds = new Set<string>()
const containerIds = new Set<string>()
for (const item of items) {
if (!isLGraphNode(item)) continue
nodeIds.add(String(item.id))
if (item instanceof SubgraphNode || isGroupNode(item)) {
containerIds.add(String(item.id))
}
}
return {
nodeIds: nodeIds.size > 0 ? nodeIds : null,
containerIds
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerIds.size === 0
)
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionStore.allErrorExecutionIds) {
const node = getNodeByExecutionId(app.rootGraph, execId)
if (node) map.set(execId, node)
}
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
const graphNode = errorNodeCache.value.get(executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
for (const containerId of selectedNodeInfo.value.containerIds) {
if (executionNodeId.startsWith(`${containerId}:`)) return true
}
return false
}
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: string,
classType: string,
idPrefix: string,
errors: ErrorItem[],
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
cards.get(nodeId)?.errors.push(...errors)
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
// For server_error, resolve the i18n key based on the environment
let errorTypeKey = error.type
if (error.type === 'server_error') {
errorTypeKey = isCloud ? 'server_error_cloud' : 'server_error_local'
}
const i18nKey = `rightSidePanel.promptErrors.${errorTypeKey}.desc`
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
cards.set(PROMPT_CARD_ID, {
id: PROMPT_CARD_ID,
title: groupTitle,
errors: [
{
message: isKnown ? t(i18nKey) : error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
})),
filterBySelection
)
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
e.node_type,
'exec',
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
}
],
filterBySelection
)
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return toSortedGroups(groupsMap)
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
return isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
return searchErrorGroups(tabErrorGroups.value, query)
})
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
}
return Array.from(messages)
})
return {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
groupedErrorMessages
}
}