From b1e6324d600be9da5e1bb2569fd8cd3d4f397a77 Mon Sep 17 00:00:00 2001 From: jaeone94 <89377375+jaeone94@users.noreply.github.com> Date: Wed, 27 May 2026 00:15:20 +0900 Subject: [PATCH] feat: add special runtime error messaging --- .../rightSidePanel/errors/TabErrors.test.ts | 9 +- .../errors/useErrorGroups.test.ts | 74 +- .../rightSidePanel/errors/useErrorGroups.ts | 9 +- src/locales/en/main.json | 195 ++++- src/platform/errorCatalog/catalogI18n.ts | 40 + src/platform/errorCatalog/catalogIds.ts | 32 + .../errorCatalog/errorMessageResolver.test.ts | 756 +++++++++++++++++- .../errorCatalog/errorMessageResolver.ts | 522 +----------- .../errorCatalog/executionErrorResolver.ts | 37 + .../errorCatalog/missingErrorResolver.ts | 78 ++ .../errorCatalog/promptErrorResolver.ts | 72 ++ src/platform/errorCatalog/runtimeErrorCopy.ts | 136 ++++ .../errorCatalog/runtimeErrorMatcher.ts | 397 +++++++++ src/platform/errorCatalog/types.ts | 12 +- .../errorCatalog/validationErrorResolver.ts | 347 ++++++++ 15 files changed, 2181 insertions(+), 535 deletions(-) create mode 100644 src/platform/errorCatalog/catalogI18n.ts create mode 100644 src/platform/errorCatalog/catalogIds.ts create mode 100644 src/platform/errorCatalog/executionErrorResolver.ts create mode 100644 src/platform/errorCatalog/missingErrorResolver.ts create mode 100644 src/platform/errorCatalog/promptErrorResolver.ts create mode 100644 src/platform/errorCatalog/runtimeErrorCopy.ts create mode 100644 src/platform/errorCatalog/runtimeErrorMatcher.ts create mode 100644 src/platform/errorCatalog/validationErrorResolver.ts diff --git a/src/components/rightSidePanel/errors/TabErrors.test.ts b/src/components/rightSidePanel/errors/TabErrors.test.ts index 1c7e89951b..1c443c939e 100644 --- a/src/components/rightSidePanel/errors/TabErrors.test.ts +++ b/src/components/rightSidePanel/errors/TabErrors.test.ts @@ -167,7 +167,10 @@ describe('TabErrors.vue', () => { expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1) expect(screen.getByText('#10')).toBeInTheDocument() - expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument() + expect(screen.getByText('Execution failed')).toBeInTheDocument() + expect( + screen.getByText('Node threw an error during execution.') + ).toBeInTheDocument() expect(screen.getByText(/Line 1/)).toBeInTheDocument() }) @@ -246,9 +249,9 @@ describe('TabErrors.vue', () => { }) expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1) - expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument() + expect(screen.getByText('Execution failed')).toBeInTheDocument() expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument() - expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1) + expect(screen.getAllByText('Execution failed')).toHaveLength(1) }) it('shows missing model Refresh in the section header when no model is downloadable', async () => { diff --git a/src/components/rightSidePanel/errors/useErrorGroups.test.ts b/src/components/rightSidePanel/errors/useErrorGroups.test.ts index 5599403700..bce969afc5 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.test.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.test.ts @@ -46,7 +46,23 @@ vi.mock('@/i18n', () => { 'errorCatalog.promptErrors.prompt_no_outputs.title': 'Prompt has no outputs', 'errorCatalog.promptErrors.prompt_no_outputs.desc': - 'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.' + 'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.', + 'errorCatalog.runtimeErrors.noCreditsCharged': 'No credits charged.', + 'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed', + 'errorCatalog.runtimeErrors.execution_failed.message': + 'Node threw an error during execution.', + 'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}', + 'errorCatalog.runtimeErrors.execution_failed.toastTitle': + '{nodeName} failed', + 'errorCatalog.runtimeErrors.execution_failed.toastMessage': + 'This node threw an error during execution. Check its inputs or try a different configuration.', + 'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed', + 'errorCatalog.runtimeErrors.out_of_memory.message': + 'Not enough GPU memory. Try reducing image resolution or batch size and run again.', + 'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}', + 'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed', + 'errorCatalog.runtimeErrors.out_of_memory.toastMessage': + 'Not enough GPU memory. Try reducing image resolution or batch size and run again.' } const interpolate = ( @@ -158,6 +174,7 @@ function createErrorGroups() { describe('useErrorGroups', () => { beforeEach(() => { setActivePinia(createPinia()) + mockIsCloud.value = false }) describe('missingPackGroups', () => { @@ -421,7 +438,8 @@ describe('useErrorGroups', () => { ) }) - it('includes execution error from runtime errors', async () => { + it('uses credit-note general display fields for unknown runtime execution errors', async () => { + mockIsCloud.value = true const { store, groups } = createErrorGroups() store.lastExecutionError = { prompt_id: 'test-prompt', @@ -430,7 +448,7 @@ describe('useErrorGroups', () => { node_type: 'KSampler', executed: [], exception_type: 'RuntimeError', - exception_message: 'CUDA out of memory', + exception_message: 'mat1 and mat2 shapes cannot be multiplied', traceback: ['line 1', 'line 2'], current_inputs: {}, current_outputs: {} @@ -443,15 +461,53 @@ describe('useErrorGroups', () => { expect(execGroups.length).toBeGreaterThan(0) if (execGroups[0].type !== 'execution') return expect(execGroups[0].cards[0].errors[0]).toMatchObject({ - message: 'RuntimeError: CUDA out of memory', + message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied', details: 'line 1\nline 2', isRuntimeError: true, - exceptionType: 'RuntimeError' + exceptionType: 'RuntimeError', + catalogId: 'execution_failed', + displayTitle: 'Execution failed', + displayMessage: + 'Node threw an error during execution. No credits charged.', + displayItemLabel: 'KSampler', + toastTitle: 'KSampler failed', + toastMessage: + 'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.' }) - // TODO(FE-816 overlay-redesign): Runtime execution errors intentionally - // bypass catalog display fields until targeted runtime handling lands. - expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined() - expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined() + }) + + it('adds display fields for targeted runtime execution errors', async () => { + mockIsCloud.value = true + const { store, groups } = createErrorGroups() + store.lastExecutionError = { + prompt_id: 'test-prompt', + timestamp: Date.now(), + node_id: 5, + node_type: 'KSampler', + executed: [], + exception_type: 'torch.OutOfMemoryError', + exception_message: + 'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.', + traceback: ['line 1', 'line 2'], + current_inputs: {}, + current_outputs: {} + } + await nextTick() + + const execGroup = groups.allErrorGroups.value.find( + (g) => g.type === 'execution' + ) + expect(execGroup?.type).toBe('execution') + if (execGroup?.type !== 'execution') return + + const error = execGroup.cards[0].errors[0] + expect(error.message).toContain('torch.OutOfMemoryError:') + expect(error.catalogId).toBe('out_of_memory') + expect(error.displayMessage).toBe( + 'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.' + ) + expect(error.displayItemLabel).toBe('KSampler') + expect(error.toastTitle).toBe('Generation failed') }) it('includes prompt error when present', async () => { diff --git a/src/components/rightSidePanel/errors/useErrorGroups.ts b/src/components/rightSidePanel/errors/useErrorGroups.ts index 93176f31d3..db7cca0e9f 100644 --- a/src/components/rightSidePanel/errors/useErrorGroups.ts +++ b/src/components/rightSidePanel/errors/useErrorGroups.ts @@ -427,7 +427,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter) { message: `${e.exception_type}: ${e.exception_message}`, details: e.traceback.join('\n'), isRuntimeError: true, - exceptionType: e.exception_type + exceptionType: e.exception_type, + ...resolveRunErrorMessage({ + kind: 'execution', + error: e, + nodeDisplayName: + resolveNodeInfo(String(e.node_id)).title || e.node_type, + isCloud + }) } ], filterBySelection diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 32665ebc86..f3019d0145 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -3774,6 +3774,192 @@ "toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again." } }, + "runtimeErrors": { + "noCreditsCharged": "No credits charged.", + "execution_failed": { + "title": "Execution failed", + "message": "Node threw an error during execution.", + "itemLabel": "{nodeName}", + "toastTitle": "{nodeName} failed", + "toastMessage": "This node threw an error during execution. Check its inputs or try a different configuration." + }, + "image_not_loaded": { + "title": "Image not loaded", + "message": "The system couldn't load this image.", + "itemLabel": "{nodeName}", + "toastTitle": "Input image couldn't be loaded", + "toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again." + }, + "out_of_memory": { + "title": "Generation failed", + "message": "Not enough GPU memory. Try reducing image resolution or batch size and run again.", + "itemLabel": "{nodeName}", + "toastTitle": "Generation failed", + "toastMessage": "Not enough GPU memory. Try reducing image resolution or batch size and run again." + }, + "content_blocked": { + "title": "Content blocked", + "message": "This request was blocked by the content moderation system. Try changing the prompt or inputs.", + "itemLabel": "{nodeName}", + "toastTitle": "Content blocked", + "toastMessage": "This request was blocked by the content moderation system. Try changing the prompt or inputs." + }, + "access_required": { + "title": "Access required", + "message": "This run requires access that is not available for the current account.", + "itemLabel": "{nodeName}", + "toastTitle": "Access required", + "toastMessage": "This run requires access that is not available for the current account." + }, + "model_access_error": { + "title": "Model access required", + "message": "One or more required models could not be accessed.", + "itemLabel": "{nodeName}", + "toastTitle": "Model access required", + "toastMessage": "One or more required models could not be accessed." + }, + "invalid_clip_input": { + "title": "Invalid CLIP input", + "message": "The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.", + "itemLabel": "{nodeName}", + "toastTitle": "Invalid CLIP input", + "toastMessage": "{nodeName} has a missing or invalid CLIP input." + }, + "invalid_prompt": { + "title": "Prompt is empty", + "message": "Enter a prompt before running this workflow.", + "itemLabel": "{nodeName}", + "toastTitle": "Prompt is empty", + "toastMessage": "Enter a prompt before running this workflow." + }, + "invalid_workflow_request": { + "title": "Invalid workflow request", + "message": "The workflow request is invalid. Check the workflow and try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Invalid workflow request", + "toastMessage": "The workflow request is invalid. Check the workflow and try again." + }, + "insufficient_credits": { + "title": "Insufficient credits", + "message": "Add credits to your account to use this node.", + "itemLabel": "{nodeName}", + "toastTitle": "Insufficient credits", + "toastMessage": "Add credits to your account to use this node." + }, + "workspace_insufficient_credits": { + "title": "Insufficient credits", + "message": "Add credits to your workspace to continue.", + "itemLabel": "{nodeName}", + "toastTitle": "Insufficient credits", + "toastMessage": "Add credits to your workspace to continue." + }, + "subscription_required": { + "title": "Subscription required", + "message": "Subscribe to a plan to continue running this workflow.", + "itemLabel": "{nodeName}", + "toastTitle": "Subscription required", + "toastMessage": "Subscribe to a plan to continue running this workflow." + }, + "subscription_upgrade_required": { + "title": "Subscription upgrade required", + "message": "Upgrade your subscription to use the private models in this workflow.", + "details": "Private models require a subscription upgrade: {modelNames}", + "itemLabel": "{nodeName}", + "toastTitle": "Subscription upgrade required", + "toastMessage": "Upgrade your subscription to use these private models: {modelNames}." + }, + "model_download_failed": { + "title": "Model download failed", + "message": "A model could not be downloaded. Try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Model download failed", + "toastMessage": "A model could not be downloaded. Try again." + }, + "unexpected_service_error": { + "title": "Service error", + "message": "The service encountered an unexpected error. Try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Service error", + "toastMessage": "The service encountered an unexpected error. Try again." + }, + "request_failed": { + "title": "Request failed", + "message": "The request failed before the run could complete. Try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Request failed", + "toastMessage": "The request failed before the run could complete. Try again." + }, + "run_start_failed": { + "title": "Run could not start", + "message": "The run could not be started. Try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Run could not start", + "toastMessage": "The run could not be started. Try again." + }, + "run_ended_unexpectedly": { + "title": "Run ended unexpectedly", + "message": "The run ended unexpectedly. Try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Run ended unexpectedly", + "toastMessage": "The run ended unexpectedly. Try again." + }, + "sign_in_required": { + "title": "Sign in required", + "message": "Partner nodes require a Comfy account. Sign in to continue.", + "itemLabel": "{nodeName}", + "toastTitle": "Sign in to use this node", + "toastMessage": "Partner nodes require a Comfy account. Sign in to continue." + }, + "rate_limited": { + "title": "Servers are busy", + "message": "High demand right now. Try again in a moment.", + "itemLabel": "{nodeName}", + "toastTitle": "Servers are busy", + "toastMessage": "High demand right now. Try again in a moment." + }, + "timeout": { + "title": "Generation timed out", + "message": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.", + "itemLabel": "{nodeName}", + "toastTitle": "Generation timed out", + "toastMessage": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again." + }, + "generation_stalled": { + "title": "Generation stalled", + "message": "This workflow stopped making progress. Try running it again.", + "itemLabel": "{nodeName}", + "toastTitle": "Generation stalled", + "toastMessage": "This workflow stopped making progress. Try running it again." + }, + "preprocessing_failed": { + "title": "Preparation failed", + "message": "The workflow could not be prepared. Try running it again.", + "itemLabel": "{nodeName}", + "toastTitle": "Preparation failed", + "toastMessage": "The workflow could not be prepared. Try running it again." + }, + "preprocessing_timeout": { + "title": "Preparation timed out", + "message": "The workflow took too long to prepare. Try running it again.", + "itemLabel": "{nodeName}", + "toastTitle": "Preparation timed out", + "toastMessage": "The workflow took too long to prepare. Try running it again." + }, + "server_crashed": { + "title": "Server crashed", + "message": "The server stopped while running this workflow. Try again.", + "itemLabel": "{nodeName}", + "toastTitle": "Server crashed", + "toastMessage": "The server stopped while running this workflow. Try again." + }, + "server_busy": { + "title": "Servers are busy", + "message": "The servers are busy right now. Try again in a moment.", + "itemLabel": "{nodeName}", + "toastTitle": "Servers are busy", + "toastMessage": "The servers are busy right now. Try again in a moment." + } + }, "promptErrors": { "prompt_no_outputs": { "title": "Prompt has no outputs", @@ -3798,15 +3984,6 @@ "prompt_outputs_failed_validation": { "title": "Prompt validation failed", "desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again." - }, - "image_not_loaded": { - "title": "Image not loaded", - "desc": "The system couldn't load this image." - }, - "out_of_memory": { - "title": "Generation failed", - "descLocal": "Not enough GPU memory. Try reducing complexity and run again.", - "descCloud": "Not enough GPU memory. Try reducing complexity and run again. No credits charged." } } }, diff --git a/src/platform/errorCatalog/catalogI18n.ts b/src/platform/errorCatalog/catalogI18n.ts new file mode 100644 index 0000000000..83c21707e7 --- /dev/null +++ b/src/platform/errorCatalog/catalogI18n.ts @@ -0,0 +1,40 @@ +import { t, te } from '@/i18n' + +// Shared i18n helpers for error catalog resolvers. These preserve the raw API +// message/details as fallbacks when a catalog key is not available. Keep this +// module folder-internal so UI code only consumes resolved display fields. +export interface ErrorResolveContext { + isCloud?: boolean + nodeDisplayName?: string +} + +export type CatalogParams = Record + +export function translateCatalogMessage( + key: string, + fallback: string, + params?: CatalogParams +): string { + if (te(key)) return params ? t(key, params) : t(key) + if (!params) return fallback + + return fallback.replace(/\{(\w+)\}/g, (match, paramName) => + params[paramName] === undefined ? match : String(params[paramName]) + ) +} + +export function translateOptionalCatalogMessage( + key: string, + fallback?: string, + params?: CatalogParams +): string | undefined { + if (te(key)) return params ? t(key, params) : t(key) + return fallback?.trim() ? fallback : undefined +} + +export function normalizeNodeName(nodeDisplayName: string | undefined): string { + return ( + nodeDisplayName?.trim() || + translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node') + ) +} diff --git a/src/platform/errorCatalog/catalogIds.ts b/src/platform/errorCatalog/catalogIds.ts new file mode 100644 index 0000000000..2862d99917 --- /dev/null +++ b/src/platform/errorCatalog/catalogIds.ts @@ -0,0 +1,32 @@ +// FE-resolved catalog IDs that either normalize multiple sources or do not map +// 1:1 to an API error type. Simple validation mappings stay with the validation +// resolver. +export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection' +export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed' +export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded' +export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory' +export const CONTENT_BLOCKED_CATALOG_ID = 'content_blocked' +export const ACCESS_REQUIRED_CATALOG_ID = 'access_required' +export const MODEL_ACCESS_ERROR_CATALOG_ID = 'model_access_error' +export const INVALID_CLIP_INPUT_CATALOG_ID = 'invalid_clip_input' +export const INVALID_PROMPT_CATALOG_ID = 'invalid_prompt' +export const INVALID_WORKFLOW_REQUEST_CATALOG_ID = 'invalid_workflow_request' +export const INSUFFICIENT_CREDITS_CATALOG_ID = 'insufficient_credits' +export const WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID = + 'workspace_insufficient_credits' +export const SUBSCRIPTION_REQUIRED_CATALOG_ID = 'subscription_required' +export const SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID = + 'subscription_upgrade_required' +export const MODEL_DOWNLOAD_FAILED_CATALOG_ID = 'model_download_failed' +export const UNEXPECTED_SERVICE_ERROR_CATALOG_ID = 'unexpected_service_error' +export const REQUEST_FAILED_CATALOG_ID = 'request_failed' +export const RUN_START_FAILED_CATALOG_ID = 'run_start_failed' +export const RUN_ENDED_UNEXPECTEDLY_CATALOG_ID = 'run_ended_unexpectedly' +export const SIGN_IN_REQUIRED_CATALOG_ID = 'sign_in_required' +export const RATE_LIMITED_CATALOG_ID = 'rate_limited' +export const TIMEOUT_CATALOG_ID = 'timeout' +export const GENERATION_STALLED_CATALOG_ID = 'generation_stalled' +export const PREPROCESSING_FAILED_CATALOG_ID = 'preprocessing_failed' +export const PREPROCESSING_TIMEOUT_CATALOG_ID = 'preprocessing_timeout' +export const SERVER_CRASHED_CATALOG_ID = 'server_crashed' +export const SERVER_BUSY_CATALOG_ID = 'server_busy' diff --git a/src/platform/errorCatalog/errorMessageResolver.test.ts b/src/platform/errorCatalog/errorMessageResolver.test.ts index fdb988b5fa..7a987b1c1c 100644 --- a/src/platform/errorCatalog/errorMessageResolver.test.ts +++ b/src/platform/errorCatalog/errorMessageResolver.test.ts @@ -5,6 +5,7 @@ import { resolveRunErrorMessage } from './errorMessageResolver' import type { NodeValidationError } from './types' +import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema' import { i18n } from '@/i18n' function nodeValidationError( @@ -36,6 +37,24 @@ function requiredInputMissing(inputName?: string): NodeValidationError { } } +function executionError( + exceptionType: string, + exceptionMessage: string +): ExecutionErrorWsMessage { + return { + prompt_id: 'prompt-1', + timestamp: Date.now(), + node_id: '1', + node_type: 'KSampler', + executed: [], + exception_type: exceptionType, + exception_message: exceptionMessage, + traceback: [], + current_inputs: {}, + current_outputs: {} + } +} + describe('errorMessageResolver', () => { it('resolves required_input_missing to missing connection display copy', () => { const result = resolveRunErrorMessage({ @@ -533,7 +552,8 @@ describe('errorMessageResolver', () => { catalogId: 'out_of_memory', displayTitle: 'Generation failed', displayMessage: - 'Not enough GPU memory. Try reducing complexity and run again. No credits charged.' + 'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.', + displayDetails: 'Workflow execution failed' }) expect( @@ -550,7 +570,8 @@ describe('errorMessageResolver', () => { catalogId: 'out_of_memory', displayTitle: 'Generation failed', displayMessage: - 'Not enough GPU memory. Try reducing complexity and run again.' + 'Not enough GPU memory. Try reducing image resolution or batch size and run again.', + displayDetails: 'Workflow execution failed' }) expect( @@ -566,7 +587,9 @@ describe('errorMessageResolver', () => { ).toEqual({ catalogId: 'image_not_loaded', displayTitle: 'Image not loaded', - displayMessage: "The system couldn't load this image." + displayMessage: + "The system couldn't load this image. No credits charged.", + displayDetails: 'Failed to validate images' }) expect( @@ -586,6 +609,733 @@ describe('errorMessageResolver', () => { }) }) + it('resolves targeted runtime execution errors', () => { + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'KSampler', + error: executionError( + 'torch.OutOfMemoryError', + 'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.' + ) + }) + ).toEqual({ + catalogId: 'out_of_memory', + displayTitle: 'Generation failed', + displayMessage: + 'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.', + displayDetails: + 'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.', + displayItemLabel: 'KSampler', + toastTitle: 'Generation failed', + toastMessage: + 'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.' + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'Load Image', + error: executionError('ImageDownloadError', 'Failed to validate images') + }) + ).toMatchObject({ + catalogId: 'image_not_loaded', + displayTitle: 'Image not loaded', + displayMessage: + "The system couldn't load this image. No credits charged.", + displayItemLabel: 'Load Image', + toastTitle: "Input image couldn't be loaded", + toastMessage: + "The image for Load Image couldn't be loaded. Try adding it again. No credits charged." + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'Load Image', + error: executionError( + 'IsADirectoryError', + "[Errno 21] Is a directory: '/app/comfyui/input'" + ) + }) + ).toMatchObject({ + catalogId: 'image_not_loaded', + displayTitle: 'Image not loaded', + displayMessage: + "The system couldn't load this image. No credits charged.", + displayItemLabel: 'Load Image' + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'File Reader', + error: executionError( + 'RuntimeError', + "[Errno 21] Is a directory: '/tmp/not-an-input-image'" + ) + }) + ).toMatchObject({ + catalogId: 'execution_failed', + displayTitle: 'Execution failed' + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: false, + nodeDisplayName: 'CLIP Text Encode', + error: executionError( + 'RuntimeError', + 'ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model.' + ) + }) + ).toMatchObject({ + catalogId: 'invalid_clip_input', + displayTitle: 'Invalid CLIP input', + displayMessage: + 'The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.', + displayItemLabel: 'CLIP Text Encode', + toastMessage: 'CLIP Text Encode has a missing or invalid CLIP input.' + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'KSampler', + error: executionError( + 'OOMError', + 'Workflow execution failed due to insufficient memory (OOM). Try reducing image resolution or batch size.' + ) + }) + ).toMatchObject({ + catalogId: 'out_of_memory', + displayTitle: 'Generation failed', + displayMessage: + 'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.', + displayItemLabel: 'KSampler' + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'KSampler', + error: executionError( + 'RuntimeError', + 'CUDA out of memory. Tried to allocate 6.00 GiB. GPU 0 has 2.00 GiB free.' + ) + }) + ).toMatchObject({ + catalogId: 'out_of_memory', + displayTitle: 'Generation failed', + displayDetails: + 'CUDA out of memory. Tried to allocate 6.00 GiB. GPU 0 has 2.00 GiB free.' + }) + + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: false, + nodeDisplayName: 'KSampler', + error: executionError('RuntimeError', 'GPU out of memory') + }) + ).toMatchObject({ + catalogId: 'out_of_memory', + displayMessage: + 'Not enough GPU memory. Try reducing image resolution or batch size and run again.', + displayDetails: 'GPU out of memory' + }) + }) + + it.for([ + { + type: 'InsufficientFundsError', + message: + 'Payment Required: Please add credits to your account to use this node.', + expected: { + catalogId: 'insufficient_credits', + displayTitle: 'Insufficient credits', + displayMessage: + 'Add credits to your account to use this node. No credits charged.' + } + }, + { + type: 'InsufficientFundsError', + message: + 'Payment Required: Please add credits to your workspace to continue.', + expected: { + catalogId: 'workspace_insufficient_credits', + displayTitle: 'Insufficient credits', + displayMessage: + 'Add credits to your workspace to continue. No credits charged.' + } + }, + { + type: 'InactiveSubscriptionError', + message: + 'User has no active subscription. Please subscribe to a plan to continue.', + expected: { + catalogId: 'subscription_required', + displayTitle: 'Subscription required', + displayMessage: + 'Subscribe to a plan to continue running this workflow. No credits charged.' + } + }, + { + type: 'RuntimeError', + message: + 'the following private models require a subscription upgrade: Skullgirls_Cerebella.safetensors', + expected: { + catalogId: 'subscription_upgrade_required', + displayTitle: 'Subscription upgrade required', + displayMessage: + 'Upgrade your subscription to use the private models in this workflow. No credits charged.', + displayDetails: + 'Private models require a subscription upgrade: Skullgirls_Cerebella.safetensors', + toastMessage: + 'Upgrade your subscription to use these private models: Skullgirls_Cerebella.safetensors. No credits charged.' + } + }, + { + type: 'RuntimeError', + message: 'Unauthorized: Please login first to use this node.', + expected: { + catalogId: 'sign_in_required', + displayTitle: 'Sign in required', + displayMessage: + 'Partner nodes require a Comfy account. Sign in to continue. No credits charged.' + } + }, + { + type: 'RuntimeError', + message: + 'Rate Limit Exceeded: The server returned 429 after all retry attempts. Please wait and try again.', + expected: { + catalogId: 'rate_limited', + displayTitle: 'Servers are busy', + displayMessage: + 'High demand right now. Try again in a moment. No credits charged.' + } + } + ])( + 'resolves $type runtime execution errors by stable copy', + ({ type, message, expected }) => { + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'API Node', + error: executionError(type, message) + }) + ).toMatchObject({ + ...expected, + displayItemLabel: 'API Node' + }) + } + ) + + it.for([ + { + type: 'ServiceError', + message: 'ServiceError: Job execution time exceeded maximum limit', + expected: { + catalogId: 'timeout', + displayTitle: 'Generation timed out', + displayMessage: + 'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again. No credits charged.' + } + }, + { + type: 'ServiceError', + message: 'ServiceError: Job went too long without making any progress', + expected: { + catalogId: 'generation_stalled', + displayTitle: 'Generation stalled', + displayMessage: + 'This workflow stopped making progress. Try running it again. No credits charged.' + } + }, + { + type: 'ServiceError', + message: 'ServiceError: Job has stagnated', + expected: { + catalogId: 'generation_stalled', + displayTitle: 'Generation stalled', + displayMessage: + 'This workflow stopped making progress. Try running it again. No credits charged.' + } + }, + { + type: 'ServiceError', + message: 'ServiceError: RIP to the server your workflow was running on.', + expected: { + catalogId: 'server_crashed', + displayTitle: 'Server crashed', + displayMessage: + 'The server stopped while running this workflow. Try again. No credits charged.' + } + }, + { + type: 'ServiceError', + message: 'ServiceError: Executor is busy with another job', + expected: { + catalogId: 'server_busy', + displayTitle: 'Servers are busy', + displayMessage: + 'The servers are busy right now. Try again in a moment. No credits charged.' + } + }, + { + type: 'DispatcherError', + message: 'DispatcherError: Preprocessing timed out', + expected: { + catalogId: 'preprocessing_timeout', + displayTitle: 'Preparation timed out', + displayMessage: + 'The workflow took too long to prepare. Try running it again. No credits charged.' + } + }, + { + type: 'DispatcherError', + message: 'DispatcherError: Preprocessing failed', + expected: { + catalogId: 'preprocessing_failed', + displayTitle: 'Preparation failed', + displayMessage: + 'The workflow could not be prepared. Try running it again. No credits charged.' + } + }, + { + type: 'DispatcherError', + message: 'DispatcherError: Preprocessing failed: input archive missing', + expected: { + catalogId: 'preprocessing_failed', + displayTitle: 'Preparation failed', + displayMessage: + 'The workflow could not be prepared. Try running it again. No credits charged.', + displayDetails: 'Preprocessing failed: input archive missing' + } + }, + { + type: 'AccessRequired', + message: + 'AccessRequired: This run requires access that is not available for the current account.', + expected: { + catalogId: 'access_required', + displayTitle: 'Access required', + displayMessage: + 'This run requires access that is not available for the current account. No credits charged.' + } + }, + { + type: 'ModelAccessError', + message: + 'ModelAccessError: One or more required models could not be accessed.', + expected: { + catalogId: 'model_access_error', + displayTitle: 'Model access required', + displayMessage: + 'One or more required models could not be accessed. No credits charged.' + } + }, + { + type: 'ValidationError', + message: + "ValidationError: Field 'prompt' cannot be shorter than 1 characters; was 0 characters long.", + expected: { + catalogId: 'invalid_prompt', + displayTitle: 'Prompt is empty', + displayMessage: + 'Enter a prompt before running this workflow. No credits charged.' + } + }, + { + type: 'ValidationError', + message: "ValidationError: Field 'prompt' cannot be empty.", + expected: { + catalogId: 'invalid_prompt', + displayTitle: 'Prompt is empty', + displayMessage: + 'Enter a prompt before running this workflow. No credits charged.' + } + }, + { + type: 'ValidationError', + message: 'ValidationError: The workflow request is invalid.', + expected: { + catalogId: 'invalid_workflow_request', + displayTitle: 'Invalid workflow request', + displayMessage: + 'The workflow request is invalid. Check the workflow and try again. No credits charged.' + } + }, + { + type: 'ValidationError', + message: 'ValidationError: Invalid job: missing workflow', + expected: { + catalogId: 'invalid_workflow_request', + displayTitle: 'Invalid workflow request', + displayMessage: + 'The workflow request is invalid. Check the workflow and try again. No credits charged.' + } + }, + { + type: 'ValidationError', + message: "ValidationError: Invalid workflow: missing 'prompt' field", + expected: { + catalogId: 'invalid_workflow_request', + displayTitle: 'Invalid workflow request', + displayMessage: + 'The workflow request is invalid. Check the workflow and try again. No credits charged.' + } + }, + { + type: 'ValidationError', + message: + "ValidationError: Invalid workflow: 'prompt' field must be an object", + expected: { + catalogId: 'invalid_workflow_request', + displayTitle: 'Invalid workflow request', + displayMessage: + 'The workflow request is invalid. Check the workflow and try again. No credits charged.' + } + }, + { + type: 'ModelDownloadError', + message: + 'ModelDownloadError: the following private models require a subscription upgrade: Skullgirls_Cerebella.safetensors, alex_ahad_style_ponyxl.safetensors', + expected: { + catalogId: 'subscription_upgrade_required', + displayTitle: 'Subscription upgrade required', + displayDetails: + 'Private models require a subscription upgrade: Skullgirls_Cerebella.safetensors, alex_ahad_style_ponyxl.safetensors' + } + }, + { + type: 'PanicError', + message: + 'PanicError: internal error during model download: runtime error: invalid memory address', + expected: { + catalogId: 'model_download_failed', + displayTitle: 'Model download failed', + displayMessage: + 'A model could not be downloaded. Try again. No credits charged.' + } + }, + { + type: 'PanicError', + message: 'PanicError: internal error during model download: boom', + expected: { + catalogId: 'model_download_failed', + displayTitle: 'Model download failed', + displayMessage: + 'A model could not be downloaded. Try again. No credits charged.' + } + }, + { + type: 'PanicError', + message: 'PanicError: panic during job execution: boom', + expected: { + catalogId: 'run_ended_unexpectedly', + displayTitle: 'Run ended unexpectedly', + displayMessage: + 'The run ended unexpectedly. Try again. No credits charged.' + } + }, + { + type: 'UnexpectedServiceError', + message: 'UnexpectedServiceError: Unexpected service error.', + expected: { + catalogId: 'unexpected_service_error', + displayTitle: 'Service error', + displayMessage: + 'The service encountered an unexpected error. Try again. No credits charged.' + } + }, + { + type: 'RequestError', + message: + 'RequestError: The request failed before the run could complete.', + expected: { + catalogId: 'request_failed', + displayTitle: 'Request failed', + displayMessage: + 'The request failed before the run could complete. Try again. No credits charged.' + } + }, + { + type: 'PreprocessingTimeout', + message: 'PreprocessingTimeout: Preprocessing timed out.', + expected: { + catalogId: 'preprocessing_timeout', + displayTitle: 'Preparation timed out', + displayMessage: + 'The workflow took too long to prepare. Try running it again. No credits charged.' + } + }, + { + type: 'ServiceError', + message: 'ServiceError: The run could not be started.', + expected: { + catalogId: 'run_start_failed', + displayTitle: 'Run could not start', + displayMessage: + 'The run could not be started. Try again. No credits charged.' + } + }, + { + type: 'WebSocketError', + message: 'WebSocketError: Failed to start WebSocket client: EOF', + expected: { + catalogId: 'run_start_failed', + displayTitle: 'Run could not start', + displayMessage: + 'The run could not be started. Try again. No credits charged.', + displayDetails: 'Failed to start WebSocket client: EOF' + } + }, + { + type: 'ServiceError', + message: + 'ServiceError: Failed to send prompt request: connection refused', + expected: { + catalogId: 'request_failed', + displayTitle: 'Request failed', + displayMessage: + 'The request failed before the run could complete. Try again. No credits charged.', + displayDetails: 'Failed to send prompt request: connection refused' + } + }, + { + type: 'ServiceError', + message: + 'ServiceError: Failed to complete preparation: transition failed', + expected: { + catalogId: 'preprocessing_failed', + displayTitle: 'Preparation failed', + displayMessage: + 'The workflow could not be prepared. Try running it again. No credits charged.', + displayDetails: 'Failed to complete preparation: transition failed' + } + }, + { + type: 'ServiceError', + message: 'ServiceError: The run ended unexpectedly.', + expected: { + catalogId: 'run_ended_unexpectedly', + displayTitle: 'Run ended unexpectedly', + displayMessage: + 'The run ended unexpectedly. Try again. No credits charged.' + } + }, + { + type: 'Exception', + message: 'Exception: Servers are busy. Please try again later.', + expected: { + catalogId: 'server_busy', + displayTitle: 'Servers are busy', + displayMessage: + 'The servers are busy right now. Try again in a moment. No credits charged.' + } + }, + { + type: 'WebSocketError', + message: + 'WebSocketError: Polling aborted due to error: API Error: {"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation."}', + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + }, + { + type: 'Exception', + message: 'Exception: Generated video rejected by content moderation.', + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + }, + { + type: 'Exception', + message: 'Exception: Prompt or Initial Image failed the safety checks.', + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + }, + { + type: 'ValueError', + message: + 'ValueError: The generated image was flagged for content policy violation.', + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + }, + { + type: 'Exception', + message: + "Exception: Content filtered by Google's Responsible AI practices: safety (1 video filtered.)", + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + }, + { + type: 'Exception', + message: + "Exception: Content blocked by Google's Responsible AI filters (1 video filtered).", + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + }, + { + type: 'Exception', + message: 'Exception: Generated content was rejected by a safety check.', + expected: { + catalogId: 'content_blocked', + displayTitle: 'Content blocked', + displayMessage: + 'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.' + } + } + ])( + 'resolves non-node-scoped runtime failures', + ({ type, message, expected }) => { + expect( + resolveRunErrorMessage({ + kind: 'prompt', + isCloud: true, + error: { + type, + message, + details: '' + } + }) + ).toMatchObject(expected) + } + ) + + it('does not append credit copy outside cloud mode', () => { + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: false, + nodeDisplayName: 'KSampler', + error: executionError( + 'ServiceError', + 'Job execution time exceeded maximum limit' + ) + }) + ).toMatchObject({ + catalogId: 'timeout', + displayMessage: + 'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.', + toastMessage: + 'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.' + }) + }) + + it('does not over-match runtime error lookalikes', () => { + expect( + resolveRunErrorMessage({ + kind: 'prompt', + isCloud: true, + error: { + type: 'RequestError', + message: + 'RequestError: Failed to send prompt request: request returned error status 400: {"error":{"type":"prompt_outputs_failed_validation"}}', + details: '' + } + }) + ).toEqual({}) + + expect( + resolveRunErrorMessage({ + kind: 'prompt', + isCloud: true, + error: { + type: 'RequestError', + message: + 'RequestError: Failed to send prompt request: renderer template {node}', + details: '' + } + }) + ).toMatchObject({ + catalogId: 'request_failed', + displayTitle: 'Request failed', + displayDetails: 'Failed to send prompt request: renderer template {node}' + }) + + expect( + resolveRunErrorMessage({ + kind: 'prompt', + isCloud: true, + error: { + type: 'Exception', + message: + 'Exception: Debug output mentioned the content moderation system, but no content was blocked.', + details: '' + } + }) + ).toEqual({}) + + expect( + resolveRunErrorMessage({ + kind: 'prompt', + isCloud: true, + error: { + type: 'ModelDownloadError', + message: + 'ModelDownloadError: the following private models require a subscription upgrade:', + details: '' + } + }) + ).toEqual({}) + }) + + it('resolves unknown node execution errors to the general runtime fallback', () => { + expect( + resolveRunErrorMessage({ + kind: 'execution', + isCloud: true, + nodeDisplayName: 'KSampler', + error: executionError( + 'RuntimeError', + 'mat1 and mat2 shapes cannot be multiplied' + ) + }) + ).toEqual({ + catalogId: 'execution_failed', + displayTitle: 'Execution failed', + displayMessage: + 'Node threw an error during execution. No credits charged.', + displayItemLabel: 'KSampler', + toastTitle: 'KSampler failed', + toastMessage: + 'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.' + }) + }) + it('resolves missing error group display copy', () => { expect( resolveMissingErrorMessage({ diff --git a/src/platform/errorCatalog/errorMessageResolver.ts b/src/platform/errorCatalog/errorMessageResolver.ts index cbe45ad448..50dee5e5aa 100644 --- a/src/platform/errorCatalog/errorMessageResolver.ts +++ b/src/platform/errorCatalog/errorMessageResolver.ts @@ -1,514 +1,13 @@ -import type { - MissingErrorMessageSource, - NodeValidationError, - ResolvedErrorMessage, - ResolvedMissingErrorMessage, - RunErrorMessageSource -} from './types' -import { st, t, te } from '@/i18n' +import type { ResolvedErrorMessage, RunErrorMessageSource } from './types' -const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing' -const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection' -const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded' -const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory' -const KNOWN_PROMPT_ERROR_TYPES = new Set([ - 'prompt_no_outputs', - 'no_prompt', - 'server_error', - 'missing_node_type', - 'prompt_outputs_failed_validation' -]) +import { resolveExecutionErrorMessage } from './executionErrorResolver' +import { resolveMissingErrorMessage } from './missingErrorResolver' +import { resolvePromptErrorMessage } from './promptErrorResolver' +import { resolveNodeValidationErrorMessage } from './validationErrorResolver' -interface ValidationCatalogRule { - catalogId: string - itemLabel: 'node' | 'nodeInput' - copyKeys?: CopyKeys -} - -interface ErrorResolveContext { - isCloud?: boolean - nodeDisplayName?: string -} - -type CatalogParams = Record - -function translateCatalogMessage( - key: string, - fallback: string, - params?: CatalogParams -): string { - if (te(key)) return params ? t(key, params) : t(key) - if (!params) return fallback - - return fallback.replace(/\{(\w+)\}/g, (match, paramName) => - params[paramName] === undefined ? match : String(params[paramName]) - ) -} - -function translateOptionalCatalogMessage( - key: string, - fallback?: string, - params?: CatalogParams -): string | undefined { - if (te(key)) return params ? t(key, params) : t(key) - return fallback?.trim() ? fallback : undefined -} - -function normalizeNodeName(nodeDisplayName: string | undefined): string { - return ( - nodeDisplayName?.trim() || - translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node') - ) -} - -function getInputName(error: NodeValidationError): string { - const inputName = error.extra_info?.input_name - return ( - inputName?.trim() || - translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input') - ) -} - -function getErrorText(error: NodeValidationError) { - return [ - 'message' in error ? error.message : undefined, - 'details' in error ? error.details : undefined - ] - .filter(Boolean) - .join('\n') -} - -function isImageNotLoadedText(text: string): boolean { - return /invalid image file|\[errno 21\].*is a directory/i.test(text) -} - -function isImageNotLoadedValidationError(error: NodeValidationError): boolean { - return ( - error.type === 'custom_validation_failed' && - isImageNotLoadedText(getErrorText(error)) - ) -} - -function nodeInputItemLabel(nodeName: string, inputName: string): string { - return `${nodeName} - ${inputName}` -} - -function formatDependencyCycleDetails(details: string): string { - // Core reports dependency cycle paths as "node -> node"; catalog copy embeds - // those paths in prose, where "to" reads more naturally. - return details.replace(/\s*->\s*/g, ' to ') -} - -function formatCatalogValue(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined - if (typeof value === 'string') return value - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) - } - - try { - return JSON.stringify(value) - } catch { - return undefined - } -} - -function getInputConfigValue( - error: NodeValidationError, - key: 'min' | 'max' -): string | undefined { - const inputConfig = error.extra_info?.input_config - if (!Array.isArray(inputConfig)) return undefined - - const config = inputConfig[1] - if (!config || typeof config !== 'object') return undefined - - return formatCatalogValue((config as Record)[key]) -} - -function getInputConfigType(error: NodeValidationError): string | undefined { - const inputConfig = error.extra_info?.input_config - if (!Array.isArray(inputConfig)) return undefined - - return formatCatalogValue(inputConfig[0]) -} - -function getValidationParams( - error: NodeValidationError, - nodeName: string, - inputName: string -): CatalogParams { - const params: CatalogParams = { nodeName, inputName } - const receivedValue = formatCatalogValue(error.extra_info?.received_value) - const receivedType = formatCatalogValue(error.extra_info?.received_type) - const expectedType = getInputConfigType(error) - const minValue = getInputConfigValue(error, 'min') - const maxValue = getInputConfigValue(error, 'max') - - if (receivedValue !== undefined) params.receivedValue = receivedValue - if (receivedType !== undefined) params.receivedType = receivedType - if (expectedType !== undefined) params.expectedType = expectedType - if (minValue !== undefined) params.minValue = minValue - if (maxValue !== undefined) params.maxValue = maxValue - - return params -} - -function hasParams(params: CatalogParams, keys: string[]): boolean { - return keys.every((key) => params[key] !== undefined) -} - -interface CopyKeys { - detailsKey: string - toastMessageKey: string -} - -const DEFAULT_COPY_KEYS: CopyKeys = { - detailsKey: 'details', - toastMessageKey: 'toastMessage' -} - -const VALUE_SPECIFIC_COPY_RULES: Record< - string, - { - requiredParams: string[] - suffix: 'WithTypes' | 'WithValue' - } -> = { - return_type_mismatch: { - requiredParams: ['expectedType', 'receivedType'], - suffix: 'WithTypes' - }, - invalid_input_type: { - requiredParams: ['receivedValue', 'expectedType'], - suffix: 'WithValue' - }, - value_smaller_than_min: { - requiredParams: ['receivedValue', 'minValue'], - suffix: 'WithValue' - }, - value_bigger_than_max: { - requiredParams: ['receivedValue', 'maxValue'], - suffix: 'WithValue' - }, - value_not_in_list: { - requiredParams: ['receivedValue'], - suffix: 'WithValue' - } -} - -function getValueSpecificCopyKeys( - errorType: string, - params: CatalogParams -): CopyKeys { - const rule = VALUE_SPECIFIC_COPY_RULES[errorType] - if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS - - return { - detailsKey: `details${rule.suffix}`, - toastMessageKey: `toastMessage${rule.suffix}` - } -} - -function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys { - return error.details.trim() - ? { - detailsKey: 'detailsWithRawDetails', - toastMessageKey: 'toastMessageWithRawDetails' - } - : DEFAULT_COPY_KEYS -} - -function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys { - if (!error.details.trim()) return DEFAULT_COPY_KEYS - - return { - detailsKey: 'detailsWithRawDetails', - toastMessageKey: 'toastMessage' - } -} - -function getValidationCopyKeys( - error: NodeValidationError, - params: CatalogParams -): CopyKeys { - if (error.type === 'exception_during_validation') { - return getRawDetailsCopyKeys(error) - } - - if (error.type === 'exception_during_inner_validation') { - return getRawDetailsCopyKeys(error) - } - - if (error.type === 'custom_validation_failed') { - return getRawDetailsOnlyCopyKeys(error) - } - - if (error.type === 'dependency_cycle') { - return getRawDetailsOnlyCopyKeys(error) - } - - return getValueSpecificCopyKeys(error.type, params) -} - -const VALIDATION_ERROR_RULES: Record = { - [REQUIRED_INPUT_MISSING_TYPE]: { - catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID, - itemLabel: 'nodeInput' - }, - bad_linked_input: { - catalogId: 'bad_linked_input', - itemLabel: 'nodeInput' - }, - return_type_mismatch: { - catalogId: 'return_type_mismatch', - itemLabel: 'nodeInput' - }, - invalid_input_type: { - catalogId: 'invalid_input_type', - itemLabel: 'nodeInput' - }, - value_smaller_than_min: { - catalogId: 'value_smaller_than_min', - itemLabel: 'nodeInput' - }, - value_bigger_than_max: { - catalogId: 'value_bigger_than_max', - itemLabel: 'nodeInput' - }, - value_not_in_list: { - catalogId: 'value_not_in_list', - itemLabel: 'nodeInput' - }, - custom_validation_failed: { - catalogId: 'custom_validation_failed', - itemLabel: 'nodeInput' - }, - exception_during_inner_validation: { - catalogId: 'exception_during_inner_validation', - itemLabel: 'nodeInput' - }, - exception_during_validation: { - catalogId: 'exception_during_validation', - itemLabel: 'node' - }, - dependency_cycle: { - catalogId: 'dependency_cycle', - itemLabel: 'node' - } -} - -// Image-not-loaded shares the custom_validation_failed type, so it needs a -// predicate override to use image_not_loaded locale copy and default copy keys. -const IMAGE_NOT_LOADED_VALIDATION_RULE = { - catalogId: IMAGE_NOT_LOADED_CATALOG_ID, - itemLabel: 'node', - copyKeys: DEFAULT_COPY_KEYS -} satisfies ValidationCatalogRule - -function resolveValidationCatalogCopy( - error: NodeValidationError, - context: ErrorResolveContext, - localeKey: string, - rule: ValidationCatalogRule -): ResolvedErrorMessage { - const nodeName = normalizeNodeName(context.nodeDisplayName) - const inputName = getInputName(error) - const trimmedDetails = error.details.trim() - const rawDetails = - error.type === 'dependency_cycle' - ? formatDependencyCycleDetails(trimmedDetails) - : trimmedDetails - const params = { - ...getValidationParams(error, nodeName, inputName), - rawDetails - } - const keyPrefix = `errorCatalog.validationErrors.${localeKey}` - const titleFallback = error.message || error.type - const itemLabelFallback = - rule.itemLabel === 'node' - ? nodeName - : nodeInputItemLabel(nodeName, inputName) - const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params) - - return { - catalogId: rule.catalogId, - displayTitle: translateCatalogMessage( - `${keyPrefix}.title`, - titleFallback, - params - ), - displayMessage: translateCatalogMessage( - `${keyPrefix}.message`, - error.message, - params - ), - displayDetails: translateOptionalCatalogMessage( - `${keyPrefix}.${copyKeys.detailsKey}`, - error.details, - params - ), - displayItemLabel: translateCatalogMessage( - `${keyPrefix}.itemLabel`, - itemLabelFallback, - params - ), - toastTitle: translateCatalogMessage( - `${keyPrefix}.toastTitle`, - titleFallback, - params - ), - toastMessage: translateCatalogMessage( - `${keyPrefix}.${copyKeys.toastMessageKey}`, - error.message, - params - ) - } -} - -function resolveNodeValidationErrorMessage( - error: NodeValidationError, - context: ErrorResolveContext -): ResolvedErrorMessage { - if (isImageNotLoadedValidationError(error)) { - return resolveValidationCatalogCopy( - error, - context, - 'image_not_loaded', - IMAGE_NOT_LOADED_VALIDATION_RULE - ) - } - - const rule = VALIDATION_ERROR_RULES[error.type] - if (!rule) return {} - - return resolveValidationCatalogCopy(error, context, error.type, rule) -} - -function resolvePromptErrorMessage( - error: Extract['error'], - context: ErrorResolveContext -): ResolvedErrorMessage { - if (error.type === 'ImageDownloadError') { - return { - catalogId: IMAGE_NOT_LOADED_CATALOG_ID, - displayTitle: st( - 'errorCatalog.promptErrors.image_not_loaded.title', - error.message - ), - displayMessage: st( - 'errorCatalog.promptErrors.image_not_loaded.desc', - error.message - ) - } - } - - if (error.type === 'OOMError') { - const messageKey = context.isCloud - ? 'errorCatalog.promptErrors.out_of_memory.descCloud' - : 'errorCatalog.promptErrors.out_of_memory.descLocal' - - return { - catalogId: OUT_OF_MEMORY_CATALOG_ID, - displayTitle: st( - 'errorCatalog.promptErrors.out_of_memory.title', - error.message - ), - displayMessage: st(messageKey, error.message) - } - } - - if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {} - - const errorTypeKey = - error.type === 'server_error' - ? context.isCloud - ? 'server_error_cloud' - : 'server_error_local' - : error.type - - return { - displayTitle: translateCatalogMessage( - `errorCatalog.promptErrors.${errorTypeKey}.title`, - error.message - ), - displayMessage: st( - `errorCatalog.promptErrors.${errorTypeKey}.desc`, - error.message - ) - } -} - -function formatCountTitle(title: string, count: number): string { - return `${title} (${count})` -} - -function translateMissingModelOverlayMessage(count: number): string { - const translated = t('errorOverlay.missingModels', { count }, count) - return translated === 'errorOverlay.missingModels' - ? `${count} required ${count === 1 ? 'model is' : 'models are'} missing` - : translated -} - -export function resolveMissingErrorMessage( - source: MissingErrorMessageSource -): ResolvedMissingErrorMessage { - switch (source.kind) { - case 'missing_node': - return { - catalogId: 'missing_node', - displayTitle: formatCountTitle( - source.isCloud - ? st( - 'rightSidePanel.missingNodePacks.unsupportedTitle', - 'Unsupported Node Packs' - ) - : st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'), - source.count - ), - displayMessage: st( - 'errorOverlay.missingNodes', - 'Some nodes are missing and need to be installed' - ) - } - case 'swap_nodes': - return { - catalogId: 'swap_nodes', - displayTitle: formatCountTitle( - st('nodeReplacement.swapNodesTitle', 'Swap Nodes'), - source.count - ), - displayMessage: st( - 'errorOverlay.swapNodes', - 'Some nodes can be replaced with alternatives' - ) - } - case 'missing_model': - return { - catalogId: 'missing_model', - displayTitle: formatCountTitle( - st( - 'rightSidePanel.missingModels.missingModelsTitle', - 'Missing Models' - ), - source.count - ), - displayMessage: translateMissingModelOverlayMessage(source.count) - } - case 'missing_media': - return { - catalogId: 'missing_media', - displayTitle: formatCountTitle( - st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'), - source.count - ), - displayMessage: st( - 'errorOverlay.missingMedia', - 'Some nodes are missing required inputs' - ) - } - } -} +// Public facade for error catalog resolution. Source-specific resolver modules +// own the actual matching/copy rules so this file stays as the routing boundary. +export { resolveMissingErrorMessage } export function resolveRunErrorMessage( source: RunErrorMessageSource @@ -522,5 +21,10 @@ export function resolveRunErrorMessage( return resolvePromptErrorMessage(source.error, { isCloud: source.isCloud }) + case 'execution': + return resolveExecutionErrorMessage(source.error, { + nodeDisplayName: source.nodeDisplayName, + isCloud: source.isCloud + }) } } diff --git a/src/platform/errorCatalog/executionErrorResolver.ts b/src/platform/errorCatalog/executionErrorResolver.ts new file mode 100644 index 0000000000..f7b43df7c6 --- /dev/null +++ b/src/platform/errorCatalog/executionErrorResolver.ts @@ -0,0 +1,37 @@ +import type { ResolvedErrorMessage, RunErrorMessageSource } from './types' + +import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds' +import type { ErrorResolveContext } from './catalogI18n' +import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy' +import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher' + +// Resolves node-scoped runtime failures while preserving raw API fields. +export function resolveExecutionErrorMessage( + error: Extract['error'], + context: ErrorResolveContext +): ResolvedErrorMessage { + const exceptionMessage = error.exception_message.trim() + const match = resolveRuntimeCatalogMatch({ + exceptionType: error.exception_type, + exceptionMessage + }) + if (!match) { + return resolveRuntimeCatalogCopy( + EXECUTION_FAILED_CATALOG_ID, + error.exception_message, + context, + { includeItemLabel: true } + ) + } + + return resolveRuntimeCatalogCopy( + match.catalogId, + error.exception_message, + context, + { + includeItemLabel: true, + params: match.params, + detailsFallback: match.detailsFallback + } + ) +} diff --git a/src/platform/errorCatalog/missingErrorResolver.ts b/src/platform/errorCatalog/missingErrorResolver.ts new file mode 100644 index 0000000000..baac3f9514 --- /dev/null +++ b/src/platform/errorCatalog/missingErrorResolver.ts @@ -0,0 +1,78 @@ +import type { + MissingErrorMessageSource, + ResolvedMissingErrorMessage +} from './types' +import { st, t } from '@/i18n' + +// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These +// are grouped catalog messages rather than individual execution error items. +function formatCountTitle(title: string, count: number): string { + return `${title} (${count})` +} + +function translateMissingModelOverlayMessage(count: number): string { + const translated = t('errorOverlay.missingModels', { count }, count) + return translated === 'errorOverlay.missingModels' + ? `${count} required ${count === 1 ? 'model is' : 'models are'} missing` + : translated +} + +export function resolveMissingErrorMessage( + source: MissingErrorMessageSource +): ResolvedMissingErrorMessage { + switch (source.kind) { + case 'missing_node': + return { + catalogId: 'missing_node', + displayTitle: formatCountTitle( + source.isCloud + ? st( + 'rightSidePanel.missingNodePacks.unsupportedTitle', + 'Unsupported Node Packs' + ) + : st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'), + source.count + ), + displayMessage: st( + 'errorOverlay.missingNodes', + 'Some nodes are missing and need to be installed' + ) + } + case 'swap_nodes': + return { + catalogId: 'swap_nodes', + displayTitle: formatCountTitle( + st('nodeReplacement.swapNodesTitle', 'Swap Nodes'), + source.count + ), + displayMessage: st( + 'errorOverlay.swapNodes', + 'Some nodes can be replaced with alternatives' + ) + } + case 'missing_model': + return { + catalogId: 'missing_model', + displayTitle: formatCountTitle( + st( + 'rightSidePanel.missingModels.missingModelsTitle', + 'Missing Models' + ), + source.count + ), + displayMessage: translateMissingModelOverlayMessage(source.count) + } + case 'missing_media': + return { + catalogId: 'missing_media', + displayTitle: formatCountTitle( + st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'), + source.count + ), + displayMessage: st( + 'errorOverlay.missingMedia', + 'Some nodes are missing required inputs' + ) + } + } +} diff --git a/src/platform/errorCatalog/promptErrorResolver.ts b/src/platform/errorCatalog/promptErrorResolver.ts new file mode 100644 index 0000000000..9c555a1a9b --- /dev/null +++ b/src/platform/errorCatalog/promptErrorResolver.ts @@ -0,0 +1,72 @@ +import type { ResolvedErrorMessage, RunErrorMessageSource } from './types' + +import type { ErrorResolveContext } from './catalogI18n' +import { translateCatalogMessage } from './catalogI18n' +import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy' +import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher' +import { st } from '@/i18n' + +// Resolves prompt-level errors and non-node-scoped failures before falling +// back to prompt-specific catalog keys. +const KNOWN_PROMPT_ERROR_TYPES = new Set([ + 'prompt_no_outputs', + 'no_prompt', + 'server_error', + 'missing_node_type', + 'prompt_outputs_failed_validation' +]) + +function getPromptExceptionMessage( + error: Extract['error'] +): string { + const message = error.message.trim() + const prefixedType = `${error.type}: ` + return message.startsWith(prefixedType) + ? message.slice(prefixedType.length).trim() + : message +} + +export function resolvePromptErrorMessage( + error: Extract['error'], + context: ErrorResolveContext +): ResolvedErrorMessage { + const promptExceptionMessage = getPromptExceptionMessage(error) + const runtimeMatch = resolveRuntimeCatalogMatch({ + exceptionType: error.type, + exceptionMessage: promptExceptionMessage + }) + if (runtimeMatch) { + // Leave toast copy to node-scoped errors where a node-specific + // action/message is safe. + return resolveRuntimeCatalogCopy( + runtimeMatch.catalogId, + promptExceptionMessage || error.message, + context, + { + includeToast: false, + params: runtimeMatch.params, + detailsFallback: runtimeMatch.detailsFallback + } + ) + } + + if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {} + + const errorTypeKey = + error.type === 'server_error' + ? context.isCloud + ? 'server_error_cloud' + : 'server_error_local' + : error.type + + return { + displayTitle: translateCatalogMessage( + `errorCatalog.promptErrors.${errorTypeKey}.title`, + error.message + ), + displayMessage: st( + `errorCatalog.promptErrors.${errorTypeKey}.desc`, + error.message + ) + } +} diff --git a/src/platform/errorCatalog/runtimeErrorCopy.ts b/src/platform/errorCatalog/runtimeErrorCopy.ts new file mode 100644 index 0000000000..bff5c05087 --- /dev/null +++ b/src/platform/errorCatalog/runtimeErrorCopy.ts @@ -0,0 +1,136 @@ +import type { ResolvedErrorMessage } from './types' + +import { + ACCESS_REQUIRED_CATALOG_ID, + CONTENT_BLOCKED_CATALOG_ID, + EXECUTION_FAILED_CATALOG_ID, + GENERATION_STALLED_CATALOG_ID, + IMAGE_NOT_LOADED_CATALOG_ID, + INSUFFICIENT_CREDITS_CATALOG_ID, + INVALID_CLIP_INPUT_CATALOG_ID, + INVALID_PROMPT_CATALOG_ID, + INVALID_WORKFLOW_REQUEST_CATALOG_ID, + MODEL_ACCESS_ERROR_CATALOG_ID, + MODEL_DOWNLOAD_FAILED_CATALOG_ID, + OUT_OF_MEMORY_CATALOG_ID, + PREPROCESSING_FAILED_CATALOG_ID, + PREPROCESSING_TIMEOUT_CATALOG_ID, + RATE_LIMITED_CATALOG_ID, + REQUEST_FAILED_CATALOG_ID, + RUN_ENDED_UNEXPECTEDLY_CATALOG_ID, + RUN_START_FAILED_CATALOG_ID, + SERVER_BUSY_CATALOG_ID, + SERVER_CRASHED_CATALOG_ID, + SIGN_IN_REQUIRED_CATALOG_ID, + SUBSCRIPTION_REQUIRED_CATALOG_ID, + SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, + TIMEOUT_CATALOG_ID, + UNEXPECTED_SERVICE_ERROR_CATALOG_ID, + WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID +} from './catalogIds' +import { + normalizeNodeName, + translateCatalogMessage, + translateOptionalCatalogMessage +} from './catalogI18n' +import type { CatalogParams, ErrorResolveContext } from './catalogI18n' + +const NO_CREDITS_CHARGED_KEY = 'errorCatalog.runtimeErrors.noCreditsCharged' +const NO_CREDITS_CHARGED_FALLBACK = 'No credits charged.' + +// Keep this opt-in so the credit note is only shown for catalog IDs whose +// product copy explicitly supports it. +const NO_CREDITS_CHARGED_RUNTIME_CATALOG_IDS = new Set([ + ACCESS_REQUIRED_CATALOG_ID, + CONTENT_BLOCKED_CATALOG_ID, + EXECUTION_FAILED_CATALOG_ID, + GENERATION_STALLED_CATALOG_ID, + IMAGE_NOT_LOADED_CATALOG_ID, + INSUFFICIENT_CREDITS_CATALOG_ID, + INVALID_CLIP_INPUT_CATALOG_ID, + INVALID_PROMPT_CATALOG_ID, + INVALID_WORKFLOW_REQUEST_CATALOG_ID, + MODEL_ACCESS_ERROR_CATALOG_ID, + MODEL_DOWNLOAD_FAILED_CATALOG_ID, + OUT_OF_MEMORY_CATALOG_ID, + PREPROCESSING_FAILED_CATALOG_ID, + PREPROCESSING_TIMEOUT_CATALOG_ID, + RATE_LIMITED_CATALOG_ID, + REQUEST_FAILED_CATALOG_ID, + RUN_ENDED_UNEXPECTEDLY_CATALOG_ID, + RUN_START_FAILED_CATALOG_ID, + SERVER_BUSY_CATALOG_ID, + SERVER_CRASHED_CATALOG_ID, + SIGN_IN_REQUIRED_CATALOG_ID, + SUBSCRIPTION_REQUIRED_CATALOG_ID, + SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, + TIMEOUT_CATALOG_ID, + UNEXPECTED_SERVICE_ERROR_CATALOG_ID, + WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID +]) + +function appendNoCreditsChargedIfNeeded( + catalogId: string, + message: string, + context: ErrorResolveContext +): string { + if ( + !context.isCloud || + !NO_CREDITS_CHARGED_RUNTIME_CATALOG_IDS.has(catalogId) + ) + return message + + const note = translateCatalogMessage( + NO_CREDITS_CHARGED_KEY, + NO_CREDITS_CHARGED_FALLBACK + ) + return message.includes(note) ? message : `${message} ${note}` +} + +// Builds resolved display fields while callers keep the raw API message/details +// on the ErrorItem. +export function resolveRuntimeCatalogCopy( + catalogId: string, + fallbackMessage: string, + context: ErrorResolveContext, + options: { + includeItemLabel?: boolean + includeToast?: boolean + params?: CatalogParams + detailsFallback?: string + } = {} +): ResolvedErrorMessage { + const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}` + const nodeName = normalizeNodeName(context.nodeDisplayName) + const params = { nodeName, ...options.params } + const resolveMessage = (suffix: string, fallback = fallbackMessage) => + translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params) + const addCloudCreditNote = (message: string) => + appendNoCreditsChargedIfNeeded(catalogId, message, context) + + const displayMessage = resolveMessage('message') + const result: ResolvedErrorMessage = { + catalogId, + displayTitle: resolveMessage('title'), + displayMessage: addCloudCreditNote(displayMessage) + } + + if (options.includeToast !== false) { + const toastMessage = resolveMessage('toastMessage') + result.toastTitle = resolveMessage('toastTitle') + result.toastMessage = addCloudCreditNote(toastMessage) + } + + const displayDetails = translateOptionalCatalogMessage( + `${keyPrefix}.details`, + options.detailsFallback, + params + ) + if (displayDetails) result.displayDetails = displayDetails + + if (options.includeItemLabel) { + result.displayItemLabel = resolveMessage('itemLabel', nodeName) + } + + return result +} diff --git a/src/platform/errorCatalog/runtimeErrorMatcher.ts b/src/platform/errorCatalog/runtimeErrorMatcher.ts new file mode 100644 index 0000000000..76e13ef811 --- /dev/null +++ b/src/platform/errorCatalog/runtimeErrorMatcher.ts @@ -0,0 +1,397 @@ +import { + ACCESS_REQUIRED_CATALOG_ID, + CONTENT_BLOCKED_CATALOG_ID, + GENERATION_STALLED_CATALOG_ID, + IMAGE_NOT_LOADED_CATALOG_ID, + INSUFFICIENT_CREDITS_CATALOG_ID, + INVALID_CLIP_INPUT_CATALOG_ID, + INVALID_PROMPT_CATALOG_ID, + INVALID_WORKFLOW_REQUEST_CATALOG_ID, + MODEL_ACCESS_ERROR_CATALOG_ID, + MODEL_DOWNLOAD_FAILED_CATALOG_ID, + OUT_OF_MEMORY_CATALOG_ID, + PREPROCESSING_FAILED_CATALOG_ID, + PREPROCESSING_TIMEOUT_CATALOG_ID, + RATE_LIMITED_CATALOG_ID, + REQUEST_FAILED_CATALOG_ID, + RUN_ENDED_UNEXPECTEDLY_CATALOG_ID, + RUN_START_FAILED_CATALOG_ID, + SERVER_BUSY_CATALOG_ID, + SERVER_CRASHED_CATALOG_ID, + SIGN_IN_REQUIRED_CATALOG_ID, + SUBSCRIPTION_REQUIRED_CATALOG_ID, + SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, + TIMEOUT_CATALOG_ID, + UNEXPECTED_SERVICE_ERROR_CATALOG_ID, + WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID +} from './catalogIds' +import type { CatalogParams } from './catalogI18n' + +// Runtime errors can share generic exception labels, so targeted cataloging +// relies on narrow stable messages. Keep these matches exact or prefix-based. +const INSUFFICIENT_CREDITS_MESSAGES = new Set([ + 'Payment Required: Please add credits to your account to use this node.' +]) +const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([ + 'Payment Required: Please add credits to your workspace to continue.' +]) +const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([ + 'Workspace has no active subscription. Please subscribe to a plan to continue.', + 'User has no active subscription. Please subscribe to a plan to continue.' +]) +const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX = + 'the following private models require a subscription upgrade:' +const TIMEOUT_MESSAGES = new Set(['Job execution time exceeded maximum limit']) +const GENERATION_STALLED_MESSAGES = new Set([ + 'Job went too long without making any progress', + 'Job has stagnated' +]) +const SERVER_CRASHED_MESSAGES = new Set([ + 'RIP to the server your workflow was running on.', + 'Inference service restarted, terminating job', + 'Job stuck in erroring state, forcing terminal transition', + 'Job was previously marked as lost and has now been acknowledged by inference service' +]) +const SERVER_BUSY_MESSAGES = new Set([ + 'Failed to enqueue job for processing', + 'Executor is busy with another job', + 'Servers are busy. Please try again later.' +]) +const INVALID_WORKFLOW_REQUEST_MESSAGES = new Set([ + 'The workflow request is invalid.', + 'Invalid job: missing workflow', + "Invalid workflow: missing 'prompt' field", + "Invalid workflow: 'prompt' field must be an object" +]) +const ACCESS_REQUIRED_MESSAGE = + 'This run requires access that is not available for the current account.' +const MODEL_ACCESS_ERROR_MESSAGE = + 'One or more required models could not be accessed.' +const UNEXPECTED_SERVICE_ERROR_MESSAGE = 'Unexpected service error.' +const REQUEST_FAILED_MESSAGE = + 'The request failed before the run could complete.' +const RUN_START_FAILED_MESSAGE = 'The run could not be started.' +const RUN_ENDED_UNEXPECTEDLY_MESSAGE = 'The run ended unexpectedly.' +const SIGN_IN_REQUIRED_MESSAGE = + 'Unauthorized: Please login first to use this node.' +const RATE_LIMITED_PREFIX = 'Rate Limit Exceeded:' +const CORE_OOM_TIP = 'This error means you ran out of memory on your GPU.' +const CORE_OOM_ALLOCATION_PREFIX = 'Allocation on device' +const CLOUD_OOM_PREFIX = + 'Workflow execution failed due to insufficient memory (OOM).' +const ERRNO_DIRECTORY_MESSAGE = '[Errno 21] Is a directory:' +const INVALID_CLIP_INPUT_PREFIX = 'ERROR: clip input is invalid: None' +const PROMPT_TOO_SHORT_MESSAGE = + "Field 'prompt' cannot be shorter than 1 characters; was 0 characters long." +const PROMPT_EMPTY_MESSAGE = "Field 'prompt' cannot be empty." +const PREPROCESSING_FAILED_MESSAGE = 'Preprocessing failed' +const PREPROCESSING_TIMEOUT_MESSAGES = new Set([ + 'Preprocessing timed out', + 'Preprocessing timed out.' +]) +const MODEL_DOWNLOAD_PANIC_PREFIX = 'internal error during model download:' +const GENERATED_VIDEO_REJECTED_MESSAGE = + 'Generated video rejected by content moderation.' +const GENERATED_CONTENT_REJECTED_MESSAGE = + 'Generated content was rejected by a safety check.' +const SAFETY_CHECK_MESSAGE = 'Prompt or Initial Image failed the safety checks.' +const CONTENT_POLICY_VIOLATION_MESSAGE = + 'The generated image was flagged for content policy violation.' +const CONTENT_MODERATION_FLAGGED_PREFIX = + 'Your request was flagged by our content moderation system' +const GOOGLE_RAI_FILTERED_PREFIX = + "Content filtered by Google's Responsible AI practices" +const GOOGLE_RAI_BLOCKED_PREFIX = + "Content blocked by Google's Responsible AI filters" + +const START_FAILED_PREFIXES = [ + 'Failed to start WebSocket client:', + 'Failed to get ComfyUI generation ID:' +] +const REQUEST_FAILED_PREFIXES = ['Failed to send prompt request:'] +const SERVER_CRASHED_PREFIXES = [ + 'Workflow execution was interrupted due to ComfyUI process restart.', + 'Job execution interrupted: server shutdown.', + 'Failed to clear queue and restart failed:', + 'WebSocket failed to reconnect after restart:' +] +const PREPROCESSING_FAILED_PREFIXES = [ + 'Preprocessing failed:', + 'Failed to complete preparation:' +] + +interface RuntimeErrorInfo { + exceptionType: string + exceptionMessage: string +} + +interface RuntimeCatalogMatch { + catalogId: string + params?: CatalogParams + detailsFallback?: string +} + +interface RuntimeMatchRule { + matches: (info: RuntimeErrorInfo, message: string) => boolean + resolve: (info: RuntimeErrorInfo, message: string) => RuntimeCatalogMatch +} + +function catalogMatch( + catalogId: string, + options: Omit = {} +): RuntimeCatalogMatch { + return { catalogId, ...options } +} + +function catalogMatchWithMessageFallback( + catalogId: string, + message: string +): RuntimeCatalogMatch { + return catalogMatch(catalogId, { detailsFallback: message }) +} + +function isOutOfMemoryError(info: RuntimeErrorInfo): boolean { + const message = info.exceptionMessage + return ( + info.exceptionType === 'OOMError' || + message.includes(CORE_OOM_TIP) || + message.includes(CORE_OOM_ALLOCATION_PREFIX) || + message.includes(CLOUD_OOM_PREFIX) || + message.includes('CUDA out of memory') || + message.includes('GPU out of memory') + ) +} + +function isImageNotLoadedError( + info: RuntimeErrorInfo, + message: string +): boolean { + return ( + info.exceptionType === 'ImageDownloadError' || + (info.exceptionType === 'IsADirectoryError' && + message.includes(ERRNO_DIRECTORY_MESSAGE)) + ) +} + +function getSubscriptionUpgradeDetails(message: string): string { + return message.slice(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX.length).trim() +} + +function isContentBlockedError(message: string): boolean { + return ( + message.includes(GENERATED_VIDEO_REJECTED_MESSAGE) || + message.includes(GENERATED_CONTENT_REJECTED_MESSAGE) || + message.includes(SAFETY_CHECK_MESSAGE) || + message.includes(CONTENT_POLICY_VIOLATION_MESSAGE) || + message.startsWith(CONTENT_MODERATION_FLAGGED_PREFIX) || + message.startsWith(GOOGLE_RAI_FILTERED_PREFIX) || + message.startsWith(GOOGLE_RAI_BLOCKED_PREFIX) + ) +} + +function startsWithAny(message: string, prefixes: string[]): boolean { + return prefixes.some((prefix) => message.startsWith(prefix)) +} + +function hasEmbeddedApiErrorPayload(message: string): boolean { + // Embedded validation responses are parsed by a more specific path, so do not + // catalog them as a generic request failure here. + return /request returned error status \d{3}:\s*\{/.test(message) +} + +function isSubscriptionUpgradeMessage(message: string): boolean { + return ( + message.toLowerCase().startsWith(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX) && + getSubscriptionUpgradeDetails(message).length > 0 + ) +} + +// Order matters: the first matching rule wins. Keep narrow user-actionable +// signatures before broader fallbacks. +const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [ + { + matches: isImageNotLoadedError, + resolve: (_info, message) => + catalogMatchWithMessageFallback(IMAGE_NOT_LOADED_CATALOG_ID, message) + }, + { + matches: isOutOfMemoryError, + resolve: (_info, message) => + catalogMatchWithMessageFallback(OUT_OF_MEMORY_CATALOG_ID, message) + }, + { + matches: (_info, message) => message.startsWith(INVALID_CLIP_INPUT_PREFIX), + resolve: (_info, message) => + catalogMatchWithMessageFallback(INVALID_CLIP_INPUT_CATALOG_ID, message) + }, + { + matches: (_info, message) => + message.includes(PROMPT_TOO_SHORT_MESSAGE) || + message.includes(PROMPT_EMPTY_MESSAGE), + resolve: () => catalogMatch(INVALID_PROMPT_CATALOG_ID) + }, + { + matches: (info, message) => + info.exceptionType === 'ValidationError' && + INVALID_WORKFLOW_REQUEST_MESSAGES.has(message), + resolve: () => catalogMatch(INVALID_WORKFLOW_REQUEST_CATALOG_ID) + }, + { + matches: (_info, message) => + WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES.has(message), + resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID) + }, + { + matches: (info, message) => + info.exceptionType === 'InsufficientFundsError' || + INSUFFICIENT_CREDITS_MESSAGES.has(message), + resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID) + }, + { + matches: (info, message) => + info.exceptionType === 'InactiveSubscriptionError' || + SUBSCRIPTION_REQUIRED_MESSAGES.has(message), + resolve: () => catalogMatch(SUBSCRIPTION_REQUIRED_CATALOG_ID) + }, + { + matches: (_info, message) => isSubscriptionUpgradeMessage(message), + resolve: (_info, message) => { + const modelNames = getSubscriptionUpgradeDetails(message) + return catalogMatch(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, { + params: { modelNames }, + detailsFallback: message + }) + } + }, + { + matches: (info, message) => + info.exceptionType === 'AccessRequired' || + message === ACCESS_REQUIRED_MESSAGE, + resolve: () => catalogMatch(ACCESS_REQUIRED_CATALOG_ID) + }, + { + matches: (info, message) => + info.exceptionType === 'ModelAccessError' || + message === MODEL_ACCESS_ERROR_MESSAGE, + resolve: () => catalogMatch(MODEL_ACCESS_ERROR_CATALOG_ID) + }, + { + matches: (_info, message) => message.includes(SIGN_IN_REQUIRED_MESSAGE), + resolve: () => catalogMatch(SIGN_IN_REQUIRED_CATALOG_ID) + }, + { + matches: (_info, message) => message.startsWith(RATE_LIMITED_PREFIX), + resolve: (_info, message) => + catalogMatchWithMessageFallback(RATE_LIMITED_CATALOG_ID, message) + }, + { + matches: (info, message) => + info.exceptionType === 'PreprocessingTimeout' || + PREPROCESSING_TIMEOUT_MESSAGES.has(message), + resolve: () => catalogMatch(PREPROCESSING_TIMEOUT_CATALOG_ID) + }, + { + matches: (_info, message) => + message === PREPROCESSING_FAILED_MESSAGE || + startsWithAny(message, PREPROCESSING_FAILED_PREFIXES), + resolve: (_info, message) => + message === PREPROCESSING_FAILED_MESSAGE + ? catalogMatch(PREPROCESSING_FAILED_CATALOG_ID) + : catalogMatchWithMessageFallback( + PREPROCESSING_FAILED_CATALOG_ID, + message + ) + }, + { + matches: (info, message) => + info.exceptionType === 'PanicError' && + message.startsWith(MODEL_DOWNLOAD_PANIC_PREFIX), + resolve: (_info, message) => + catalogMatchWithMessageFallback(MODEL_DOWNLOAD_FAILED_CATALOG_ID, message) + }, + { + matches: (_info, message) => isContentBlockedError(message), + resolve: (_info, message) => + catalogMatchWithMessageFallback(CONTENT_BLOCKED_CATALOG_ID, message) + }, + { + matches: (_info, message) => startsWithAny(message, START_FAILED_PREFIXES), + resolve: (_info, message) => + catalogMatchWithMessageFallback(RUN_START_FAILED_CATALOG_ID, message) + }, + { + matches: (_info, message) => + startsWithAny(message, REQUEST_FAILED_PREFIXES) && + !hasEmbeddedApiErrorPayload(message), + resolve: (_info, message) => + catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message) + }, + { + matches: (_info, message) => message === RUN_START_FAILED_MESSAGE, + resolve: () => catalogMatch(RUN_START_FAILED_CATALOG_ID) + }, + { + matches: (_info, message) => message === RUN_ENDED_UNEXPECTEDLY_MESSAGE, + resolve: () => catalogMatch(RUN_ENDED_UNEXPECTEDLY_CATALOG_ID) + }, + { + matches: (info, message) => + info.exceptionType === 'PanicError' && + message.startsWith('panic during job execution:'), + resolve: (_info, message) => + catalogMatchWithMessageFallback( + RUN_ENDED_UNEXPECTEDLY_CATALOG_ID, + message + ) + }, + { + matches: (_info, message) => TIMEOUT_MESSAGES.has(message), + resolve: () => catalogMatch(TIMEOUT_CATALOG_ID) + }, + { + matches: (_info, message) => GENERATION_STALLED_MESSAGES.has(message), + resolve: () => catalogMatch(GENERATION_STALLED_CATALOG_ID) + }, + { + matches: (_info, message) => SERVER_CRASHED_MESSAGES.has(message), + resolve: () => catalogMatch(SERVER_CRASHED_CATALOG_ID) + }, + { + matches: (_info, message) => + startsWithAny(message, SERVER_CRASHED_PREFIXES), + resolve: (_info, message) => + catalogMatchWithMessageFallback(SERVER_CRASHED_CATALOG_ID, message) + }, + { + matches: (_info, message) => SERVER_BUSY_MESSAGES.has(message), + resolve: () => catalogMatch(SERVER_BUSY_CATALOG_ID) + }, + { + matches: (info, message) => + info.exceptionType === 'UnexpectedServiceError' || + message === UNEXPECTED_SERVICE_ERROR_MESSAGE, + resolve: () => catalogMatch(UNEXPECTED_SERVICE_ERROR_CATALOG_ID) + }, + { + matches: (info, message) => + message === REQUEST_FAILED_MESSAGE || + (info.exceptionType === 'RequestError' && + !hasEmbeddedApiErrorPayload(message)), + resolve: (_info, message) => + message === REQUEST_FAILED_MESSAGE + ? catalogMatch(REQUEST_FAILED_CATALOG_ID) + : catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message) + } +] + +export function resolveRuntimeCatalogMatch( + info: RuntimeErrorInfo +): RuntimeCatalogMatch | undefined { + const message = info.exceptionMessage.trim() + + for (const rule of RUNTIME_MATCH_RULES) { + if (rule.matches(info, message)) return rule.resolve(info, message) + } + + return undefined +} diff --git a/src/platform/errorCatalog/types.ts b/src/platform/errorCatalog/types.ts index 979ee91650..4ac0b11a9e 100644 --- a/src/platform/errorCatalog/types.ts +++ b/src/platform/errorCatalog/types.ts @@ -1,4 +1,8 @@ -import type { NodeError, PromptError } from '@/schemas/apiSchema' +import type { + ExecutionErrorWsMessage, + NodeError, + PromptError +} from '@/schemas/apiSchema' import type { MissingMediaGroup, MediaType @@ -40,6 +44,12 @@ export type RunErrorMessageSource = error: PromptError isCloud: boolean } + | { + kind: 'execution' + error: ExecutionErrorWsMessage + nodeDisplayName: string + isCloud: boolean + } export type MissingErrorMessageSource = | { diff --git a/src/platform/errorCatalog/validationErrorResolver.ts b/src/platform/errorCatalog/validationErrorResolver.ts new file mode 100644 index 0000000000..0a4fedc0db --- /dev/null +++ b/src/platform/errorCatalog/validationErrorResolver.ts @@ -0,0 +1,347 @@ +import type { NodeValidationError, ResolvedErrorMessage } from './types' + +import { + IMAGE_NOT_LOADED_CATALOG_ID, + MISSING_CONNECTION_CATALOG_ID +} from './catalogIds' +import { + normalizeNodeName, + translateCatalogMessage, + translateOptionalCatalogMessage +} from './catalogI18n' +import type { CatalogParams, ErrorResolveContext } from './catalogI18n' + +const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing' + +// Resolves node validation errors. Most validation types map 1:1 to their +// catalog/locale keys; FE-specific recategorization uses a separate catalogId, +// such as required_input_missing -> missing_connection. +interface ValidationCatalogRule { + catalogId: string + itemLabel: 'node' | 'nodeInput' + copyKeys?: CopyKeys +} + +interface CopyKeys { + detailsKey: string + toastMessageKey: string +} + +const DEFAULT_COPY_KEYS: CopyKeys = { + detailsKey: 'details', + toastMessageKey: 'toastMessage' +} + +const VALUE_SPECIFIC_COPY_RULES: Record< + string, + { + requiredParams: string[] + suffix: 'WithTypes' | 'WithValue' + } +> = { + return_type_mismatch: { + requiredParams: ['expectedType', 'receivedType'], + suffix: 'WithTypes' + }, + invalid_input_type: { + requiredParams: ['receivedValue', 'expectedType'], + suffix: 'WithValue' + }, + value_smaller_than_min: { + requiredParams: ['receivedValue', 'minValue'], + suffix: 'WithValue' + }, + value_bigger_than_max: { + requiredParams: ['receivedValue', 'maxValue'], + suffix: 'WithValue' + }, + value_not_in_list: { + requiredParams: ['receivedValue'], + suffix: 'WithValue' + } +} + +const VALIDATION_ERROR_RULES: Record = { + [REQUIRED_INPUT_MISSING_TYPE]: { + catalogId: MISSING_CONNECTION_CATALOG_ID, + itemLabel: 'nodeInput' + }, + bad_linked_input: { + catalogId: 'bad_linked_input', + itemLabel: 'nodeInput' + }, + return_type_mismatch: { + catalogId: 'return_type_mismatch', + itemLabel: 'nodeInput' + }, + invalid_input_type: { + catalogId: 'invalid_input_type', + itemLabel: 'nodeInput' + }, + value_smaller_than_min: { + catalogId: 'value_smaller_than_min', + itemLabel: 'nodeInput' + }, + value_bigger_than_max: { + catalogId: 'value_bigger_than_max', + itemLabel: 'nodeInput' + }, + value_not_in_list: { + catalogId: 'value_not_in_list', + itemLabel: 'nodeInput' + }, + custom_validation_failed: { + catalogId: 'custom_validation_failed', + itemLabel: 'nodeInput' + }, + exception_during_inner_validation: { + catalogId: 'exception_during_inner_validation', + itemLabel: 'nodeInput' + }, + exception_during_validation: { + catalogId: 'exception_during_validation', + itemLabel: 'node' + }, + dependency_cycle: { + catalogId: 'dependency_cycle', + itemLabel: 'node' + } +} + +// Image-not-loaded shares the custom_validation_failed type, so type-keyed +// dispatch cannot distinguish it. The override also keeps it on default copy +// keys instead of custom_validation_failed's raw-details variant. +const IMAGE_NOT_LOADED_VALIDATION_RULE = { + catalogId: IMAGE_NOT_LOADED_CATALOG_ID, + itemLabel: 'node', + copyKeys: DEFAULT_COPY_KEYS +} satisfies ValidationCatalogRule + +function getInputName(error: NodeValidationError): string { + const inputName = error.extra_info?.input_name + return ( + inputName?.trim() || + translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input') + ) +} + +function getErrorText(error: NodeValidationError) { + return [ + 'message' in error ? error.message : undefined, + 'details' in error ? error.details : undefined + ] + .filter(Boolean) + .join('\n') +} + +function isImageNotLoadedText(text: string): boolean { + return /invalid image file|\[errno 21\].*is a directory/i.test(text) +} + +function isImageNotLoadedValidationError(error: NodeValidationError): boolean { + return ( + error.type === 'custom_validation_failed' && + isImageNotLoadedText(getErrorText(error)) + ) +} + +function nodeInputItemLabel(nodeName: string, inputName: string): string { + return `${nodeName} - ${inputName}` +} + +function formatDependencyCycleDetails(details: string): string { + // Dependency cycle paths may be reported as "node -> node"; catalog copy + // embeds those paths in prose, where "to" reads more naturally. + return details.replace(/\s*->\s*/g, ' to ') +} + +function formatCatalogValue(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + try { + return JSON.stringify(value) + } catch { + return undefined + } +} + +function getInputConfigValue( + error: NodeValidationError, + key: 'min' | 'max' +): string | undefined { + const inputConfig = error.extra_info?.input_config + if (!Array.isArray(inputConfig)) return undefined + + const config = inputConfig[1] + if (!config || typeof config !== 'object') return undefined + + return formatCatalogValue((config as Record)[key]) +} + +function getInputConfigType(error: NodeValidationError): string | undefined { + const inputConfig = error.extra_info?.input_config + if (!Array.isArray(inputConfig)) return undefined + + return formatCatalogValue(inputConfig[0]) +} + +function getValidationParams( + error: NodeValidationError, + nodeName: string, + inputName: string +): CatalogParams { + const params: CatalogParams = { nodeName, inputName } + const receivedValue = formatCatalogValue(error.extra_info?.received_value) + const receivedType = formatCatalogValue(error.extra_info?.received_type) + const expectedType = getInputConfigType(error) + const minValue = getInputConfigValue(error, 'min') + const maxValue = getInputConfigValue(error, 'max') + + if (receivedValue !== undefined) params.receivedValue = receivedValue + if (receivedType !== undefined) params.receivedType = receivedType + if (expectedType !== undefined) params.expectedType = expectedType + if (minValue !== undefined) params.minValue = minValue + if (maxValue !== undefined) params.maxValue = maxValue + + return params +} + +function hasParams(params: CatalogParams, keys: string[]): boolean { + return keys.every((key) => params[key] !== undefined) +} + +function getValueSpecificCopyKeys( + errorType: string, + params: CatalogParams +): CopyKeys { + const rule = VALUE_SPECIFIC_COPY_RULES[errorType] + if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS + + return { + detailsKey: `details${rule.suffix}`, + toastMessageKey: `toastMessage${rule.suffix}` + } +} + +function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys { + return error.details.trim() + ? { + detailsKey: 'detailsWithRawDetails', + toastMessageKey: 'toastMessageWithRawDetails' + } + : DEFAULT_COPY_KEYS +} + +function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys { + if (!error.details.trim()) return DEFAULT_COPY_KEYS + + return { + detailsKey: 'detailsWithRawDetails', + toastMessageKey: 'toastMessage' + } +} + +function getValidationCopyKeys( + error: NodeValidationError, + params: CatalogParams +): CopyKeys { + if ( + error.type === 'exception_during_validation' || + error.type === 'exception_during_inner_validation' + ) { + return getRawDetailsCopyKeys(error) + } + + if (error.type === 'custom_validation_failed') { + return getRawDetailsOnlyCopyKeys(error) + } + + if (error.type === 'dependency_cycle') { + return getRawDetailsOnlyCopyKeys(error) + } + + return getValueSpecificCopyKeys(error.type, params) +} + +function resolveValidationCatalogCopy( + error: NodeValidationError, + context: ErrorResolveContext, + localeKey: string, + rule: ValidationCatalogRule +): ResolvedErrorMessage { + const nodeName = normalizeNodeName(context.nodeDisplayName) + const inputName = getInputName(error) + const trimmedDetails = error.details.trim() + const rawDetails = + error.type === 'dependency_cycle' + ? formatDependencyCycleDetails(trimmedDetails) + : trimmedDetails + const params = { + ...getValidationParams(error, nodeName, inputName), + rawDetails + } + const keyPrefix = `errorCatalog.validationErrors.${localeKey}` + const titleFallback = error.message || error.type + const itemLabelFallback = + rule.itemLabel === 'node' + ? nodeName + : nodeInputItemLabel(nodeName, inputName) + const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params) + + return { + catalogId: rule.catalogId, + displayTitle: translateCatalogMessage( + `${keyPrefix}.title`, + titleFallback, + params + ), + displayMessage: translateCatalogMessage( + `${keyPrefix}.message`, + error.message, + params + ), + displayDetails: translateOptionalCatalogMessage( + `${keyPrefix}.${copyKeys.detailsKey}`, + error.details, + params + ), + displayItemLabel: translateCatalogMessage( + `${keyPrefix}.itemLabel`, + itemLabelFallback, + params + ), + toastTitle: translateCatalogMessage( + `${keyPrefix}.toastTitle`, + titleFallback, + params + ), + toastMessage: translateCatalogMessage( + `${keyPrefix}.${copyKeys.toastMessageKey}`, + error.message, + params + ) + } +} + +export function resolveNodeValidationErrorMessage( + error: NodeValidationError, + context: ErrorResolveContext +): ResolvedErrorMessage { + if (isImageNotLoadedValidationError(error)) { + return resolveValidationCatalogCopy( + error, + context, + 'image_not_loaded', + IMAGE_NOT_LOADED_VALIDATION_RULE + ) + } + + const rule = VALIDATION_ERROR_RULES[error.type] + if (!rule) return {} + + return resolveValidationCatalogCopy(error, context, error.type, rule) +}