New bottom button and badges (#8603)

- "Enter Subgraph" "Show advanced inputs" and a new "show node Errors"
button now use a combined button design at the bottom of the node.
- A new "Errors" tab is added to the right side panel
- After a failed queue, the label of an invalid widget is now red.
- Badges other than price are now displayed on the bottom of the node.
- Price badge will now truncate from the first space, prioritizing the
sizing of the node title
- An indicator for the node resize handle is now displayed while mousing
over the node.

<img width="669" height="233" alt="image"
src="https://github.com/user-attachments/assets/53b3b59c-830b-474d-8f20-07f557124af7"
/>


![resize](https://github.com/user-attachments/assets/e2473b5b-fe4d-4f1e-b1c3-57c23d2a0349)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
AustinMroz
2026-02-10 23:29:45 -08:00
committed by GitHub
parent 69062c6da1
commit 28b171168a
25 changed files with 396 additions and 251 deletions

View File

@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -33,6 +35,7 @@ import {
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -87,10 +90,25 @@ function closePanel() {
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
icon?: string
}>
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
)
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
}
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
}
return {
getCreditsBadge,
isCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -68,6 +68,7 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"enterSubgraph": "Enter Subgraph",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
"resizeFromBottomLeft": "Resize from bottom-left corner",

View File

@@ -23,7 +23,7 @@
:class="
cn(
'-translate-x-1/2 w-3',
hasSlotError &&
hasError &&
'before:ring-2 before:ring-error before:ring-offset-0 before:size-4 before:absolute before:rounded-full before:pointer-events-none'
)
"
@@ -40,7 +40,7 @@
:class="
cn(
'truncate text-node-component-slot-text',
hasSlotError && 'text-error font-medium'
hasError && 'text-error font-medium'
)
"
>
@@ -65,19 +65,19 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {
slotData: INodeSlot
compatible?: boolean
connected?: boolean
dotOnly?: boolean
hasError?: boolean
index: number
nodeType?: string
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
dotOnly?: boolean
socketless?: boolean
}
@@ -91,18 +91,6 @@ const hasNoLabel = computed(
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const executionStore = useExecutionStore()
const hasSlotError = computed(() => {
const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? '']
if (!nodeErrors) return false
const slotName = props.slotData.name
return nodeErrors.errors.some(
(error) => error.extra_info?.input_name === slotName
)
})
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()

View File

@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraph,
LGraphNode,
LGraphNode as LGLGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
@@ -56,7 +56,7 @@ vi.mock('@/i18n', () => ({
}
}))
describe('NodeHeader - Subgraph Functionality', () => {
describe('Vue Node - Subgraph Functionality', () => {
// Helper to setup common mocks
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
if (hasGraph) mockApp.rootGraph = {}
@@ -64,7 +64,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
vi.mocked(getNodeByLocatorId).mockReturnValue({
isSubgraphNode: (): this is SubgraphNode => isSubgraph
} as LGraphNode)
} as LGLGraphNode)
}
beforeEach(() => {
@@ -89,8 +89,8 @@ describe('NodeHeader - Subgraph Functionality', () => {
flags: {}
})
const createWrapper = (props = {}) => {
return mount(NodeHeader, {
const createWrapper = (props: { nodeData: VueNodeData }) => {
return mount(LGraphNode, {
props,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
@@ -106,8 +106,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()
@@ -120,8 +119,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
await setupMocks(false) // isSubgraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()
@@ -130,29 +128,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
expect(subgraphButton.exists()).toBe(false)
})
it('should emit enter-subgraph event when button is clicked', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
await subgraphButton.trigger('click')
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
})
it('should handle subgraph context correctly', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
readonly: false
nodeData: createMockNodeData('test-node-1', 'subgraph-id')
})
await wrapper.vm.$nextTick()
@@ -167,26 +147,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
expect(subgraphButton.exists()).toBe(true)
})
it('should handle missing graph gracefully', async () => {
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should prevent event propagation on double click', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()

View File

@@ -9,7 +9,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node absolute text-sm',
'group/node bg-node-component-header-surface lg-node absolute text-sm',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
shapeClass,
'touch-none flex flex-col',
@@ -28,11 +28,9 @@
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none',
!isCollapsed && ' pb-1'
: 'pointer-events-none'
)
"
:style="[
@@ -40,7 +38,8 @@
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor)
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}
]"
v-bind="remainingPointerHandlers"
@@ -71,9 +70,9 @@
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
@@ -89,7 +88,7 @@
/>
<template v-if="!isCollapsed">
<div class="relative mb-1">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
@@ -105,7 +104,7 @@
</div>
<div
class="flex flex-1 flex-col gap-1 pb-2"
class="flex flex-1 flex-col gap-1 pt-1 pb-3 bg-component-node-background rounded-b-2xl"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
@@ -120,42 +119,75 @@
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<!-- Show advanced inputs button for subgraph nodes -->
<div v-if="showAdvancedInputsButton" class="flex justify-center px-3">
<button
:class="
cn(
WidgetInputBaseClass,
'w-full h-7 flex justify-center items-center gap-2 text-sm px-3 outline-0 ring-0 truncate',
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
)
"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<i class="icon-[lucide--chevron-up] size-4" />
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
</template>
<template v-else>
<i class="icon-[lucide--settings-2] size-4" />
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
</template>
</button>
</div>
<NodeBadges v-bind="badges" :pricing="undefined" />
</div>
</template>
<Button
variant="textonly"
:class="
cn(
'w-full h-12 rounded-b-2xl -mt-5 pt-7 pb-2 -z-1 text-xs',
hasAnyError && 'hover:bg-destructive-background-hover'
)
"
as-child
>
<button
v-if="hasAnyError"
@click.stop="useRightSidePanelStore().openPanel('error')"
>
<span>{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4" />
</button>
<button
v-else-if="lgraphNode?.isSubgraphNode()"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span>{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4" />
</button>
<button
v-else-if="showAdvancedState || showAdvancedInputsButton"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
<i class="icon-[lucide--chevron-up] size-4" />
</template>
<template v-else>
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
<i class="icon-[lucide--settings-2] size-4" />
</template>
</button>
</Button>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
cn(
baseResizeHandleClasses,
'-right-1 -bottom-1 cursor-se-resize group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown"
/>
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
class="w-2/5 h-2/5 top-1 left-1 absolute"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</template>
@@ -172,6 +204,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -189,10 +222,12 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
@@ -212,7 +247,6 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import { WidgetInputBaseClass } from '../widgets/components/layout'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
@@ -299,6 +333,7 @@ const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
const badges = usePartitionedBadges(nodeData)
async function nodeOnPointerdown(event: PointerEvent) {
if (event.altKey && lgraphNode.value) {
@@ -405,7 +440,7 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error'
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
defineProps<{
hasComfyBadge: boolean
core: NodeBadgeProps[]
extension: NodeBadgeProps[]
}>()
</script>
<template>
<div
v-if="hasComfyBadge || core.length || extension.length"
class="flex h-5 w-full gap-2 px-2 text-muted-foreground"
>
<div
v-if="hasComfyBadge"
class="rounded-full bg-component-node-widget-background size-6 flex justify-center items-center"
>
<i class="icon-[comfy--comfy-c] size-3" />
</div>
<div
v-if="core.length"
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
>
<template v-for="(badge, index) of core" :key="badge.text">
<div
v-if="index !== 0"
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
/>
<NodeBadge
bg-color="transparent"
v-bind="badge"
class="h-6 first:pl-2 last:pr-2"
/>
</template>
</div>
<div
v-if="extension.length"
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
>
<template v-for="(badge, index) of extension" :key="badge.text">
<div
v-if="index !== 0"
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
/>
<NodeBadge
bg-color="transparent"
v-bind="badge"
class="h-6 first:pl-2 last:pr-2"
/>
</template>
</div>
</div>
</template>

View File

@@ -7,20 +7,16 @@
:class="
cn(
'lg-node-header text-sm py-2 pl-2 pr-3 w-full min-w-0',
'text-node-component-header bg-node-component-header-surface',
'text-node-component-header',
headerShapeClass
)
"
:style="{
backgroundColor: applyLightThemeColor(nodeData?.color),
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
}"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<div class="flex items-center justify-between gap-2.5 min-w-0">
<!-- Collapse/Expand Button -->
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
<div class="relative flex items-center gap-2.5 min-w-0 shrink-1 mr-auto">
<div class="flex shrink-0 items-center px-0.5">
<Button
size="icon-sm"
@@ -41,17 +37,13 @@
/>
</Button>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--component] size-4" />
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
>
<div class="truncate min-w-0 flex-1">
<div class="truncate flex-1">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
@@ -63,56 +55,46 @@
</div>
</div>
<div class="flex shrink-0 items-center justify-between gap-2">
<NodeBadge
v-for="badge of nodeBadges"
:key="badge.text"
v-bind="badge"
/>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i-comfy:pin
v-if="isPinned"
class="size-5"
data-testid="node-pin-indicator"
/>
<Button
v-if="isSubgraphNode"
v-tooltip.top="enterSubgraphTooltipConfig"
variant="textonly"
size="sm"
data-testid="subgraph-enter-button"
class="text-node-component-header h-5 px-0.5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
<template v-for="badge in priceBadges ?? []" :key="badge.required">
<span
:class="
cn(
'flex h-5 bg-component-node-widget-background p-1 items-center text-xs shrink-0',
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<span>{{ $t('g.edit') }}</span>
<i class="icon-[lucide--scaling] size-5" />
</Button>
</div>
<i class="h-full icon-[lucide--component] bg-amber-400" />
<span class="truncate" v-text="badge.required" />
</span>
<span
v-if="badge.rest"
class="truncate -ml-2.5 grow-1 basis-0 bg-component-node-widget-background rounded-r-full max-w-max min-w-0"
>
<span class="pr-2" v-text="badge.rest" />
</span>
</template>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i
v-if="isPinned"
class="size-5 icon-[comfy--pin]"
data-testid="node-pin-indicator"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, toValue, watch } from 'vue'
import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -121,6 +103,7 @@ import type { NodeBadgeProps } from './NodeBadge.vue'
interface NodeHeaderProps {
nodeData?: VueNodeData
collapsed?: boolean
priceBadges?: { required: string; rest?: string }[]
}
const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
@@ -128,7 +111,6 @@ const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
'enter-subgraph': []
}>()
// Error boundary implementation
@@ -156,10 +138,6 @@ const tooltipConfig = computed(() => {
return createTooltipConfig(description)
})
const enterSubgraphTooltipConfig = computed(() => {
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
@@ -185,71 +163,7 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
: undefined
)
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const nodeBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
const headerShapeClass = computed(() => {
if (collapsed) {
@@ -272,22 +186,6 @@ const headerShapeClass = computed(() => {
}
})
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false
// Get the underlying LiteGraph node
const graph = app.rootGraph
if (!graph) return false
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
// Use the official type guard method
return litegraphNode?.isSubgraphNode() ?? false
})
// Watch for external changes to the node title or type
watch(
() => [nodeData?.title, nodeData?.type] as const,
@@ -320,8 +218,4 @@ const handleTitleEdit = (newTitle: string) => {
const handleTitleCancel = () => {
isEditing.value = false
}
const handleEnterSubgraph = () => {
emit('enter-subgraph')
}
</script>

View File

@@ -47,6 +47,7 @@
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
@@ -60,7 +61,12 @@
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
/>
</div>
@@ -95,6 +101,7 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -109,6 +116,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { bringNodeToFront } = useNodeZIndex()
const executionStore = useExecutionStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -146,21 +154,23 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
const widgetValueStore = useWidgetValueStore()
interface ProcessedWidget {
name: string
type: string
vueComponent: Component
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: WidgetValue) => void
tooltipConfig: TooltipOptions
slotMetadata?: WidgetSlotMetadata
hidden: boolean
advanced: boolean
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const nodeErrors = executionStore.lastNodeErrors?.[nodeData.id ?? '']
const nodeId = nodeData.id
const { widgets } = nodeData
@@ -220,6 +230,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const tooltipConfig = createTooltipConfig(tooltipText)
result.push({
advanced: widget.options?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
hidden: widget.options?.hidden ?? false,
name: widget.name,
type: widget.type,
vueComponent,
@@ -227,10 +244,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
value,
updateHandler,
tooltipConfig,
slotMetadata,
hidden: widget.options?.hidden ?? false,
advanced: widget.options?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false
slotMetadata
})
}

View File

@@ -0,0 +1,135 @@
import { trim } from 'es-toolkit'
import { computed, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
function splitAroundFirstSpace(text: string): [string, string | undefined] {
const index = text.indexOf(' ')
if (index === -1) return [text, undefined]
return [text.slice(0, index), text.slice(index + 1)]
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
const { isCreditsBadge } = usePriceBadge()
const settingStore = useSettingStore()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const unpartitionedBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const nodeDef = useNodeDefStore().nodeDefsByName[nodeData.type]
return computed(() => {
const displaySource = settingStore.get(
'Comfy.NodeBadge.NodeSourceBadgeMode'
)
const isCoreNode =
nodeDef?.isCoreNode && displaySource === NodeBadgeMode.ShowAll
const core: NodeBadgeProps[] = []
const extension: NodeBadgeProps[] = []
const pricing: { required: string; rest?: string }[] = []
if (
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') !==
NodeBadgeMode.None
) {
const lifecycleText = nodeDef?.nodeLifeCycleBadgeText ?? ''
const trimmed = trim(lifecycleText, ['[', ']'])
if (trimmed) core.push({ text: trimmed })
}
if (
settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') !== NodeBadgeMode.None
)
core.push({ text: `#${nodeData.id}` })
const sourceText = nodeDef?.nodeSource?.badgeText
if (
!nodeDef?.isCoreNode &&
displaySource !== NodeBadgeMode.None &&
sourceText
)
core.push({ text: sourceText })
for (const badge of unpartitionedBadges.value.slice(1)) {
if (!badge.text) continue
if (isCreditsBadge(badge)) {
const [required, rest] = splitAroundFirstSpace(badge.text)
pricing.push({ required, rest })
continue
}
extension.push(badge)
}
return {
hasComfyBadge: isCoreNode && pricing.length === 0,
core,
extension,
pricing
}
})
}

View File

@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export type RightSidePanelTab =
| 'error'
| 'parameters'
| 'nodes'
| 'settings'