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>
This commit is contained in:
jaeone94
2026-02-21 05:14:52 +09:00
committed by GitHub
parent c2452c5d20
commit 46c40c755e
25 changed files with 997 additions and 240 deletions

View File

@@ -61,16 +61,21 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
})
test.describe('Execution error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.queueButton.click()
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for the element with the .comfy-execution-error selector to be visible
const executionError = comfyPage.page.locator('.comfy-error-report')
await expect(executionError).toBeVisible()
// Wait for the error overlay to be visible
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).toBeVisible()
})
})

View File

@@ -7,22 +7,29 @@ test.beforeEach(async ({ comfyPage }) => {
})
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test(
'Report error on unconnected slot',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
await comfyPage.page
.locator('.p-dialog')
.getByRole('button', { name: 'Close' })
.locator('[data-testid="error-overlay"]')
.getByRole('button', { name: 'Dismiss' })
.click()
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.waitFor({ state: 'hidden' })
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -110,6 +110,7 @@
</Button>
</div>
</div>
<ErrorOverlay />
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
@@ -156,6 +157,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'

View File

@@ -0,0 +1,105 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="-translate-y-3 opacity-0"
enter-to-class="translate-y-0 opacity-100"
>
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
<div
role="alert"
aria-live="assertive"
data-testid="error-overlay"
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
<span class="flex-1 text-sm font-bold text-destructive-background">
{{ errorCountLabel }}
</span>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
@click="dismiss"
>
<i class="icon-[lucide--x] block size-5 leading-none" />
</Button>
</div>
<!-- Body -->
<div class="px-4 pb-3">
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="(message, idx) in groupedErrorMessages"
:key="idx"
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground min-w-0"
>
<span
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
/>
<span class="break-words line-clamp-3 whitespace-pre-wrap">{{
message
}}</span>
</li>
</ul>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-3">
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{ t('errorOverlay.seeErrors') }}
</Button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
const { t } = useI18n()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const isVisible = computed(
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
)
function dismiss() {
executionStore.dismissErrorOverlay()
}
function seeErrors() {
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionStore.dismissErrorOverlay()
}
</script>

View File

