mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-21 12:57:30 +00:00
Compare commits
4 Commits
feat/repla
...
feat/math-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6bf4a287 | ||
|
|
ea57c7bffc | ||
|
|
57cb3b4756 | ||
|
|
823f4d3726 |
@@ -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 = {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type CurvePoint = [x: number, y: number]
|
||||
@@ -438,6 +438,7 @@ onMounted(() => {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,4 +22,3 @@ export type ErrorGroup =
|
||||
priority: number
|
||||
}
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
467
src/composables/node/useNodeEagerEval.test.ts
Normal file
467
src/composables/node/useNodeEagerEval.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
389
src/composables/node/useNodeEagerEval.ts
Normal file
389
src/composables/node/useNodeEagerEval.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
216
src/extensions/core/eagerEval.ts
Normal file
216
src/extensions/core/eagerEval.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './customWidgets'
|
||||
import './dynamicPrompts'
|
||||
import './eagerEval'
|
||||
import './editAttention'
|
||||
import './electronAdapter'
|
||||
import './groupNode'
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -282,6 +282,7 @@ const getCheckoutTier = (
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -270,6 +270,21 @@ 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(),
|
||||
@@ -311,6 +326,13 @@ 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). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user