Compare commits

..

1 Commits

Author SHA1 Message Date
John Haugeland
bcc2c51df1 feat: add executionIdToNodeLocatorId call counter behind feature flag
Add dev-only instrumentation to count how many times
executionIdToNodeLocatorId is called per execution run. This
provides a baseline measurement for evaluating the impact of
caching this function's results, in a later PR.

Gated behind the feature flag
"ff:expose_executionId_to_node_locator_id_cache_counters"
(localStorage). Uses the existing getDevOverride utility which
is tree-shaken from production builds via import.meta.env.DEV.

Enable in browser console:
  localStorage.setItem(
    'ff:expose_executionId_to_node_locator_id_cache_counters',
    'true'
  )

On execution completion, logs the total call count to
console.warn. No reload needed to toggle.

**feat**: Allow measurement of caching impact, behind a flag

**impact**: None unless activated, then just some logging

There isn't one.  I don't have access to AmpCode or Unito.
2026-02-25 23:10:11 -08:00
46 changed files with 422 additions and 692 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.6",
"version": "1.42.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -51,6 +51,7 @@ 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 './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
/**
* 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 invMax = 1 / max
const parts: string[] = ['M0,1']
const step = 1 / 255
let d = 'M0,1'
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
}
parts.push('L1,1 Z')
return parts.join(' ')
d += ' L1,1 Z'
return d
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {

View File

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

View File

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

View File

@@ -234,7 +234,6 @@ 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'
@@ -246,7 +245,6 @@ const { missingNodeTypes } = defineProps<{
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const executionErrorStore = useExecutionErrorStore()
interface ProcessedNode {
label: string
@@ -341,11 +339,6 @@ 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 priority = 104
const queueIndex = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 90_000)
makePendingTask(jobId, queueIndex, 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 priority = 210
const queueIndex = 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, priority, Date.now() - 120_000)
makePendingTask(jobId, queueIndex, 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 priority = 300
const queueIndex = 300
queue.runningTasks = [
makeRunningTask(jobId, priority, Date.now() - 65_000)
makeRunningTask(jobId, queueIndex, 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 priority = 510
const queueIndex = 510
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 45_000)
makePendingTask(jobId, queueIndex, 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 priority = 520
const queueIndex = 520
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 20_000)
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const priority = 410
const queueIndex = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
priority,
queueIndex,
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.job.priority) : null
return task ? Number(task.queueIndex) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.job.priority) < idx
(t: TaskItemImpl) => Number(t.queueIndex) < idx
)
return ahead.length
})

View File

@@ -1,150 +0,0 @@
<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

@@ -1,38 +0,0 @@
<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,11 +27,7 @@
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
:size="group.type === 'missing_node' ? 'lg' : 'default'"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
@@ -44,9 +40,7 @@
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
: group.title
}}
</span>
<span
@@ -75,21 +69,6 @@
: 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>
@@ -103,16 +82,8 @@
@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-if="group.type === 'execution'" class="px-4 space-y-3">
<div v-else class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -179,14 +150,11 @@ 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()
@@ -199,8 +167,6 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const searchQuery = ref('')
@@ -217,8 +183,7 @@ const {
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
swapNodeGroups
missingPackGroups
} = useErrorGroups(searchQuery, t)
/**
@@ -264,14 +229,6 @@ 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,4 +22,3 @@ export type ErrorGroup =
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }

View File

@@ -42,12 +42,6 @@ export interface MissingPackGroup {
isResolving: boolean
}
export interface SwapNodeGroup {
type: string
newNodeId: string | undefined
nodeTypes: MissingNodeType[]
}
interface GroupEntry {
type: 'execution'
priority: number
@@ -450,8 +444,6 @@ 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') {
@@ -503,53 +495,18 @@ 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 []
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({
return [
{
type: 'missing_node' as const,
title: error.message,
priority: 1
})
}
return groups.sort((a, b) => a.priority - b.priority)
priority: 0
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
@@ -607,7 +564,6 @@ export function useErrorGroups(
errorNodeCache,
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
swapNodeGroups
missingPackGroups
}
}

View File

@@ -14,7 +14,6 @@ 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'
@@ -443,11 +442,6 @@ 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,11 +2,7 @@ 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',
'GLSLShader'
])
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)

View File

@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
jobId: string
job: { priority: number }
queueIndex: 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)}`,
job: overrides.job ?? { priority: 0 },
queueIndex: overrides.queueIndex ?? 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', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -287,7 +287,7 @@ describe('useJobList', () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -300,7 +300,7 @@ describe('useJobList', () => {
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
createTask({ jobId: taskId, queueIndex: 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', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
@@ -331,7 +331,7 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'p',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending',
createTime: 3000
})
@@ -339,7 +339,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'r',
job: { priority: 5 },
queueIndex: 5,
mockState: 'running',
createTime: 2000
})
@@ -347,7 +347,7 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'h',
job: { priority: 3 },
queueIndex: 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', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
@@ -384,7 +384,7 @@ describe('useJobList', () => {
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
@@ -396,13 +396,13 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'wf-1',
job: { priority: 2 },
queueIndex: 2,
mockState: 'pending',
workflowId: 'workflow-1'
}),
createTask({
jobId: 'wf-2',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending',
workflowId: 'workflow-2'
})
@@ -426,14 +426,14 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
job: { priority: 2 },
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
job: { priority: 1 },
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
@@ -471,13 +471,13 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'active',
job: { priority: 3 },
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
jobId: 'other',
job: { priority: 2 },
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
@@ -507,7 +507,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'live-preview',
job: { priority: 1 },
queueIndex: 1,
mockState: 'running'
})
]
@@ -526,7 +526,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'disabled-preview',
job: { priority: 1 },
queueIndex: 1,
mockState: 'running'
})
]
@@ -567,28 +567,28 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'today-small',
job: { priority: 4 },
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
jobId: 'today-large',
job: { priority: 3 },
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
jobId: 'yesterday',
job: { priority: 2 },
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
jobId: 'undated',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending'
})
]

View File

@@ -4,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, copied } = useClipboard({ legacy: true })
const { copy, copied } = useClipboard()
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')
})
}
async function copyToClipboard(text: string) {
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) => {
try {
await copy(text)
if (copied.value) {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
showSuccessToast()
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
// If VueUse copy failed, try fallback
fallbackCopy(text)
}
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
}
}

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 '@/components/curve/types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
@@ -21,12 +21,11 @@ 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 + range * (i / segments)
const x = xMin + (xMax - xMin) * (i / segments)
const y = 1 - interpolate(x)
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
}
return parts.join('')
})

View File

@@ -1,44 +0,0 @@
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

@@ -25,10 +25,7 @@ export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode',
// Shader Nodes
'GLSLShader'
'KlingOmniProEditVideoNode'
])
/**

View File

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

View File

@@ -184,17 +184,6 @@ 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')
@@ -243,25 +232,4 @@ 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,29 +227,6 @@ 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,5 +1,4 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type {
CanvasColour,
@@ -331,6 +330,8 @@ 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,8 +27,6 @@ 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 {
@@ -142,9 +140,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state = useWidgetValueStore().registerWidget(graphId, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
nodeId
})

View File

@@ -3073,14 +3073,7 @@
"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.",
"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"
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",

View File

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

View File

@@ -229,12 +229,7 @@ export function useNodeReplacement() {
try {
const placeholders = collectAllNodes(
graph,
(n) =>
!!n.last_serialization &&
!(
(n.last_serialization.type ?? n.type ?? '') in
LiteGraph.registered_node_types
)
(n) => !!n.has_errors && !!n.last_serialization
)
for (const node of placeholders) {
@@ -266,10 +261,6 @@ 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)
}
@@ -288,19 +279,6 @@ 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,36 +189,25 @@ 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) {
try {
target.releasePointerCapture(upEvent.pointerId)
} catch {
// Pointer capture may already be released
}
cleanup()
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()
}
}
const stopMoveListen = useEventListener('pointermove', handlePointerMove)
const stopUpListen = useEventListener('pointerup', handlePointerUp)
const stopCancelListen = useEventListener('pointercancel', cleanup)
}
return {

View File

@@ -92,15 +92,9 @@ 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 isSingleImage(value) ? null : value
return typeof value === 'string' ? null : value
})
const beforeBatchCount = computed(
@@ -132,26 +126,26 @@ watch(
const beforeImage = computed(() => {
const value = props.widget.value
if (isSingleImage(value)) return value
if (typeof value === 'string') return value
return value?.beforeImages?.[beforeIndex.value] ?? ''
})
const afterImage = computed(() => {
const value = props.widget.value
if (isSingleImage(value)) return ''
if (typeof value === 'string') return ''
return value?.afterImages?.[afterIndex.value] ?? ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return !isSingleImage(value) && value?.beforeAlt
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return !isSingleImage(value) && value?.afterAlt
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})

View File

@@ -4,7 +4,6 @@ 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'
@@ -78,9 +77,7 @@ const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number,
computedHeight: number | undefined,
imgs: HTMLImageElement[],
width: number
computedHeight: number | undefined
) => {
if (!node.size) return
@@ -102,6 +99,7 @@ const renderPreview = (
node.pointerDown = null
}
const imgs = node.imgs ?? []
if (imgs.length === 0) return
let { imageIndex } = node
@@ -114,7 +112,7 @@ const renderPreview = (
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = width
const dw = node.size[0]
const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0
if (imageIndex == null) {
@@ -360,29 +358,8 @@ class ImagePreviewWidget extends BaseWidget {
this.serialize = false
}
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 drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y, this.computedHeight)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {

View File

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

View File

@@ -112,17 +112,6 @@ 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
@@ -417,7 +406,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Missing node actions
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
// Lookup helpers
getNodeErrors,

View File

@@ -31,6 +31,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
@@ -67,6 +68,16 @@ export const useExecutionStore = defineStore('execution', () => {
const initializingJobIds = ref<Set<string>>(new Set())
let executionIdToLocatorCallCount = 0
function isLocatorCacheCounterEnabled(): boolean {
return (
getDevOverride<boolean>(
'expose_executionId_to_node_locator_id_cache_counters'
) ?? false
)
}
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
@@ -106,6 +117,9 @@ export const useExecutionStore = defineStore('execution', () => {
const parts = String(state.display_node_id).split(':')
for (let i = 0; i < parts.length; i++) {
const executionId = parts.slice(0, i + 1).join(':')
if (isLocatorCacheCounterEnabled()) {
executionIdToLocatorCallCount++
}
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!locatorId) continue
@@ -424,6 +438,12 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
if (isLocatorCacheCounterEnabled() && executionIdToLocatorCallCount > 0) {
console.warn(
`[executionStore] executionIdToNodeLocatorId calls this run: ${executionIdToLocatorCallCount}`
)
executionIdToLocatorCallCount = 0
}
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null
if (jobId) {

View File

@@ -190,28 +190,78 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('')
})
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()
it('should return provider for new extension model types', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const provider = modelToNodeStore.getNodeProvider(modelType)
expect(provider?.nodeDef?.name).toBe(expectedNodeName)
expect(provider?.key).toBe(expectedKey)
}
)
// 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'
)
})
})
describe('getAllNodeProviders', () => {

View File

@@ -334,7 +334,7 @@ describe('useQueueStore', () => {
})
describe('update() - sorting', () => {
it('should sort tasks by job.priority descending', async () => {
it('should sort tasks by queueIndex 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].job.priority).toBe(5)
expect(store.historyTasks[1].job.priority).toBe(3)
expect(store.historyTasks[2].job.priority).toBe(1)
expect(store.historyTasks[0].queueIndex).toBe(5)
expect(store.historyTasks[1].queueIndex).toBe(3)
expect(store.historyTasks[2].queueIndex).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].job.priority).toBe(15)
expect(store.pendingTasks[1].job.priority).toBe(12)
expect(store.pendingTasks[2].job.priority).toBe(10)
expect(store.pendingTasks[0].queueIndex).toBe(15)
expect(store.pendingTasks[1].queueIndex).toBe(12)
expect(store.pendingTasks[2].queueIndex).toBe(10)
})
})
describe('update() - queue index collision (THE BUG FIX)', () => {
it('should NOT confuse different prompts with same job.priority', async () => {
it('should NOT confuse different prompts with same queueIndex', 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].job.priority).toBe(51)
expect(store.historyTasks[0].queueIndex).toBe(51)
})
it('should correctly reconcile when job.priority is reused', async () => {
it('should correctly reconcile when queueIndex 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 job.priority collisions simultaneously', async () => {
it('should handle multiple queueIndex 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].job.priority).toBe(10)
expect(store.historyTasks[1].job.priority).toBe(9)
expect(store.historyTasks[2].job.priority).toBe(8)
expect(store.historyTasks[0].queueIndex).toBe(10)
expect(store.historyTasks[1].queueIndex).toBe(9)
expect(store.historyTasks[2].queueIndex).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].job.priority).toBe(23)
expect(store.historyTasks[0].queueIndex).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].job.priority).toBe(10)
expect(store.historyTasks[0].queueIndex).toBe(10)
})
it('should dynamically adjust when maxHistoryItems changes', async () => {

View File

@@ -312,6 +312,10 @@ export class TaskItemImpl {
return this.jobId + this.displayStatus
}
get queueIndex() {
return this.job.priority
}
get jobId() {
return this.job.id
}
@@ -496,7 +500,7 @@ export const useQueueStore = defineStore('queue', () => {
)
const lastHistoryQueueIndex = computed<number>(() =>
historyTasks.value.length ? historyTasks.value[0].job.priority : -1
historyTasks.value.length ? historyTasks.value[0].queueIndex : -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.job.priority
const idx = task.queueIndex
if (typeof idx === 'number') return `${prefix} #${idx}`
if (shortId) return `${prefix} ${shortId}`
return prefix

View File

@@ -0,0 +1,9 @@
/**
* 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,70 +1,93 @@
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getCnrIdFromProperties,
getCnrIdFromNode
buildMissingNodeHint,
createMissingNodeTypeFromError
} from './missingNodeErrorUtil'
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'
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)'
)
})
it('prefers cnr_id over aux_id', () => {
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', () => {
expect(
getCnrIdFromProperties({ cnr_id: 'primary', aux_id: 'secondary' })
).toBe('primary')
buildMissingNodeHint('MyNodeClass', 'MyNodeClass', undefined)
).toBeUndefined()
})
it('returns undefined when neither is present', () => {
expect(getCnrIdFromProperties({})).toBeUndefined()
it('returns undefined when title is null and no node ID', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', undefined)).toBeUndefined()
})
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()
it('returns node ID hint when title is null but node ID exists', () => {
expect(buildMissingNodeHint(null, 'MyNodeClass', '42')).toBe('Node ID #42')
})
})
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')
describe('createMissingNodeTypeFromError', () => {
it('returns string type when no hint is generated', () => {
const result = createMissingNodeTypeFromError({
class_type: 'MyNodeClass',
node_title: 'MyNodeClass'
})
expect(result).toBe('MyNodeClass')
})
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('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('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 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('returns undefined when node has no cnr_id or aux_id', () => {
const node = { properties: {} } as unknown as LGraphNode
expect(getCnrIdFromNode(node)).toBeUndefined()
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'
})
})
})

View File

@@ -1,4 +1,48 @@
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