pygpt-net 2.6.59__py3-none-any.whl → 2.6.61__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 (91) hide show
  1. pygpt_net/CHANGELOG.txt +11 -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/chat/common.py +115 -6
  6. pygpt_net/controller/chat/input.py +4 -1
  7. pygpt_net/controller/presets/editor.py +442 -39
  8. pygpt_net/controller/presets/presets.py +121 -6
  9. pygpt_net/controller/settings/editor.py +0 -15
  10. pygpt_net/controller/theme/markdown.py +2 -5
  11. pygpt_net/controller/ui/ui.py +4 -7
  12. pygpt_net/core/agents/custom/__init__.py +281 -0
  13. pygpt_net/core/agents/custom/debug.py +64 -0
  14. pygpt_net/core/agents/custom/factory.py +109 -0
  15. pygpt_net/core/agents/custom/graph.py +71 -0
  16. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  17. pygpt_net/core/agents/custom/llama_index/factory.py +100 -0
  18. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  19. pygpt_net/core/agents/custom/llama_index/runner.py +562 -0
  20. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  21. pygpt_net/core/agents/custom/llama_index/utils.py +253 -0
  22. pygpt_net/core/agents/custom/logging.py +50 -0
  23. pygpt_net/core/agents/custom/memory.py +51 -0
  24. pygpt_net/core/agents/custom/router.py +155 -0
  25. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  26. pygpt_net/core/agents/custom/runner.py +455 -0
  27. pygpt_net/core/agents/custom/schema.py +127 -0
  28. pygpt_net/core/agents/custom/utils.py +193 -0
  29. pygpt_net/core/agents/provider.py +72 -7
  30. pygpt_net/core/agents/runner.py +7 -4
  31. pygpt_net/core/agents/runners/helpers.py +1 -1
  32. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  33. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  34. pygpt_net/core/db/viewer.py +11 -5
  35. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  36. pygpt_net/core/{builder → node_editor}/graph.py +28 -226
  37. pygpt_net/core/node_editor/models.py +118 -0
  38. pygpt_net/core/node_editor/types.py +78 -0
  39. pygpt_net/core/node_editor/utils.py +17 -0
  40. pygpt_net/core/presets/presets.py +216 -29
  41. pygpt_net/core/render/markdown/parser.py +0 -2
  42. pygpt_net/core/render/web/renderer.py +10 -8
  43. pygpt_net/data/config/config.json +5 -6
  44. pygpt_net/data/config/models.json +3 -3
  45. pygpt_net/data/config/settings.json +2 -38
  46. pygpt_net/data/locale/locale.de.ini +64 -1
  47. pygpt_net/data/locale/locale.en.ini +63 -4
  48. pygpt_net/data/locale/locale.es.ini +64 -1
  49. pygpt_net/data/locale/locale.fr.ini +64 -1
  50. pygpt_net/data/locale/locale.it.ini +64 -1
  51. pygpt_net/data/locale/locale.pl.ini +65 -2
  52. pygpt_net/data/locale/locale.uk.ini +64 -1
  53. pygpt_net/data/locale/locale.zh.ini +64 -1
  54. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  55. pygpt_net/item/agent.py +5 -1
  56. pygpt_net/item/preset.py +19 -1
  57. pygpt_net/provider/agents/base.py +33 -2
  58. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  59. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  60. pygpt_net/provider/core/agent/json_file.py +11 -5
  61. pygpt_net/provider/core/config/patch.py +10 -1
  62. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  63. pygpt_net/tools/agent_builder/tool.py +233 -52
  64. pygpt_net/tools/agent_builder/ui/dialogs.py +172 -28
  65. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  66. pygpt_net/ui/__init__.py +2 -4
  67. pygpt_net/ui/dialog/about.py +58 -38
  68. pygpt_net/ui/dialog/db.py +142 -3
  69. pygpt_net/ui/dialog/preset.py +62 -8
  70. pygpt_net/ui/layout/toolbox/presets.py +52 -16
  71. pygpt_net/ui/main.py +1 -1
  72. pygpt_net/ui/widget/dialog/db.py +0 -0
  73. pygpt_net/ui/widget/lists/preset.py +644 -60
  74. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  75. pygpt_net/ui/widget/node_editor/command.py +373 -0
  76. pygpt_net/ui/widget/node_editor/config.py +157 -0
  77. pygpt_net/ui/widget/node_editor/editor.py +2070 -0
  78. pygpt_net/ui/widget/node_editor/item.py +493 -0
  79. pygpt_net/ui/widget/node_editor/node.py +1460 -0
  80. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  81. pygpt_net/ui/widget/node_editor/view.py +364 -0
  82. pygpt_net/ui/widget/tabs/output.py +1 -1
  83. pygpt_net/ui/widget/textarea/input.py +2 -2
  84. pygpt_net/utils.py +114 -2
  85. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +80 -93
  86. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +88 -61
  87. pygpt_net/core/agents/custom.py +0 -150
  88. pygpt_net/ui/widget/builder/editor.py +0 -2001
  89. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
  90. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
  91. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/entry_points.txt +0 -0
