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
@@ -6,9 +6,10 @@
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
+ import copy
12
13
  from datetime import datetime
13
14
  from typing import Dict
14
15
 
@@ -18,21 +19,26 @@ from PySide6.QtGui import QAction, QIcon
18
19
  from pygpt_net.tools.base import BaseTool
19
20
  from pygpt_net.utils import trans
20
21
 
22
+ from pygpt_net.core.node_editor.types import (
23
+ NodeTypeRegistry,
24
+ PropertySpec,
25
+ NodeTypeSpec
26
+ )
27
+
21
28
  from .ui.dialogs import Builder
22
29
 
30
+
23
31
  class AgentBuilder(BaseTool):
24
32
  def __init__(self, *args, **kwargs):
25
- """
26
- Agent builder (nodes editor)
27
-
28
- :param window: Window instance
29
- """
30
33
  super(AgentBuilder, self).__init__(*args, **kwargs)
31
34
  self.id = "agent_builder"
32
35
  self.opened = False
33
36
  self.dialog = None # dialog
34
37
  self.initialized = False
35
38
  self.current_agent = None # current agent ID
39
+ # Reentrancy/teardown guards
40
+ self._restoring = False # True while restore/load is in progress
41
+ self._closing = False # True while dialog is being closed
36
42
 
37
43
  def setup(self):
38
44
  """Setup controller"""
@@ -42,17 +48,41 @@ class AgentBuilder(BaseTool):
42
48
  """Update"""
43
49
  pass
44
50
 
45
- def save(self):
51
+ def save(self, force: bool = False):
46
52
  """Save layout to file"""
53
+ # Never save during restore or teardown
54
+ if not self.opened or self._closing or self._restoring:
55
+ if not force:
56
+ return
47
57
  if "agent.builder" not in self.window.ui.editor:
48
58
  return
59
+
60
+ builder = self.window.ui.editor["agent.builder"]
61
+ # Ensure the editor is alive (not closing)
62
+ if not getattr(builder, "_alive", True) or getattr(builder, "_closing", False):
63
+ return
64
+
65
+ data = builder.save_layout()
66
+ if not isinstance(data, dict):
67
+ return # skip invalid snapshots
68
+
69
+ schema_list = builder.export_schema(as_list=True)
70
+ try:
71
+ # Deep copy for safety (JSON-only types expected)
72
+ layout = copy.deepcopy(data)
73
+ schema = copy.deepcopy(schema_list)
74
+ except Exception:
75
+ # As a fallback, store raw references (still pure dict/list)
76
+ layout = data
77
+ schema = schema_list
78
+
49
79
  custom = self.window.core.agents.custom
50
- layout = self.window.ui.editor["agent.builder"].save_layout()
51
80
  custom.update_layout(layout)
52
81
  if self.current_agent:
53
- custom.update_agent_layout(self.current_agent, layout)
82
+ custom.update_agent(self.current_agent, layout, schema)
54
83
  custom.save()
55
84
  self.window.update_status(f"Saved at: {datetime.now().strftime('%H:%M:%S')}")
85
+ self.update_presets()
56
86
 
57
87
  def load(self):
58
88
  """Load layout from file"""
@@ -60,11 +90,10 @@ class AgentBuilder(BaseTool):
60
90
 
61
91
  def open(self):
62
92
  """Open dialog"""
63
- if not self.initialized:
64
- self.dialog = Builder(self.window, tool=self)
65
- self.dialog.setup()
66
- self.restore()
67
- self.initialized = True
93
+ self.dialog = Builder(self.window, tool=self)
94
+ self.dialog.setup()
95
+ # Defer restore to allow previous deleteLater() to be processed
96
+ QtCore.QTimer.singleShot(0, self.restore)
68
97
  self.window.ui.dialogs.open('agent.builder', width=900, height=600)
69
98
  self.opened = True
70
99
  self.update()
@@ -81,26 +110,33 @@ class AgentBuilder(BaseTool):
81
110
  else:
82
111
  self.open()
83
112
 
84
- def store_current(self):
113
+ def store_current(self, force: bool = False):
85
114
  """Store current to file"""
86
- self.save()
115
+ if self.opened and not self._restoring and (not self._closing or force):
116
+ self.save(force=force)
87
117
 
88
118
  def restore(self):
89
119
  """Restore layout and agents from file"""
