Compare commits

...

6 Commits

Author SHA1 Message Date
jaeone94
5647997117 fix: group validation errors by catalog id 2026-06-05 13:59:50 +09:00
jaeone94
50057338f8 test: cover validation error catalog grouping 2026-06-05 13:59:25 +09:00
jaeone94
9f7162d8b8 test: cover missing resource display messages 2026-06-05 02:53:07 +09:00
jaeone94
9d52a05a3c fix: render swap node rows in errors tab 2026-06-05 02:38:37 +09:00
jaeone94
44f9f56069 refactor: trim missing resource error copy 2026-06-04 22:27:02 +09:00
jaeone94
c6d3f5a400 fix: resolve missing resource error messages 2026-06-04 18:17:54 +09:00
25 changed files with 1093 additions and 198 deletions

View File

@@ -54,6 +54,7 @@ export const TestIds = {
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
errorGroupDisplayMessage: 'error-group-display-message',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',

View File

@@ -11,6 +11,7 @@ import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
@@ -38,6 +39,9 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })

View File

@@ -36,6 +36,10 @@ function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -46,14 +50,24 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const overlay = getErrorOverlay(comfyPage)
await expect(overlay).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
).toContainText(/Load Image/)
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
await expect(missingMediaGroup).toBeVisible()
await expect(
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Shows correct number of missing media rows', async ({

View File

@@ -25,9 +25,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeVisible()
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({

View File

@@ -23,9 +23,13 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Should show missing node packs group', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(missingNodeGroup).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should expand pack group to reveal node type names', async ({

View File

@@ -90,8 +90,6 @@ const i18n = createI18n({
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
ossManagerDisabledHint:
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
applyChanges: 'Apply Changes'
@@ -159,21 +157,6 @@ describe('MissingNodeCard', () => {
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
renderCard()
expect(
screen.getByText('Unsupported node packs detected.')
).toBeInTheDocument()
})
it('renders OSS message when isCloud is false', () => {
renderCard()
expect(
screen.getByText('Missing node packs detected. Install them.')
).toBeInTheDocument()
})
it('renders correct number of MissingPackGroupRow components', () => {
renderCard({ missingPackGroups: makePackGroups(3) })
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)

View File

@@ -36,19 +36,6 @@
</div>
</div>
</div>
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p
class="m-0 text-sm/relaxed text-muted-foreground"
:class="showManagerHint ? 'pb-3' : 'pb-5'"
>
{{
isCloud
? t('rightSidePanel.missingNodePacks.cloudMessage')
: t('rightSidePanel.missingNodePacks.ossMessage')
}}
</p>
<!-- Manager disabled hint: shown on OSS when manager is not active -->
<i18n-t
v-if="showManagerHint"

View File

@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
@@ -36,6 +38,16 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
}),
isModelDownloadable: vi.fn(() => true),
toBrowsableUrl: vi.fn((url: string) => url)
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
@@ -57,6 +69,18 @@ describe('TabErrors.vue', () => {
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs',
image: 'Images',
uploadFile: 'Upload {type}',
useFromLibrary: 'Use from Library',
confirmSelection: 'Confirm selection',
locateNode: 'Locate node',
expandNodes: 'Show referencing nodes',
collapseNodes: 'Hide referencing nodes',
cancelSelection: 'Cancel selection',
or: 'OR'
}
}
}
@@ -282,6 +306,85 @@ describe('TabErrors.vue', () => {
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
})
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
} satisfies MissingModelCandidate
renderComponent({
missingModel: {
missingModelCandidates: [missingModel]
}
})
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.getByText('Download a model, or open the node to replace it.')
).toBeInTheDocument()
})
it('renders missing media display message below the section title', () => {
const missingMedia = {
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'portrait.png',
isMissing: true
} satisfies MissingMediaCandidate
renderComponent({
missingMedia: {
missingMediaCandidates: [missingMedia]
}
})
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
expect(
screen.getByText('A required media input has no file selected.')
).toBeInTheDocument()
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',
nodeId: '1',
isReplaceable: true,
replacement: {
old_node_id: 'OldSampler',
new_node_id: 'KSampler',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
} satisfies MissingNodeType
renderComponent({
missingNodesError: {
missingNodesError: {
message: 'Missing Node Packs',
nodeTypes: [swapNode]
}
}
})
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: '1',

View File

@@ -154,6 +154,18 @@
</div>
</template>
<div
v-if="group.type !== 'execution' && group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
<p
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
@@ -166,7 +178,7 @@
<!-- Swap Nodes -->
<SwapNodesCard
v-else-if="group.type === 'swap_nodes'"
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@@ -174,7 +186,7 @@
/>
<!-- Execution Errors -->
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -189,7 +201,7 @@
<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
@@ -197,7 +209,7 @@
<!-- Missing Media -->
<MissingMediaCard
v-else-if="group.type === 'missing_media'"
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"

View File

@@ -43,6 +43,18 @@ vi.mock('@/i18n', () => {
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.unknown_validation_error.title':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.message':
'A node returned a validation error ComfyUI does not recognize.',
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
'{nodeName}',
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
'{nodeName} returned an unrecognized validation error.',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
@@ -302,7 +314,24 @@ describe('useErrorGroups', () => {
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Some nodes are missing and need to be installed'
'Install missing packs to use this workflow.'
)
})
it('uses Cloud copy for missing_node group in Cloud', async () => {
mockIsCloud.value = true
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup?.displayMessage).toBe(
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
)
})
@@ -367,7 +396,7 @@ describe('useErrorGroups', () => {
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
it('uses fallback catalog grouping for unknown node validation errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -388,8 +417,8 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
expect(execGroups[0].displayTitle).toBe('Validation failed')
})
it('resolves required_input_missing item display copy', async () => {
@@ -438,6 +467,55 @@ describe('useErrorGroups', () => {
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'clip',
extra_info: {
input_name: 'clip'
}
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups).toHaveLength(1)
const [group] = execGroups
expect(group.groupKey).toBe('execution:missing_connection')
expect(group.displayTitle).toBe('Missing connection')
expect(group.cards.map((card) => card.title)).toEqual([
'KSampler',
'CLIPLoader'
])
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
})
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()

View File

@@ -30,6 +30,7 @@ import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
@@ -43,7 +44,6 @@ import {
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -79,6 +79,8 @@ interface ErrorSearchItem {
searchableDetails: string
}
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
@@ -138,44 +140,6 @@ function createErrorCard(
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
) {
const displayTitle =
error.displayTitle ?? error.displayMessage ?? error.message
const groupKey = error.catalogId ?? displayTitle
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
group.get(card.id)?.errors.push(error)
}
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
@@ -333,18 +297,22 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
nodeId: string,
classType: string,
idPrefix: string,
errors: ErrorItem[],
error: CataloguedErrorItem,
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
const cards = getOrCreateGroup(
groupsMap,
error.catalogId,
error.displayTitle ?? classType,
1
)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push(...errors)
card.errors.push(error)
}
function processPromptError(
@@ -395,13 +363,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
)) {
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => {
return {
for (const e of nodeError.errors) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
{
message: e.message,
details: e.details ?? undefined,
...resolveRunErrorMessage({
@@ -409,10 +377,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
error: e,
nodeDisplayName
})
}
}),
filterBySelection
)
},
filterBySelection
)
}
}
}
@@ -428,20 +396,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
String(e.node_id),
e.node_type,
'exec',
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
},
filterBySelection
)
}
@@ -723,7 +689,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
isCloud
})
}
@@ -840,9 +805,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
mediaTypes: filteredMissingMediaGroups.value.map(
(group) => group.mediaType
),
isCloud
})
}
@@ -871,10 +833,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const executionGroups = isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
@@ -887,7 +845,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...executionGroups
...toSortedGroups(groupsMap)
]
})
@@ -902,7 +860,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.displayMessage ?? err.message)
messages.add(err.message)
}
}
} else {

View File

@@ -3533,7 +3533,6 @@
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
"willBeReplacedBy": "This node will be replaced by:",
"replaceNode": "Replace Node",
"replaceAll": "Replace All",
@@ -3615,8 +3614,6 @@
"missingNodePacks": {
"title": "Missing Node Packs",
"unsupportedTitle": "Unsupported Node Packs",
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
"installAll": "Install All",
"installNodePack": "Install node pack",
@@ -3686,7 +3683,6 @@
"viewDetails": "View details",
"missingNodes": "Some nodes are missing and need to be installed",
"missingModels": "{count} required model is missing | {count} required models are missing",
"swapNodes": "Some nodes can be replaced with alternatives",
"missingMedia": "Some nodes are missing required inputs"
},
"errorCatalog": {
@@ -3694,6 +3690,46 @@
"nodeName": "This node",
"inputName": "unknown input"
},
"missingErrors": {
"missing_node": {
"displayMessageCloud": "Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
"displayMessageOss": "Install missing packs to use this workflow.",
"toastTitleOneCloud": "{nodeType} isn't available on Cloud",
"toastTitleOneOss": "Missing node: {nodeType}",
"toastTitleManyCloud": "Nodes aren't available on Cloud",
"toastTitleManyOss": "Missing nodes",
"toastMessageOneCloud": "This node isn't supported on Cloud.",
"toastMessageOneOss": "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node.",
"toastMessageManyCloud": "This workflow uses nodes that aren't supported on Cloud.",
"toastMessageManyOss": "{count} nodes require missing node packs."
},
"swap_nodes": {
"displayMessage": "Some nodes can be replaced with alternatives",
"toastTitleOne": "{nodeType} can be replaced",
"toastTitleMany": "Nodes can be replaced",
"toastMessageOne": "Replace it with {replacementNodeType} from the error panel.",
"toastMessageMany": "{count} node types can be replaced with compatible alternatives."
},
"missing_model": {
"displayMessageCloud": "Import a model, or open the node to replace it.",
"displayMessageOss": "Download a model, or open the node to replace it.",
"toastTitleOneCloud": "{modelName} isn't available on Cloud",
"toastTitleOneOss": "{modelName} is missing",
"toastTitleMany": "Missing models",
"toastTitleManyCloud": "Models aren't available on Cloud",
"toastMessageOneCloud": "This model isn't supported. Choose a different one.",
"toastMessageOneOss": "{nodeName} is missing a required model file.",
"toastMessageManyCloud": "Some models aren't supported. Choose different ones.",
"toastMessageManyOss": "{count} model files are missing."
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",
"toastMessageMany": "Please select the missing media inputs before running this workflow."
}
},
"validationErrors": {
"required_input_missing": {
"title": "Missing connection",
@@ -3770,6 +3806,15 @@
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} rejected the value for {inputName}."
},
"unknown_validation_error": {
"title": "Validation failed",
"message": "A node returned a validation error ComfyUI does not recognize.",
"details": "{nodeName} returned an unrecognized validation error: {errorType}",
"detailsWithRawDetails": "{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} returned an unrecognized validation error."
},
"exception_during_inner_validation": {
"title": "Validation failed",
"message": "The workflow couldn't validate a connected node.",

View File

@@ -2,6 +2,7 @@
// 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 UNKNOWN_VALIDATION_ERROR_CATALOG_ID = 'unknown_validation_error'
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'

View File

@@ -6,6 +6,9 @@ import {
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import { i18n } from '@/i18n'
function nodeValidationError(
@@ -55,6 +58,59 @@ function executionError(
}
}
function missingNodeType(
type: string,
nodeId: string,
cnrId?: string
): MissingNodeType {
return {
type,
nodeId,
cnrId,
isReplaceable: false
}
}
function replaceableNodeType(
type: string,
nodeId: string,
replacementNodeType: string
): MissingNodeType {
return {
type,
nodeId,
isReplaceable: true,
replacement: {
old_node_id: type,
new_node_id: replacementNodeType,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
}
}
function missingModelGroups(...names: string[]): MissingModelGroup[] {
return [
{
directory: 'checkpoints',
isAssetSupported: true,
models: names.map((name) => ({
name,
representative: {
name,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: []
}))
}
]
}
describe('errorMessageResolver', () => {
it('resolves required_input_missing to missing connection display copy', () => {
const result = resolveRunErrorMessage({
@@ -88,6 +144,26 @@ describe('errorMessageResolver', () => {
})
})
it('resolves unknown validation errors to fallback catalog copy', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError('value_not_valid', undefined, 'some detail'),
nodeDisplayName: 'KSampler'
})
).toEqual({
catalogId: 'unknown_validation_error',
displayTitle: 'Validation failed',
displayMessage:
'A node returned a validation error ComfyUI does not recognize.',
displayDetails:
'KSampler returned an unrecognized validation error (value_not_valid): some detail',
displayItemLabel: 'KSampler',
toastTitle: 'Validation failed',
toastMessage: 'KSampler returned an unrecognized validation error.'
})
})
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
const originalLocale = i18n.global.locale.value
const originalKoMessages = i18n.global.getLocaleMessage('ko')
@@ -1307,17 +1383,342 @@ describe('errorMessageResolver', () => {
})
it('resolves missing error group display copy', () => {
const missingNodeTypes = [missingNodeType('FooNode', '7', 'foo-pack')]
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: missingNodeTypes,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Missing Node Packs (1)',
displayMessage: 'Install missing packs to use this workflow.',
toastTitle: 'Missing node: FooNode',
toastMessage:
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: missingNodeTypes,
count: 1,
isCloud: true
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Unsupported Node Packs (1)',
displayMessage:
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
toastTitle: "FooNode isn't available on Cloud",
toastMessage: "This node isn't supported on Cloud."
})
const multipleMissingNodeTypes = [
missingNodeType('FooNode', '7', 'foo-pack'),
missingNodeType('BarNode', '9', 'bar-pack')
]
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: multipleMissingNodeTypes,
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing nodes',
toastMessage: '2 nodes require missing node packs.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: multipleMissingNodeTypes,
count: 2,
isCloud: true
})
).toMatchObject({
toastTitle: "Nodes aren't available on Cloud",
toastMessage: "This workflow uses nodes that aren't supported on Cloud."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: [
missingNodeType('FooNode', '7', 'foo-pack'),
missingNodeType('FooNode', '8', 'foo-pack')
],
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing node: FooNode',
toastMessage:
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
})
const swapNodeTypes = [replaceableNodeType('OldNode', '8', 'NewNode')]
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: swapNodeTypes,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'swap_nodes',
displayTitle: 'Swap Nodes (1)',
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
})
const multipleSwapNodeTypes = [
replaceableNodeType('OldNodeA', '8', 'NewNodeA'),
replaceableNodeType('OldNodeB', '9', 'NewNodeB')
]
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: multipleSwapNodeTypes,
count: 2,
isCloud: false
})
).toMatchObject({
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'Nodes can be replaced',
toastMessage: '2 node types can be replaced with compatible alternatives.'
})
expect(
resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: [
replaceableNodeType('OldNode', '8', 'NewNode'),
replaceableNodeType('OldNode', '9', 'NewNode')
],
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
})
const groups = missingModelGroups('sdxl.safetensors')
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: [],
groups,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayMessage: '1 required model is missing'
displayMessage: 'Download a model, or open the node to replace it.',
toastTitle: 'sdxl.safetensors is missing',
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups,
count: 1,
isCloud: true
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: "sdxl.safetensors isn't available on Cloud",
toastMessage: "This model isn't supported. Choose a different one."
})
})
it('resolves missing media group display and toast copy', () => {
const groups: MissingMediaGroup[] = [
{
mediaType: 'image',
items: [
{
name: 'portrait.png',
mediaType: 'image',
representative: {
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'portrait.png',
isMissing: true
},
referencingNodes: [{ nodeId: '4', widgetName: 'image' }]
}
]
}
]
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups,
count: 1,
isCloud: false
})
).toEqual({
catalogId: 'missing_media',
displayTitle: 'Missing Inputs (1)',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.'
})
})
it.for([
[
'image',
'LoadImage',
'image',
'portrait.png',
'Media input missing',
'Load Image is missing a required media file.'
],
[
'video',
'LoadVideo',
'file',
'clip.mp4',
'Media input missing',
'Load Video is missing a required media file.'
],
[
'audio',
'LoadAudio',
'audio',
'voice.wav',
'Media input missing',
'Load Audio is missing a required media file.'
]
] as const)(
'resolves missing %s toast copy from media type and node type',
([
mediaType,
nodeType,
widgetName,
mediaName,
toastTitle,
toastMessage
]) => {
const groups: MissingMediaGroup[] = [
{
mediaType,
items: [
{
name: mediaName,
mediaType,
representative: {
nodeId: '4',
nodeType,
widgetName,
mediaType,
name: mediaName,
isMissing: true
},
referencingNodes: [{ nodeId: '4', widgetName }]
}
]
}
]
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups,
count: 1,
isCloud: false
})
).toMatchObject({
toastTitle,
toastMessage
})
}
)
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: modelGroups,
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing models',
toastMessage: '2 model files are missing.'
})
expect(
resolveMissingErrorMessage({
kind: 'missing_model',
groups: modelGroups,
count: 2,
isCloud: true
})
).toMatchObject({
toastTitle: "Models aren't available on Cloud",
toastMessage: "Some models aren't supported. Choose different ones."
})
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'a.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'image' }]
},
{
name: 'b.png',
mediaType: 'image',
representative: {
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
},
referencingNodes: [{ nodeId: '2', widgetName: 'image' }]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
})
})
})