@@ -19,8 +19,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -41,7 +41,7 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError } = storeToRefs(executionStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
const { findParentGroup } = useGraphHierarchy()
@@ -96,30 +96,31 @@ type RightSidePanelTabList = Array<{
icon?: string
}>
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
const hasDirectNodeError = computed(() =>
selectedNodes.value.some((node) =>
executionStore.activeGraphErrorNodeIds.has(String(node.id))
)
)
const hasContainerInternalError = computed(() => {
if (allErrorExecutionIds.value.length === 0) return false
return selectedNodes.value.some((node) => {
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
return executionStore.hasInternalErrorForNode(node.id)
})
})
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return hasDirectNodeError.value || hasContainerInternalError.value
})
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (
selectedNodeErrors.value.length &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
if (
hasAnyError.value &&
!hasSelection.value &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
hasRelevantErrors.value
) {
list.push({
label: () => t('rightSidePanel.errors'),
@@ -315,9 +316,9 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabErrors v-if="activeTab === 'errors'" />
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
<TabErrors v-if="activeTab === 'errors'" />
<template v-else-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>
@@ -326,7 +327,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -1,10 +1,13 @@
<template>
<div class="overflow-hidden">
<!-- Card Header (Node ID & Actions) -->
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
>
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
>
#{{ card.nodeId }}
</span>
@@ -19,7 +22,7 @@
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
@@ -27,7 +30,8 @@
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
@click.stop="emit('locateNode', card.nodeId ?? '')"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
@@ -43,8 +47,8 @@
>
<!-- Error Message -->
<p
v-if="error.message"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
v-if="error.message && !compact"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5 max-h-[4lh] overflow-y-auto"
>
{{ error.message }}
</p>
@@ -69,7 +73,7 @@
<Button
variant="secondary"
size="sm"
class="w-full justify-center gap-2 h-8 text-[11px]"
class="w-full justify-center gap-2 h-8 text-xs"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
@@ -88,9 +92,15 @@ import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
const { card, showNodeIdBadge = false } = defineProps<{
const {
card,
showNodeIdBadge = false,
compact = false
} = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
}>()
const emit = defineEmits<{
@@ -101,6 +111,18 @@ const emit = defineEmits<{
const { t } = useI18n()
function handleLocateNode() {
if (card.nodeId) {
emit('locateNode', card.nodeId)
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',

View File

@@ -17,6 +17,7 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn()
}))

View File

@@ -55,8 +55,9 @@
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
@locate-node="focusNode"
@enter-subgraph="enterSubgraph"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
@@ -97,16 +98,16 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -119,10 +120,10 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const searchQuery = ref('')
const settingStore = useSettingStore()
const showNodeIdBadge = computed(
() =>
@@ -130,17 +131,30 @@ const showNodeIdBadge = computed(
NodeBadgeMode.None
)
const { filteredGroups } = useErrorGroups(searchQuery, t)
const collapseState = reactive<Record<string, boolean>>({})
const {
allErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache
} = useErrorGroups(searchQuery, t)
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
for (const group of filteredGroups.value) {
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch = group.cards.some(
(card) => card.graphNodeId === graphNodeId
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
collapseState[group.title] = !hasMatch
}
@@ -149,6 +163,14 @@ watch(
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
@@ -162,6 +184,6 @@ async function contactSupport() {
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -1,16 +1,34 @@
import { computed } from 'vue'
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 { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
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 } from './types'
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>
@@ -25,20 +43,26 @@ interface ErrorSearchItem {
searchableDetails: string
}
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
function resolveNodeInfo(nodeId: string): {
title: string
graphNodeId: string | undefined
} {
/**
* 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: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined
title: isParentGroupNode
? parentNode?.title || ''
: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined,
isParentGroupNode
}
}
@@ -55,93 +79,56 @@ function getOrCreateGroup(
return entry.cards
}
function processPromptError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
) {
if (!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)
cards.set('__prompt__', {
id: '__prompt__',
title: groupTitle,
errors: [
{
message: isKnown
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
: error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `node-${nodeId}`,
title: nodeError.class_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) continue
card.errors.push(
...nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
}))
)
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: []
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastExecutionError) return
/**
* 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 e = executionStore.lastExecutionError
const nodeId = String(e.node_id)
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `exec-${nodeId}`,
title: e.node_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push({
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
})
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[] {
@@ -157,27 +144,14 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
})
}
function buildErrorGroups(
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
): ErrorGroup[] {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, executionStore, t)
processNodeErrors(groupsMap, executionStore)
processExecutionError(groupsMap, executionStore)
return toSortedGroups(groupsMap)
}
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
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]!
const group = groups[gi]
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]!
const card = group.cards[ci]
searchableList.push({
groupIndex: gi,
cardIndex: ci,
@@ -219,18 +193,194 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const canvasStore = useCanvasStore()
const collapseState = reactive<Record<string, boolean>>({})
const errorGroups = computed<ErrorGroup[]>(() =>
buildErrorGroups(executionStore, t)
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(errorGroups.value, query)
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 {
errorGroups,
filteredGroups
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
groupedErrorMessages
}
}

View File

@@ -5,17 +5,15 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type {
LGraphGroup,
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -110,11 +108,26 @@ const targetNode = computed<LGraphNode | null>(() => {
return allSameNode ? widgets.value[0].node : null
})
const nodeHasError = computed(() => {
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
const hasDirectError = computed(() => {
if (!targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const hasContainerInternalError = computed(() => {
if (!targetNode.value) return false
const isContainer =
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionStore.hasInternalErrorForNode(targetNode.value.id)
})
const nodeHasError = computed(() => {
if (!targetNode.value) return false
if (canvasStore.selectedItems.length === 1) return false
return hasDirectError.value || hasContainerInternalError.value
})
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -207,7 +220,10 @@ defineExpose({
</span>
</span>
<Button
v-if="nodeHasError"
v-if="
nodeHasError &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"

View File

@@ -2,8 +2,16 @@ import { nextTick } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getRootParentNode
} from '@/utils/graphTraversalUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
@@ -29,20 +37,40 @@ async function navigateToGraph(targetGraph: LGraph) {
export function useFocusNode() {
const canvasStore = useCanvasStore()
async function focusNode(nodeId: string) {
/* Locate and focus a node on the canvas by its execution ID. */
async function focusNode(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
// For group node internals, locate the root parent group node instead
const parentNode = getRootParentNode(app.rootGraph, nodeId)
if (parentNode && isGroupNode(parentNode) && parentNode.graph) {
await navigateToGraph(parentNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(parentNode.boundingRect)
return
}
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(nodeId: string) {
async function enterSubgraph(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
if (!canvasStore.canvas) return
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)

View File

@@ -1381,6 +1381,7 @@
"Execution": "Execution",
"PLY": "PLY",
"Workspace": "Workspace",
"Error System": "Error System",
"Other": "Other",
"Secrets": "Secrets",
"Error System": "Error System"
@@ -3010,6 +3011,7 @@
"hideAdvancedInputsButton": "Hide advanced inputs",
"errors": "Errors",
"noErrors": "No errors",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"promptErrors": {
@@ -3018,6 +3020,12 @@
},
"no_prompt": {
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
},
"server_error_local": {
"desc": "The server encountered an unexpected error. Please check the server logs."
},
"server_error_cloud": {
"desc": "The server encountered an unexpected error. Please try again later."
}
},
"errorHelp": "For more help, {github} or {support}",
@@ -3026,6 +3034,10 @@
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters"
},
"errorOverlay": {
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
"seeErrors": "See Errors"
},
"help": {
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"

View File

@@ -1237,7 +1237,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance.',
type: 'boolean',
defaultValue: false,
defaultValue: true,
experimental: true,
versionAdded: '1.40.0'
}

View File

@@ -134,11 +134,8 @@
as-child
>
<button
v-if="hasAnyError"
@click.stop="
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
useRightSidePanelStore().openPanel('error')
"
v-if="hasAnyError && showErrorsTabEnabled"
@click.stop="useRightSidePanelStore().openPanel('errors')"
>
<span>{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4" />
@@ -310,6 +307,10 @@ const hasAnyError = computed((): boolean => {
)
})
const showErrorsTabEnabled = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)

View File

@@ -831,7 +831,20 @@ export class ComfyApi extends EventTarget {
})
if (res.status !== 200) {
throw new PromptExecutionError(await res.json())
const text = await res.text()
let errorResponse
try {
errorResponse = JSON.parse(text)
} catch {
errorResponse = {
error: {
type: 'server_error',
message: `${res.status} ${res.statusText}`,
details: text
}
}
}
throw new PromptExecutionError(errorResponse)
}
return await res.json()

View File

@@ -71,7 +71,6 @@ import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
@@ -713,13 +712,11 @@ export class ComfyApp {
isInsufficientCredits: true
})
}
} else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
useExecutionStore().showErrorOverlay()
} else {
useDialogService().showExecutionErrorDialog(detail)
}
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
this.canvas.deselectAll()
useRightSidePanelStore().openPanel('errors')
}
this.canvas.draw(true, true)
})
@@ -1465,7 +1462,10 @@ export class ComfyApp {
{}) as MissingNodeTypeExtraInfo
const missingNodeType = createMissingNodeTypeFromError(extraInfo)
this.showMissingNodesError([missingNodeType])
} else {
} else if (
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
!(error instanceof PromptExecutionError)
) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.promptExecutionError'),
reportType: 'promptExecutionError'
@@ -1500,11 +1500,8 @@ export class ComfyApp {
}
}
// Clear selection and open the error panel so the user can immediately
// see the error details without extra clicks.
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
this.canvas.deselectAll()
useRightSidePanelStore().openPanel('errors')
executionStore.showErrorOverlay()
}
this.canvas.draw(true, true)
}

