Compare commits

...

4 Commits

Author SHA1 Message Date
DrJKL
96657158b7 test: isolate widget state fixtures
Amp-Thread-ID: https://ampcode.com/threads/T-019e8ab9-4c71-766f-8325-bcf5b756be3d
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 17:33:10 -07:00
DrJKL
4307d4cdb7 fix: use canonical widget ids in blockers
Amp-Thread-ID: https://ampcode.com/threads/T-019e8ab9-4c71-766f-8325-bcf5b756be3d
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 17:26:09 -07:00
DrJKL
e7f4578a4a fix: migrate legacy widget ids on load
Keep prefixed widget entity ids canonical while preserving app-mode inputs saved with the previous unprefixed format.

Amp-Thread-ID: https://ampcode.com/threads/T-019e85d1-2aac-713a-8dbc-3719d39c9e6b
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 16:24:12 -07:00
DrJKL
c1afcead2c refactor(world): make WidgetEntityId branded type the canonical key
Convert widget state access from loose `(graphId, nodeId, name)` triples
to the branded `WidgetEntityId`, and propagate the change through every
producer and consumer of widget identity.

`src/world/entityIds.ts`
- Add `GraphId` and `NodeEntityId` branded types plus `asGraphId`,
  `nodeEntityId`, `graphNodePrefix`, `isNodeIdForGraph`, and
  `isWidgetIdForGraph` helpers — same on-the-wire format owners,
  guarded construction.
- Add `deriveWidgetEntityId(graphId, nodeId, name)` as the single
  guard for raw widget identity. Returns `undefined` for graphless
  widgets, unbound `nodeId === -1`, or missing inputs.
- Change `widgetEntityId` to emit the `widget:` prefix
  (`widget:${graphId}:${nodeId}:${name}`) and rewrite
  `parseWidgetEntityId` / `isWidgetEntityId` with a regex that throws
  on malformed input so upstream cast bugs surface at the parse site.

`src/stores/widgetValueStore.ts` — entity-id-native API:
- `registerWidget(widgetId, state)`           — was `(graphId, registration)`
- `getWidget(widgetId)`                       — was `_lookupWidgetState(g,n,n)`
- `setValue(widgetId, value)`                 — was `(g,n,n,v)`
- `getNodeWidgets(nodeId)` / `getNodeWidgetsByName(nodeId)` — was `(g,n)`
- `clearGraph(graphId)` unchanged.
- `WidgetState` is a flat interface (no identity fields — they live in
  the entity id). `WidgetRegistration` extends it with `name`/`nodeId`
  for `registerWidget` inputs only.

`src/lib/litegraph/src/types/widgets.ts`,
`src/lib/litegraph/src/widgets/BaseWidget.ts` — `IBaseWidget` extends
the `WidgetState` shape directly, eliminating the prior cross-layer
`Pick<IBaseWidget, …>` dependency. `BaseWidget.entityId` and
`getWidgetEntityIdForNode` delegate to `deriveWidgetEntityId`.

`src/world/widgets/widgetState.ts` (new, 19 lines) — leaf module
defining the canonical `WidgetState` and `WidgetRegistration` shapes
so non-litegraph code can reference them without crossing layers.

Consumers migrated to either `store.getWidget(widget.entityId)` (when
a `BaseWidget` is in scope) or `deriveWidgetEntityId(g,n,name)` then
`getWidget(eid)`:
- core: `promotedWidgetView`, `promotionUtils`, `dynamicWidgets`,
  `customWidgets`, `uploadAudio`, `WidgetItem.vue`
- renderer: `useGLSLPreview`, `useGLSLUniforms`, `usePartitionedBadges`,
  `useProcessedWidgets`, `useMarkdownWidget`, `useProgressTextWidget`,
  `useStringWidget`, `NodeWidgets`
- composables: `useUpstreamValue`
- litegraph: `SubgraphNode`, `LGraph` test fixtures, BaseWidget tests,
  SubgraphWidgetPromotion tests
- store tests: `widgetValueStore`, `useGraphNodeManager`,
  `useResolvedSelectedInputs`, `useImageCrop`, `appModeStore`
- e2e: `nodeDisplay.spec.ts` converted to vue-nodes

`src/world/widgetValueIO.{ts,test.ts}` — deleted. The IO module's four
functions all parsed a `WidgetEntityId` into `(graphId, nodeId, name)`
only to call store methods that immediately reconstructed the same
string. The round-trip ran on every widget read and write. Removed
once the store became entity-id-native.

Amp-Thread-ID: https://ampcode.com/threads/T-019e8580-a262-71ac-9361-7d80aa73f5bb
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 16:24:12 -07:00
39 changed files with 1149 additions and 739 deletions

View File

@@ -77,14 +77,42 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
)
await expect(comfyPage.canvas).toHaveScreenshot(
'missing_nodes_converted_widget.png'
)
})
test(
'unknown converted widget',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
)
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
// Title reflects the unknown node type
await expect(node).toContainText('UNKNOWN NODE')
// Inputs include the regular IMAGE input and the converted-widget "foo"
// (which is what differentiates this fixture from a plain missing node).
const nodeRef = await comfyPage.nodeOps.getNodeRefById('1')
const inputs = (await nodeRef.getProperty('inputs')) as {
name: string
type: string
widget?: { name: string }
}[]
expect(inputs.map((i) => i.name)).toEqual(['image', 'foo'])
const fooInput = inputs.find((i) => i.name === 'foo')
expect(fooInput?.type).toBe('STRING')
expect(fooInput?.widget?.name).toBe('foo')
// Per-node DOM screenshot localizes any visual regression to this node.
// A canvas-wide screenshot turns a 1-row label drift into a "763 px diff"
// with no signal about which widget is responsible.
await expect(node).toHaveScreenshot(
'missing_nodes_converted_widget_node.png'
)
}
)
test('dynamically added input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')

View File

@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -22,14 +22,14 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySeed = widgetEntityId(rootGraphId, 1, 'seed')
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
entityId: widgetEntityId(rootGraphId, id, name)
}))
})
}

View File

@@ -16,10 +16,7 @@ import {
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -58,8 +55,8 @@ const { t } = useI18n()
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const widgetValueStore = useWidgetValueStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -74,10 +71,8 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
const widgetState = sourceWidget.entityId
? widgetValueStore.getWidget(sourceWidget.entityId)
: undefined
return {

View File

@@ -7,7 +7,7 @@ import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -47,9 +47,10 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const testnumId = widgetEntityId(asGraphId(graph.id), node.id, 'testnum')
expect(store.getWidget(testnumId)?.value).toBe(2)
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(testnumId)
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -74,7 +75,9 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(
widgetEntityId(asGraphId(graph.id), node.id, 'testnum')
)
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)

View File

@@ -66,7 +66,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getNodeWidgets: vi.fn(() => [])
getNodeWidgets: vi.fn(() => []),
getNodeWidgetsByName: vi.fn(() => new Map())
})
}))

View File

