mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 06:49:37 +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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user