@bian-womp/spark-workbench 0.2.53 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/lib/cjs/index.cjs +383 -292
  2. package/lib/cjs/index.cjs.map +1 -1
  3. package/lib/cjs/src/core/AbstractWorkbench.d.ts +2 -0
  4. package/lib/cjs/src/core/AbstractWorkbench.d.ts.map +1 -1
  5. package/lib/cjs/src/core/InMemoryWorkbench.d.ts +9 -2
  6. package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
  7. package/lib/cjs/src/core/contracts.d.ts +3 -0
  8. package/lib/cjs/src/core/contracts.d.ts.map +1 -1
  9. package/lib/cjs/src/core/ui-extensions.d.ts +44 -47
  10. package/lib/cjs/src/core/ui-extensions.d.ts.map +1 -1
  11. package/lib/cjs/src/index.d.ts +1 -0
  12. package/lib/cjs/src/index.d.ts.map +1 -1
  13. package/lib/cjs/src/misc/DefaultContextMenu.d.ts +2 -12
  14. package/lib/cjs/src/misc/DefaultContextMenu.d.ts.map +1 -1
  15. package/lib/cjs/src/misc/NodeContextMenu.d.ts +2 -9
  16. package/lib/cjs/src/misc/NodeContextMenu.d.ts.map +1 -1
  17. package/lib/cjs/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  18. package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
  19. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts +41 -0
  20. package/lib/cjs/src/misc/context/ContextMenuHandlers.d.ts.map +1 -0
  21. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts +7 -0
  22. package/lib/cjs/src/misc/context/ContextMenuHelpers.d.ts.map +1 -0
  23. package/lib/cjs/src/misc/load.d.ts +5 -0
  24. package/lib/cjs/src/misc/load.d.ts.map +1 -0
  25. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts +3 -2
  26. package/lib/cjs/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  27. package/lib/cjs/src/runtime/IGraphRunner.d.ts +1 -0
  28. package/lib/cjs/src/runtime/IGraphRunner.d.ts.map +1 -1
  29. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts +1 -1
  30. package/lib/cjs/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  31. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts +0 -1
  32. package/lib/cjs/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  33. package/lib/esm/index.js +384 -295
  34. package/lib/esm/index.js.map +1 -1
  35. package/lib/esm/src/core/AbstractWorkbench.d.ts +2 -0
  36. package/lib/esm/src/core/AbstractWorkbench.d.ts.map +1 -1
  37. package/lib/esm/src/core/InMemoryWorkbench.d.ts +9 -2
  38. package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
  39. package/lib/esm/src/core/contracts.d.ts +3 -0
  40. package/lib/esm/src/core/contracts.d.ts.map +1 -1
  41. package/lib/esm/src/core/ui-extensions.d.ts +44 -47
  42. package/lib/esm/src/core/ui-extensions.d.ts.map +1 -1
  43. package/lib/esm/src/index.d.ts +1 -0
  44. package/lib/esm/src/index.d.ts.map +1 -1
  45. package/lib/esm/src/misc/DefaultContextMenu.d.ts +2 -12
  46. package/lib/esm/src/misc/DefaultContextMenu.d.ts.map +1 -1
  47. package/lib/esm/src/misc/NodeContextMenu.d.ts +2 -9
  48. package/lib/esm/src/misc/NodeContextMenu.d.ts.map +1 -1
  49. package/lib/esm/src/misc/WorkbenchCanvas.d.ts.map +1 -1
  50. package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
  51. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts +41 -0
  52. package/lib/esm/src/misc/context/ContextMenuHandlers.d.ts.map +1 -0
  53. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts +7 -0
  54. package/lib/esm/src/misc/context/ContextMenuHelpers.d.ts.map +1 -0
  55. package/lib/esm/src/misc/load.d.ts +5 -0
  56. package/lib/esm/src/misc/load.d.ts.map +1 -0
  57. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts +3 -2
  58. package/lib/esm/src/runtime/AbstractGraphRunner.d.ts.map +1 -1
  59. package/lib/esm/src/runtime/IGraphRunner.d.ts +1 -0
  60. package/lib/esm/src/runtime/IGraphRunner.d.ts.map +1 -1
  61. package/lib/esm/src/runtime/LocalGraphRunner.d.ts +1 -1
  62. package/lib/esm/src/runtime/LocalGraphRunner.d.ts.map +1 -1
  63. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts +0 -1
  64. package/lib/esm/src/runtime/RemoteGraphRunner.d.ts.map +1 -1
  65. package/package.json +4 -4