pygpt_net/ui/dialog/db.py CHANGED
@@ -6,13 +6,13 @@
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.05 18:00:00 #
9
+ # Updated Date: 2025.09.26 03:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtCore import Qt, QTimer, QSignalBlocker
12
+ from PySide6.QtCore import Qt, QTimer, QSignalBlocker, QObject, QEvent
13
13
  from PySide6.QtGui import QAction, QIcon, QIntValidator
14
14
  from PySide6.QtWidgets import QGridLayout, QScrollArea, QSplitter, QComboBox, QLineEdit, QPushButton, \
15
- QHBoxLayout, QVBoxLayout, QLabel, QWidget, QSizePolicy, QCheckBox, QMenuBar, QAbstractItemView, QHeaderView, QStyledItemDelegate
15
+ QHBoxLayout, QVBoxLayout, QLabel, QWidget, QSizePolicy, QCheckBox, QMenuBar, QAbstractItemView, QHeaderView, QStyledItemDelegate, QMenu, QApplication
16
16
 
17
17
  from pygpt_net.ui.widget.dialog.db import DatabaseDialog
18
18
  from pygpt_net.ui.widget.lists.db import DatabaseList, DatabaseTableModel
@@ -32,6 +32,98 @@ class _FastTextDelegate(QStyledItemDelegate):
32
32
  return s
33
33
 
34
34
 
35
+ class _ContextMenuStyler(QObject):
36
+ """
37
+ Ensures that any QMenu spawned from the observed widgets adopts the same
38
+ application stylesheet, palette and font (e.g. Qt Material), even if
39
+ the menu is created without a parent or uses platform defaults.
40
+ The filter is installed only on specific widgets (no app-wide filters).
41
+ """
42
+
43
+ def __init__(self, style_source: QWidget):
44
+ super().__init__(style_source)
45
+ self._source = style_source
46
+ self._scan_timer = QTimer(self)
47
+ self._scan_timer.setInterval(50) # fast enough to catch freshly spawned menus
48
+ self._scan_timer.timeout.connect(self._scan_and_style)
49
+ self._scan_ticks = 0
50
+ self._scan_ticks_max = 8 # up to ~400ms window
51
+
52
+ def eventFilter(self, obj, event):
53
+ # Trigger styling round right before/around menu show moments
54
+ if event.type() in (QEvent.ContextMenu, QEvent.MouseButtonPress, QEvent.KeyPress):
55
+ if event.type() == QEvent.ContextMenu:
56
+ self._start_scan()
57
+ elif event.type() == QEvent.MouseButtonPress and hasattr(event, "button") and event.button() == Qt.RightButton:
58
+ self._start_scan()
59
+ elif event.type() == QEvent.KeyPress:
60
+ key = getattr(event, "key", lambda: None)()
61
+ mods = getattr(event, "modifiers", lambda: Qt.NoModifier)()
62
+ if key == Qt.Key_Menu or (key == Qt.Key_F10 and (mods & Qt.ShiftModifier)):
63
+ self._start_scan()
64
+ return False # do not consume events
65
+
66
+ def _start_scan(self):
67
+ # Start a short, repeated scan to reliably catch the menu top-level window
68
+ self._scan_ticks = 0
69
+ if not self._scan_timer.isActive():
70
+ # immediate pass + scheduled ones
71
+ QTimer.singleShot(0, self._scan_and_style)
72
+ self._scan_timer.start()
73
+
74
+ def _scan_and_style(self):
75
+ self._scan_ticks += 1
76
+ app = QApplication.instance()
77
+ if app is None:
78
+ self._scan_timer.stop()
79
+ return
80
+
81
+ found_any = False
82
+ for w in app.topLevelWidgets():
83
+ if isinstance(w, QMenu):
84
+ found_any = True
85
+ self._ensure_menu_style(w)
86
+
87
+ # Stop once we scanned enough frames, or if no menus appear after a short while
88
+ if self._scan_ticks >= self._scan_ticks_max or (self._scan_ticks >= 2 and not found_any):
89
+ self._scan_timer.stop()
90
+
91
+ def _ensure_menu_style(self, menu: QMenu):
92
+ # Avoid re-styling the same widget repeatedly
93
+ if bool(menu.property("_pygpt_menu_styled")):
94
+ return
95
+
96
+ app = QApplication.instance()
97
+ try:
98
+ # Apply application-level stylesheet (covers Qt Material CSS selectors for QMenu)
99
+ if app is not None:
100
+ ss = app.styleSheet()
101
+ if ss:
102
+ # Assign directly to the menu; do not append, to avoid duplication on repeated shows
103
+ menu.setStyleSheet(ss)
104
+
105
+ # Copy palette, font and style from the source window to guarantee readable contrast
106
+ src = self._source.window() if self._source is not None else None
107
+ if src is None and app is not None:
108
+ src = app.activeWindow()
109
+
110
+ if src is not None:
111
+ menu.setPalette(src.palette())
112
+ menu.setFont(src.font())
113
+ # Also align QStyle to prevent platform defaults from clashing with stylesheet
114
+ menu.setStyle(src.style())
115
+
116
+ # Mark as processed
117
+ menu.setProperty("_pygpt_menu_styled", True)
118
+
119
+ # Update to reflect changes immediately if the menu is already visible
120
+ if menu.isVisible():
121
+ menu.update()
122
+ except Exception:
123
+ # Fail-safe: never break the context menu on styling issues
124
+ pass
125
+
126
+
35
127
  class Database:
