mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
feat: show missing node packs in Errors Tab with install support (#9213)
## Summary Surfaces missing node pack information in the Errors Tab, grouped by registry pack, with one-click install support via ComfyUI Manager. ## Changes - **What**: Errors Tab now groups missing nodes by their registry pack and shows a `MissingPackGroupRow` with pack name, node/pack counts, and an Install button that triggers Manager installation. A `MissingNodeCard` shows individual unresolvable nodes that have no associated pack. `useErrorGroups` was extended to resolve missing node types to their registry packs using the `/api/workflow/missing_nodes` endpoint. `executionErrorStore` was refactored to track missing node types separately from execution errors and expose them reactively. - **Breaking**: None ## Review Focus - `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches pack metadata and maps node types to pack IDs; edge cases around partial resolution (some nodes have a pack, some don't) produce both `MissingPackGroupRow` and `MissingNodeCard` entries - `executionErrorStore.ts` — the store now separates `missingNodeTypes` state from `errors`; the deferred-warnings path in `app.ts` now calls `setMissingNodeTypes` so the Errors Tab is populated even when a workflow loads without executing ## Screenshots (if applicable) https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -40,7 +40,8 @@ const rightSidePanelStore = useRightSidePanelStore()
|
|||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||||
|
storeToRefs(executionErrorStore)
|
||||||
|
|
||||||
const { findParentGroup } = useGraphHierarchy()
|
const { findParentGroup } = useGraphHierarchy()
|
||||||
|
|
||||||
@@ -109,9 +110,21 @@ const hasContainerInternalError = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasMissingNodeSelected = computed(
|
||||||
|
() =>
|
||||||
|
hasSelection.value &&
|
||||||
|
selectedNodes.value.some((node) =>
|
||||||
|
activeMissingNodeGraphIds.value.has(String(node.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const hasRelevantErrors = computed(() => {
|
const hasRelevantErrors = computed(() => {
|
||||||
if (!hasSelection.value) return hasAnyError.value
|
if (!hasSelection.value) return hasAnyError.value
|
||||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
return (
|
||||||
|
hasDirectNodeError.value ||
|
||||||
|
hasContainerInternalError.value ||
|
||||||
|
hasMissingNodeSelected.value
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabs = computed<RightSidePanelTabList>(() => {
|
const tabs = computed<RightSidePanelTabList>(() => {
|
||||||
|
|||||||
@@ -17,24 +17,26 @@
|
|||||||
>
|
>
|
||||||
{{ card.nodeTitle }}
|
{{ card.nodeTitle }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div class="flex items-center shrink-0">
|
||||||
v-if="card.isSubgraphNode"
|
<Button
|
||||||
variant="secondary"
|
v-if="card.isSubgraphNode"
|
||||||
size="sm"
|
variant="secondary"
|
||||||
class="rounded-lg text-sm shrink-0"
|
size="sm"
|
||||||
@click.stop="handleEnterSubgraph"
|
class="rounded-lg text-sm shrink-0 h-8"
|
||||||
>
|
@click.stop="handleEnterSubgraph"
|
||||||
{{ t('rightSidePanel.enterSubgraph') }}
|
>
|
||||||
</Button>
|
{{ t('rightSidePanel.enterSubgraph') }}
|
||||||
<Button
|
</Button>
|
||||||
variant="textonly"
|
<Button
|
||||||
size="icon-sm"
|
variant="textonly"
|
||||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
size="icon-sm"
|
||||||
:aria-label="t('rightSidePanel.locateNode')"
|
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||||
@click.stop="handleLocateNode"
|
:aria-label="t('rightSidePanel.locateNode')"
|
||||||
>
|
@click.stop="handleLocateNode"
|
||||||
<i class="icon-[lucide--locate] size-3.5" />
|
>
|
||||||
</Button>
|
<i class="icon-[lucide--locate] size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Multiple Errors within one Card -->
|
<!-- Multiple Errors within one Card -->
|
||||||
|
|||||||
79
src/components/rightSidePanel/errors/MissingNodeCard.vue
Normal file
79
src/components/rightSidePanel/errors/MissingNodeCard.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-4 pb-2">
|
||||||
|
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||||
|
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{{
|
||||||
|
isCloud
|
||||||
|
? t('rightSidePanel.missingNodePacks.cloudMessage')
|
||||||
|
: t('rightSidePanel.missingNodePacks.ossMessage')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<MissingPackGroupRow
|
||||||
|
v-for="group in missingPackGroups"
|
||||||
|
:key="group.packId ?? '__unknown__'"
|
||||||
|
:group="group"
|
||||||
|
:show-info-button="showInfoButton"
|
||||||
|
:show-node-id-badge="showNodeIdBadge"
|
||||||
|
@locate-node="emit('locateNode', $event)"
|
||||||
|
@open-manager-info="emit('openManagerInfo', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
|
||||||
|
<div v-if="shouldShowManagerButtons" class="px-4">
|
||||||
|
<Button
|
||||||
|
v-if="hasInstalledPacksPendingRestart"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="isRestarting"
|
||||||
|
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
|
||||||
|
@click="applyChanges()"
|
||||||
|
>
|
||||||
|
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
|
||||||
|
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
|
||||||
|
<span class="truncate min-w-0">{{
|
||||||
|
t('rightSidePanel.missingNodePacks.applyChanges')
|
||||||
|
}}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||||
|
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
|
||||||
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||||
|
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||||
|
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
showInfoButton: boolean
|
||||||
|
showNodeIdBadge: boolean
|
||||||
|
missingPackGroups: MissingPackGroup[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
locateNode: [nodeId: string]
|
||||||
|
openManagerInfo: [packId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
|
const { isRestarting, applyChanges } = useApplyChanges()
|
||||||
|
const { shouldShowManagerButtons } = useManagerState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show Apply Changes when any pack from the error group is already installed
|
||||||
|
* on disk but ComfyUI hasn't restarted yet to load it.
|
||||||
|
* This is server-state based → persists across browser refreshes.
|
||||||
|
*/
|
||||||
|
const hasInstalledPacksPendingRestart = computed(() =>
|
||||||
|
props.missingPackGroups.some(
|
||||||
|
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
250
src/components/rightSidePanel/errors/MissingPackGroupRow.vue
Normal file
250
src/components/rightSidePanel/errors/MissingPackGroupRow.vue
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col w-full mb-2">
|
||||||
|
<!-- Pack header row: pack name + info + chevron -->
|
||||||
|
<div class="flex h-8 items-center w-full">
|
||||||
|
<!-- Warning icon for unknown packs -->
|
||||||
|
<i
|
||||||
|
v-if="group.packId === null && !group.isResolving"
|
||||||
|
class="icon-[lucide--triangle-alert] size-4 text-warning-background shrink-0 mr-1.5"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
:class="
|
||||||
|
group.packId === null && !group.isResolving
|
||||||
|
? 'text-warning-background'
|
||||||
|
: 'text-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span v-if="group.isResolving" class="text-muted-foreground italic">
|
||||||
|
{{ t('g.loading') }}...
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{
|
||||||
|
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
v-if="showInfoButton && group.packId !== null"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon-sm"
|
||||||
|
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||||
|
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
|
||||||
|
@click="emit('openManagerInfo', group.packId ?? '')"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--info] size-4" />
|
||||||
|
</Button>
|
||||||
|
<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')
|
||||||
|
: t('rightSidePanel.missingNodePacks.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-1 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')"
|
||||||
|
@click="handleLocateNode(nodeType)"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--locate] size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionCollapse>
|
||||||
|
|
||||||
|
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
shouldShowManagerButtons &&
|
||||||
|
group.packId !== null &&
|
||||||
|
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
|
||||||
|
"
|
||||||
|
class="flex items-start w-full pt-1 pb-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
class="flex flex-1 w-full"
|
||||||
|
:disabled="
|
||||||
|
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
|
||||||
|
"
|
||||||
|
@click="handlePackInstallClick"
|
||||||
|
>
|
||||||
|
<DotSpinner
|
||||||
|
v-if="isInstalling"
|
||||||
|
duration="1s"
|
||||||
|
:size="12"
|
||||||
|
class="mr-1.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
|
||||||
|
class="icon-[lucide--check] size-4 text-foreground shrink-0 mr-1"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="icon-[lucide--download] size-4 text-foreground shrink-0 mr-1"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-foreground truncate min-w-0">
|
||||||
|
{{
|
||||||
|
isInstalling
|
||||||
|
? t('rightSidePanel.missingNodePacks.installing')
|
||||||
|
: comfyManagerStore.isPackInstalled(group.packId)
|
||||||
|
? t('rightSidePanel.missingNodePacks.installed')
|
||||||
|
: t('rightSidePanel.missingNodePacks.installNodePack')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registry still loading: packId known but result not yet available -->
|
||||||
|
<div
|
||||||
|
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
|
||||||
|
class="flex items-start w-full pt-1 pb-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background opacity-60 cursor-not-allowed select-none"
|
||||||
|
>
|
||||||
|
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
|
||||||
|
<span class="text-sm text-foreground truncate min-w-0">
|
||||||
|
{{ t('g.loading') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search in Manager: fetch done but pack not found in registry -->
|
||||||
|
<div
|
||||||
|
v-else-if="group.packId !== null && shouldShowManagerButtons"
|
||||||
|
class="flex items-start w-full pt-1 pb-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
class="flex flex-1 w-full"
|
||||||
|
@click="
|
||||||
|
openManager({
|
||||||
|
initialTab: ManagerTab.All,
|
||||||
|
initialPackId: group.packId!
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--search] size-4 text-foreground shrink-0 mr-1" />
|
||||||
|
<span class="text-sm text-foreground truncate min-w-0">
|
||||||
|
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||||
|
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||||
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||||
|
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||||
|
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||||
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||||
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||||
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
|
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
group: MissingPackGroup
|
||||||
|
showInfoButton: boolean
|
||||||
|
showNodeIdBadge: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
locateNode: [nodeId: string]
|
||||||
|
openManagerInfo: [packId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { missingNodePacks, isLoading } = useMissingNodes()
|
||||||
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
|
const { shouldShowManagerButtons, openManager } = useManagerState()
|
||||||
|
|
||||||
|
const nodePack = computed(() => {
|
||||||
|
if (!props.group.packId) return null
|
||||||
|
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isInstalling, installAllPacks } = usePackInstall(() =>
|
||||||
|
nodePack.value ? [nodePack.value] : []
|
||||||
|
)
|
||||||
|
|
||||||
|
function handlePackInstallClick() {
|
||||||
|
if (!props.group.packId) return
|
||||||
|
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
|
||||||
|
void installAllPacks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('locateNode', String(nodeType.nodeId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -18,7 +18,8 @@ vi.mock('@/scripts/app', () => ({
|
|||||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||||
getNodeByExecutionId: vi.fn(),
|
getNodeByExecutionId: vi.fn(),
|
||||||
getRootParentNode: vi.fn(() => null),
|
getRootParentNode: vi.fn(() => null),
|
||||||
forEachNode: vi.fn()
|
forEachNode: vi.fn(),
|
||||||
|
mapAllNodes: vi.fn(() => [])
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
:key="group.title"
|
:key="group.title"
|
||||||
:collapse="collapseState[group.title] ?? false"
|
:collapse="collapseState[group.title] ?? false"
|
||||||
class="border-b border-interface-stroke"
|
class="border-b border-interface-stroke"
|
||||||
|
:size="group.type === 'missing_node' ? 'lg' : 'default'"
|
||||||
@update:collapse="collapseState[group.title] = $event"
|
@update:collapse="collapseState[group.title] = $event"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
@@ -36,20 +37,53 @@
|
|||||||
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
||||||
/>
|
/>
|
||||||
<span class="text-destructive-background-hover truncate">
|
<span class="text-destructive-background-hover truncate">
|
||||||
{{ group.title }}
|
{{
|
||||||
|
group.type === 'missing_node'
|
||||||
|
? `${group.title} (${missingPackGroups.length})`
|
||||||
|
: group.title
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="group.cards.length > 1"
|
v-if="group.type === 'execution' && group.cards.length > 1"
|
||||||
class="text-destructive-background-hover"
|
class="text-destructive-background-hover"
|
||||||
>
|
>
|
||||||
({{ group.cards.length }})
|
({{ group.cards.length }})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
group.type === 'missing_node' &&
|
||||||
|
missingNodePacks.length > 0 &&
|
||||||
|
shouldShowInstallButton
|
||||||
|
"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 mr-2 h-8 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Cards in Group (default slot) -->
|
<!-- Missing Node Packs -->
|
||||||
<div class="px-4 space-y-3">
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Execution Errors -->
|
||||||
|
<div v-else class="px-4 space-y-3">
|
||||||
<ErrorNodeCard
|
<ErrorNodeCard
|
||||||
v-for="card in group.cards"
|
v-for="card in group.cards"
|
||||||
:key="card.id"
|
:key="card.id"
|
||||||
@@ -108,12 +142,18 @@ import { useExternalLink } from '@/composables/useExternalLink'
|
|||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
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 { NodeBadgeMode } from '@/types/nodeSource'
|
||||||
|
|
||||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||||
|
import MissingNodeCard from './MissingNodeCard.vue'
|
||||||
import Button from '@/components/ui/button/Button.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 { useErrorGroups } from './useErrorGroups'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -122,6 +162,11 @@ const { focusNode, enterSubgraph } = useFocusNode()
|
|||||||
const { staticUrls } = useExternalLink()
|
const { staticUrls } = useExternalLink()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
|
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
||||||
|
useManagerState()
|
||||||
|
const { missingNodePacks } = useMissingNodes()
|
||||||
|
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||||
|
usePackInstall(() => missingNodePacks.value)
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
@@ -136,7 +181,9 @@ const {
|
|||||||
filteredGroups,
|
filteredGroups,
|
||||||
collapseState,
|
collapseState,
|
||||||
isSingleNodeSelected,
|
isSingleNodeSelected,
|
||||||
errorNodeCache
|
errorNodeCache,
|
||||||
|
missingNodeCache,
|
||||||
|
missingPackGroups
|
||||||
} = useErrorGroups(searchQuery, t)
|
} = useErrorGroups(searchQuery, t)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,11 +198,13 @@ watch(
|
|||||||
if (!graphNodeId) return
|
if (!graphNodeId) return
|
||||||
const prefix = `${graphNodeId}:`
|
const prefix = `${graphNodeId}:`
|
||||||
for (const group of allErrorGroups.value) {
|
for (const group of allErrorGroups.value) {
|
||||||
const hasMatch = group.cards.some(
|
const hasMatch =
|
||||||
(card) =>
|
group.type === 'execution' &&
|
||||||
card.graphNodeId === graphNodeId ||
|
group.cards.some(
|
||||||
(card.nodeId?.startsWith(prefix) ?? false)
|
(card) =>
|
||||||
)
|
card.graphNodeId === graphNodeId ||
|
||||||
|
(card.nodeId?.startsWith(prefix) ?? false)
|
||||||
|
)
|
||||||
collapseState[group.title] = !hasMatch
|
collapseState[group.title] = !hasMatch
|
||||||
}
|
}
|
||||||
rightSidePanelStore.focusedErrorNodeId = null
|
rightSidePanelStore.focusedErrorNodeId = null
|
||||||
@@ -167,6 +216,19 @@ function handleLocateNode(nodeId: string) {
|
|||||||
focusNode(nodeId, errorNodeCache.value)
|
focusNode(nodeId, errorNodeCache.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLocateMissingNode(nodeId: string) {
|
||||||
|
focusNode(nodeId, missingNodeCache.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleEnterSubgraph(nodeId: string) {
|
function handleEnterSubgraph(nodeId: string) {
|
||||||
enterSubgraph(nodeId, errorNodeCache.value)
|
enterSubgraph(nodeId, errorNodeCache.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ export interface ErrorCardData {
|
|||||||
errors: ErrorItem[]
|
errors: ErrorItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorGroup {
|
export type ErrorGroup =
|
||||||
title: string
|
| {
|
||||||
cards: ErrorCardData[]
|
type: 'execution'
|
||||||
priority: number
|
title: string
|
||||||
}
|
cards: ErrorCardData[]
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
| { type: 'missing_node'; title: string; priority: number }
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import type { IFuseOptions } from 'fuse.js'
|
import type { IFuseOptions } from 'fuse.js'
|
||||||
|
|
||||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
|
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
@@ -20,6 +20,7 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
|||||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||||
import { st } from '@/i18n'
|
import { st } from '@/i18n'
|
||||||
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||||
@@ -32,7 +33,17 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
|||||||
'server_error'
|
'server_error'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||||
|
const RESOLVING = '__RESOLVING__'
|
||||||
|
|
||||||
|
export interface MissingPackGroup {
|
||||||
|
packId: string | null
|
||||||
|
nodeTypes: MissingNodeType[]
|
||||||
|
isResolving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface GroupEntry {
|
interface GroupEntry {
|
||||||
|
type: 'execution'
|
||||||
priority: number
|
priority: number
|
||||||
cards: Map<string, ErrorCardData>
|
cards: Map<string, ErrorCardData>
|
||||||
}
|
}
|
||||||
@@ -76,7 +87,7 @@ function getOrCreateGroup(
|
|||||||
): Map<string, ErrorCardData> {
|
): Map<string, ErrorCardData> {
|
||||||
let entry = groupsMap.get(title)
|
let entry = groupsMap.get(title)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { priority, cards: new Map() }
|
entry = { type: 'execution', priority, cards: new Map() }
|
||||||
groupsMap.set(title, entry)
|
groupsMap.set(title, entry)
|
||||||
}
|
}
|
||||||
return entry.cards
|
return entry.cards
|
||||||
@@ -137,6 +148,7 @@ function addCardErrorToGroup(
|
|||||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||||
return Array.from(groupsMap.entries())
|
return Array.from(groupsMap.entries())
|
||||||
.map(([title, groupData]) => ({
|
.map(([title, groupData]) => ({
|
||||||
|
type: 'execution' as const,
|
||||||
title,
|
title,
|
||||||
cards: Array.from(groupData.cards.values()),
|
cards: Array.from(groupData.cards.values()),
|
||||||
priority: groupData.priority
|
priority: groupData.priority
|
||||||
@@ -153,6 +165,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
|||||||
const searchableList: ErrorSearchItem[] = []
|
const searchableList: ErrorSearchItem[] = []
|
||||||
for (let gi = 0; gi < groups.length; gi++) {
|
for (let gi = 0; gi < groups.length; gi++) {
|
||||||
const group = groups[gi]
|
const group = groups[gi]
|
||||||
|
if (group.type !== 'execution') continue
|
||||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||||
const card = group.cards[ci]
|
const card = group.cards[ci]
|
||||||
searchableList.push({
|
searchableList.push({
|
||||||
@@ -160,8 +173,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
|||||||
cardIndex: ci,
|
cardIndex: ci,
|
||||||
searchableNodeId: card.nodeId ?? '',
|
searchableNodeId: card.nodeId ?? '',
|
||||||
searchableNodeTitle: card.nodeTitle ?? '',
|
searchableNodeTitle: card.nodeTitle ?? '',
|
||||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
searchableMessage: card.errors
|
||||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
.map((e: ErrorItem) => e.message)
|
||||||
|
.join(' '),
|
||||||
|
searchableDetails: card.errors
|
||||||
|
.map((e: ErrorItem) => e.details ?? '')
|
||||||
|
.join(' ')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,11 +201,16 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
.map((group, gi) => ({
|
.map((group, gi) => {
|
||||||
...group,
|
if (group.type !== 'execution') return group
|
||||||
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
|
return {
|
||||||
}))
|
...group,
|
||||||
.filter((group) => group.cards.length > 0)
|
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
|
||||||
|
matchedCardKeys.has(`${gi}:${ci}`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useErrorGroups(
|
export function useErrorGroups(
|
||||||
@@ -197,6 +219,7 @@ export function useErrorGroups(
|
|||||||
) {
|
) {
|
||||||
const executionErrorStore = useExecutionErrorStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||||
const collapseState = reactive<Record<string, boolean>>({})
|
const collapseState = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
const selectedNodeInfo = computed(() => {
|
const selectedNodeInfo = computed(() => {
|
||||||
@@ -237,6 +260,19 @@ export function useErrorGroups(
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const missingNodeCache = computed(() => {
|
||||||
|
const map = new Map<string, LGraphNode>()
|
||||||
|
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||||
|
for (const nodeType of nodeTypes) {
|
||||||
|
if (typeof nodeType === 'string') continue
|
||||||
|
if (nodeType.nodeId == null) continue
|
||||||
|
const nodeId = String(nodeType.nodeId)
|
||||||
|
const node = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||||
|
if (node) map.set(nodeId, node)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
function isErrorInSelection(executionNodeId: string): boolean {
|
function isErrorInSelection(executionNodeId: string): boolean {
|
||||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||||
if (!nodeIds) return true
|
if (!nodeIds) return true
|
||||||
@@ -343,6 +379,136 @@ export function useErrorGroups(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Async pack-ID resolution for missing node types that lack a cnrId
|
||||||
|
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
|
||||||
|
|
||||||
|
const pendingTypes = computed(() =>
|
||||||
|
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
|
||||||
|
(n): n is Exclude<MissingNodeType, string> =>
|
||||||
|
typeof n !== 'string' && !n.cnrId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
pendingTypes,
|
||||||
|
async (pending, _, onCleanup) => {
|
||||||
|
const toResolve = pending.filter(
|
||||||
|
(n) => asyncResolvedIds.value.get(n.type) === undefined
|
||||||
|
)
|
||||||
|
if (!toResolve.length) return
|
||||||
|
|
||||||
|
const resolvingTypes = toResolve.map((n) => n.type)
|
||||||
|
let cancelled = false
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
const next = new Map(asyncResolvedIds.value)
|
||||||
|
for (const type of resolvingTypes) {
|
||||||
|
if (next.get(type) === RESOLVING) next.delete(type)
|
||||||
|
}
|
||||||
|
asyncResolvedIds.value = next
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = new Map(asyncResolvedIds.value)
|
||||||
|
for (const type of resolvingTypes) updated.set(type, RESOLVING)
|
||||||
|
asyncResolvedIds.value = updated
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
toResolve.map(async (n) => ({
|
||||||
|
type: n.type,
|
||||||
|
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const final = new Map(asyncResolvedIds.value)
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
final.set(r.value.type, r.value.packId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear any remaining RESOLVING markers for failed lookups
|
||||||
|
for (const type of resolvingTypes) {
|
||||||
|
if (final.get(type) === RESOLVING) final.set(type, null)
|
||||||
|
}
|
||||||
|
asyncResolvedIds.value = final
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const missingPackGroups = computed<MissingPackGroup[]>(() => {
|
||||||
|
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
|
||||||
|
const map = new Map<
|
||||||
|
string | null,
|
||||||
|
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
|
||||||
|
>()
|
||||||
|
const resolvingKeys = new Set<string | null>()
|
||||||
|
|
||||||
|
for (const nodeType of nodeTypes) {
|
||||||
|
let packId: string | null
|
||||||
|
|
||||||
|
if (typeof nodeType === 'string') {
|
||||||
|
packId = null
|
||||||
|
} else if (nodeType.cnrId) {
|
||||||
|
packId = nodeType.cnrId
|
||||||
|
} else {
|
||||||
|
const resolved = asyncResolvedIds.value.get(nodeType.type)
|
||||||
|
if (resolved === undefined || resolved === RESOLVING) {
|
||||||
|
packId = null
|
||||||
|
resolvingKeys.add(null)
|
||||||
|
} else {
|
||||||
|
packId = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = map.get(packId)
|
||||||
|
if (existing) {
|
||||||
|
existing.nodeTypes.push(nodeType)
|
||||||
|
} else {
|
||||||
|
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of resolvingKeys) {
|
||||||
|
const group = map.get(key)
|
||||||
|
if (group) group.isResolving = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort(([packIdA], [packIdB]) => {
|
||||||
|
// null (Unknown Pack) always goes last
|
||||||
|
if (packIdA === null) return 1
|
||||||
|
if (packIdB === null) return -1
|
||||||
|
return packIdA.localeCompare(packIdB)
|
||||||
|
})
|
||||||
|
.map(([packId, { nodeTypes, isResolving }]) => ({
|
||||||
|
packId,
|
||||||
|
nodeTypes: [...nodeTypes].sort((a, b) => {
|
||||||
|
const typeA = typeof a === 'string' ? a : a.type
|
||||||
|
const typeB = typeof b === 'string' ? b : b.type
|
||||||
|
const typeCmp = typeA.localeCompare(typeB)
|
||||||
|
if (typeCmp !== 0) return typeCmp
|
||||||
|
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
|
||||||
|
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
|
||||||
|
return idA.localeCompare(idB, undefined, { numeric: true })
|
||||||
|
}),
|
||||||
|
isResolving
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
|
||||||
|
function buildMissingNodeGroups(): ErrorGroup[] {
|
||||||
|
const error = executionErrorStore.missingNodesError
|
||||||
|
if (!error) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'missing_node' as const,
|
||||||
|
title: error.message,
|
||||||
|
priority: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||||
const groupsMap = new Map<string, GroupEntry>()
|
const groupsMap = new Map<string, GroupEntry>()
|
||||||
|
|
||||||
@@ -350,7 +516,7 @@ export function useErrorGroups(
|
|||||||
processNodeErrors(groupsMap)
|
processNodeErrors(groupsMap)
|
||||||
processExecutionError(groupsMap)
|
processExecutionError(groupsMap)
|
||||||
|
|
||||||
return toSortedGroups(groupsMap)
|
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||||
@@ -360,9 +526,11 @@ export function useErrorGroups(
|
|||||||
processNodeErrors(groupsMap, true)
|
processNodeErrors(groupsMap, true)
|
||||||
processExecutionError(groupsMap, true)
|
processExecutionError(groupsMap, true)
|
||||||
|
|
||||||
return isSingleNodeSelected.value
|
const executionGroups = isSingleNodeSelected.value
|
||||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||||
: toSortedGroups(groupsMap)
|
: toSortedGroups(groupsMap)
|
||||||
|
|
||||||
|
return [...buildMissingNodeGroups(), ...executionGroups]
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||||
@@ -373,10 +541,15 @@ export function useErrorGroups(
|
|||||||
const groupedErrorMessages = computed<string[]>(() => {
|
const groupedErrorMessages = computed<string[]>(() => {
|
||||||
const messages = new Set<string>()
|
const messages = new Set<string>()
|
||||||
for (const group of allErrorGroups.value) {
|
for (const group of allErrorGroups.value) {
|
||||||
for (const card of group.cards) {
|
if (group.type === 'execution') {
|
||||||
for (const err of card.errors) {
|
for (const card of group.cards) {
|
||||||
messages.add(err.message)
|
for (const err of card.errors) {
|
||||||
|
messages.add(err.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Groups without cards (e.g. missing_node) surface their title as the message.
|
||||||
|
messages.add(group.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(messages)
|
return Array.from(messages)
|
||||||
@@ -389,6 +562,8 @@ export function useErrorGroups(
|
|||||||
collapseState,
|
collapseState,
|
||||||
isSingleNodeSelected,
|
isSingleNodeSelected,
|
||||||
errorNodeCache,
|
errorNodeCache,
|
||||||
groupedErrorMessages
|
missingNodeCache,
|
||||||
|
groupedErrorMessages,
|
||||||
|
missingPackGroups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ const {
|
|||||||
label,
|
label,
|
||||||
enableEmptyState,
|
enableEmptyState,
|
||||||
tooltip,
|
tooltip,
|
||||||
|
size = 'default',
|
||||||
class: className
|
class: className
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
label?: string
|
label?: string
|
||||||
enableEmptyState?: boolean
|
enableEmptyState?: boolean
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
size?: 'default' | 'lg'
|
||||||
class?: string
|
class?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -39,7 +41,8 @@ const tooltipConfig = computed(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
|
||||||
|
size === 'lg' ? 'min-h-16' : 'min-h-12',
|
||||||
!disabled && 'cursor-pointer'
|
!disabled && 'cursor-pointer'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ const nodeHasError = computed(() => {
|
|||||||
return hasDirectError.value || hasContainerInternalError.value
|
return hasDirectError.value || hasContainerInternalError.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showSeeError = computed(
|
||||||
|
() =>
|
||||||
|
nodeHasError.value &&
|
||||||
|
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||||
|
)
|
||||||
|
|
||||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||||
if (!targetNode.value || !getNodeParentGroup) return null
|
if (!targetNode.value || !getNodeParentGroup) return null
|
||||||
return getNodeParentGroup(targetNode.value)
|
return getNodeParentGroup(targetNode.value)
|
||||||
@@ -194,6 +200,7 @@ defineExpose({
|
|||||||
:enable-empty-state
|
:enable-empty-state
|
||||||
:disabled="isEmpty"
|
:disabled="isEmpty"
|
||||||
:tooltip
|
:tooltip
|
||||||
|
:size="showSeeError ? 'lg' : 'default'"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||||
@@ -223,13 +230,10 @@ defineExpose({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
v-if="showSeeError"
|
||||||
nodeHasError &&
|
|
||||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
|
||||||
"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0 rounded-lg text-sm"
|
class="shrink-0 rounded-lg text-sm h-8"
|
||||||
@click.stop="navigateToErrorTab"
|
@click.stop="navigateToErrorTab"
|
||||||
>
|
>
|
||||||
{{ t('rightSidePanel.seeError') }}
|
{{ t('rightSidePanel.seeError') }}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"enter": "Enter",
|
"enter": "Enter",
|
||||||
"enterSubgraph": "Enter Subgraph",
|
"enterSubgraph": "Enter Subgraph",
|
||||||
|
"inSubgraph": "in subgraph '{name}'",
|
||||||
"resizeFromBottomRight": "Resize from bottom-right corner",
|
"resizeFromBottomRight": "Resize from bottom-right corner",
|
||||||
"resizeFromTopRight": "Resize from top-right corner",
|
"resizeFromTopRight": "Resize from top-right corner",
|
||||||
"resizeFromBottomLeft": "Resize from bottom-left corner",
|
"resizeFromBottomLeft": "Resize from bottom-left corner",
|
||||||
@@ -450,6 +451,9 @@
|
|||||||
"import_failed": "Import Failed"
|
"import_failed": "Import Failed"
|
||||||
},
|
},
|
||||||
"warningTooltip": "This package may have compatibility issues with your current environment"
|
"warningTooltip": "This package may have compatibility issues with your current environment"
|
||||||
|
},
|
||||||
|
"packInstall": {
|
||||||
|
"nodeIdRequired": "Node ID is required for installation"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"importFailed": {
|
"importFailed": {
|
||||||
@@ -3136,7 +3140,23 @@
|
|||||||
"errorHelpGithub": "submit a GitHub issue",
|
"errorHelpGithub": "submit a GitHub issue",
|
||||||
"errorHelpSupport": "contact our support",
|
"errorHelpSupport": "contact our support",
|
||||||
"resetToDefault": "Reset to default",
|
"resetToDefault": "Reset to default",
|
||||||
"resetAllParameters": "Reset all parameters"
|
"resetAllParameters": "Reset all parameters",
|
||||||
|
"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.",
|
||||||
|
"installAll": "Install All",
|
||||||
|
"installNodePack": "Install node pack",
|
||||||
|
"unknownPack": "Unknown pack",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"installed": "Installed",
|
||||||
|
"applyChanges": "Apply Changes",
|
||||||
|
"searchInManager": "Search in Node Manager",
|
||||||
|
"viewInManager": "View in Manager",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errorOverlay": {
|
"errorOverlay": {
|
||||||
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
|
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
|
|||||||
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
|
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||||
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
import { appendJsonExt } from '@/utils/formatUtil'
|
import { appendJsonExt } from '@/utils/formatUtil'
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export const useWorkflowService = () => {
|
|||||||
const missingNodesDialog = useMissingNodesDialog()
|
const missingNodesDialog = useMissingNodesDialog()
|
||||||
const workflowThumbnail = useWorkflowThumbnail()
|
const workflowThumbnail = useWorkflowThumbnail()
|
||||||
const domWidgetStore = useDomWidgetStore()
|
const domWidgetStore = useDomWidgetStore()
|
||||||
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const workflowDraftStore = useWorkflowDraftStore()
|
const workflowDraftStore = useWorkflowDraftStore()
|
||||||
|
|
||||||
async function getFilename(defaultName: string): Promise<string | null> {
|
async function getFilename(defaultName: string): Promise<string | null> {
|
||||||
@@ -467,12 +469,15 @@ export const useWorkflowService = () => {
|
|||||||
const { missingNodeTypes, missingModels } = wf.pendingWarnings
|
const { missingNodeTypes, missingModels } = wf.pendingWarnings
|
||||||
wf.pendingWarnings = null
|
wf.pendingWarnings = null
|
||||||
|
|
||||||
if (
|
if (missingNodeTypes?.length) {
|
||||||
missingNodeTypes?.length &&
|
// Remove modal once Node Replacement is implemented in TabErrors.
|
||||||
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
|
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
|
||||||
) {
|
missingNodesDialog.show({ missingNodeTypes })
|
||||||
missingNodesDialog.show({ missingNodeTypes })
|
}
|
||||||
|
|
||||||
|
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
missingModels &&
|
missingModels &&
|
||||||
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
|
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||||
|
|||||||
@@ -553,7 +553,6 @@ export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>
|
|||||||
* where that definition is instantiated in the workflow.
|
* where that definition is instantiated in the workflow.
|
||||||
*
|
*
|
||||||
* "def-A" → ["5", "10"] for each container node instantiating that subgraph definition.
|
* "def-A" → ["5", "10"] for each container node instantiating that subgraph definition.
|
||||||
* @knipIgnoreUsedByStackedPR
|
|
||||||
*/
|
*/
|
||||||
export function buildSubgraphExecutionPaths(
|
export function buildSubgraphExecutionPaths(
|
||||||
rootNodes: ComfyNode[],
|
rootNodes: ComfyNode[],
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ const hasAnyError = computed((): boolean => {
|
|||||||
error ||
|
error ||
|
||||||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
|
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
|
||||||
(lgraphNode.value &&
|
(lgraphNode.value &&
|
||||||
executionErrorStore.isContainerWithInternalError(lgraphNode.value))
|
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
|
||||||
|
executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
|
|||||||
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
import { useWorkflowValidation } from '@/platform/workflow/validation/composables/useWorkflowValidation'
|
||||||
|
import type {
|
||||||
|
ComfyApiWorkflow,
|
||||||
|
ComfyWorkflowJSON,
|
||||||
|
ModelFile,
|
||||||
|
NodeId
|
||||||
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import {
|
import {
|
||||||
type ComfyApiWorkflow,
|
isSubgraphDefinition,
|
||||||
type ComfyWorkflowJSON,
|
buildSubgraphExecutionPaths
|
||||||
type ModelFile,
|
|
||||||
type NodeId,
|
|
||||||
isSubgraphDefinition
|
|
||||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import type {
|
import type {
|
||||||
ExecutionErrorWsMessage,
|
ExecutionErrorWsMessage,
|
||||||
@@ -77,10 +80,15 @@ import type { ExtensionManager } from '@/types/extensionTypes'
|
|||||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||||
import { graphToPrompt } from '@/utils/executionUtil'
|
import { graphToPrompt } from '@/utils/executionUtil'
|
||||||
import type { MissingNodeTypeExtraInfo } from '@/workbench/extensions/manager/types/missingNodeErrorTypes'
|
import type { MissingNodeTypeExtraInfo } from '@/workbench/extensions/manager/types/missingNodeErrorTypes'
|
||||||
import { createMissingNodeTypeFromError } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
|
||||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
|
||||||
import { collectAllNodes, forEachNode } from '@/utils/graphTraversalUtil'
|
|
||||||
import {
|
import {
|
||||||
|
createMissingNodeTypeFromError,
|
||||||
|
getCnrIdFromNode,
|
||||||
|
getCnrIdFromProperties
|
||||||
|
} from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||||
|
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||||
|
import {
|
||||||
|
collectAllNodes,
|
||||||
|
forEachNode,
|
||||||
getNodeByExecutionId,
|
getNodeByExecutionId,
|
||||||
triggerCallbackOnAllNodes
|
triggerCallbackOnAllNodes
|
||||||
} from '@/utils/graphTraversalUtil'
|
} from '@/utils/graphTraversalUtil'
|
||||||
@@ -1097,9 +1105,13 @@ export class ComfyApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
|
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
|
||||||
|
// Remove modal once Node Replacement is implemented in TabErrors.
|
||||||
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
|
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
|
||||||
useMissingNodesDialog().show({ missingNodeTypes })
|
useMissingNodesDialog().show({ missingNodeTypes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
|
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadGraphData(
|
async loadGraphData(
|
||||||
@@ -1181,12 +1193,13 @@ export class ComfyApp {
|
|||||||
|
|
||||||
const collectMissingNodesAndModels = (
|
const collectMissingNodesAndModels = (
|
||||||
nodes: ComfyWorkflowJSON['nodes'],
|
nodes: ComfyWorkflowJSON['nodes'],
|
||||||
path: string = ''
|
pathPrefix: string = '',
|
||||||
|
displayName: string = ''
|
||||||
) => {
|
) => {
|
||||||
if (!Array.isArray(nodes)) {
|
if (!Array.isArray(nodes)) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Workflow nodes data is missing or invalid, skipping node processing',
|
'Workflow nodes data is missing or invalid, skipping node processing',
|
||||||
{ nodes, path }
|
{ nodes, pathPrefix }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1195,9 +1208,23 @@ export class ComfyApp {
|
|||||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||||
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
||||||
|
|
||||||
|
// To access missing node information in the error tab
|
||||||
|
// we collect the cnr_id and execution_id here.
|
||||||
|
const cnrId = getCnrIdFromProperties(
|
||||||
|
n.properties as Record<string, unknown> | undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
const executionId = pathPrefix
|
||||||
|
? `${pathPrefix}:${n.id}`
|
||||||
|
: String(n.id)
|
||||||
|
|
||||||
missingNodeTypes.push({
|
missingNodeTypes.push({
|
||||||
type: n.type,
|
type: n.type,
|
||||||
...(path && { hint: `in subgraph '${path}'` }),
|
nodeId: executionId,
|
||||||
|
cnrId,
|
||||||
|
...(displayName && {
|
||||||
|
hint: t('g.inSubgraph', { name: displayName })
|
||||||
|
}),
|
||||||
isReplaceable: replacement !== null,
|
isReplaceable: replacement !== null,
|
||||||
replacement: replacement ?? undefined
|
replacement: replacement ?? undefined
|
||||||
})
|
})
|
||||||
@@ -1216,14 +1243,25 @@ export class ComfyApp {
|
|||||||
// Process nodes at the top level
|
// Process nodes at the top level
|
||||||
collectMissingNodesAndModels(graphData.nodes)
|
collectMissingNodesAndModels(graphData.nodes)
|
||||||
|
|
||||||
|
// Build map: subgraph definition UUID → full execution path prefix.
|
||||||
|
// Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14").
|
||||||
|
const subgraphContainerIdMap = buildSubgraphExecutionPaths(
|
||||||
|
graphData.nodes,
|
||||||
|
graphData.definitions?.subgraphs ?? []
|
||||||
|
)
|
||||||
|
|
||||||
// Process nodes in subgraphs
|
// Process nodes in subgraphs
|
||||||
if (graphData.definitions?.subgraphs) {
|
if (graphData.definitions?.subgraphs) {
|
||||||
for (const subgraph of graphData.definitions.subgraphs) {
|
for (const subgraph of graphData.definitions.subgraphs) {
|
||||||
if (isSubgraphDefinition(subgraph)) {
|
if (isSubgraphDefinition(subgraph)) {
|
||||||
collectMissingNodesAndModels(
|
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
|
||||||
subgraph.nodes,
|
for (const pathPrefix of paths) {
|
||||||
subgraph.name || subgraph.id
|
collectMissingNodesAndModels(
|
||||||
)
|
subgraph.nodes,
|
||||||
|
pathPrefix,
|
||||||
|
subgraph.name || subgraph.id
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1491,7 +1529,32 @@ export class ComfyApp {
|
|||||||
) {
|
) {
|
||||||
const extraInfo = (error.response.error.extra_info ??
|
const extraInfo = (error.response.error.extra_info ??
|
||||||
{}) as MissingNodeTypeExtraInfo
|
{}) as MissingNodeTypeExtraInfo
|
||||||
const missingNodeType = createMissingNodeTypeFromError(extraInfo)
|
|
||||||
|
let graphNode = null
|
||||||
|
if (extraInfo.node_id && this.rootGraph) {
|
||||||
|
graphNode = getNodeByExecutionId(
|
||||||
|
this.rootGraph,
|
||||||
|
extraInfo.node_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedExtraInfo: MissingNodeTypeExtraInfo = {
|
||||||
|
...extraInfo,
|
||||||
|
class_type: extraInfo.class_type ?? graphNode?.type,
|
||||||
|
node_title: extraInfo.node_title ?? graphNode?.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingNodeType =
|
||||||
|
createMissingNodeTypeFromError(enrichedExtraInfo)
|
||||||
|
|
||||||
|
if (
|
||||||
|
graphNode &&
|
||||||
|
typeof missingNodeType !== 'string' &&
|
||||||
|
!missingNodeType.cnrId
|
||||||
|
) {
|
||||||
|
missingNodeType.cnrId = getCnrIdFromNode(graphNode)
|
||||||
|
}
|
||||||
|
|
||||||
this.showMissingNodesError([missingNodeType])
|
this.showMissingNodesError([missingNodeType])
|
||||||
} else if (
|
} else if (
|
||||||
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
|
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { st } from '@/i18n'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import type {
|
import type {
|
||||||
@@ -10,8 +13,13 @@ import type {
|
|||||||
PromptError
|
PromptError
|
||||||
} from '@/schemas/apiSchema'
|
} from '@/schemas/apiSchema'
|
||||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import {
|
||||||
|
getAncestorExecutionIds,
|
||||||
|
getParentExecutionIds
|
||||||
|
} from '@/types/nodeIdentification'
|
||||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||||
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
import {
|
import {
|
||||||
executionIdToNodeLocatorId,
|
executionIdToNodeLocatorId,
|
||||||
forEachNode,
|
forEachNode,
|
||||||
@@ -19,13 +27,50 @@ import {
|
|||||||
getExecutionIdByNode
|
getExecutionIdByNode
|
||||||
} from '@/utils/graphTraversalUtil'
|
} from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
/**
|
interface MissingNodesError {
|
||||||
* Store dedicated to execution error state management.
|
message: string
|
||||||
*
|
nodeTypes: MissingNodeType[]
|
||||||
* Extracted from executionStore to separate error-related concerns
|
}
|
||||||
* (state, computed properties, graph flag propagation, overlay UI)
|
|
||||||
* from execution flow management (progress, queuing, events).
|
function clearAllNodeErrorFlags(rootGraph: LGraph): void {
|
||||||
*/
|
forEachNode(rootGraph, (node) => {
|
||||||
|
node.has_errors = false
|
||||||
|
if (node.inputs) {
|
||||||
|
for (const slot of node.inputs) {
|
||||||
|
slot.hasErrors = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function markNodeSlotErrors(node: LGraphNode, nodeError: NodeError): void {
|
||||||
|
if (!node.inputs) return
|
||||||
|
for (const error of nodeError.errors) {
|
||||||
|
const slotName = error.extra_info?.input_name
|
||||||
|
if (!slotName) continue
|
||||||
|
const slot = node.inputs.find((s) => s.name === slotName)
|
||||||
|
if (slot) slot.hasErrors = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNodeError(
|
||||||
|
rootGraph: LGraph,
|
||||||
|
executionId: NodeExecutionId,
|
||||||
|
nodeError: NodeError
|
||||||
|
): void {
|
||||||
|
const node = getNodeByExecutionId(rootGraph, executionId)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
node.has_errors = true
|
||||||
|
markNodeSlotErrors(node, nodeError)
|
||||||
|
|
||||||
|
for (const parentId of getParentExecutionIds(executionId)) {
|
||||||
|
const parentNode = getNodeByExecutionId(rootGraph, parentId)
|
||||||
|
if (parentNode) parentNode.has_errors = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execution error state: node errors, runtime errors, prompt errors, and missing nodes. */
|
||||||
export const useExecutionErrorStore = defineStore('executionError', () => {
|
export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
@@ -33,6 +78,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||||
const lastPromptError = ref<PromptError | null>(null)
|
const lastPromptError = ref<PromptError | null>(null)
|
||||||
|
const missingNodesError = ref<MissingNodesError | null>(null)
|
||||||
|
|
||||||
const isErrorOverlayOpen = ref(false)
|
const isErrorOverlayOpen = ref(false)
|
||||||
|
|
||||||
@@ -49,6 +95,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
lastExecutionError.value = null
|
lastExecutionError.value = null
|
||||||
lastPromptError.value = null
|
lastPromptError.value = null
|
||||||
lastNodeErrors.value = null
|
lastNodeErrors.value = null
|
||||||
|
missingNodesError.value = null
|
||||||
isErrorOverlayOpen.value = false
|
isErrorOverlayOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +104,48 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
lastPromptError.value = null
|
lastPromptError.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
|
||||||
|
function surfaceMissingNodes(types: MissingNodeType[]) {
|
||||||
|
setMissingNodeTypes(types)
|
||||||
|
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||||
|
showErrorOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMissingNodeTypes(types: MissingNodeType[]) {
|
||||||
|
if (!types.length) {
|
||||||
|
missingNodesError.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const uniqueTypes = types.filter((node) => {
|
||||||
|
// For string entries (group nodes), deduplicate by the string itself.
|
||||||
|
// For object entries, prefer nodeId so multiple instances of the same
|
||||||
|
// type are kept as separate rows; fall back to type if nodeId is absent.
|
||||||
|
const isString = typeof node === 'string'
|
||||||
|
let key: string
|
||||||
|
if (isString) {
|
||||||
|
key = node
|
||||||
|
} else if (node.nodeId != null) {
|
||||||
|
key = String(node.nodeId)
|
||||||
|
} else {
|
||||||
|
key = node.type
|
||||||
|
}
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
missingNodesError.value = {
|
||||||
|
message: isCloud
|
||||||
|
? st(
|
||||||
|
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||||
|
'Unsupported Node Packs'
|
||||||
|
)
|
||||||
|
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||||
|
nodeTypes: uniqueTypes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||||
const err = lastExecutionError.value
|
const err = lastExecutionError.value
|
||||||
if (!err) return null
|
if (!err) return null
|
||||||
@@ -81,9 +170,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
/** Whether any missing node types are present in the current workflow */
|
||||||
|
const hasMissingNodes = computed(() => !!missingNodesError.value)
|
||||||
|
|
||||||
|
/** Whether any error (node validation, runtime execution, prompt-level, or missing nodes) is present */
|
||||||
const hasAnyError = computed(
|
const hasAnyError = computed(
|
||||||
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
|
() =>
|
||||||
|
hasExecutionError.value ||
|
||||||
|
hasPromptError.value ||
|
||||||
|
hasNodeError.value ||
|
||||||
|
hasMissingNodes.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const allErrorExecutionIds = computed<string[]>(() => {
|
const allErrorExecutionIds = computed<string[]>(() => {
|
||||||
@@ -116,13 +212,19 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
/** Count of runtime execution errors (0 or 1) */
|
/** Count of runtime execution errors (0 or 1) */
|
||||||
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
|
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
|
||||||
|
|
||||||
|
/** Count of missing node errors (0 or 1) */
|
||||||
|
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
|
||||||
|
|
||||||
/** Total count of all individual errors */
|
/** Total count of all individual errors */
|
||||||
const totalErrorCount = computed(
|
const totalErrorCount = computed(
|
||||||
() =>
|
() =>
|
||||||
promptErrorCount.value + nodeErrorCount.value + executionErrorCount.value
|
promptErrorCount.value +
|
||||||
|
nodeErrorCount.value +
|
||||||
|
executionErrorCount.value +
|
||||||
|
missingNodeCount.value
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */
|
/** Graph node IDs (as strings) that have errors in the current graph scope. */
|
||||||
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
||||||
const ids = new Set<string>()
|
const ids = new Set<string>()
|
||||||
if (!app.rootGraph) return ids
|
if (!app.rootGraph) return ids
|
||||||
@@ -150,6 +252,44 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
return ids
|
return ids
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of all execution ID prefixes derived from missing node execution IDs,
|
||||||
|
* including the missing nodes themselves.
|
||||||
|
*
|
||||||
|
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||||
|
*/
|
||||||
|
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
||||||
|
const ids = new Set<NodeExecutionId>()
|
||||||
|
const error = missingNodesError.value
|
||||||
|
if (!error) return ids
|
||||||
|
|
||||||
|
for (const nodeType of error.nodeTypes) {
|
||||||
|
if (typeof nodeType === 'string') continue
|
||||||
|
if (nodeType.nodeId == null) continue
|
||||||
|
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
|
||||||
|
ids.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
if (!app.rootGraph) return ids
|
||||||
|
|
||||||
|
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||||
|
|
||||||
|
for (const executionId of missingAncestorExecutionIds.value) {
|
||||||
|
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||||
|
if (graphNode?.graph === activeGraph) {
|
||||||
|
ids.add(String(graphNode.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
/** Map of node errors indexed by locator ID. */
|
/** Map of node errors indexed by locator ID. */
|
||||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||||
() => {
|
() => {
|
||||||
@@ -196,15 +336,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
*/
|
*/
|
||||||
const errorAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
const errorAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
||||||
const ids = new Set<NodeExecutionId>()
|
const ids = new Set<NodeExecutionId>()
|
||||||
|
|
||||||
for (const executionId of allErrorExecutionIds.value) {
|
for (const executionId of allErrorExecutionIds.value) {
|
||||||
const parts = executionId.split(':')
|
for (const id of getAncestorExecutionIds(executionId)) {
|
||||||
// Add every prefix including the full ID (error leaf node itself)
|
ids.add(id)
|
||||||
for (let i = 1; i <= parts.length; i++) {
|
|
||||||
ids.add(parts.slice(0, i).join(':'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ids
|
return ids
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -216,59 +352,26 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
return errorAncestorExecutionIds.value.has(execId)
|
return errorAncestorExecutionIds.value.has(execId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** True if the node has a missing node inside it at any nesting depth. */
|
||||||
* Update node and slot error flags when validation errors change.
|
function isContainerWithMissingNode(node: LGraphNode): boolean {
|
||||||
* Propagates errors up subgraph chains.
|
if (!app.rootGraph) return false
|
||||||
*/
|
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||||
watch(lastNodeErrors, () => {
|
if (!execId) return false
|
||||||
if (!app.rootGraph) return
|
return missingAncestorExecutionIds.value.has(execId)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear all error flags
|
watch(lastNodeErrors, () => {
|
||||||
forEachNode(app.rootGraph, (node) => {
|
const rootGraph = app.rootGraph
|
||||||
node.has_errors = false
|
if (!rootGraph) return
|
||||||
if (node.inputs) {
|
|
||||||
for (const slot of node.inputs) {
|
clearAllNodeErrorFlags(rootGraph)
|
||||||
slot.hasErrors = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!lastNodeErrors.value) return
|
if (!lastNodeErrors.value) return
|
||||||
|
|
||||||
// Set error flags on nodes and slots
|
|
||||||
for (const [executionId, nodeError] of Object.entries(
|
for (const [executionId, nodeError] of Object.entries(
|
||||||
lastNodeErrors.value
|
lastNodeErrors.value
|
||||||
)) {
|
)) {
|
||||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
applyNodeError(rootGraph, executionId, nodeError)
|
||||||
if (!node) continue
|
|
||||||
|
|
||||||
node.has_errors = true
|
|
||||||
|
|
||||||
// Mark input slots with errors
|
|
||||||
if (node.inputs) {
|
|
||||||
for (const error of nodeError.errors) {
|
|
||||||
const slotName = error.extra_info?.input_name
|
|
||||||
if (!slotName) continue
|
|
||||||
|
|
||||||
const slot = node.inputs.find((s) => s.name === slotName)
|
|
||||||
if (slot) {
|
|
||||||
slot.hasErrors = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Propagate errors to parent subgraph nodes
|
|
||||||
const parts = executionId.split(':')
|
|
||||||
for (let i = parts.length - 1; i > 0; i--) {
|
|
||||||
const parentExecutionId = parts.slice(0, i).join(':')
|
|
||||||
const parentNode = getNodeByExecutionId(
|
|
||||||
app.rootGraph,
|
|
||||||
parentExecutionId
|
|
||||||
)
|
|
||||||
if (parentNode) {
|
|
||||||
parentNode.has_errors = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -277,6 +380,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
lastNodeErrors,
|
lastNodeErrors,
|
||||||
lastExecutionError,
|
lastExecutionError,
|
||||||
lastPromptError,
|
lastPromptError,
|
||||||
|
missingNodesError,
|
||||||
|
|
||||||
// Clearing
|
// Clearing
|
||||||
clearAllErrors,
|
clearAllErrors,
|
||||||
@@ -291,16 +395,22 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
|||||||
hasExecutionError,
|
hasExecutionError,
|
||||||
hasPromptError,
|
hasPromptError,
|
||||||
hasNodeError,
|
hasNodeError,
|
||||||
|
hasMissingNodes,
|
||||||
hasAnyError,
|
hasAnyError,
|
||||||
allErrorExecutionIds,
|
allErrorExecutionIds,
|
||||||
totalErrorCount,
|
totalErrorCount,
|
||||||
lastExecutionErrorNodeId,
|
lastExecutionErrorNodeId,
|
||||||
activeGraphErrorNodeIds,
|
activeGraphErrorNodeIds,
|
||||||
|
activeMissingNodeGraphIds,
|
||||||
|
|
||||||
|
// Missing node actions
|
||||||
|
setMissingNodeTypes,
|
||||||
|
surfaceMissingNodes,
|
||||||
|
|
||||||
// Lookup helpers
|
// Lookup helpers
|
||||||
getNodeErrors,
|
getNodeErrors,
|
||||||
slotHasError,
|
slotHasError,
|
||||||
errorAncestorExecutionIds,
|
isContainerWithInternalError,
|
||||||
isContainerWithInternalError
|
isContainerWithMissingNode
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -345,3 +345,100 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
|
||||||
|
let store: ReturnType<typeof useExecutionErrorStore>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
store = useExecutionErrorStore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears missingNodesError when called with an empty array', () => {
|
||||||
|
store.setMissingNodeTypes([{ type: 'NodeA' }])
|
||||||
|
store.setMissingNodeTypes([])
|
||||||
|
expect(store.missingNodesError).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasMissingNodes is false when error is null', () => {
|
||||||
|
store.setMissingNodeTypes([])
|
||||||
|
expect(store.hasMissingNodes).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasMissingNodes is true after setting non-empty types', () => {
|
||||||
|
store.setMissingNodeTypes([{ type: 'NodeA' }])
|
||||||
|
expect(store.hasMissingNodes).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates string entries by value', () => {
|
||||||
|
store.setMissingNodeTypes(['GroupNode', 'GroupNode', 'OtherGroup'])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toEqual([
|
||||||
|
'GroupNode',
|
||||||
|
'OtherGroup'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a single string entry unchanged', () => {
|
||||||
|
store.setMissingNodeTypes(['GroupNode'])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates object entries with the same nodeId', () => {
|
||||||
|
store.setMissingNodeTypes([
|
||||||
|
{ type: 'NodeA', nodeId: 1 },
|
||||||
|
{ type: 'NodeA', nodeId: 1 }
|
||||||
|
])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps object entries with different nodeIds even if same type', () => {
|
||||||
|
store.setMissingNodeTypes([
|
||||||
|
{ type: 'NodeA', nodeId: 1 },
|
||||||
|
{ type: 'NodeA', nodeId: 2 }
|
||||||
|
])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates object entries by type when nodeId is absent', () => {
|
||||||
|
store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeB' }])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps distinct types when nodeId is absent', () => {
|
||||||
|
store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeC' }])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats absent nodeId the same as type-only key (falls back to type)', () => {
|
||||||
|
store.setMissingNodeTypes([{ type: 'NodeD' }, { type: 'NodeD' }])
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a mix of string and object entries correctly', () => {
|
||||||
|
store.setMissingNodeTypes([
|
||||||
|
'GroupNode',
|
||||||
|
'GroupNode', // string dup
|
||||||
|
{ type: 'NodeA', nodeId: 1 },
|
||||||
|
{ type: 'NodeA', nodeId: 1 }, // object dup by nodeId
|
||||||
|
{ type: 'NodeA', nodeId: 2 }, // same type, different nodeId → kept
|
||||||
|
{ type: 'NodeB' },
|
||||||
|
{ type: 'NodeB' } // object dup by type
|
||||||
|
])
|
||||||
|
// Unique: 'GroupNode', {NodeA,1}, {NodeA,2}, {NodeB} → 4
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a non-empty message string in missingNodesError', () => {
|
||||||
|
store.setMissingNodeTypes([{ type: 'NodeA' }])
|
||||||
|
expect(typeof store.missingNodesError?.message).toBe('string')
|
||||||
|
expect(store.missingNodesError!.message.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the deduplicated nodeTypes array in missingNodesError', () => {
|
||||||
|
const input = [{ type: 'NodeA' }, { type: 'NodeB' }]
|
||||||
|
store.setMissingNodeTypes(input)
|
||||||
|
expect(store.missingNodesError?.nodeTypes).toEqual(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export type MissingNodeType =
|
|||||||
// Primarily used by group nodes.
|
// Primarily used by group nodes.
|
||||||
| {
|
| {
|
||||||
type: string
|
type: string
|
||||||
|
nodeId?: string | number
|
||||||
|
cnrId?: string
|
||||||
hint?: string
|
hint?: string
|
||||||
action?: {
|
action?: {
|
||||||
text: string
|
text: string
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
|
|||||||
* Returns all ancestor execution IDs for a given execution ID, including itself.
|
* Returns all ancestor execution IDs for a given execution ID, including itself.
|
||||||
*
|
*
|
||||||
* Example: "65:70:63" → ["65", "65:70", "65:70:63"]
|
* Example: "65:70:63" → ["65", "65:70", "65:70:63"]
|
||||||
* @knipIgnoreUsedByStackedPR
|
|
||||||
*/
|
*/
|
||||||
export function getAncestorExecutionIds(
|
export function getAncestorExecutionIds(
|
||||||
executionId: string | NodeExecutionId
|
executionId: string | NodeExecutionId
|
||||||
@@ -141,7 +140,6 @@ export function getAncestorExecutionIds(
|
|||||||
* Returns all ancestor execution IDs for a given execution ID, excluding itself.
|
* Returns all ancestor execution IDs for a given execution ID, excluding itself.
|
||||||
*
|
*
|
||||||
* Example: "65:70:63" → ["65", "65:70"]
|
* Example: "65:70:63" → ["65", "65:70"]
|
||||||
* @knipIgnoreUsedByStackedPR
|
|
||||||
*/
|
*/
|
||||||
export function getParentExecutionIds(
|
export function getParentExecutionIds(
|
||||||
executionId: string | NodeExecutionId
|
executionId: string | NodeExecutionId
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('createMissingNodeTypeFromError', () => {
|
|||||||
})
|
})
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'MyNodeClass',
|
type: 'MyNodeClass',
|
||||||
|
nodeId: '42',
|
||||||
hint: '"My Custom Title" (Node ID #42)'
|
hint: '"My Custom Title" (Node ID #42)'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -68,6 +69,7 @@ describe('createMissingNodeTypeFromError', () => {
|
|||||||
})
|
})
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'Unknown',
|
type: 'Unknown',
|
||||||
|
nodeId: '42',
|
||||||
hint: '"Some Title" (Node ID #42)'
|
hint: '"Some Title" (Node ID #42)'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -84,6 +86,7 @@ describe('createMissingNodeTypeFromError', () => {
|
|||||||
})
|
})
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'MyNodeClass',
|
type: 'MyNodeClass',
|
||||||
|
nodeId: '123',
|
||||||
hint: 'Node ID #123'
|
hint: 'Node ID #123'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { MissingNodeType } from '@/types/comfy'
|
import type { MissingNodeType } from '@/types/comfy'
|
||||||
|
|
||||||
import type { MissingNodeTypeExtraInfo } from '../types/missingNodeErrorTypes'
|
import type { MissingNodeTypeExtraInfo } from '../types/missingNodeErrorTypes'
|
||||||
@@ -33,5 +34,38 @@ export function createMissingNodeTypeFromError(
|
|||||||
const nodeTitle = extraInfo.node_title ?? classType
|
const nodeTitle = extraInfo.node_title ?? classType
|
||||||
const hint = buildMissingNodeHint(nodeTitle, classType, extraInfo.node_id)
|
const hint = buildMissingNodeHint(nodeTitle, classType, extraInfo.node_id)
|
||||||
|
|
||||||
return hint ? { type: classType, hint } : classType
|
if (hint) {
|
||||||
|
return {
|
||||||
|
type: classType,
|
||||||
|
...(extraInfo.node_id ? { nodeId: extraInfo.node_id } : {}),
|
||||||
|
...(hint ? { hint } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return classType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the custom node registry ID (cnr_id or aux_id) from a raw
|
||||||
|
* properties bag.
|
||||||
|
*
|
||||||
|
* @param properties - The properties object to inspect
|
||||||
|
* @returns The cnrId string, or undefined if not found
|
||||||
|
*/
|
||||||
|
export function getCnrIdFromProperties(
|
||||||
|
properties: Record<string, unknown> | undefined | null
|
||||||
|
): string | undefined {
|
||||||
|
if (typeof properties?.cnr_id === 'string') return properties.cnr_id
|
||||||
|
if (typeof properties?.aux_id === 'string') return properties.aux_id
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the custom node registry ID (cnr_id or aux_id) from a node's properties.
|
||||||
|
* Returns undefined if neither property is present.
|
||||||
|
*
|
||||||
|
* @param node - The graph node to inspect
|
||||||
|
* @returns The cnrId string, or undefined if not found
|
||||||
|
*/
|
||||||
|
export function getCnrIdFromNode(node: LGraphNode): string | undefined {
|
||||||
|
return getCnrIdFromProperties(node.properties as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user