package/lib/esm/index.js CHANGED
@@ -1,80 +1,84 @@
1
- import { GraphBuilder, createEngine, StepEngine, PullEngine, BatchedEngine, isTypedOutput, getTypedOutputValue, getTypedOutputTypeId, isInputPrivate, getInputTypeId, createSimpleGraphRegistry, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
1
+ import { generateId, GraphBuilder, createEngine, StepEngine, PullEngine, BatchedEngine, isTypedOutput, getTypedOutputValue, getTypedOutputTypeId, isInputPrivate, getInputTypeId, createSimpleGraphRegistry, createSimpleGraphDef, createAsyncGraphDef, createAsyncGraphRegistry, createProgressGraphDef, createProgressGraphRegistry, createValidationGraphDef, createValidationGraphRegistry } from '@bian-womp/spark-graph';
2
2
  import { RuntimeApiClient } from '@bian-womp/spark-remote';
3
3
  import { Position, Handle, useUpdateNodeInternals, useReactFlow, ReactFlowProvider, ReactFlow, Background, BackgroundVariant, MiniMap, Controls } from '@xyflow/react';
4
4
  import React, { useCallback, useState, useRef, useEffect, useMemo, createContext, useContext, useImperativeHandle } from 'react';
5
5
  import cx from 'classnames';
6
6
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
7
- import { XCircleIcon, WarningCircleIcon, CopyIcon, TrashIcon, XIcon, ArrowClockwiseIcon, ClockClockwiseIcon, StopIcon, PlayIcon, PlugsConnectedIcon, WifiHighIcon, WifiSlashIcon, PlayPauseIcon, LightningIcon, TreeStructureIcon, CornersOutIcon, DownloadSimpleIcon, DownloadIcon, UploadIcon, BugBeetleIcon, ListBulletsIcon } from '@phosphor-icons/react';
7
+ import { XCircleIcon, WarningCircleIcon, CopyIcon, TrashIcon, XIcon, ArrowClockwiseIcon, ClockClockwiseIcon, StopIcon, PlayIcon, PlugsConnectedIcon, WifiHighIcon, WifiSlashIcon, PlayPauseIcon, LightningIcon, TreeStructureIcon, CornersOutIcon, DownloadIcon, UploadIcon, BugBeetleIcon, ListBulletsIcon } from '@phosphor-icons/react';
8
8
  import isEqual from 'lodash/isEqual';
9
9
 
10
10
  class DefaultUIExtensionRegistry {
11
11
  constructor() {
12
12
  this.nodeRenderers = new Map();
13
- this.portRenderers = new Map();
14
- this.edgeRenderers = new Map();
15
13
  }
16
14
  registerNodeRenderer(nodeTypeId, renderer) {
17
- this.nodeRenderers.set(nodeTypeId, renderer);
15
+ if (renderer === undefined) {
16
+ this.nodeRenderers.delete(nodeTypeId);
17
+ }
18
+ else {
19
+ this.nodeRenderers.set(nodeTypeId, renderer);
20
+ }
18
21
  return this;
19
22
  }
20
23
  getNodeRenderer(nodeTypeId) {
21
24
  return this.nodeRenderers.get(nodeTypeId);
22
25
  }
23
- registerPortRenderer(dataTypeId, renderer) {
24
- this.portRenderers.set(dataTypeId, renderer);
25
- return this;
26
- }
27
- getPortRenderer(dataTypeId) {
28
- return this.portRenderers.get(dataTypeId);
26
+ getAllNodeRenderers() {
27
+ const result = {};
28
+ for (const [nodeTypeId, renderer] of this.nodeRenderers.entries()) {
29
+ result[nodeTypeId] = renderer;
30
+ }
31
+ return result;
29
32
  }
30
- registerEdgeRenderer(typeId, renderer) {
31
- this.edgeRenderers.set(typeId, renderer);
33
+ registerIconProvider(provider) {
34
+ this.iconProvider = provider;
32
35
  return this;
33
36
  }
34
- getEdgeRenderer(typeId) {
35
- return this.edgeRenderers.get(typeId);
37
+ getIconProvider() {
38
+ return this.iconProvider;
36
39
  }
37
- setInspector(renderer) {
38
- this.inspector = renderer;
40
+ // React Flow renderers
41
+ registerConnectionLineRenderer(renderer) {
42
+ this.connectionLineRenderer = renderer;
39
43
  return this;
40
44
  }
41
- getInspector() {
42
- return this.inspector;
45
+ getConnectionLineRenderer() {
46
+ return this.connectionLineRenderer;
43
47
  }
44
- setPalette(renderer) {
45
- this.palette = renderer;
48
+ registerMinimapRenderer(renderer) {
49
+ this.minimapRenderer = renderer;
46
50
  return this;
47
51
  }
48
- getPalette() {
49
- return this.palette;
52
+ getMinimapRenderer() {
53
+ return this.minimapRenderer;
50
54
  }
51
- setToolbar(renderer) {
52
- this.toolbar = renderer;
55
+ registerControlsRenderer(renderer) {
56
+ this.controlsRenderer = renderer;
53
57
  return this;
54
58
  }
55
- getToolbar() {
56
- return this.toolbar;
59
+ getControlsRenderer() {
60
+ return this.controlsRenderer;
57
61
  }
58
- setContextMenu(renderer) {
59
- this.contextMenu = renderer;
62
+ registerBackgroundRenderer(renderer) {
63
+ this.backgroundRenderer = renderer;
60
64
  return this;
61
65
  }
62
- getContextMenu() {
63
- return this.contextMenu;
66
+ getBackgroundRenderer() {
67
+ return this.backgroundRenderer;
64
68
  }
65
- setMiniMap(renderer) {
66
- this.miniMap = renderer;
69
+ registerDefaultContextMenuRenderer(renderer) {
70
+ this.defaultContextMenuRenderer = renderer;
67
71
  return this;
68
72
  }
69
- getMiniMap() {
70
- return this.miniMap;
73
+ getDefaultContextMenuRenderer() {
74
+ return this.defaultContextMenuRenderer;
71
75
  }
72
- setIconProvider(provider) {
73
- this.iconProvider = provider;
76
+ registerNodeContextMenuRenderer(renderer) {
77
+ this.nodeContextMenuRenderer = renderer;
74
78
  return this;
75
79
  }
76
- getIconProvider() {
77
- return this.iconProvider;
80
+ getNodeContextMenuRenderer() {
81
+ return this.nodeContextMenuRenderer;
78
82
  }
79
83
  }
80
84
 
@@ -84,6 +88,7 @@ class AbstractWorkbench {
84
88
  this.layout = args.layout;
85
89
  this.storage = args.storage;
86
90
  this.serializer = args.serializer;
91
+ this.genId = args.genId || generateId;
87
92
  }
88
93
  // Expose UI registry to adapters (React Flow, CLI) to allow overrides
89
94
  getUI() {
@@ -112,6 +117,13 @@ class InMemoryWorkbench extends AbstractWorkbench {
112
117
  const { positions } = await this.layout.layout(this.def);
113
118
  this.positions = positions;
114
119
  }
120
+ const defNodeIds = new Set(this.def.nodes.map((n) => n.nodeId));
121
+ const defEdgeIds = new Set(this.def.edges.map((e) => e.id));
122
+ const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
123
+ const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
124
+ const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
125
+ this.positions = filteredPositions;
126
+ this.selection = { nodes: filteredNodes, edges: filteredEdges };
115
127
  this.emit("graphChanged", { def: this.def });
116
128
  this.refreshValidation();
117
129
  }
@@ -155,11 +167,14 @@ class InMemoryWorkbench extends AbstractWorkbench {
155
167
  return { ok: issues.every((i) => i.level !== "error"), issues };
156
168
  }
157
169
  addNode(node) {
158
- const id = node.nodeId ?? this.generateId("n");
170
+ const id = node.nodeId ??
171
+ this.genId("n", new Set(this.def.nodes.map((n) => n.nodeId)));
159
172
  this.def.nodes.push({
160
173
  nodeId: id,
161
174
  typeId: node.typeId,
162
175
  params: node.params,
176
+ initialInputs: node.initialInputs,
177
+ resolvedHandles: node.resolvedHandles,
163
178
  });
164
179
  if (node.position)
165
180
  this.positions[id] = node.position;
@@ -181,7 +196,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
181
196
  this.refreshValidation();
182
197
  }
183
198
  connect(edge) {
184
- const id = edge.id ?? this.generateId("e");
199
+ const id = edge.id ?? this.genId("e", new Set(this.def.edges.map((e) => e.id)));
185
200
  this.def.edges.push({
186
201
  id,
187
202
  source: { ...edge.source },
@@ -228,29 +243,32 @@ class InMemoryWorkbench extends AbstractWorkbench {
228
243
  });
229
244
  }
230
245
  // Position and selection APIs for React Flow bridge
231
- setPosition(nodeId, pos) {
246
+ setPosition(nodeId, pos, opts) {
232
247
  this.positions[nodeId] = pos;
233
248
  this.emit("graphUiChanged", {
234
249
  def: this.def,
235
250
  change: { type: "moveNode", nodeId, pos },
251
+ commit: !!opts?.commit === true,
236
252
  });
237
253
  }
238
- setPositions(map) {
254
+ setPositions(map, opts) {
239
255
  this.positions = { ...map };
240
256
  this.emit("graphUiChanged", {
241
257
  def: this.def,
242
258
  change: { type: "moveNodes" },
259
+ commit: opts?.commit,
243
260
  });
244
261
  }
245
262
  getPositions() {
246
263
  return { ...this.positions };
247
264
  }
248
- setSelection(sel) {
265
+ setSelection(sel, opts) {
249
266
  this.selection = { nodes: [...sel.nodes], edges: [...sel.edges] };
250
267
  this.emit("selectionChanged", this.selection);
251
268
  this.emit("graphUiChanged", {
252
269
  def: this.def,
253
270
  change: { type: "selection" },
271
+ commit: opts?.commit,
254
272
  });
255
273
  }
256
274
  getSelection() {
@@ -259,21 +277,31 @@ class InMemoryWorkbench extends AbstractWorkbench {
259
277
  edges: [...this.selection.edges],
260
278
  };
261
279
  }
262
- setViewport(viewport) {
280
+ setViewport(viewport, opts) {
263
281
  this.viewport = { ...viewport };
282
+ this.emit("graphUiChanged", {
283
+ def: this.def,
284
+ change: { type: "viewport" },
285
+ commit: opts?.commit,
286
+ });
264
287
  }
265
288
  getViewport() {
266
289
  return this.viewport ? { ...this.viewport } : null;
267
290
  }
268
291
  getUIState() {
292
+ const defNodeIds = new Set(this.def.nodes.map((n) => n.nodeId));
293
+ const defEdgeIds = new Set(this.def.edges.map((e) => e.id));
294
+ const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
295
+ const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
296
+ const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
269
297
  return {
270
- positions: Object.keys(this.positions).length > 0
271
- ? { ...this.positions }
298
+ positions: Object.keys(filteredPositions).length > 0
299
+ ? filteredPositions
272
300
  : undefined,
273
- selection: this.selection.nodes.length > 0 || this.selection.edges.length > 0
301
+ selection: filteredNodes.length > 0 || filteredEdges.length > 0
274
302
  ? {
275
- nodes: [...this.selection.nodes],
276
- edges: [...this.selection.edges],
303
+ nodes: filteredNodes,
304
+ edges: filteredEdges,
277
305
  }
278
306
  : undefined,
279
307
  viewport: this.viewport ? { ...this.viewport } : undefined,
@@ -309,9 +337,6 @@ class InMemoryWorkbench extends AbstractWorkbench {
309
337
  for (const h of Array.from(set))
310
338
  h(payload);
311
339
  }
312
- generateId(prefix) {
313
- return `${prefix}${Math.random().toString(36).slice(2, 8)}`;
314
- }
315
340
  }
316
341
 
317
342
  class CLIWorkbench {
@@ -443,6 +468,18 @@ class AbstractGraphRunner {
443
468
  }
444
469
  }
445
470
  }
471
+ getInputDefaults(def) {
472
+ const out = {};
473
+ for (const n of def.nodes) {
474
+ const dynDefaults = n.resolvedHandles?.inputDefaults ?? {};
475
+ const graphDefaults = n.initialInputs ?? {};
476
+ const merged = { ...dynDefaults, ...graphDefaults };
477
+ if (Object.keys(merged).length > 0) {
478
+ out[n.nodeId] = merged;
479
+ }
480
+ }
481
+ return out;
482
+ }
446
483
  on(event, handler) {
447
484
  if (!this.listeners.has(event))
448
485
  this.listeners.set(event, new Set());
@@ -604,16 +641,6 @@ class LocalGraphRunner extends AbstractGraphRunner {
604
641
  }
605
642
  return out;
606
643
  }
607
- getInputDefaults(def) {
608
- const out = {};
609
- for (const n of def.nodes) {
610
- const dynDefaults = n.resolvedHandles?.inputDefaults ?? {};
611
- if (Object.keys(dynDefaults).length > 0) {
612
- out[n.nodeId] = dynDefaults;
613
- }
614
- }
615
- return out;
616
- }
617
644
  async snapshotFull() {
618
645
  const def = undefined; // UI will supply def/positions on download for local
619
646
  const inputs = this.getInputs(this.runtime
@@ -641,11 +668,25 @@ class LocalGraphRunner extends AbstractGraphRunner {
641
668
  if (payload.def)
642
669
  this.build(payload.def);
643
670
  this.setEnvironment?.(payload.environment || {}, { merge: false });
644
- // Hydrate via runtime for exact restore and re-emit
671
+ this.hydrateSnapshotFull(payload);
672
+ }
673
+ hydrateSnapshotFull(snapshot) {
674
+ // Hydrate via runtime for exact restore (this emits events on runtime emitter)
645
675
  this.runtime?.hydrate({
646
- inputs: payload.inputs || {},
647
- outputs: payload.outputs || {},
676
+ inputs: snapshot.inputs || {},
677
+ outputs: snapshot.outputs || {},
648
678
  });
679
+ // Also emit directly from runner to ensure UI gets events even if engine isn't running
680
+ for (const [nodeId, map] of Object.entries(snapshot.inputs || {})) {
681
+ for (const [handle, value] of Object.entries(map || {})) {
682
+ this.emit("value", { nodeId, handle, value, io: "input" });
683
+ }
684
+ }
685
+ for (const [nodeId, map] of Object.entries(snapshot.outputs || {})) {
686
+ for (const [handle, value] of Object.entries(map || {})) {
687
+ this.emit("value", { nodeId, handle, value, io: "output" });
688
+ }
689
+ }
649
690
  }
650
691
  dispose() {
651
692
  super.dispose();
@@ -840,10 +881,13 @@ class RemoteGraphRunner extends AbstractGraphRunner {
840
881
  // Auto-fetch registry on first connection (only once)
841
882
  if (!this.registryFetched && !this.registryFetching) {
842
883
  // Log loading state (UI can listen to transport status for loading indication)
843
- console.info("Loading registry from remote...");
844
- this.fetchRegistry(client).catch((err) => {
884
+ console.info("[RemoteGraphRunner] Loading registry from remote...");
885
+ this.fetchRegistry(client)
886
+ .then(() => {
887
+ console.info("[RemoteGraphRunner] Loaded registry from remote");
888
+ })
889
+ .catch((err) => {
845
890
  console.error("[RemoteGraphRunner] Failed to fetch registry:", err);
846
- // Error handling is done inside fetchRegistry, but we catch unhandled rejections
847
891
  });
848
892
  }
849
893
  // Clear promise on success
@@ -1199,16 +1243,6 @@ class RemoteGraphRunner extends AbstractGraphRunner {
1199
1243
  }
1200
1244
  return out;
1201
1245
  }
1202
- getInputDefaults(def) {
1203
- const out = {};
1204
- for (const n of def.nodes) {
1205
- const dynDefaults = n.resolvedHandles?.inputDefaults ?? {};
1206
- if (Object.keys(dynDefaults).length > 0) {
1207
- out[n.nodeId] = dynDefaults;
1208
- }
1209
- }
1210
- return out;
1211
- }
1212
1246
  dispose() {
1213
1247
  // Idempotent: allow multiple calls safely
1214
1248
  if (this.disposed)
@@ -1477,10 +1511,10 @@ function useWorkbenchBridge(wb) {
1477
1511
  });
1478
1512
  }, [wb]);
1479
1513
  const onNodesChange = useCallback((changes) => {
1480
- // Apply position updates
1514
+ // Apply position updates continuously, but mark commit only on drag end
1481
1515
  changes.forEach((c) => {
1482
1516
  if (c.type === "position" && c.position) {
1483
- wb.setPosition(c.id, c.position);
1517
+ wb.setPosition(c.id, c.position, { commit: !c.dragging });
1484
1518
  }
1485
1519
  });
1486
1520
  // Derive next node selection from change set
@@ -1954,6 +1988,99 @@ function getHandleClassName(args) {
1954
1988
  return cx("!w-3 !h-3 !bg-white !dark:bg-stone-900", borderColor, kind === "output" && "!rounded-none");
1955
1989
  }
1956
1990
 
1991
+ function generateTimestamp() {
1992
+ const d = new Date();
1993
+ const pad = (n) => String(n).padStart(2, "0");
1994
+ return `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
1995
+ }
1996
+ function downloadJSON(payload, filename) {
1997
+ const pretty = JSON.stringify(payload, null, 2);
1998
+ const blob = new Blob([pretty], { type: "application/json" });
1999
+ const url = URL.createObjectURL(blob);
2000
+ const a = document.createElement("a");
2001
+ a.href = url;
2002
+ a.download = filename;
2003
+ document.body.appendChild(a);
2004
+ a.click();
2005
+ a.remove();
2006
+ URL.revokeObjectURL(url);
2007
+ }
2008
+ function isSnapshotPayload(parsed) {
2009
+ return (parsed !== null &&
2010
+ typeof parsed === "object" &&
2011
+ ("def" in parsed ||
2012
+ "inputs" in parsed ||
2013
+ "outputs" in parsed ||
2014
+ "environment" in parsed));
2015
+ }
2016
+ async function download(wb, runner) {
2017
+ try {
2018
+ const def = wb.export();
2019
+ const uiState = wb.getUIState();
2020
+ let snapshot;
2021
+ if (runner.isRunning()) {
2022
+ const fullSnapshot = await runner.snapshotFull();
2023
+ snapshot = {
2024
+ ...fullSnapshot,
2025
+ def,
2026
+ extData: {
2027
+ ...(fullSnapshot.extData || {}),
2028
+ ui: uiState,
2029
+ },
2030
+ };
2031
+ }
2032
+ else {
2033
+ const inputs = runner.getInputs(def);
2034
+ snapshot = {
2035
+ def,
2036
+ inputs,
2037
+ outputs: {},
2038
+ environment: {},
2039
+ extData: { ui: uiState },
2040
+ };
2041
+ }
2042
+ downloadJSON(snapshot, `spark-snapshot-${generateTimestamp()}.json`);
2043
+ }
2044
+ catch (err) {
2045
+ const message = err instanceof Error ? err.message : String(err);
2046
+ throw new Error(`Failed to download snapshot: ${message}`);
2047
+ }
2048
+ }
2049
+ async function upload(parsed, wb, runner) {
2050
+ if (!isSnapshotPayload(parsed)) {
2051
+ throw new Error("Invalid snapshot format - expected RuntimeSnapshotFull");
2052
+ }
2053
+ const def = parsed.def;
2054
+ const environment = parsed.environment || {};
2055
+ const inputs = parsed.inputs || {};
2056
+ const outputs = parsed.outputs || {};
2057
+ const extData = parsed.extData || {};
2058
+ if (!def) {
2059
+ throw new Error("Graph definition is empty");
2060
+ }
2061
+ await wb.load(def);
2062
+ if (extData.ui && typeof extData.ui === "object") {
2063
+ wb.setUIState(extData.ui);
2064
+ }
2065
+ if (runner.isRunning()) {
2066
+ await runner.applySnapshotFull({
2067
+ def,
2068
+ environment,
2069
+ inputs,
2070
+ outputs,
2071
+ extData,
2072
+ });
2073
+ }
2074
+ else {
2075
+ runner.build(wb.export());
2076
+ if (inputs && typeof inputs === "object") {
2077
+ for (const [nodeId, map] of Object.entries(inputs)) {
2078
+ runner.setInputs(nodeId, map);
2079
+ }
2080
+ }
2081
+ }
2082
+ }
2083
+
1957
2084
  const WorkbenchContext = createContext(null);
1958
2085
  function useWorkbenchContext() {
1959
2086
  const ctx = useContext(WorkbenchContext);
@@ -2139,7 +2266,7 @@ function WorkbenchProvider({ wb, runner, registry, setRegistry, overrides, uiVer
2139
2266
  }
2140
2267
  curX += maxWidth + H_GAP;
2141
2268
  }
2142
- wb.setPositions(pos);
2269
+ wb.setPositions(pos, { commit: true });
2143
2270
  }, [wb, registry, overrides?.getDefaultNodeSize]);
2144
2271
  const updateEdgeType = useCallback((edgeId, typeId) => wb.updateEdgeType(edgeId, typeId), [wb]);
2145
2272
  const triggerExternal = useCallback((nodeId, event) => runner.triggerExternal(nodeId, event), [runner]);
@@ -3096,15 +3223,13 @@ function DefaultNodeContent({ data, isConnectable, }) {
3096
3223
  } })] }));
3097
3224
  }
3098
3225
 
3099
- function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3100
- const { registry } = useWorkbenchContext();
3226
+ function DefaultContextMenu({ open, clientPos, handlers, registry, nodeIds, }) {
3101
3227
  const rf = useReactFlow();
3102
- const ids = Array.from(registry.nodes.keys());
3103
3228
  const [query, setQuery] = useState("");
3104
3229
  const q = query.trim().toLowerCase();
3105
3230
  const filteredIds = q
3106
- ? ids.filter((id) => id.toLowerCase().includes(q))
3107
- : ids;
3231
+ ? nodeIds.filter((id) => id.toLowerCase().includes(q))
3232
+ : nodeIds;
3108
3233
  const root = { __children: {} };
3109
3234
  for (const id of filteredIds) {
3110
3235
  const parts = id.split(".");
@@ -3128,11 +3253,11 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3128
3253
  if (!ref.current)
3129
3254
  return;
3130
3255
  if (!ref.current.contains(e.target))
3131
- onClose();
3256
+ handlers.onClose();
3132
3257
  };
3133
3258
  const onKey = (e) => {
3134
3259
  if (e.key === "Escape")
3135
- onClose();
3260
+ handlers.onClose();
3136
3261
  };
3137
3262
  window.addEventListener("mousedown", onDown, true);
3138
3263
  window.addEventListener("keydown", onKey);
@@ -3140,7 +3265,7 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3140
3265
  window.removeEventListener("mousedown", onDown, true);
3141
3266
  window.removeEventListener("keydown", onKey);
3142
3267
  };
3143
- }, [open, onClose]);
3268
+ }, [open, handlers]);
3144
3269
  // Focus search input when menu opens
3145
3270
  const inputRef = useRef(null);
3146
3271
  useEffect(() => {
@@ -3159,8 +3284,8 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3159
3284
  const handleClick = (typeId) => {
3160
3285
  // project() is deprecated; use screenToFlowPosition for screen coordinates
3161
3286
  const p = rf.screenToFlowPosition({ x: clientPos.x, y: clientPos.y });
3162
- onAdd(typeId, p);
3163
- onClose();
3287
+ handlers.onAddNode(typeId, { position: p });
3288
+ handlers.onClose();
3164
3289
  };
3165
3290
  const renderTree = (tree, path = []) => {
3166
3291
  const entries = Object.entries(tree?.__children ?? {}).sort((a, b) => a[0].localeCompare(b[0]));
@@ -3182,8 +3307,7 @@ function DefaultContextMenu({ open, clientPos, onAdd, onClose, }) {
3182
3307
  }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Add Node", " ", jsxs("span", { className: "text-gray-500 font-normal", children: ["(", totalCount, ")"] })] }), jsx("div", { className: "px-2 pb-1", children: jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Filter nodes...", className: "w-full border border-gray-300 px-2 py-1 text-sm outline-none focus:border-gray-400", onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation() }) }), jsx("div", { className: "max-h-60 overflow-auto", children: totalCount > 0 ? (renderTree(root)) : (jsx("div", { className: "px-3 py-2 text-gray-400", children: "No matches" })) })] }));
3183
3308
  }
3184
3309
 
3185
- function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3186
- const { wb, runner, engineKind, registry, outputsMap, outputTypesMap } = useWorkbenchContext();
3310
+ function NodeContextMenu({ open, clientPos, nodeId, handlers, canRunPull, bakeableOutputs, }) {
3187
3311
  const ref = useRef(null);
3188
3312
  // outside click + ESC
3189
3313
  useEffect(() => {
@@ -3193,11 +3317,11 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3193
3317
  if (!ref.current)
3194
3318
  return;
3195
3319
  if (!ref.current.contains(e.target))
3196
- onClose();
3320
+ handlers.onClose();
3197
3321
  };
3198
3322
  const onKey = (e) => {
3199
3323
  if (e.key === "Escape")
3200
- onClose();
3324
+ handlers.onClose();
3201
3325
  };
3202
3326
  window.addEventListener("mousedown", onDown, true);
3203
3327
  window.addEventListener("keydown", onKey);
@@ -3205,48 +3329,26 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3205
3329
  window.removeEventListener("mousedown", onDown, true);
3206
3330
  window.removeEventListener("keydown", onKey);
3207
3331
  };
3208
- }, [open, onClose]);
3332
+ }, [open, handlers]);
3209
3333
  useEffect(() => {
3210
3334
  if (open)
3211
3335
  ref.current?.focus();
3212
3336
  }, [open]);
3213
- // Bake helpers
3214
- const getBakeableOutputs = () => {
3215
- try {
3216
- const def = wb.export();
3217
- const node = def.nodes.find((n) => n.nodeId === nodeId);
3218
- if (!node)
3219
- return [];
3220
- const desc = registry.nodes.get(node.typeId);
3221
- const handles = Object.keys(desc?.outputs || {});
3222
- const out = [];
3223
- for (const h of handles) {
3224
- const tId = outputTypesMap?.[nodeId]?.[h];
3225
- if (!tId)
3226
- continue;
3227
- if (tId.endsWith("[]")) {
3228
- const base = tId.slice(0, -2);
3229
- const tArr = registry.types.get(tId);
3230
- const tElem = registry.types.get(base);
3231
- const arrT = tArr?.bakeTarget;
3232
- const elemT = tElem?.bakeTarget;
3233
- if ((arrT && registry.nodes.has(arrT.nodeTypeId)) ||
3234
- (elemT && registry.nodes.has(elemT.nodeTypeId)))
3235
- out.push(h);
3236
- }
3237
- else {
3238
- const t = registry.types.get(tId);
3239
- const bt = t?.bakeTarget;
3240
- if (bt && registry.nodes.has(bt.nodeTypeId))
3241
- out.push(h);
3242
- }
3243
- }
3244
- return out;
3245
- }
3246
- catch {
3247
- return [];
3248
- }
3249
- };
3337
+ if (!open || !clientPos || !nodeId)
3338
+ return null;
3339
+ // clamp
3340
+ const MENU_MIN_WIDTH = 180;
3341
+ const PADDING = 16;
3342
+ const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
3343
+ (MENU_MIN_WIDTH + PADDING));
3344
+ const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
3345
+ return (jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3346
+ e.preventDefault();
3347
+ e.stopPropagation();
3348
+ }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDelete, children: "Delete" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onDuplicate, children: "Duplicate" }), canRunPull && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onRunPull, children: "Run (pull)" })), jsx("div", { className: "h-px bg-gray-200 my-1" }), bakeableOutputs.length > 0 && (jsxs(Fragment, { children: [jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), bakeableOutputs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: () => handlers.onBake(h), children: ["Bake: ", h] }, h))), jsx("div", { className: "h-px bg-gray-200 my-1" })] })), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handlers.onCopyId, children: "Copy Node ID" })] }));
3349
+ }
3350
+
3351
+ function createNodeContextMenuHandlers(nodeId, wb, runner, registry, outputsMap, outputTypesMap, onClose) {
3250
3352
  const doBake = async (handleId) => {
3251
3353
  try {
3252
3354
  const typeId = outputTypesMap?.[nodeId]?.[handleId];
@@ -3279,7 +3381,6 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3279
3381
  const newId = wb.addNode({
3280
3382
  typeId: singleTarget.nodeTypeId,
3281
3383
  position: { x: pos.x + 180, y: pos.y },
3282
- params: {},
3283
3384
  });
3284
3385
  runner.update(wb.export());
3285
3386
  await runner.whenIdle();
@@ -3290,12 +3391,9 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3290
3391
  const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
3291
3392
  const inType = getInputTypeId(nodeDesc?.inputs, arrTarget.inputHandle);
3292
3393
  const coerced = await coerceIfNeeded(typeId, inType, unwrap(raw));
3293
- const newId = `n${Math.random().toString(36).slice(2, 8)}`;
3294
- wb.addNode({
3295
- nodeId: newId,
3394
+ const newId = wb.addNode({
3296
3395
  typeId: arrTarget.nodeTypeId,
3297
3396
  position: { x: pos.x + 180, y: pos.y },
3298
- params: {},
3299
3397
  });
3300
3398
  runner.update(wb.export());
3301
3399
  await runner.whenIdle();
@@ -3313,14 +3411,11 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3313
3411
  const DY = 160;
3314
3412
  const nodeIds = [];
3315
3413
  for (let idx = 0; idx < coercedItems.length; idx++) {
3316
- const cv = coercedItems[idx];
3317
3414
  const col = idx % COLS;
3318
3415
  const row = Math.floor(idx / COLS);
3319
3416
  const newId = wb.addNode({
3320
3417
  typeId: elemTarget.nodeTypeId,
3321
3418
  position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
3322
- params: {},
3323
- initialInputs: { [elemTarget.inputHandle]: structuredClone(cv) },
3324
3419
  });
3325
3420
  nodeIds.push(newId);
3326
3421
  }
@@ -3338,63 +3433,88 @@ function NodeContextMenu({ open, clientPos, nodeId, onClose, }) {
3338
3433
  }
3339
3434
  catch { }
3340
3435
  };
3341
- // actions
3342
- const handleDelete = useCallback(() => {
3343
- wb.removeNode(nodeId);
3344
- onClose();
3345
- }, [nodeId, wb, onClose]);
3346
- const handleDuplicate = useCallback(() => {
3436
+ return {
3437
+ onDelete: () => {
3438
+ wb.removeNode(nodeId);
3439
+ onClose();
3440
+ },
3441
+ onDuplicate: async () => {
3442
+ const def = wb.export();
3443
+ const n = def.nodes.find((n) => n.nodeId === nodeId);
3444
+ if (!n)
3445
+ return onClose();
3446
+ const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
3447
+ const newId = wb.addNode({
3448
+ typeId: n.typeId,
3449
+ params: n.params,
3450
+ position: { x: pos.x + 24, y: pos.y + 24 },
3451
+ initialInputs: n.initialInputs,
3452
+ resolvedHandles: n.resolvedHandles,
3453
+ });
3454
+ await runner.whenIdle();
3455
+ runner.setInputs(newId, { ...runner.getInputs(def)[nodeId] });
3456
+ onClose();
3457
+ },
3458
+ onRunPull: async () => {
3459
+ try {
3460
+ await runner.computeNode(nodeId);
3461
+ }
3462
+ catch { }
3463
+ onClose();
3464
+ },
3465
+ onBake: async (handleId) => {
3466
+ await doBake(handleId);
3467
+ onClose();
3468
+ },
3469
+ onCopyId: async () => {
3470
+ try {
3471
+ await navigator.clipboard.writeText(nodeId);
3472
+ }
3473
+ catch { }
3474
+ onClose();
3475
+ },
3476
+ onClose,
3477
+ };
3478
+ }
3479
+ function getBakeableOutputs(nodeId, wb, registry, outputTypesMap) {
3480
+ try {
3347
3481
  const def = wb.export();
3348
- const n = def.nodes.find((n) => n.nodeId === nodeId);
3349
- if (!n)
3350
- return onClose();
3351
- const pos = wb.getPositions?.()[nodeId] || { x: 0, y: 0 };
3352
- wb.addNode({
3353
- typeId: n.typeId,
3354
- params: n.params,
3355
- position: { x: pos.x + 24, y: pos.y + 24 },
3356
- });
3357
- onClose();
3358
- }, [nodeId, wb, onClose]);
3359
- useCallback(async (handleId) => {
3360
- await doBake(handleId);
3361
- onClose();
3362
- }, [doBake, onClose]);
3363
- const handleCopyId = useCallback(async () => {
3364
- try {
3365
- await navigator.clipboard.writeText(nodeId);
3366
- }
3367
- catch { }
3368
- onClose();
3369
- }, [nodeId, onClose]);
3370
- const handleRunPull = useCallback(async () => {
3371
- try {
3372
- await runner.computeNode(nodeId);
3482
+ const node = def.nodes.find((n) => n.nodeId === nodeId);
3483
+ if (!node)
3484
+ return [];
3485
+ const desc = registry.nodes.get(node.typeId);
3486
+ const handles = Object.keys(desc?.outputs || {});
3487
+ const out = [];
3488
+ for (const h of handles) {
3489
+ const tId = outputTypesMap?.[nodeId]?.[h];
3490
+ if (!tId)
3491
+ continue;
3492
+ if (tId.endsWith("[]")) {
3493
+ const base = tId.slice(0, -2);
3494
+ const tArr = registry.types.get(tId);
3495
+ const tElem = registry.types.get(base);
3496
+ const arrT = tArr?.bakeTarget;
3497
+ const elemT = tElem?.bakeTarget;
3498
+ if ((arrT && registry.nodes.has(arrT.nodeTypeId)) ||
3499
+ (elemT && registry.nodes.has(elemT.nodeTypeId)))
3500
+ out.push(h);
3501
+ }
3502
+ else {
3503
+ const t = registry.types.get(tId);
3504
+ const bt = t?.bakeTarget;
3505
+ if (bt && registry.nodes.has(bt.nodeTypeId))
3506
+ out.push(h);
3507
+ }
3373
3508
  }
3374
- catch { }
3375
- onClose();
3376
- }, [nodeId, runner, onClose]);
3377
- if (!open || !clientPos || !nodeId)
3378
- return null;
3379
- // clamp
3380
- const MENU_MIN_WIDTH = 180;
3381
- const PADDING = 16;
3382
- const x = Math.min(clientPos.x, (typeof window !== "undefined" ? window.innerWidth : 0) -
3383
- (MENU_MIN_WIDTH + PADDING));
3384
- const y = Math.min(clientPos.y, (typeof window !== "undefined" ? window.innerHeight : 0) - 240);
3385
- const canRunPull = engineKind()?.toString() === "pull";
3386
- const outs = getBakeableOutputs();
3387
- return (jsxs("div", { ref: ref, tabIndex: -1, className: "fixed z-[1000] bg-white border border-gray-300 rounded-lg shadow-lg p-1 min-w-[180px] text-sm text-gray-700", style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), onWheel: (e) => e.stopPropagation(), onContextMenu: (e) => {
3388
- e.preventDefault();
3389
- e.stopPropagation();
3390
- }, children: [jsxs("div", { className: "px-2 py-1 font-semibold text-gray-700", children: ["Node (", nodeId, ")"] }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDelete, children: "Delete" }), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleDuplicate, children: "Duplicate" }), canRunPull && (jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleRunPull, children: "Run (pull)" })), jsx("div", { className: "h-px bg-gray-200 my-1" }), outs.length > 0 && (jsxs(Fragment, { children: [jsx("div", { className: "px-2 py-1 font-semibold text-gray-700", children: "Bake" }), outs.map((h) => (jsxs("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: async () => {
3391
- await doBake(h);
3392
- onClose();
3393
- }, children: ["Bake: ", h] }, h))), jsx("div", { className: "h-px bg-gray-200 my-1" })] })), jsx("button", { className: "block w-full text-left px-2 py-1 hover:bg-gray-100", onClick: handleCopyId, children: "Copy Node ID" })] }));
3509
+ return out;
3510
+ }
3511
+ catch {
3512
+ return [];
3513
+ }
3394
3514
  }
3395
3515
 
3396
3516
  const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, getDefaultNodeSize }, ref) => {
3397
- const { wb, registry, inputsMap, inputDefaultsMap, outputsMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, uiVersion, } = useWorkbenchContext();
3517
+ const { wb, registry, inputsMap, inputDefaultsMap, outputsMap, outputTypesMap, valuesTick, nodeStatus, edgeStatus, validationByNode, validationByEdge, uiVersion, runner, engineKind, } = useWorkbenchContext();
3398
3518
  const nodeValidation = validationByNode;
3399
3519
  const edgeValidation = validationByEdge.errors;
3400
3520
  // Keep stable references for nodes/edges to avoid unnecessary updates
@@ -3461,9 +3581,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3461
3581
  },
3462
3582
  }));
3463
3583
  const { onConnect, onNodesChange, onEdgesChange, onEdgesDelete, onNodesDelete, } = useWorkbenchBridge(wb);
3584
+ const ui = wb.getUI();
3464
3585
  const { nodeTypes, resolveNodeType } = useMemo(() => {
3465
3586
  // Build nodeTypes map using UI extension registry
3466
- const ui = wb.getUI();
3467
3587
  const custom = new Map(); // Include all types present in registry AND current graph to avoid timing issues
3468
3588
  const def = wb.export();
3469
3589
  const ids = new Set([
@@ -3485,7 +3605,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3485
3605
  const resolver = (nodeTypeId) => custom.has(nodeTypeId) ? `spark-${nodeTypeId}` : "spark-default";
3486
3606
  return { nodeTypes: types, resolveNodeType: resolver };
3487
3607
  // Include uiVersion to recompute when custom renderers are registered
3488
- }, [wb, registry, uiVersion]);
3608
+ }, [wb, registry, uiVersion, ui]);
3489
3609
  const { nodes, edges } = useMemo(() => {
3490
3610
  const def = wb.export();
3491
3611
  const sel = wb.getSelection();
@@ -3649,19 +3769,63 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3649
3769
  setNodeMenuOpen(false);
3650
3770
  }
3651
3771
  };
3652
- const addNodeAt = useCallback((typeId, pos) => {
3653
- wb.addNode({ typeId, position: pos });
3654
- }, [wb]);
3772
+ const addNodeAt = useCallback(async (typeId, opts) => {
3773
+ const nodeId = wb.addNode({
3774
+ typeId,
3775
+ initialInputs: opts.initialInputs,
3776
+ position: opts.position,
3777
+ });
3778
+ if (opts.inputs) {
3779
+ runner.update(wb.export());
3780
+ await runner.whenIdle();
3781
+ runner.setInputs(nodeId, opts.inputs);
3782
+ }
3783
+ }, [wb, runner]);
3655
3784
  const onCloseMenu = useCallback(() => {
3656
3785
  setMenuOpen(false);
3657
3786
  }, []);
3658
3787
  const onCloseNodeMenu = useCallback(() => {
3659
3788
  setNodeMenuOpen(false);
3660
3789
  }, []);
3790
+ const nodeIds = useMemo(() => Array.from(registry.nodes.keys()), [registry]);
3791
+ const defaultContextMenuHandlers = useMemo(() => ({
3792
+ onAddNode: addNodeAt,
3793
+ onClose: onCloseMenu,
3794
+ }), [addNodeAt, onCloseMenu]);
3795
+ const nodeContextMenuHandlers = useMemo(() => {
3796
+ if (!nodeAtMenu)
3797
+ return null;
3798
+ return createNodeContextMenuHandlers(nodeAtMenu, wb, runner, registry, outputsMap, outputTypesMap, onCloseNodeMenu);
3799
+ }, [
3800
+ nodeAtMenu,
3801
+ wb,
3802
+ runner,
3803
+ registry,
3804
+ outputsMap,
3805
+ outputTypesMap,
3806
+ onCloseNodeMenu,
3807
+ ]);
3808
+ const canRunPull = useMemo(() => engineKind()?.toString() === "pull", [engineKind]);
3809
+ const bakeableOutputs = useMemo(() => {
3810
+ if (!nodeAtMenu)
3811
+ return [];
3812
+ return getBakeableOutputs(nodeAtMenu, wb, registry, outputTypesMap);
3813
+ }, [nodeAtMenu, wb, registry, outputTypesMap]);
3814
+ // Get custom renderers from UI extension registry (reactive to uiVersion changes)
3815
+ const { BackgroundRenderer, MinimapRenderer, ControlsRenderer, DefaultContextMenuRenderer, NodeContextMenuRenderer, connectionLineRenderer, } = useMemo(() => {
3816
+ return {
3817
+ BackgroundRenderer: ui.getBackgroundRenderer(),
3818
+ MinimapRenderer: ui.getMinimapRenderer(),
3819
+ ControlsRenderer: ui.getControlsRenderer(),
3820
+ DefaultContextMenuRenderer: ui.getDefaultContextMenuRenderer(),
3821
+ NodeContextMenuRenderer: ui.getNodeContextMenuRenderer(),
3822
+ connectionLineRenderer: ui.getConnectionLineRenderer(),
3823
+ };
3824
+ }, [ui, uiVersion]);
3661
3825
  const onMoveEnd = useCallback(() => {
3662
3826
  if (rfInstanceRef.current) {
3663
3827
  const viewport = rfInstanceRef.current.getViewport();
3664
- wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom });
3828
+ wb.setViewport({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }, { commit: true });
3665
3829
  }
3666
3830
  }, [wb]);
3667
3831
  const viewportRef = useRef(null);
@@ -3682,7 +3846,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3682
3846
  });
3683
3847
  }
3684
3848
  });
3685
- return (jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsx(ReactFlowProvider, { children: jsxs(ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, selectionOnDrag: true, onInit: (inst) => {
3849
+ return (jsx("div", { className: "w-full h-full", onContextMenu: onContextMenu, children: jsx(ReactFlowProvider, { children: jsxs(ReactFlow, { nodes: throttled.nodes, edges: throttled.edges, nodeTypes: nodeTypes, connectionLineComponent: connectionLineRenderer, selectionOnDrag: true, onInit: (inst) => {
3686
3850
  rfInstanceRef.current = inst;
3687
3851
  const savedViewport = wb.getViewport();
3688
3852
  if (savedViewport) {
@@ -3693,7 +3857,9 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
3693
3857
  zoom: savedViewport.zoom,
3694
3858
  });
3695
3859
  }
3696
- }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onMoveEnd: onMoveEnd, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", fitView: true, children: [jsx(Background, { id: "workbench-canvas-background", variant: BackgroundVariant.Dots, gap: 12, size: 1 }), jsx(MiniMap, {}), jsx(Controls, {}), jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, onAdd: addNodeAt, onClose: onCloseMenu }), !!nodeAtMenu && (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, onClose: onCloseNodeMenu }))] }) }) }));
3860
+ }, onConnect: onConnect, onEdgesChange: onEdgesChange, onEdgesDelete: onEdgesDelete, onNodesDelete: onNodesDelete, onNodesChange: onNodesChange, onMoveEnd: onMoveEnd, deleteKeyCode: ["Backspace", "Delete"], proOptions: { hideAttribution: true }, noDragClassName: "wb-nodrag", noWheelClassName: "wb-nowheel", noPanClassName: "wb-nopan", fitView: true, children: [BackgroundRenderer ? (jsx(BackgroundRenderer, {})) : (jsx(Background, { id: "workbench-canvas-background", variant: BackgroundVariant.Dots, gap: 12, size: 1 })), MinimapRenderer ? jsx(MinimapRenderer, {}) : jsx(MiniMap, {}), ControlsRenderer ? jsx(ControlsRenderer, {}) : jsx(Controls, {}), DefaultContextMenuRenderer ? (jsx(DefaultContextMenuRenderer, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds })) : (jsx(DefaultContextMenu, { open: menuOpen, clientPos: menuPos, handlers: defaultContextMenuHandlers, registry: registry, nodeIds: nodeIds })), !!nodeAtMenu &&
3861
+ nodeContextMenuHandlers &&
3862
+ (NodeContextMenuRenderer ? (jsx(NodeContextMenuRenderer, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs })) : (jsx(NodeContextMenu, { open: nodeMenuOpen, clientPos: nodeMenuPos, nodeId: nodeAtMenu, handlers: nodeContextMenuHandlers, canRunPull: canRunPull, bakeableOutputs: bakeableOutputs })))] }) }) }));
3697
3863
  });
3698
3864
 
3699
3865
  function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
@@ -3828,9 +3994,20 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3828
3994
  }
3829
3995
  catch { }
3830
3996
  });
3997
+ const off3 = wb.on("graphUiChanged", (evt) => {
3998
+ if (!evt.commit)
3999
+ return;
4000
+ try {
4001
+ const cur = wb.export();
4002
+ const inputs = runner.getInputs(cur);
4003
+ onChange({ def: cur, inputs });
4004
+ }
4005
+ catch { }
4006
+ });
3831
4007
  return () => {
3832
4008
  off1();
3833
4009
  off2();
4010
+ off3();
3834
4011
  };
3835
4012
  }, [wb, runner, onChange]);
3836
4013
  const applyExample = useCallback(async (key) => {
@@ -3866,24 +4043,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3866
4043
  setRegistry,
3867
4044
  backendKind,
3868
4045
  ]);
3869
- const downloadGraph = useCallback(() => {
4046
+ const download$1 = useCallback(async () => {
3870
4047
  try {
3871
- const def = wb.export();
3872
- const inputs = runner.getInputs(def);
3873
- const payload = { def, inputs };
3874
- const pretty = JSON.stringify(payload, null, 2);
3875
- const blob = new Blob([pretty], { type: "application/json" });
3876
- const url = URL.createObjectURL(blob);
3877
- const a = document.createElement("a");
3878
- const d = new Date();
3879
- const pad = (n) => String(n).padStart(2, "0");
3880
- const ts = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
3881
- a.href = url;
3882
- a.download = `spark-graph-${ts}.json`;
3883
- document.body.appendChild(a);
3884
- a.click();
3885
- a.remove();
3886
- URL.revokeObjectURL(url);
4048
+ await download(wb, runner);
3887
4049
  }
3888
4050
  catch (err) {
3889
4051
  const message = err instanceof Error ? err.message : String(err);
@@ -3897,51 +4059,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
3897
4059
  return;
3898
4060
  const text = await file.text();
3899
4061
  const parsed = JSON.parse(text);
3900
- // Support both Graph and Snapshot payloads
3901
- const isSnapshot = parsed &&
3902
- typeof parsed === "object" &&
3903
- (parsed.def || parsed.inputs || parsed.outputs || parsed.environment);
3904
- if (isSnapshot) {
3905
- const def = parsed.def;
3906
- const positions = parsed.positions || {};
3907
- const environment = parsed.environment || {};
3908
- const inputs = parsed.inputs || {};
3909
- if (def && runner.isRunning()) {
3910
- // Remote exact restore path
3911
- await runner.applySnapshotFull({
3912
- def,
3913
- environment,
3914
- inputs,
3915
- outputs: parsed.outputs || {},
3916
- });
3917
- await wb.load(def);
3918
- if (positions && typeof positions === "object")
3919
- wb.setPositions(positions);
3920
- }
3921
- else if (!runner.isRunning()) {
3922
- alert("Engine is not running");
3923
- }
3924
- else {
3925
- alert("Graph definition is empty");
3926
- }
3927
- }
3928
- else {
3929
- const def = parsed?.def ?? parsed;
3930
- const inputs = parsed?.inputs ?? {};
3931
- await wb.load(def);
3932
- try {
3933
- runner.build(wb.export());
3934
- }
3935
- catch { }
3936
- if (inputs && typeof inputs === "object") {
3937
- for (const [nodeId, map] of Object.entries(inputs)) {
3938
- try {
3939
- runner.setInputs(nodeId, map);
3940
- }
3941
- catch { }
3942
- }
3943
- }
3944
- }
4062
+ await upload(parsed, wb, runner);
3945
4063
  }
3946
4064
  catch (err) {
3947
4065
  const message = err instanceof Error ? err.message : String(err);
@@ -4197,36 +4315,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
4197
4315
  // Normal change when not running
4198
4316
  onEngineChange?.(kind);
4199
4317
  }
4200
- }, children: [jsx("option", { value: "", children: "Select Engine\u2026" }), jsx("option", { value: "push", children: "Push" }), jsx("option", { value: "batched", children: "Batched" }), jsx("option", { value: "pull", children: "Pull" }), jsx("option", { value: "hybrid", children: "Hybrid" }), jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsx(PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsx(LightningIcon, { size: 24 }) })), renderStartStopButton(), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsx(TreeStructureIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsx(CornersOutIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: downloadGraph, children: jsx(DownloadSimpleIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
4201
- try {
4202
- const def = wb.export();
4203
- const positions = wb.getPositions();
4204
- const snapshot = await runner.snapshotFull();
4205
- const payload = {
4206
- ...snapshot,
4207
- def,
4208
- positions,
4209
- schemaVersion: 1,
4210
- };
4211
- const pretty = JSON.stringify(payload, null, 2);
4212
- const blob = new Blob([pretty], { type: "application/json" });
4213
- const url = URL.createObjectURL(blob);
4214
- const a = document.createElement("a");
4215
- const d = new Date();
4216
- const pad = (n) => String(n).padStart(2, "0");
4217
- const ts = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
4218
- a.href = url;
4219
- a.download = `spark-snapshot-${ts}.json`;
4220
- document.body.appendChild(a);
4221
- a.click();
4222
- a.remove();
4223
- URL.revokeObjectURL(url);
4224
- }
4225
- catch (err) {
4226
- const message = err instanceof Error ? err.message : String(err);
4227
- alert(message);
4228
- }
4229
- }, children: jsx(DownloadIcon, { size: 24 }) }), jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsx(UploadIcon, { size: 24 }) }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsx(BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsx(ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxs("div", { className: "flex flex-1 min-h-0", children: [jsx("div", { className: "flex-1 min-w-0", children: jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
4318
+ }, children: [jsx("option", { value: "", children: "Select Engine\u2026" }), jsx("option", { value: "push", children: "Push" }), jsx("option", { value: "batched", children: "Batched" }), jsx("option", { value: "pull", children: "Pull" }), jsx("option", { value: "hybrid", children: "Hybrid" }), jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsx(PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsx(LightningIcon, { size: 24 }) })), renderStartStopButton(), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsx(TreeStructureIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsx(CornersOutIcon, { size: 24 }) }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsx(DownloadIcon, { size: 24 }) }), jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsx(UploadIcon, { size: 24 }) }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsx(BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxs("label", { className: "flex items-center gap-1", children: [jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsx(ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxs("div", { className: "flex flex-1 min-h-0", children: [jsx("div", { className: "flex-1 min-w-0", children: jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
4230
4319
  }
4231
4320
  function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
4232
4321
  const [registry, setRegistry] = useState(createSimpleGraphRegistry());
@@ -4301,5 +4390,5 @@ function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, bac
4301
4390
  return (jsx(WorkbenchProvider, { wb: wb, runner: runner, registry: registry, setRegistry: setRegistry, overrides: overrides, uiVersion: uiVersion, children: jsx(WorkbenchStudioCanvas, { setRegistry: setRegistry, autoScroll: autoScroll, onAutoScrollChange: onAutoScrollChange, example: example, onExampleChange: onExampleChange, engine: engine, onEngineChange: onEngineChange, backendKind: backendKind, onBackendKindChange: onBackendKindChangeWithDispose, httpBaseUrl: httpBaseUrl, onHttpBaseUrlChange: onHttpBaseUrlChange, wsUrl: wsUrl, onWsUrlChange: onWsUrlChange, debug: debug, onDebugChange: onDebugChange, showValues: showValues, onShowValuesChange: onShowValuesChange, hideWorkbench: hideWorkbench, onHideWorkbenchChange: onHideWorkbenchChange, overrides: overrides, onInit: onInit, onChange: onChange }) }));
4302
4391
  }
4303
4392
 
4304
- export { AbstractWorkbench, CLIWorkbench, DefaultNode, DefaultNodeContent, DefaultNodeHeader, DefaultUIExtensionRegistry, InMemoryWorkbench, Inspector, LocalGraphRunner, NodeHandles, RemoteGraphRunner, WorkbenchCanvas, WorkbenchContext, WorkbenchProvider, WorkbenchStudio, computeEffectiveHandles, countVisibleHandles, estimateNodeSize, formatDataUrlAsLabel, formatDeclaredTypeSignature, getHandleClassName, getNodeBorderClassNames, layoutNode, preformatValueForDisplay, prettyHandle, resolveOutputDisplay, summarizeDeep, toReactFlow, useQueryParamBoolean, useQueryParamString, useThrottledValue, useWorkbenchBridge, useWorkbenchContext, useWorkbenchGraphTick, useWorkbenchGraphUiTick, useWorkbenchVersionTick };
4393
+ export { AbstractWorkbench, CLIWorkbench, DefaultNode, DefaultNodeContent, DefaultNodeHeader, DefaultUIExtensionRegistry, InMemoryWorkbench, Inspector, LocalGraphRunner, NodeHandles, RemoteGraphRunner, WorkbenchCanvas, WorkbenchContext, WorkbenchProvider, WorkbenchStudio, computeEffectiveHandles, countVisibleHandles, download, estimateNodeSize, formatDataUrlAsLabel, formatDeclaredTypeSignature, getHandleClassName, getNodeBorderClassNames, layoutNode, preformatValueForDisplay, prettyHandle, resolveOutputDisplay, summarizeDeep, toReactFlow, upload, useQueryParamBoolean, useQueryParamString, useThrottledValue, useWorkbenchBridge, useWorkbenchContext, useWorkbenchGraphTick, useWorkbenchGraphUiTick, useWorkbenchVersionTick };
4305
4394
  //# sourceMappingURL=index.js.map