@@ -1,12 +1,42 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/utils/uuid'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
import {
boundsExtractor,
singleValueExtractor,
useUpstreamValue
} from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: { rootGraph: { id: '00000000-0000-0000-0000-000000000001' } }
}
})
}))
function widgetState(value: unknown): WidgetState {
return {
type: 'INPUT',
value,
options: {},
label: undefined,
serialize: undefined,
disabled: undefined
}
}
function widgetMap(
...entries: Array<[string, unknown]>
): Map<string, WidgetState> {
return new Map(entries.map(([name, value]) => [name, widgetState(value)]))
}
const isNumber = (v: unknown): v is number => typeof v === 'number'
@@ -15,37 +45,37 @@ describe('singleValueExtractor', () => {
const extract = singleValueExtractor(isNumber)
it('matches widget by outputName', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
const widgets = widgetMap(['a', 'text'], ['b', 42])
expect(extract(widgets, 'b')).toBe(42)
})
it('returns undefined when outputName widget has invalid value', () => {
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
const widgets = widgetMap(['a', 'text'], ['b', 'not a number'])
expect(extract(widgets, 'b')).toBeUndefined()
})
it('falls back to unique valid widget when outputName has no match', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
const widgets = widgetMap(['a', 'text'], ['b', 42])
expect(extract(widgets, 'missing')).toBe(42)
})
it('falls back to unique valid widget when no outputName provided', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
const widgets = widgetMap(['a', 'text'], ['b', 42])
expect(extract(widgets, undefined)).toBe(42)
})
it('returns undefined when multiple widgets have valid values', () => {
const widgets = [widget('a', 1), widget('b', 2)]
const widgets = widgetMap(['a', 1], ['b', 2])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when no widgets have valid values', () => {
const widgets = [widget('a', 'text')]
const widgets = widgetMap(['a', 'text'])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
expect(extract(new Map(), undefined)).toBeUndefined()
})
})
@@ -54,23 +84,23 @@ describe('boundsExtractor', () => {
it('extracts a single bounds object widget', () => {
const bounds = { x: 10, y: 20, width: 100, height: 200 }
const widgets = [widget('crop', bounds)]
const widgets = widgetMap(['crop', bounds])
expect(extract(widgets, undefined)).toEqual(bounds)
})
it('matches bounds widget by outputName', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [widget('other', 'text'), widget('crop', bounds)]
const widgets = widgetMap(['other', 'text'], ['crop', bounds])
expect(extract(widgets, 'crop')).toEqual(bounds)
})
it('assembles bounds from individual x/y/width/height widgets', () => {
const widgets = [
widget('x', 10),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
const widgets = widgetMap(
['x', 10],
['y', 20],
['width', 100],
['height', 200]
)
expect(extract(widgets, undefined)).toEqual({
x: 10,
y: 20,
@@ -80,39 +110,76 @@ describe('boundsExtractor', () => {
})
it('returns undefined when some bound components are missing', () => {
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
const widgets = widgetMap(['x', 10], ['y', 20], ['width', 100])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when bound components have wrong types', () => {
const widgets = [
widget('x', '10'),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
const widgets = widgetMap(
['x', '10'],
['y', 20],
['width', 100],
['height', 200]
)
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
expect(extract(new Map(), undefined)).toBeUndefined()
})
it('rejects partial bounds objects', () => {
const partial = { x: 10, y: 20 }
const widgets = [widget('crop', partial)]
const widgets = widgetMap(['crop', partial])
expect(extract(widgets, undefined)).toBeUndefined()
})
it('prefers single bounds object over individual widgets', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [
widget('crop', bounds),
widget('x', 99),
widget('y', 99),
widget('width', 99),
widget('height', 99)
]
const widgets = widgetMap(
['crop', bounds],
['x', 99],
['y', 99],
['width', 99],
['height', 99]
)
expect(extract(widgets, undefined)).toEqual(bounds)
})
})
describe('useUpstreamValue (store-backed read path)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('reads upstream node widgets via the widget value store', () => {
const graphId = '00000000-0000-0000-0000-000000000001' as UUID
const widgetId = widgetEntityId(
asGraphId(graphId),
'upstream-1' as NodeId,
'value'
)
const state = useWidgetValueStore().registerWidget(widgetId, {
type: 'number',
value: 7,
options: {}
})
const upstreamValue = useUpstreamValue<number>(
() => ({ nodeId: 'upstream-1', outputName: 'value' }),
singleValueExtractor((v): v is number => typeof v === 'number')
)
expect(upstreamValue.value).toBe(7)
state.value = 11
expect(upstreamValue.value).toBe(11)
})
it('returns undefined when no upstream linkage is provided', () => {
const upstreamValue = useUpstreamValue(
() => undefined,
singleValueExtractor((v): v is number => typeof v === 'number')
)
expect(upstreamValue.value).toBeUndefined()
})
})

View File

@@ -1,13 +1,14 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
import { asGraphId, nodeEntityId } from '@/world/entityIds'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
widgets: Map<string, WidgetState>,
outputName: string | undefined
) => T | undefined
@@ -23,7 +24,9 @@ export function useUpstreamValue<T>(
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
const widgets = widgetValueStore.getNodeWidgetsByName(
nodeEntityId(asGraphId(graphId), upstream.nodeId)
)
return extractValue(widgets, upstream.outputName)
})
}
@@ -33,10 +36,12 @@ export function singleValueExtractor<T>(
): ValueExtractor<T> {
return (widgets, outputName) => {
if (outputName) {
const matched = widgets.find((w) => w.name === outputName)
const matched = widgets.get(outputName)
if (matched && isValid(matched.value)) return matched.value
}
const validValues = widgets.map((w) => w.value).filter(isValid)
const validValues = [...widgets.values()]
.map((w) => w.value)
.filter(isValid)
return validValues.length === 1 ? validValues[0] : undefined
}
}
@@ -60,7 +65,7 @@ export function boundsExtractor(): ValueExtractor<Bounds> {
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
const getNum = (name: string): number | undefined => {
const w = widgets.find((w) => w.name === name)
const w = widgets.get(name)
return typeof w?.value === 'number' ? w.value : undefined
}
const x = getNum('x')

View File

@@ -9,6 +9,7 @@ import {
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import { isPromotedWidgetView } from './promotedWidgetTypes'
@@ -73,9 +74,12 @@ describe('PromotedWidgetView — host-wins semantics', () => {
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
const interiorWidgetId = widgetEntityId(
asGraphId(subgraph.rootGraph.id),
String(interior.id),
'widget'
)
widgetStore.registerWidget(interiorWidgetId, {
type: 'number',
value: 42,
options: {},
@@ -90,11 +94,7 @@ describe('PromotedWidgetView — host-wins semantics', () => {
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
const interiorState = widgetStore.getWidget(interiorWidgetId)
expect(interiorState?.value).toBe(42)
})
})

View File

@@ -1,4 +1,4 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -11,11 +11,8 @@ import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
@@ -24,7 +21,6 @@ import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -162,7 +158,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
return useWidgetValueStore().getWidget(this.entityId)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
@@ -207,8 +203,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const widgetValueStore = useWidgetValueStore()
const existing = widgetValueStore.getWidget(this.entityId)
if (existing) return
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
widgetValueStore.registerWidget(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
@@ -418,19 +418,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (!resolved?.widget.entityId) return undefined
return useWidgetValueStore().getWidget(resolved.widget.entityId)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
private getLinkedInputWidgets(): IBaseWidget[] {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
@@ -457,23 +449,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
return linkedInput.getConnectedWidgets().filter(hasWidgetNode)
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
const widgetValueStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.map((widget) => widget.entityId)
.filter((id): id is WidgetEntityId => id !== undefined)
.map((id) => widgetValueStore.getWidget(id))
.filter((state): state is WidgetState => state !== undefined)
}

View File

@@ -18,7 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -121,7 +121,7 @@ function getExplicitHostWidgetValue(
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
const value = readWidgetValue(widget.entityId)
const value = useWidgetValueStore().getWidget(widget.entityId)?.value
return isWidgetValue(value) ? value : undefined
}

View File

@@ -11,7 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -187,11 +187,9 @@ function dynamicComboWidget(
//A little hacky, but onConfigure won't work.
//It fires too late and is overly disruptive
let widgetValue = widget.value
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
}
const widgetValueStore = useWidgetValueStore()
const getState = () =>
widget.entityId ? widgetValueStore.getWidget(widget.entityId) : undefined
Object.defineProperty(widget, 'value', {
get() {
return getState()?.value ?? widgetValue

View File

@@ -47,21 +47,20 @@ function onCustomComboCreated(this: LGraphNode) {
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
const widgetValueStore = useWidgetValueStore()
Object.defineProperty(widget, 'value', {
get() {
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
const state = widget.entityId
? widgetValueStore.getWidget(widget.entityId)
: undefined
return state?.value ?? localValue
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)
const state = widget.entityId
? widgetValueStore.getWidget(widget.entityId)
: undefined
if (state) state.value = v
updateCombo()
if (!node.widgets) return

View File

@@ -24,6 +24,7 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import { deriveWidgetEntityId } from '@/world/entityIds'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function updateUIWidget(
@@ -150,19 +151,27 @@ app.registerExtension({
}
}
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(
const widgetValueStore = useWidgetValueStore()
audioUIWidget.options.getValue = () => {
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)?.value as string) ?? ''
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return (widgetState?.value as string) ?? ''
}
audioUIWidget.options.setValue = (v) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState) widgetState.value = v
}

View File

@@ -16,6 +16,7 @@ import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphData,
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
name: 'seed',
const seedId = widgetEntityId(asGraphId(graphId), '10' as NodeId, 'seed')
widgetValueStore.registerWidget(seedId, {
type: 'number',
value: 1,
options: {},
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect(widgetValueStore.getWidget(seedId)).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
graph.clear()
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(widgetValueStore.getWidget(seedId)).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)

View File

@@ -45,7 +45,7 @@ import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuara
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { readWidgetValue } from '@/world/widgetValueIO'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -1088,10 +1088,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
}
const widgetValueStore = useWidgetValueStore()
const widgetValues = this.inputs.flatMap((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return []
const value = readWidgetValue(widget.entityId)
const value = widgetValueStore.getWidget(widget.entityId)?.value
return [isWidgetValue(value) ? value : undefined]
})

View File

@@ -24,6 +24,7 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
import {
createEventCapture,
@@ -588,13 +589,18 @@ describe('SubgraphWidgetPromotion', () => {
const hostNode = createTestSubgraphNode(subgraph)
const hostWidget = hostNode.widgets[0]
expectPromotedWidgetView(hostWidget)
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
nodeId: hostNode.id,
name: hostWidget.name,
type: hostWidget.type,
value: 99,
options: {}
})
useWidgetValueStore().registerWidget(
widgetEntityId(
asGraphId(hostNode.rootGraph.id),
hostNode.id,
hostWidget.name
),
{
type: hostWidget.type,
value: 99,
options: {}
}
)
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
@@ -944,8 +950,10 @@ describe('SubgraphWidgetPromotion', () => {
if (c.expect.storeSeedValue !== undefined) {
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name === 'seed')?.value
.getNodeWidgetsByName(
nodeEntityId(asGraphId(host.rootGraph.id), host.id)
)
.get('seed')?.value
).toBe(c.expect.storeSeedValue)
}
})
@@ -1006,13 +1014,14 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
for (const { node, widget } of sources) {
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: node.id,
name: widget.name,
type: widget.type,
value: `${node.title} value`,
options: {}
})
widgetStore.registerWidget(
widgetEntityId(asGraphId(host.rootGraph.id), node.id, widget.name),
{
type: widget.type,
value: `${node.title} value`,
options: {}
}
)
}
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toBeUndefined()
@@ -1032,13 +1041,18 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph, { id: 101 })
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: interiorNode.id,
name: interiorWidget.name,
type: interiorWidget.type,
value: 'source fallback',
options: {}
})
widgetStore.registerWidget(
widgetEntityId(
asGraphId(host.rootGraph.id),
interiorNode.id,
interiorWidget.name
),
{
type: interiorWidget.type,
value: 'source fallback',
options: {}
}
)
const serialized = host.serialize()
expect(serialized.widgets_values).toBeUndefined()
@@ -1047,7 +1061,9 @@ describe('SubgraphWidgetPromotion', () => {
reloaded.configure(serialized)
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
widgetStore.getNodeWidgets(
nodeEntityId(asGraphId(reloaded.rootGraph.id), reloaded.id)
)
).toEqual([])
expect(reloaded.serialize().widgets_values).toBeUndefined()
})
@@ -1073,14 +1089,27 @@ describe('SubgraphWidgetPromotion', () => {
expectPromotedWidgetView(first)
expectPromotedWidgetView(second)
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
widgetStore.getWidget(
widgetEntityId(
asGraphId(reloaded.rootGraph.id),
reloaded.id,
first.name
)
)
).toBeUndefined()
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
?.value
widgetStore.getWidget(
widgetEntityId(
asGraphId(reloaded.rootGraph.id),
reloaded.id,
second.name
)
)?.value
).toBe('second host value')
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
widgetStore.getNodeWidgets(
nodeEntityId(asGraphId(reloaded.rootGraph.id), reloaded.id)
)
).toHaveLength(1)
expect(reloaded.serialize().widgets_values).toEqual([
undefined,

View File

@@ -1,6 +1,7 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetState } from '@/world/widgets/widgetState'
import type {
CanvasColour,
@@ -394,7 +395,7 @@ export interface IBaseWidget<
TValue = boolean | number | string | object | undefined,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> {
> extends WidgetState<TValue, TType, TOptions> {
[symbol: symbol]: boolean
linkedWidgets?: IBaseWidget[]
@@ -402,25 +403,9 @@ export interface IBaseWidget<
readonly entityId?: WidgetEntityId
name: string
options: TOptions
label?: string
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
/**
* Whether the widget value is persisted in the workflow JSON
* (`widgets_values`). Checked by {@link LGraphNode.serialize} and
* {@link LGraphNode.configure}.
*
* This is distinct from {@link IWidgetOptions.serialize}, which controls
* whether the value is included in the API prompt sent for execution.
*
* @default true
* @see IWidgetOptions.serialize — API prompt inclusion
*/
serialize?: boolean
/**
* The computed height of the widget. Used by customized node resize logic.
@@ -444,15 +429,10 @@ export interface IBaseWidget<
last_y?: number
width?: number
/**
* Whether the widget is disabled. Disabled widgets are rendered at half opacity.
* See also {@link IBaseWidget.computedDisabled}.
*/
disabled?: boolean
/**
* The disabled state used for rendering based on various conditions including
* {@link IBaseWidget.disabled}.
* {@link WidgetState.disabled}.
* @readonly [Computed] This property is computed by the node.
*/
computedDisabled?: boolean

View File

@@ -4,9 +4,23 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { UUID } from '@/utils/uuid'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
function lookup(
store: ReturnType<typeof useWidgetValueStore>,
graphId: string,
nodeId: NodeId,
name: string
) {
return store.getWidget(
widgetEntityId(asGraphId(graphId as UUID), nodeId, name)
)
}
function createTestWidget(
node: LGraphNode,
@@ -95,7 +109,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = true
widget.advanced = true
const state = store.getWidget(graph.id, 1, 'writeWidget')
const state = lookup(store, graph.id, 1, 'writeWidget')
expect(state?.label).toBe('Updated Label')
expect(state?.disabled).toBe(true)
@@ -108,9 +122,9 @@ describe('BaseWidget store integration', () => {
widget.setNodeId(1)
widget.value = 99
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
expect(lookup(store, graph.id, 1, 'valueWidget')?.value).toBe(99)
const state = store.getWidget(graph.id, 1, 'valueWidget')!
const state = lookup(store, graph.id, 1, 'valueWidget')!
state.value = 55
expect(widget.value).toBe(55)
})
@@ -128,10 +142,8 @@ describe('BaseWidget store integration', () => {
})
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
const state = lookup(store, graph.id, 1, 'autoRegWidget')
expect(state).toBeDefined()
expect(state?.nodeId).toBe(1)
expect(state?.name).toBe('autoRegWidget')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
expect(state?.label).toBe('Auto Label')
@@ -146,7 +158,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'defaultsWidget' })
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
const state = lookup(store, graph.id, 1, 'defaultsWidget')
expect(state).toBeDefined()
expect(state?.disabled).toBe(false)
expect(state?.label).toBeUndefined()
@@ -159,7 +171,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
widget.setNodeId(1)
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
expect(lookup(store, graph.id, 1, 'valuesWidget')?.value).toBe(77)
})
})
@@ -177,20 +189,20 @@ describe('BaseWidget store integration', () => {
get() {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return defaultValue
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = lookup(store, graphId, node.id, 'system_prompt')
return (state?.value as string) ?? defaultValue
},
set(v: string) {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = lookup(store, graphId, node.id, 'system_prompt')
if (state) state.value = v
}
})
widget.setNodeId(node.id)
const state = store.getWidget(graph.id, node.id, 'system_prompt')
const state = lookup(store, graph.id, node.id, 'system_prompt')
expect(state?.value).toBe(defaultValue)
})
})
@@ -211,7 +223,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = undefined
const state = store.getWidget(graph.id, 1, 'testWidget')
const state = lookup(store, graph.id, 1, 'testWidget')
expect(state?.disabled).toBe(false)
})
})