36
128
  def __init__(self, window=None):
37
129
  """
@@ -94,6 +186,9 @@ class Database:
94
186
  self.batch_actions_menu.addAction(self.delete_all_action)
95
187
  self.batch_actions_menu.addAction(self.truncate_action)
96
188
 
189
+ # Ensure QMenu used by menu bar follows the application/theme styling
190
+ self._apply_menu_style(self.batch_actions_menu)
191
+
97
192
  layout = QGridLayout()
98
193
  layout.addWidget(splitter, 1, 0)
99
194
  layout.setMenuBar(self.menu_bar)
@@ -102,6 +197,22 @@ class Database:
102
197
  self.window.ui.dialog['debug.db'].setLayout(layout)
103
198
  self.window.ui.dialog['debug.db'].setWindowTitle("Debug: Database (SQLite)")
104
199
 
200
+ def _apply_menu_style(self, menu: QMenu):
201
+ """Ensure QMenu follows application/theme styling consistently."""
202
+ try:
203
+ app = QApplication.instance()
204
+ if app is not None:
205
+ ss = app.styleSheet()
206
+ if ss:
207
+ menu.setStyleSheet(ss)
208
+ if self.window is not None:
209
+ menu.setStyle(self.window.style())
210
+ menu.setPalette(self.window.palette())
211
+ menu.setFont(self.window.font())
212
+ except Exception:
213
+ # Never break UI if styling fails
214
+ pass
215
+
105
216
  def _on_splitter_moved(self, pos, index):
106
217
  if self._text_viewer is not None:
107
218
  self._text_viewer.setUpdatesEnabled(False)
@@ -267,6 +378,34 @@ class DataBrowser(QWidget):
267
378
  self.setLayout(main_layout)
268
379
 
269
380
  view = self.get_list_widget()
381
+
382
+ # Robust, local-only context menu styling (no app-wide filters)
383
+ self._menu_styler = _ContextMenuStyler(self.window if self.window is not None else self)
384
+ if hasattr(view, "installEventFilter"):
385
+ view.installEventFilter(self._menu_styler)
386
+ # Context menu usually originates from the viewport in item views
387
+ if hasattr(view, "viewport") and callable(view.viewport):
388
+ vp = view.viewport()
389
+ if vp is not None and hasattr(vp, "installEventFilter"):
390
+ vp.installEventFilter(self._menu_styler)
391
+ # Also watch headers (they may expose their own context menus)
392
+ if hasattr(view, "horizontalHeader"):
393
+ hh = view.horizontalHeader()
394
+ if hh is not None:
395
+ hh.installEventFilter(self._menu_styler)
396
+ if hasattr(hh, "viewport"):
397
+ hvp = hh.viewport()
398
+ if hvp is not None:
399
+ hvp.installEventFilter(self._menu_styler)
400
+ if hasattr(view, "verticalHeader"):
401
+ vh = view.verticalHeader()
402
+ if vh is not None:
403
+ vh.installEventFilter(self._menu_styler)
404
+ if hasattr(vh, "viewport"):
405
+ vvp = vh.viewport()
406
+ if vvp is not None:
407
+ vvp.installEventFilter(self._menu_styler)
408
+
270
409
  if hasattr(view, "setUniformRowHeights"):
271
410
  view.setUniformRowHeights(True)
272
411
  if hasattr(view, "setWordWrap"):
@@ -6,10 +6,11 @@
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.28 09:00:00 #
9
+ # Updated Date: 2025.09.25 13:04:51 #
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
 
@@ -155,6 +156,8 @@ class Preset(BaseConfigDialog):
155
156
  modes = QWidget()
156
157
  modes.setLayout(rows_mode)
157
158
  modes.setContentsMargins(0, 0, 0, 0)
159
+ # Ensure modes column never grows taller than its content; avoids creating vertical gap above the splitter.
160
+ modes.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
158
161
  self.window.ui.nodes['preset.editor.modes'] = modes
159
162
 
160
163
  # experts
@@ -202,9 +205,23 @@ class Preset(BaseConfigDialog):
202
205
  "ai_personalize",
203
206
  ]
204
207
  for key in left_keys:
208
+ option_layout = options[key]
209
+ if key in ["agent_provider", "agent_provider_openai"]:
210
+ # special case with settings icon on right
211
+ node_layout = QHBoxLayout()
212
+ builder_btn = QPushButton(QIcon(":/icons/robot.svg"), "")
213
+ builder_btn.setToolTip(trans("agent.builder.tooltip"))
214
+ builder_btn.clicked.connect(lambda: self.window.tools.get("agent_builder").toggle())
215
+ node_layout.setContentsMargins(0, 0, 0, 0)
216
+ node_layout.addLayout(options[key])
217
+ node_layout.addWidget(builder_btn)
218
+ # align top right
219
+ node_layout.setAlignment(builder_btn, Qt.AlignTop | Qt.AlignRight)
220
+ option_layout = node_layout
221
+
205
222
  node_key = f"preset.editor.{key}"
206
223
  node = QWidget()
207
- node.setLayout(options[key])
224
+ node.setLayout(option_layout)
208
225
  node.setContentsMargins(0, 0, 0, 0)
209
226
  rows.addWidget(node)
210
227
  self.window.ui.nodes[node_key] = node
@@ -250,6 +267,8 @@ class Preset(BaseConfigDialog):
250
267
  widget_base = QWidget()
251
268
  widget_base.setLayout(rows)
252
269
  widget_base.setMinimumWidth(300)
270
+ # Keep base column at content height; do not stretch vertically.
271
+ widget_base.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
253
272
 
254
273
  self.window.ui.nodes['preset.editor.experts'].setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
255
274
 
@@ -259,12 +278,16 @@ class Preset(BaseConfigDialog):
259
278
 
260
279
  widget_main = QWidget()
261
280
  widget_main.setLayout(main)
281
+ # Critical: ensure the whole top pane (base + modes) stays at content height.
282
+ widget_main.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
262
283
 
263
284
  splitter = QSplitter(Qt.Vertical)
264
285
  splitter.addWidget(widget_main)
265
286
  splitter.addWidget(widget_prompt)
266
- splitter.setStretchFactor(0, 1)
267
- splitter.setStretchFactor(1, 2)
287
+ splitter.setChildrenCollapsible(False) # avoid accidental collapsing of any pane
288
+ # All extra vertical space goes to bottom (extra agent options); top stays at its sizeHint.
289
+ splitter.setStretchFactor(0, 0)
290
+ splitter.setStretchFactor(1, 1)
268
291
  self.window.ui.splitters['editor.presets'] = splitter
269
292
 
270
293
  widget_personalize = QWidget()
@@ -383,13 +406,17 @@ class AvatarWidget(QWidget):
383
406
  main_layout.setContentsMargins(0, 0, 0, 0)
384
407
 
385
408
  current_avatar_label = QLabel(trans("preset.personalize.avatar.current"))
409
+ # Center the section title for better visual balance.
410
+ current_avatar_label.setAlignment(Qt.AlignHCenter)
386
411
  main_layout.addWidget(current_avatar_label)
387
412
 
388
413
  self.avatar_preview = QLabel(self)
389
414
  self.avatar_preview.setFixedSize(200, 200)
390
- self.avatar_preview.setStyleSheet("border: 1px solid gray;")
415
+ # Keep a visible border, but round it to visually indicate the circular avatar.
416
+ self.avatar_preview.setStyleSheet("border: 1px solid gray; border-radius: 100px; background: transparent;")
391
417
  self.avatar_preview.setAlignment(Qt.AlignCenter)
392
- main_layout.addWidget(self.avatar_preview)
418
+ # Center the avatar widget within the column.
419
+ main_layout.addWidget(self.avatar_preview, 0, Qt.AlignHCenter)
393
420
 
394
421
  buttons_layout = QHBoxLayout()
395
422
 
@@ -402,6 +429,8 @@ class AvatarWidget(QWidget):
402
429
  self.remove_button.setEnabled(False)
403
430
  buttons_layout.addWidget(self.remove_button)
404
431
  buttons_layout.setContentsMargins(0, 10, 0, 0)
432
+ # Center the action buttons below the avatar.
433
+ buttons_layout.setAlignment(Qt.AlignCenter)
405
434
 
406
435
  main_layout.addLayout(buttons_layout)
407
436
  main_layout.setContentsMargins(0, 10, 0, 0)
@@ -425,7 +454,9 @@ class AvatarWidget(QWidget):
425
454
  pixmap = QPixmap(file_path)
426
455
  if not pixmap.isNull():
427
456
  cover_pix = self.get_cover_pixmap(pixmap, self.avatar_preview.width(), self.avatar_preview.height())
428
- self.avatar_preview.setPixmap(cover_pix)
457
+ # Render the avatar as a circular pixmap.
458
+ circle_pix = self.get_circular_pixmap(cover_pix, self.avatar_preview.width(), self.avatar_preview.height())
459
+ self.avatar_preview.setPixmap(circle_pix)
429
460
  self.remove_button.setEnabled(True)
430
461
 
431
462
  def enable_remove_button(self, enabled: bool = True):
@@ -457,7 +488,30 @@ class AvatarWidget(QWidget):
457
488
  y = (scaled_pix.height() - target_height) // 2
458
489
  return scaled_pix.copy(x, y, target_width, target_height)
459
490
 
491
+ def get_circular_pixmap(self, pixmap, target_width: int, target_height: int):
492
+ """
493
+ Create a circular version of the given pixmap using an antialiased clip path.
494
+
495
+ :param pixmap: Source pixmap already scaled/cropped to target size
496
+ :param target_width: Target width
497
+ :param target_height: Target height
498
+ :return: Circular masked pixmap with transparent corners
499
+ """
500
+ from PySide6.QtGui import QPainter, QPainterPath, QPixmap # local import to avoid altering global imports
501
+ result = QPixmap(target_width, target_height)
502
+ result.fill(Qt.transparent)
503
+
504
+ painter = QPainter(result)
505
+ painter.setRenderHint(QPainter.Antialiasing, True)
506
+ path = QPainterPath()
507
+ path.addEllipse(0, 0, target_width, target_height)
508
+ painter.setClipPath(path)
509
+ painter.drawPixmap(0, 0, pixmap)
510
+ painter.end()
511
+
512
+ return result
513
+
460
514
  def remove_avatar(self):
461
515
  """Remove the current avatar image."""
462
516
  self.avatar_preview.clear()
463
- self.remove_button.setEnabled(False)
517
+ self.remove_button.setEnabled(False)
@@ -6,11 +6,11 @@
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.05 18:00:00 #
9
+ # Updated Date: 2025.09.26 03:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6 import QtCore
13
- from PySide6.QtGui import QStandardItemModel, Qt, QIcon
13
+ from PySide6.QtGui import QStandardItemModel, QStandardItem, Qt, QIcon
14
14
  from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QSizePolicy
15
15
 
16
16
  from pygpt_net.core.types import (
@@ -113,35 +113,42 @@ class Presets:
113
113
  nodes = self.window.ui.nodes
114
114
  models = self.window.ui.models
115
115
 
116
- view = nodes[self.id]
116
+ view: PresetList = nodes[self.id]
117
117
  model = models.get(self.id)
118
118
 
119
- view.backup_selection()
119
+ # If view requested selection override, do NOT override it by backup
120
+ selection_override_ids = getattr(view, "_selection_override_ids", None)
121
+ if not selection_override_ids:
122
+ view.backup_selection()
120
123
 
121
124
  if model is None:
122
125
  model = self.create_model(self.window)
123
126
  models[self.id] = model
124
127
  view.setModel(model)
125
128
 
129
+ # Block user input during model rebuild to avoid crashes on quick clicks
130
+ view.begin_model_update()
131
+
132
+ # Turn off updates to avoid flicker and transient artifacts
126
133
  view.setUpdatesEnabled(False)
127
134
  try:
128
- if not data:
129
- model.setRowCount(0)
130
- else:
131
- count = len(data)
132
- model.setRowCount(count)
135
+ # Rebuild model cleanly to avoid any stale items causing visual glitches
136
+ model.clear()
137
+ model.setColumnCount(1)
133
138
 
139
+ if data:
134
140
  is_expert_mode = (mode == MODE_EXPERT)
135
141
  is_agent_mode = (mode == MODE_AGENT)
136
142
  count_experts = self.window.core.experts.count_experts if is_agent_mode else None
137
143
  startswith_current = "current."
138
144
 
139
- index_fn = model.index
140
- set_item_data = model.setItemData
141
- display_role = QtCore.Qt.DisplayRole
142
- tooltip_role = QtCore.Qt.ToolTipRole
145
+ role_uuid = QtCore.Qt.UserRole + 1
146
+ role_id = QtCore.Qt.UserRole + 2
147
+ role_is_special = QtCore.Qt.UserRole + 3
143
148
 
144
149
  for i, (key, item) in enumerate(data.items()):
150
+ qitem = QStandardItem()
151
+
145
152
  name = item.name
146
153
  if is_expert_mode and item.enabled and not key.startswith(startswith_current):
147
154
  name = f"[x] {name}"
@@ -153,9 +160,38 @@ class Presets:
153
160
  prompt = str(item.prompt)
154
161
  tooltip = prompt if len(prompt) <= 80 else f"{prompt[:80]}..."
155
162
 
156
- idx = index_fn(i, 0)
157
- set_item_data(idx, {display_role: name, tooltip_role: tooltip})
163
+ qitem.setText(name)
164
+ qitem.setToolTip(tooltip)
165
+ qitem.setData(item.uuid, role_uuid)
166
+ qitem.setData(key, role_id)
167
+ qitem.setData(key.startswith(startswith_current), role_is_special)
168
+
169
+ # Pin row 0 (no drag, no drop)
170
+ # Other rows: drag enabled only; drop is handled by view between rows
171
+ if i != 0 and not key.startswith(startswith_current):
172
+ flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled
173
+ else:
174
+ flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
175
+ qitem.setFlags(flags)
176
+
177
+ model.appendRow(qitem)
158
178
  finally:
179
+ # Apply pending scroll (if any) before re-enabling updates
180
+ view.apply_pending_scroll()
159
181
  view.setUpdatesEnabled(True)
160
182
 
161
- view.restore_selection()
183
+ dnd_enabled = bool(self.window.core.config.get('presets.drag_and_drop.enabled'))
184
+ view.set_dnd_enabled(dnd_enabled)
185
+
186
+ # If override requested, force saved selection IDs to those override IDs
187
+ if selection_override_ids:
188
+ view._saved_selection_ids = list(selection_override_ids)
189
+ view._selection_override_ids = None # consume one-shot override
190
+
191
+ # Restore selection by ID (so it follows the same item even if rows moved)
192
+ view.restore_selection()
193
+ # Force repaint in case Qt defers layout until next input
194
+ view.viewport().update()
195
+
196
+ # Re-enable user interaction after the rebuild is fully done
197
+ view.end_model_update()
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
  """
File without changes