View File

@@ -1,4 +1,8 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
ResolvedErrorMessage,
RunErrorMessageSource
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
@@ -9,6 +13,15 @@ import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'execution' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage {

View File

@@ -1,4 +1,7 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
RunErrorMessageSource
} from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
@@ -11,7 +14,7 @@ type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ExecutionErrorResolveContext
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,

View File

@@ -2,19 +2,312 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { st, t } from '@/i18n'
import { translateCatalogMessage } from './catalogI18n'
import { st } 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
function formatNodeTypeName(nodeType: string): string | null {
const trimmed = nodeType.trim()
if (!trimmed) return null
return trimmed
.replace(/[_-]+/g, ' ')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim()
}
type NodeTypeErrorSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_node' | 'swap_nodes' }
>
type NodeTypeErrorItem = NodeTypeErrorSource['nodeTypes'][number]
function getNodeTypeLabel(nodeType: NodeTypeErrorItem): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function getDistinctNodeTypeLabels(nodeTypes: NodeTypeErrorItem[]): string[] {
const labels = new Set<string>()
for (const nodeType of nodeTypes) labels.add(getNodeTypeLabel(nodeType))
return Array.from(labels)
}
type MissingNodeSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_node' }
>
function isMissingNodeType(nodeType: NodeTypeErrorItem): boolean {
return typeof nodeType === 'string' || !nodeType.isReplaceable
}
function resolveMissingNodeDisplayMessage(source: MissingNodeSource): string {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.displayMessageCloud'
: 'errorCatalog.missingErrors.missing_node.displayMessageOss'
const fallback = source.isCloud
? "Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
: 'Install missing packs to use this workflow.'
return translateCatalogMessage(key, fallback)
}
function resolveMissingNodeToastTitle(source: MissingNodeSource): string {
const labels = getDistinctNodeTypeLabels(
source.nodeTypes.filter(isMissingNodeType)
)
const [firstLabel] = labels
if (labels.length === 1 && firstLabel) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastTitleOneCloud'
: 'errorCatalog.missingErrors.missing_node.toastTitleOneOss'
const fallback = source.isCloud
? "{nodeType} isn't available on Cloud"
: 'Missing node: {nodeType}'
return translateCatalogMessage(key, fallback, { nodeType: firstLabel })
}
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastTitleManyCloud'
: 'errorCatalog.missingErrors.missing_node.toastTitleManyOss'
const fallback = source.isCloud
? "Nodes aren't available on Cloud"
: 'Missing nodes'
return translateCatalogMessage(key, fallback)
}
function resolveMissingNodeToastMessage(source: MissingNodeSource): string {
const labels = getDistinctNodeTypeLabels(
source.nodeTypes.filter(isMissingNodeType)
)
const count = labels.length || source.count
if (count === 1) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastMessageOneCloud'
: 'errorCatalog.missingErrors.missing_node.toastMessageOneOss'
const fallback = source.isCloud
? "This node isn't supported on Cloud."
: "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
return translateCatalogMessage(key, fallback)
}
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_node.toastMessageManyCloud'
: 'errorCatalog.missingErrors.missing_node.toastMessageManyOss'
const fallback = source.isCloud
? "This workflow uses nodes that aren't supported on Cloud."
: '{count} nodes require missing node packs.'
return translateCatalogMessage(key, fallback, { count })
}
type SwapNodeSource = Extract<MissingErrorMessageSource, { kind: 'swap_nodes' }>
function isSwapNodeType(nodeType: NodeTypeErrorItem): nodeType is Exclude<
NodeTypeErrorItem,
string
> & {
isReplaceable: true
} {
return typeof nodeType !== 'string' && nodeType.isReplaceable === true
}
function getSwapNodeTypes(source: SwapNodeSource) {
return source.nodeTypes.filter(isSwapNodeType)
}
function resolveSwapNodeToastTitle(source: SwapNodeSource): string {
const nodeTypes = getSwapNodeTypes(source)
const labels = getDistinctNodeTypeLabels(nodeTypes)
const [firstLabel] = labels
if (labels.length === 1 && firstLabel) {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastTitleOne',
'{nodeType} can be replaced',
{ nodeType: firstLabel }
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastTitleMany',
'Nodes can be replaced'
)
}
function resolveSwapNodeToastMessage(source: SwapNodeSource): string {
const nodeTypes = getSwapNodeTypes(source)
const labels = getDistinctNodeTypeLabels(nodeTypes)
const [firstNodeType] = nodeTypes
if (labels.length === 1 && firstNodeType?.replacement?.new_node_id) {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastMessageOne',
'Replace it with {replacementNodeType} from the error panel.',
{ replacementNodeType: firstNodeType.replacement.new_node_id }
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.toastMessageMany',
'{count} node types can be replaced with compatible alternatives.',
{ count: labels.length || source.count }
)
}
function resolveSwapNodeDisplayMessage(): string {
return translateCatalogMessage(
'errorCatalog.missingErrors.swap_nodes.displayMessage',
'Some nodes can be replaced with alternatives'
)
}
type MissingModelSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_model' }
>
function getMissingModelCount(source: MissingModelSource): number {
const count = source.groups.reduce(
(total, group) => total + group.models.length,
0
)
return count || source.count
}
function resolveMissingModelDisplayMessage(source: MissingModelSource): string {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.displayMessageCloud'
: 'errorCatalog.missingErrors.missing_model.displayMessageOss'
const fallback = source.isCloud
? 'Import a model, or open the node to replace it.'
: 'Download a model, or open the node to replace it.'
return translateCatalogMessage(key, fallback)
}
function resolveMissingModelToastTitle(source: MissingModelSource): string {
const [firstModel] = source.groups.flatMap((group) => group.models)
const count = getMissingModelCount(source)
if (count === 1 && firstModel) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.toastTitleOneCloud'
: 'errorCatalog.missingErrors.missing_model.toastTitleOneOss'
const fallback = source.isCloud
? "{modelName} isn't available on Cloud"
: '{modelName} is missing'
return translateCatalogMessage(key, fallback, {
modelName: firstModel.name
})
}
const useCloudPluralTitle = source.isCloud && count > 1
const key = useCloudPluralTitle
? 'errorCatalog.missingErrors.missing_model.toastTitleManyCloud'
: 'errorCatalog.missingErrors.missing_model.toastTitleMany'
const fallback = useCloudPluralTitle
? "Models aren't available on Cloud"
: 'Missing models'
return translateCatalogMessage(key, fallback)
}
function getMissingModelNodeName(
model: MissingModelSource['groups'][number]['models'][number]
): string {
return (
formatNodeTypeName(model.representative.nodeType) ??
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}
function resolveMissingModelToastMessage(source: MissingModelSource): string {
const [firstModel] = source.groups.flatMap((group) => group.models)
const count = getMissingModelCount(source)
if (!firstModel || count !== 1) {
const key = source.isCloud
? 'errorCatalog.missingErrors.missing_model.toastMessageManyCloud'
: 'errorCatalog.missingErrors.missing_model.toastMessageManyOss'
const fallback = source.isCloud
? "Some models aren't supported. Choose different ones."
: '{count} model files are missing.'
return translateCatalogMessage(key, fallback, { count })
}
if (source.isCloud) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_model.toastMessageOneCloud',
"This model isn't supported. Choose a different one."
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_model.toastMessageOneOss',
'{nodeName} is missing a required model file.',
{ nodeName: getMissingModelNodeName(firstModel) }
)
}
type MissingMediaSource = Extract<
MissingErrorMessageSource,
{ kind: 'missing_media' }
>
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
function getMissingMediaNodeName(
item: ReturnType<typeof getMissingMediaItems>[number]
): string | null {
return formatNodeTypeName(item.representative.nodeType)
}
function resolveMissingMediaDisplayMessage(): string {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.displayMessage',
'A required media input has no file selected.'
)
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
)
}
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleOne',
'Media input missing'
)
}
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'
)
}
const nodeName = getMissingMediaNodeName(firstItem)
const displayNodeName =
nodeName ??
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageWithNode',
'{nodeName} is missing a required media file.',
{
nodeName: displayNodeName
}
)
}
export function resolveMissingErrorMessage(
@@ -33,10 +326,9 @@ export function resolveMissingErrorMessage(
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
displayMessage: resolveMissingNodeDisplayMessage(source),
toastTitle: resolveMissingNodeToastTitle(source),
toastMessage: resolveMissingNodeToastMessage(source)
}
case 'swap_nodes':
return {
@@ -45,10 +337,9 @@ export function resolveMissingErrorMessage(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
displayMessage: resolveSwapNodeDisplayMessage(),
toastTitle: resolveSwapNodeToastTitle(source),
toastMessage: resolveSwapNodeToastMessage(source)
}
case 'missing_model':
return {
@@ -60,7 +351,9 @@ export function resolveMissingErrorMessage(
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
displayMessage: resolveMissingModelDisplayMessage(source),
toastTitle: resolveMissingModelToastTitle(source),
toastMessage: resolveMissingModelToastMessage(source)
}
case 'missing_media':
return {
@@ -69,10 +362,9 @@ export function resolveMissingErrorMessage(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
displayMessage: resolveMissingMediaDisplayMessage(),
toastTitle: resolveMissingMediaToastTitle(source),
toastMessage: resolveMissingMediaToastMessage(source)
}
}
}

View File

@@ -1,4 +1,4 @@
import type { ResolvedErrorMessage } from './types'
import type { ResolvedCatalogErrorMessage } from './types'
import {
normalizeNodeName,
@@ -19,7 +19,7 @@ export function resolveRuntimeCatalogCopy(
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
@@ -27,7 +27,7 @@ export function resolveRuntimeCatalogCopy(
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const displayMessage = resolveMessage('message')
const result: ResolvedErrorMessage = {
const result: ResolvedCatalogErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage

View File

@@ -3,10 +3,7 @@ import type {
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
@@ -28,6 +25,10 @@ export interface ResolvedErrorMessage {
toastMessage?: string
}
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
catalogId: string
}
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
displayTitle: string
displayMessage: string
@@ -73,6 +74,5 @@ export type MissingErrorMessageSource =
kind: 'missing_media'
groups: MissingMediaGroup[]
count: number
mediaTypes: MediaType[]
isCloud: boolean
}

View File

@@ -1,8 +1,9 @@
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import type { NodeValidationError, ResolvedCatalogErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID
MISSING_CONNECTION_CATALOG_ID,
UNKNOWN_VALIDATION_ERROR_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
@@ -117,6 +118,11 @@ const IMAGE_NOT_LOADED_VALIDATION_RULE = {
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
const UNKNOWN_VALIDATION_ERROR_RULE = {
catalogId: UNKNOWN_VALIDATION_ERROR_CATALOG_ID,
itemLabel: 'node'
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
@@ -272,7 +278,7 @@ function resolveValidationCatalogCopy(
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details.trim()
@@ -282,6 +288,7 @@ function resolveValidationCatalogCopy(
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
errorType: error.type,
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
@@ -330,7 +337,7 @@ function resolveValidationCatalogCopy(
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
@@ -341,7 +348,17 @@ export function resolveNodeValidationErrorMessage(
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
if (!rule) {
return resolveValidationCatalogCopy(
error,
context,
'unknown_validation_error',
{
...UNKNOWN_VALIDATION_ERROR_RULE,
copyKeys: getRawDetailsOnlyCopyKeys(error)
}
)
}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}

View File

@@ -422,6 +422,7 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
const otherGroup = result.find((g) => g.name === 'other.png')
expect(otherGroup?.referencingNodes).toHaveLength(1)

View File

@@ -262,6 +262,7 @@ export function groupCandidatesByName(
map.set(c.name, {
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
})
}

View File

@@ -27,6 +27,7 @@ export interface MissingMediaCandidate {
export interface MissingMediaViewModel {
name: string
mediaType: MediaType
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
widgetName: string

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
@@ -19,14 +18,6 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
import SwapNodesCard from './SwapNodesCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function makeGroups(count = 2): SwapNodeGroup[] {
return Array.from({ length: count }, (_, i) => ({
type: `Type${i}`,
@@ -56,19 +47,13 @@ function mountCard(
...(callbacks?.onReplace ? { onReplace: callbacks.onReplace } : {})
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n]
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue]
}
})
}
describe('SwapNodesCard', () => {
describe('Rendering', () => {
it('renders guidance message', () => {
const { container } = mountCard()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('p')).not.toBeNull()
})
it('renders correct number of SwapNodeGroupRow components', () => {
const { container } = mountCard({ swapNodeGroups: makeGroups(3) })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access

View File

@@ -1,15 +1,5 @@
<template>
<div class="mt-2 px-4 pb-2">
<!-- Sub-label: guidance message shown above all swap groups -->
<p class="m-0 pb-5 text-sm/relaxed text-muted-foreground">
{{
t(
'nodeReplacement.swapNodesGuide',
'The following nodes can be automatically replaced with compatible alternatives.'
)
}}
</p>
<!-- Group Rows -->
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"
@@ -22,12 +12,9 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { t } = useI18n()
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean