Compare commits

..

11 Commits

Author SHA1 Message Date
jaeone94
4dc67b375b fix: node replacement fails after execution and modal sync
- Detect missing nodes by unregistered type instead of has_errors flag,
  which gets cleared by clearAllNodeErrorFlags during execution
- Sync modal replace action with executionErrorStore so Errors Tab
  updates immediately when nodes are replaced from the dialog
2026-02-27 10:37:47 +09:00
jaeone94
1d8a01cdf8 fix: ensure node replacement data loads before workflow processing
Await nodeReplacementStore.load() before collectMissingNodesAndModels
to prevent race condition where replacement mappings are not yet
available when determining isReplaceable flag.
2026-02-27 00:14:43 +09:00
jaeone94
b585dfa4fc fix: address review feedback for handleReplaceAll
- Remove redundant parameter that shadowed composable ref
- Only remove actually replaced types from error list on partial success
2026-02-26 23:05:12 +09:00
jaeone94
1be6d27024 refactor: Destructure defineProps in SwapNodesCard.vue 2026-02-26 22:03:42 +09:00
jaeone94
5aa4baf116 fix: address review feedback for node replacement
- Use i18n key for 'Swap Nodes' group title
- Preserve partial replacement results on error instead of returning empty array
2026-02-26 21:53:00 +09:00
jaeone94
7d69a0db5b fix: remove unused export from scanMissingNodes 2026-02-26 20:28:10 +09:00
jaeone94
83bb4300e3 fix: address code review feedback on node replacement
- Add error toast in replaceNodesInPlace for user-visible failure
  feedback, returning empty array on error instead of throwing
- Guard removeMissingNodesByType behind replacement success check
  (replaced.length > 0) to prevent stale error list updates
- Sort buildMissingNodeGroups by priority for deterministic UI order
  (Swap Nodes 0 → Missing Node Packs 1 → Execution Errors)
- Add aux_id fallback and cnr_id precedence tests for getCnrIdFromNode
- Split replaceAllWarning from replaceWarning to fix i18n key mismatch
  between TabErrors tooltip and MissingNodesContent dialog
2026-02-26 20:24:56 +09:00
jaeone94
0d58a92e34 feat: add node replacement UI to Errors Tab
Integrate the existing node replacement functionality into the Errors
Tab, allowing users to replace missing nodes directly from the side
panel without opening the modal dialog.
New components:
- SwapNodesCard: container with guidance label and grouped rows
- SwapNodeGroupRow: per-type replacement row with expand/collapse,
  node instance list, locate button, and replace action
Bug fixes discovered during implementation:
- Fix stale canvas rendering after replacement by calling onNodeAdded
  to refresh VueNodeData (bypassed by replaceWithMapping)
- Guard initializeVueNodeLayout against duplicate layout creation
- Fix missing node list being overwritten by incomplete server 400
  response — replaced with full graph rescan via useMissingNodeScan
- Add removeMissingNodesByType to prune replaced types from error list
Cleanup:
- Remove dead code: buildMissingNodeHint, createMissingNodeTypeFromError
2026-02-26 20:24:44 +09:00
Johnpaul Chiwetelu
45ca1beea2 fix: address small CodeRabbit issues (#9229)
## Summary

Address several small CodeRabbit-filed issues: clipboard simplification,
queue getter cleanup, pointer handling, and test parameterization.

## Changes

- **What**:
- Simplify `useCopyToClipboard` by using VueUse's built-in `legacy` mode
instead of a manual `document.execCommand` fallback
- Remove `queueIndex` getter alias from `TaskItemImpl`, replace all
usages with `job.priority`
- Add `pointercancel` event handling and try-catch around
`releasePointerCapture` in `useNodeResize` to prevent stuck resize state
- Parameterize repetitive `getNodeProvider` tests in
`modelToNodeStore.test.ts` using `it.each()`

- Fixes #9024
- Fixes #7955
- Fixes #7323
- Fixes #8703

## Review Focus

- `useCopyToClipboard`: VueUse's `legacy: true` enables the
`execCommand` fallback internally — verify browser compat is acceptable
- `useNodeResize`: cleanup logic extracted into shared function used by
both `pointerup` and `pointercancel`
2026-02-26 02:32:53 -08:00
Christian Byrne
aef299caf8 fix: add GLSLShader to canvas image preview node types (#9198)
## Summary

Add `GLSLShader` to `CANVAS_IMAGE_PREVIEW_NODE_TYPES` so GLSL shader
previews are promoted through subgraph nodes.

## Changes

- Add `'GLSLShader'` to the `CANVAS_IMAGE_PREVIEW_NODE_TYPES` set in
`src/composables/node/useNodeCanvasImagePreview.ts`

## Context

GLSLShader node previews were not showing on parent subgraph nodes
because `CANVAS_IMAGE_PREVIEW_NODE_TYPES` only included `PreviewImage`
and `SaveImage`. The `$$canvas-image-preview` pseudo-widget was never
created for GLSLShader nodes, so the promotion system had nothing to
promote. This degraded the UX of all 12 shipped GLSL blueprint subgraphs
— users couldn't see shader output previews without expanding the
subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9198-fix-add-GLSLShader-to-canvas-image-preview-node-types-3126d73d3650817dbe9beab4bdeaa414)
by [Unito](https://www.unito.io)
2026-02-26 01:15:24 -08:00
Johnpaul Chiwetelu
188fafa89a fix: address trivial CodeRabbit issues (#9196)
## Summary

Address several trivial CodeRabbit-filed issues: type guard extraction,
ESLint globals, curve editor optimizations, and type relocation.

## Changes

- **What**: Extract `isSingleImage()` type guard in WidgetImageCompare;
add `__DISTRIBUTION__`/`__IS_NIGHTLY__` to ESLint globals and remove
stale disable comments; remove unnecessary `toFixed(4)` from curve path
generation; optimize `histogramToPath` with array join; move
`CurvePoint` type to curve domain

- Fixes #9175
- Fixes #8281
- Fixes #9116
- Fixes #9145
- Fixes #9147

## Review Focus

All changes are mechanical/trivial. Curve path output changes from
fixed-precision to raw floats — SVG handles both fine.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9196-fix-address-trivial-CodeRabbit-issues-3126d73d365081f19a5ce20305403098)
by [Unito](https://www.unito.io)
2026-02-26 00:43:14 -08:00
48 changed files with 684 additions and 1495 deletions

View File

@@ -22,7 +22,9 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const
const settings = {

View File

@@ -51,7 +51,6 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -77,7 +77,8 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import {
createMonotoneInterpolator,

View File

@@ -1,4 +1,4 @@
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
/**
* Monotone cubic Hermite interpolation.
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const step = 1 / 255
let d = 'M0,1'
const invMax = 1 / max
const parts: string[] = ['M0,1']
for (let i = 0; i < 256; i++) {
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
}
d += ' L1,1 Z'
return d
parts.push('L1,1 Z')
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {

View File

@@ -0,0 +1 @@
export type CurvePoint = [x: number, y: number]

View File

@@ -438,7 +438,6 @@ onMounted(() => {
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]

View File

@@ -234,6 +234,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -245,6 +246,7 @@ const { missingNodeTypes } = defineProps<{
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const executionErrorStore = useExecutionErrorStore()
interface ProcessedNode {
label: string
@@ -339,6 +341,11 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Sync with execution error store so the Errors Tab updates immediately
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)

View File

@@ -131,11 +131,11 @@ export const Queued: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const queueIndex = 104
const priority = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
makePendingTask(jobId, priority, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const queueIndex = 210
const priority = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
makePendingTask(jobId, priority, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
@@ -238,9 +238,9 @@ export const Running: Story = {
const exec = useExecutionStore()
const jobId = 'job-running-1'
const queueIndex = 300
const priority = 300
queue.runningTasks = [
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
makeRunningTask(jobId, priority, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const queueIndex = 510
const priority = 510
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
makePendingTask(jobId, priority, Date.now() - 45_000)
]
queue.historyTasks = [
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const queueIndex = 520
const priority = 520
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
makePendingTask(jobId, priority, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const queueIndex = 410
const priority = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
queueIndex,
priority,
12,
false,
'Example error: invalid inputs for node X'

View File

@@ -168,14 +168,14 @@ const queuedAtValue = computed(() =>
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.queueIndex) : null
return task ? Number(task.job.priority) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.queueIndex) < idx
(t: TaskItemImpl) => Number(t.job.priority) < idx
)
return ahead.length
})

View 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>

View 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>

View File

@@ -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)
}

View File

@@ -22,3 +22,4 @@ export type ErrorGroup =
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }

View File

@@ -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
}
}

View File

@@ -14,6 +14,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -442,6 +443,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {

View File

@@ -2,7 +2,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)

View File

@@ -1,467 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildEagerEvalContext,
useNodeEagerEval
} from '@/composables/node/useNodeEagerEval'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { EagerEval } from '@/schemas/nodeDefSchema'
import {
createMockLGraphNode,
createMockLLink
} from '@/utils/__tests__/litegraphTestUtils'
// ---------------------
// Test helpers
// ---------------------
function createMockEagerNode(
config: Partial<EagerEval>,
widgets: Array<{ name: string; value: unknown }> = [],
inputs: Array<{ name: string; link: number | null }> = []
): LGraphNode {
const fullConfig: EagerEval = { engine: 'jsonata', ...config }
const mockWidgets = widgets.map(({ name, value }) => ({
name,
value,
type: 'number'
}))
const baseNode = createMockLGraphNode()
return Object.assign(baseNode, {
widgets: mockWidgets,
inputs,
constructor: {
nodeData: {
name: 'TestMathNode',
eager_eval: fullConfig
}
}
})
}
// ---------------------
// buildEagerEvalContext
// ---------------------
describe('buildEagerEvalContext', () => {
it('maps disconnected input widgets to context by name', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 5 },
{ name: 'b', value: 3 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
expect(ctx.b).toBe(3)
})
it('skips connected inputs when linked node has no output data yet', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 5 },
{ name: 'b', value: 3 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: 42 }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
expect('b' in ctx).toBe(false)
})
it('uses connected node output value when data is available', () => {
const sourceNode = createMockLGraphNode()
Object.assign(sourceNode, {
outputs: [{ _data: 7, name: 'result' }]
})
const mockLink = createMockLLink({
id: 42,
origin_id: 99,
origin_slot: 0,
target_id: 1,
target_slot: 1
})
const node = createMockEagerNode(
{ expr: 'a + b' },
[{ name: 'a', value: 5 }],
[
{ name: 'a', link: null },
{ name: 'b', link: 42 }
]
)
Object.assign(node, {
graph: {
getLink: (id: number) => (id === 42 ? mockLink : undefined),
getNodeById: (id: number) => (id === 99 ? sourceNode : null)
}
})
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
expect(ctx.b).toBe(7)
})
it('assigns positional letters to connected inputs with data', () => {
const sourceNode = createMockLGraphNode()
Object.assign(sourceNode, {
outputs: [{ _data: 12, name: 'value' }]
})
const mockLink = createMockLLink({
id: 10,
origin_id: 50,
origin_slot: 0,
target_id: 1,
target_slot: 0
})
const node = createMockEagerNode(
{ expr: 'a * 2' },
[],
[{ name: 'x_input', link: 10 }]
)
Object.assign(node, {
graph: {
getLink: (id: number) => (id === 10 ? mockLink : undefined),
getNodeById: (id: number) => (id === 50 ? sourceNode : null)
}
})
const ctx = buildEagerEvalContext(node)
expect(ctx.x_input).toBe(12)
expect(ctx.a).toBe(12)
})
it('exposes autogrow base names (e.g. "value0") alongside full names ("values.value0")', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[
{ name: 'expression', value: 'value0 + value1' },
{ name: 'values.value0', value: 3 },
{ name: 'values.value1', value: 7 }
],
[
{ name: 'expression', link: null },
{ name: 'values.value0', link: null },
{ name: 'values.value1', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx['values.value0']).toBe(3)
expect(ctx['values.value1']).toBe(7)
expect(ctx['value0']).toBe(3)
expect(ctx['value1']).toBe(7)
expect(ctx.a).toBe(3)
expect(ctx.b).toBe(7)
expect((ctx as Record<string, unknown>).values).toEqual([3, 7])
})
it('includes values array for aggregate functions', () => {
const node = createMockEagerNode(
{ expr: '$sum(values)' },
[
{ name: 'value0', value: 1 },
{ name: 'value1', value: 2 },
{ name: 'value2', value: 3 }
],
[
{ name: 'value0', link: null },
{ name: 'value1', link: null },
{ name: 'value2', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect((ctx as Record<string, unknown>).values).toEqual([1, 2, 3])
})
it('assigns positional letters (a, b, c) to disconnected inputs', () => {
const node = createMockEagerNode(
{ expr: 'a * b' },
[
{ name: 'x_input', value: 4 },
{ name: 'y_input', value: 7 }
],
[
{ name: 'x_input', link: null },
{ name: 'y_input', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(4)
expect(ctx.b).toBe(7)
expect(ctx.x_input).toBe(4)
expect(ctx.y_input).toBe(7)
})
it('parses string widget values as numbers', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: '10' },
{ name: 'b', value: '3.5' }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(10)
expect(ctx.b).toBe(3.5)
})
it('returns null for non-numeric widget values', () => {
const node = createMockEagerNode(
{ expr: 'a' },
[{ name: 'a', value: 'not a number' }],
[{ name: 'a', link: null }]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBeNull()
})
it('includes standalone widgets not tied to inputs', () => {
const node = createMockEagerNode(
{ expr: 'a', expr_widget: 'expression' },
[
{ name: 'expression', value: 'a + 1' },
{ name: 'a', value: 5 }
],
[{ name: 'a', link: null }]
)
const ctx = buildEagerEvalContext(node)
expect(ctx.a).toBe(5)
})
it('excludes non-numeric values from the values array', () => {
const node = createMockEagerNode(
{ expr: '$sum(values)' },
[
{ name: 'v0', value: 1 },
{ name: 'v1', value: 'not a number' },
{ name: 'v2', value: 3 }
],
[
{ name: 'v0', link: null },
{ name: 'v1', link: null },
{ name: 'v2', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect((ctx as Record<string, unknown>).values).toEqual([1, 3])
})
it('omits values array when all inputs are non-numeric', () => {
const node = createMockEagerNode(
{ expr: 'a' },
[{ name: 'a', value: 'hello' }],
[{ name: 'a', link: null }]
)
const ctx = buildEagerEvalContext(node)
expect('values' in ctx).toBe(false)
})
it('excludes expr_widget input from context and positional mapping', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[
{ name: 'expression', value: 'a + b' },
{ name: 'a', value: 5 },
{ name: 'b', value: 2 },
{ name: 'c', value: 3 }
],
[
{ name: 'expression', link: null },
{ name: 'a', link: null },
{ name: 'b', link: null },
{ name: 'c', link: null }
]
)
const ctx = buildEagerEvalContext(node)
expect('expression' in ctx).toBe(false)
expect(ctx.a).toBe(5)
expect(ctx.b).toBe(2)
expect(ctx.c).toBe(3)
})
it('does not overwrite named inputs with positional letters', () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 10 },
{ name: 'b', value: 20 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const ctx = buildEagerEvalContext(node)
// Named inputs 'a' and 'b' should keep their original values
expect(ctx.a).toBe(10)
expect(ctx.b).toBe(20)
})
})
// ---------------------
// useNodeEagerEval
// ---------------------
describe('useNodeEagerEval', () => {
describe('hasEagerEval', () => {
it('returns true for nodes with eager_eval config', () => {
const node = createMockEagerNode({ expr: 'a + b' })
const { hasEagerEval } = useNodeEagerEval()
expect(hasEagerEval(node)).toBe(true)
})
it('returns false for nodes without eager_eval', () => {
const node = createMockLGraphNode()
Object.assign(node, {
constructor: { nodeData: { name: 'RegularNode' } }
})
const { hasEagerEval } = useNodeEagerEval()
expect(hasEagerEval(node)).toBe(false)
})
it('returns false for nodes with no constructor data', () => {
const node = createMockLGraphNode()
const { hasEagerEval } = useNodeEagerEval()
expect(hasEagerEval(node)).toBe(false)
})
})
describe('formatEagerResult', () => {
it('formats integer result', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 42 })).toBe('42')
})
it('formats float result with trimmed zeros', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 3.14 })).toBe('3.14')
})
it('trims trailing zeros', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 3.1 })).toBe('3.1')
})
it('formats whole float as integer', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: 5.0 })).toBe('5')
})
it('returns error message for errors', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: null, error: 'bad expr' })).toBe(
'bad expr'
)
})
it('returns empty string for null value', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: null })).toBe('')
})
it('stringifies non-number values', () => {
const { formatEagerResult } = useNodeEagerEval()
expect(formatEagerResult({ value: true })).toBe('true')
})
})
describe('getNodeEagerResult', () => {
it('returns null for nodes without eager_eval', () => {
const node = createMockLGraphNode()
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns error for invalid expression', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[{ name: 'expression', value: '(((' }],
[]
)
const { getNodeEagerResult } = useNodeEagerEval()
const result = getNodeEagerResult(node)
expect(result).toEqual({ value: null, error: 'Invalid expression' })
})
it('returns null when no expression configured', () => {
const node = createMockEagerNode({}, [], [])
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns null when expr_widget references missing widget', () => {
const node = createMockEagerNode(
{ expr_widget: 'missing_widget' },
[],
[]
)
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns null when expr_widget value is empty', () => {
const node = createMockEagerNode(
{ expr_widget: 'expression' },
[{ name: 'expression', value: '' }],
[]
)
const { getNodeEagerResult } = useNodeEagerEval()
expect(getNodeEagerResult(node)).toBeNull()
})
it('returns cached result on subsequent calls with same inputs', async () => {
const node = createMockEagerNode(
{ expr: 'a + b' },
[
{ name: 'a', value: 2 },
{ name: 'b', value: 3 }
],
[
{ name: 'a', link: null },
{ name: 'b', link: null }
]
)
const { getNodeEagerResult } = useNodeEagerEval()
// First call schedules async eval
const first = getNodeEagerResult(node)
expect(first).toBeNull() // no cached result yet
// Wait for async eval to complete
await new Promise((r) => setTimeout(r, 50))
// Second call returns cached result
const second = getNodeEagerResult(node)
expect(second).toEqual({ value: 5 })
})
})
})

View File

@@ -1,389 +0,0 @@
// Frontend eager evaluation for pure-computation nodes (e.g., math expression).
//
// Nodes declare `eager_eval` in their definition to opt in. The frontend
// evaluates a JSONata expression against the node's widget values whenever
// they change, displaying the result without a backend round-trip.
//
// Follows the same async-eval + cache pattern as useNodePricing.ts.
import { readonly, ref } from 'vue'
import type { Ref } from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComfyNodeDef, EagerEval } from '@/schemas/nodeDefSchema'
import type { Expression } from 'jsonata'
import jsonata from 'jsonata'
// ---------------------
// Types
// ---------------------
type CompiledEagerEval = EagerEval & {
_compiled: Expression | null
}
type NodeConstructorData = Partial<Pick<ComfyNodeDef, 'name' | 'eager_eval'>>
type EagerEvalContext = Record<string, number | null>
type CacheEntry = { sig: string; result: EagerEvalResult }
type InflightEntry = { sig: string; promise: Promise<void> }
type EagerEvalResult = {
value: unknown
error?: string
}
// ---------------------
// Helpers
// ---------------------
/** Convert 0-based index to spreadsheet-style alias: a..z, aa..az, ba... */
function positionalAlias(index: number): string {
let s = ''
let n = index
while (true) {
const rem = n % 26
s = String.fromCharCode(97 + rem) + s
n = Math.floor(n / 26)
if (n === 0) break
n -= 1
}
return s
}
const getNodeConstructorData = (
node: LGraphNode
): NodeConstructorData | undefined =>
(node.constructor as { nodeData?: NodeConstructorData }).nodeData
const asFiniteNumber = (v: unknown): number | null => {
if (v === null || v === undefined) return null
if (typeof v === 'number') return Number.isFinite(v) ? v : null
if (typeof v === 'string') {
const t = v.trim()
if (t === '') return null
const n = Number(t)
return Number.isFinite(n) ? n : null
}
return null
}
/**
* Retrieve the numeric output value from the node connected to the given link.
* Returns undefined when the link/source node cannot be resolved or has no data yet.
*
* Checks output._data first (set by eager eval or backend execution), then
* falls back to reading the source node's widget value for simple pass-through
* nodes (e.g. INT, FLOAT) where output equals widget value.
*/
function getLinkedOutputValue(
node: LGraphNode,
linkId: number
): number | null | undefined {
const link = node.graph?.getLink(linkId)
if (!link) return undefined
const sourceNode = node.graph?.getNodeById(link.origin_id)
if (!sourceNode) return undefined
const output = sourceNode.outputs?.[link.origin_slot]
if (output?._data !== undefined) return asFiniteNumber(output._data)
// Fallback: for single-output nodes (e.g. Int, Float primitives),
// read the "value" widget directly. This enables eager eval without
// requiring a backend round-trip.
if (sourceNode.outputs?.length === 1 && sourceNode.widgets) {
const valueWidget = sourceNode.widgets.find(
(w: IBaseWidget) => w.name === 'value'
)
if (valueWidget) return asFiniteNumber(valueWidget.value)
}
return undefined
}
// ---------------------
// Compile cache
// ---------------------
const compiledCache = new Map<string, CompiledEagerEval | null>()
function compileEagerEval(config: EagerEval): CompiledEagerEval {
const expr = config.expr
if (!expr) return { ...config, _compiled: null }
try {
return { ...config, _compiled: jsonata(expr) }
} catch (e) {
console.error('[eager-eval] failed to compile expr:', expr, e)
return { ...config, _compiled: null }
}
}
function getCompiledForNodeType(
nodeName: string,
config: EagerEval
): CompiledEagerEval | null {
const cacheKey = `${nodeName}:${config.expr ?? ''}`
if (compiledCache.has(cacheKey)) return compiledCache.get(cacheKey) ?? null
const compiled = compileEagerEval(config)
compiledCache.set(cacheKey, compiled)
return compiled
}
// ---------------------
// Context building
// ---------------------
/**
* Build evaluation context from node widget values.
* Maps input widgets to named variables: first input → "a", second → "b", etc.
* Also includes original widget names for direct reference.
*/
export function buildEagerEvalContext(node: LGraphNode): EagerEvalContext {
const ctx: EagerEvalContext = {}
const values: (number | null)[] = []
// Determine which widget holds the expression (should be excluded from context)
const eagerConfig = getNodeConstructorData(node)?.eager_eval
const exprWidgetName = eagerConfig?.expr_widget
// Collect values from input slots, using widget values when disconnected
// and connected node output data when connected.
if (node.inputs) {
for (const input of node.inputs) {
if (input.name === exprWidgetName) continue
let numVal: number | null
if (input.link != null) {
const linked = getLinkedOutputValue(node, input.link)
if (linked === undefined) continue // connected but no data yet
numVal = linked
} else {
const widget = node.widgets?.find(
(w: IBaseWidget) => w.name === input.name
)
if (!widget) continue
numVal = asFiniteNumber(widget.value)
}
ctx[input.name] = numVal
values.push(numVal)
// Autogrow inputs are named "group.baseName" (e.g. "values.value0").
// Also expose the baseName alone so expressions can use "value0" directly,
// matching the context the backend builds from autogrow dict keys.
if (input.name.includes('.')) {
const baseName = input.name.slice(input.name.indexOf('.') + 1)
if (baseName && !(baseName in ctx)) ctx[baseName] = numVal
}
}
}
// Also collect from standalone widgets (not tied to inputs)
if (node.widgets) {
for (const widget of node.widgets) {
if (widget.name in ctx) continue
if (widget.name === exprWidgetName) continue
const isInputWidget = node.inputs?.some((inp) => inp.name === widget.name)
if (isInputWidget) continue
ctx[widget.name] = asFiniteNumber(widget.value)
}
}
// Map positional variables: a, b, c, ... aa, ab, ...
// Only assign if the alias doesn't already exist as a named input
let letterIdx = 0
if (node.inputs) {
for (const input of node.inputs) {
if (input.name === exprWidgetName) continue
if (!(input.name in ctx)) continue
const alias = positionalAlias(letterIdx)
if (!(alias in ctx)) {
ctx[alias] = ctx[input.name]
}
letterIdx++
}
}
// Add values array for aggregate functions ($sum, $max, etc.)
const numericValues = values.filter((v): v is number => v !== null)
if (numericValues.length > 0) {
;(ctx as Record<string, unknown>)['values'] = numericValues
}
return ctx
}
function buildSignature(ctx: EagerEvalContext, expr: string): string {
const parts: string[] = [`e:${expr}`]
for (const [key, val] of Object.entries(ctx)) {
parts.push(`${key}=${val === null ? '' : String(val)}`)
}
return parts.join('|')
}
// ---------------------
// Async eval + cache
// ---------------------
const evalTick = ref(0)
const nodeRevisions = new WeakMap<LGraphNode, Ref<number>>()
function getNodeRevisionRef(node: LGraphNode): Ref<number> {
let rev = nodeRevisions.get(node)
if (!rev) {
rev = ref(0)
nodeRevisions.set(node, rev)
}
return rev
}
const cache = new WeakMap<LGraphNode, CacheEntry>()
const desiredSig = new WeakMap<LGraphNode, string>()
const inflight = new WeakMap<LGraphNode, InflightEntry>()
function scheduleEvaluation(
node: LGraphNode,
compiled: Expression,
ctx: EagerEvalContext,
sig: string
) {
desiredSig.set(node, sig)
const running = inflight.get(node)
if (running && running.sig === sig) return
const promise = Promise.resolve(compiled.evaluate(ctx))
.then((res) => {
if (desiredSig.get(node) !== sig) return
cache.set(node, { sig, result: { value: res } })
// Write result to output._data so downstream nodes (and input label
// display) can read it without requiring a backend round-trip.
if (node.outputs?.[0]) node.outputs[0]._data = res
})
.catch((err: unknown) => {
if (desiredSig.get(node) === sig) {
const message = err instanceof Error ? err.message : 'Evaluation error'
cache.set(node, { sig, result: { value: null, error: message } })
}
})
.finally(() => {
const cur = inflight.get(node)
if (cur && cur.sig === sig) inflight.delete(node)
// Only bump revision if this eval is still the desired one
if (desiredSig.get(node) === sig) {
if (LiteGraph.vueNodesMode) {
getNodeRevisionRef(node).value++
} else {
evalTick.value++
}
}
})
inflight.set(node, { sig, promise })
}
// ---------------------
// Expression resolution
// ---------------------
/**
* Resolve the JSONata expression for a node.
* If `expr_widget` is set, reads the expression from the widget value.
* If `expr` is set, uses the static expression.
*/
function resolveExpression(node: LGraphNode, config: EagerEval): string | null {
if (config.expr_widget) {
const widget = node.widgets?.find(
(w: IBaseWidget) => w.name === config.expr_widget
)
return widget ? String(widget.value ?? '') : null
}
return config.expr ?? null
}
// ---------------------
// Public composable
// ---------------------
export function useNodeEagerEval() {
/**
* Get the eager evaluation result for a node.
* Returns cached result synchronously; schedules async evaluation on cache miss.
*/
function getNodeEagerResult(node: LGraphNode): EagerEvalResult | null {
void evalTick.value
const nodeData = getNodeConstructorData(node)
if (!nodeData?.eager_eval) return null
const config = nodeData.eager_eval
const expr = resolveExpression(node, config)
if (!expr) return null
// Build context and check cache before compiling
const ctx = buildEagerEvalContext(node)
const sig = buildSignature(ctx, expr)
const cached = cache.get(node)
if (cached && cached.sig === sig) return cached.result
// Compile expression only on cache miss
let compiled: Expression | null = null
if (config.expr_widget) {
try {
compiled = jsonata(expr)
} catch {
return { value: null, error: 'Invalid expression' }
}
} else {
const cachedCompiled = getCompiledForNodeType(nodeData.name ?? '', config)
compiled = cachedCompiled?._compiled ?? null
}
if (!compiled) return null
scheduleEvaluation(node, compiled, ctx, sig)
return null
}
/**
* Format an eager eval result for display as a badge.
*/
function formatEagerResult(result: EagerEvalResult): string {
if (result.error) return result.error
if (result.value === null || result.value === undefined) return ''
if (typeof result.value === 'number') {
return Number.isInteger(result.value)
? String(result.value)
: result.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
}
return String(result.value)
}
/**
* Check if a node supports eager evaluation.
*/
function hasEagerEval(node: LGraphNode): boolean {
return !!getNodeConstructorData(node)?.eager_eval
}
/**
* Trigger re-evaluation for a node (call when inputs/widgets change).
*/
function triggerEagerEval(node: LGraphNode): void {
getNodeEagerResult(node)
}
return {
getNodeEagerResult,
formatEagerResult,
hasEagerEval,
triggerEagerEval,
getNodeRevisionRef,
evalRevision: readonly(evalTick)
}
}

View File

@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
jobId: string
queueIndex: number
job: { priority: number }
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
@@ -174,7 +174,7 @@ const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
queueIndex: overrides.queueIndex ?? 0,
job: overrides.job ?? { priority: 0 },
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
@@ -258,7 +258,7 @@ describe('useJobList', () => {
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
createTask({ jobId: '1', job: { priority: 1 }, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -287,7 +287,7 @@ describe('useJobList', () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -300,7 +300,7 @@ describe('useJobList', () => {
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
]
await flush()
jobItems.value
@@ -314,7 +314,7 @@ describe('useJobList', () => {
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
createTask({ jobId: '3', job: { priority: 1 }, mockState: 'pending' })
]
initComposable()
@@ -331,7 +331,7 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'p',
queueIndex: 1,
job: { priority: 1 },
mockState: 'pending',
createTime: 3000
})
@@ -339,7 +339,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'r',
queueIndex: 5,
job: { priority: 5 },
mockState: 'running',
createTime: 2000
})
@@ -347,7 +347,7 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'h',
queueIndex: 3,
job: { priority: 3 },
mockState: 'completed',
createTime: 1000,
executionEndTimestamp: 5000
@@ -366,9 +366,9 @@ describe('useJobList', () => {
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
]
const instance = initComposable()
@@ -384,7 +384,7 @@ describe('useJobList', () => {
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
]
await flush()
@@ -396,13 +396,13 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'wf-1',
queueIndex: 2,
job: { priority: 2 },
mockState: 'pending',
workflowId: 'workflow-1'
}),
createTask({
jobId: 'wf-2',
queueIndex: 1,
job: { priority: 1 },
mockState: 'pending',
workflowId: 'workflow-2'
})
@@ -426,14 +426,14 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
queueIndex: 2,
job: { priority: 2 },
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
queueIndex: 1,
job: { priority: 1 },
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
@@ -471,13 +471,13 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'active',
queueIndex: 3,
job: { priority: 3 },
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
jobId: 'other',
queueIndex: 2,
job: { priority: 2 },
mockState: 'running',
executionTime: 3_600_000
})
@@ -507,7 +507,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'live-preview',
queueIndex: 1,
job: { priority: 1 },
mockState: 'running'
})
]
@@ -526,7 +526,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'disabled-preview',
queueIndex: 1,
job: { priority: 1 },
mockState: 'running'
})
]
@@ -567,28 +567,28 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'today-small',
queueIndex: 4,
job: { priority: 4 },
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
jobId: 'today-large',
queueIndex: 3,
job: { priority: 3 },
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
jobId: 'yesterday',
queueIndex: 2,
job: { priority: 2 },
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
jobId: 'undated',
queueIndex: 1,
job: { priority: 1 },
mockState: 'pending'
})
]

View File

@@ -4,64 +4,32 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, copied } = useClipboard()
const { copy, copied } = useClipboard({ legacy: true })
const toast = useToast()
const showSuccessToast = () => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
}
const showErrorToast = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
textarea.setAttribute('aria-hidden', 'true')
textarea.setAttribute('tabindex', '-1')
textarea.style.width = '1px'
textarea.style.height = '1px'
document.body.appendChild(textarea)
textarea.select()
try {
// using legacy document.execCommand for fallback for old and linux browsers
const successful = document.execCommand('copy')
if (successful) {
showSuccessToast()
} else {
showErrorToast()
}
} catch (err) {
showErrorToast()
} finally {
textarea.remove()
}
}
const copyToClipboard = async (text: string) => {
async function copyToClipboard(text: string) {
try {
await copy(text)
if (copied.value) {
showSuccessToast()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} else {
// If VueUse copy failed, try fallback
fallbackCopy(text)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
}

View File

@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from '@/components/curve/types'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
@@ -21,11 +21,12 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []
for (let i = 0; i <= segments; i++) {
const x = xMin + (xMax - xMin) * (i / segments)
const x = xMin + range * (i / segments)
const y = 1 - interpolate(x)
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
}
return parts.join('')
})

View File

@@ -0,0 +1,44 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
const nodeReplacementStore = useNodeReplacementStore()
const missingNodeTypes: MissingNodeType[] = []
const allNodes = collectAllNodes(rootGraph)
for (const node of allNodes) {
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (originalType in LiteGraph.registered_node_types) continue
const cnrId = getCnrIdFromNode(node)
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const executionId = getExecutionIdByNode(rootGraph, node)
missingNodeTypes.push({
type: originalType,
nodeId: executionId ?? String(node.id),
cnrId,
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
}
return missingNodeTypes
}
/** Re-scan the graph for missing nodes and update the error store. */
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
const types = scanMissingNodes(rootGraph)
useExecutionErrorStore().surfaceMissingNodes(types)
}

View File

@@ -201,7 +201,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY

View File

@@ -184,6 +184,17 @@ describe('getPromotableWidgets', () => {
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
@@ -232,4 +243,25 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})

View File

@@ -227,6 +227,29 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))

View File

@@ -1,216 +0,0 @@
// Extension that enables frontend-side eager evaluation for nodes
// that declare `eager_eval` in their definition.
//
// When a node's widget values change, the extension evaluates the JSONata
// expression and displays the result as a badge on the node.
import { watch } from 'vue'
import jsonata from 'jsonata'
import {
buildEagerEvalContext,
useNodeEagerEval
} from '@/composables/node/useNodeEagerEval'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useExtensionStore } from '@/stores/extensionStore'
function inputDisplayName(input: INodeInputSlot): string {
return input.name.includes('.')
? input.name.slice(input.name.indexOf('.') + 1)
: input.name
}
function formatNum(v: number): string {
return Number.isInteger(v)
? String(v)
: v.toFixed(4).replace(/0+$/, '').replace(/\.$/, '')
}
const extensionStore = useExtensionStore()
extensionStore.registerExtension({
name: 'Comfy.EagerEval',
nodeCreated(node: LGraphNode) {
const eagerEval = useNodeEagerEval()
if (!eagerEval.hasEagerEval(node)) return
const colorPaletteStore = useColorPaletteStore()
function updateInputValueLabels() {
if (!node.inputs) return
const ctx = buildEagerEvalContext(node) as Record<string, unknown>
for (const input of node.inputs) {
const displayName = inputDisplayName(input)
if (input.link != null) {
let val = ctx[input.name]
// Fall back to cached backend context when eager eval
// can't resolve values (e.g. inputs from non-primitive nodes)
if (val === undefined && backendContext) {
const baseName = inputDisplayName(input)
val = backendContext[baseName] ?? backendContext[input.name]
}
input.label =
typeof val === 'number'
? `${displayName}: ${formatNum(val)}`
: displayName
} else {
input.label = inputDisplayName(input)
}
}
}
// Watch all widgets for changes to trigger re-evaluation
const widgetNames = node.widgets?.map((w) => w.name) ?? []
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
widgetNames,
triggerCanvasRedraw: true
})
computedWithWidgetWatch(() => 0)
// When async evaluation completes, redraw the canvas so the badge updates
watch(eagerEval.evalRevision, () => {
node.graph?.setDirtyCanvas(true, true)
})
// Watch connection changes for input-dependent re-evaluation
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
() => {
backendContext = {}
contextEvalCache = { expr: '', result: NaN }
eagerEval.triggerEagerEval(node)
node.graph?.setDirtyCanvas(true, true)
}
)
const emptyBadge = new LGraphBadge({ text: '' })
let lastLabel = ''
let lastBadge = emptyBadge
function makeBadge(label: string, isError = false): LGraphBadge {
if (label === lastLabel) return lastBadge
lastLabel = label
lastBadge = new LGraphBadge({
text: isError ? label : `= ${label}`,
fgColor: isError
? '#ff6b6b'
: (colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR ?? '#fff'),
bgColor: isError ? '#4a1a1a' : '#1a4a2a'
})
return lastBadge
}
// Track backend execution result separately from eager eval.
// backendBadge is shown when eager eval can't compute (e.g. inputs
// come from non-primitive nodes like Get Image Size).
let backendBadge: LGraphBadge = emptyBadge
let backendExpr = ''
// Cached backend context for re-evaluating changed expressions
let backendContext: Record<string, unknown> = {}
let contextEvalCache = { expr: '', result: NaN }
let contextEvalInFlight = ''
node.onExecuted = useChainCallback(
node.onExecuted,
(output: Record<string, unknown>) => {
const exprWidget = node.widgets?.find((w) => w.name === 'expression')
backendExpr = exprWidget ? String(exprWidget.value) : ''
// Cache context for re-evaluation with changed expressions
const ctxArr = output.context
if (
Array.isArray(ctxArr) &&
ctxArr[0] &&
typeof ctxArr[0] === 'object'
) {
backendContext = ctxArr[0] as Record<string, unknown>
contextEvalCache = { expr: '', result: NaN }
}
const resultArr = output.result
if (Array.isArray(resultArr)) {
const raw = resultArr[0]
if (typeof raw === 'number' && node.outputs?.[0]) {
node.outputs[0]._data = raw
backendBadge = new LGraphBadge({
text: `= ${formatNum(raw)}`,
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR ?? '#fff',
bgColor: '#1a4a2a'
})
node.graph?.setDirtyCanvas(true, true)
}
}
}
)
const badgeGetter: () => LGraphBadge = () => {
updateInputValueLabels()
const result = eagerEval.getNodeEagerResult(node)
// Eager eval succeeded — use it directly
if (result && result.value != null) {
return makeBadge(eagerEval.formatEagerResult(result), !!result.error)
}
const exprWidget = node.widgets?.find((w) => w.name === 'expression')
const currentExpr = exprWidget ? String(exprWidget.value) : ''
// Backend result for the same expression
if (backendBadge !== emptyBadge && currentExpr === backendExpr) {
return backendBadge
}
// Re-evaluate with cached backend context when expression changed
if (Object.keys(backendContext).length > 0 && currentExpr) {
if (
currentExpr === contextEvalCache.expr &&
!Number.isNaN(contextEvalCache.result)
) {
return makeBadge(formatNum(contextEvalCache.result))
}
if (currentExpr !== contextEvalInFlight) {
contextEvalInFlight = currentExpr
const capturedExpr = currentExpr
try {
Promise.resolve(jsonata(currentExpr).evaluate(backendContext))
.then((val: unknown) => {
if (typeof val === 'number') {
contextEvalCache = { expr: capturedExpr, result: val }
if (node.outputs?.[0]) node.outputs[0]._data = val
node.graph?.setDirtyCanvas(true, true)
}
})
.catch(() => {
contextEvalCache = { expr: capturedExpr, result: NaN }
})
.finally(() => {
if (contextEvalInFlight === capturedExpr) {
contextEvalInFlight = ''
}
})
} catch {
contextEvalCache = { expr: currentExpr, result: NaN }
contextEvalInFlight = ''
}
}
}
// Eager eval has an error and no backend/context fallback
if (result?.error) {
return makeBadge(result.error, true)
}
return emptyBadge
}
node.badges.push(badgeGetter)
}
})

View File

@@ -4,7 +4,6 @@ import './clipspace'
import './contextMenuFilter'
import './customWidgets'
import './dynamicPrompts'
import './eagerEval'
import './editAttention'
import './electronAdapter'
import './groupNode'

View File

@@ -1,4 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type {
CanvasColour,
@@ -330,8 +331,6 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
value: Bounds
}
export type CurvePoint = [x: number, y: number]
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
type: 'curve'
value: CurvePoint[]

View File

@@ -27,6 +27,8 @@ export interface DrawWidgetOptions {
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
}
interface DrawTruncatingTextOptions extends DrawWidgetOptions {

View File

@@ -3073,7 +3073,14 @@
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
"willBeReplacedBy": "This node will be replaced by:",
"replaceNode": "Replace Node",
"replaceAll": "Replace All",
"unknownNode": "Unknown",
"replaceAllWarning": "Replaces all available nodes in this group.",
"swapNodesTitle": "Swap Nodes"
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",

View File

@@ -282,7 +282,6 @@ const getCheckoutTier = (
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

View File

@@ -229,7 +229,12 @@ export function useNodeReplacement() {
try {
const placeholders = collectAllNodes(
graph,
(n) => !!n.has_errors && !!n.last_serialization
(n) =>
!!n.last_serialization &&
!(
(n.last_serialization.type ?? n.type ?? '') in
LiteGraph.registered_node_types
)
)
for (const node of placeholders) {
@@ -261,6 +266,10 @@ export function useNodeReplacement() {
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
// Refresh Vue node data — replaceWithMapping bypasses graph.add()
// so onNodeAdded must be called explicitly to update VueNodeData.
nodeGraph.onNodeAdded?.(newNode)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
@@ -279,6 +288,19 @@ export function useNodeReplacement() {
life: 3000
})
}
} catch (error) {
console.error('Failed to replace nodes:', error)
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
}
toastStore.add({
severity: 'error',
summary: t('g.error', 'Error'),
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
life: 5000
})
return replacedTypes
} finally {
changeTracker?.afterChange()
}

View File

@@ -189,25 +189,36 @@ export function useNodeResize(
}
}
const cleanup = () => {
if (!isResizing.value) return
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeStartSize.value = null
resizeStartPosition.value = null
// Stop tracking shift key state
stopShiftSync()
stopMoveListen()
stopUpListen()
stopCancelListen()
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeStartSize.value = null
resizeStartPosition.value = null
// Stop tracking shift key state
stopShiftSync()
target.releasePointerCapture(upEvent.pointerId)
stopMoveListen()
stopUpListen()
try {
target.releasePointerCapture(upEvent.pointerId)
} catch {
// Pointer capture may already be released
}
cleanup()
}
}
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
const stopUpListen = useEventListener('pointerup', handlePointerUp)
const stopCancelListen = useEventListener('pointercancel', cleanup)
}
return {

View File

@@ -92,9 +92,15 @@ watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
}
})
function isSingleImage(
value: ImageCompareValue | string | undefined
): value is string {
return typeof value === 'string'
}
const parsedValue = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? null : value
return isSingleImage(value) ? null : value
})
const beforeBatchCount = computed(
@@ -126,26 +132,26 @@ watch(
const beforeImage = computed(() => {
const value = props.widget.value
if (typeof value === 'string') return value
if (isSingleImage(value)) return value
return value?.beforeImages?.[beforeIndex.value] ?? ''
})
const afterImage = computed(() => {
const value = props.widget.value
if (typeof value === 'string') return ''
if (isSingleImage(value)) return ''
return value?.afterImages?.[afterIndex.value] ?? ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.beforeAlt
return !isSingleImage(value) && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.afterAlt
return !isSingleImage(value) && value?.afterAlt
? value.afterAlt
: 'After image'
})

View File

@@ -4,6 +4,7 @@ import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -77,7 +78,9 @@ const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number,
computedHeight: number | undefined
computedHeight: number | undefined,
imgs: HTMLImageElement[],
width: number
) => {
if (!node.size) return
@@ -99,7 +102,6 @@ const renderPreview = (
node.pointerDown = null
}
const imgs = node.imgs ?? []
if (imgs.length === 0) return
let { imageIndex } = node
@@ -112,7 +114,7 @@ const renderPreview = (
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dw = width
const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0
if (imageIndex == null) {
@@ -358,8 +360,29 @@ class ImagePreviewWidget extends BaseWidget {
this.serialize = false
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y, this.computedHeight)
override drawWidget(
ctx: CanvasRenderingContext2D,
options: DrawWidgetOptions
): void {
const imgs = options.previewImages ?? this.node.imgs ?? []
renderPreview(
ctx,
this.node,
this.y,
this.computedHeight,
imgs,
options.width
)
}
override createCopyForNode(node: LGraphNode): this {
const copy = new ImagePreviewWidget(
node,
this.name,
this.options as IWidgetOptions<string | object>
) as this
copy.value = this.value
return copy
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {

View File

@@ -270,21 +270,6 @@ const zPriceBadge = z.object({
export type PriceBadge = z.infer<typeof zPriceBadge>
/**
* Schema for eager evaluation definition.
* Allows nodes to be evaluated on the frontend without a backend round-trip.
* Used for math expression nodes and similar pure-computation nodes.
*/
const zEagerEval = z.object({
engine: z.literal('jsonata').optional().default('jsonata'),
/** Static JSONata expression (e.g., "$sum($values)" for an Add blueprint). */
expr: z.string().optional(),
/** Widget name containing a user-editable JSONata expression. */
expr_widget: z.string().optional()
})
export type EagerEval = z.infer<typeof zEagerEval>
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
@@ -326,13 +311,6 @@ export const zComfyNodeDef = z.object({
* and input connectivity.
*/
price_badge: zPriceBadge.optional(),
/**
* Eager evaluation definition for frontend-side computation.
* When present, the frontend evaluates the node's expression client-side
* using JSONata whenever input values change, displaying the result
* without requiring a backend round-trip.
*/
eager_eval: zEagerEval.optional(),
/** Category for the Essentials tab. If set, the node appears in Essentials. */
essentials_category: z.string().optional(),
/** Whether the blueprint is a global/installed blueprint (not user-created). */

View File

@@ -79,12 +79,8 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import type { MissingNodeTypeExtraInfo } from '@/workbench/extensions/manager/types/missingNodeErrorTypes'
import {
createMissingNodeTypeFromError,
getCnrIdFromNode,
getCnrIdFromProperties
} from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { rescanAndSurfaceMissingNodes } from '@/composables/useMissingNodeScan'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import {
collectAllNodes,
@@ -1190,7 +1186,7 @@ export class ComfyApp {
const embeddedModels: ModelFile[] = []
const nodeReplacementStore = useNodeReplacementStore()
await nodeReplacementStore.load()
const collectMissingNodesAndModels = (
nodes: ComfyWorkflowJSON['nodes'],
pathPrefix: string = '',
@@ -1527,35 +1523,8 @@ export class ComfyApp {
typeof error.response.error === 'object' &&
error.response.error?.type === 'missing_node_type'
) {
const extraInfo = (error.response.error.extra_info ??
{}) as MissingNodeTypeExtraInfo
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])
// Re-scan the full graph instead of using the server's single-node response.
rescanAndSurfaceMissingNodes(this.rootGraph)
} else if (
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
!(error instanceof PromptExecutionError)

View File

@@ -112,6 +112,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
@@ -406,6 +417,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Missing node actions
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
// Lookup helpers
getNodeErrors,

View File

@@ -190,78 +190,28 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('')
})
it('should return provider for new extension model types', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
it.each([
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
['sams', 'SAMLoader', 'model_name'],
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name'],
['FlashVSR', 'FlashVSRNode', ''],
['FlashVSR-v1.1', 'FlashVSRNode', ''],
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name']
])(
'should return correct provider for %s',
(modelType, expectedNodeName, expectedKey) => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// SAM2
const sam2Provider = modelToNodeStore.getNodeProvider('sam2')
expect(sam2Provider?.nodeDef?.name).toBe('DownloadAndLoadSAM2Model')
expect(sam2Provider?.key).toBe('model')
// SAMLoader (original SAM)
const samsProvider = modelToNodeStore.getNodeProvider('sams')
expect(samsProvider?.nodeDef?.name).toBe('SAMLoader')
expect(samsProvider?.key).toBe('model_name')
// IP-Adapter
const ipadapterProvider = modelToNodeStore.getNodeProvider('ipadapter')
expect(ipadapterProvider?.nodeDef?.name).toBe('IPAdapterModelLoader')
expect(ipadapterProvider?.key).toBe('ipadapter_file')
// DepthAnything
const depthProvider = modelToNodeStore.getNodeProvider('depthanything')
expect(depthProvider?.nodeDef?.name).toBe(
'DownloadAndLoadDepthAnythingV2Model'
)
expect(depthProvider?.key).toBe('model')
})
it('should use hierarchical fallback for ultralytics subcategories', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// ultralytics/bbox should fall back to ultralytics
const bboxProvider = modelToNodeStore.getNodeProvider('ultralytics/bbox')
expect(bboxProvider?.nodeDef?.name).toBe('UltralyticsDetectorProvider')
expect(bboxProvider?.key).toBe('model_name')
// ultralytics/segm should also fall back to ultralytics
const segmProvider = modelToNodeStore.getNodeProvider('ultralytics/segm')
expect(segmProvider?.nodeDef?.name).toBe('UltralyticsDetectorProvider')
})
it('should return provider for FlashVSR nodes with empty key (auto-load)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const flashVSRProvider = modelToNodeStore.getNodeProvider('FlashVSR')
expect(flashVSRProvider?.nodeDef?.name).toBe('FlashVSRNode')
expect(flashVSRProvider?.key).toBe('')
const flashVSR11Provider =
modelToNodeStore.getNodeProvider('FlashVSR-v1.1')
expect(flashVSR11Provider?.nodeDef?.name).toBe('FlashVSRNode')
expect(flashVSR11Provider?.key).toBe('')
})
it('should return provider for segformer models', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const segformerB2Provider = modelToNodeStore.getNodeProvider(
'segformer_b2_clothes'
)
expect(segformerB2Provider?.nodeDef?.name).toBe('LS_LoadSegformerModel')
expect(segformerB2Provider?.key).toBe('model_name')
const segformerB3FashionProvider = modelToNodeStore.getNodeProvider(
'segformer_b3_fashion'
)
expect(segformerB3FashionProvider?.nodeDef?.name).toBe(
'LS_LoadSegformerModel'
)
})
const provider = modelToNodeStore.getNodeProvider(modelType)
expect(provider?.nodeDef?.name).toBe(expectedNodeName)
expect(provider?.key).toBe(expectedKey)
}
)
})
describe('getAllNodeProviders', () => {

View File

@@ -334,7 +334,7 @@ describe('useQueueStore', () => {
})
describe('update() - sorting', () => {
it('should sort tasks by queueIndex descending', async () => {
it('should sort tasks by job.priority descending', async () => {
const job1 = createHistoryJob(1, 'hist-1')
const job2 = createHistoryJob(5, 'hist-2')
const job3 = createHistoryJob(3, 'hist-3')
@@ -344,9 +344,9 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks[0].queueIndex).toBe(5)
expect(store.historyTasks[1].queueIndex).toBe(3)
expect(store.historyTasks[2].queueIndex).toBe(1)
expect(store.historyTasks[0].job.priority).toBe(5)
expect(store.historyTasks[1].job.priority).toBe(3)
expect(store.historyTasks[2].job.priority).toBe(1)
})
it('should preserve API sort order for pending tasks', async () => {
@@ -363,14 +363,14 @@ describe('useQueueStore', () => {
await store.update()
expect(store.pendingTasks[0].queueIndex).toBe(15)
expect(store.pendingTasks[1].queueIndex).toBe(12)
expect(store.pendingTasks[2].queueIndex).toBe(10)
expect(store.pendingTasks[0].job.priority).toBe(15)
expect(store.pendingTasks[1].job.priority).toBe(12)
expect(store.pendingTasks[2].job.priority).toBe(10)
})
})
describe('update() - queue index collision (THE BUG FIX)', () => {
it('should NOT confuse different prompts with same queueIndex', async () => {
it('should NOT confuse different prompts with same job.priority', async () => {
const hist1 = createHistoryJob(50, 'prompt-uuid-aaa')
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
@@ -387,10 +387,10 @@ describe('useQueueStore', () => {
expect(store.historyTasks).toHaveLength(1)
expect(store.historyTasks[0].jobId).toBe('prompt-uuid-bbb')
expect(store.historyTasks[0].queueIndex).toBe(51)
expect(store.historyTasks[0].job.priority).toBe(51)
})
it('should correctly reconcile when queueIndex is reused', async () => {
it('should correctly reconcile when job.priority is reused', async () => {
const hist1 = createHistoryJob(100, 'first-prompt-at-100')
const hist2 = createHistoryJob(99, 'prompt-at-99')
@@ -412,7 +412,7 @@ describe('useQueueStore', () => {
expect(jobIds).not.toContain('first-prompt-at-100')
})
it('should handle multiple queueIndex collisions simultaneously', async () => {
it('should handle multiple job.priority collisions simultaneously', async () => {
const hist1 = createHistoryJob(10, 'old-at-10')
const hist2 = createHistoryJob(20, 'old-at-20')
const hist3 = createHistoryJob(30, 'keep-at-30')
@@ -563,9 +563,9 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks).toHaveLength(3)
expect(store.historyTasks[0].queueIndex).toBe(10)
expect(store.historyTasks[1].queueIndex).toBe(9)
expect(store.historyTasks[2].queueIndex).toBe(8)
expect(store.historyTasks[0].job.priority).toBe(10)
expect(store.historyTasks[1].job.priority).toBe(9)
expect(store.historyTasks[2].job.priority).toBe(8)
})
it('should respect maxHistoryItems when combining new and existing', async () => {
@@ -589,7 +589,7 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks).toHaveLength(5)
expect(store.historyTasks[0].queueIndex).toBe(23)
expect(store.historyTasks[0].job.priority).toBe(23)
})
it('should handle maxHistoryItems = 0', async () => {
@@ -619,7 +619,7 @@ describe('useQueueStore', () => {
await store.update()
expect(store.historyTasks).toHaveLength(1)
expect(store.historyTasks[0].queueIndex).toBe(10)
expect(store.historyTasks[0].job.priority).toBe(10)
})
it('should dynamically adjust when maxHistoryItems changes', async () => {

View File

@@ -312,10 +312,6 @@ export class TaskItemImpl {
return this.jobId + this.displayStatus
}
get queueIndex() {
return this.job.priority
}
get jobId() {
return this.job.id
}
@@ -500,7 +496,7 @@ export const useQueueStore = defineStore('queue', () => {
)
const lastHistoryQueueIndex = computed<number>(() =>
historyTasks.value.length ? historyTasks.value[0].queueIndex : -1
historyTasks.value.length ? historyTasks.value[0].job.priority : -1
)
const hasPendingTasks = computed<boolean>(() => pendingTasks.value.length > 0)

View File

@@ -44,7 +44,7 @@ export const iconForJobState = (state: JobState): string => {
const buildTitle = (task: TaskItemImpl, t: (k: string) => string): string => {
const prefix = t('g.job')
const shortId = String(task.jobId ?? '').split('-')[0]
const idx = task.queueIndex
const idx = task.job.priority
if (typeof idx === 'number') return `${prefix} #${idx}`
if (shortId) return `${prefix} ${shortId}`
return prefix

View File

@@ -1,9 +0,0 @@
/**
* Extra info returned by the backend for missing_node_type errors
* from the /prompt endpoint validation.
*/
export interface MissingNodeTypeExtraInfo {
class_type?: string | null
node_title?: string | null
node_id?: string
}

View File

@@ -1,93 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
buildMissingNodeHint,
createMissingNodeTypeFromError
getCnrIdFromProperties,
getCnrIdFromNode
} from './missingNodeErrorUtil'
describe('buildMissingNodeHint', () => {
it('returns hint with title and node ID when both available', () => {
expect(buildMissingNodeHint('My Node', 'MyNodeClass', '42')).toBe(
'"My Node" (Node ID #42)'
describe('getCnrIdFromProperties', () => {
it('returns cnr_id when present', () => {
expect(getCnrIdFromProperties({ cnr_id: 'my-pack' })).toBe('my-pack')
})
it('returns aux_id when cnr_id is absent', () => {
expect(getCnrIdFromProperties({ aux_id: 'my-aux-pack' })).toBe(
'my-aux-pack'
)
})
it('returns hint with title only when no node ID', () => {
expect(buildMissingNodeHint('My Node', 'MyNodeClass', undefined)).toBe(
'"My Node"'
)
})
it('returns hint with node ID only when title matches class type', () => {
expect(buildMissingNodeHint('MyNodeClass', 'MyNodeClass', '42')).toBe(
'Node ID #42'
)
})
it('returns undefined when title matches class type and no node ID', () => {
it('prefers cnr_id over aux_id', () => {
expect(
buildMissingNodeHint('MyNodeClass', 'MyNodeClass', undefined)
).toBeUndefined()
getCnrIdFromProperties({ cnr_id: 'primary', aux_id: 'secondary' })
).toBe('primary')
})
it('returns undefined when title is null and no node ID', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', undefined)).toBeUndefined()
it('returns undefined when neither is present', () => {
expect(getCnrIdFromProperties({})).toBeUndefined()
})
it('returns node ID hint when title is null but node ID exists', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', '42')).toBe('Node ID #42')
it('returns undefined for null properties', () => {
expect(getCnrIdFromProperties(null)).toBeUndefined()
})
it('returns undefined for undefined properties', () => {
expect(getCnrIdFromProperties(undefined)).toBeUndefined()
})
it('returns undefined when cnr_id is not a string', () => {
expect(getCnrIdFromProperties({ cnr_id: 123 })).toBeUndefined()
})
})
describe('createMissingNodeTypeFromError', () => {
it('returns string type when no hint is generated', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_title: 'MyNodeClass'
})
expect(result).toBe('MyNodeClass')
describe('getCnrIdFromNode', () => {
it('returns cnr_id from node properties', () => {
const node = {
properties: { cnr_id: 'node-pack' }
} as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBe('node-pack')
})
it('returns object with hint when title differs from class type', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_title: 'My Custom Title',
node_id: '42'
})
expect(result).toEqual({
type: 'MyNodeClass',
nodeId: '42',
hint: '"My Custom Title" (Node ID #42)'
})
it('returns aux_id when cnr_id is absent', () => {
const node = {
properties: { aux_id: 'node-aux-pack' }
} as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBe('node-aux-pack')
})
it('handles null class_type by defaulting to Unknown', () => {
const result = createMissingNodeTypeFromError({
class_type: null,
node_title: 'Some Title',
node_id: '42'
})
expect(result).toEqual({
type: 'Unknown',
nodeId: '42',
hint: '"Some Title" (Node ID #42)'
})
it('prefers cnr_id over aux_id in node properties', () => {
const node = {
properties: { cnr_id: 'primary', aux_id: 'secondary' }
} as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBe('primary')
})
it('handles empty extra_info', () => {
const result = createMissingNodeTypeFromError({})
expect(result).toBe('Unknown')
})
it('returns object with node ID hint when only node_id is available', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_id: '123'
})
expect(result).toEqual({
type: 'MyNodeClass',
nodeId: '123',
hint: 'Node ID #123'
})
it('returns undefined when node has no cnr_id or aux_id', () => {
const node = { properties: {} } as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBeUndefined()
})
})

View File

@@ -1,48 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
import type { MissingNodeTypeExtraInfo } from '../types/missingNodeErrorTypes'
/**
* Builds a hint string from missing node metadata.
* Provides context about which node is missing (title, ID) when available.
*/
export function buildMissingNodeHint(
nodeTitle: string | null | undefined,
classType: string,
nodeId: string | undefined
): string | undefined {
const hasTitle = nodeTitle && nodeTitle !== classType
if (hasTitle && nodeId) {
return `"${nodeTitle}" (Node ID #${nodeId})`
} else if (hasTitle) {
return `"${nodeTitle}"`
} else if (nodeId) {
return `Node ID #${nodeId}`
}
return undefined
}
/**
* Creates a MissingNodeType from backend error extra_info.
* Used when the /prompt endpoint returns a missing_node_type error.
*/
export function createMissingNodeTypeFromError(
extraInfo: MissingNodeTypeExtraInfo
): MissingNodeType {
const classType = extraInfo.class_type ?? 'Unknown'
const nodeTitle = extraInfo.node_title ?? classType
const hint = buildMissingNodeHint(nodeTitle, classType, extraInfo.node_id)
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