pygpt-net 2.6.59__py3-none-any.whl → 2.6.60__py3-none-any.whl

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 (61) hide show
  1. pygpt_net/CHANGELOG.txt +4 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +9 -5
  4. pygpt_net/controller/__init__.py +1 -0
  5. pygpt_net/controller/presets/editor.py +442 -39
  6. pygpt_net/core/agents/custom/__init__.py +275 -0
  7. pygpt_net/core/agents/custom/debug.py +64 -0
  8. pygpt_net/core/agents/custom/factory.py +109 -0
  9. pygpt_net/core/agents/custom/graph.py +71 -0
  10. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  11. pygpt_net/core/agents/custom/llama_index/factory.py +89 -0
  12. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  13. pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
  14. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  15. pygpt_net/core/agents/custom/llama_index/utils.py +242 -0
  16. pygpt_net/core/agents/custom/logging.py +50 -0
  17. pygpt_net/core/agents/custom/memory.py +51 -0
  18. pygpt_net/core/agents/custom/router.py +116 -0
  19. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  20. pygpt_net/core/agents/custom/runner.py +454 -0
  21. pygpt_net/core/agents/custom/schema.py +125 -0
  22. pygpt_net/core/agents/custom/utils.py +181 -0
  23. pygpt_net/core/agents/provider.py +72 -7
  24. pygpt_net/core/agents/runner.py +7 -4
  25. pygpt_net/core/agents/runners/helpers.py +1 -1
  26. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  27. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  28. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  29. pygpt_net/core/{builder → node_editor}/graph.py +11 -218
  30. pygpt_net/core/node_editor/models.py +111 -0
  31. pygpt_net/core/node_editor/types.py +76 -0
  32. pygpt_net/core/node_editor/utils.py +17 -0
  33. pygpt_net/core/render/web/renderer.py +10 -8
  34. pygpt_net/data/config/config.json +3 -3
  35. pygpt_net/data/config/models.json +3 -3
  36. pygpt_net/data/locale/locale.en.ini +4 -4
  37. pygpt_net/item/agent.py +5 -1
  38. pygpt_net/item/preset.py +19 -1
  39. pygpt_net/provider/agents/base.py +33 -2
  40. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  41. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  42. pygpt_net/provider/core/agent/json_file.py +11 -5
  43. pygpt_net/tools/agent_builder/tool.py +217 -52
  44. pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
  45. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  46. pygpt_net/ui/dialog/preset.py +16 -1
  47. pygpt_net/ui/main.py +1 -1
  48. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  49. pygpt_net/ui/widget/node_editor/command.py +373 -0
  50. pygpt_net/ui/widget/node_editor/editor.py +2038 -0
  51. pygpt_net/ui/widget/node_editor/item.py +492 -0
  52. pygpt_net/ui/widget/node_editor/node.py +1205 -0
  53. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  54. pygpt_net/ui/widget/node_editor/view.py +247 -0
  55. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +72 -2
  56. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +59 -33
  57. pygpt_net/core/agents/custom.py +0 -150
  58. pygpt_net/ui/widget/builder/editor.py +0 -2001
  59. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,2038 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.24 00:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+ from typing import Dict, Optional, List, Tuple, Any, Union