View File

@@ -1,6 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { isEmpty } from 'es-toolkit/compat'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -36,6 +35,7 @@ import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
interface QueuedJob {
/**
@@ -291,6 +291,8 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
lastExecutionError.value = null
lastPromptError.value = null
lastNodeErrors.value = null
isErrorOverlayOpen.value = false
activeJobId.value = e.detail.prompt_id
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
clearInitializationByJobId(activeJobId.value)
@@ -391,7 +393,6 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
lastExecutionError.value = e.detail
if (isCloud) {
useTelemetry()?.trackExecutionError({
jobId: e.detail.prompt_id,
@@ -399,11 +400,55 @@ export const useExecutionStore = defineStore('execution', () => {
nodeType: e.detail.node_type,
error: e.detail.exception_message
})
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
if (handleCloudValidationError(e.detail)) return
}
// Service-level errors (e.g. "Job has stagnated") have no associated node.
// Route them as job errors
if (handleServiceLevelError(e.detail)) return
// OSS path / Cloud fallback (real runtime errors)
lastExecutionError.value = e.detail
clearInitializationByJobId(e.detail.prompt_id)
resetExecutionState(e.detail.prompt_id)
}
function handleServiceLevelError(detail: ExecutionErrorWsMessage): boolean {
const nodeId = detail.node_id
if (nodeId !== null && nodeId !== undefined && String(nodeId) !== '')
return false
clearInitializationByJobId(detail.prompt_id)
resetExecutionState(detail.prompt_id)
lastPromptError.value = {
type: detail.exception_type ?? 'error',
message: detail.exception_type
? `${detail.exception_type}: ${detail.exception_message}`
: (detail.exception_message ?? ''),
details: detail.traceback?.join('\n') ?? ''
}
return true
}
function handleCloudValidationError(
detail: ExecutionErrorWsMessage
): boolean {
const result = classifyCloudValidationError(detail.exception_message)
if (!result) return false
clearInitializationByJobId(detail.prompt_id)
resetExecutionState(detail.prompt_id)
if (result.kind === 'nodeErrors') {
lastNodeErrors.value = result.nodeErrors
} else {
lastPromptError.value = result.promptError
}
return true
}
/**
* Notification handler used for frontend/cloud initialization tracking.
* Marks a job as initializing when cloud notifies it is waiting for a machine.
@@ -653,7 +698,7 @@ export const useExecutionStore = defineStore('execution', () => {
/** Whether any node validation errors are present */
const hasNodeError = computed(
() => !!lastNodeErrors.value && !isEmpty(lastNodeErrors.value)
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
@@ -661,12 +706,49 @@ export const useExecutionStore = defineStore('execution', () => {
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
)
/** Pre-computed Set of graph node IDs (as strings) that have errors. */
const allErrorExecutionIds = computed<string[]>(() => {
const ids: string[] = []
if (lastNodeErrors.value) {
ids.push(...Object.keys(lastNodeErrors.value))
}
if (lastExecutionError.value) {
const nodeId = lastExecutionError.value.node_id
if (nodeId !== null && nodeId !== undefined) {
ids.push(String(nodeId))
}
}
return ids
})
/** Count of prompt-level errors (0 or 1) */
const promptErrorCount = computed(() => (lastPromptError.value ? 1 : 0))
/** Count of all individual node validation errors */
const nodeErrorCount = computed(() => {
if (!lastNodeErrors.value) return 0
let count = 0
for (const nodeError of Object.values(lastNodeErrors.value)) {
count += nodeError.errors.length
}
return count
})
/** Count of runtime execution errors (0 or 1) */
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
/** Total count of all individual errors */
const totalErrorCount = computed(
() =>
promptErrorCount.value + nodeErrorCount.value + executionErrorCount.value
)
/** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.rootGraph) return ids
const activeGraph = useCanvasStore().currentGraph ?? app.rootGraph
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
if (lastNodeErrors.value) {
for (const executionId of Object.keys(lastNodeErrors.value)) {
@@ -688,6 +770,21 @@ export const useExecutionStore = defineStore('execution', () => {
return ids
})
function hasInternalErrorForNode(nodeId: string | number): boolean {
const prefix = `${nodeId}:`
return allErrorExecutionIds.value.some((id) => id.startsWith(prefix))
}
const isErrorOverlayOpen = ref(false)
function showErrorOverlay() {
isErrorOverlayOpen.value = true
}
function dismissErrorOverlay() {
isErrorOverlayOpen.value = false
}
return {
isIdle,
clientId,
@@ -697,6 +794,8 @@ export const useExecutionStore = defineStore('execution', () => {
lastExecutionError,
lastPromptError,
hasAnyError,
allErrorExecutionIds,
totalErrorCount,
lastExecutionErrorNodeId,
executingNodeId,
executingNodeIds,
@@ -730,6 +829,10 @@ export const useExecutionStore = defineStore('execution', () => {
// Node error lookup helpers
getNodeErrors,
slotHasError,
activeGraphErrorNodeIds
hasInternalErrorForNode,
activeGraphErrorNodeIds,
isErrorOverlayOpen,
showErrorOverlay,
dismissErrorOverlay
}
})

View File

@@ -4,7 +4,6 @@ import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export type RightSidePanelTab =
| 'error'
| 'parameters'
| 'nodes'
| 'settings'

View File

@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import {
isCloudValidationError,
tryExtractValidationError,
classifyCloudValidationError
} from '@/utils/executionErrorUtil'
describe('executionErrorUtil', () => {
describe('isCloudValidationError', () => {
it('should return true when object has error field', () => {
expect(isCloudValidationError({ error: 'some error' })).toBe(true)
})
it('should return true when object has node_errors field', () => {
expect(isCloudValidationError({ node_errors: {} })).toBe(true)
})
it('should return true when object has both fields', () => {
expect(isCloudValidationError({ error: 'err', node_errors: {} })).toBe(
true
)
})
it('should return false for null', () => {
expect(isCloudValidationError(null)).toBe(false)
})
it('should return false for non-object', () => {
expect(isCloudValidationError('string')).toBe(false)
})
it('should return false for object without error or node_errors', () => {
expect(isCloudValidationError({ foo: 'bar' })).toBe(false)
})
})
describe('tryExtractValidationError', () => {
it('should extract JSON from a message with embedded validation error', () => {
const embedded = JSON.stringify({
error: {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
},
node_errors: {}
})
const message = `Failed to send prompt request: status 400: ${embedded}`
const result = tryExtractValidationError(message)
expect(result).not.toBeNull()
expect(result?.error).toEqual({
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
})
})
it('should return null when message has no JSON', () => {
expect(tryExtractValidationError('plain error message')).toBeNull()
})
it('should return null when JSON is not a validation error shape', () => {
const message = 'error: {"foo": "bar"}'
expect(tryExtractValidationError(message)).toBeNull()
})
it('should return null when JSON is malformed', () => {
const message = 'error: {invalid json'
expect(tryExtractValidationError(message)).toBeNull()
})
})
describe('classifyCloudValidationError', () => {
it('should classify node errors when node_errors is present', () => {
const nodeErrors = {
'11:1': {
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'clip',
extra_info: { input_name: 'clip' }
}
],
dependent_outputs: ['9'],
class_type: 'CLIPTextEncode'
}
}
const embedded = JSON.stringify({
error: {
type: 'prompt_outputs_failed_validation',
message: 'Prompt outputs failed validation',
details: ''
},
node_errors: nodeErrors
})
const message = `Failed to send prompt request: status 400: ${embedded}`
const result = classifyCloudValidationError(message)
expect(result).not.toBeNull()
expect(result?.kind).toBe('nodeErrors')
if (result?.kind === 'nodeErrors') {
expect(result.nodeErrors['11:1'].class_type).toBe('CLIPTextEncode')
}
})
it('should classify prompt error when error is an object and no node_errors', () => {
const embedded = JSON.stringify({
error: {
type: 'prompt_no_outputs',
message: 'Prompt has no outputs',
details: ''
}
})
const message = `Failed: ${embedded}`
const result = classifyCloudValidationError(message)
expect(result).not.toBeNull()
expect(result?.kind).toBe('promptError')
if (result?.kind === 'promptError') {
expect(result.promptError.type).toBe('prompt_no_outputs')
expect(result.promptError.message).toBe('Prompt has no outputs')
}
})
it('should classify prompt error when error is a string', () => {
const embedded = JSON.stringify({ error: 'Something went wrong' })
const message = `Failed: ${embedded}`
const result = classifyCloudValidationError(message)
expect(result).not.toBeNull()
expect(result?.kind).toBe('promptError')
if (result?.kind === 'promptError') {
expect(result.promptError.type).toBe('error')
expect(result.promptError.message).toBe('Something went wrong')
}
})
it('should return null when message has no embedded JSON', () => {
expect(classifyCloudValidationError('plain error')).toBeNull()
})
it('should return null when embedded JSON has no error or node_errors', () => {
const message = 'error: {"foo": "bar"}'
expect(classifyCloudValidationError(message)).toBeNull()
})
it('should return null when error field is neither object nor string', () => {
const embedded = JSON.stringify({ error: 123 })
const message = `Failed: ${embedded}`
expect(classifyCloudValidationError(message)).toBeNull()
})
it('should prefer node_errors over error when both present', () => {
const embedded = JSON.stringify({
error: { type: 'validation', message: 'fail', details: '' },
node_errors: {
'5': {
errors: [{ type: 'err', message: 'bad', details: '' }],
dependent_outputs: [],
class_type: 'KSampler'
}
}
})
const message = `Failed: ${embedded}`
const result = classifyCloudValidationError(message)
expect(result?.kind).toBe('nodeErrors')
})
it('should treat empty node_errors as prompt error', () => {
const embedded = JSON.stringify({
error: { type: 'no_prompt', message: 'No prompt', details: '' },
node_errors: {}
})
const message = `Failed: ${embedded}`
const result = classifyCloudValidationError(message)
expect(result?.kind).toBe('promptError')
})
})
})

View File

@@ -0,0 +1,92 @@
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeError, PromptError } from '@/schemas/apiSchema'
/**
* The standard prompt validation response shape (`{ error, node_errors }`).
* In cloud, this is embedded as JSON inside `execution_error.exception_message`
* because prompts are queued asynchronously and errors arrive via WebSocket
* rather than as direct HTTP responses.
*/
interface CloudValidationError {
error?: { type?: string; message?: string; details?: string } | string
node_errors?: Record<NodeId, NodeError>
}
export function isCloudValidationError(
value: unknown
): value is CloudValidationError {
return (
value !== null &&
typeof value === 'object' &&
('error' in value || 'node_errors' in value)
)
}
/**
* Extracts a prompt validation response embedded in an exception message string.
*
* Cloud example `exception_message`:
* "Failed to send prompt request: ... 400: {\"error\":{...},\"node_errors\":{...}}"
*
* This function finds the first '{' and parses the trailing JSON.
*/
export function tryExtractValidationError(
exceptionMessage: string
): CloudValidationError | null {
const jsonStart = exceptionMessage.indexOf('{')
const jsonEnd = exceptionMessage.lastIndexOf('}')
if (jsonStart === -1 || jsonEnd === -1) return null
try {
const parsed: unknown = JSON.parse(
exceptionMessage.substring(jsonStart, jsonEnd + 1)
)
return isCloudValidationError(parsed) ? parsed : null
} catch {
return null
}
}
type CloudValidationResult =
| { kind: 'nodeErrors'; nodeErrors: Record<NodeId, NodeError> }
| { kind: 'promptError'; promptError: PromptError }
/**
* Classifies an embedded cloud validation error from `exception_message`
* as either node-level errors or a prompt-level error.
*
* Returns `null` if the message does not contain a recognizable validation error.
*/
export function classifyCloudValidationError(
exceptionMessage: string
): CloudValidationResult | null {
const extracted = tryExtractValidationError(exceptionMessage)
if (!extracted) return null
const { error, node_errors } = extracted
const hasNodeErrors = node_errors && Object.keys(node_errors).length > 0
if (hasNodeErrors) {
return { kind: 'nodeErrors', nodeErrors: node_errors }
}
if (error && typeof error === 'object') {
return {
kind: 'promptError',
promptError: {
type: error.type ?? 'error',
message: error.message ?? '',
details: error.details ?? ''
}
}
}
if (typeof error === 'string') {
return {
kind: 'promptError',
promptError: { type: 'error', message: error, details: '' }
}
}
return null
}

View File

@@ -274,6 +274,28 @@ export function findSubgraphPathById(
return null
}
/**
* Gets the root parent node associated with a hierarchical execution ID.
* Both Group Nodes and Subgraph Nodes use hierarchical IDs (e.g. "rootId:childId:...").
* The root parent is always located in the rootGraph.
*
* @param rootGraph - The root graph to search from
* @param executionId - The execution ID (e.g., "123:456")
* @returns The root parent node if found, null otherwise
*/
export function getRootParentNode(
rootGraph: LGraph,
executionId: string
): LGraphNode | null {
const parts = parseExecutionId(executionId)
if (!parts || parts.length < 2) return null
const parentId = parts[0]
if (!rootGraph) return null
return rootGraph.getNodeById(Number(parentId)) || null
}
/**
* Get a node by its execution ID from anywhere in the graph hierarchy.
* Execution IDs use hierarchical format like "123:456:789" for nested nodes.