90
- layout = self.window.core.agents.custom.get_layout()
91
- if layout:
92
- data = layout.data
93
- if data:
94
- self.window.ui.editor["agent.builder"].load_layout(data)
95
- self.window.update_status(f"Loaded layout at: {datetime.now().strftime('%H:%M:%S')}")
120
+ if self._closing:
121
+ return
122
+ self._restoring = True
123
+ try:
124
+ layout = self.window.core.agents.custom.get_layout()
125
+ if layout and "agent.builder" in self.window.ui.editor:
126
+ data = layout.data
127
+ if data:
128
+ self.window.ui.editor["agent.builder"].load_layout(data)
129
+ self.window.update_status(f"Loaded layout at: {datetime.now().strftime('%H:%M:%S')}")
96
130
 
97
- self.update_list()
131
+ self.update_list()
98
132
 
99
- # select first agent on list
100
- agents = self.window.core.agents.custom.get_agents()
101
- if agents and len(agents) > 0:
102
- first_agent_id = list(agents.keys())[0]
103
- self.edit_agent(first_agent_id)
133
+ # select first agent on list
134
+ agents = self.window.core.agents.custom.get_agents()
135
+ if agents and len(agents) > 0:
136
+ first_agent_id = list(agents.keys())[0]
137
+ self.edit_agent(first_agent_id)
138
+ finally:
139
+ self._restoring = False
104
140
 
105
141
  def show_hide(self, show: bool = True):
106
142
  """
@@ -114,13 +150,18 @@ class AgentBuilder(BaseTool):
114
150
  self.close()
115
151
 
116
152
  def on_close(self):
117
- """On window close"""
118
- self.opened = False
119
- self.update()
153
+ """On dialog window close (before destroy)"""
154
+ self._closing = True
155
+ try:
156
+ self.store_current(force=True)
157
+ finally:
158
+ self.opened = False
159
+ self._closing = False
120
160
 
121
161
  def on_exit(self):
122
- """On exit"""
123
- self.store_current()
162
+ """On app exit"""
163
+ if self.opened:
164
+ self.on_close()
124
165
 
125
166
  def clear(self, force: bool = False):
126
167
  """
@@ -135,24 +176,33 @@ class AgentBuilder(BaseTool):
135
176
  msg=trans('agent.builder.confirm.clear.msg'),
136
177
  )
137
178
  return
138
- if self.window.ui.editor["agent.builder"].clear(ask_user=False):
139
- self.save()
179
+ if "agent.builder" in self.window.ui.editor:
180
+ if self.window.ui.editor["agent.builder"].clear(ask_user=False):
181
+ self.save()
140
182
 
141
183
  def add_agent(self, name: str = None):
142
184
  """Add new agent"""
143
185
  if name is None:
144
- self.window.ui.dialog['create'].id = 'agent.builder.agent'
145
- self.window.ui.dialog['create'].input.setText("")
146
- self.window.ui.dialog['create'].current = ""
147
- self.window.ui.dialog['create'].show()
148
- self.window.ui.dialog['create'].input.setFocus()
186
+ dialog = self.window.ui.dialog['create']
187
+ dialog.id = 'agent.builder.agent'
188
+ dialog.input.setText("")
189
+ dialog.current = ""
190
+ dialog.show()
191
+ dialog.input.setFocus()
149
192
  return
193
+
194
+ if self.current_agent:
195
+ self.save() # save current before creating new
196
+
197
+ self.current_agent = None # prevent overwriting current agent
150
198
  uuid = self.window.core.agents.custom.new_agent(name)
151
199
  if uuid:
152
200
  self.update_list()
153
- self.window.ui.editor["agent.builder"].clear(ask_user=False)
201
+ if "agent.builder" in self.window.ui.editor:
202
+ self.window.ui.editor["agent.builder"].clear(ask_user=False)
154
203
  self.window.ui.dialogs.close('create')
155
204
  self.edit_agent(uuid)
205
+ self.update_presets()
156
206
 
157
207
  def rename_agent(self, agent_id: str, name: str = None):
