pygpt-net 2.6.58__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 (72) hide show
  1. pygpt_net/CHANGELOG.txt +10 -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/core/filesystem/parser.py +37 -24
  29. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  30. pygpt_net/core/{builder → node_editor}/graph.py +11 -218
  31. pygpt_net/core/node_editor/models.py +111 -0
  32. pygpt_net/core/node_editor/types.py +76 -0
  33. pygpt_net/core/node_editor/utils.py +17 -0
  34. pygpt_net/core/render/web/renderer.py +10 -8
  35. pygpt_net/data/config/config.json +3 -3
  36. pygpt_net/data/config/models.json +3 -3
  37. pygpt_net/data/locale/locale.en.ini +4 -4
  38. pygpt_net/data/locale/plugin.cmd_system.en.ini +68 -0
  39. pygpt_net/item/agent.py +5 -1
  40. pygpt_net/item/preset.py +19 -1
  41. pygpt_net/plugin/cmd_system/config.py +377 -1
  42. pygpt_net/plugin/cmd_system/plugin.py +52 -8
  43. pygpt_net/plugin/cmd_system/runner.py +508 -32
  44. pygpt_net/plugin/cmd_system/winapi.py +481 -0
  45. pygpt_net/plugin/cmd_system/worker.py +88 -15
  46. pygpt_net/provider/agents/base.py +33 -2
  47. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  48. pygpt_net/provider/agents/llama_index/workflow/supervisor.py +0 -0
  49. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  50. pygpt_net/provider/core/agent/json_file.py +11 -5
  51. pygpt_net/provider/llms/openai.py +6 -4
  52. pygpt_net/tools/agent_builder/tool.py +217 -52
  53. pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
  54. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  55. pygpt_net/tools/code_interpreter/ui/html.py +2 -1
  56. pygpt_net/ui/dialog/preset.py +16 -1
  57. pygpt_net/ui/main.py +1 -1
  58. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  59. pygpt_net/ui/widget/node_editor/command.py +373 -0
  60. pygpt_net/ui/widget/node_editor/editor.py +2038 -0
  61. pygpt_net/ui/widget/node_editor/item.py +492 -0
  62. pygpt_net/ui/widget/node_editor/node.py +1205 -0
  63. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  64. pygpt_net/ui/widget/node_editor/view.py +247 -0
  65. pygpt_net/ui/widget/textarea/web.py +1 -1
  66. {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +135 -61
  67. {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +69 -42
  68. pygpt_net/core/agents/custom.py +0 -150
  69. pygpt_net/ui/widget/builder/editor.py +0 -2001
  70. {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
  71. {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
  72. {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.09.24 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6 import QtCore
@@ -19,7 +19,7 @@ from pygpt_net.utils import trans
19
19
 
20
20
 
21
21
  class AgentsWidget:
22
- def __init__(self, window=None, tool=None):
22
+ def __init__(self, window=None, tool=None, parent=None):
23
23
  """
24
24
  Agents select widget
25
25
 
@@ -27,6 +27,7 @@ class AgentsWidget:
27
27
  :param tool: tool instance
28
28
  """
29
29
  self.window = window
30
+ self.parent = parent
30
31
  self.tool = tool
31
32
  self.id = "agent.builder.list"
32
33
  self.list = None
@@ -37,21 +38,42 @@ class AgentsWidget:
37
38
 
38
39
  :return: QWidget
39
40
  """
40
- new_btn = QPushButton(QIcon(":/icons/add.svg"), "")
41
+ new_btn = QPushButton(QIcon(":/icons/add.svg"), "", self.parent)
41
42
  new_btn.clicked.connect(self.action_new)
42
43
 
43
- self.list = AgentsList(self.window, tool=self.tool, id=self.id)
44
+ self.list = AgentsList(self.parent, tool=self.tool, id=self.id)
44
45
  layout = QVBoxLayout()
45
46
  layout.addWidget(new_btn)
46
47
  layout.addWidget(self.list)
47
48
 
48
- self.window.ui.models[self.id] = self.create_model(self.window)
49
+ self.window.ui.models[self.id] = self.create_model(self.parent)
49
50
  self.list.setModel(self.window.ui.models[self.id])
50
51
 
51
52
  widget = QWidget()
52
53
  widget.setLayout(layout)
53
54
  return widget
54
55
 
56
+ def cleanup(self):
57
+ """Cleanup agents list widget"""
58
+ # Defensively detach the model and schedule deletion only if the view still exists
59
+ try:
60
+ if self.list is not None:
61
+ try:
62
+ self.list.setModel(None)
63
+ except Exception:
64
+ pass
65
+ try:
66
+ self.list.deleteLater()
67
+ except Exception:
68
+ pass
69
+ finally:
70
+ # Drop model reference in UI registry
71
+ try:
72
+ self.window.ui.models.pop(self.id, None)
73
+ except Exception:
74
+ pass
75
+ self.list = None
76
+
55
77
  def action_new(self):
56
78
  """
57
79
  New agent action
@@ -64,7 +86,7 @@ class AgentsWidget:
64
86
  :param parent: parent widget
65
87
  :return: QStandardItemModel
66
88
  """
67
- return QStandardItemModel(0, 1, parent)
89
+ return QStandardItemModel(0, 1, parent=parent)
68
90
 
69
91
  def update_list(self, data):
70
92
  """
@@ -75,13 +97,18 @@ class AgentsWidget:
75
97
  nodes = self.window.ui.nodes
76
98
  models = self.window.ui.models
77
99
 
78
- view = nodes[self.id]
79
- model = models.get(self.id)
100
+ # Guard: dialog may be closed; widget/list may not exist anymore
101
+ widget = nodes.get(self.id)
102
+ if widget is None or widget.list is None:
103
+ return
80
104
 
105
+ model = models.get(self.id)
81
106
  if model is None:
82
- model = self.create_model(self.window)
107
+ # When reopened after cleanup, re-create model and re-bind it to the view
108
+ model = self.create_model(self.parent)
83
109
  models[self.id] = model
84
- view.setModel(model)
110
+ widget.list.setModel(model)
111
+
85
112
  try:
86
113
  if not data:
87
114
  model.setRowCount(0)
@@ -810,7 +810,8 @@ class CustomWebEnginePage(QWebEnginePage):
810
810
  :param line_number: line number
811
811
  :param source_id: source ID
812
812
  """
813
- self.signals.js_message.emit(line_number, message, source_id) # handled in debug controller
813
+ pass
814
+ # self.signals.js_message.emit(line_number, message, source_id) # handled in debug controller
814
815
 
815
816
  def cleanup(self):
816
817
  """Cleanup method to release resources"""
@@ -10,6 +10,7 @@
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import Qt
13
+ from PySide6.QtGui import QIcon
13
14
  from PySide6.QtWidgets import QPushButton, QHBoxLayout, QLabel, QVBoxLayout, QSplitter, QWidget, QSizePolicy, \
14
15
  QTabWidget, QFileDialog
15
16
 
@@ -202,9 +203,23 @@ class Preset(BaseConfigDialog):
202
203
  "ai_personalize",
203
204
  ]
204
205
  for key in left_keys:
206
+ option_layout = options[key]
207
+ if key in ["agent_provider", "agent_provider_openai"]:
208
+ # special case with settings icon on right
209
+ node_layout = QHBoxLayout()
210
+ builder_btn = QPushButton(QIcon(":/icons/robot.svg"), "")
211
+ builder_btn.setToolTip("Open Agents Builder (beta)")
212
+ builder_btn.clicked.connect(lambda: self.window.tools.get("agent_builder").toggle())
213
+ node_layout.setContentsMargins(0, 0, 0, 0)
214
+ node_layout.addLayout(options[key])
215
+ node_layout.addWidget(builder_btn)
216
+ # align top right
217
+ node_layout.setAlignment(builder_btn, Qt.AlignTop | Qt.AlignRight)
218
+ option_layout = node_layout
219
+
205
220
  node_key = f"preset.editor.{key}"
206
221
  node = QWidget()
207
- node.setLayout(options[key])
222
+ node.setLayout(option_layout)
208
223
  node.setContentsMargins(0, 0, 0, 0)
209
224
  rows.addWidget(node)
210
225
  self.window.ui.nodes[node_key] = node
pygpt_net/ui/main.py CHANGED
@@ -356,7 +356,7 @@ class MainWindow(QMainWindow, QtStyleTools):
356
356
  self.core.presets.save_all()
357
357
  print("Exiting...")
358
358
  print("")
359
- print("Have a nice day! :)")
359
+ print("Do you like PyGPT? Support the development of the project: https://pygpt.net/#donate")
360
360
 
361
361
  def changeEvent(self, event):
362
362
  """
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.19 00:00:00 #
9
+ # Updated Date: 2025.09.24 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from .graph import NodeGraph, NodeTypeRegistry, NodeModel, PropertyModel, ConnectionModel
12
+ from .editor import NodeEditor
@@ -0,0 +1,373 @@
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 Optional, List, Dict
14
+
15
+ from PySide6.QtCore import QPointF, QSizeF
16
+ from PySide6.QtGui import (QUndoCommand)
17
+
18
+ from pygpt_net.core.node_editor.models import ConnectionModel
19
+
20
+ from .item import PortItem
21
+
22
+
23
+ # ------------------------ Undo/Redo Commands ------------------------
24
+
25
+ class AddNodeCommand(QUndoCommand):
26
+ """Undoable command that adds a node of a given type at a scene position."""
27
+
28
+ def __init__(self, editor: "NodeEditor", type_name: str, scene_pos: QPointF):
29
+ """Prepare command.
30
+
31
+ Args:
32
+ editor: Owning NodeEditor.
33
+ type_name: Registered node type name.
34
+ scene_pos: Target scene position to place the node.
35
+ """
36
+ super().__init__(f"Add {type_name}")
37
+ self.editor = editor
38
+ self.type_name = type_name
39
+ self.scene_pos = scene_pos
40
+ self._node_uuid: Optional[str] = None
41
+
42
+ def redo(self):
43
+ """Create or re-add the node and its item to the scene.
44
+
45
+ Behavior:
46
+ - First invocation creates a fresh NodeModel from registry, applies defaults,
47
+ and inserts it via editor._add_node_model().
48
+ - Subsequent redos re-instantiate the NodeItem for the preserved model UUID.
49
+ """
50
+ if self._node_uuid is None:
51
+ node = self.editor.graph.create_node_from_type(self.type_name)
52
+ self.editor._prepare_new_node_defaults(node)
53
+ self._node_uuid = node.uuid
54
+ self.editor._add_node_model(node, self.scene_pos)
55
+ else:
56
+ node = self.editor._model_by_uuid(self._node_uuid)
57
+ self.editor._add_node_item(node, self.scene_pos)
58
+
59
+ def undo(self):
60
+ """Remove the created node by its UUID."""
61
+ if self._node_uuid:
62
+ self.editor._remove_node_by_uuid(self._node_uuid)
63
+
64
+
65
+ class MoveNodeCommand(QUndoCommand):
66
+ """Undoable command to move a node item from old_pos to new_pos."""
67
+
68
+ def __init__(self, item: "NodeItem", old_pos: QPointF, new_pos: QPointF):
69
+ """Store references and positions for undo/redo."""
70
+ super().__init__("Move Node")
71
+ self.item = item
72
+ self.old_pos = old_pos
73
+ self.new_pos = new_pos
74
+
75
+ def redo(self):
76
+ """Apply the new position."""
77
+ self.item.setPos(self.new_pos)
78
+
79
+ def undo(self):
80
+ """Restore the old position."""
81
+ self.item.setPos(self.old_pos)
82
+
83
+
84
+ class ResizeNodeCommand(QUndoCommand):
85
+ """Undoable command to resize a node item from old_size to new_size."""
86
+
87
+ def __init__(self, item: "NodeItem", old_size: QSizeF, new_size: QSizeF):
88
+ """Store sizes for undo/redo (QSizeF copies kept)."""
89
+ super().__init__("Resize Node")
90
+ self.item = item
91
+ self.old_size = QSizeF(old_size)
92
+ self.new_size = QSizeF(new_size)
93
+
94
+ def redo(self):
95
+ """Apply the new size, clamped to content constraints."""
96
+ self.item._apply_resize(self.new_size, clamp=True)
97
+
98
+ def undo(self):
99
+ """Restore the previous size, clamped to content constraints."""
100
+ self.item._apply_resize(self.old_size, clamp=True)
101
+
102
+
103
+ class ConnectCommand(QUndoCommand):
104
+ """Undoable command that creates a connection between two ports."""
105
+
106
+ def __init__(self, editor: "NodeEditor", src: PortItem, dst: PortItem):
107
+ """Keep enough data to restore the connection across undo/redo.
108
+
109
+ Args:
110
+ editor: Owning NodeEditor.
111
+ src: Source PortItem (output).
112
+ dst: Destination PortItem (input).
113
+ """
114
+ super().__init__("Connect")
115
+ self.editor = editor
116
+ self.src_port = src
117
+ self.dst_port = dst
118
+ self.src_node = src.node_item.node.uuid
119
+ self.src_prop = src.prop_id
120
+ self.dst_node = dst.node_item.node.uuid
121
+ self.dst_prop = dst.prop_id
122
+ self._conn_uuid: Optional[str] = None
123
+
124
+ def redo(self):
125
+ """Create the connection or re-add it by UUID."""
126
+ if self._conn_uuid is None:
127
+ ok, reason, conn = self.editor.graph.connect(
128
+ (self.src_node, self.src_prop), (self.dst_node, self.dst_prop)
129
+ )
130
+ self.editor._dbg(f"ConnectCommand.redo (new) -> ok={ok}, reason='{reason}', new_uuid={(conn.uuid if ok else None)}")
131
+ if ok:
132
+ self._conn_uuid = conn.uuid
133
+ else:
134
+ conn = ConnectionModel(
135
+ uuid=self._conn_uuid,
136
+ src_node=self.src_node, src_prop=self.src_prop,
137
+ dst_node=self.dst_node, dst_prop=self.dst_prop
138
+ )
139
+ ok, reason = self.editor.graph.add_connection(conn)
140
+ self.editor._dbg(f"ConnectCommand.redo (restore) -> ok={ok}, reason='{reason}', uuid={self._conn_uuid}")
141
+
142
+ def undo(self):
143
+ """Remove the connection by UUID."""
144
+ if self._conn_uuid:
145
+ self.editor._dbg(f"ConnectCommand.undo -> remove uuid={self._conn_uuid}")
146
+ self.editor._remove_connection_by_uuid(self._conn_uuid)
147
+
148
+
149
+ class RewireConnectionCommand(QUndoCommand):
150
+ """Undoable command that rewires an existing connection or deletes it."""
151
+
152
+ def __init__(self, editor: "NodeEditor",
153
+ old_conn: ConnectionModel,
154
+ new_src: Optional[PortItem],
155
+ new_dst: Optional[PortItem]):
156
+ """Prepare rewire/delete action.
157
+
158
+ Args:
159
+ editor: Owning NodeEditor.
160
+ old_conn: Existing connection model to replace.
161
+ new_src: New source port (or None to delete).
162
+ new_dst: New destination port (or None to delete).
163
+ """
164
+ title = "Delete Connection" if (new_src is None or new_dst is None) else "Rewire Connection"
165
+ super().__init__(title)
166
+ self.editor = editor
167
+ self.old_conn_data = old_conn.to_dict()
168
+ self.old_uuid = old_conn.uuid
169
+ self.new_src = new_src
170
+ self.new_dst = new_dst
171
+ self._new_uuid: Optional[str] = None
172
+ self._applied = False
173
+
174
+ def redo(self):
175
+ """Apply rewire or deletion, restoring the old connection on failure."""
176
+ self.editor._dbg(f"RewireCommand.redo -> remove old={self.old_uuid}, new={'DELETE' if (self.new_src is None or self.new_dst is None) else 'CONNECT'}")
177
+ self.editor._remove_connection_by_uuid(self.old_uuid)
178
+ if self.new_src is not None and self.new_dst is not None:
179
+ ok, reason, conn = self.editor.graph.connect(
180
+ (self.new_src.node_item.node.uuid, self.new_src.prop_id),
181
+ (self.new_dst.node_item.node.uuid, self.new_dst.prop_id)
182
+ )
183
+ self.editor._dbg(f"RewireCommand.redo connect -> ok={ok}, reason='{reason}', new_uuid={(conn.uuid if ok else None)}")
184
+ if not ok:
185
+ old = ConnectionModel.from_dict(self.old_conn_data)
186
+ self.editor.graph.add_connection(old)
187
+ self._applied = False
188
+ return
189
+ self._new_uuid = conn.uuid
190
+ self._applied = True
191
+
192
+ def undo(self):
193
+ """Revert the rewire/delete by restoring the original connection."""
194
+ self.editor._dbg(f"RewireCommand.undo -> restore old={self.old_uuid}, remove new={self._new_uuid}")
195
+ if not self._applied:
196
+ return
197
+ if self._new_uuid:
198
+ self._editor = self.editor
199
+ self._editor._remove_connection_by_uuid(self._new_uuid)
200
+ old = ConnectionModel.from_dict(self.old_conn_data)
201
+ self.editor.graph.add_connection(old)
202
+
203
+
204
+ class ClearGraphCommand(QUndoCommand):
205
+ """Undoable command that clears the entire graph and scene with a snapshot."""
206
+
207
+ def __init__(self, editor: "NodeEditor"):
208
+ """Take an internal snapshot on first redo to enable undo restoration."""
209
+ super().__init__("Clear")
210
+ self.editor = editor
211
+ self._snapshot: Optional[dict] = None
212
+
213
+ def redo(self):
214
+ """Clear the scene and the graph, taking a snapshot if not already taken."""
215
+ if self._snapshot is None:
216
+ self._snapshot = self.editor.graph.to_dict()
217
+ self.editor._dbg("ClearGraph.redo -> clearing scene+graph")
218
+ self.editor._clear_scene_and_graph()
219
+
220
+ def undo(self):
221
+ """Restore the previous snapshot."""
222
+ self._dbg = self.editor._dbg
223
+ self._dbg("ClearGraph.undo -> restoring snapshot")
224
+ if self._snapshot:
225
+ self.editor.load_layout(self._snapshot)
226
+
227
+ class DeleteConnectionCommand(QUndoCommand):
228
+ """Undoable command that deletes a single existing connection and can restore it."""
229
+
230
+ def __init__(self, editor: "NodeEditor", conn: ConnectionModel):
231
+ """Snapshot connection for reliable restore.
232
+
233
+ Args:
234
+ editor: Owning NodeEditor.
235
+ conn: Existing ConnectionModel to delete/restore.
236
+ """
237
+ super().__init__("Delete Connection")
238
+ self.editor = editor
239
+ self.conn_uuid: Optional[str] = conn.uuid
240
+ # Keep a serializable snapshot for undo
241
+ self.conn_data: Dict = conn.to_dict() if hasattr(conn, "to_dict") else {
242
+ "uuid": conn.uuid,
243
+ "src_node": conn.src_node, "src_prop": conn.src_prop,
244
+ "dst_node": conn.dst_node, "dst_prop": conn.dst_prop,
245
+ }
246
+
247
+ def redo(self):
248
+ """Remove the connection by UUID (no-op if already gone)."""
249
+ if self.conn_uuid and self.conn_uuid in self.editor.graph.connections:
250
+ self.editor._remove_connection_by_uuid(self.conn_uuid)
251
+
252
+ def undo(self):
253
+ """Recreate the exact same connection (UUID preserved)."""
254
+ if not self.conn_uuid:
255
+ return
256
+ try:
257
+ cm = ConnectionModel.from_dict(self.conn_data)
258
+ except Exception:
259
+ cm = ConnectionModel(
260
+ uuid=self.conn_data.get("uuid"),
261
+ src_node=self.conn_data.get("src_node"),
262
+ src_prop=self.conn_data.get("src_prop"),
263
+ dst_node=self.conn_data.get("dst_node"),
264
+ dst_prop=self.conn_data.get("dst_prop"),
265
+ )
266
+ # Best-effort restore; if add_connection fails, try connect() as fallback
267
+ ok, _ = self.editor.graph.add_connection(cm)
268
+ if not ok:
269
+ self.editor.graph.connect(
270
+ (cm.src_node, cm.src_prop),
271
+ (cm.dst_node, cm.dst_prop),
272
+ )
273
+ class DeleteNodeCommand(QUndoCommand):
274
+ """Undoable command that deletes a node and all its connections, and can restore them."""
275
+
276
+ def __init__(self, editor: "NodeEditor", item: "NodeItem"):
277
+ """Snapshot node type, uuid, name, property values, position, size and related connections.
278
+
279
+ Args:
280
+ editor: Owning NodeEditor.
281
+ item: NodeItem to delete (used only to take a snapshot at construction time).
282
+ """
283
+ super().__init__("Delete Node")
284
+ self.editor = editor
285
+ node = item.node
286
+ self.node_uuid: str = node.uuid
287
+ self.node_type: str = node.type
288
+ self.node_name: str = node.name
289
+ # Snapshot property values
290
+ self.prop_values: Dict[str, object] = {pid: pm.value for pid, pm in node.properties.items()}
291
+ # Snapshot UI geometry
292
+ self.pos = QPointF(item.pos())
293
+ self.size = QSizeF(item.size())
294
+ # Snapshot all connections touching this node
295
+ self._connections: List[Dict] = []
296
+ for conn in editor.graph.connections.values():
297
+ if conn.src_node == self.node_uuid or conn.dst_node == self.node_uuid:
298
+ self._connections.append(conn.to_dict() if hasattr(conn, "to_dict") else {
299
+ "uuid": conn.uuid,
300
+ "src_node": conn.src_node, "src_prop": conn.src_prop,
301
+ "dst_node": conn.dst_node, "dst_prop": conn.dst_prop,
302
+ })
303
+
304
+ def redo(self):
305
+ """Delete the node (graph will remove its connections and UI will sync via signals)."""
306
+ self.editor._remove_node_by_uuid(self.node_uuid)
307
+
308
+ def undo(self):
309
+ """Recreate the node with the same UUID, restore pos/size and re-add all its connections."""
310
+ # 1) Recreate node from registry using saved type and UUID
311
+ try:
312
+ node = self.editor.graph.create_node_from_type(self.node_type)
313
+ except Exception:
314
+ return
315
+ try:
316
+ node.uuid = self.node_uuid
317
+ except Exception:
318
+ pass
319
+ try:
320
+ if isinstance(self.node_name, str) and self.node_name.strip():
321
+ node.name = self.node_name
322
+ except Exception:
323
+ pass
324
+
325
+ # 2) Restore property values (casted to current spec types)
326
+ for pid, pm in list(node.properties.items()):
327
+ if pid in self.prop_values:
328
+ try:
329
+ pm.value = self.editor._coerce_value_for_property(pm, self.prop_values[pid])
330
+ except Exception:
331
+ pm.value = self.prop_values[pid]
332
+
333
+ # 3) Place node back at original position; scene item will be created by graph signal
334
+ self.editor._pending_node_positions[self.node_uuid] = QPointF(self.pos)
335
+ self.editor.graph.add_node(node)
336
+
337
+ # 4) Apply size after item exists
338
+ item = self.editor._uuid_to_item.get(self.node_uuid)
339
+ if item:
340
+ try:
341
+ item._apply_resize(QSizeF(self.size), clamp=True)
342
+ except Exception:
343
+ pass
344
+
345
+ # 5) Restore connections (skip duplicates and validate ports)
346
+ for cd in self._connections:
347
+ cuuid = cd.get("uuid")
348
+ # Skip if already present (could be restored by another command in a macro)
349
+ if cuuid and cuuid in self.editor.graph.connections:
350
+ continue
351
+ s_n = cd.get("src_node"); s_p = cd.get("src_prop")
352
+ d_n = cd.get("dst_node"); d_p = cd.get("dst_prop")
353
+
354
+ # Validate node/port presence in current graph/spec
355
+ if s_n not in self.editor.graph.nodes or d_n not in self.editor.graph.nodes:
356
+ continue
357
+ s_node = self.editor.graph.nodes[s_n]
358
+ d_node = self.editor.graph.nodes[d_n]
359
+ if s_p not in s_node.properties or d_p not in d_node.properties:
360
+ continue
361
+
362
+ try:
363
+ cm = ConnectionModel(
364
+ uuid=cuuid if isinstance(cuuid, str) and cuuid else None,
365
+ src_node=s_n, src_prop=s_p,
366
+ dst_node=d_n, dst_prop=d_p
367
+ )
368
+ ok, _ = self.editor.graph.add_connection(cm)
369
+ if not ok:
370
+ self.editor.graph.connect((s_n, s_p), (d_n, d_p))
371
+ except Exception:
372
+ # Best-effort: ignore a single failing connection to not break the undo sequence
373
+ pass