mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
Move price badges to python nodes (#7816)
## Summary Backend part: https://github.com/Comfy-Org/ComfyUI/pull/11582 - Move API node pricing definitions from hardcoded frontend functions to backend-defined JSONata expressions - Add `price_badge` field to node definition schema containing JSONata expression and dependency declarations - Implement async JSONata evaluation with signature-based caching for efficient reactive updates - Show one decimal in credit badges when meaningful (e.g., 1.5 credits instead of 2 credits) ## Screenshots (if applicable) <!-- Add screenshots or video recording to help explain your changes --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7816-Move-price-badges-to-python-nodes-2da6d73d365081ec815ef61f7e3c65f7) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -169,6 +169,7 @@
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -186,6 +186,9 @@ catalogs:
|
||||
jsdom:
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0
|
||||
jsonata:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
knip:
|
||||
specifier: ^5.75.1
|
||||
version: 5.75.1
|
||||
@@ -449,6 +452,9 @@ importers:
|
||||
glob:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
@@ -6045,6 +6051,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonata@2.1.0:
|
||||
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -14403,6 +14413,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonata@2.1.0: {}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
@@ -62,6 +62,7 @@ catalog:
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsonata: ^2.1.0
|
||||
jsdom: ^27.4.0
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
|
||||
@@ -73,6 +73,14 @@ export const useNodeBadge = () => {
|
||||
onMounted(() => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
() => nodePricing.pricingRevision.value,
|
||||
() => {
|
||||
if (!showApiPricingBadge.value) return
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
extensionStore.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
@@ -111,17 +119,16 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
return priceBadge.getCreditsBadge(price)
|
||||
}
|
||||
!!pricingConfig &&
|
||||
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||
|
||||
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
|
||||
// (We no longer rely on it to hold the current badge value.)
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
@@ -133,13 +140,63 @@ export const useNodeBadge = () => {
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
// Ensure watchers are installed; ignore the returned value.
|
||||
// (This call is what registers the widget listeners in most implementations.)
|
||||
computedWithWidgetWatch(() => 0)
|
||||
|
||||
// Hook into connection changes to trigger price recalculation
|
||||
// This handles both connect and disconnect in VueNodes mode
|
||||
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
|
||||
const inputGroupPrefixes =
|
||||
pricingConfig?.depends_on?.input_groups ?? []
|
||||
const hasRelevantInputs =
|
||||
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
|
||||
|
||||
if (hasRelevantInputs) {
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
node.onConnectionsChange = function (
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
) {
|
||||
originalOnConnectionsChange?.call(
|
||||
this,
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
)
|
||||
// Only trigger if this input affects pricing
|
||||
const inputName = ioSlot?.name
|
||||
if (!inputName) return
|
||||
const isRelevantInput =
|
||||
relevantInputs.includes(inputName) ||
|
||||
inputGroupPrefixes.some((prefix) =>
|
||||
inputName.startsWith(prefix + '.')
|
||||
)
|
||||
if (isRelevantInput) {
|
||||
nodePricing.triggerPriceRecalculation(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
let lastLabel = nodePricing.getNodeDisplayPrice(node)
|
||||
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
|
||||
|
||||
const creditsBadgeGetter: () => LGraphBadge = () => {
|
||||
const label = nodePricing.getNodeDisplayPrice(node)
|
||||
if (label !== lastLabel) {
|
||||
lastLabel = label
|
||||
lastBadge = priceBadge.getCreditsBadge(label)
|
||||
}
|
||||
return lastBadge
|
||||
}
|
||||
|
||||
node.badges.push(creditsBadgeGetter)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,7 @@ import { computed, onErrorCaptured, ref, toValue, 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'
|
||||
@@ -183,9 +184,67 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
|
||||
: undefined
|
||||
)
|
||||
|
||||
const nodeBadges = computed<NodeBadgeProps[]>(() =>
|
||||
[...(nodeData?.badges ?? [])].map(toValue)
|
||||
// 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
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
if (relevantNames.length > 0) {
|
||||
nodeData?.widgets?.forEach((w) => {
|
||||
if (relevantNames.includes(w.name)) w.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))
|
||||
|
||||
|
||||
@@ -197,6 +197,50 @@ const zComfyOutputTypesSpec = z.array(
|
||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
||||
)
|
||||
|
||||
/**
|
||||
* Widget dependency with type information.
|
||||
* Provides strong type enforcement for JSONata evaluation context.
|
||||
*/
|
||||
const zWidgetDependency = z.object({
|
||||
name: z.string(),
|
||||
type: z.string()
|
||||
})
|
||||
|
||||
export type WidgetDependency = z.infer<typeof zWidgetDependency>
|
||||
|
||||
/**
|
||||
* Schema for price badge depends_on field.
|
||||
* Specifies which widgets and inputs the pricing expression depends on.
|
||||
* Widgets must be specified as objects with name and type.
|
||||
*/
|
||||
const zPriceBadgeDepends = z.object({
|
||||
widgets: z.array(zWidgetDependency).optional().default([]),
|
||||
inputs: z.array(z.string()).optional().default([]),
|
||||
/**
|
||||
* Autogrow input group names to track.
|
||||
* For each group, the count of connected inputs will be available in the
|
||||
* JSONata context as `g.<groupName>`.
|
||||
* Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
|
||||
* available with the count of connected inputs like `reference_videos.character1`, etc.
|
||||
*/
|
||||
input_groups: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for price badge definition.
|
||||
* Used to calculate and display pricing information for API nodes.
|
||||
* The `expr` field contains a JSONata expression that returns a PricingResult.
|
||||
*/
|
||||
const zPriceBadge = z.object({
|
||||
engine: z.literal('jsonata').optional().default('jsonata'),
|
||||
depends_on: zPriceBadgeDepends
|
||||
.optional()
|
||||
.default({ widgets: [], inputs: [], input_groups: [] }),
|
||||
expr: z.string()
|
||||
})
|
||||
|
||||
export type PriceBadge = z.infer<typeof zPriceBadge>
|
||||
|
||||
export const zComfyNodeDef = z.object({
|
||||
input: zComfyInputsSpec.optional(),
|
||||
output: zComfyOutputTypesSpec.optional(),
|
||||
@@ -224,7 +268,13 @@ export const zComfyNodeDef = z.object({
|
||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
||||
*/
|
||||
input_order: z.record(z.array(z.string())).optional()
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
price_badge: zPriceBadge.optional()
|
||||
})
|
||||
|
||||
export const zAutogrowOptions = z.object({
|
||||
|
||||
@@ -14,7 +14,8 @@ import type {
|
||||
import type {
|
||||
ComfyInputsSpec as ComfyInputSpecV1,
|
||||
ComfyNodeDef as ComfyNodeDefV1,
|
||||
ComfyOutputTypesSpec as ComfyOutputSpecV1
|
||||
ComfyOutputTypesSpec as ComfyOutputSpecV1,
|
||||
PriceBadge
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -66,6 +67,12 @@ export class ComfyNodeDefImpl
|
||||
* Order of inputs for each category (required, optional, hidden)
|
||||
*/
|
||||
readonly input_order?: Record<string, string[]>
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
readonly price_badge?: PriceBadge
|
||||
|
||||
// V2 fields
|
||||
readonly inputs: Record<string, InputSpecV2>
|
||||
@@ -134,6 +141,7 @@ export class ComfyNodeDefImpl
|
||||
this.output_name = obj.output_name
|
||||
this.output_tooltips = obj.output_tooltips
|
||||
this.input_order = obj.input_order
|
||||
this.price_badge = obj.price_badge
|
||||
|
||||
// Initialize V2 fields
|
||||
const defV2 = transformNodeDefV1ToV2(obj)
|
||||
|
||||
Reference in New Issue
Block a user