158
208
  """
@@ -167,17 +217,19 @@ class AgentBuilder(BaseTool):
167
217
  return
168
218
  current_name = agent.name
169
219
  if name is None:
170
- self.window.ui.dialog['rename'].id = 'agent.builder.agent'
171
- self.window.ui.dialog['rename'].input.setText(current_name)
172
- self.window.ui.dialog['rename'].current =agent_id
173
- self.window.ui.dialog['rename'].show()
174
- self.window.ui.dialog['rename'].input.setFocus()
220
+ dialog = self.window.ui.dialog['rename']
221
+ dialog.id = 'agent.builder.agent'
222
+ dialog.input.setText(current_name)
223
+ dialog.current =agent_id
224
+ dialog.show()
225
+ dialog.input.setFocus()
175
226
  return
176
227
  if agent:
177
228
  agent.name = name
178
229
  self.window.core.agents.custom.save()
179
230
  self.update_list()
180
231
  self.window.ui.dialogs.close('rename')
232
+ self.update_presets()
181
233
 
182
234
  def edit_agent(self, agent_id: str):
183
235
  """
@@ -185,16 +237,36 @@ class AgentBuilder(BaseTool):
185
237
 
186
238
  :param agent_id: agent ID
187
239
  """
188
- self.save()
240
+ # Save current agent only when switching and UI is stable
241
+ if (
242
+ self.current_agent
243
+ and self.current_agent != agent_id
244
+ and self.opened
245
+ and not self._restoring
246
+ and not self._closing
247
+ and "agent.builder" in self.window.ui.editor
248
+ ):
249
+ builder = self.window.ui.editor["agent.builder"]
250
+ if getattr(builder, "_alive", True) and not getattr(builder, "_closing", False):
251
+ self.save()
252
+
189
253
  self.current_agent = agent_id
254
+
255
+ if "agent.builder" not in self.window.ui.editor:
256
+ return
257
+
190
258
  agent = self.window.core.agents.custom.get_agent(agent_id)
191
259
  if agent:
260
+ editor = self.window.ui.editor["agent.builder"]
192
261
  layout = agent.layout
193
262
  if layout:
194
- self.window.ui.editor["agent.builder"].load_layout(layout)
263
+ editor.load_layout(layout)
195
264
  else:
196
- self.window.ui.editor["agent.builder"].clear(ask_user=False)
197
- self.select_on_list(agent_id)
265
+ editor.clear(ask_user=False)
266
+
267
+ # Select on list only when the list exists (dialog open)
268
+ if "agent.builder.list" in self.window.ui.nodes:
269
+ self.select_on_list(agent_id)
198
270
 
199
271
  def duplicate_agent(self, agent_id: str):
200
272
  """
@@ -224,12 +296,31 @@ class AgentBuilder(BaseTool):
224
296
  return
225
297
  self.window.core.agents.custom.delete_agent(agent_id)
226
298
  self.update_list()
299
+
300
+ # if deleted current, clear editor
227
301
  if self.current_agent == agent_id:
228
302
  self.current_agent = None
229
- self.window.ui.editor["agent.builder"].clear(ask_user=False)
303
+ if "agent.builder" in self.window.ui.editor:
304
+ self.window.ui.editor["agent.builder"].clear(ask_user=False)
305
+
306
+ # select current selected on list if any after deletion (only if UI is open)
307
+ if self.opened and "agent.builder.list" in self.window.ui.nodes:
308
+ agents = self.window.core.agents.custom.get_agents()
309
+ if agents and len(agents) > 0:
310
+ idx = self.window.ui.nodes["agent.builder.list"].list.currentIndex()
311
+ if idx.isValid():
312
+ next_id = idx.data(QtCore.Qt.UserRole)
313
+ self.edit_agent(next_id)
314
+
315
+ def update_presets(self):
316
+ """Update presets in the tools"""
317
+ self.window.controller.presets.editor.reload_all(all=True)
230
318
 
231
319
  def update_list(self):
232
320
  """Update agents list"""
321
+ # Guard: dialog may not be open; do nothing if widget is not present
322
+ if "agent.builder.list" not in self.window.ui.nodes:
323
+ return
233
324
  data = self.window.core.agents.custom.get_agents()
234
325
  self.window.ui.nodes["agent.builder.list"].update_list(data)
235
326
 
@@ -239,13 +330,16 @@ class AgentBuilder(BaseTool):
239
330
 
240
331
  :param agent_id: agent ID
