mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 16:05:11 +00:00
feat: add special runtime error messaging
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -427,7 +427,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
40
src/platform/errorCatalog/catalogI18n.ts
Normal file
40
src/platform/errorCatalog/catalogI18n.ts
Normal file
@@ -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<string, string | number>
|
||||
|
||||
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')
|
||||
)
|
||||
}
|
||||
32
src/platform/errorCatalog/catalogIds.ts
Normal file
32
src/platform/errorCatalog/catalogIds.ts
Normal file
@@ -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'
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, string | number>
|
||||
|
||||
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<string, unknown>)[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<string, ValidationCatalogRule> = {
|
||||
[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<RunErrorMessageSource, { kind: 'prompt' }>['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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
37
src/platform/errorCatalog/executionErrorResolver.ts
Normal file
37
src/platform/errorCatalog/executionErrorResolver.ts
Normal file
@@ -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<RunErrorMessageSource, { kind: 'execution' }>['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
|
||||
}
|
||||
)
|
||||
}
|
||||
78
src/platform/errorCatalog/missingErrorResolver.ts
Normal file
78
src/platform/errorCatalog/missingErrorResolver.ts
Normal file
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/platform/errorCatalog/promptErrorResolver.ts
Normal file
72
src/platform/errorCatalog/promptErrorResolver.ts
Normal file
@@ -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<RunErrorMessageSource, { kind: 'prompt' }>['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<RunErrorMessageSource, { kind: 'prompt' }>['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
|
||||
)
|
||||
}
|
||||
}
|
||||
136
src/platform/errorCatalog/runtimeErrorCopy.ts
Normal file
136
src/platform/errorCatalog/runtimeErrorCopy.ts
Normal file
@@ -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
|
||||
}
|
||||
397
src/platform/errorCatalog/runtimeErrorMatcher.ts
Normal file
397
src/platform/errorCatalog/runtimeErrorMatcher.ts
Normal file
@@ -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, 'catalogId'> = {}
|
||||
): 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
|
||||
}
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
347
src/platform/errorCatalog/validationErrorResolver.ts
Normal file
347
src/platform/errorCatalog/validationErrorResolver.ts
Normal file
@@ -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<string, ValidationCatalogRule> = {
|
||||
[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<string, unknown>)[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)
|
||||
}
|
||||
Reference in New Issue
Block a user