From 1349fffbceb53d789f15b9502a75302997b0a367 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:01:15 +0900 Subject: [PATCH] Feat/errors tab panel (#8807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a dedicated **Errors tab** to the Right Side Panel that displays prompt-level, node validation, and runtime execution errors in a unified, searchable, grouped view — replacing the need to rely solely on modal dialogs for error inspection. ## Changes - **What**: - **New components** (`errors/` directory): - `TabErrors.vue` — Main error tab with search, grouping by class type, and canvas navigation (locate node / enter subgraph). - `ErrorNodeCard.vue` — Renders a single error card with node ID badge, title, action buttons, and error details. - `types.ts` — Shared type definitions (`ErrorItem`, `ErrorCardData`, `ErrorGroup`). - **`executionStore.ts`** — Added `PromptError` interface, `lastPromptError` ref and `hasAnyError` computed getter. Clears `lastPromptError` alongside existing error state on execution start and graph clear. - **`rightSidePanelStore.ts`** — Registered `'errors'` as a valid tab value. - **`app.ts`** — On prompt submission failure (`PromptExecutionError`), stores prompt-level errors (when no node errors exist) into `lastPromptError`. On both runtime execution error and prompt error, deselects all nodes and opens the errors tab automatically. - **`RightSidePanel.vue`** — Shows the `'errors'` tab (with ⚠ icon) when errors exist and no node is selected. Routes to `TabErrors` component. - **`TopMenuSection.vue`** — Highlights the action bar with a red border when any error exists, using `hasAnyError`. - **`SectionWidgets.vue`** — Detects per-node errors by matching execution IDs to graph node IDs. Shows an error icon (⚠) and "See Error" button that navigates to the errors tab. - **`en/main.json`** — Added i18n keys: `errors`, `noErrors`, `enterSubgraph`, `seeError`, `promptErrors.*`, and `errorHelp*`. - **Testing**: 6 unit tests (`TabErrors.test.ts`) covering prompt/node/runtime errors, search filtering, and clipboard copy. - **Storybook**: 7 stories (`ErrorNodeCard.stories.ts`) for badge visibility, subgraph buttons, multiple errors, runtime tracebacks, and prompt-only errors. - **Breaking**: None - **Dependencies**: None — uses only existing project dependencies (`vue-i18n`, `pinia`, `primevue`) ## Related Work > **Note**: Upstream PR #8603 (`New bottom button and badges`) introduced a separate `TabError.vue` (singular) that shows per-node errors when a specific node is selected. Our `TabErrors.vue` (plural) provides the **global error overview** — a different scope. The two tabs coexist: > - `'error'` (singular) → appears when a node with errors is selected → shows only that node's errors > - `'errors'` (plural) → appears when no node is selected and errors exist → shows all errors grouped by class type > > A future consolidation of these two tabs may be desirable after design review. ## Architecture ``` executionStore ├── lastPromptError: PromptError | null ← NEW (prompt-level errors without node IDs) ├── lastNodeErrors: Record (existing) ├── lastExecutionError: ExecutionError (existing) └── hasAnyError: ComputedRef ← NEW (centralized error detection) TabErrors.vue (errors tab - global view) ├── errorGroups: ComputedRef ← normalizes all 3 error sources ├── filteredGroups ← search-filtered view ├── locateNode() ← pan canvas to node ├── enterSubgraph() ← navigate into subgraph └── ErrorNodeCard.vue ← per-node card with copy/locate actions types.ts ├── ErrorItem { message, details?, isRuntimeError? } ├── ErrorCardData { id, title, nodeId?, errors[] } └── ErrorGroup { title, cards[], priority } ``` ## Review Focus 1. **Error normalization logic** (`TabErrors.vue` L75–150): Three different error sources (prompt, node validation, runtime) are normalized into a common `ErrorGroup → ErrorCardData → ErrorItem` hierarchy. Edge cases to verify: - Prompt errors with known vs unknown types (known types use localized descriptions) - Multiple errors on the same node (grouped into one card) - Runtime errors with long tracebacks (capped height with scroll) 2. **Canvas navigation** (`TabErrors.vue` L210–250): The `locateNode` and `enterSubgraph` functions navigate to potentially nested subgraphs. The double `requestAnimationFrame` is required due to LiteGraph's asynchronous subgraph switching — worth verifying this timing is sufficient. 3. **Store getter consolidation**: `hasAnyError` replaces duplicated logic in `TopMenuSection` and `RightSidePanel`. Confirm that the reactive dependency chain works correctly (it depends on 3 separate refs). 4. **Coexistence with upstream `TabError.vue`**: The singular `'error'` tab (upstream, PR #8603) and our plural `'errors'` tab serve different purposes but share similar naming. Consider whether a unified approach is preferred. ## Test Results ``` ✓ renders "no errors" state when store is empty ✓ renders prompt-level errors (Group title = error message) ✓ renders node validation errors grouped by class_type ✓ renders runtime execution errors from WebSocket ✓ filters errors based on search query ✓ calls copyToClipboard when copy button is clicked Test Files 1 passed (1) Tests 6 passed (6) ``` ## Screenshots (if applicable) image image image image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8807-Feat-errors-tab-panel-3046d73d36508127981ac670a70da467) by [Unito](https://www.unito.io) --- src/components/TopMenuSection.vue | 12 +- .../rightSidePanel/RightSidePanel.vue | 23 +- .../errors/ErrorNodeCard.stories.ts | 162 ++++++++++++ .../rightSidePanel/errors/ErrorNodeCard.vue | 110 ++++++++ .../rightSidePanel/errors/TabErrors.test.ts | 218 ++++++++++++++++ .../rightSidePanel/errors/TabErrors.vue | 167 +++++++++++++ src/components/rightSidePanel/errors/types.ts | 21 ++ .../rightSidePanel/errors/useErrorGroups.ts | 236 ++++++++++++++++++ .../parameters/SectionWidgets.vue | 42 +++- src/composables/canvas/useFocusNode.ts | 56 +++++ src/locales/en/main.json | 15 ++ src/locales/en/settings.json | 4 + .../settings/constants/coreSettings.ts | 11 + .../vueNodes/components/LGraphNode.vue | 5 +- .../components/form/FormSearchInput.vue | 2 +- src/schemas/apiSchema.ts | 8 + src/scripts/app.ts | 39 +++ src/stores/executionStore.ts | 55 +++- src/stores/workspace/rightSidePanelStore.ts | 8 + 19 files changed, 1185 insertions(+), 9 deletions(-) create mode 100644 src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts create mode 100644 src/components/rightSidePanel/errors/ErrorNodeCard.vue create mode 100644 src/components/rightSidePanel/errors/TabErrors.test.ts create mode 100644 src/components/rightSidePanel/errors/TabErrors.vue create mode 100644 src/components/rightSidePanel/errors/types.ts create mode 100644 src/components/rightSidePanel/errors/useErrorGroups.ts create mode 100644 src/composables/canvas/useFocusNode.ts diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index 69f79baad0..27be55962c 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -36,7 +36,14 @@
@@ -168,6 +175,7 @@ import { isDesktop } from '@/platform/distribution/types' import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' +import { cn } from '@/utils/tailwindUtil' const settingStore = useSettingStore() const workspaceStore = useWorkspaceStore() @@ -252,6 +260,8 @@ const shouldShowRedDot = computed((): boolean => { return shouldShowConflictRedDot.value }) +const { hasAnyError } = storeToRefs(executionStore) + // Right side panel toggle const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore) const rightSidePanelTooltipConfig = computed(() => diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue index cdf6b891fd..3b36a62996 100644 --- a/src/components/rightSidePanel/RightSidePanel.vue +++ b/src/components/rightSidePanel/RightSidePanel.vue @@ -33,6 +33,7 @@ import { useFlatAndCategorizeSelectedItems } from './shared' import SubgraphEditor from './subgraph/SubgraphEditor.vue' +import TabErrors from './errors/TabErrors.vue' const canvasStore = useCanvasStore() const executionStore = useExecutionStore() @@ -40,6 +41,8 @@ const rightSidePanelStore = useRightSidePanelStore() const settingStore = useSettingStore() const { t } = useI18n() +const { hasAnyError } = storeToRefs(executionStore) + const { findParentGroup } = useGraphHierarchy() const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore) @@ -102,7 +105,10 @@ const selectedNodeErrors = computed(() => const tabs = computed(() => { const list: RightSidePanelTabList = [] - if (selectedNodeErrors.value.length) { + if ( + selectedNodeErrors.value.length && + settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') + ) { list.push({ label: () => t('g.error'), value: 'error', @@ -110,6 +116,18 @@ const tabs = computed(() => { }) } + if ( + hasAnyError.value && + !hasSelection.value && + settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') + ) { + list.push({ + label: () => t('rightSidePanel.errors'), + value: 'errors', + icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1' + }) + } + list.push({ label: () => flattedItems.value.length > 1 @@ -298,7 +316,8 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
diff --git a/src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts b/src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts new file mode 100644 index 0000000000..44e264aeaa --- /dev/null +++ b/src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts @@ -0,0 +1,162 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import ErrorNodeCard from './ErrorNodeCard.vue' +import type { ErrorCardData } from './types' + +/** + * ErrorNodeCard displays a single error card inside the error tab. + * It shows the node header (ID badge, title, action buttons) + * and the list of error items (message, traceback, copy button). + */ +const meta: Meta = { + title: 'RightSidePanel/Errors/ErrorNodeCard', + component: ErrorNodeCard, + parameters: { + layout: 'centered' + }, + argTypes: { + showNodeIdBadge: { control: 'boolean' } + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +const singleErrorCard: ErrorCardData = { + id: 'node-10', + title: 'CLIPTextEncode', + nodeId: '10', + nodeTitle: 'CLIP Text Encode (Prompt)', + isSubgraphNode: false, + errors: [ + { + message: 'Required input "text" is missing.', + details: 'Input: text\nExpected: STRING' + } + ] +} + +const multipleErrorsCard: ErrorCardData = { + id: 'node-24', + title: 'VAEDecode', + nodeId: '24', + nodeTitle: 'VAE Decode', + isSubgraphNode: false, + errors: [ + { + message: 'Required input "samples" is missing.', + details: '' + }, + { + message: 'Value "NaN" is not a valid number for "strength".', + details: 'Expected: FLOAT [0.0 .. 1.0]' + } + ] +} + +const runtimeErrorCard: ErrorCardData = { + id: 'exec-45', + title: 'KSampler', + nodeId: '45', + nodeTitle: 'KSampler', + isSubgraphNode: false, + errors: [ + { + message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.', + details: [ + 'Traceback (most recent call last):', + ' File "ksampler.py", line 142, in sample', + ' samples = model.apply(latent)', + 'RuntimeError: CUDA out of memory.' + ].join('\n'), + isRuntimeError: true + } + ] +} + +const subgraphErrorCard: ErrorCardData = { + id: 'node-3:15', + title: 'KSampler', + nodeId: '3:15', + nodeTitle: 'Nested KSampler', + isSubgraphNode: true, + errors: [ + { + message: 'Latent input is required.', + details: '' + } + ] +} + +const promptOnlyCard: ErrorCardData = { + id: '__prompt__', + title: 'Prompt has no outputs.', + errors: [ + { + message: + 'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.' + } + ] +} + +/** Single validation error with node ID badge visible */ +export const WithNodeIdBadge: Story = { + args: { + card: singleErrorCard, + showNodeIdBadge: true + } +} + +/** Single validation error without node ID badge */ +export const WithoutNodeIdBadge: Story = { + args: { + card: singleErrorCard, + showNodeIdBadge: false + } +} + +/** Subgraph node error — shows "Enter subgraph" button */ +export const WithEnterSubgraphButton: Story = { + args: { + card: subgraphErrorCard, + showNodeIdBadge: true + } +} + +/** Regular node error — no "Enter subgraph" button */ +export const WithoutEnterSubgraphButton: Story = { + args: { + card: singleErrorCard, + showNodeIdBadge: true + } +} + +/** Multiple validation errors on one node */ +export const MultipleErrors: Story = { + args: { + card: multipleErrorsCard, + showNodeIdBadge: true + } +} + +/** Runtime execution error with full traceback */ +export const RuntimeError: Story = { + args: { + card: runtimeErrorCard, + showNodeIdBadge: true + } +} + +/** Prompt-level error (no node header) */ +export const PromptError: Story = { + args: { + card: promptOnlyCard, + showNodeIdBadge: false + } +} diff --git a/src/components/rightSidePanel/errors/ErrorNodeCard.vue b/src/components/rightSidePanel/errors/ErrorNodeCard.vue new file mode 100644 index 0000000000..815f622fec --- /dev/null +++ b/src/components/rightSidePanel/errors/ErrorNodeCard.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts new file mode 100644 index 0000000000..6d3abc7023 --- /dev/null +++ b/src/components/rightSidePanel/errors/TabErrors.test.ts @@ -0,0 +1,218 @@ +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' +import TabErrors from './TabErrors.vue' + +// Mock dependencies +vi.mock('@/scripts/app', () => ({ + app: { + rootGraph: { + serialize: vi.fn(() => ({})), + getNodeById: vi.fn() + } + } +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + getNodeByExecutionId: vi.fn(), + forEachNode: vi.fn() +})) + +vi.mock('@/composables/useCopyToClipboard', () => ({ + useCopyToClipboard: vi.fn(() => ({ + copyToClipboard: vi.fn() + })) +})) + +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: vi.fn(() => ({ + fitView: vi.fn() + })) +})) + +describe('TabErrors.vue', () => { + let i18n: ReturnType + + beforeEach(() => { + i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + workflow: 'Workflow', + copy: 'Copy' + }, + rightSidePanel: { + noErrors: 'No errors', + noneSearchDesc: 'No results found', + promptErrors: { + prompt_no_outputs: { + desc: 'Prompt has no outputs' + } + } + } + } + } + }) + }) + + function mountComponent(initialState = {}) { + return mount(TabErrors, { + global: { + plugins: [ + PrimeVue, + i18n, + createTestingPinia({ + createSpy: vi.fn, + initialState + }) + ], + stubs: { + FormSearchInput: { + template: + '' + }, + PropertiesAccordionItem: { + template: '
' + }, + Button: { + template: '' + } + } + } + }) + } + + it('renders "no errors" state when store is empty', () => { + const wrapper = mountComponent() + expect(wrapper.text()).toContain('No errors') + }) + + it('renders prompt-level errors (Group title = error message)', async () => { + const wrapper = mountComponent({ + execution: { + lastPromptError: { + type: 'prompt_no_outputs', + message: 'Server Error: No outputs', + details: 'Error details' + } + } + }) + + // Group title should be the raw message from store + expect(wrapper.text()).toContain('Server Error: No outputs') + // Item message should be localized desc + expect(wrapper.text()).toContain('Prompt has no outputs') + // Details should not be rendered for prompt errors + expect(wrapper.text()).not.toContain('Error details') + }) + + it('renders node validation errors grouped by class_type', async () => { + const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil') + vi.mocked(getNodeByExecutionId).mockReturnValue({ + title: 'CLIP Text Encode' + } as ReturnType) + + const wrapper = mountComponent({ + execution: { + lastNodeErrors: { + '6': { + class_type: 'CLIPTextEncode', + errors: [ + { message: 'Required input is missing', details: 'Input: text' } + ] + } + } + } + }) + + expect(wrapper.text()).toContain('CLIPTextEncode') + expect(wrapper.text()).toContain('#6') + expect(wrapper.text()).toContain('CLIP Text Encode') + expect(wrapper.text()).toContain('Required input is missing') + }) + + it('renders runtime execution errors from WebSocket', async () => { + const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil') + vi.mocked(getNodeByExecutionId).mockReturnValue({ + title: 'KSampler' + } as ReturnType) + + const wrapper = mountComponent({ + execution: { + lastExecutionError: { + prompt_id: 'abc', + node_id: '10', + node_type: 'KSampler', + exception_message: 'Out of memory', + exception_type: 'RuntimeError', + traceback: ['Line 1', 'Line 2'], + timestamp: Date.now() + } + } + }) + + expect(wrapper.text()).toContain('KSampler') + expect(wrapper.text()).toContain('#10') + expect(wrapper.text()).toContain('RuntimeError: Out of memory') + expect(wrapper.text()).toContain('Line 1') + }) + + it('filters errors based on search query', async () => { + const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil') + vi.mocked(getNodeByExecutionId).mockReturnValue(null) + + const wrapper = mountComponent({ + execution: { + lastNodeErrors: { + '1': { + class_type: 'CLIPTextEncode', + errors: [{ message: 'Missing text input' }] + }, + '2': { + class_type: 'KSampler', + errors: [{ message: 'Out of memory' }] + } + } + } + }) + + expect(wrapper.text()).toContain('CLIPTextEncode') + expect(wrapper.text()).toContain('KSampler') + + const searchInput = wrapper.find('input') + await searchInput.setValue('Missing text input') + + expect(wrapper.text()).toContain('CLIPTextEncode') + expect(wrapper.text()).not.toContain('KSampler') + }) + + it('calls copyToClipboard when copy button is clicked', async () => { + const { useCopyToClipboard } = + await import('@/composables/useCopyToClipboard') + const mockCopy = vi.fn() + vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy }) + + const wrapper = mountComponent({ + execution: { + lastNodeErrors: { + '1': { + class_type: 'TestNode', + errors: [{ message: 'Test message', details: 'Test details' }] + } + } + } + }) + + // Find the copy button (rendered inside ErrorNodeCard) + const copyButtons = wrapper.findAll('button') + const copyButton = copyButtons.find((btn) => btn.text().includes('Copy')) + expect(copyButton).toBeTruthy() + await copyButton!.trigger('click') + + expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details') + }) +}) diff --git a/src/components/rightSidePanel/errors/TabErrors.vue b/src/components/rightSidePanel/errors/TabErrors.vue new file mode 100644 index 0000000000..836d683d73 --- /dev/null +++ b/src/components/rightSidePanel/errors/TabErrors.vue @@ -0,0 +1,167 @@ + + + diff --git a/src/components/rightSidePanel/errors/types.ts b/src/components/rightSidePanel/errors/types.ts new file mode 100644 index 0000000000..b5729fd032 --- /dev/null +++ b/src/components/rightSidePanel/errors/types.ts @@ -0,0 +1,21 @@ +export interface ErrorItem { + message: string + details?: string + isRuntimeError?: boolean +} + +export interface ErrorCardData { + id: string + title: string + nodeId?: string + nodeTitle?: string + graphNodeId?: string + isSubgraphNode?: boolean + errors: ErrorItem[] +} + +export interface ErrorGroup { + title: string + cards: ErrorCardData[] + priority: number +} diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts new file mode 100644 index 0000000000..9c83829af6 --- /dev/null +++ b/src/components/rightSidePanel/errors/useErrorGroups.ts @@ -0,0 +1,236 @@ +import { computed } from 'vue' +import type { Ref } from 'vue' +import Fuse from 'fuse.js' +import type { IFuseOptions } from 'fuse.js' + +import { useExecutionStore } from '@/stores/executionStore' +import { app } from '@/scripts/app' +import { getNodeByExecutionId } from '@/utils/graphTraversalUtil' +import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil' +import { st } from '@/i18n' +import type { ErrorCardData, ErrorGroup } from './types' +import { isNodeExecutionId } from '@/types/nodeIdentification' + +interface GroupEntry { + priority: number + cards: Map +} + +interface ErrorSearchItem { + groupIndex: number + cardIndex: number + searchableNodeId: string + searchableNodeTitle: string + searchableMessage: string + searchableDetails: string +} + +const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt']) + +function resolveNodeInfo(nodeId: string): { + title: string + graphNodeId: string | undefined +} { + const graphNode = getNodeByExecutionId(app.rootGraph, nodeId) + return { + title: resolveNodeDisplayName(graphNode, { + emptyLabel: '', + untitledLabel: '', + st + }), + graphNodeId: graphNode ? String(graphNode.id) : undefined + } +} + +function getOrCreateGroup( + groupsMap: Map, + title: string, + priority = 1 +): Map { + let entry = groupsMap.get(title) + if (!entry) { + entry = { priority, cards: new Map() } + groupsMap.set(title, entry) + } + return entry.cards +} + +function processPromptError( + groupsMap: Map, + executionStore: ReturnType, + 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, + executionStore: ReturnType +) { + 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 processExecutionError( + groupsMap: Map, + executionStore: ReturnType +) { + if (!executionStore.lastExecutionError) return + + const e = executionStore.lastExecutionError + const nodeId = String(e.node_id) + const cards = getOrCreateGroup(groupsMap, e.node_type, 1) + + 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 card = cards.get(nodeId) + if (!card) return + card.errors.push({ + message: `${e.exception_type}: ${e.exception_message}`, + details: e.traceback.join('\n'), + isRuntimeError: true + }) +} + +function toSortedGroups(groupsMap: Map): 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 buildErrorGroups( + executionStore: ReturnType, + t: (key: string) => string +): ErrorGroup[] { + const groupsMap = new Map() + + processPromptError(groupsMap, executionStore, t) + processNodeErrors(groupsMap, executionStore) + processExecutionError(groupsMap, executionStore) + + return toSortedGroups(groupsMap) +} + +function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] { + 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 = { + 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, + t: (key: string) => string +) { + const executionStore = useExecutionStore() + + const errorGroups = computed(() => + buildErrorGroups(executionStore, t) + ) + + const filteredGroups = computed(() => { + const query = searchQuery.value.trim() + return searchErrorGroups(errorGroups.value, query) + }) + + return { + errorGroups, + filteredGroups + } +} diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue index 1ecac869f3..5e2e77ecd1 100644 --- a/src/components/rightSidePanel/parameters/SectionWidgets.vue +++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue @@ -12,6 +12,10 @@ import type { } 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 { useNodeDefStore } from '@/stores/nodeDefStore' import { getWidgetDefaultValue } from '@/utils/widgetUtil' import type { WidgetValue } from '@/utils/widgetUtil' @@ -60,6 +64,8 @@ watchEffect(() => (widgets.value = widgetsProp)) provide(HideLayoutFieldKey, true) const canvasStore = useCanvasStore() +const executionStore = useExecutionStore() +const rightSidePanelStore = useRightSidePanelStore() const nodeDefStore = useNodeDefStore() const { t } = useI18n() @@ -104,6 +110,11 @@ const targetNode = computed(() => { return allSameNode ? widgets.value[0].node : null }) +const nodeHasError = computed(() => { + if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false + return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id)) +}) + const parentGroup = computed(() => { if (!targetNode.value || !getNodeParentGroup) return null return getNodeParentGroup(targetNode.value) @@ -122,6 +133,13 @@ function handleLocateNode() { } } +function navigateToErrorTab() { + if (!targetNode.value) return + if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return + rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id) + rightSidePanelStore.openPanel('errors') +} + function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) { widget.value = value widget.callback?.(value) @@ -162,9 +180,20 @@ defineExpose({ :tooltip >