mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 00:14:55 +00:00
## Summary This PR introduces the frontend error catalog display resolver as the foundation for the DES-220 / FE-816 error messaging work. The main goal is to create a single FE boundary where raw Core/Cloud error payloads can be converted into human-friendly display fields, while preserving the original API contract fields (`message` and `details`) unchanged. UI components can now prefer resolved display copy when it exists and fall back to the raw API copy otherwise. As a small concrete sample, this PR implements the first cataloged validation error: - `required_input_missing` is resolved as the `missing_connection` catalog item. - Panel title: `Missing connection` - Panel message: `Required input slots have no connection feeding them.` - Detail/item copy can include the node and input name, e.g. `KSampler is missing a required input: model` and `KSampler - model`. - Single-error toast/overlay-oriented fields are added to the data model for follow-up UI work, but this PR does not redesign the overlay. ## What This PR Targets This PR is intentionally scoped as the skeleton PR for the error catalog UX system. It adds: - A new resolver module under `src/platform/errorCatalog`. - Shared resolved display fields: - `catalogId` - `displayTitle` - `displayMessage` - `displayDetails` - `displayItemLabel` - `toastTitle` - `toastMessage` - A resolver entry point for run-time workflow errors: - node validation errors - execution/runtime errors - prompt errors - A resolver entry point for pre-run missing-resource groups: - missing node packs - swap nodes - missing models - missing media - Error group wiring so `useErrorGroups` resolves display copy in one place instead of making UI components own message decisions. - The first real validation rule for `required_input_missing` / Missing connection. - The existing prompt error copy moved into the `errorCatalog.promptErrors` namespace in `src/locales/en/main.json`. - Tests covering the resolver, grouping behavior, panel rendering, prompt error copy, missing group copy, and fallback behavior. ## What This PR Deliberately Does Not Target This PR avoids the larger UX and product behavior changes so the foundation can land separately. It does not: - Redesign the error overlay. - Redesign the right-side error panel. - Change the shape of Core/Cloud API error payloads. - Replace raw `message` / `details`; those remain intact for API-contract alignment and technical debugging. - Re-group execution errors by final message type yet. - Add special runtime error messaging for credits, timeouts, content policy, OOM, or rate limits. - Render the new `displayItemLabel` everywhere it will eventually be useful. ## User-Facing Behavior Most behavior is preserved. The main visible change is for missing required input validation errors. Those now display as Missing connection copy instead of exposing the raw validation message directly. Prompt errors should keep the same user-facing wording as before, but the source of that wording now lives under the error catalog namespace. Missing node/model/media/swap-node groups still preserve the existing titles, counts, and friendly messages, but their display copy now flows through the same resolver boundary. Execution/runtime errors receive catalog fields for future toast/overlay usage, but the current runtime overlay path intentionally keeps the raw technical error copy until the overlay redesign PR decides how to consume the new fields. ## Screenshots Before <img width="505" height="266" alt="스크린샷 2026-05-22 오후 2 15 27" src="https://github.com/user-attachments/assets/09e8eb31-dca4-42d8-8237-9474cb71a14c" /> <img width="463" height="317" alt="스크린샷 2026-05-22 오후 2 16 09" src="https://github.com/user-attachments/assets/c0a0159e-5bd9-4b3f-9c21-c0040373fbca" /> After <img width="482" height="297" alt="스크린샷 2026-05-22 오후 2 14 25" src="https://github.com/user-attachments/assets/4ca10bf0-29d2-4b65-940e-0d78db3fd278" /> <img width="460" height="194" alt="스크린샷 2026-05-22 오후 2 16 55" src="https://github.com/user-attachments/assets/20848054-5012-4dd3-b903-ef8c920f70c8" /> ## Follow-Up PR Plan This PR is the first stacked PR in the error catalog work. Follow-up PRs are expected to build on this foundation in roughly this order: 1. Expand general execution error messaging. - Add broader validation error handling beyond `required_input_missing`, including list/range/value validation cases. - Add general runtime execution messaging. - Continue migrating prompt error display decisions into the catalog resolver. 2. Add special runtime error messaging. - Credits / insufficient credits. - Timeout. - Content not allowed / blocked content. - Server crash. - Out of memory. - Rate limiting. - Other high-volume Cloud-only runtime failures from DES-220. 3. Re-group execution errors by message/catalog type. - Move away from grouping primarily by node class when the cataloged error type is the more useful user-facing grouping key. - Keep raw technical details available inside cards/logs. 4. Update the error overlay behavior. - Use `toastTitle` and `toastMessage` for single-error cases. - Use aggregate copy such as "N errors found" for multi-error cases. - Add node navigation affordances where appropriate. 5. Update the right-side error panel design. - Render resolved item labels such as `Node name - Input name`. - Align expanded card details and logs with the new design. - Preserve copy/debug affordances for technical details. 6. Fold in related missing media/model/node messaging improvements. - FE-583 should become a child/follow-up issue in this stack for improving missing image/media messaging. ## Validation - `pnpm format` - `pnpm lint` - `pnpm typecheck` - `pnpm test:unit` - Targeted resolver/grouping tests during review iterations - `pnpm knip` `pnpm knip` passes with only the pre-existing tag hint: `Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes`
440 lines
15 KiB
Vue
440 lines
15 KiB
Vue
<template>
|
|
<div class="flex h-full min-w-0 flex-col">
|
|
<!-- Search bar + collapse toggle -->
|
|
<div
|
|
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
|
>
|
|
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
|
|
<CollapseToggleButton
|
|
v-model="isAllCollapsed"
|
|
:show="!isSearching && tabErrorGroups.length > 1"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Runtime error: full-height panel outside accordion -->
|
|
<div
|
|
v-if="singleRuntimeErrorCard"
|
|
data-testid="runtime-error-panel"
|
|
aria-live="polite"
|
|
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
|
>
|
|
<div
|
|
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
|
>
|
|
{{ singleRuntimeErrorGroup?.displayTitle }}
|
|
</div>
|
|
<ErrorNodeCard
|
|
:key="singleRuntimeErrorCard.id"
|
|
:card="singleRuntimeErrorCard"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
full-height
|
|
class="min-h-0 flex-1"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@copy-to-clipboard="copyToClipboard"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Scrollable content (non-runtime or mixed errors) -->
|
|
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
|
<TransitionGroup tag="div" name="list-scale" class="relative">
|
|
<div
|
|
v-if="filteredGroups.length === 0"
|
|
key="empty"
|
|
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
|
>
|
|
{{
|
|
searchQuery.trim()
|
|
? t('rightSidePanel.noneSearchDesc')
|
|
: t('rightSidePanel.noErrors')
|
|
}}
|
|
</div>
|
|
|
|
<!-- Group by Class Type -->
|
|
<PropertiesAccordionItem
|
|
v-for="group in filteredGroups"
|
|
:key="group.groupKey"
|
|
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
|
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
|
|
class="border-b border-interface-stroke"
|
|
:size="getGroupSize(group)"
|
|
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
|
|
>
|
|
<template #label>
|
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
<i
|
|
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
|
/>
|
|
<span class="truncate text-destructive-background-hover">
|
|
{{ group.displayTitle }}
|
|
</span>
|
|
<span
|
|
v-if="group.type === 'execution' && group.cards.length > 1"
|
|
class="text-destructive-background-hover"
|
|
>
|
|
({{ group.cards.length }})
|
|
</span>
|
|
</span>
|
|
<Button
|
|
v-if="
|
|
group.type === 'missing_node' &&
|
|
missingNodePacks.length > 0 &&
|
|
shouldShowInstallButton
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
:disabled="isInstallingAll"
|
|
@click.stop="installAll"
|
|
>
|
|
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
|
{{
|
|
isInstallingAll
|
|
? t('rightSidePanel.missingNodePacks.installing')
|
|
: t('rightSidePanel.missingNodePacks.installAll')
|
|
}}
|
|
</Button>
|
|
<Button
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
v-tooltip.top="
|
|
t(
|
|
'nodeReplacement.replaceAllWarning',
|
|
'Replaces all available nodes in this group.'
|
|
)
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
@click.stop="handleReplaceAll()"
|
|
>
|
|
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
|
</Button>
|
|
<Button
|
|
v-else-if="
|
|
group.type === 'missing_model' &&
|
|
showMissingModelHeaderRefresh
|
|
"
|
|
data-testid="missing-model-header-refresh"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
:aria-busy="missingModelStore.isRefreshingMissingModels"
|
|
:aria-disabled="missingModelStore.isRefreshingMissingModels"
|
|
@click.stop="handleMissingModelRefresh"
|
|
>
|
|
<DotSpinner
|
|
v-if="missingModelStore.isRefreshingMissingModels"
|
|
aria-hidden="true"
|
|
duration="1s"
|
|
:size="12"
|
|
/>
|
|
<i
|
|
v-else
|
|
aria-hidden="true"
|
|
class="icon-[lucide--refresh-cw] size-4 shrink-0"
|
|
/>
|
|
{{ t('rightSidePanel.missingModels.refresh') }}
|
|
</Button>
|
|
<span
|
|
v-if="
|
|
group.type === 'missing_model' &&
|
|
showMissingModelHeaderRefresh
|
|
"
|
|
role="status"
|
|
aria-live="polite"
|
|
class="sr-only"
|
|
>
|
|
{{
|
|
missingModelStore.isRefreshingMissingModels
|
|
? t('rightSidePanel.missingModels.refreshing')
|
|
: ''
|
|
}}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Missing Node Packs -->
|
|
<MissingNodeCard
|
|
v-if="group.type === 'missing_node'"
|
|
:show-info-button="shouldShowManagerButtons"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:missing-pack-groups="missingPackGroups"
|
|
@locate-node="handleLocateMissingNode"
|
|
@open-manager-info="handleOpenManagerInfo"
|
|
/>
|
|
|
|
<!-- Swap Nodes -->
|
|
<SwapNodesCard
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
:swap-node-groups="swapNodeGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateMissingNode"
|
|
@replace="handleReplaceGroup"
|
|
/>
|
|
|
|
<!-- Execution Errors -->
|
|
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
|
<ErrorNodeCard
|
|
v-for="card in group.cards"
|
|
:key="card.id"
|
|
:card="card"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:compact="isSingleNodeSelected"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@copy-to-clipboard="copyToClipboard"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Missing Models -->
|
|
<MissingModelCard
|
|
v-else-if="group.type === 'missing_model'"
|
|
:missing-model-groups="missingModelGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-model="handleLocateAssetNode"
|
|
/>
|
|
|
|
<!-- Missing Media -->
|
|
<MissingMediaCard
|
|
v-else-if="group.type === 'missing_media'"
|
|
:missing-media-groups="missingMediaGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateAssetNode"
|
|
/>
|
|
</PropertiesAccordionItem>
|
|
</TransitionGroup>
|
|
</div>
|
|
|
|
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
|
|
|
|
<!-- Fixed Footer: Help Links -->
|
|
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
|
<i18n-t
|
|
keypath="rightSidePanel.errorHelp"
|
|
tag="p"
|
|
class="m-0 text-sm/tight wrap-break-word text-muted-foreground"
|
|
>
|
|
<template #github>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline text-sm whitespace-nowrap text-inherit underline"
|
|
@click="openGitHubIssues"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpGithub') }}
|
|
</Button>
|
|
</template>
|
|
<template #support>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline text-sm whitespace-nowrap text-inherit underline"
|
|
@click="contactSupport"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpSupport') }}
|
|
</Button>
|
|
</template>
|
|
</i18n-t>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
|
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
import { NodeBadgeMode } from '@/types/nodeSource'
|
|
|
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
|
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
|
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
|
import ErrorNodeCard from './ErrorNodeCard.vue'
|
|
import MissingNodeCard from './MissingNodeCard.vue'
|
|
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
|
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
|
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
|
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
|
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
|
import { useErrorActions } from './useErrorActions'
|
|
import { useErrorGroups } from './useErrorGroups'
|
|
import type { SwapNodeGroup } from './useErrorGroups'
|
|
import type { ErrorGroup } from './types'
|
|
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
|
|
|
const ErrorPanelSurveyCta =
|
|
isNightly && !isCloud && !isDesktop
|
|
? defineAsyncComponent(
|
|
() => import('@/platform/surveys/ErrorPanelSurveyCta.vue')
|
|
)
|
|
: undefined
|
|
|
|
const { t } = useI18n()
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
const { focusNode, enterSubgraph } = useFocusNode()
|
|
const { openGitHubIssues, contactSupport } = useErrorActions()
|
|
const settingStore = useSettingStore()
|
|
const rightSidePanelStore = useRightSidePanelStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
|
useManagerState()
|
|
const { missingNodePacks } = useMissingNodes()
|
|
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
|
usePackInstall(() => missingNodePacks.value)
|
|
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
|
|
|
const searchQuery = ref('')
|
|
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
|
|
|
const fullSizeGroupTypes = new Set([
|
|
'missing_node',
|
|
'swap_nodes',
|
|
'missing_model',
|
|
'missing_media'
|
|
])
|
|
function getGroupSize(group: ErrorGroup) {
|
|
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
|
}
|
|
|
|
const showNodeIdBadge = computed(
|
|
() =>
|
|
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
|
NodeBadgeMode.None
|
|
)
|
|
|
|
const {
|
|
allErrorGroups,
|
|
tabErrorGroups,
|
|
filteredGroups,
|
|
collapseState,
|
|
isSingleNodeSelected,
|
|
errorNodeCache,
|
|
missingNodeCache,
|
|
missingPackGroups,
|
|
filteredMissingModelGroups: missingModelGroups,
|
|
filteredMissingMediaGroups: missingMediaGroups,
|
|
swapNodeGroups
|
|
} = useErrorGroups(searchQuery)
|
|
|
|
const missingModelDownloadableModels = computed(() => {
|
|
if (isCloud) return []
|
|
|
|
return getDownloadableModels(missingModelGroups.value)
|
|
})
|
|
|
|
const showMissingModelHeaderRefresh = computed(
|
|
() =>
|
|
!isCloud &&
|
|
missingModelGroups.value.length > 0 &&
|
|
missingModelDownloadableModels.value.length === 0
|
|
)
|
|
|
|
function handleMissingModelRefresh() {
|
|
void missingModelStore.refreshMissingModels()
|
|
}
|
|
|
|
const singleRuntimeErrorGroup = computed(() => {
|
|
if (filteredGroups.value.length !== 1) return null
|
|
const group = filteredGroups.value[0]
|
|
const isSoleRuntimeError =
|
|
group.type === 'execution' &&
|
|
group.cards.length === 1 &&
|
|
group.cards[0].errors.every((e) => e.isRuntimeError)
|
|
return isSoleRuntimeError ? group : null
|
|
})
|
|
|
|
const singleRuntimeErrorCard = computed(
|
|
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
|
)
|
|
|
|
const isAllCollapsed = computed({
|
|
get() {
|
|
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
|
},
|
|
set(collapse: boolean) {
|
|
for (const group of tabErrorGroups.value) {
|
|
setSectionCollapsed(group.groupKey, collapse)
|
|
}
|
|
}
|
|
})
|
|
|
|
function isSectionCollapsed(groupKey: string): boolean {
|
|
// Defaults to expanded when not explicitly set by the user
|
|
return collapseState[groupKey] ?? false
|
|
}
|
|
|
|
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
|
|
collapseState[groupKey] = collapsed
|
|
}
|
|
|
|
/**
|
|
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
|
* sets focusedErrorNodeId, expand only the group containing the target
|
|
* node and collapse all others so the user sees the relevant errors
|
|
* immediately.
|
|
*/
|
|
watch(
|
|
() => rightSidePanelStore.focusedErrorNodeId,
|
|
(graphNodeId) => {
|
|
if (!graphNodeId) return
|
|
const prefix = `${graphNodeId}:`
|
|
for (const group of allErrorGroups.value) {
|
|
if (group.type !== 'execution') continue
|
|
|
|
const hasMatch = group.cards.some(
|
|
(card) =>
|
|
card.graphNodeId === graphNodeId ||
|
|
(card.nodeId?.startsWith(prefix) ?? false)
|
|
)
|
|
setSectionCollapsed(group.groupKey, !hasMatch)
|
|
}
|
|
rightSidePanelStore.focusedErrorNodeId = null
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function handleLocateNode(nodeId: string) {
|
|
focusNode(nodeId, errorNodeCache.value)
|
|
}
|
|
|
|
function handleLocateMissingNode(nodeId: string) {
|
|
focusNode(nodeId, missingNodeCache.value)
|
|
}
|
|
|
|
function handleLocateAssetNode(nodeId: string) {
|
|
focusNode(nodeId)
|
|
}
|
|
|
|
function handleOpenManagerInfo(packId: string) {
|
|
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
|
if (isKnownToRegistry) {
|
|
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
|
} else {
|
|
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
|
}
|
|
}
|
|
|
|
function handleReplaceGroup(group: SwapNodeGroup) {
|
|
replaceGroup(group)
|
|
}
|
|
|
|
function handleReplaceAll() {
|
|
replaceAllGroups(swapNodeGroups.value)
|
|
}
|
|
|
|
function handleEnterSubgraph(nodeId: string) {
|
|
enterSubgraph(nodeId, errorNodeCache.value)
|
|
}
|
|
</script>
|