diff --git a/browser_tests/tests/priceBadge.spec.ts b/browser_tests/tests/priceBadge.spec.ts index 2ba37cf1fe..70a3447f95 100644 --- a/browser_tests/tests/priceBadge.spec.ts +++ b/browser_tests/tests/priceBadge.spec.ts @@ -3,36 +3,40 @@ import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage' -test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => { - const apiNodeName = 'Node With Price Badge' +test( + 'Price badge displays on subgraphs', + { tag: ['@vue-nodes'] }, + async ({ comfyPage }) => { + const apiNodeName = 'Node With Price Badge' - const priceBadge = comfyPage.page.locator('.lg-node-header i + span') - const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName) + const priceBadge = comfyPage.page.locator('.lg-node-header i + span') + const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName) - await comfyPage.menu.topbar.newWorkflowButton.click() - await comfyPage.nextFrame() + await comfyPage.menu.topbar.newWorkflowButton.click() + await comfyPage.nextFrame() - await comfyPage.searchBoxV2.addNode(apiNodeName) - await expect(apiNode, 'Add partner node').toBeVisible() - await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible() + await comfyPage.searchBoxV2.addNode(apiNodeName) + await expect(apiNode, 'Add partner node').toBeVisible() + await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible() - await comfyPage.contextMenu - .openForVueNode(apiNode) - .then((m) => m.clickMenuItemExact('Convert to Subgraph')) - const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') - await expect(subgraphNode, 'Convert to Subgraph').toBeVisible() + await comfyPage.contextMenu + .openForVueNode(apiNode) + .then((m) => m.clickMenuItemExact('Convert to Subgraph')) + const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph') + await expect(subgraphNode, 'Convert to Subgraph').toBeVisible() - const nodePrice = subgraphNode.locator(priceBadge) - await expect(nodePrice, 'subgraphNode has price badge').toBeVisible() - const initialPrice = Number(await nodePrice.innerText()) + const nodePrice = subgraphNode.locator(priceBadge) + await expect(nodePrice, 'subgraphNode has price badge').toBeVisible() + const initialPrice = Number(await nodePrice.innerText()) - await comfyPage.subgraph.editor.togglePromotion(subgraphNode, { - nodeName: apiNodeName, - widgetName: 'price', - toState: true - }) - await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x') - await expect(nodePrice, 'Price is reactive').toHaveText( - String(initialPrice * 2) - ) -}) + await comfyPage.subgraph.editor.togglePromotion(subgraphNode, { + nodeName: apiNodeName, + widgetName: 'price', + toState: true + }) + await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x') + await expect(nodePrice, 'Price is reactive').toHaveText( + String(initialPrice * 2) + ) + } +) diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 5d5ed7668e..f593696191 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -237,12 +237,15 @@ const normalizeWidgetValue = ( const buildJsonataContext = ( node: LGraphNode, - rule: JsonataPricingRule + rule: JsonataPricingRule, + widgetOverrides?: ReadonlyMap ): JsonataEvalContext => { const widgets: Record = {} for (const dep of rule.depends_on.widgets) { - const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name) - widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type) + const raw = widgetOverrides?.has(dep.name) + ? widgetOverrides.get(dep.name) + : node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value + widgets[dep.name] = normalizeWidgetValue(raw, dep.type) } const inputs: Record = {} @@ -552,7 +555,10 @@ export const useNodePricing = () => { * - schedules async evaluation when needed * - remains non-fatal on errors (returns safe fallback '') */ - const getNodeDisplayPrice = (node: LGraphNode): string => { + const getNodeDisplayPrice = ( + node: LGraphNode, + widgetOverrides?: ReadonlyMap + ): string => { // Make this function reactive: when async evaluation completes, we bump pricingTick, // which causes this getter to recompute in Vue render/computed contexts. void pricingTick.value @@ -565,7 +571,7 @@ export const useNodePricing = () => { if (rule.engine !== 'jsonata') return '' if (!rule._compiled) return '' - const ctx = buildJsonataContext(node, rule) + const ctx = buildJsonataContext(node, rule, widgetOverrides) const sig = buildSignature(ctx, rule) const cached = cache.get(node) diff --git a/src/composables/node/usePriceBadge.ts b/src/composables/node/usePriceBadge.ts index 5febb21b16..3d9130b6a6 100644 --- a/src/composables/node/usePriceBadge.ts +++ b/src/composables/node/usePriceBadge.ts @@ -1,6 +1,8 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import { LGraphBadge } from '@/lib/litegraph/src/litegraph' +import { useNodePricing } from '@/composables/node/useNodePricing' +import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { adjustColor } from '@/utils/colorUtil' @@ -9,14 +11,25 @@ componentIconSvg.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E" export const usePriceBadge = () => { + const nodePricing = useNodePricing() + function updateSubgraphCredits(node: LGraphNode) { if (!node.isSubgraphNode()) return node.badges = node.badges.filter((b) => !isCreditsBadge(b)) - const newBadges = collectCreditsBadges(node.subgraph) - if (newBadges.length > 1) { - node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length)) - } else { - node.badges.push(...newBadges) + const innerCreditsBadges = collectCreditsBadges(node.subgraph) + if (innerCreditsBadges.length > 1) { + node.badges.push( + getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length) + ) + } else if (innerCreditsBadges.length === 1) { + const innerApiNodes = collectInnerApiNodes(node.subgraph) + // When a single inner api node is the price source, swap its static + // getter for a wrapper-aware one that resolves promoted widget values. + if (innerApiNodes.length === 1) { + node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0])) + } else { + node.badges.push(...innerCreditsBadges) + } } const graph = node.graph if (!graph) return @@ -28,13 +41,14 @@ export const usePriceBadge = () => { newValue: node.badges }) } + function collectCreditsBadges( graph: LGraph, visited: Set = new Set() ): (LGraphBadge | (() => LGraphBadge))[] { if (visited.has(graph.id)) return [] visited.add(graph.id) - const badges = [] + const badges: (LGraphBadge | (() => LGraphBadge))[] = [] for (const node of graph.nodes) { badges.push( ...(node.isSubgraphNode() @@ -45,6 +59,57 @@ export const usePriceBadge = () => { return badges } + function collectInnerApiNodes( + graph: LGraph, + visited: Set = new Set() + ): LGraphNode[] { + if (visited.has(graph.id)) return [] + visited.add(graph.id) + const apiNodes: LGraphNode[] = [] + for (const node of graph.nodes) { + if (node.isSubgraphNode()) { + apiNodes.push(...collectInnerApiNodes(node.subgraph, visited)) + } else if (node.constructor?.nodeData?.api_node) { + apiNodes.push(node) + } + } + return apiNodes + } + + /** + * Wrapper-aware price badge getter: resolves the inner node's price using + * the host-effective values of any promoted widgets on the wrapper, so the + * badge updates when a user edits the promoted control without leaking + * into the interior widget state (ADR 0009 host-wins). + */ + function buildWrapperAwarePriceBadge( + wrapper: LGraphNode, + innerNode: LGraphNode + ): () => LGraphBadge { + return () => + getCreditsBadge( + nodePricing.getNodeDisplayPrice( + innerNode, + collectPromotedOverrides(wrapper, innerNode) + ) + ) + } + + function collectPromotedOverrides( + wrapper: LGraphNode, + innerNode: LGraphNode + ): ReadonlyMap { + const overrides = new Map() + if (!wrapper.isSubgraphNode()) return overrides + const innerId = String(innerNode.id) + for (const w of wrapper.widgets ?? []) { + if (!isPromotedWidgetView(w)) continue + if (w.sourceNodeId !== innerId) continue + overrides.set(w.sourceWidgetName, w.value) + } + return overrides + } + function isCreditsBadge( badge: Partial | (() => Partial) ): boolean { diff --git a/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts index 81621da66f..1e2bb9054a 100644 --- a/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts +++ b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts @@ -4,7 +4,7 @@ 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 type { NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' import { useSettingStore } from '@/platform/settings/settingStore' import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue' @@ -69,6 +69,39 @@ export function trackNodePrice(node: TrackableNode) { } } +/** + * Register reactive deps on every contained api node's pricing inputs so the + * SubgraphNode wrapper's badge computed re-runs when an inner (e.g. promoted) + * widget value changes. Also tracks the wrapper's own promoted widget host + * values so user edits on the wrapper trigger re-evaluation (ADR 0009 + * host-wins: promoted writes stay on the host and never leak interior). + */ +function trackSubgraphInnerNodePrices(wrapper: LGraphNode) { + if (!wrapper.isSubgraphNode()) return + // Touch each promoted widget's host value to register reactive deps. + for (const w of wrapper.widgets ?? []) void w.value + + const visited = new Set() + function walk(nodes: LGraphNode[]) { + for (const inner of nodes) { + if (inner.isSubgraphNode()) { + const id = String(inner.subgraph.id) + if (visited.has(id)) continue + visited.add(id) + walk(inner.subgraph.nodes) + continue + } + if (!inner.constructor?.nodeData?.api_node) continue + trackNodePrice({ + id: inner.id, + type: inner.type ?? '', + inputs: inner.inputs + }) + } + } + walk(wrapper.subgraph.nodes) +} + export function usePartitionedBadges(nodeData: VueNodeData) { // Use per-node pricing revision to re-compute badges only when this node's pricing updates const { @@ -96,6 +129,10 @@ export function usePartitionedBadges(nodeData: VueNodeData) { nodeData?.apiNode ? getInputNames(nodeData.type) : [] ) const unpartitionedBadges = computed(() => { + if (nodeData?.id != null) { + const wrapper = app.canvas?.graph?.getNodeById(nodeData.id) + if (wrapper?.isSubgraphNode()) trackSubgraphInnerNodePrices(wrapper) + } // 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) {