View File

@@ -20,7 +20,7 @@ import type {
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { deriveWidgetEntityId } from '@/world/entityIds'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
@@ -85,8 +85,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
computedDisabled?: boolean
tooltip?: string
private _state: Omit<WidgetState, 'nodeId'> &
Partial<Pick<WidgetState, 'nodeId'>>
private _state: WidgetState
private _nodeId?: NodeId
get label(): string | undefined {
return this._state.label
@@ -133,10 +133,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
get entityId(): WidgetEntityId | undefined {
const graphId = this.node.graph?.rootGraph.id
const nodeId = this._state.nodeId
if (!graphId || nodeId === undefined) return undefined
return widgetEntityId(graphId, nodeId, this.name)
return deriveWidgetEntityId(
this.node.graph?.rootGraph.id,
this._nodeId,
this.name
)
}
/**
@@ -144,16 +145,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
* Once set, value reads/writes will be delegated to the store.
*/
setNodeId(nodeId: NodeId): void {
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._nodeId = nodeId
const widgetId = this.entityId
if (!widgetId) return
this._state = useWidgetValueStore().registerWidget(graphId, {
this._state = useWidgetValueStore().registerWidget(widgetId, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
nodeId
value: this.value
})
}
@@ -202,7 +203,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
Object.assign(this, safeValues)
this._state = {
name: this.name,
type: this.type as TWidgetType,
value,
label,

View File

@@ -294,16 +294,17 @@ describe('NodeWidgets', () => {
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget('graph-test', {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
})
widgetValueStore.registerWidget(
widgetEntityId(GRAPH_ID, 'test_node', 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
}
)
await nextTick()

View File

@@ -10,6 +10,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
@@ -40,13 +41,13 @@ export function trackNodePrice(node: TrackableNode) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = getRelevantWidgetNames(node.type)
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && node.id != null) {
const widgetValueStore = useWidgetValueStore()
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, node.id, name)?.value
const entityId = deriveWidgetEntityId(graphId, node.id, name)
if (entityId) void widgetValueStore.getWidget(entityId)?.value
}
}
// Access input connections for regular inputs
@@ -142,13 +143,13 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && nodeData?.id != null) {
const widgetValueStore = useWidgetValueStore()
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
const entityId = deriveWidgetEntityId(graphId, nodeData.id, name)
if (entityId) void widgetValueStore.getWidget(entityId)?.value
}
}
// Access input connections for regular inputs

View File

@@ -13,7 +13,7 @@ import {
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from '@/world/entityIds'
import { deriveWidgetEntityId, widgetEntityId } from '@/world/entityIds'
const GRAPH_ID = 'graph-test'
@@ -432,13 +432,14 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
callback
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
useWidgetValueStore().registerWidget(
widgetEntityId(GRAPH_ID, NODE_ID, 'seed'),
{
type: 'combo',
value: 0,
options: {}
}
)
const [processed] = processWidgets([widget])
processed.updateHandler(42)
@@ -466,21 +467,47 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
nodeId: NODE_ID
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
type: 'combo',
value: 0,
options: {}
})
useWidgetValueStore().registerWidget(
widgetEntityId(GRAPH_ID, NODE_ID, 'seed'),
{
type: 'combo',
value: 0,
options: {}
}
)
const [processed] = processWidgets([widget])
processed.updateHandler(99)
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
const entityId = deriveWidgetEntityId(GRAPH_ID, NODE_ID, 'seed')
const state = entityId
? useWidgetValueStore().getWidget(entityId)
: undefined
expect(state?.value).toBe(99)
})
it('uses slotName for fallback widget-state lookup when entityId is absent', () => {
const widget = createMockWidget({
name: 'source_seed',
slotName: 'host_seed',
nodeId: NODE_ID,
entityId: undefined
})
useWidgetValueStore().registerWidget(
widgetEntityId(GRAPH_ID, NODE_ID, 'host_seed'),
{
type: 'combo',
value: 42,
options: {}
}
)
const [processed] = processWidgets([widget])
expect(processed.value).toBe(42)
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',

View File

@@ -25,14 +25,11 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
import type { WidgetEntityId } from '@/world/entityIds'
import { getWidgetState } from '@/world/widgetValueIO'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
@@ -77,6 +74,15 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
/**
* Strips graph-scope prefix segments from a node id (e.g. `outer:inner:42`
* → `42`) so nested node renders get stable DOM identity keys. Not for
* widget value lookup — that routes through {@link WidgetEntityId}.
*/
function extractRawNodeId(scopedId: string | number): string {
return String(scopedId).replace(/^(.*:)+/, '')
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
@@ -135,10 +141,10 @@ export function getWidgetIdentity(
const slotNameForIdentity = widget.slotName ?? widget.name
const hostNodeIdRoot =
nodeId !== undefined && nodeId !== ''
? `node:${String(stripGraphPrefix(nodeId))}`
? `node:${String(extractRawNodeId(nodeId))}`
: undefined
const stableIdentityRoot = widget.nodeId
? `node:${String(stripGraphPrefix(widget.nodeId))}`
? `node:${String(extractRawNodeId(widget.nodeId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
@@ -198,15 +204,19 @@ export function computeProcessedWidgets({
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetState = widget.entityId
? getWidgetState(widget.entityId)
: graphId
? widgetValueStore.getWidget(
graphId,
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
widget.name
)
let widgetState: WidgetState | undefined
if (widget.entityId) {
widgetState = widgetValueStore.getWidget(widget.entityId)
} else {
const fallbackEntityId = deriveWidgetEntityId(
graphId,
String(extractRawNodeId(widget.nodeId ?? nodeId ?? '')),
widget.slotName ?? widget.name
)
widgetState = fallbackEntityId
? widgetValueStore.getWidget(fallbackEntityId)
: undefined
}
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
@@ -254,7 +264,7 @@ export function computeProcessedWidgets({
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = String(stripGraphPrefix(widget.nodeId ?? nodeId ?? ''))
const bareWidgetId = String(extractRawNodeId(widget.nodeId ?? nodeId ?? ''))
const vueComponent =
getComponent(widget.type) ||
@@ -318,7 +328,7 @@ export function computeProcessedWidgets({
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
? String(extractRawNodeId(widget.nodeId))
: undefined
)
}

View File

@@ -10,11 +10,11 @@ import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
@@ -42,24 +42,35 @@ function addMarkdownWidget(
editable: false
})
const widgetStore = useWidgetValueStore()
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widgetValueStore = useWidgetValueStore()
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const storedValue = entityId
? widgetValueStore.getWidget(entityId)?.value
: undefined
return typeof storedValue === 'string' ? storedValue : textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState) widgetState.value = v
}
})
@@ -106,7 +117,29 @@ function addMarkdownWidget(
signal
})
forwardMiddleButtonToCanvas(inputEl, signal)
inputEl.addEventListener(
'pointerdown',
(event) => {
if (event.button === 1) app.canvas.processMouseDown(event)
},
{ signal }
)
inputEl.addEventListener(
'pointermove',
(event) => {
if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event)
},
{ signal }
)
inputEl.addEventListener(
'pointerup',
(event) => {
if (event.button === 1) app.canvas.processMouseUp(event)
},
{ signal }
)
widget.onRemove = useChainCallback(widget.onRemove, () => {
controller.abort()

View File

@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
@@ -25,6 +26,7 @@ export function useTextPreviewWidget(
node: LGraphNode,
inputSpec: InputSpec
): IBaseWidget {
const widgetValueStore = useWidgetValueStore()
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
@@ -37,19 +39,26 @@ export function useTextPreviewWidget(
nodeId: node.id
},
options: {
getValue: () =>
useWidgetValueStore().getWidget(
getValue: () => {
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
)?.value ?? '',
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return widgetState?.value ?? ''
},
setValue: (value: string | object) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState)
widgetState.value =
typeof value === 'string' ? value : String(value)

View File

@@ -3,12 +3,12 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
import { useSettingStore } from '@/platform/settings/settingStore'
import { forwardMiddleButtonToCanvas } from '@/renderer/extensions/vueNodes/widgets/utils/forwardMiddleButtonToCanvas'
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
const TRACKPAD_DETECTION_THRESHOLD = 50
@@ -19,7 +19,6 @@ function addMultilineWidget(
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const widgetStore = useWidgetValueStore()
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.dataset.testid = 'dom-widget-textarea'
@@ -27,17 +26,29 @@ function addMultilineWidget(
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widgetValueStore = useWidgetValueStore()
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const entityId = deriveWidgetEntityId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
name
)
const widgetState = entityId
? widgetValueStore.getWidget(entityId)
: undefined
if (widgetState) widgetState.value = v
}
})
@@ -67,7 +78,30 @@ function addMultilineWidget(
{ signal }
)
forwardMiddleButtonToCanvas(inputEl, signal)
// Allow middle mouse button panning
inputEl.addEventListener(
'pointerdown',
(event: PointerEvent) => {
if (event.button === 1) app.canvas.processMouseDown(event)
},
{ signal }
)
inputEl.addEventListener(
'pointermove',
(event: PointerEvent) => {
if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event)
},
{ signal }
)
inputEl.addEventListener(
'pointerup',
(event: PointerEvent) => {
if (event.button === 1) app.canvas.processMouseUp(event)
},
{ signal }
)
inputEl.addEventListener(
'wheel',

View File

@@ -4,15 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asGraphId, widgetEntityId } from '@/world/entityIds'
type WidgetValueStoreStub = {
_widgetMap: Map<string, { value: unknown }>
}
const TEST_GRAPH_ID = 'test-graph-id'
const mockRendererFactory = vi.hoisted(() => {
const init = vi.fn(() => true)
const compileFragment = vi.fn(() => ({ success: true, log: '' }))
@@ -78,12 +81,12 @@ vi.mock('@/stores/nodeOutputStore', () => ({
vi.mock('@/stores/widgetValueStore', () => {
const widgetMap = new Map<string, { value: unknown }>()
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
widgetMap.get(name)
)
const getWidget = vi.fn((widgetId: string) => widgetMap.get(widgetId))
const getNodeWidgets = vi.fn(() => [])
return {
useWidgetValueStore: () => ({
getWidget,
getNodeWidgets,
_widgetMap: widgetMap
})
}
@@ -102,7 +105,7 @@ vi.mock('@/utils/objectUrlUtil', () => ({
}))
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
const graph = { id: 'test-graph-id', rootGraph: { id: 'test-graph-id' } }
const graph = { id: TEST_GRAPH_ID, rootGraph: { id: TEST_GRAPH_ID } }
return fromAny<LGraphNode, unknown>({
id: 1,
type: 'GLSLShader',
@@ -120,6 +123,18 @@ function wrapNode(
return ref(node) as MaybeRefOrGetter<LGraphNode | null>
}
function setWidget(
store: WidgetValueStoreStub,
node: LGraphNode,
name: string,
value: unknown
) {
store._widgetMap.set(
widgetEntityId(asGraphId(TEST_GRAPH_ID), node.id as NodeId, name),
{ value }
)
}
describe('useGLSLPreview', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -184,9 +199,7 @@ describe('useGLSLPreview', () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('fragment_shader', {
value: 'void main() {}'
})
setWidget(store, node, 'fragment_shader', 'void main() {}')
const nodeRef = shallowRef<LGraphNode | null>(null)
useGLSLPreview(nodeRef)
@@ -248,9 +261,7 @@ describe('useGLSLPreview', () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('fragment_shader', {
value: 'void main() {}'
})
setWidget(store, node, 'fragment_shader', 'void main() {}')
const nodeRef = shallowRef<LGraphNode | null>(null)
const result = useGLSLPreview(nodeRef)
@@ -306,9 +317,15 @@ describe('useGLSLPreview', () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.delete('fragment_shader')
const node = createMockNode()
store._widgetMap.delete(
widgetEntityId(
asGraphId(TEST_GRAPH_ID),
node.id as NodeId,
'fragment_shader'
)
)
mockNodeOutputs[String(node.id)] = {
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
}
@@ -327,32 +344,30 @@ describe('useGLSLPreview', () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'custom' })
store._widgetMap.set('size_mode.width', { value: 800 })
store._widgetMap.set('size_mode.height', { value: 600 })
const node = createMockNode()
setWidget(store, node, 'size_mode', 'custom')
setWidget(store, node, 'size_mode.width', 800)
setWidget(store, node, 'size_mode.height', 600)
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
store._widgetMap.delete('size_mode')
store._widgetMap.delete('size_mode.width')
store._widgetMap.delete('size_mode.height')
store._widgetMap.clear()
})
it('uses default resolution when size_mode is not custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'from_input' })
const node = createMockNode()
setWidget(store, node, 'size_mode', 'from_input')
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
store._widgetMap.delete('size_mode')
store._widgetMap.clear()
})
it('disposes renderer and cancels debounce on cleanup', async () => {

View File

@@ -8,6 +8,7 @@ import type { UUID } from '@/utils/uuid'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { deriveWidgetEntityId } from '@/world/entityIds'
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
@@ -121,8 +122,8 @@ function createInnerPreview(
lastError: Ref<string | null>,
isActiveOut: Ref<boolean>
): () => void {
const widgetValueStore = useWidgetValueStore()
const nodeOutputStore = useNodeOutputStore()
const widgetValueStore = useWidgetValueStore()
const { nodeToNodeLocatorId } = useWorkflowStore()
let renderer: ReturnType<typeof useGLSLRenderer> | null = null
@@ -194,18 +195,22 @@ function createInnerPreview(
if (isGLSLNode.value) {
const nId = nodeId.value
if (nId == null) return undefined
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
| string
| undefined
const entityId = deriveWidgetEntityId(gId, nId, 'fragment_shader')
return entityId
? (widgetValueStore.getWidget(entityId)?.value as string | undefined)
: undefined
}
const inner = innerGLSLNode
if (inner) {
return widgetValueStore.getWidget(
const entityId = deriveWidgetEntityId(
gId,
inner.id as NodeId,
'fragment_shader'
)?.value as string | undefined
)
return entityId
? (widgetValueStore.getWidget(entityId)?.value as string | undefined)
: undefined
}
return undefined
@@ -291,23 +296,16 @@ function createInnerPreview(
: nodeId.value
if (sizeModeNodeId == null) return null
const sizeMode = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode'
)
const lookup = (name: string) => {
const entityId = deriveWidgetEntityId(gId, sizeModeNodeId, name)
return entityId ? widgetValueStore.getWidget(entityId) : undefined
}
const sizeMode = lookup('size_mode')
if (sizeMode?.value !== 'custom') return null
const widthWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.height'
)
const widthWidget = lookup('size_mode.width')
const heightWidget = lookup('size_mode.height')
if (!widthWidget || !heightWidget) return null
return clampResolution(

View File

@@ -6,6 +6,11 @@ import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { UUID } from '@/utils/uuid'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
asGraphId,
deriveWidgetEntityId,
nodeEntityId
} from '@/world/entityIds'
import { isCurveData } from '@/components/curve/curveUtils'
import type { CurveData } from '@/components/curve/types'
@@ -132,7 +137,10 @@ export function useGLSLUniforms(
if (subgraphSources) {
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const entityId = deriveWidgetEntityId(gId, nId, widgetName)
const widget = entityId
? widgetValueStore.getWidget(entityId)
: undefined
return coerce(widget?.value ?? directValue() ?? defaultValue)
})
}
@@ -144,7 +152,8 @@ export function useGLSLUniforms(
const values: T[] = []
for (let i = 0; i < maxCount; i++) {
const inputName = `${groupName}.${uniformPrefix}${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
const entityId = deriveWidgetEntityId(gId, nId, inputName)
const widget = entityId ? widgetValueStore.getWidget(entityId) : undefined
if (widget !== undefined) {
values.push(coerce(widget.value))
continue
@@ -158,8 +167,7 @@ export function useGLSLUniforms(
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
nodeEntityId(asGraphId(gId), upstreamNode.id as NodeId)
)
if (
upstreamWidgets.length === 0 ||
@@ -214,7 +222,10 @@ export function useGLSLUniforms(
if (sources && sources.length > 0) {
return sources
.map(({ nodeId: nId, widgetName, directValue }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const entityId = deriveWidgetEntityId(gId, nId, widgetName)
const widget = entityId
? widgetValueStore.getWidget(entityId)
: undefined
const value = widget?.value ?? directValue()
return isCurveData(value) ? (value as CurveData) : null
})
@@ -230,7 +241,8 @@ export function useGLSLUniforms(
for (let i = 0; i < max; i++) {
const inputName = `curves.u_curve${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
const entityId = deriveWidgetEntityId(gId, nId, inputName)
const widget = entityId ? widgetValueStore.getWidget(entityId) : undefined
if (widget && isCurveData(widget.value)) {
values.push(widget.value as CurveData)
continue
@@ -243,8 +255,7 @@ export function useGLSLUniforms(
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
nodeEntityId(asGraphId(gId), upstreamNode.id as NodeId)
)
const curveWidget = upstreamWidgets.find((w) => isCurveData(w.value))
if (!curveWidget) break

View File

@@ -18,6 +18,7 @@ import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
@@ -127,16 +128,16 @@ function createWorkflowWithLinearData(
}
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetEntityId
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySteps = `${rootGraphId}:1:steps` as WidgetEntityId
const entityPrompt = widgetEntityId(rootGraphId, 1, 'prompt')
const entitySeed = widgetEntityId(rootGraphId, 1, 'seed')
const entitySteps = widgetEntityId(rootGraphId, 1, 'steps')
function nodeWithWidgets(id: number, widgetNames: string[]) {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
entityId: widgetEntityId(rootGraphId, id, name)
}))
})
}
@@ -803,6 +804,30 @@ describe('appModeStore', () => {
describe('legacy selectedInput tuple migration', () => {
const rootGraphId = '11111111-1111-4111-8111-111111111111'
it('migrates legacy unprefixed widget entity ids to canonical widget ids', () => {
const legacyEntityId = `${rootGraphId}:42:seed`
const canonicalEntityId = widgetEntityId(rootGraphId, 42, 'seed')
const rootNode = fromAny<LGraphNode, unknown>({
id: 42,
widgets: [{ name: 'seed', entityId: canonicalEntityId }]
})
vi.mocked(app.rootGraph).id = rootGraphId
vi.mocked(app.rootGraph).nodes = [rootNode]
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id: NodeId | null | undefined) => (id == 42 ? rootNode : null)
)
const result = store.pruneLinearData({
inputs: [[legacyEntityId, 'seed', { height: 120 }]],
outputs: []
})
expect(result.inputs).toEqual([
[canonicalEntityId, 'seed', { height: 120 }]
])
})
it('migrates legacy `(sourceNodeId, sourceWidgetName)` to the host promoted widget entity id', () => {
const hostId = 5
const sourceNodeId = 42

View File

@@ -26,7 +26,11 @@ import {
resolveNodeWidget
} from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
import {
isWidgetEntityId,
parseLegacyWidgetEntityId,
parseWidgetEntityId
} from '@/world/entityIds'
function findWidgetByEntityId(
rootGraph: LGraph,
@@ -105,6 +109,17 @@ export const useAppModeStore = defineStore('appMode', () => {
return null
}
if (typeof storedId === 'string') {
const legacyId = parseLegacyWidgetEntityId(storedId, rootGraph.id)
if (legacyId) {
const node = rootGraph.getNodeById?.(legacyId.nodeId)
const widget = node?.widgets?.find((w) => w.name === legacyId.name)
const entityId =
node && widget && getWidgetEntityIdForNode(node, widget)
return entityId ? buildEntry(entityId, widgetName, config) : null
}
}
if (typeof storedId === 'string' && storedId.includes(':')) {
const [, widget] = resolveNodeWidget(storedId, widgetName)
if (!widget?.entityId) return null

View File

@@ -2,25 +2,33 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/utils/uuid'
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
name: string,
function widgetState<T>(
type: string,
value: T,
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
return {
type,
value,
options: {},
...extra
}
}
describe('useWidgetValueStore', () => {
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const wid = (graphId: UUID, nodeId: NodeId, name: string): WidgetEntityId =>
widgetEntityId(asGraphId(graphId), nodeId, name)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -28,35 +36,45 @@ describe('useWidgetValueStore', () => {
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(
store.getWidget(wid(graphA, 'missing' as NodeId, 'widget'))
).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const state = store.registerWidget(id, widgetState('number', 100))
expect(state.value).toBe(100)
state.value = 200
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
expect(store.getWidget(id)?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
const node = 'node-1' as NodeId
store.registerWidget(
graphA,
widget('node-1', 'array', 'combo', [1, 2, 3])
wid(graphA, node, 'text'),
widgetState('string', 'hello')
)
store.registerWidget(
wid(graphA, node, 'number'),
widgetState('number', 42)
)
store.registerWidget(
wid(graphA, node, 'boolean'),
widgetState('toggle', true)
)
store.registerWidget(
wid(graphA, node, 'array'),
widgetState('combo', [1, 2, 3])
)
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
expect(store.getWidget(wid(graphA, node, 'text'))?.value).toBe('hello')
expect(store.getWidget(wid(graphA, node, 'number'))?.value).toBe(42)
expect(store.getWidget(wid(graphA, node, 'boolean'))?.value).toBe(true)
expect(store.getWidget(wid(graphA, node, 'array'))?.value).toEqual([
1, 2, 3
])
})
@@ -66,15 +84,13 @@ describe('useWidgetValueStore', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 12345)
wid(graphA, 'node-1' as NodeId, 'seed'),
widgetState('number', 12345)
)
expect(state.nodeId).toBe('node-1')
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.disabled).toBeUndefined()
expect(state.disabled).toBe(false)
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
})
@@ -82,8 +98,8 @@ describe('useWidgetValueStore', () => {
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'prompt', 'string', 'test', {
wid(graphA, 'node-1' as NodeId, 'prompt'),
widgetState('string', 'test', {
label: 'Prompt Text',
disabled: true,
serialize: false,
@@ -96,84 +112,189 @@ describe('useWidgetValueStore', () => {
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
})
it('overwrites existing widget state when registerWidget is called twice', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const first = store.registerWidget(id, widgetState('number', 11))
first.value = 99
store.registerWidget(id, widgetState('number', 11))
expect(store.getWidget(id)?.value).toBe(11)
})
it('register-if-absent pattern preserves existing state', () => {
// Captures the idempotency guarantee that the prior IO helper used to
// provide: callers that want non-destructive init must check getWidget
// first.
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 11))
const first = store.getWidget(id)!
first.value = 99
const existing = store.getWidget(id)
if (!existing) store.registerWidget(id, widgetState('number', 11))
expect(store.getWidget(id)?.value).toBe(99)
})
})
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 100))
const state = store.getWidget(graphA, 'node-1', 'seed')
const state = store.getWidget(id)
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
})
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(
store.getWidget(wid(graphA, 'missing' as NodeId, 'widget'))
).toBeUndefined()
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
const node1 = 'node-1' as NodeId
const node2 = 'node-2' as NodeId
store.registerWidget(wid(graphA, node1, 'seed'), widgetState('number', 1))
store.registerWidget(
wid(graphA, node1, 'steps'),
widgetState('number', 20)
)
store.registerWidget(wid(graphA, node2, 'cfg'), widgetState('number', 7))
const widgets = store.getNodeWidgets(graphA, 'node-1')
const widgets = store.getNodeWidgets(
nodeEntityId(asGraphId(graphA), node1)
)
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
})
describe('direct property mutation', () => {
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const state = store.registerWidget(id, widgetState('number', 100))
state.disabled = true
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
expect(store.getWidget(id)?.disabled).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const id = wid(graphA, 'node-1' as NodeId, 'seed')
const state = store.registerWidget(id, widgetState('number', 100))
state.label = 'Random Seed'
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
'Random Seed'
)
expect(store.getWidget(id)?.label).toBe('Random Seed')
state.label = undefined
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
expect(store.getWidget(id)?.label).toBeUndefined()
})
})
describe('graph isolation', () => {
it('isolates widget states by graph', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
const node = 'node-1' as NodeId
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
store.registerWidget(wid(graphB, node, 'seed'), widgetState('number', 2))
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(wid(graphA, node, 'seed'))?.value).toBe(1)
expect(store.getWidget(wid(graphB, node, 'seed'))?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
const node = 'node-1' as NodeId
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
store.registerWidget(wid(graphB, node, 'seed'), widgetState('number', 2))
store.clearGraph(graphA)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(wid(graphA, node, 'seed'))).toBeUndefined()
expect(store.getWidget(wid(graphB, node, 'seed'))?.value).toBe(2)
})
})
describe('returned state identity', () => {
const node = 'node-1' as NodeId
const widgetId = wid(graphA, node, 'seed')
it('getWidget returns the same reference as registerWidget', () => {
const store = useWidgetValueStore()
const registered = store.registerWidget(
widgetId,
widgetState('number', 100)
)
expect(store.getWidget(widgetId)).toBe(registered)
})
it('cached references detach safely after clearGraph', () => {
const store = useWidgetValueStore()
const view = store.registerWidget(widgetId, widgetState('number', 100))
store.clearGraph(graphA)
view.value = 999
view.label = 'ignored'
view.disabled = true
expect(store.getWidget(widgetId)).toBeUndefined()
})
})
describe('getNodeWidgetsByName', () => {
it('returns empty map when node has no widgets', () => {
const store = useWidgetValueStore()
const map = store.getNodeWidgetsByName(
nodeEntityId(asGraphId(graphA), 'no-such' as NodeId)
)
expect(map.size).toBe(0)
})
it('returns map keyed by widget name', () => {
const store = useWidgetValueStore()
const node = 'node-1' as NodeId
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
store.registerWidget(wid(graphA, node, 'cfg'), widgetState('number', 7))
const map = store.getNodeWidgetsByName(
nodeEntityId(asGraphId(graphA), node)
)
expect(map.size).toBe(2)
expect(map.get('seed')?.value).toBe(1)
expect(map.get('cfg')?.value).toBe(7)
expect(map.get('missing')).toBeUndefined()
})
})
describe('setValue', () => {
it('updates an existing widget value and returns true', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 1))
expect(store.setValue(id, 99)).toBe(true)
expect(store.getWidget(id)?.value).toBe(99)
})
it('returns false when setting value on an unregistered widget', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
expect(store.setValue(id, 99)).toBe(false)
})
})
describe('reactivity through the view', () => {
it('clearGraph removes data; subsequent getWidget returns undefined', () => {
const store = useWidgetValueStore()
const id = wid(graphA, 'node-1' as NodeId, 'seed')
store.registerWidget(id, widgetState('number', 100))
store.clearGraph(graphA)
expect(store.getWidget(id)).toBeUndefined()
})
})
})

View File

@@ -1,97 +1,97 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import { reactive } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/utils/uuid'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import {
asGraphId,
isNodeIdForGraph,
isWidgetIdForGraph,
nodeEntityId,
parseWidgetEntityId
} from '@/world/entityIds'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
import type { WidgetState } from '@/world/widgets/widgetState'
type WidgetKey = `${NodeId}:${string}`
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
}
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> extends Pick<
IBaseWidget<TValue, TType, TOptions>,
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
> {
nodeId: NodeId
}
export type { WidgetState } from '@/world/widgets/widgetState'
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
const widgets = reactive(new Map<WidgetEntityId, WidgetState>())
const widgetIdsByNode = reactive(new Map<NodeEntityId, WidgetEntityId[]>())
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
if (widgetStates) return widgetStates
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
graphWidgetStates.value.set(graphId, nextWidgetStates)
return nextWidgetStates
}
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
}
/**
* @deprecated Use `ensureWidgetState(widget.entityId, init)` from
* `src/world/widgetValueIO.ts` — the branded `WidgetEntityId` prevents
* producer/consumer drift that loose triples allow.
*/
function registerWidget<TValue = unknown>(
graphId: UUID,
widgetId: WidgetEntityId,
state: WidgetState<TValue>
): WidgetState<TValue> {
const widgetStates = getWidgetStateMap(graphId)
const key = makeKey(state.nodeId, state.name)
widgetStates.set(key, state)
return widgetStates.get(key) as WidgetState<TValue>
const { graphId, nodeId } = parseWidgetEntityId(widgetId)
widgets.set(widgetId, {
...state,
disabled: state.disabled ?? false
})
const ownerId = nodeEntityId(graphId, nodeId)
const ids = widgetIdsByNode.get(ownerId)
if (!ids) {
widgetIdsByNode.set(ownerId, [widgetId])
} else if (!ids.includes(widgetId)) {
ids.push(widgetId)
}
return widgets.get(widgetId) as WidgetState<TValue>
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${nodeId}:`
return [...widgetStates]
.filter(([key]) => key.startsWith(prefix))
.map(([, state]) => state)
function getWidget(widgetId: WidgetEntityId): WidgetState | undefined {
return widgets.get(widgetId)
}
/**
* @deprecated Use `getWidgetState(widget.entityId)` or
* `readWidgetValue(widget.entityId)` from `src/world/widgetValueIO.ts` —
* the branded `WidgetEntityId` prevents producer/consumer drift that loose
* triples allow.
*/
function getWidget(
graphId: UUID,
nodeId: NodeId,
widgetName: string
): WidgetState | undefined {
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
function getNodeWidgets(nodeId: NodeEntityId): WidgetState[] {
const ids = widgetIdsByNode.get(nodeId)
if (!ids) return []
const result: WidgetState[] = []
for (const widgetId of ids) {
const w = widgets.get(widgetId)
if (w) result.push(w)
}
return result
}
function getNodeWidgetsByName(
nodeId: NodeEntityId
): Map<string, WidgetState> {
const result = new Map<string, WidgetState>()
const ids = widgetIdsByNode.get(nodeId)
if (!ids) return result
for (const widgetId of ids) {
const w = widgets.get(widgetId)
if (!w) continue
const { name } = parseWidgetEntityId(widgetId)
result.set(name, w)
}
return result
}
function setValue(
graphId: UUID,
nodeId: NodeId,
widgetName: string,
widgetId: WidgetEntityId,
value: WidgetState['value']
): boolean {
const state = getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
if (!state) return false
state.value = value
const widget = widgets.get(widgetId)
if (!widget) return false
widget.value = value
return true
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
const branded = asGraphId(graphId)
for (const widgetId of widgets.keys()) {
if (isWidgetIdForGraph(branded, widgetId)) {
widgets.delete(widgetId)
}
}
for (const nodeId of widgetIdsByNode.keys()) {
if (isNodeIdForGraph(branded, nodeId)) {
widgetIdsByNode.delete(nodeId)
}
}
}
return {
@@ -99,6 +99,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
getWidget,
setValue,
getNodeWidgets,
getNodeWidgetsByName,
clearGraph
}
})

View File

@@ -29,7 +29,7 @@ import { app } from '@/scripts/app'
import { t } from '@/i18n'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { deriveWidgetEntityId } from '@/world/entityIds'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -377,9 +377,7 @@ export function getWidgetEntityIdForNode(
widget: Pick<IBaseWidget, 'name' | 'entityId'>
): WidgetEntityId | undefined {
if (widget.entityId) return widget.entityId
const graphId = node.graph?.rootGraph.id
if (!graphId || node.id === -1) return undefined
return widgetEntityId(graphId, node.id, widget.name)
return deriveWidgetEntityId(node.graph?.rootGraph.id, node.id, widget.name)
}
export function isLoad3dNode(node: LGraphNode) {

View File

@@ -1,45 +1,23 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { WidgetEntityId } from './entityIds'
import type { UUID } from '@/utils/uuid'
import type { NodeEntityId, NodeId, WidgetEntityId } from './entityIds'
import {
asGraphId,
deriveWidgetEntityId,
isWidgetEntityId,
nodeEntityId,
parseLegacyWidgetEntityId,
parseWidgetEntityId,
widgetEntityId
} from './entityIds'
describe('widgetEntityId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
it('builds a deterministic id from its components', () => {
const id = widgetEntityId(graphId, 42, 'seed')
expect(id).toBe(`${graphId}:42:seed`)
})
it('produces equal ids for equal inputs', () => {
expect(widgetEntityId(graphId, 42, 'seed')).toBe(
widgetEntityId(graphId, 42, 'seed')
)
})
it('produces distinct ids when any component differs', () => {
const baseline = widgetEntityId(graphId, 42, 'seed')
expect(widgetEntityId(graphId, 43, 'seed')).not.toBe(baseline)
expect(widgetEntityId(graphId, 42, 'steps')).not.toBe(baseline)
const otherGraph = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
expect(widgetEntityId(otherGraph, 42, 'seed')).not.toBe(baseline)
})
it('accepts string node ids', () => {
const id = widgetEntityId(graphId, 'node-7', 'value')
expect(id).toBe(`${graphId}:node-7:value`)
})
})
describe('parseWidgetEntityId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphId = asGraphId('a3f2c1d8-4567-89ab-cdef-1234567890ab' as UUID)
it('round-trips a constructed id', () => {
const id = widgetEntityId(graphId, 42, 'seed')
it('round-trips a simple name', () => {
const id = widgetEntityId(graphId, 42 as NodeId, 'seed')
expect(parseWidgetEntityId(id)).toEqual({
graphId,
nodeId: '42',
@@ -47,29 +25,64 @@ describe('parseWidgetEntityId', () => {
})
})
it('preserves colons inside the name segment', () => {
const rawName = 'nested:label:with:colons'
const rawId = `${graphId}:42:${rawName}` as WidgetEntityId
expect(parseWidgetEntityId(rawId)).toEqual({
it('preserves names containing colons', () => {
const id = widgetEntityId(graphId, 7 as NodeId, 'images.image:0')
expect(parseWidgetEntityId(id).name).toBe('images.image:0')
})
it('handles string node ids', () => {
const id = widgetEntityId(graphId, '12:5' as NodeId, 'sub_widget')
const parsed = parseWidgetEntityId(id)
expect(parsed.graphId).toBe(graphId)
})
it('round-trips an empty name', () => {
const id = widgetEntityId(graphId, 1 as NodeId, '')
expect(parseWidgetEntityId(id)).toEqual({
graphId,
nodeId: '42',
name: rawName
nodeId: '1',
name: ''
})
})
it('throws on missing widget: prefix', () => {
expect(() =>
parseWidgetEntityId(`node:${graphId}:42` as unknown as WidgetEntityId)
).toThrow(/Malformed WidgetEntityId/)
})
it('throws on too few colons', () => {
expect(() => parseWidgetEntityId('widget:abc' as WidgetEntityId)).toThrow(
/Malformed WidgetEntityId/
)
})
it('throws when nodeId segment is missing', () => {
expect(() =>
parseWidgetEntityId(`widget:${graphId}:42` as WidgetEntityId)
).toThrow(/Malformed WidgetEntityId/)
})
})
describe('isWidgetEntityId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphId = asGraphId('a1b2c3d4-e5f6-7890-abcd-ef1234567890' as UUID)
it('accepts ids built by the constructor', () => {
expect(isWidgetEntityId(widgetEntityId(graphId, 1, 'x'))).toBe(true)
expect(isWidgetEntityId(widgetEntityId(graphId, 1 as NodeId, 'x'))).toBe(
true
)
})
it('rejects strings without two colon-separated segments', () => {
it('rejects strings lacking the widget: prefix', () => {
expect(isWidgetEntityId('only-one-colon:42')).toBe(false)
expect(isWidgetEntityId('no-colons')).toBe(false)
expect(isWidgetEntityId(':leading-colon:name')).toBe(false)
expect(isWidgetEntityId('graph::name')).toBe(false)
expect(isWidgetEntityId(`${graphId}:42:seed`)).toBe(false)
expect(isWidgetEntityId(`node:${graphId}:42`)).toBe(false)
})
it('rejects strings with too few segments', () => {
expect(isWidgetEntityId('widget:abc')).toBe(false)
expect(isWidgetEntityId(`widget:${graphId}:42`)).toBe(false)
})
it('rejects non-strings', () => {
@@ -79,3 +92,90 @@ describe('isWidgetEntityId', () => {
expect(isWidgetEntityId({})).toBe(false)
})
})
describe('parseLegacyWidgetEntityId', () => {
const graphId = asGraphId('11111111-1111-4111-8111-111111111111' as UUID)
it('parses an unprefixed widget id for the current graph', () => {
expect(parseLegacyWidgetEntityId(`${graphId}:42:seed`, graphId)).toEqual({
graphId,
nodeId: '42',
name: 'seed'
})
})
it('preserves widget names containing colons', () => {
expect(
parseLegacyWidgetEntityId(`${graphId}:7:images.image:0`, graphId)?.name
).toBe('images.image:0')
})
it('rejects canonical widget ids and other graph ids', () => {
expect(
parseLegacyWidgetEntityId(widgetEntityId(graphId, 42, 'seed'), graphId)
).toBeUndefined()
expect(
parseLegacyWidgetEntityId(
'22222222-2222-4222-8222-222222222222:42:seed',
graphId
)
).toBeUndefined()
})
})
describe('deriveWidgetEntityId', () => {
const graphId = asGraphId('e1d2c3b4-a5f6-1234-5678-90abcdef1234' as UUID)
it('builds an entity id when all inputs are present', () => {
const id = deriveWidgetEntityId(graphId, 5 as NodeId, 'seed')
expect(id).toBe(widgetEntityId(graphId, 5 as NodeId, 'seed'))
})
it('returns undefined when graphId is missing', () => {
expect(deriveWidgetEntityId(undefined, 5 as NodeId, 'seed')).toBeUndefined()
})
it('returns undefined when nodeId is undefined', () => {
expect(deriveWidgetEntityId(graphId, undefined, 'seed')).toBeUndefined()
})
it('returns undefined for the sentinel nodeId -1', () => {
expect(deriveWidgetEntityId(graphId, -1, 'seed')).toBeUndefined()
})
it('accepts a plain UUID for graphId', () => {
const plain = 'f0e1d2c3-b4a5-6789-0123-456789abcdef' as UUID
expect(deriveWidgetEntityId(plain, 1 as NodeId, 'x')).toBe(
widgetEntityId(plain, 1 as NodeId, 'x')
)
})
})
describe('entityIds type shapes', () => {
type GraphId = ReturnType<typeof asGraphId>
it('widgetEntityId returns the WidgetEntityId brand', () => {
expectTypeOf(widgetEntityId).returns.toEqualTypeOf<WidgetEntityId>()
})
it('nodeEntityId returns the NodeEntityId brand', () => {
expectTypeOf(nodeEntityId).returns.toEqualTypeOf<NodeEntityId>()
})
it('parseWidgetEntityId returns the documented shape', () => {
expectTypeOf(parseWidgetEntityId).returns.toEqualTypeOf<{
graphId: GraphId
nodeId: NodeId
name: string
}>()
})
it('WidgetEntityId and NodeEntityId are distinct brands', () => {
expectTypeOf<
WidgetEntityId extends NodeEntityId ? WidgetEntityId : never
>().toEqualTypeOf<never>()
expectTypeOf<
NodeEntityId extends WidgetEntityId ? NodeEntityId : never
>().toEqualTypeOf<never>()
})
})

View File

@@ -1,39 +1,128 @@
/**
* Entity IDs are deterministic, content-addressed, string-prefixed values
* — not opaque numerics (cf. bitECS, koota, miniplex).
*
* Identity is keyed by `rootGraph.id`, so an entity viewed at different
* subgraph depths shares state. Migrating to numeric IDs would break
* cross-subgraph value sharing. See ADR 0008 and `widgetValueStore.ts`.
*
* The `graph*Prefix` and `*EntityId` helpers below are the sole owners of
* the on-the-wire format. Never hand-construct or parse these strings.
*/
import type { UUID } from '@/utils/uuid'
import type { Brand } from './brand'
export type NodeId = number | string
type GraphId = Brand<UUID, 'GraphId'>
export function asGraphId(id: UUID): GraphId {
return id as GraphId
}
export type NodeEntityId = Brand<string, 'NodeEntityId'>
function graphNodePrefix(graphId: GraphId): string {
return `node:${graphId}:`
}
export function nodeEntityId(graphId: GraphId, nodeId: NodeId): NodeEntityId {
return `${graphNodePrefix(graphId)}${nodeId}` as NodeEntityId
}
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
const SEPARATOR = ':'
function graphWidgetPrefix(graphId: GraphId): string {
return `widget:${graphId}:`
}
export function widgetEntityId(
graphId: UUID,
graphId: UUID | GraphId,
nodeId: NodeId,
name: string
): WidgetEntityId {
return `${graphId}${SEPARATOR}${nodeId}${SEPARATOR}${name}` as WidgetEntityId
return `${graphWidgetPrefix(graphId as GraphId)}${nodeId}:${name}` as WidgetEntityId
}
/**
* Guarded factory for `WidgetEntityId`. Returns `undefined` when any input
* required to construct the id is missing — graphless widgets, unbound
* node ids (`-1`), or unknown node ids cannot have a valid entity id.
*
* Use this from call sites that hold raw widget identity (graphId, nodeId,
* name); use the `widget.entityId` getter directly when you already have a
* `BaseWidget` instance.
*/
export function deriveWidgetEntityId(
graphId: UUID | GraphId | undefined,
nodeId: NodeId | undefined,
name: string
): WidgetEntityId | undefined {
if (!graphId || nodeId === undefined || nodeId === -1) return undefined
return widgetEntityId(graphId, nodeId, name)
}
/**
* Parse a `WidgetEntityId` into its constituent parts.
*
* On-the-wire format: `widget:${graphId}:${nodeId}:${name}`. The regex
* captures the first two colon-delimited segments as graphId and nodeId,
* then takes the rest as the widget name — so widget names may contain
* colons (e.g. `images.image:0`). NodeId values containing colons split
* at the first colon; production NodeIds are scalar-shaped, so this is a
* documented edge case rather than a defect. Throws on malformed input
* so upstream cast bugs surface at the parse site.
*/
const WIDGET_ID_RE = /^widget:([^:]+):([^:]+):(.*)$/
export function parseWidgetEntityId(id: WidgetEntityId): {
graphId: UUID
graphId: GraphId
nodeId: NodeId
name: string
} {
const firstColon = id.indexOf(SEPARATOR)
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
const match = WIDGET_ID_RE.exec(id)
if (!match) {
throw new Error(`Malformed WidgetEntityId: ${id}`)
}
const [, graphId, nodeId, name] = match
return {
graphId: id.slice(0, firstColon),
nodeId: id.slice(firstColon + 1, secondColon),
name: id.slice(secondColon + 1)
graphId: graphId as GraphId,
nodeId: nodeId as NodeId,
name
}
}
export function isWidgetEntityId(value: unknown): value is WidgetEntityId {
if (typeof value !== 'string') return false
const firstColon = value.indexOf(SEPARATOR)
if (firstColon <= 0) return false
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
return secondColon > firstColon + 1
return typeof value === 'string' && WIDGET_ID_RE.test(value)
}
export function parseLegacyWidgetEntityId(
id: string,
graphId: UUID | GraphId
): { graphId: GraphId; nodeId: NodeId; name: string } | undefined {
const brandedGraphId = graphId as GraphId
const prefix = `${brandedGraphId}:`
if (!id.startsWith(prefix)) return undefined
const rest = id.slice(prefix.length)
const separator = rest.indexOf(':')
if (separator <= 0) return undefined
return {
graphId: brandedGraphId,
nodeId: rest.slice(0, separator) as NodeId,
name: rest.slice(separator + 1)
}
}
export function isNodeIdForGraph(graphId: GraphId, id: NodeEntityId): boolean {
return id.startsWith(graphNodePrefix(graphId))
}
export function isWidgetIdForGraph(
graphId: GraphId,
id: WidgetEntityId
): boolean {
return id.startsWith(graphWidgetPrefix(graphId))
}

View File

@@ -1,164 +0,0 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from './entityIds'
import {
ensureWidgetState,
getWidgetState,
readWidgetValue,
writeWidgetValue
} from './widgetValueIO'
describe('widgetValueIO', () => {
const graphA = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphB = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('ensureWidgetState', () => {
it('registers a new state when none exists', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const state = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(state.value).toBe(11)
expect(state.nodeId).toBe('1')
expect(state.name).toBe('seed')
})
it('is idempotent — returns the same state on repeated calls', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const init = {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
}
const first = ensureWidgetState(id, init)
const second = ensureWidgetState(id, init)
expect(second).toBe(first)
})
it('does not overwrite an existing state with init values', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const first = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
first.value = 99
const second = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(second.value).toBe(99)
})
})
describe('readWidgetValue / writeWidgetValue', () => {
it('round-trips a value through the entity-id surface', () => {
const id = widgetEntityId(graphA, 1, 'seed')
ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(readWidgetValue(id)).toBe(11)
expect(writeWidgetValue(id, 22)).toBe(true)
expect(readWidgetValue(id)).toBe(22)
})
it('returns false when writing to an unregistered id', () => {
const id = widgetEntityId(graphA, 1, 'seed')
expect(writeWidgetValue(id, 22)).toBe(false)
})
it('returns undefined when reading an unregistered id', () => {
const id = widgetEntityId(graphA, 1, 'seed')
expect(readWidgetValue(id)).toBeUndefined()
expect(getWidgetState(id)).toBeUndefined()
})
})
describe('isolation', () => {
it('keeps independent values across distinct entity ids', () => {
const id1 = widgetEntityId(graphA, 1, 'seed')
const id2 = widgetEntityId(graphA, 2, 'seed')
const init = {
type: 'number',
value: 0,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(id1, init)
ensureWidgetState(id2, init)
writeWidgetValue(id1, 11)
writeWidgetValue(id2, 22)
expect(readWidgetValue(id1)).toBe(11)
expect(readWidgetValue(id2)).toBe(22)
})
it('isolates values across graph ids', () => {
const idA = widgetEntityId(graphA, 1, 'seed')
const idB = widgetEntityId(graphB, 1, 'seed')
const init = {
type: 'number',
value: 0,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(idA, init)
ensureWidgetState(idB, init)
writeWidgetValue(idA, 11)
writeWidgetValue(idB, 22)
expect(readWidgetValue(idA)).toBe(11)
expect(readWidgetValue(idB)).toBe(22)
})
it('matches the legacy triple-keyed API for the same widget', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const init = {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(id, init)
const viaLegacy = useWidgetValueStore().getWidget(graphA, '1', 'seed')
expect(viaLegacy).toBe(getWidgetState(id))
})
})
})

View File

@@ -1,42 +0,0 @@
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { parseWidgetEntityId } from './entityIds'
import type { WidgetEntityId } from './entityIds'
export function getWidgetState(
entityId: WidgetEntityId
): WidgetState | undefined {
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().getWidget(graphId, nodeId, name)
}
export function readWidgetValue(
entityId: WidgetEntityId
): WidgetState['value'] | undefined {
return getWidgetState(entityId)?.value
}
export function writeWidgetValue(
entityId: WidgetEntityId,
value: WidgetState['value']
): boolean {
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().setValue(graphId, nodeId, name, value)
}
type WidgetStateInit = Omit<WidgetState, 'nodeId' | 'name'>
export function ensureWidgetState(
entityId: WidgetEntityId,
init: WidgetStateInit
): WidgetState {
const existing = getWidgetState(entityId)
if (existing) return existing
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().registerWidget(graphId, {
...init,
nodeId,
name
})
}

View File

@@ -0,0 +1,19 @@
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends object = object
> {
value?: TValue
label?: string
disabled?: boolean
type: TType
options: TOptions
/**
* Whether the widget value is persisted in the workflow JSON
* (`widgets_values`). Distinct from `IWidgetOptions.serialize`, which
* controls whether the value is included in the API prompt sent for
* execution. See `src/lib/litegraph/docs/WIDGET_SERIALIZATION.md`.
* @default true
*/
serialize?: boolean
}