+
@@ -120,42 +119,75 @@
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
-
-
-
-
-
+
-
+
+ >
+
+
@@ -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 &&
diff --git a/src/renderer/extensions/vueNodes/components/NodeBadges.vue b/src/renderer/extensions/vueNodes/components/NodeBadges.vue
new file mode 100644
index 00000000000..ac73d78dffc
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/NodeBadges.vue
@@ -0,0 +1,55 @@
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue
index 4f9bd75602f..b1600efe32c 100644
--- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue
+++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue
@@ -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"
>
-
+
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
index c18db793719..41ad4e93e8d 100644
--- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
+++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
@@ -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"
/>
@@ -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
()
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 {
+ advanced: boolean
+ hasLayoutSize: boolean
+ hasError: boolean
+ hidden: boolean
name: string
- type: string
- vueComponent: Component
simplified: SimplifiedWidget
- value: WidgetValue
- updateHandler: (value: WidgetValue) => void
tooltipConfig: TooltipOptions
+ type: string
+ updateHandler: (value: WidgetValue) => void
+ value: WidgetValue
+ vueComponent: Component
slotMetadata?: WidgetSlotMetadata
- hidden: boolean
- advanced: boolean
- hasLayoutSize: boolean
}
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
})
}
diff --git a/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts
new file mode 100644
index 00000000000..eda1171f144
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts
@@ -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(() => {
+ // 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
+ }
+ })
+}
diff --git a/src/stores/workspace/rightSidePanelStore.ts b/src/stores/workspace/rightSidePanelStore.ts
index ad4a26ae8fd..75e7b16bbfe 100644
--- a/src/stores/workspace/rightSidePanelStore.ts
+++ b/src/stores/workspace/rightSidePanelStore.ts
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export type RightSidePanelTab =
+ | 'error'
| 'parameters'
| 'nodes'
| 'settings'