mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
fix(subgraph): reactive price badge for ADR 0009 promoted widgets
Under ADR 0009 host-wins, PromotedWidgetView no longer mutates the
interior widget on edit. The subgraph wrapper's credits badge was still
computed against the inner LGraphNode's raw widget value and so stayed
frozen at the conversion-time price when the user changed a promoted
combo on the wrapper.
- getNodeDisplayPrice / buildJsonataContext accept an optional
widgetOverrides map so callers can supply effective widget values
without mutating the inner widget.
- For a SubgraphNode wrapping a single api node, updateSubgraphCredits
now pushes a wrapper-aware badge getter that builds overrides from
the wrapper's PromotedWidgetView host values and calls
getNodeDisplayPrice with them. The legacy static / multi-node
branches are preserved.
- usePartitionedBadges touches each PromotedWidgetView's host value and
the inner api node's pricing inputs inside the wrapper's badge
computed so a promoted edit invalidates the wrapper's badge.
priceBadge.spec.ts tag converted to the typed `{ tag: [...] }` form.
Amp-Thread-ID: https://ampcode.com/threads/T-019e5248-7986-77ae-a78b-41cd08c5af38
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -237,12 +237,15 @@ const normalizeWidgetValue = (
|
||||
|
||||
const buildJsonataContext = (
|
||||
node: LGraphNode,
|
||||
rule: JsonataPricingRule
|
||||
rule: JsonataPricingRule,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): JsonataEvalContext => {
|
||||
const widgets: Record<string, NormalizedWidgetValue> = {}
|
||||
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<string, { connected: boolean }> = {}
|
||||
@@ -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, unknown>
|
||||
): 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)
|
||||
|
||||
@@ -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<string> = 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<string> = 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<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
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<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
|
||||
@@ -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<string>()
|
||||
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<NodeBadgeProps[]>(() => {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user