mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 22:34:15 +00:00
Feat/errors tab panel (#8807)
## 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<string, NodeError> (existing) ├── lastExecutionError: ExecutionError (existing) └── hasAnyError: ComputedRef<boolean> ← NEW (centralized error detection) TabErrors.vue (errors tab - global view) ├── errorGroups: ComputedRef<ErrorGroup[]> ← 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) <img width="1238" height="1914" alt="image" src="https://github.com/user-attachments/assets/ec39b872-cca1-4076-8795-8bc7c05dc665" /> <img width="669" height="1028" alt="image" src="https://github.com/user-attachments/assets/bdcaa82a-34b0-46a5-a08f-14950c5a479b" /> <img width="644" height="1005" alt="image" src="https://github.com/user-attachments/assets/ffef38c6-8f42-4c01-a0de-11709d54b638" /> <img width="672" height="505" alt="image" src="https://github.com/user-attachments/assets/5cff7f57-8d79-4808-a71e-9ad05bab6e17" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8807-Feat-errors-tab-panel-3046d73d36508127981ac670a70da467) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -36,7 +36,14 @@
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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<RightSidePanelTabList>(() => {
|
||||
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<RightSidePanelTabList>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<template v-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
|
||||
162
src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
Normal file
162
src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
Normal file
@@ -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<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
110
src/components/rightSidePanel/errors/ErrorNodeCard.vue
Normal file
110
src/components/rightSidePanel/errors/ErrorNodeCard.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
<span
|
||||
v-if="card.nodeTitle"
|
||||
class="flex-1 text-sm text-muted-foreground truncate font-medium"
|
||||
>
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
@click.stop="emit('locateNode', card.nodeId ?? '')"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div class="divide-y divide-interface-stroke/20 space-y-4">
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details -->
|
||||
<div
|
||||
v-if="error.details"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
|
||||
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
|
||||
>
|
||||
{{ error.details }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-[11px]"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
{{ t('g.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const { card, showNodeIdBadge = false } = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
[error.message, error.details].filter(Boolean).join('\n\n')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
218
src/components/rightSidePanel/errors/TabErrors.test.ts
Normal file
218
src/components/rightSidePanel/errors/TabErrors.test.ts
Normal file
@@ -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<typeof createI18n>
|
||||
|
||||
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:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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<typeof getNodeByExecutionId>)
|
||||
|
||||
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<typeof getNodeByExecutionId>)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
167
src/components/rightSidePanel/errors/TabErrors.vue
Normal file
167
src/components/rightSidePanel/errors/TabErrors.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-w-0">
|
||||
<!-- Search bar -->
|
||||
<div
|
||||
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
||||
/>
|
||||
<span class="text-destructive-background-hover truncate">
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards in Group (default slot) -->
|
||||
<div class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="focusNode"
|
||||
@enter-subgraph="enterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-muted-foreground leading-tight break-words"
|
||||
>
|
||||
<template #github>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="openGitHubIssues"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpGithub') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #support>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="contactSupport"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpSupport') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, 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 { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const { filteredGroups } = useErrorGroups(searchQuery, t)
|
||||
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
for (const group of filteredGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) => card.graphNodeId === graphNodeId
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function contactSupport() {
|
||||
useTelemetry()?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
</script>
|
||||
21
src/components/rightSidePanel/errors/types.ts
Normal file
21
src/components/rightSidePanel/errors/types.ts
Normal file
@@ -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
|
||||
}
|
||||
236
src/components/rightSidePanel/errors/useErrorGroups.ts
Normal file
236
src/components/rightSidePanel/errors/useErrorGroups.ts
Normal file
@@ -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<string, ErrorCardData>
|
||||
}
|
||||
|
||||
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<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 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 processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
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<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 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[] {
|
||||
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 errorGroups = computed<ErrorGroup[]>(() =>
|
||||
buildErrorGroups(executionStore, t)
|
||||
)
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(errorGroups.value, query)
|
||||
})
|
||||
|
||||
return {
|
||||
errorGroups,
|
||||
filteredGroups
|
||||
}
|
||||
}
|
||||
@@ -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<LGraphNode | null>(() => {
|
||||
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<LGraphGroup | null>(() => {
|
||||
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
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">
|
||||
<i
|
||||
v-if="nodeHasError"
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'truncate',
|
||||
nodeHasError && 'text-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
@@ -177,6 +206,15 @@ defineExpose({
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="nodeHasError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@click.stop="navigateToErrorTab"
|
||||
>
|
||||
{{ t('rightSidePanel.seeError') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEmpty"
|
||||
variant="textonly"
|
||||
|
||||
56
src/composables/canvas/useFocusNode.ts
Normal file
56
src/composables/canvas/useFocusNode.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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 { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return
|
||||
|
||||
if (canvas.graph !== targetGraph) {
|
||||
canvas.subgraph = targetGraph.isRootGraph
|
||||
? undefined
|
||||
: (targetGraph as Subgraph)
|
||||
canvas.setGraph(targetGraph)
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Double RAF to wait for LiteGraph's internal canvas frame cycle
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function useFocusNode() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
async function focusNode(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(nodeId: string) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
|
||||
return {
|
||||
focusNode,
|
||||
enterSubgraph
|
||||
}
|
||||
}
|
||||
@@ -2977,6 +2977,21 @@
|
||||
"fallbackGroupTitle": "Group",
|
||||
"fallbackNodeTitle": "Node",
|
||||
"hideAdvancedInputsButton": "Hide advanced inputs",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"promptErrors": {
|
||||
"prompt_no_outputs": {
|
||||
"desc": "The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result."
|
||||
},
|
||||
"no_prompt": {
|
||||
"desc": "The workflow data sent to the server is empty. This may be an unexpected system error."
|
||||
}
|
||||
},
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
"errorHelpSupport": "contact our support",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllParameters": "Reset all parameters"
|
||||
},
|
||||
|
||||
@@ -350,6 +350,10 @@
|
||||
"name": "Batch count limit",
|
||||
"tooltip": "The maximum number of tasks added to the queue at one button click"
|
||||
},
|
||||
"Comfy_RightSidePanel_ShowErrorsTab": {
|
||||
"name": "Show errors tab in side panel",
|
||||
"tooltip": "When enabled, an errors tab is displayed in the right side panel to show workflow execution errors at a glance."
|
||||
},
|
||||
"Comfy_Sidebar_Location": {
|
||||
"name": "Sidebar location",
|
||||
"options": {
|
||||
|
||||
@@ -1221,5 +1221,16 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
category: ['Comfy', 'Error System'],
|
||||
name: 'Show errors tab in side panel',
|
||||
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,
|
||||
experimental: true,
|
||||
versionAdded: '1.40.0'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -135,7 +135,10 @@
|
||||
>
|
||||
<button
|
||||
v-if="hasAnyError"
|
||||
@click.stop="useRightSidePanelStore().openPanel('error')"
|
||||
@click.stop="
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
useRightSidePanelStore().openPanel('error')
|
||||
"
|
||||
>
|
||||
<span>{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
|
||||
@@ -89,7 +89,7 @@ function handleFocus(event: FocusEvent) {
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2"
|
||||
class="bg-transparent border-0 outline-0 ring-0 h-5 w-full my-1.5 mx-2 min-w-0"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
|
||||
:autofocus
|
||||
@focus="handleFocus"
|
||||
|
||||
@@ -204,6 +204,12 @@ const zPromptResponse = z.object({
|
||||
error: z.union([z.string(), zError])
|
||||
})
|
||||
|
||||
const zPromptError = z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
details: z.string()
|
||||
})
|
||||
|
||||
const zDeviceStats = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
@@ -433,12 +439,14 @@ const zSettings = z.object({
|
||||
'LiteGraph.Pointer.TrackpadGestures': z.boolean(),
|
||||
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
|
||||
'Comfy.RightSidePanel.IsOpen': z.boolean(),
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
export type ExtensionsResponse = z.infer<typeof zExtensionsResponse>
|
||||
export type PromptResponse = z.infer<typeof zPromptResponse>
|
||||
export type PromptError = z.infer<typeof zPromptError>
|
||||
export type NodeError = z.infer<typeof zNodeError>
|
||||
export type Settings = z.infer<typeof zSettings>
|
||||
export type DeviceStats = z.infer<typeof zDeviceStats>
|
||||
|
||||
@@ -70,6 +70,7 @@ 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'
|
||||
@@ -714,6 +715,10 @@ export class ComfyApp {
|
||||
} else {
|
||||
useDialogService().showExecutionErrorDialog(detail)
|
||||
}
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
this.canvas.deselectAll()
|
||||
useRightSidePanelStore().openPanel('errors')
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
})
|
||||
|
||||
@@ -1398,6 +1403,8 @@ export class ComfyApp {
|
||||
this.processingQueue = true
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
executionStore.lastPromptError = null
|
||||
|
||||
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
|
||||
const comfyOrgAuthToken = await useFirebaseAuthStore().getAuthToken()
|
||||
@@ -1468,6 +1475,37 @@ export class ComfyApp {
|
||||
|
||||
if (error instanceof PromptExecutionError) {
|
||||
executionStore.lastNodeErrors = error.response.node_errors ?? null
|
||||
|
||||
// Store prompt-level error separately only when no node-specific errors exist,
|
||||
// because node errors already carry the full context. Prompt-level errors
|
||||
// (e.g. prompt_no_outputs, no_prompt) lack node IDs and need their own path.
|
||||
const nodeErrors = error.response.node_errors
|
||||
const hasNodeErrors =
|
||||
nodeErrors && Object.keys(nodeErrors).length > 0
|
||||
|
||||
if (!hasNodeErrors) {
|
||||
const respError = error.response.error
|
||||
if (respError && typeof respError === 'object') {
|
||||
executionStore.lastPromptError = {
|
||||
type: respError.type,
|
||||
message: respError.message,
|
||||
details: respError.details ?? ''
|
||||
}
|
||||
} else if (typeof respError === 'string') {
|
||||
executionStore.lastPromptError = {
|
||||
type: 'error',
|
||||
message: respError,
|
||||
details: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
}
|
||||
break
|
||||
@@ -1845,6 +1883,7 @@ export class ComfyApp {
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
executionStore.lastPromptError = null
|
||||
|
||||
useDomWidgetStore().clear()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -25,7 +26,8 @@ import type {
|
||||
NotificationWsMessage,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
ProgressWsMessage
|
||||
ProgressWsMessage,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -113,6 +115,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const lastPromptError = ref<PromptError | null>(null)
|
||||
// This is the progress of all nodes in the currently executing workflow
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
const nodeProgressStatesByPrompt = ref<
|
||||
@@ -287,6 +290,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
activePromptId.value = e.detail.prompt_id
|
||||
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
|
||||
clearInitializationByPromptId(activePromptId.value)
|
||||
@@ -462,6 +466,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
activePromptId.value = null
|
||||
_executingNodeProgress.value = null
|
||||
lastPromptError.value = null
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
@@ -641,6 +646,49 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Whether a runtime execution error is present */
|
||||
const hasExecutionError = computed(() => !!lastExecutionError.value)
|
||||
|
||||
/** Whether a prompt-level error is present (e.g. invalid_prompt, prompt_no_outputs) */
|
||||
const hasPromptError = computed(() => !!lastPromptError.value)
|
||||
|
||||
/** Whether any node validation errors are present */
|
||||
const hasNodeError = computed(
|
||||
() => !!lastNodeErrors.value && !isEmpty(lastNodeErrors.value)
|
||||
)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
||||
const hasAnyError = computed(
|
||||
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
|
||||
)
|
||||
|
||||
/** Pre-computed Set of graph node IDs (as strings) that have errors. */
|
||||
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
|
||||
const activeGraph = useCanvasStore().currentGraph ?? app.rootGraph
|
||||
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastExecutionError.value) {
|
||||
const execNodeId = String(lastExecutionError.value.node_id)
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, execNodeId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -648,6 +696,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
queuedPrompts,
|
||||
lastNodeErrors,
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
hasAnyError,
|
||||
lastExecutionErrorNodeId,
|
||||
executingNodeId,
|
||||
executingNodeIds,
|
||||
@@ -679,6 +729,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
promptIdToWorkflowId,
|
||||
// Node error lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError
|
||||
slotHasError,
|
||||
activeGraphErrorNodeIds
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ export type RightSidePanelTab =
|
||||
| 'settings'
|
||||
| 'info'
|
||||
| 'subgraph'
|
||||
| 'errors'
|
||||
|
||||
type RightSidePanelSection = 'advanced-inputs' | string
|
||||
|
||||
@@ -33,6 +34,12 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
|
||||
const activeTab = ref<RightSidePanelTab>('parameters')
|
||||
const isEditingSubgraph = computed(() => activeTab.value === 'subgraph')
|
||||
const focusedSection = ref<RightSidePanelSection | null>(null)
|
||||
/**
|
||||
* Graph node ID to focus in the errors tab.
|
||||
* Set by SectionWidgets when the user clicks "See Error", consumed and
|
||||
* cleared by TabErrors after expanding the relevant error group.
|
||||
*/
|
||||
const focusedErrorNodeId = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Auto-close panel when switching to legacy menu mode
|
||||
@@ -80,6 +87,7 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
|
||||
activeTab,
|
||||
isEditingSubgraph,
|
||||
focusedSection,
|
||||
focusedErrorNodeId,
|
||||
searchQuery,
|
||||
openPanel,
|
||||
closePanel,
|
||||
|
||||
Reference in New Issue
Block a user