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.
- pygpt_net/CHANGELOG.txt +10 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +9 -5
- pygpt_net/controller/__init__.py +1 -0
- pygpt_net/controller/presets/editor.py +442 -39
- pygpt_net/core/agents/custom/__init__.py +275 -0
- pygpt_net/core/agents/custom/debug.py +64 -0
- pygpt_net/core/agents/custom/factory.py +109 -0
- pygpt_net/core/agents/custom/graph.py +71 -0
- pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
- pygpt_net/core/agents/custom/llama_index/factory.py +89 -0
- pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
- pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
- pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
- pygpt_net/core/agents/custom/llama_index/utils.py +242 -0
- pygpt_net/core/agents/custom/logging.py +50 -0
- pygpt_net/core/agents/custom/memory.py +51 -0
- pygpt_net/core/agents/custom/router.py +116 -0
- pygpt_net/core/agents/custom/router_streamer.py +187 -0
- pygpt_net/core/agents/custom/runner.py +454 -0
- pygpt_net/core/agents/custom/schema.py +125 -0
- pygpt_net/core/agents/custom/utils.py +181 -0
- pygpt_net/core/agents/provider.py +72 -7
- pygpt_net/core/agents/runner.py +7 -4
- pygpt_net/core/agents/runners/helpers.py +1 -1
- pygpt_net/core/agents/runners/llama_workflow.py +3 -0
- pygpt_net/core/agents/runners/openai_workflow.py +8 -1
- pygpt_net/core/filesystem/parser.py +37 -24
- pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
- pygpt_net/core/{builder → node_editor}/graph.py +11 -218
- pygpt_net/core/node_editor/models.py +111 -0
- pygpt_net/core/node_editor/types.py +76 -0
- pygpt_net/core/node_editor/utils.py +17 -0
- pygpt_net/core/render/web/renderer.py +10 -8
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/locale/locale.en.ini +4 -4
- pygpt_net/data/locale/plugin.cmd_system.en.ini +68 -0
- pygpt_net/item/agent.py +5 -1
- pygpt_net/item/preset.py +19 -1
- pygpt_net/plugin/cmd_system/config.py +377 -1
- pygpt_net/plugin/cmd_system/plugin.py +52 -8
- pygpt_net/plugin/cmd_system/runner.py +508 -32
- pygpt_net/plugin/cmd_system/winapi.py +481 -0
- pygpt_net/plugin/cmd_system/worker.py +88 -15
- pygpt_net/provider/agents/base.py +33 -2
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
- pygpt_net/provider/agents/llama_index/workflow/supervisor.py +0 -0
- pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
- pygpt_net/provider/core/agent/json_file.py +11 -5
- pygpt_net/provider/llms/openai.py +6 -4
- pygpt_net/tools/agent_builder/tool.py +217 -52
- pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
- pygpt_net/tools/agent_builder/ui/list.py +37 -10
- pygpt_net/tools/code_interpreter/ui/html.py +2 -1
- pygpt_net/ui/dialog/preset.py +16 -1
- pygpt_net/ui/main.py +1 -1
- pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
- pygpt_net/ui/widget/node_editor/command.py +373 -0
- pygpt_net/ui/widget/node_editor/editor.py +2038 -0
- pygpt_net/ui/widget/node_editor/item.py +492 -0
- pygpt_net/ui/widget/node_editor/node.py +1205 -0
- pygpt_net/ui/widget/node_editor/utils.py +17 -0
- pygpt_net/ui/widget/node_editor/view.py +247 -0
- pygpt_net/ui/widget/textarea/web.py +1 -1
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +135 -61
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +69 -42
- pygpt_net/core/agents/custom.py +0 -150
- pygpt_net/ui/widget/builder/editor.py +0 -2001
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"""
|
pygpt_net/ui/dialog/preset.py
CHANGED
|
@@ -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(
|
|
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("
|
|
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.
|
|
9
|
+
# Updated Date: 2025.09.24 00:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
|
-
from .
|
|
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
|