14
+
15
+ from PySide6.QtCore import Qt, QPoint, QPointF, QRectF, QSizeF, Property, QEvent
16
+ from PySide6.QtGui import QAction, QColor, QUndoStack, QPalette, QCursor, QKeySequence, QShortcut
17
+ from PySide6.QtWidgets import (
18
+ QWidget, QApplication, QGraphicsView, QMenu, QMessageBox, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox,
19
+ QTextEdit, QPlainTextEdit
20
+ )
21
+
22
+ from pygpt_net.core.node_editor.graph import NodeGraph
23
+ from pygpt_net.core.node_editor.types import NodeTypeRegistry
24
+ from pygpt_net.core.node_editor.models import NodeModel, PropertyModel, ConnectionModel
25
+
26
+ from pygpt_net.utils import trans
27
+
28
+ from .command import AddNodeCommand, ClearGraphCommand, RewireConnectionCommand, ConnectCommand, DeleteNodeCommand, DeleteConnectionCommand
29
+ from .item import EdgeItem, PortItem
30
+ from .node import NodeItem
31
+ from .view import NodeGraphicsScene, NodeGraphicsView
32
+ from .utils import _qt_is_valid
33
+
34
+
35
+ # ------------------------ NodeEditor ------------------------
36
+
37
+ class NodeEditor(QWidget):
38
+ """Main widget embedding a QGraphicsView-based node editor.
39
+
40
+ Responsibilities:
41
+ - Owns NodeGraph and keeps it in sync with scene items (NodeItem/EdgeItem).
42
+ - Manages user interactions: add/move/resize nodes, connect/rewire/delete edges.
43
+ - Provides save/load layout (schema-agnostic) and undo/redo integration.
44
+ - Applies theming and propagates style/palette to embedded editors.
45
+ """
46
+
47
+ def _q_set(self, name, value):
48
+ """Qt property writer: set attribute and refresh theme if ready."""
49
+ setattr(self, name, value)
50
+ if not getattr(self, "_ready_for_theme", True):
51
+ return
52
+ if not getattr(self, "_alive", True):
53
+ return
54
+ if getattr(self, "view", None) is None:
55
+ return
56
+ try:
57
+ self._update_theme()
58
+ except Exception:
59
+ pass
60
+
61
+ def _q_set_bool(self, name, value: bool):
62
+ """Qt property writer for booleans."""
63
+ setattr(self, name, bool(value))
64
+
65
+ # Default theme colors and interaction parameters (Qt Properties are declared below)
66
+ _node_bg_color = QColor(45, 45, 48)
67
+ _node_border_color = QColor(80, 80, 90)
68
+ _node_selection_color = QColor(255, 170, 0)
69
+ _node_title_color = QColor(60, 60, 63)
70
+ _port_input_color = QColor(100, 180, 255)
71
+ _port_output_color = QColor(140, 255, 140)
72
+ _port_connected_color = QColor(255, 220, 100)
73
+ _port_accept_color = QColor(255, 255, 140)
74
+ _edge_color = QColor(180, 180, 180)
75
+ _edge_selected_color = QColor(255, 140, 90)
76
+ _grid_back_color = QColor(35, 35, 38)
77
+ _grid_pen_color = QColor(55, 55, 60)
78
+
79
+ _port_label_color = QColor(220, 220, 220)
80
+ _port_capacity_color = QColor(200, 200, 200)
81
+
82
+ _edge_pick_width: float = 12.0
83
+ _resize_grip_margin: float = 12.0
84
+ _resize_grip_hit_inset: float = 5.0
85
+ _port_pick_radius: float = 10.0
86
+ _port_click_rewire_if_connected: bool = True
87
+ _rewire_drop_deletes: bool = True
88
+
89
+ # Expose appearance/interaction knobs as Qt Properties (useful for Designer/QML bindings)
90
+ nodeBackgroundColor = Property(QColor, lambda self: self._node_bg_color, lambda self, v: self._q_set("_node_bg_color", v))
91
+ nodeBorderColor = Property(QColor, lambda self: self._node_border_color, lambda self, v: self._q_set("_node_border_color", v))
92
+ nodeSelectionColor = Property(QColor, lambda self: self._node_selection_color, lambda self, v: self._q_set("_node_selection_color", v))
93
+ nodeTitleColor = Property(QColor, lambda self: self._node_title_color, lambda self, v: self._q_set("_node_title_color", v))
94
+ portInputColor = Property(QColor, lambda self: self._port_input_color, lambda self, v: self._q_set("_port_input_color", v))
95
+ portOutputColor = Property(QColor, lambda self: self._port_output_color, lambda self, v: self._q_set("_port_output_color", v))
96
+ portConnectedColor = Property(QColor, lambda self: self._port_connected_color, lambda self, v: self._q_set("_port_connected_color", v))
97
+ portAcceptColor = Property(QColor, lambda self: self._port_accept_color, lambda self, v: self._q_set("_port_accept_color", v))
98
+ edgeColor = Property(QColor, lambda self: self._edge_color, lambda self, v: self._q_set("_edge_color", v))
99
+ edgeSelectedColor = Property(QColor, lambda self: self._edge_selected_color, lambda self, v: self._q_set("_edge_selected_color", v))
100
+ gridBackColor = Property(QColor, lambda self: self._grid_back_color, lambda self, v: self._q_set("_grid_back_color", v))
101
+ gridPenColor = Property(QColor, lambda self: self._grid_pen_color, lambda self, v: self._q_set("_grid_pen_color", v))
102
+
103
+ portLabelColor = Property(QColor, lambda self: self._port_label_color, lambda self, v: self._q_set("_port_label_color", v))
104
+ portCapacityColor = Property(QColor, lambda self: self._port_capacity_color, lambda self, v: self._q_set("_port_capacity_color", v))
105
+
106
+ edgePickWidth = Property(float, lambda self: self._edge_pick_width, lambda self, v: self._set_edge_pick_width(v))
107
+ resizeGripMargin = Property(float, lambda self: self._resize_grip_margin, lambda self, v: self._q_set("_resize_grip_margin", float(v)))
108
+ resizeGripHitInset = Property(float, lambda self: self._resize_grip_hit_inset, lambda self, v: self._set_resize_grip_hit_inset(float(v)))
109
+ portPickRadius = Property(float, lambda self: self._port_pick_radius, lambda self, v: self._q_set("_port_pick_radius", float(v)))
110
+ portClickRewireIfConnected = Property(bool, lambda self: self._port_click_rewire_if_connected, lambda self, v: self._q_set_bool("_port_click_rewire_if_connected", v))
111
+ rewireDropDeletes = Property(bool, lambda self: self._rewire_drop_deletes, lambda self, v: self._q_set_bool("_rewire_drop_deletes", v))
112
+
113
+ def _set_resize_grip_hit_inset(self, v: float):
114
+ """Setter that also re-applies size on all items to refresh hit regions."""
115
+ self._resize_grip_hit_inset = max(0.0, float(v))
116
+ for item in list(self._uuid_to_item.values()):
117
+ if _qt_is_valid(item):
118
+ item._apply_resize(item.size(), clamp=True)
119
+ if _qt_is_valid(self.view):
120
+ self.view.viewport().update()
121
+
122
+ def _set_edge_pick_width(self, v: float):
123
+ """Setter that recomputes shapes for all edges (including interactive)."""
124
+ self._edge_pick_width = float(v) if v and v > 0 else 12.0
125
+ for edge in list(self._conn_uuid_to_edge.values()):
126
+ if _qt_is_valid(edge):
127
+ edge.prepareGeometryChange()
128
+ edge.update()
129
+ if getattr(self, "_interactive_edge", None) and _qt_is_valid(self._interactive_edge):
130
+ self._interactive_edge.prepareGeometryChange()
131
+ self._interactive_edge.update()
132
+ if _qt_is_valid(self.view):
133
+ self.view.viewport().update()
134
+
135
+ def __init__(self, parent: Optional[QWidget] = None, registry: Optional[NodeTypeRegistry] = None):
136
+ """Initialize the editor, scene, view, graph and interaction state."""
137
+ super().__init__(parent)
138
+ self.setObjectName("NodeEditor")
139
+ self._debug = False
140
+ self._dbg("INIT NodeEditor")
141
+
142
+ self.graph = NodeGraph(registry)
143
+ self.scene = NodeGraphicsScene(self)
144
+ self.view = NodeGraphicsView(self.scene, self)
145
+ self.view.setGeometry(self.rect())
146
+ self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
147
+ self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
148
+ self._undo = QUndoStack(self)
149
+ self._uuid_to_item: Dict[str, NodeItem] = {}
150
+ self._conn_uuid_to_edge: Dict[str, EdgeItem] = {}
151
+ self._interactive_edge: Optional[EdgeItem] = None
152
+ self._interactive_src_port: Optional[PortItem] = None
153
+ self._hover_candidate: Optional[PortItem] = None
154
+ self._pending_node_positions: Dict[str, QPointF] = {}
155
+
156
+ self._wire_state: str = "idle"
157
+ self._rewire_conn_uuid: Optional[str] = None
158
+ self._rewire_hidden_edge: Optional[EdgeItem] = None
159
+ self._rewire_fixed_src: Optional[PortItem] = None
160
+ self._rewire_press_scene_pos: Optional[QPointF] = None
161
+
162
+ self._spawn_origin: Optional[QPointF] = None
163
+ self._spawn_index: int = 0
164
+ self._saved_drag_mode: Optional[QGraphicsView.DragMode] = None
165
+
166
+ self.scene.sceneContextRequested.connect(self._on_scene_context_menu)
167
+ self.graph.nodeAdded.connect(self._on_graph_node_added)
168
+ self.graph.nodeRemoved.connect(self._on_graph_node_removed)
169
+ self.graph.connectionAdded.connect(self._on_graph_connection_added)
170
+ self.graph.connectionRemoved.connect(self._on_graph_connection_removed)
171
+ self.graph.cleared.connect(self._on_graph_cleared)
172
+
173
+ self.scene.installEventFilter(self)
174
+
175
+ self._alive = True
176
+ self._closing = False
177
+ self._ready_for_theme = True
178
+ self.destroyed.connect(self._on_destroyed)
179
+ self.on_clear = None
180
+
181
+ # Tracks if a layout is being loaded; prevents auto-renumbering during import.
182
+ self._is_loading_layout: bool = False
183
+
184
+ # Track whether a text-like editor currently has focus (robust against QGraphicsProxyWidget)
185
+ self._text_input_active: bool = False
186
+ app = QApplication.instance()
187
+ if app:
188
+ app.focusChanged.connect(self._on_focus_changed)
189
+
190
+ # Delete shortcut handled at editor-level; never handled by the view
191
+ self._shortcut_delete = QShortcut(QKeySequence(Qt.Key_Delete), self)
192
+ self._shortcut_delete.setContext(Qt.WidgetWithChildrenShortcut)
193
+ self._shortcut_delete.activated.connect(self._on_delete_shortcut)
194
+
195
+ # Per-layout Base ID counters: {property_id -> {base_string -> max_suffix_used}}
196
+ # These counters are rebuilt on load_layout() and reset on clear.
197
+ self._base_id_max: Dict[str, Dict[str, int]] = {}
198
+ self._reset_base_id_counters()
199
+
200
+ # ---------- Debug helper ----------
201
+ def _dbg(self, msg: str):
202
+ """Conditional debug logger (enabled by self._debug)."""
203
+ if self._debug:
204
+ print(f"[NodeEditor][{hex(id(self))}] {msg}")
205
+
206
+ # Focus tracking to detect if the user is typing in an input
207
+ def _is_text_entry_widget(self, w: Optional[QWidget]) -> bool:
208
+ """Return True if the widget is a focused, editable text-like editor."""
209
+ if w is None:
210
+ return False
211
+ try:
212
+ if isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
213
+ if hasattr(w, "isReadOnly") and w.isReadOnly():
214
+ return False
215
+ return True
216
+ if isinstance(w, QComboBox):
217
+ return w.isEditable() and w.hasFocus()
218
+ if isinstance(w, (QSpinBox, QDoubleSpinBox)):
219
+ return w.hasFocus()
220
+ except Exception:
221
+ return False
222
+ return False
223
+
224
+ def _on_focus_changed(self, old: Optional[QWidget], now: Optional[QWidget]):
225
+ """Update internal flag when focus moves to/from text widgets."""
226
+ self._text_input_active = self._is_text_entry_widget(now)
227
+
228
+ # Backward-compatible helper used by other parts
229
+ def _is_text_input_focused(self) -> bool:
230
+ """Return True if an input field is currently focused."""
231
+ return bool(self._text_input_active)
232
+
233
+ # ---------- QWidget overrides ----------
234
+
235
+ def _on_destroyed(self):
236
+ """Set flags and disconnect global signals on destruction."""
237
+ self._alive = False
238
+ self._ready_for_theme = False
239
+ try:
240
+ app = QApplication.instance()
241
+ if app:
242
+ app.focusChanged.disconnect(self._on_focus_changed)
243
+ except Exception:
244
+ pass
245
+
246
+ def closeEvent(self, e):
247
+ """Perform a thorough cleanup to prevent dangling Qt wrappers during shutdown."""
248
+ self._dbg("closeEvent -> full cleanup")
249
+
250
+ # Make the editor effectively inert for any in-flight events
251
+ self._closing = True
252
+ self._alive = False
253
+ self._ready_for_theme = False
254
+
255
+ # Stop listening to global focus changes
256
+ try:
257
+ app = QApplication.instance()
258
+ if app:
259
+ app.focusChanged.disconnect(self._on_focus_changed)
260
+ except Exception:
261
+ pass
262
+
263
+ # Disconnect scene signals and filters
264
+ try:
265
+ if _qt_is_valid(self.scene):
266
+ self.scene.sceneContextRequested.disconnect(self._on_scene_context_menu)
267
+ except Exception:
268
+ pass
269
+
270
+ # Cancel any interactive wire state before clearing items
271
+ self._reset_interaction_states(remove_hidden_edges=True)
272
+
273
+ try:
274
+ if _qt_is_valid(self.scene):
275
+ self.scene.removeEventFilter(self)
276
+ except Exception:
277
+ pass
278
+
279
+ # Disconnect graph signals early to prevent callbacks during teardown
280
+ self._disconnect_graph_signals()
281
+
282
+ # Clear undo stack to drop references to scene items and widgets
283
+ try:
284
+ if self._undo is not None:
285
+ self._undo.clear()
286
+ except Exception:
287
+ pass
288
+
289
+ # Proxies/widgets first (prevents QWidget-in-scene dangling)
290
+ self._cleanup_node_proxies()
291
+
292
+ # Remove edge items, then all remaining scene items
293
+ try:
294
+ self._remove_all_edge_items_from_scene()
295
+ if _qt_is_valid(self.scene):
296
+ self.scene.clear()
297
+ except Exception:
298
+ pass
299
+
300
+ # Detach view from scene to break mutual references
301
+ try:
302
+ if _qt_is_valid(self.view):
303
+ self.view.setScene(None)
304
+ except Exception:
305
+ pass
306
+
307
+ # Drop internal maps to help GC
308
+ try:
309
+ self._uuid_to_item.clear()
310
+ self._conn_uuid_to_edge.clear()
311
+ except Exception:
312
+ pass
313
+ self._interactive_edge = None
314
+ self._interactive_src_port = None
315
+ self._hover_candidate = None
316
+ self._pending_node_positions.clear()
317
+
318
+ # Optionally schedule deletion of scene/view
319
+ try:
320
+ if _qt_is_valid(self.scene):
321
+ self.scene.deleteLater()
322
+ except Exception:
323
+ pass
324
+ try:
325
+ if _qt_is_valid(self.view):
326
+ self.view.deleteLater()
327
+ except Exception:
328
+ pass
329
+
330
+ self.scene = None
331
+ self.view = None
332
+
333
+ # Force-flush all deferred deletions to avoid old wrappers lingering into the next open
334
+ try:
335
+ from PySide6.QtWidgets import QApplication
336
+ from PySide6.QtCore import QEvent
337
+ for _ in range(3):
338
+ QApplication.sendPostedEvents(None, QEvent.DeferredDelete)
339
+ QApplication.processEvents()
340
+ except Exception:
341
+ pass
342
+
343
+ super().closeEvent(e)
344
+
345
+ def _disconnect_graph_signals(self):
346
+ """Disconnect all graph signals safely (ignore if already disconnected)."""
347
+ for signal, slot in (
348
+ (self.graph.nodeAdded, self._on_graph_node_added),
349
+ (self.graph.nodeRemoved, self._on_graph_node_removed),
350
+ (self.graph.connectionAdded, self._on_graph_connection_added),
351
+ (self.graph.connectionRemoved, self._on_graph_connection_removed),
352
+ (self.graph.cleared, self._on_graph_cleared),
353
+ ):
354
+ try:
355
+ signal.disconnect(slot)
356
+ except Exception:
357
+ pass
358
+
359
+ def resizeEvent(self, e):
360
+ """Keep the view sized to the editor widget."""
361
+ if _qt_is_valid(self.view):
362
+ self.view.setGeometry(self.rect())
363
+ super().resizeEvent(e)
364
+
365
+ def showEvent(self, e):
366
+ """Cache the spawn origin and reapply stylesheets when shown."""
367
+ if self._spawn_origin is None and _qt_is_valid(self.view):
368
+ self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
369
+ self._reapply_stylesheets()
370
+ super().showEvent(e)
371
+
372
+ def event(self, e):
373
+ """React to global style/palette changes and reset interactions on focus loss."""
374
+ et = e.type()
375
+ # Do not accept ShortcutOverride here; we rely on the editor-level QShortcut for Delete.
376
+ if et in (QEvent.StyleChange, QEvent.PaletteChange, QEvent.FontChange,
377
+ QEvent.ApplicationPaletteChange, QEvent.ApplicationFontChange):
378
+ self._reapply_stylesheets()
379
+ if et in (QEvent.FocusOut, QEvent.WindowDeactivate):
380
+ self._dbg("event -> focus out/window deactivate -> reset interaction")
381
+ self._reset_interaction_states(remove_hidden_edges=False)
382
+ return super().event(e)
383
+
384
+ # ---------- Public API ----------
385
+
386
+ def add_node(self, type_name: str, scene_pos: QPointF):
387
+ """Add a new node of the given type at scene_pos (undoable)."""
388
+ self._undo.push(AddNodeCommand(self, type_name, scene_pos))
389
+
390
+ def clear(self, ask_user: bool = True):
391
+ """Clear the entire editor (undoable), optionally asking the user for confirmation.
392
+
393
+ Returns:
394
+ bool: True if a clear operation was initiated.
395
+ """
396
+ if not self._alive or self.scene is None or self.view is None:
397
+ return False
398
+ if ask_user and self.on_clear and callable(self.on_clear):
399
+ self.on_clear()
400
+ return
401
+ if ask_user:
402
+ reply = QMessageBox.question(self, trans("agent.builder.confirm.clear.title"),
403
+ trans("agent.builder.confirm.clear.msg"),
404
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
405
+ if reply != QMessageBox.Yes:
406
+ return False
407
+ self._undo.push(ClearGraphCommand(self))
408
+ return True
409
+
410
+ def undo(self):
411
+ """Undo last action."""
412
+ self._undo.undo()
413
+
414
+ def redo(self):
415
+ """Redo last undone action."""
416
+ self._undo.redo()
417
+
418
+ def save_layout(self) -> dict:
419
+ """Serialize the current layout into a compact, value-only dict.
420
+
421
+ Notes:
422
+ - Property specification (type, editable, allowed_inputs/outputs, etc.) is NOT exported.
423
+ - Only dynamic, value-carrying properties are persisted.
424
+ - Registry definitions are the single source of truth for property specs during load.
425
+ """
426
+ if not self._alive or self.scene is None or self.view is None:
427
+ return False
428
+
429
+ # Build compact graph payload (nodes + connections without specs)
430
+ compact = self._serialize_layout_compact()
431
+
432
+ # Positions and sizes come from live scene items
433
+ compact["positions"] = {
434
+ nuuid: [self._uuid_to_item[nuuid].pos().x(), self._uuid_to_item[nuuid].pos().y()]
435
+ for nuuid in self._uuid_to_item
436
+ }
437
+ compact["sizes"] = {
438
+ nuuid: [float(self._uuid_to_item[nuuid].size().width()),
439
+ float(self._uuid_to_item[nuuid].size().height())]
440
+ for nuuid in self._uuid_to_item
441
+ }
442
+
443
+ # Persist the current view (zoom and scrollbars)
444
+ try:
445
+ compact["view"] = self._save_view_state()
446
+ except Exception:
447
+ compact["view"] = {}
448
+
449
+ return compact
450
+
451
+ def load_layout(self, data: dict):
452
+ """
453
+ Load layout using the live registry for node specs. Only values from the layout are applied,
454
+ and only for properties that still exist in the current spec. Everything else falls back to
455
+ the registry defaults. Connections are recreated only if ports still exist and can connect.
456
+
457
+ Example:
458
+ editor.load_layout({
459
+ "nodes": [
460
+ {"uuid": "n1", "type": "Agent", "values": {"name": "Collector"}, "pos": [100, 120], "size": [220, 180]},
461
+ {"uuid": "n2", "type": "Tool", "values": {"kind": "Search"}}
462
+ ],
463
+ "connections": [
464
+ {"uuid": "c1", "src_node": "n1", "src_prop": "out", "dst_node": "n2", "dst_prop": "in"}
465
+ ]
466
+ })
467
+ """
468
+ if not self._alive or self.scene is None or self.view is None:
469
+ return False
470
+
471
+ # Guard: mark layout import phase to avoid auto-renumbering of Base ID on nodeAdded.
472
+ self._is_loading_layout = True
473
+ try:
474
+ self._dbg("load_layout -> registry-first import with value-only merge")
475
+ # Reset interaction and clear both scene and graph (do not reuse graph.from_dict here).
476
+ self._reset_interaction_states(remove_hidden_edges=True)
477
+ self._clear_scene_only(hard=True)
478
+ try:
479
+ self.graph.clear(silent=True)
480
+ except Exception:
481
+ # Be defensive in case .clear is not available or raises
482
+ self.graph.nodes = {}
483
+ self.graph.connections = {}
484
+
485
+ # Also reset per-layout Base ID counters; they will be rebuilt from the loaded data.
486
+ self._reset_base_id_counters()
487
+
488
+ # Extract normalized structures from possibly diverse layout shapes.
489
+ nodes_norm, conns_norm, positions, sizes = self._normalize_layout_dict(data)
490
+
491
+ # 1) Create nodes from registry spec, preserve UUID, set values that match current spec.
492
+ created: Dict[str, NodeModel] = {}
493
+ for nd in nodes_norm:
494
+ tname = nd.get("type")
495
+ nuuid = nd.get("uuid")
496
+ if not tname or not nuuid:
497
+ self._dbg(f"load_layout: skip node without type/uuid: {nd}")
498
+ continue
499
+
500
+ # Ensure type exists in registry.
501
+ spec = self.graph.registry.get(tname) if self.graph and self.graph.registry else None
502
+ if spec is None:
503
+ self._dbg(f"load_layout: skip node '{nuuid}' with unknown type '{tname}' (not in registry)")
504
+ continue
505
+
506
+ try:
507
+ node = self.graph.create_node_from_type(tname)
508
+ except Exception as ex:
509
+ self._dbg(f"load_layout: create_node_from_type failed for '{tname}': {ex}")
510
+ continue
511
+
512
+ # Preserve UUID and optional name from layout (if present).
513
+ try:
514
+ node.uuid = nuuid
515
+ except Exception:
516
+ pass
517
+ name = nd.get("name")
518
+ if isinstance(name, str) and name.strip():
519
+ try:
520
+ node.name = name
521
+ except Exception:
522
+ pass
523
+
524
+ # Preserve friendly node.id from layout if present (keeps per-layout numbering).
525
+ try:
526
+ fid = nd.get("id")
527
+ if isinstance(fid, str) and fid.strip():
528
+ node.id = fid.strip()
529
+ except Exception:
530
+ pass
531
+
532
+ # Apply property values only for properties that exist in the current spec.
533
+ values_map: Dict[str, Any] = nd.get("values", {})
534
+ for pid, pm in list(node.properties.items()):
535
+ if pid in values_map:
536
+ val = self._coerce_value_for_property(pm, values_map[pid])
537
+ try:
538
+ pm.value = val
539
+ except Exception:
540
+ pass
541
+ else:
542
+ # Keep default from registry-created node.
543
+ pass
544
+
545
+ # Schedule position (used in _on_graph_node_added) and add to graph.
546
+ pos_xy = positions.get(nuuid)
547
+ if isinstance(pos_xy, (list, tuple)) and len(pos_xy) == 2:
548
+ try:
549
+ self._pending_node_positions[nuuid] = QPointF(float(pos_xy[0]), float(pos_xy[1]))
550
+ except Exception:
551
+ pass
552
+ self.graph.add_node(node)
553
+ created[nuuid] = node
554
+
555
+ # 2) Apply sizes after items exist.
556
+ for nuuid, wh in sizes.items():
557
+ item = self._uuid_to_item.get(nuuid)
558
+ if item and isinstance(wh, (list, tuple)) and len(wh) == 2:
559
+ try:
560
+ w = float(wh[0]); h = float(wh[1])
561
+ item._apply_resize(QSizeF(w, h), clamp=True)
562
+ except Exception:
563
+ pass
564
+
565
+ # 3) Recreate connections if possible.
566
+ for cd in conns_norm:
567
+ cuuid = cd.get("uuid")
568
+ s_n = cd.get("src_node")
569
+ s_p = cd.get("src_prop")
570
+ d_n = cd.get("dst_node")
571
+ d_p = cd.get("dst_prop")
572
+
573
+ if not (s_n in created and d_n in created and isinstance(s_p, str) and isinstance(d_p, str)):
574
+ self._dbg(f"load_layout: skip connection with missing nodes/props: {cd}")
575
+ continue
576
+
577
+ # Validate ports exist in current spec.
578
+ if s_p not in created[s_n].properties or d_p not in created[d_n].properties:
579
+ self._dbg(f"load_layout: skip connection with non-existing port(s) in current spec: {cd}")
580
+ continue
581
+
582
+ # Check if connection is allowed in current graph state.
583
+ ok, reason = self.graph.can_connect((s_n, s_p), (d_n, d_p))
584
+ if not ok:
585
+ self._dbg(f"load_layout: cannot connect ({s_n}.{s_p}) -> ({d_n}.{d_p}): {reason}")
586
+ continue
587
+
588
+ # Try to preserve original connection UUID if available.
589
+ conn_model = ConnectionModel(
590
+ uuid=cuuid if isinstance(cuuid, str) and cuuid else None,
591
+ src_node=s_n, src_prop=s_p,
592
+ dst_node=d_n, dst_prop=d_p
593
+ )
594
+ ok_add, reason_add = self.graph.add_connection(conn_model)
595
+ if not ok_add:
596
+ # Fallback: let graph assign a fresh UUID (should rarely be needed).
597
+ ok2, reason2, _ = self.graph.connect((s_n, s_p), (d_n, d_p))
598
+ if not ok2:
599
+ self._dbg(f"load_layout: failed to add connection even via connect(): {reason_add} / {reason2}")
600
+
601
+ # 4) Sync ports after all nodes/connections are in place.
602
+ for item in self._uuid_to_item.values():
603
+ if _qt_is_valid(item):
604
+ item.update_ports_positions()
605
+
606
+ # Rebuild Base ID counters based on the just-loaded layout.
607
+ self._rebuild_base_id_counters()
608
+
609
+ self._reapply_stylesheets()
610
+
611
+ # Restore view state if present; otherwise center on content (backward compatible).
612
+ vs = self._extract_view_state(data)
613
+ if vs:
614
+ self._apply_view_state(vs)
615
+ else:
616
+ self.center_on_content()
617
+ return True
618
+ finally:
619
+ # Always clear the loading flag even if exceptions happen during import.
620
+ self._is_loading_layout = False
621
+
622
+ def export_schema(self, as_list: bool = False) -> Union[dict, List[dict]]:
623
+ """Export the graph schema (delegates to NodeGraph)."""
624
+ if as_list:
625
+ return self.graph.to_list_schema()
626
+ return self.graph.to_schema()
627
+
628
+ def debug_state(self) -> dict:
629
+ """Return a diagnostics snapshot (same as save_layout)."""
630
+ return self.save_layout()
631
+
632
+ def zoom_in(self):
633
+ """Zoom in the view."""
634
+ if _qt_is_valid(self.view):
635
+ self.view.zoom_in()
636
+
637
+ def zoom_out(self):
638
+ """Zoom out the view."""
639
+ if _qt_is_valid(self.view):
640
+ self.view.zoom_out()
641
+
642
+ # ---------- Graph <-> UI sync ----------
643
+
644
+ def _model_by_uuid(self, node_uuid: str) -> Optional[NodeModel]:
645
+ """Return NodeModel by UUID from the graph."""
646
+ return self.graph.nodes.get(node_uuid)
647
+
648
+ def _on_graph_node_added(self, node: NodeModel):
649
+ """Create a NodeItem when a NodeModel is added to the graph."""
650
+ if node.uuid in self._uuid_to_item:
651
+ return
652
+
653
+ # Do not auto-increment Base ID here to avoid double steps.
654
+ # Base ID assignment is handled at the command/model creation stage (e.g. AddNodeCommand).
655
+ pos = self._pending_node_positions.pop(node.uuid, None)
656
+ if pos is None:
657
+ pos = self._next_spawn_pos()
658
+ self._dbg(f"graph.nodeAdded -> add item for node={node.name}({node.uuid}) at {pos}")
659
+ self._add_node_item(node, pos)
660
+
661
+ # Keep Base ID counters in sync with newly added nodes.
662
+ self._update_base_id_counters_from_node(node)
663
+
664
+ def _on_graph_node_removed(self, node_uuid: str):
665
+ """Remove the NodeItem when model is removed from the graph."""
666
+ self._dbg(f"graph.nodeRemoved -> remove item for node={node_uuid}")
667
+ item = self._uuid_to_item.pop(node_uuid, None)
668
+ if item and _qt_is_valid(item):
669
+ try:
670
+ item.detach_proxy_widget()
671
+ except Exception:
672
+ pass
673
+ try:
674
+ self.scene.removeItem(item)
675
+ except Exception:
676
+ pass
677
+
678
+ # Rebuild Base ID counters after removal to reflect current layout state.
679
+ self._rebuild_base_id_counters()
680
+
681
+ def _on_graph_connection_added(self, conn: ConnectionModel):
682
+ """Create an EdgeItem when a ConnectionModel appears in the graph."""
683
+ self._dbg(f"graph.connectionAdded -> add edge uuid={conn.uuid} src=({conn.src_node},{conn.src_prop}) dst=({conn.dst_node},{conn.dst_prop})")
684
+ self._add_edge_for_connection(conn)
685
+
686
+ def _on_graph_connection_removed(self, conn_uuid: str):
687
+ """Remove the EdgeItem when a connection is removed from the graph."""
688
+ self._dbg(f"graph.connectionRemoved -> remove edge uuid={conn_uuid}")
689
+ edge = self._conn_uuid_to_edge.pop(conn_uuid, None)
690
+ if edge:
691
+ edge.src_port.increment_connections(-1)
692
+ edge.dst_port.increment_connections(-1)
693
+ if _qt_is_valid(edge.src_port.node_item):
694
+ edge.src_port.node_item.remove_edge(edge)
695
+ if _qt_is_valid(edge.dst_port.node_item):
696
+ edge.dst_port.node_item.remove_edge(edge)
697
+ if _qt_is_valid(edge):
698
+ try:
699
+ self.scene.removeItem(edge)
700
+ except Exception:
701
+ pass
702
+
703
+ def _on_graph_cleared(self):
704
+ """React to graph cleared by clearing the scene (items only)."""
705
+ self._dbg("graph.cleared -> clear scene only")
706
+ self._reset_interaction_states(remove_hidden_edges=True)
707
+ self._clear_scene_only(hard=True)
708
+ # Reset Base ID counters on clear so next layout starts fresh.
709
+ self._reset_base_id_counters()
710
+
711
+ # ---------- Scene helpers ----------
712
+
713
+ def _scene_to_global(self, scene_pos: QPointF) -> QPoint:
714
+ """Convert scene coordinates to a global screen QPoint."""
715
+ from PySide6.QtCore import QPoint as _QPoint
716
+ vp_pt = self.view.mapFromScene(scene_pos)
717
+ if isinstance(vp_pt, QPointF):
718
+ vp_pt = _QPoint(int(vp_pt.x()), int(vp_pt.y()))
719
+ return self.view.viewport().mapToGlobal(vp_pt)
720
+
721
+ def _on_scene_context_menu(self, scene_pos: QPointF):
722
+ """Show context menu for adding nodes and undo/redo/clear at empty scene position."""
723
+ menu = QMenu(self.window())
724
+ ss = self.window().styleSheet()
725
+ if ss:
726
+ menu.setStyleSheet(ss)
727
+
728
+ add_menu = menu.addMenu("Add")
729
+ action_by_type: Dict[QAction, str] = {}
730
+ for tname in self.graph.registry.types():
731
+ act = add_menu.addAction(tname)
732
+ action_by_type[act] = tname
733
+
734
+ menu.addSeparator()
735
+ act_undo = QAction("Undo", menu)
736
+ act_redo = QAction("Redo", menu)
737
+ act_clear = QAction("Clear", menu)
738
+ act_undo.setEnabled(self._undo.canUndo())
739
+ act_redo.setEnabled(self._undo.canRedo())
740
+ menu.addAction(act_undo)
741
+ menu.addAction(act_redo)
742
+ menu.addSeparator()
743
+ menu.addAction(act_clear)
744
+
745
+ global_pos = self._scene_to_global(scene_pos)
746
+ chosen = menu.exec(global_pos)
747
+ if chosen is None:
748
+ return
749
+ if chosen == act_undo:
750
+ self.undo()
751
+ elif chosen == act_redo:
752
+ self.redo()
753
+ elif chosen == act_clear:
754
+ self.clear(ask_user=True)
755
+ elif chosen in action_by_type:
756
+ type_name = action_by_type[chosen]
757
+ self._undo.push(AddNodeCommand(self, type_name, scene_pos))
758
+
759
+ # ---------- Add/remove nodes/edges ----------
760
+
761
+ def _add_node_model(self, node: NodeModel, scene_pos: QPointF):
762
+ """Schedule a new node's position and add the model to the graph."""
763
+ self._pending_node_positions[node.uuid] = scene_pos
764
+ self.graph.add_node(node)
765
+
766
+ def _add_node_item(self, node: NodeModel, scene_pos: QPointF):
767
+ """Create and place a NodeItem for the given NodeModel."""
768
+ item = NodeItem(self, node)
769
+ self.scene.addItem(item)
770
+ free_pos = self._find_free_position(scene_pos, item.size())
771
+ item.setPos(free_pos)
772
+ item.update_ports_positions()
773
+ # From now on it's safe for itemChange to touch the scene/edges
774
+ item.mark_ready_for_scene_ops(True)
775
+
776
+ self._uuid_to_item[node.uuid] = item
777
+ self._apply_styles_to_content(item._content)
778
+
779
+ def _find_free_position(self, desired: QPointF, size: QSizeF, step: int = 40, max_rings: int = 20) -> QPointF:
780
+ """Find a non-overlapping position near 'desired' using a spiral search."""
781
+ def rect_at(p: QPointF) -> QRectF:
782
+ return QRectF(p.x(), p.y(), size.width(), size.height()).adjusted(1, 1, -1, -1)
783
+ def collides(p: QPointF) -> bool:
784
+ r = rect_at(p)
785
+ for it in self.scene.items(r, Qt.IntersectsItemBoundingRect):
786
+ if isinstance(it, NodeItem):
787
+ other = QRectF(it.scenePos().x(), it.scenePos().y(), it.size().width(), it.size().height()).adjusted(1,1,-1,-1)
788
+ if r.intersects(other):
789
+ return True
790
+ return False
791
+
792
+ if not collides(desired):
793
+ return desired
794
+
795
+ x = y = 0
796
+ dx, dy = 1, 0
797
+ segment_length = 1
798
+ p = QPointF(desired)
799
+ for ring in range(1, max_rings + 1):
800
+ for _ in range(2):
801
+ for _ in range(segment_length):
802
+ p = QPointF(desired.x() + x * step, desired.y() + y * step)
803
+ if not collides(p):
804
+ return p
805
+ x += dx; y += dy
806
+ dx, dy = -dy, dx
807
+ segment_length += 1
808
+ return desired
809
+
810
+ def _next_spawn_pos(self) -> QPointF:
811
+ """Return next spawn position around cached origin (grid-based expansion)."""
812
+ if self._spawn_origin is None:
813
+ if not self.view.viewport().rect().isEmpty():
814
+ self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
815
+ else:
816
+ self._spawn_origin = self.scene.sceneRect().center()
817
+ origin = self._spawn_origin
818
+ step = 80
819
+ base_grid = [(-1, -1), (0, -1), (1, -1),
820
+ (-1, 0), (0, 0), (1, 0),
821
+ (-1, 1), (0, 1), (1, 1)]
822
+ ring = self._spawn_index // len(base_grid) + 1
823
+ gx, gy = base_grid[self._spawn_index % len(base_grid)]
824
+ self._spawn_index += 1
825
+ return QPointF(origin.x() + gx * step * ring, origin.y() + gy * step * ring)
826
+
827
+ def _remove_node_by_uuid(self, node_uuid: str):
828
+ """Remove a node model by UUID (graph will emit nodeRemoved)."""
829
+ self.graph.remove_node(node_uuid)
830
+
831
+ def _add_edge_for_connection(self, conn: ConnectionModel):
832
+ """Create an EdgeItem in the scene for a given ConnectionModel."""
833
+ ex = self._conn_uuid_to_edge.get(conn.uuid)
834
+ if ex and _qt_is_valid(ex):
835
+ self._dbg(f"_add_edge_for_connection: guard skip duplicate for uuid={conn.uuid}")
836
+ return
837
+
838
+ src_item = self._uuid_to_item.get(conn.src_node)
839
+ dst_item = self._uuid_to_item.get(conn.dst_node)
840
+ if not src_item or not dst_item:
841
+ self._dbg(f"_add_edge_for_connection: missing node items for conn={conn.uuid}")
842
+ return
843
+ src_port = src_item._out_ports.get(conn.src_prop)
844
+ dst_port = dst_item._in_ports.get(conn.dst_prop)
845
+ if not src_port or not dst_port:
846
+ self._dbg(f"_add_edge_for_connection: missing ports src={bool(src_port)} dst={bool(dst_port)}")
847
+ return
848
+ edge = EdgeItem(src_port, dst_port, temporary=False)
849
+ edge.update_path()
850
+ self.scene.addItem(edge)
851
+ src_item.add_edge(edge)
852
+ dst_item.add_edge(edge)
853
+ src_port.increment_connections(+1)
854
+ dst_port.increment_connections(+1)
855
+ edge._conn_uuid = conn.uuid
856
+ self._conn_uuid_to_edge[conn.uuid] = edge
857
+ self._dbg(f"_add_edge_for_connection: edge id={id(edge)} mapped to uuid={conn.uuid}")
858
+
859
+ def _remove_connection_by_uuid(self, conn_uuid: str):
860
+ """Remove a connection by UUID (graph will emit connectionRemoved)."""
861
+ self._dbg(f"_remove_connection_by_uuid -> {conn_uuid}")
862
+ self.graph.remove_connection(conn_uuid)
863
+
864
+ # ---------- Theming helpers ----------
865
+
866
+ def _update_theme(self):
867
+ """Refresh colors, pens, and re-apply styles to content widgets."""
868
+ if not getattr(self, "_alive", True):
869
+ return
870
+ if getattr(self, "_closing", False):
871
+ return
872
+ if getattr(self, "view", None) is None:
873
+ return
874
+ self._dbg("_update_theme")
875
+ for item in self._uuid_to_item.values():
876
+ if _qt_is_valid(item):
877
+ item._apply_resize(item.size(), clamp=True)
878
+ item.update()
879
+ for p in list(item._in_ports.values()) + list(item._out_ports.values()):
880
+ if _qt_is_valid(p):
881
+ p.notify_theme_changed()
882
+ for edge in self._conn_uuid_to_edge.values():
883
+ if _qt_is_valid(edge):
884
+ edge._update_pen()
885
+ edge.update()
886
+ if _qt_is_valid(self.view):
887
+ self.view.viewport().update()
888
+ self._reapply_stylesheets()
889
+
890
+ def _current_stylesheet(self) -> str:
891
+ """Return the active stylesheet from window or QApplication."""
892
+ wnd = self.window()
893
+ if isinstance(wnd, QWidget) and wnd.styleSheet():
894
+ return wnd.styleSheet()
895
+ if QApplication.instance() and QApplication.instance().styleSheet():
896
+ return QApplication.instance().styleSheet()
897
+ return ""
898
+
899
+ def _current_palette(self) -> QPalette:
900
+ """Return the active palette from window or QApplication."""
901
+ wnd = self.window()
902
+ if isinstance(wnd, QWidget):
903
+ return wnd.palette()
904
+ return QApplication.instance().palette() if QApplication.instance() else self.palette()
905
+
906
+ def _apply_styles_to_content(self, content_widget: QWidget):
907
+ """Propagate palette and stylesheet to the embedded content widget subtree."""
908
+ if content_widget is None:
909
+ return
910
+ content_widget.setAttribute(Qt.WA_StyledBackground, True)
911
+ stylesheet = self._current_stylesheet()
912
+ pal = self._current_palette()
913
+ content_widget.setPalette(pal)
914
+ if stylesheet:
915
+ content_widget.setStyleSheet(stylesheet)
916
+ content_widget.ensurePolished()
917
+ for w in content_widget.findChildren(QWidget):
918
+ w.ensurePolished()
919
+
920
+ def _reapply_stylesheets(self):
921
+ """Reapply palette and stylesheet to all NodeContentWidget instances."""
922
+ if not getattr(self, "_alive", True) or getattr(self, "_closing", False):
923
+ return
924
+ stylesheet = self._current_stylesheet()
925
+ pal = self._current_palette()
926
+ for item in self._uuid_to_item.values():
927
+ if item._content and _qt_is_valid(item._content):
928
+ item._content.setPalette(pal)
929
+ if stylesheet:
930
+ item._content.setStyleSheet(stylesheet)
931
+ item._content.ensurePolished()
932
+ for w in item._content.findChildren(QWidget):
933
+ w.ensurePolished()
934
+
935
+ # ---------- Edge/Port helpers + rewire ----------
936
+
937
+ def _edges_for_port(self, port: PortItem) -> List[EdgeItem]:
938
+ """Return all persistent edges currently attached to a port."""
939
+ res: List[EdgeItem] = []
940
+ nitem = port.node_item
941
+ for e in list(nitem._edges):
942
+ if _qt_is_valid(e) and not e.temporary and (e.src_port is port or e.dst_port is port):
943
+ res.append(e)
944
+ for it in self.scene.items():
945
+ if isinstance(it, EdgeItem) and not it.temporary and (it.src_port is port or it.dst_port is port):
946
+ if it not in res:
947
+ res.append(it)
948
+ self._dbg(f"_edges_for_port: port={port.prop_id}/{port.side} -> {len(res)} edges")
949
+ return res
950
+
951
+ def _port_has_connections(self, port: PortItem) -> bool:
952
+ """Return True if the port has any attached edges (fast path + scene scan)."""
953
+ res = (getattr(port, "_connected_count", 0) > 0) or bool(self._edges_for_port(port))
954
+ self._dbg(f"_port_has_connections: port={port.prop_id}/{port.side} -> {res}")
955
+ return res
956
+
957
+ def _choose_edge_near_cursor(self, edges: List[EdgeItem], cursor_scene: QPointF, ref_port: PortItem) -> Optional[EdgeItem]:
958
+ """Pick the edge whose 'other end' is closest to the cursor (ties broken by distance)."""
959
+ if not edges:
960
+ return None
961
+ if len(edges) == 1:
962
+ return edges[0]
963
+ best = None
964
+ best_d2 = float("inf")
965
+ for e in edges:
966
+ other = e.dst_port if e.src_port is ref_port else e.src_port
967
+ op = other.scenePos()
968
+ dx = cursor_scene.x() - op.x()
969
+ dy = cursor_scene.y() - op.y()
970
+ d2 = dx*dx + dy*dy
971
+ if d2 < best_d2:
972
+ best_d2 = d2
973
+ best = e
974
+ return best
975
+
976
+ def _allowed_from_spec(self, node: NodeModel, pid: str) -> Tuple[int, int]:
977
+ """Return (allowed_outputs, allowed_inputs) for a property ID using registry or model."""
978
+ out_allowed = 0
979
+ in_allowed = 0
980
+ try:
981
+ tp = node.type
982
+ spec_type = self.graph.registry.get(tp) if self.graph and self.graph.registry else None
983
+ prop_obj = None
984
+ if spec_type is not None:
985
+ for attr in ("properties", "props", "fields", "ports", "inputs", "outputs"):
986
+ try:
987
+ cont = getattr(spec_type, attr, None)
988
+ if isinstance(cont, dict) and pid in cont:
989
+ prop_obj = cont[pid]
990
+ break
991
+ except Exception:
992
+ pass
993
+ if prop_obj is None:
994
+ for meth in ("get_property", "property_spec", "get_prop", "prop", "property"):
995
+ if hasattr(spec_type, meth):
996
+ try:
997
+ prop_obj = getattr(spec_type, meth)(pid)
998
+ break
999
+ except Exception:
1000
+ pass
1001
+ def _get_int(o, k, default=0):
1002
+ try:
1003
+ v = getattr(o, k, None)
1004
+ if isinstance(v, int):
1005
+ return v
1006
+ except Exception:
1007
+ pass
1008
+ try:
1009
+ if isinstance(o, dict):
1010
+ v = o.get(k)
1011
+ if isinstance(v, int):
1012
+ return v
1013
+ except Exception:
1014
+ pass
1015
+ return default
1016
+ if prop_obj is not None:
1017
+ out_allowed = _get_int(prop_obj, "allowed_outputs", 0)
1018
+ in_allowed = _get_int(prop_obj, "allowed_inputs", 0)
1019
+ else:
1020
+ pm = node.properties.get(pid)
1021
+ if pm:
1022
+ out_allowed = int(getattr(pm, "allowed_outputs", 0))
1023
+ in_allowed = int(getattr(pm, "allowed_inputs", 0))
1024
+ except Exception:
1025
+ pm = node.properties.get(pid)
1026
+ if pm:
1027
+ out_allowed = int(getattr(pm, "allowed_outputs", 0))
1028
+ in_allowed = int(getattr(pm, "allowed_inputs", 0))
1029
+ return out_allowed, in_allowed
1030
+
1031
+ def _can_connect_during_rewire(self, src: PortItem, dst: PortItem) -> bool:
1032
+ """Lightweight, allocation-free check to validate a rewire candidate."""
1033
+ try:
1034
+ src_node = self.graph.nodes.get(src.node_item.node.uuid)
1035
+ dst_node = self.graph.nodes.get(dst.node_item.node.uuid)
1036
+ if not src_node or not dst_node:
1037
+ return False
1038
+ sp = src_node.properties.get(src.prop_id)
1039
+ dp = dst_node.properties.get(dst.prop_id)
1040
+ if not sp or not dp:
1041
+ return False
1042
+ if sp.type != dp.type:
1043
+ return False
1044
+ sp_out_allowed, _ = self._allowed_from_spec(src_node, src.prop_id)
1045
+ _, dp_in_allowed = self._allowed_from_spec(dst_node, dst.prop_id)
1046
+ skip_uuid = self._rewire_conn_uuid
1047
+ src_count = sum(1 for c in self.graph.connections.values()
1048
+ if c.src_node == src.node_item.node.uuid and c.src_prop == src.prop_id and c.uuid != skip_uuid)
1049
+ dst_count = sum(1 for c in self.graph.connections.values()
1050
+ if c.dst_node == dst.node_item.node.uuid and c.dst_prop == dst.prop_id and c.uuid != skip_uuid)
1051
+ if isinstance(sp_out_allowed, int) and sp_out_allowed > 0 and src_count >= sp_out_allowed:
1052
+ return False
1053
+ if isinstance(dp_in_allowed, int) and dp_in_allowed > 0 and dst_count >= dp_in_allowed:
1054
+ return False
1055
+ return True
1056
+ except Exception:
1057
+ return False
1058
+
1059
+ def _can_connect_for_interaction(self, src: PortItem, dst: PortItem) -> bool:
1060
+ """Check whether src -> dst would be allowed, considering current interaction mode."""
1061
+ if self._wire_state in ("rewiring", "rewire-primed"):
1062
+ return self._can_connect_during_rewire(src, dst)
1063
+ ok, _ = self.graph.can_connect((src.node_item.node.uuid, src.prop_id),
1064
+ (dst.node_item.node.uuid, dst.prop_id))
1065
+ return ok
1066
+
1067
+ def _find_compatible_port_at(self, scene_pos: QPointF, radius: Optional[float] = None) -> Optional[PortItem]:
1068
+ """Find nearest compatible port to scene_pos when dragging an interactive wire."""
1069
+ if self._interactive_src_port is None:
1070
+ return None
1071
+ src = self._interactive_src_port
1072
+ pick_r_cfg = float(getattr(self, "_port_pick_radius", 10.0) or 10.0)
1073
+ pick_r = float(radius) if radius is not None else max(18.0, pick_r_cfg + 10.0)
1074
+ rect = QRectF(scene_pos.x() - pick_r, scene_pos.y() - pick_r, 2 * pick_r, 2 * pick_r) if False else QRectF(scene_pos.x() - pick_r, scene_pos.y() - pick_r, 2 * pick_r, 2 * pick_r)
1075
+ items = self.scene.items(rect, Qt.IntersectsItemShape, Qt.DescendingOrder, self.view.transform())
1076
+ best: Optional[PortItem] = None
1077
+ best_d2 = float("inf")
1078
+ for it in items:
1079
+ if not isinstance(it, PortItem) or it is src:
1080
+ continue
1081
+ a, b = self._resolve_direction(src, it)
1082
+ if not a or not b:
1083
+ continue
1084
+ if a.node_item.node.uuid == b.node_item.node.uuid:
1085
+ continue
1086
+ if not self._can_connect_for_interaction(a, b):
1087
+ continue
1088
+ pp = it.scenePos()
1089
+ dx = scene_pos.x() - pp.x()
1090
+ dy = scene_pos.y() - pp.y()
1091
+ d2 = dx * dx + dy * dy
1092
+ if d2 < best_d2:
1093
+ best_d2 = d2
1094
+ best = it
1095
+ if best:
1096
+ self._dbg(f"_find_compatible_port_at: FOUND port={best.prop_id}/{best.side} on node={best.node_item.node.name}")
1097
+ return best
1098
+
1099
+ def _enter_wire_drag_mode(self):
1100
+ """Temporarily disable view drag to ensure smooth wire interaction."""
1101
+ try:
1102
+ self._saved_drag_mode = self.view.dragMode()
1103
+ self.view.setDragMode(QGraphicsView.NoDrag)
1104
+ except Exception:
1105
+ self._saved_drag_mode = None
1106
+
1107
+ def _leave_wire_drag_mode(self):
1108
+ """Restore the view drag mode after wire interaction finishes."""
1109
+ if self._saved_drag_mode is not None and _qt_is_valid(self.view):
1110
+ try:
1111
+ self.view.setDragMode(self._saved_drag_mode)
1112
+ except Exception:
1113
+ pass
1114
+ self._saved_drag_mode = None
1115
+
1116
+ def _on_port_clicked(self, port: PortItem):
1117
+ """Start drawing a new wire or prime a rewire depending on modifiers and port state.
1118
+
1119
+ Behavior:
1120
+ - Click: start a new connection from the clicked port.
1121
+ - Ctrl+Click on a connected port: prime rewire/detach, selecting the closest edge.
1122
+ """
1123
+ self._dbg(f"_on_port_clicked: side={port.side}, prop={port.prop_id}, connected={self._port_has_connections(port)}")
1124
+ mods = QApplication.keyboardModifiers()
1125
+ # Ctrl+click on a connected port enters rewire/detach mode; plain click always starts a new connection
1126
+ rewire_requested = bool(mods & Qt.ControlModifier)
1127
+ if rewire_requested and self._port_has_connections(port):
1128
+ cursor_scene = self.view.mapToScene(self.view.mapFromGlobal(QCursor.pos()))
1129
+ edges = self._edges_for_port(port)
1130
+ edge = self._choose_edge_near_cursor(edges, cursor_scene, port)
1131
+ if edge:
1132
+ self._prime_rewire_from_conn(port, getattr(edge, "_conn_uuid", None), edge, cursor_scene)
1133
+ return
1134
+ self._start_draw(port)
1135
+
1136
+ def _prime_rewire_from_conn(self, origin_port: PortItem, conn_uuid: Optional[str],
1137
+ edge: Optional[EdgeItem], press_scene_pos: QPointF):
1138
+ """Prime the 'rewire' state from an existing connection without moving yet.
1139
+
1140
+ This state transitions into actual rewiring when the cursor moves beyond a small threshold.
1141
+ """
1142
+ if self._wire_state != "idle":
1143
+ return
1144
+ self._wire_state = "rewire-primed"
1145
+ self._rewire_conn_uuid = conn_uuid
1146
+ self._rewire_hidden_edge = edge
1147
+ fixed_src = origin_port if origin_port.side == "output" else (edge.src_port if edge else None)
1148
+ if fixed_src is None:
1149
+ self._wire_state = "idle"
1150
+ self._rewire_conn_uuid = None
1151
+ self._rewire_hidden_edge = None
1152
+ return
1153
+ self._rewire_fixed_src = fixed_src
1154
+ self._interactive_src_port = fixed_src
1155
+ self._rewire_press_scene_pos = QPointF(press_scene_pos)
1156
+
1157
+ def _start_draw(self, src_port: PortItem):
1158
+ """Begin interactive wire drawing from a source port."""
1159
+ if self._wire_state != "idle":
1160
+ return
1161
+ self._wire_state = "drawing"
1162
+ self._interactive_src_port = src_port
1163
+ self._interactive_edge = EdgeItem(src_port=src_port, dst_port=src_port, temporary=True)
1164
+ self.scene.addItem(self._interactive_edge)
1165
+ self._interactive_edge.update_path(src_port.scenePos())
1166
+ self._enter_wire_drag_mode()
1167
+
1168
+ def _start_rewire_from_edge(self, edge: EdgeItem, cursor_scene_pos: QPointF):
1169
+ """Start rewiring by hiding the original edge and creating a temporary wire."""
1170
+ if self._wire_state != "idle" or not _qt_is_valid(edge):
1171
+ return
1172
+ self._wire_state = "rewiring"
1173
+ self._rewire_hidden_edge = edge
1174
+ self._rewire_conn_uuid = getattr(edge, "_conn_uuid", None)
1175
+ self._rewire_fixed_src = edge.src_port
1176
+ self._interactive_src_port = edge.src_port
1177
+ edge.setVisible(False)
1178
+ self._interactive_edge = EdgeItem(src_port=edge.src_port, dst_port=edge.src_port, temporary=True)
1179
+ self.scene.addItem(self._interactive_edge)
1180
+ self._interactive_edge.update_path(end_pos=cursor_scene_pos)
1181
+ self._enter_wire_drag_mode()
1182
+
1183
+ def _resolve_direction(self, a: PortItem, b: PortItem) -> Tuple[Optional[PortItem], Optional[PortItem]]:
1184
+ """Return (src, dst) if ports are complementary, otherwise (None, None)."""
1185
+ if a.side == "output" and b.side == "input":
1186
+ return a, b
1187
+ if a.side == "input" and b.side == "output":
1188
+ return b, a
1189
+ return None, None
1190
+
1191
+ def _set_hover_candidate(self, port: Optional[PortItem]):
1192
+ """Highlight/unhighlight the current hover-accept candidate port."""
1193
+ if self._hover_candidate is port:
1194
+ return
1195
+ if self._hover_candidate is not None and _qt_is_valid(self._hover_candidate):
1196
+ self._hover_candidate.set_accept_highlight(False)
1197
+ self._hover_candidate = port
1198
+ if self._hover_candidate is not None and _qt_is_valid(self._hover_candidate):
1199
+ self._hover_candidate.set_accept_highlight(True)
1200
+
1201
+ def eventFilter(self, obj, event):
1202
+ """Intercept scene mouse events to implement interactive wiring and rewiring."""
1203
+ if not self._alive or self.scene is None or self.view is None:
1204
+ return False
1205
+ if obj is self.scene:
1206
+ et = event.type()
1207
+
1208
+ # Rewire: convert primed state into actual rewiring after small cursor movement.
1209
+ if self._wire_state == "rewire-primed" and et == QEvent.GraphicsSceneMouseMove:
1210
+ pos = event.scenePos()
1211
+ if self._rewire_press_scene_pos is not None:
1212
+ dist = abs(pos.x() - self._rewire_press_scene_pos.x()) + abs(pos.y() - self._rewire_press_scene_pos.y())
1213
+ else:
1214
+ dist = 9999
1215
+ if dist > 6 and self._rewire_fixed_src is not None:
1216
+ if self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
1217
+ self._rewire_hidden_edge.setVisible(False)
1218
+ self._interactive_edge = EdgeItem(src_port=self._rewire_fixed_src, dst_port=self._rewire_fixed_src, temporary=True)
1219
+ self.scene.addItem(self._interactive_edge)
1220
+ self._interactive_edge.update_path(end_pos=pos)
1221
+ self._enter_wire_drag_mode()
1222
+ self._wire_state = "rewiring"
1223
+ candidate = self._find_compatible_port_at(pos, radius=28.0)
1224
+ self._set_hover_candidate(candidate)
1225
+ return True
1226
+
1227
+ # While dragging a wire, update temporary path and hover highlight.
1228
+ if self._interactive_edge is not None and et == QEvent.GraphicsSceneMouseMove:
1229
+ pos = event.scenePos()
1230
+ if _qt_is_valid(self._interactive_edge):
1231
+ self._interactive_edge.update_path(end_pos=pos)
1232
+ candidate = self._find_compatible_port_at(pos, radius=28.0)
1233
+ self._set_hover_candidate(candidate)
1234
+ return True
1235
+
1236
+ # RMB cancels interactive connection (drawing or rewiring).
1237
+ if self._interactive_edge is not None and et == QEvent.GraphicsSceneMousePress and event.button() == Qt.RightButton:
1238
+ self._cancel_interactive_connection()
1239
+ return True
1240
+
1241
+ # LMB release: finalize interactive connection or rewire.
1242
+ if et == QEvent.GraphicsSceneMouseRelease and event.button() == Qt.LeftButton:
1243
+ if self._wire_state == "rewire-primed":
1244
+ self._finish_interactive_connection()
1245
+ return True
1246
+
1247
+ if self._interactive_edge is not None:
1248
+ pos = event.scenePos()
1249
+ target = self._find_compatible_port_at(pos, radius=48.0)
1250
+ if target is None and self._hover_candidate is not None:
1251
+ target = self._hover_candidate
1252
+
1253
+ if self._wire_state == "rewiring":
1254
+ if isinstance(target, PortItem) and self._rewire_fixed_src is not None:
1255
+ src, dst = self._resolve_direction(self._rewire_fixed_src, target)
1256
+ if src and dst:
1257
+ if self._rewire_conn_uuid and self._rewire_conn_uuid in self.graph.connections:
1258
+ old = self.graph.connections.get(self._rewire_conn_uuid)
1259
+ if old:
1260
+ self._undo.push(RewireConnectionCommand(self, old, src, dst))
1261
+ else:
1262
+ self._undo.push(ConnectCommand(self, src, dst))
1263
+ if self._rewire_hidden_edge:
1264
+ self._detach_edge_item(self._rewire_hidden_edge)
1265
+ else:
1266
+ # Drop on empty space: delete or detach depending on availability.
1267
+ if self._rewire_conn_uuid and self._rewire_conn_uuid in self.graph.connections:
1268
+ old = self.graph.connections.get(self._rewire_conn_uuid)
1269
+ if old:
1270
+ self._undo.push(RewireConnectionCommand(self, old, None, None))
1271
+ elif self._rewire_hidden_edge:
1272
+ self._delete_edge(self._rewire_hidden_edge)
1273
+ self._finish_interactive_connection()
1274
+ return True
1275
+
1276
+ elif self._wire_state == "drawing":
1277
+ if isinstance(target, PortItem) and self._interactive_src_port is not None:
1278
+ src, dst = self._resolve_direction(self._interactive_src_port, target)
1279
+ if src and dst:
1280
+ self._undo.push(ConnectCommand(self, src, dst))
1281
+ self._finish_interactive_connection()
1282
+ return True
1283
+
1284
+ return super().eventFilter(obj, event)
1285
+
1286
+ def _reset_interaction_states(self, remove_hidden_edges: bool):
1287
+ """Reset all interactive wiring state and optionally remove hidden edges."""
1288
+ self._set_hover_candidate(None)
1289
+ if self._interactive_edge and _qt_is_valid(self._interactive_edge):
1290
+ try:
1291
+ self.scene.removeItem(self._interactive_edge)
1292
+ except Exception:
1293
+ pass
1294
+ self._interactive_edge = None
1295
+ self._interactive_src_port = None
1296
+
1297
+ if remove_hidden_edges and self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
1298
+ try:
1299
+ self.scene.removeItem(self._rewire_hidden_edge)
1300
+ except Exception:
1301
+ pass
1302
+ elif self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
1303
+ if self._rewire_hidden_edge.scene() is not None:
1304
+ self._rewire_hidden_edge.setVisible(True)
1305
+
1306
+ self._wire_state = "idle"
1307
+ self._rewire_conn_uuid = None
1308
+ self._rewire_hidden_edge = None
1309
+ self._rewire_fixed_src = None
1310
+ self._rewire_press_scene_pos = None
1311
+ self._leave_wire_drag_mode()
1312
+
1313
+ def _finish_interactive_connection(self):
1314
+ """Finish interactive connection/rewire and restore view drag mode."""
1315
+ self._reset_interaction_states(remove_hidden_edges=False)
1316
+
1317
+ def _cancel_interactive_connection(self):
1318
+ """Cancel interactive connection/rewire."""
1319
+ self._reset_interaction_states(remove_hidden_edges=False)
1320
+
1321
+ # ---------- Delete helpers ----------
1322
+
1323
+ def _on_delete_shortcut(self):
1324
+ """Delete selected nodes and/or connections unless a text input is focused (undoable)."""
1325
+ if self._is_text_input_focused():
1326
+ self._dbg("Delete ignored: text input is focused")
1327
+ return
1328
+ if not _qt_is_valid(self.scene):
1329
+ return
1330
+
1331
+ selected = list(self.scene.selectedItems())
1332
+ if not selected:
1333
+ return
1334
+
1335
+ nodes = [it for it in selected if isinstance(it, NodeItem)]
1336
+ edges = [it for it in selected if isinstance(it, EdgeItem)]
1337
+
1338
+ # Filter out edges that are attached to nodes being deleted; they will be handled by the node command.
1339
+ if nodes and edges:
1340
+ node_uuids = {n.node.uuid for n in nodes}
1341
+ edges = [
1342
+ e for e in edges
1343
+ if _qt_is_valid(e.src_port) and _qt_is_valid(e.dst_port) and
1344
+ e.src_port.node_item.node.uuid not in node_uuids and
1345
+ e.dst_port.node_item.node.uuid not in node_uuids
1346
+ ]
1347
+
1348
+ if not nodes and not edges:
1349
+ return
1350
+
1351
+ self._dbg(f"Delete shortcut -> nodes={len(nodes)}, edges={len(edges)} (undoable)")
1352
+ self._undo.beginMacro("Delete selection")
1353
+ try:
1354
+ for n in nodes:
1355
+ # Push per-node undoable deletion (restores its own connections)
1356
+ self._delete_node_item(n)
1357
+ for e in edges:
1358
+ self._delete_edge_undoable(e)
1359
+ finally:
1360
+ self._undo.endMacro()
1361
+
1362
+ def _delete_node_item(self, item: "NodeItem"):
1363
+ """Delete a node via undoable command."""
1364
+ try:
1365
+ if _qt_is_valid(item):
1366
+ self._dbg(f"_delete_node_item (undoable) -> node={item.node.uuid}")
1367
+ self._undo.push(DeleteNodeCommand(self, item))
1368
+ except Exception:
1369
+ pass
1370
+
1371
+ def _detach_edge_item(self, edge: EdgeItem):
1372
+ """Detach an edge item from scene and internal maps without touching the graph."""
1373
+ try:
1374
+ edge.src_port.increment_connections(-1)
1375
+ edge.dst_port.increment_connections(-1)
1376
+ except Exception:
1377
+ pass
1378
+ if _qt_is_valid(edge.src_port.node_item):
1379
+ edge.src_port.node_item.remove_edge(edge)
1380
+ if _qt_is_valid(edge.dst_port.node_item):
1381
+ edge.dst_port.node_item.remove_edge(edge)
1382
+ for k, v in list(self._conn_uuid_to_edge.items()):
1383
+ if v is edge:
1384
+ self._conn_uuid_to_edge.pop(k, None)
1385
+ if _qt_is_valid(edge):
1386
+ try:
1387
+ self.scene.removeItem(edge)
1388
+ except Exception:
1389
+ pass
1390
+
1391
+ def _delete_edge(self, edge: EdgeItem):
1392
+ """Delete an edge either by graph UUID or by detaching the item if orphan."""
1393
+ conn_uuid = getattr(edge, "_conn_uuid", None)
1394
+ exists = bool(conn_uuid and conn_uuid in self.graph.connections)
1395
+ if exists:
1396
+ self._remove_connection_by_uuid(conn_uuid)
1397
+ else:
1398
+ self._detach_edge_item(edge)
1399
+
1400
+ def _delete_selected_connections(self):
1401
+ """Delete all currently selected edges in the scene (undoable)."""
1402
+ if not _qt_is_valid(self.scene):
1403
+ return
1404
+ for it in list(self.scene.selectedItems()):
1405
+ if isinstance(it, EdgeItem):
1406
+ self._delete_edge_undoable(it)
1407
+ if _qt_is_valid(self.view):
1408
+ self.view.viewport().update()
1409
+
1410
+ def _delete_edge_undoable(self, edge: EdgeItem):
1411
+ """Delete a connection through the undo stack; fallback to immediate detach for orphans."""
1412
+ conn_uuid = getattr(edge, "_conn_uuid", None)
1413
+ if conn_uuid and conn_uuid in self.graph.connections:
1414
+ conn = self.graph.connections[conn_uuid]
1415
+ self._undo.push(DeleteConnectionCommand(self, conn))
1416
+ else:
1417
+ # Orphan/temporary edge: remove immediately (not tracked in the graph)
1418
+ self._delete_edge(edge)
1419
+
1420
+ # ---------- Clear helpers ----------
1421
+
1422
+ def _remove_all_edge_items_from_scene(self):
1423
+ """Remove every EdgeItem from the scene (used during teardown)."""
1424
+ if not _qt_is_valid(self.scene):
1425
+ return
1426
+ for it in list(self.scene.items()):
1427
+ if isinstance(it, EdgeItem):
1428
+ try:
1429
+ self.scene.removeItem(it)
1430
+ except Exception:
1431
+ pass
1432
+
1433
+ def _cleanup_node_proxies(self):
1434
+ """Detach and delete QWidget proxies inside all NodeItems to prevent leaks."""
1435
+ for item in list(self._uuid_to_item.values()):
1436
+ try:
1437
+ if _qt_is_valid(item):
1438
+ # Break edges/ports and disconnect signals before tearing down widgets/proxies
1439
+ try:
1440
+ item.pre_cleanup()
1441
+ except Exception:
1442
+ pass
1443
+ item.detach_proxy_widget()
1444
+ except Exception:
1445
+ pass
1446
+
1447
+ def _clear_scene_only(self, hard: bool = False):
1448
+ """Clear all NodeItems and EdgeItems from the scene (graph untouched unless caller decides)."""
1449
+ self._cleanup_node_proxies()
1450
+ for edge in list(self._conn_uuid_to_edge.values()):
1451
+ if _qt_is_valid(edge):
1452
+ try:
1453
+ self.scene.removeItem(edge)
1454
+ except Exception:
1455
+ pass
1456
+ self._conn_uuid_to_edge.clear()
1457
+ for item in list(self._uuid_to_item.values()):
1458
+ if _qt_is_valid(item):
1459
+ try:
1460
+ # Ensure references are broken even if proxies were not detached earlier
1461
+ try:
1462
+ item.pre_cleanup()
1463
+ except Exception:
1464
+ pass
1465
+ self.scene.removeItem(item)
1466
+ except Exception:
1467
+ pass
1468
+ self._uuid_to_item.clear()
1469
+ if hard:
1470
+ self._remove_all_edge_items_from_scene()
1471
+
1472
+ def _clear_scene_and_graph(self):
1473
+ """Clear both the scene items and the graph models."""
1474
+ self._reset_interaction_states(remove_hidden_edges=True)
1475
+ self._clear_scene_only(hard=True)
1476
+ self.graph.clear(silent=True)
1477
+
1478
+ # ---------- Base ID auto-increment ----------
1479
+
1480
+ def _is_base_id_pid(self, pid: str, pm: PropertyModel) -> bool:
1481
+ """Detect base-id-like property ids/names or explicit flag on the PropertyModel."""
1482
+ try:
1483
+ if getattr(pm, "is_base_id", False):
1484
+ return True
1485
+ except Exception:
1486
+ pass
1487
+ pid_key = (pid or "").strip().lower().replace(" ", "_")
1488
+ name_key = ""
1489
+ try:
1490
+ name_key = (pm.name or "").strip().lower().replace(" ", "_")
1491
+ except Exception:
1492
+ pass
1493
+ candidates = {"base_id", "baseid", "base", "id_base", "basename", "base_name"}
1494
+ return pid_key in candidates or name_key in candidates
1495
+
1496
+ def _split_base_suffix(self, text: str) -> Tuple[str, Optional[int]]:
1497
+ """Split 'name_12' into ('name', 12). If no numeric suffix, return (text, None)."""
1498
+ if not isinstance(text, str):
1499
+ text = f"{text}"
1500
+ parts = text.rsplit("_", 1)
1501
+ if len(parts) == 2 and parts[1].isdigit():
1502
+ return parts[0], int(parts[1])
1503
+ return text, None
1504
+
1505
+ def _reset_base_id_counters(self):
1506
+ """Reset per-layout counters for Base ID suffixes."""
1507
+ self._base_id_max = {}
1508
+
1509
+ def _bump_base_counter(self, prop_id: str, base: str, suffix: int):
1510
+ """Ensure the internal counter for (prop_id, base) is at least suffix."""
1511
+ try:
1512
+ d = self._base_id_max.setdefault(prop_id, {})
1513
+ cur = int(d.get(base, 0))
1514
+ if int(suffix) > cur:
1515
+ d[base] = int(suffix)
1516
+ except Exception:
1517
+ pass
1518
+
1519
+ def _current_max_suffix_in_graph(self, prop_id: str, base: str) -> int:
1520
+ """Scan the current graph and return the highest numeric suffix used for given (prop_id, base).
1521
+ This guards against stale in-memory counters when switching layouts without a hard reset."""
1522
+ max_suf = 0
1523
+ try:
1524
+ if not base:
1525
+ return 0
1526
+ for node in self.graph.nodes.values():
1527
+ pm = node.properties.get(prop_id)
1528
+ if not pm:
1529
+ continue
1530
+ raw = "" if pm.value is None else str(pm.value)
1531
+ b, suf = self._split_base_suffix(raw)
1532
+ if b == base and isinstance(suf, int):
1533
+ if suf > max_suf:
1534
+ max_suf = suf
1535
+ except Exception:
1536
+ return max_suf
1537
+ return max_suf
1538
+
1539
+ def _next_suffix_for_base(self, prop_id: str, base: str) -> int:
1540
+ """Return next suffix to use for a given (prop_id, base) scoped to the current layout.
1541
+ Always computes from the live graph to avoid stale counters leaking between layouts."""
1542
+ try:
1543
+ actual_max = self._current_max_suffix_in_graph(prop_id, base)
1544
+ # Keep internal cache in sync with the observed state
1545
+ self._bump_base_counter(prop_id, base, actual_max)
1546
+ return max(1, actual_max + 1)
1547
+ except Exception:
1548
+ return 1
1549
+
1550
+ def _update_base_id_counters_from_node(self, node: NodeModel):
1551
+ """Update counters using values from a single node."""
1552
+ try:
1553
+ for pid, pm in node.properties.items():
1554
+ if not self._is_base_id_pid(pid, pm):
1555
+ continue
1556
+ raw = "" if pm.value is None else str(pm.value)
1557
+ base, suf = self._split_base_suffix(raw)
1558
+ if base is None or base == "":
1559
+ continue
1560
+ if suf is None:
1561
+ # At least register the base with suffix 0
1562
+ self._bump_base_counter(pid, base, 0)
1563
+ else:
1564
+ self._bump_base_counter(pid, base, int(suf))
1565
+ except Exception:
1566
+ pass
1567
+
1568
+ def _rebuild_base_id_counters(self):
1569
+ """Recompute Base ID counters by scanning the current graph."""
1570
+ self._reset_base_id_counters()
1571
+ try:
1572
+ for node in self.graph.nodes.values():
1573
+ self._update_base_id_counters_from_node(node)
1574
+ except Exception:
1575
+ pass
1576
+
1577
+ def _prepare_new_node_defaults(self, node: NodeModel):
1578
+ """When adding a node, auto-increment Base ID to keep uniqueness within the layout.
1579
+
1580
+ Behavior for interactive add (not during load):
1581
+ - Detect base-id-like property.
1582
+ - Ignore any numeric suffix already present in the default.
1583
+ - Always assign base_{next_local_suffix}, where 'next' is computed from per-layout counters.
1584
+ """
1585
+ try:
1586
+ base_pid = None
1587
+ for pid, pm in node.properties.items():
1588
+ if self._is_base_id_pid(pid, pm):
1589
+ base_pid = pid
1590
+ break
1591
+ if not base_pid:
1592
+ return
1593
+
1594
+ pm = node.properties.get(base_pid)
1595
+ if not pm:
1596
+ return
1597
+
1598
+ raw = "" if pm.value is None else str(pm.value)
1599
+ base, _ = self._split_base_suffix(raw)
1600
+ if not base:
1601
+ # Do not generate suffix if base is empty
1602
+ return
1603
+
1604
+ next_suf = self._next_suffix_for_base(base_pid, base)
1605
+ pm.value = f"{base}_{next_suf}"
1606
+ self._bump_base_counter(base_pid, base, next_suf)
1607
+ except Exception:
1608
+ pass
1609
+
1610
+ # ---------- View centering ----------
1611
+
1612
+ def content_bounding_rect(self) -> Optional[QRectF]:
1613
+ """Return the bounding rect of all nodes (in scene coords), or None if empty."""
1614
+ rect: Optional[QRectF] = None
1615
+ for item in self._uuid_to_item.values():
1616
+ r = item.mapToScene(item.boundingRect()).boundingRect()
1617
+ rect = r if rect is None else rect.united(r)
1618
+ return rect
1619
+
1620
+ def center_on_content(self, margin: float = 80.0):
1621
+ """Center the view on all content, expanding scene rect with a safety margin."""
1622
+ rect = self.content_bounding_rect()
1623
+ if rect and rect.isValid():
1624
+ padded = rect.adjusted(-margin, -margin, margin, margin)
1625
+ self.scene.setSceneRect(self.scene.sceneRect().united(padded))
1626
+ self.view.centerOn(rect.center())
1627
+
1628
+ def _save_view_state(self) -> dict:
1629
+ """Return a serializable snapshot of the current view (zoom and scrollbars)."""
1630
+ if not _qt_is_valid(self.view):
1631
+ return {}
1632
+ try:
1633
+ return self.view.view_state()
1634
+ except Exception:
1635
+ # Fallback in case view_state() is not available
1636
+ try:
1637
+ return {
1638
+ "zoom": float(getattr(self.view, "_zoom", 1.0)),
1639
+ "h": int(self.view.horizontalScrollBar().value()),
1640
+ "v": int(self.view.verticalScrollBar().value()),
1641
+ }
1642
+ except Exception:
1643
+ return {}
1644
+
1645
+ def _apply_view_state(self, state: dict):
1646
+ """Apply a previously saved view state to the graphics view."""
1647
+ if not _qt_is_valid(self.view) or not isinstance(state, dict):
1648
+ return
1649
+ try:
1650
+ self.view.set_view_state(state)
1651
+ except Exception:
1652
+ # Fallback path maintaining compatibility with minimal attributes
1653
+ try:
1654
+ z = state.get("zoom") or state.get("scale")
1655
+ if z is not None:
1656
+ self.view.resetTransform()
1657
+ self.view._zoom = 1.0
1658
+ z = max(self.view._min_zoom, min(self.view._max_zoom, float(z)))
1659
+ if abs(z - 1.0) > 1e-9:
1660
+ self.view.scale(z, z)
1661
+ self.view._zoom = z
1662
+ if state.get("h") is not None:
1663
+ self.view.horizontalScrollBar().setValue(int(state["h"]))
1664
+ if state.get("v") is not None:
1665
+ self.view.verticalScrollBar().setValue(int(state["v"]))
1666
+ except Exception:
1667
+ pass
1668
+
1669
+ def _extract_view_state(self, data: dict) -> Optional[dict]:
1670
+ """Extract a view state from various possible blocks in layout data."""
1671
+ if not isinstance(data, dict):
1672
+ return None
1673
+ vs = None
1674
+ for key in ("view", "viewport", "camera", "view_state", "viewState"):
1675
+ blk = data.get(key)
1676
+ if isinstance(blk, dict):
1677
+ vs = blk
1678
+ break
1679
+ if vs is None:
1680
+ # Optional nested blocks
1681
+ for parent in ("meta", "ui"):
1682
+ blk = data.get(parent)
1683
+ if isinstance(blk, dict):
1684
+ for key in ("view", "viewport", "camera", "view_state", "viewState"):
1685
+ if isinstance(blk.get(key), dict):
1686
+ vs = blk.get(key)
1687
+ break
1688
+ if vs is not None:
1689
+ break
1690
+ if not isinstance(vs, dict):
1691
+ return None
1692
+ out = {}
1693
+ z = vs.get("zoom") or vs.get("scale") or vs.get("z")
1694
+ h = vs.get("h") or vs.get("hScroll") or vs.get("scrollH") or vs.get("x")
1695
+ v = vs.get("v") or vs.get("vScroll") or vs.get("scrollV") or vs.get("y")
1696
+ try:
1697
+ if z is not None:
1698
+ out["zoom"] = float(z)
1699
+ except Exception:
1700
+ pass
1701
+ try:
1702
+ if h is not None:
1703
+ out["h"] = int(h)
1704
+ except Exception:
1705
+ pass
1706
+ try:
1707
+ if v is not None:
1708
+ out["v"] = int(v)
1709
+ except Exception:
1710
+ pass
1711
+ return out if out else None
1712
+
1713
+ # ---------- Layout normalization helpers (registry-first import) ----------
1714
+
1715
+ def _coerce_value_for_property(self, pm: PropertyModel, value: Any) -> Any:
1716
+ """Cast persisted value to the property's current type. Be permissive and safe.
1717
+
1718
+ Supported types:
1719
+ - int, float, bool, combo (str), str, text
1720
+ All other/custom types are returned unchanged.
1721
+ """
1722
+ t = getattr(pm, "type", None)
1723
+ try:
1724
+ if t == "int":
1725
+ return int(value)
1726
+ if t == "float":
1727
+ return float(value)
1728
+ if t == "bool":
1729
+ if isinstance(value, bool):
1730
+ return value
1731
+ if isinstance(value, (int, float)):
1732
+ return bool(value)
1733
+ if isinstance(value, str):
1734
+ lv = value.strip().lower()
1735
+ if lv in ("1", "true", "yes", "y", "on"):
1736
+ return True
1737
+ if lv in ("0", "false", "no", "n", "off"):
1738
+ return False
1739
+ return bool(value)
1740
+ if t == "combo":
1741
+ # Keep as string; actual validation against options happens in UI.
1742
+ return str(value)
1743
+ if t in ("str", "text"):
1744
+ return "" if value is None else str(value)
1745
+ # For custom types like "flow", "memory" we keep the raw value (usually None or id).
1746
+ return value
1747
+ except Exception:
1748
+ return value
1749
+
1750
+ def _normalize_layout_dict(self, data: dict) -> Tuple[List[dict], List[dict], Dict[str, Tuple[float, float]], Dict[str, Tuple[float, float]]]:
1751
+ """
1752
+ Normalize various layout shapes into:
1753
+ - nodes: List[ { uuid, type, name?, values:{pid:value}, id? } ]
1754
+ - conns: List[ { uuid?, src_node, src_prop, dst_node, dst_prop } ]
1755
+ - positions: { uuid: [x, y] }
1756
+ - sizes: { uuid: [w, h] }
1757
+ """
1758
+ nodes_norm: List[dict] = []
1759
+ conns_norm: List[dict] = []
1760
+ positions: Dict[str, Tuple[float, float]] = {}
1761
+ sizes: Dict[str, Tuple[float, float]] = {}
1762
+
1763
+ # Positions / sizes blocks used by our save_layout
1764
+ pos_block = data.get("positions", {}) or {}
1765
+ siz_block = data.get("sizes", {}) or {}
1766
+ if isinstance(pos_block, dict):
1767
+ positions = pos_block
1768
+ if isinstance(siz_block, dict):
1769
+ sizes = siz_block
1770
+
1771
+ # Nodes block: accept dict keyed by uuid or list of node dicts.
1772
+ nodes_block = data.get("nodes")
1773
+ if isinstance(nodes_block, dict):
1774
+ for uuid_key, nd in nodes_block.items():
1775
+ norm = self._normalize_node_dict(uuid_key, nd)
1776
+ if norm:
1777
+ # allow node-level position/size override if embedded
1778
+ if isinstance(nd, dict):
1779
+ if "pos" in nd and isinstance(nd["pos"], (list, tuple)) and len(nd["pos"]) == 2:
1780
+ positions[norm["uuid"]] = nd["pos"]
1781
+ if "size" in nd and isinstance(nd["size"], (list, tuple)) and len(nd["size"]) == 2:
1782
+ sizes[norm["uuid"]] = nd["size"]
1783
+ nodes_norm.append(norm)
1784
+ elif isinstance(nodes_block, list):
1785
+ for nd in nodes_block:
1786
+ uuid_key = None
1787
+ if isinstance(nd, dict):
1788
+ uuid_key = nd.get("uuid") or nd.get("id")
1789
+ norm = self._normalize_node_dict(uuid_key, nd)
1790
+ if norm:
1791
+ if isinstance(nd, dict):
1792
+ if "pos" in nd and isinstance(nd["pos"], (list, tuple)) and len(nd["pos"]) == 2:
1793
+ positions[norm["uuid"]] = nd["pos"]
1794
+ if "size" in nd and isinstance(nd["size"], (list, tuple)) and len(nd["size"]) == 2:
1795
+ sizes[norm["uuid"]] = nd["size"]
1796
+ nodes_norm.append(norm)
1797
+ else:
1798
+ # Fallback: try graph-like root with 'graph' or 'items'
1799
+ for alt_key in ("graph", "items"):
1800
+ blk = data.get(alt_key)
1801
+ if isinstance(blk, list):
1802
+ for nd in blk:
1803
+ uuid_key = nd.get("uuid") if isinstance(nd, dict) else None
1804
+ norm = self._normalize_node_dict(uuid_key, nd)
1805
+ if norm:
1806
+ nodes_norm.append(norm)
1807
+ elif isinstance(blk, dict):
1808
+ for uuid_key, nd in blk.items():
1809
+ norm = self._normalize_node_dict(uuid_key, nd)
1810
+ if norm:
1811
+ nodes_norm.append(norm)
1812
+
1813
+ # Connections block: accept dict keyed by uuid or list.
1814
+ conns_block = data.get("connections") or data.get("edges") or data.get("links") or {}
1815
+ if isinstance(conns_block, dict):
1816
+ for cuuid, cd in conns_block.items():
1817
+ norm = self._normalize_conn_dict(cuuid, cd)
1818
+ if norm:
1819
+ conns_norm.append(norm)
1820
+ elif isinstance(conns_block, list):
1821
+ for cd in conns_block:
1822
+ cuuid = cd.get("uuid") if isinstance(cd, dict) else None
1823
+ norm = self._normalize_conn_dict(cuuid, cd)
1824
+ if norm:
1825
+ conns_norm.append(norm)
1826
+
1827
+ return nodes_norm, conns_norm, positions, sizes
1828
+
1829
+ def _normalize_node_dict(self, uuid_key: Optional[str], nd: Any) -> Optional[dict]:
1830
+ """Normalize a node dict to {uuid, type, name?, values{}, id?}."""
1831
+ if not isinstance(nd, dict):
1832
+ return None
1833
+ # Prefer explicit 'uuid' or the key from dict form; 'id' may be used for friendly label
1834
+ nuuid = nd.get("uuid") or uuid_key or nd.get("id")
1835
+ tname = nd.get("type") or nd.get("type_name") or nd.get("t")
1836
+ if not nuuid or not tname:
1837
+ return None
1838
+ name = nd.get("name") or nd.get("title") or nd.get("label")
1839
+ values = self._extract_values_from_properties_block(nd)
1840
+
1841
+ # Extract friendly id if present and distinct from uuid to preserve per-layout numbering.
1842
+ friendly_id: Optional[str] = None
1843
+ try:
1844
+ raw_id = nd.get("id")
1845
+ if isinstance(raw_id, str):
1846
+ if (nd.get("uuid") and raw_id != nd.get("uuid")) or (uuid_key and raw_id != uuid_key) or (not nd.get("uuid") and not uuid_key):
1847
+ friendly_id = raw_id
1848
+ except Exception:
1849
+ friendly_id = None
1850
+
1851
+ res = {"uuid": nuuid, "type": tname, "name": name, "values": values}
1852
+ if friendly_id:
1853
+ res["id"] = friendly_id
1854
+ return res
1855
+
1856
+ def _extract_values_from_properties_block(self, nd: dict) -> Dict[str, Any]:
1857
+ """Extract {prop_id: value} from various shapes of 'properties'."""
1858
+ values: Dict[str, Any] = {}
1859
+ block = nd.get("properties") or nd.get("props") or nd.get("fields") or {}
1860
+ if isinstance(block, dict):
1861
+ for k, v in block.items():
1862
+ if isinstance(v, dict):
1863
+ if "value" in v:
1864
+ values[k] = v.get("value")
1865
+ elif "val" in v:
1866
+ values[k] = v.get("val")
1867
+ else:
1868
+ # heuristics: take raw if simple type
1869
+ values[k] = v.get("default") if "default" in v else v.get("data") if "data" in v else None
1870
+ else:
1871
+ values[k] = v
1872
+ elif isinstance(block, list):
1873
+ for item in block:
1874
+ if not isinstance(item, dict):
1875
+ continue
1876
+ pid = item.get("id") or item.get("key") or item.get("name")
1877
+ if not pid:
1878
+ continue
1879
+ if "value" in item:
1880
+ values[pid] = item.get("value")
1881
+ elif "val" in item:
1882
+ values[pid] = item.get("val")
1883
+ elif "default" in item:
1884
+ values[pid] = item.get("default")
1885
+ return values
1886
+
1887
+ def _normalize_conn_dict(self, cuuid: Optional[str], cd: Any) -> Optional[dict]:
1888
+ """Normalize a connection dict to {uuid?, src_node, src_prop, dst_node, dst_prop}."""
1889
+ if not isinstance(cd, dict):
1890
+ return None
1891
+ s_n = cd.get("src_node") or cd.get("source") or cd.get("from_node")
1892
+ d_n = cd.get("dst_node") or cd.get("target") or cd.get("to_node")
1893
+ s_p = cd.get("src_prop") or cd.get("src") or cd.get("from") or cd.get("out")
1894
+ d_p = cd.get("dst_prop") or cd.get("dst") or cd.get("to") or cd.get("in")
1895
+ if not (s_n and d_n and s_p and d_p):
1896
+ # Try nested shapes: {src:{node,prop}, dst:{node,prop}}
1897
+ src = cd.get("src") or cd.get("from")
1898
+ dst = cd.get("dst") or cd.get("to")
1899
+ if isinstance(src, dict) and isinstance(dst, dict):
1900
+ s_n = src.get("node") or src.get("uuid")
1901
+ s_p = src.get("prop") or src.get("port") or src.get("id")
1902
+ d_n = dst.get("node") or dst.get("uuid")
1903
+ d_p = dst.get("prop") or dst.get("port") or dst.get("id")
1904
+ if not (s_n and d_n and s_p and d_p):
1905
+ return None
1906
+ return {"uuid": cuuid, "src_node": s_n, "src_prop": s_p, "dst_node": d_n, "dst_prop": d_p}
1907
+
1908
+ # ---------- Compact layout serialization (value-only) ----------
1909
+
1910
+ def _registry_prop_spec(self, type_name: str, prop_id: str) -> Optional[Any]:
1911
+ """Return the PropertySpec from the registry for given type/prop id, if available."""
1912
+ try:
1913
+ spec = self.graph.registry.get(type_name) if self.graph and self.graph.registry else None
1914
+ if not spec:
1915
+ return None
1916
+ props = getattr(spec, "properties", None)
1917
+ if isinstance(props, list):
1918
+ for ps in props:
1919
+ if getattr(ps, "id", None) == prop_id:
1920
+ return ps
1921
+ # Accept dict-like collections if registry exposes them
1922
+ for attr in ("props", "fields", "ports", "inputs", "outputs"):
1923
+ cont = getattr(spec, attr, None)
1924
+ if isinstance(cont, dict) and prop_id in cont:
1925
+ return cont[prop_id]
1926
+ except Exception:
1927
+ return None
1928
+ return None
1929
+
1930
+ def _should_persist_property(self, node: NodeModel, pm: PropertyModel) -> bool:
1931
+ """Decide whether a property should be written to layout.
1932
+
1933
+ Rules:
1934
+ - Always include explicit base-id like fields (e.g. 'base_id') as they carry per-node state.
1935
+ - Include editable fields (typical user data).
1936
+ - Include non-editable fields only if their current value differs from registry default.
1937
+ - Ports (e.g., 'flow', 'memory') are skipped unless they carry a non-default value.
1938
+ """
1939
+ try:
1940
+ pid = pm.id
1941
+ if self._is_base_id_pid(pid, pm):
1942
+ return True
1943
+
1944
+ # Editable properties are considered dynamic by nature
1945
+ if bool(getattr(pm, "editable", False)):
1946
+ return True
1947
+
1948
+ # Compare with registry default to detect runtime changes
1949
+ ps = self._registry_prop_spec(node.type, pid)
1950
+ default = getattr(ps, "value", None) if ps is not None else None
1951
+ if pm.value != default:
1952
+ return True
1953
+
1954
+ # Ports rarely carry a data value; skip when value equals default (usually None)
1955
+ return False
1956
+ except Exception:
1957
+ # Be conservative on errors: do not persist
1958
+ return False
1959
+
1960
+ def _serialize_layout_compact(self) -> dict:
1961
+ """Build compact layout with minimal per-property data.
1962
+
1963
+ Output shape:
1964
+ {
1965
+ "nodes": {
1966
+ "<node_uuid>": {
1967
+ "uuid": "...",
1968
+ "id": "...",
1969
+ "name": "...",
1970
+ "type": "Flow/Agent",
1971
+ "properties": {
1972
+ "<prop_id>": {
1973
+ "uuid": "...",
1974
+ "id": "name",
1975
+ "name": "Name",
1976
+ "value": "Alice",
1977
+ "options": [...] # only if options differ from registry default
1978
+ },
1979
+ ...
1980
+ }
1981
+ },
1982
+ ...
1983
+ },
1984
+ "connections": {
1985
+ "<conn_uuid>": { "uuid": "...", "src_node": "...", "src_prop": "...", "dst_node": "...", "dst_prop": "..." },
1986
+ ...
1987
+ }
1988
+ }
1989
+ """
1990
+ nodes_out: Dict[str, dict] = {}
1991
+ for nuuid, n in self.graph.nodes.items():
1992
+ props_out: Dict[str, dict] = {}
1993
+ for pid, pm in n.properties.items():
1994
+ if not isinstance(pm, PropertyModel):
1995
+ continue
1996
+ if not self._should_persist_property(n, pm):
1997
+ continue
1998
+
1999
+ entry = {
2000
+ "uuid": pm.uuid,
2001
+ "id": pm.id,
2002
+ "name": pm.name,
2003
+ "value": pm.value,
2004
+ }
2005
+
2006
+ # Persist options only when they differ from registry default (dynamic options).
2007
+ try:
2008
+ cur_opts = list(getattr(pm, "options", [])) if getattr(pm, "options", None) is not None else None
2009
+ ps = self._registry_prop_spec(n.type, pid)
2010
+ def_opts = list(getattr(ps, "options", [])) if ps is not None and getattr(ps, "options", None) is not None else None
2011
+ if cur_opts is not None:
2012
+ if def_opts is None or cur_opts != def_opts:
2013
+ entry["options"] = cur_opts
2014
+ except Exception:
2015
+ pass
2016
+
2017
+ props_out[pid] = entry
2018
+
2019
+ nodes_out[nuuid] = {
2020
+ "uuid": n.uuid,
2021
+ "id": n.id,
2022
+ "name": n.name,
2023
+ "type": n.type,
2024
+ "properties": props_out,
2025
+ }
2026
+
2027
+ # Minimal connections (dict keyed by uuid for backwards compatibility)
2028
+ conns_out: Dict[str, dict] = {}
2029
+ for cuuid, c in self.graph.connections.items():
2030
+ conns_out[cuuid] = {
2031
+ "uuid": c.uuid,
2032
+ "src_node": c.src_node,
2033
+ "src_prop": c.src_prop,
2034
+ "dst_node": c.dst_node,
2035
+ "dst_prop": c.dst_prop,
2036
+ }
2037
+
2038
+ return {"nodes": nodes_out, "connections": conns_out}