241
332
  """
333
+ if "agent.builder.list" not in self.window.ui.nodes:
334
+ return
335
+
242
336
  nodes = self.window.ui.nodes
243
337
  models = self.window.ui.models
244
338
 
245
339
  agents_list = nodes["agent.builder.list"].list
246
340
  model = models.get("agent.builder.list")
247
341
 
248
- if model is None:
342
+ if model is None or agents_list is None:
249
343
  return
250
344
  for row in range(model.rowCount()):
251
345
  idx = model.index(row, 0)
@@ -279,6 +373,77 @@ class AgentBuilder(BaseTool):
279
373
  """Setup dialogs (static)"""
280
374
  pass
281
375
 
376
+ def get_registry(self) -> NodeTypeRegistry:
377
+ """
378
+ Get node type registry
379
+
380
+ :return: NodeTypeRegistry
381
+ """
382
+ registry = NodeTypeRegistry(empty=True)
383
+ # Tip: to allow multiple connections to an input or output, set allowed_inputs/allowed_outputs to -1.
384
+
385
+ # Start
386
+ registry.register(NodeTypeSpec(
387
+ type_name="Flow/Start",
388
+ title="Start",
389
+ base_id="start",
390
+ export_kind="start",
391
+ bg_color="#2D5A27",
392
+ properties=[
393
+ PropertySpec(id="output", type="flow", name="Output", editable=False, allowed_inputs=0,
394
+ allowed_outputs=1),
395
+ PropertySpec(id="memory", type="memory", name="Memory", editable=False, allowed_inputs=0,
396
+ allowed_outputs=-1),
397
+ # base_id will be auto-injected as read-only property at creation
398
+ ],
399
+ ))
400
+ # Agent
401
+ registry.register(NodeTypeSpec(
402
+ type_name="Flow/Agent",
403
+ title="Agent",
404
+ base_id="agent",
405
+ export_kind="agent",
406
+ bg_color="#304A6E",
407
+ properties=[
408
+ PropertySpec(id="name", type="str", name="Name", editable=True, value=""),
409
+ PropertySpec(id="instruction", type="text", name="Instruction", editable=True, value=""),
410
+ PropertySpec(id="remote_tools", type="bool", name="Remote tools", editable=True, value=True),
411
+ PropertySpec(id="local_tools", type="bool", name="Local tools", editable=True, value=True),
412
+ PropertySpec(id="input", type="flow", name="Input", editable=False, allowed_inputs=-1,
413
+ allowed_outputs=0),
414
+ PropertySpec(id="output", type="flow", name="Output", editable=False, allowed_inputs=0,
415
+ allowed_outputs=-1),
416
+ PropertySpec(id="memory", type="memory", name="Memory", editable=False, allowed_inputs=0,
417
+ allowed_outputs=1),
418
+ ],
419
+ ))
420
+ # Memory
421
+ registry.register(NodeTypeSpec(
422
+ type_name="Flow/Memory",
423
+ title="Memory (Context)",
424
+ base_id="mem",
425
+ export_kind="memory",
426
+ bg_color="#593E78",
427
+ properties=[
428
+ PropertySpec(id="name", type="str", name="Name", editable=True, value=""),
429
+ PropertySpec(id="input", type="memory", name="Agent", editable=False, allowed_inputs=-1,
430
+ allowed_outputs=0),
431
+ ],
432
+ ))
433
+ # End
434
+ registry.register(NodeTypeSpec(
435
+ type_name="Flow/End",
436
+ title="End",
437
+ base_id="end",
438
+ export_kind="end",
439
+ bg_color="#6B2E2E",
440
+ properties=[
441
+ PropertySpec(id="input", type="flow", name="Input", editable=False, allowed_inputs=-1,
442
+ allowed_outputs=0),
443
+ ],
444
+ ))
445
+ return registry
446
+
282
447
  def get_lang_mappings(self) -> Dict[str, Dict]:
283
448
  """
284
449
  Get language mappings
@@ -6,18 +6,20 @@
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 PySide6.QtCore import Qt
12
+ from PySide6.QtCore import Qt, QEvent
13
13
  from PySide6.QtGui import QAction, QIcon
14
- from PySide6.QtWidgets import QVBoxLayout, QMenuBar, QSplitter
14
+ from PySide6.QtWidgets import QVBoxLayout, QMenuBar, QSplitter, QSizePolicy
15
15
 
16
- from pygpt_net.tools.agent_builder.ui.list import AgentsWidget
17
- from pygpt_net.ui.widget.builder.editor import NodeEditor
16
+ from pygpt_net.ui.widget.element.labels import HelpLabel
17
+ from pygpt_net.ui.widget.node_editor.editor import NodeEditor
18
18
  from pygpt_net.ui.widget.dialog.base import BaseDialog
