mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 23:20:04 +00:00
feat: add node replacement UI to Errors Tab (#9253)
## Summary Adds a node replacement UI to the Errors Tab so users can swap missing nodes with compatible alternatives directly from the error panel, without opening a separate dialog. ## Changes - **What**: New `SwapNodesCard` and `SwapNodeGroupRow` components render swap groups in the Errors Tab; each group shows the missing node type, its instances (with locate buttons), and a Replace button. Added `useMissingNodeScan` composable to scan the graph for missing nodes and populate `executionErrorStore`. Added `removeMissingNodesByType()` to `executionErrorStore` so replaced nodes are pruned from the error list reactively. ## Bug Fixes Found During Implementation ### Bug 1: Replaced nodes render as empty shells until page refresh `replaceWithMapping()` directly mutates `_nodes[idx]`, bypassing the Vue rendering pipeline entirely. Because the replacement node reuses the same ID, `vueNodeData` retains the stale entry from the old placeholder (`hasErrors: true`, empty widgets/inputs). `graph.setDirtyCanvas()` only repaints the LiteGraph canvas and has no effect on Vue. **Fix**: After `replaceWithMapping()`, manually call `nodeGraph.onNodeAdded?.(newNode)` to trigger `handleNodeAdded` in `useGraphNodeManager`, which runs `extractVueNodeData(newNode)` and updates `vueNodeData` correctly. Also added a guard in `handleNodeAdded` to skip `layoutStore.createNode()` when a layout for the same ID already exists, preventing a duplicate `spatialIndex.insert()`. ### Bug 2: Missing node error list overwritten by incomplete server response Two compounding issues: (A) the server's `missing_node_type` error only reports the *first* missing node — the old handler parsed this and called `surfaceMissingNodes([singleNode])`, overwriting the full list collected at load time. (B) `queuePrompt()` calls `clearAllErrors()` before the API request; if the subsequent rescan used the stale `has_errors` flag and found nothing, the missing nodes were permanently lost. **Fix**: Created `useMissingNodeScan.ts` which scans `LiteGraph.registered_node_types` directly (not `has_errors`). The `missing_node_type` catch block in `app.ts` now calls `rescanAndSurfaceMissingNodes(this.rootGraph)` instead of parsing the server's partial response. ## Review Focus - `handleReplaceNode` removes the group from the store only when `replaceNodesInPlace` returns at least one replaced node — should we always clear, or only on full success? - `useMissingNodeScan` re-scans on every execution-error change; confirm no performance concerns for large graphs with many subgraphs. ## Screenshots https://github.com/user-attachments/assets/78310fc4-0424-4920-b369-cef60a123d50 https://github.com/user-attachments/assets/3d2fd5e1-5e85-4c20-86aa-8bf920e86987 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9253-feat-add-node-replacement-UI-to-Errors-Tab-3136d73d365081718d4ddfd628cb4449) by [Unito](https://www.unito.io)
This commit is contained in:
150
src/components/rightSidePanel/errors/SwapNodeGroupRow.vue
Normal file
150
src/components/rightSidePanel/errors/SwapNodeGroupRow.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full mb-4">
|
||||
<!-- Type header row: type name + chevron -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<p
|
||||
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap text-foreground"
|
||||
>
|
||||
{{ `${group.type} (${group.nodeTypes.length})` }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
{ 'rotate-180': expanded }
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
|
||||
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
|
||||
"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-labels: individual node instances, each with their own Locate button -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="flex flex-col gap-0.5 pl-2 mb-2 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="nodeType in group.nodeTypes"
|
||||
:key="getKey(nodeType)"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showNodeIdBadge &&
|
||||
typeof nodeType !== 'string' &&
|
||||
nodeType.nodeId != null
|
||||
"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
|
||||
>
|
||||
#{{ nodeType.nodeId }}
|
||||
</span>
|
||||
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{{ getLabel(nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
|
||||
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
|
||||
@click="handleLocateNode(nodeType)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Description rows: what it is replaced by -->
|
||||
<div class="flex flex-col text-[13px] mb-2 mt-1 px-1 gap-0.5">
|
||||
<span class="text-muted-foreground">{{
|
||||
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
|
||||
}}</span>
|
||||
<span class="font-bold text-foreground">{{
|
||||
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Replace Action Button -->
|
||||
<div class="flex items-start w-full pt-1 pb-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex flex-1 w-full"
|
||||
@click="handleReplaceNode"
|
||||
>
|
||||
<i class="icon-[lucide--repeat] size-4 text-foreground shrink-0 mr-1" />
|
||||
<span class="text-sm text-foreground truncate min-w-0">
|
||||
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const props = defineProps<{
|
||||
group: SwapNodeGroup
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate-node': [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpand() {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function getKey(nodeType: MissingNodeType): string {
|
||||
if (typeof nodeType === 'string') return nodeType
|
||||
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
|
||||
}
|
||||
|
||||
function getLabel(nodeType: MissingNodeType): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function handleLocateNode(nodeType: MissingNodeType) {
|
||||
if (typeof nodeType === 'string') return
|
||||
if (nodeType.nodeId != null) {
|
||||
emit('locate-node', String(nodeType.nodeId))
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceNode() {
|
||||
const replaced = replaceNodesInPlace(props.group.nodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType([props.group.type])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
38
src/components/rightSidePanel/errors/SwapNodesCard.vue
Normal file
38
src/components/rightSidePanel/errors/SwapNodesCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2 mt-2">
|
||||
<!-- Sub-label: guidance message shown above all swap groups -->
|
||||
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
|
||||
{{
|
||||
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"
|
||||
:group="group"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locate-node', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate-node': [nodeId: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -27,7 +27,11 @@
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="group.type === 'missing_node' ? 'lg' : 'default'"
|
||||
:size="
|
||||
group.type === 'missing_node' || group.type === 'swap_nodes'
|
||||
? 'lg'
|
||||
: 'default'
|
||||
"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
@@ -40,7 +44,9 @@
|
||||
{{
|
||||
group.type === 'missing_node'
|
||||
? `${group.title} (${missingPackGroups.length})`
|
||||
: group.title
|
||||
: group.type === 'swap_nodes'
|
||||
? `${group.title} (${swapNodeGroups.length})`
|
||||
: group.title
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
@@ -69,6 +75,21 @@
|
||||
: 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="shrink-0 mr-2 h-8 rounded-lg text-sm"
|
||||
@click.stop="handleReplaceAll()"
|
||||
>
|
||||
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,8 +103,16 @@
|
||||
@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"
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-else class="px-4 space-y-3">
|
||||
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
@@ -150,11 +179,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from './SwapNodesCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -167,6 +199,8 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
usePackInstall(() => missingNodePacks.value)
|
||||
const { replaceNodesInPlace } = useNodeReplacement()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -183,7 +217,8 @@ const {
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups
|
||||
missingPackGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
/**
|
||||
@@ -229,6 +264,14 @@ function handleOpenManagerInfo(packId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplaceAll() {
|
||||
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
|
||||
const replaced = replaceNodesInPlace(allNodeTypes)
|
||||
if (replaced.length > 0) {
|
||||
executionErrorStore.removeMissingNodesByType(replaced)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@ export type ErrorGroup =
|
||||
priority: number
|
||||
}
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface MissingPackGroup {
|
||||
isResolving: boolean
|
||||
}
|
||||
|
||||
export interface SwapNodeGroup {
|
||||
type: string
|
||||
newNodeId: string | undefined
|
||||
nodeTypes: MissingNodeType[]
|
||||
}
|
||||
|
||||
interface GroupEntry {
|
||||
type: 'execution'
|
||||
priority: number
|
||||
@@ -444,6 +450,8 @@ export function useErrorGroups(
|
||||
const resolvingKeys = new Set<string | null>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
|
||||
|
||||
let packId: string | null
|
||||
|
||||
if (typeof nodeType === 'string') {
|
||||
@@ -495,18 +503,53 @@ export function useErrorGroups(
|
||||
}))
|
||||
})
|
||||
|
||||
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
|
||||
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||
const map = new Map<string, SwapNodeGroup>()
|
||||
|
||||
for (const nodeType of nodeTypes) {
|
||||
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
|
||||
|
||||
const typeName = nodeType.type
|
||||
const existing = map.get(typeName)
|
||||
if (existing) {
|
||||
existing.nodeTypes.push(nodeType)
|
||||
} else {
|
||||
map.set(typeName, {
|
||||
type: typeName,
|
||||
newNodeId: nodeType.replacement?.new_node_id,
|
||||
nodeTypes: [nodeType]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
|
||||
})
|
||||
|
||||
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||
const error = executionErrorStore.missingNodesError
|
||||
if (!error) return []
|
||||
|
||||
return [
|
||||
{
|
||||
const groups: ErrorGroup[] = []
|
||||
|
||||
if (swapNodeGroups.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'swap_nodes' as const,
|
||||
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
|
||||
if (missingPackGroups.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'missing_node' as const,
|
||||
title: error.message,
|
||||
priority: 0
|
||||
}
|
||||
]
|
||||
priority: 1
|
||||
})
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -564,6 +607,7 @@ export function useErrorGroups(
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups
|
||||
missingPackGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user