19
19
  from pygpt_net.utils import trans
20
20
 
21
+ from .list import AgentsWidget
22
+
21
23
 
22
24
  class Builder:
23
25
 
@@ -34,12 +36,12 @@ class Builder:
34
36
  self.actions = {}
35
37
 
36
38
  def setup_menu(self, parent=None) -> QMenuBar:
37
- """Setup audio agent_builder dialog menu"""
39
+ """Setup agent_builder dialog menu"""
38
40
  self.menu_bar = QMenuBar(parent)
39
41
  self.file_menu = self.menu_bar.addMenu(trans("menu.file"))
40
42
  t = self.tool
41
43
 
42
- self.actions["open"] = QAction(QIcon(":/icons/folder.svg"), trans("action.open"), self.menu_bar)
44
+ self.actions["open"] = QAction(QIcon(":/icons/reload.svg"), "Reload", self.menu_bar)
43
45
  self.actions["open"].triggered.connect(lambda checked=False, t=t: t.load())
44
46
 
45
47
  self.actions["save"] = QAction(QIcon(":/icons/save.svg"), trans("action.save"), self.menu_bar)
@@ -55,36 +57,44 @@ class Builder:
55
57
 
56
58
  def setup(self):
57
59
  """Setup agent_builder dialog"""
60
+ # Ensure previous deferred deletions are flushed before building a new editor instance
61
+ try:
62
+ from PySide6.QtWidgets import QApplication
63
+ QApplication.sendPostedEvents(None, QEvent.DeferredDelete)
64
+ QApplication.processEvents()
65
+ except Exception:
66
+ pass
67
+
58
68
  id = 'agent.builder'
59
69
  u = self.window.ui
60
70
 
61
71
  u.dialog['agent.builder'] = BuilderDialog(self.window)
62
72
  dlg = u.dialog['agent.builder']
63
73
 
64
- editor = NodeEditor(dlg) # parent == dialog
74
+ registry = self.tool.get_registry() # node types
75
+ editor = NodeEditor(
76
+ parent=dlg,
77
+ registry=registry
78
+ ) # parent == dialog
79
+
65
80
  editor.setStyleSheet("""
66
81
  NodeEditor {
67
82
  qproperty-gridBackColor: #242629;
68
83
  qproperty-gridPenColor: #3b3f46;
69
-
84
+
70
85
  qproperty-nodeBackgroundColor: #2d2f34;
71
86
  qproperty-nodeBorderColor: #4b4f57;
72
87
  qproperty-nodeSelectionColor: #ff9900;
73
88
  qproperty-nodeTitleColor: #3a3d44;
74
-
89
+
75
90
  qproperty-portInputColor: #66b2ff;
76
91
  qproperty-portOutputColor: #70e070;
77
92
  qproperty-portConnectedColor: #ffd166;
78
-
93
+
79
94
  qproperty-edgeColor: #c0c0c0;
80
95
  qproperty-edgeSelectedColor: #ff8a5c;
81
96
  }
82
97
  """)
83
-
84
- # Demo: add a couple of nodes
85
- #editor.add_node("Value/Float", QPointF(100, 120))
86
- #editor.add_node("Value/Float", QPointF(100, 240))
87
- #editor.add_node("Math/Add", QPointF(420, 180))
88
98
  editor.on_clear = self.tool.clear
89
99
 
90
100
  u.editor[id] = editor
@@ -92,7 +102,7 @@ class Builder:
92
102
  layout = QVBoxLayout()
93
103
  layout.setMenuBar(self.setup_menu(dlg))
94
104
 
95
- agents_list = AgentsWidget(self.window, tool=self.tool)
105
+ agents_list = AgentsWidget(self.window, tool=self.tool, parent=dlg)
96
106
  list_widget = agents_list.setup()
97
107
  list_widget.setFixedWidth(250)
98
108
  center_splitter = QSplitter(Qt.Horizontal)
@@ -101,14 +111,30 @@ class Builder:
101
111
  center_splitter.setStretchFactor(0, 1)
102
112
  center_splitter.setStretchFactor(1, 8)
103
113
  layout.addWidget(center_splitter)
104
-
114
+ # Make the splitter take all extra vertical space so the legend stays compact at the bottom
115
+ layout.setStretch(0, 1)
116
+
117
+ # Bottom legend as a compact, centered help label
118
+ legend_label = HelpLabel(
119
+ "Right-click: add node / undo / redo • Middle-click: pan view • Ctrl + Mouse wheel: zoom • "
120
+ "Left-click a port: create connection • Ctrl + Left-click a port: rewire or detach • Right-click or DEL a node/connection: remove"
121
+ )
122
+ legend_label.setAlignment(Qt.AlignCenter)
123
+ legend_label.setWordWrap(True)
124
+ legend_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
125
+ legend_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
126
+
127
+ layout.addWidget(legend_label)
128
+
129
+ u.nodes["agent.builder.splitter"] = center_splitter
105
130
  u.nodes["agent.builder.list"] = agents_list
131
+ u.nodes["agent.builder.legend"] = legend_label
106
132
 
107
133
  dlg.setLayout(layout)
108
134
  dlg.setWindowTitle(trans("agent.builder.title"))
109
135
 
110
136
  def clear(self):
111
- """Clear transcribe dialog"""
137
+ """Clear dialog"""
112
138
  pass
113
139
 
114
140
 
@@ -123,6 +149,7 @@ class BuilderDialog(BaseDialog):
123
149
  super(BuilderDialog, self).__init__(window, id)
124
150
  self.window = window
125
151
  self.id = id # id
152
+ self._cleaned = False
126
153
 
127
154
  def closeEvent(self, event):
128
155
  """
@@ -140,13 +167,81 @@ class BuilderDialog(BaseDialog):
140
167
  :param event: key press event
141
168
  """
142
169
  if event.key() == Qt.Key_Escape:
143
- self.cleanup()
144
170
  self.close()
145
171
  else:
146
172
  super(BuilderDialog, self).keyPressEvent(event)
147
173
 
148
174
  def cleanup(self):
149
- """
150
- Cleanup on close
151
- """
152
- self.window.tools.get("agent_builder").on_close()
175
+ """Cleanup on dialog close"""
176
+ if self._cleaned:
177
+ return
178
+ self._cleaned = True
179
+
180
+ tool = self.window.tools.get("agent_builder")
181
+ if tool:
182
+ try:
183
+ tool.on_close() # store current state (layout, nodes positions, etc.)
184
+ except Exception:
185
+ pass
186
+
187
+ u = self.window.ui
188
+
189
+ try:
190
+ u.nodes["agent.builder.list"].cleanup()
191
+ except Exception as e:
192
+ print("Agents list close failed:", e)
193
+
194
+ splitter = u.nodes.get("agent.builder.splitter")
195
+ editor = u.editor.pop('agent.builder', None)
196
+
197
+ # Detach editor from splitter before closing/deleting it to avoid dangling pointers in QSplitter
198
+ try:
199
+ if splitter is not None and editor is not None:
200
+ # Ensure the editor actually belongs to this splitter
201
+ idx = splitter.indexOf(editor) if editor.parent() is splitter else -1
202
+ if idx != -1:
203
+ # Create the replacement without a parent; QSplitter will adopt it.
204
+ from PySide6.QtWidgets import QWidget as _QW
205
+ placeholder = _QW()
206
+ splitter.replaceWidget(idx, placeholder)
207
+ except Exception as e:
208
+ print("Splitter detach failed:", e)
209
+
210
+ if editor is not None:
211
+ try:
212
+ editor.setStyleSheet("")
213
+ except Exception:
214
+ pass
215
+ try:
216
+ editor.close()
217
+ except Exception as e:
218
+ print("NodeEditor close failed:", e)
219
+ try:
220
+ editor.setParent(None)
221
+ editor.deleteLater()
222
+ except Exception:
223
+ pass
224
+
225
+ # Dispose legend label safely
226
+ legend = u.nodes.pop("agent.builder.legend", None)
227
+ if legend is not None:
228
+ try:
229
+ legend.setParent(None)
230
+ legend.deleteLater()
231
+ except Exception:
232
+ pass
233
+
234
+ # Drop splitter reference
235
+ try:
236
+ u.nodes.pop("agent.builder.splitter", None)
237
+ except Exception:
238
+ pass
239
+
240
+ # Ensure all deferred deletions are processed (do it a few rounds to be safe)
241
+ try:
242
+ from PySide6.QtWidgets import QApplication
243
+ for _ in range(3):
244
+ QApplication.sendPostedEvents(None, QEvent.DeferredDelete)
245
+ QApplication.processEvents()
246
+ except Exception:
247
+ pass