pygpt-net 2.6.60__py3-none-any.whl → 2.6.62__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 (87) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/common.py +115 -6
  4. pygpt_net/controller/chat/input.py +4 -1
  5. pygpt_net/controller/chat/response.py +8 -2
  6. pygpt_net/controller/presets/presets.py +121 -6
  7. pygpt_net/controller/settings/editor.py +0 -15
  8. pygpt_net/controller/settings/profile.py +16 -4
  9. pygpt_net/controller/settings/workdir.py +30 -5
  10. pygpt_net/controller/theme/common.py +4 -2
  11. pygpt_net/controller/theme/markdown.py +4 -7
  12. pygpt_net/controller/theme/theme.py +2 -1
  13. pygpt_net/controller/ui/ui.py +32 -7
  14. pygpt_net/core/agents/custom/__init__.py +7 -1
  15. pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
  16. pygpt_net/core/agents/custom/llama_index/runner.py +52 -4
  17. pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
  18. pygpt_net/core/agents/custom/router.py +45 -6
  19. pygpt_net/core/agents/custom/runner.py +11 -5
  20. pygpt_net/core/agents/custom/schema.py +3 -1
  21. pygpt_net/core/agents/custom/utils.py +13 -1
  22. pygpt_net/core/agents/runners/llama_workflow.py +65 -5
  23. pygpt_net/core/agents/runners/openai_workflow.py +2 -1
  24. pygpt_net/core/db/viewer.py +11 -5
  25. pygpt_net/core/node_editor/graph.py +18 -9
  26. pygpt_net/core/node_editor/models.py +9 -2
  27. pygpt_net/core/node_editor/types.py +15 -1
  28. pygpt_net/core/presets/presets.py +216 -29
  29. pygpt_net/core/render/markdown/parser.py +0 -2
  30. pygpt_net/core/render/web/renderer.py +76 -11
  31. pygpt_net/data/config/config.json +5 -6
  32. pygpt_net/data/config/models.json +3 -3
  33. pygpt_net/data/config/settings.json +2 -38
  34. pygpt_net/data/css/style.dark.css +18 -0
  35. pygpt_net/data/css/style.light.css +20 -1
  36. pygpt_net/data/locale/locale.de.ini +66 -1
  37. pygpt_net/data/locale/locale.en.ini +64 -3
  38. pygpt_net/data/locale/locale.es.ini +66 -1
  39. pygpt_net/data/locale/locale.fr.ini +66 -1
  40. pygpt_net/data/locale/locale.it.ini +66 -1
  41. pygpt_net/data/locale/locale.pl.ini +67 -2
  42. pygpt_net/data/locale/locale.uk.ini +66 -1
  43. pygpt_net/data/locale/locale.zh.ini +66 -1
  44. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  45. pygpt_net/item/ctx.py +23 -1
  46. pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
  47. pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
  48. pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
  49. pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
  50. pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
  51. pygpt_net/provider/agents/openai/agent.py +3 -1
  52. pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
  53. pygpt_net/provider/agents/openai/agent_planner.py +6 -2
  54. pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
  55. pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
  56. pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
  57. pygpt_net/provider/agents/openai/evolve.py +6 -2
  58. pygpt_net/provider/agents/openai/supervisor.py +3 -1
  59. pygpt_net/provider/api/openai/agents/response.py +1 -0
  60. pygpt_net/provider/core/config/patch.py +18 -1
  61. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  62. pygpt_net/tools/agent_builder/tool.py +48 -26
  63. pygpt_net/tools/agent_builder/ui/dialogs.py +36 -28
  64. pygpt_net/ui/__init__.py +2 -4
  65. pygpt_net/ui/dialog/about.py +58 -38
  66. pygpt_net/ui/dialog/db.py +142 -3
  67. pygpt_net/ui/dialog/preset.py +47 -8
  68. pygpt_net/ui/layout/toolbox/presets.py +64 -16
  69. pygpt_net/ui/main.py +2 -2
  70. pygpt_net/ui/widget/dialog/confirm.py +27 -3
  71. pygpt_net/ui/widget/dialog/db.py +0 -0
  72. pygpt_net/ui/widget/draw/painter.py +90 -1
  73. pygpt_net/ui/widget/lists/preset.py +908 -60
  74. pygpt_net/ui/widget/node_editor/command.py +10 -10
  75. pygpt_net/ui/widget/node_editor/config.py +157 -0
  76. pygpt_net/ui/widget/node_editor/editor.py +223 -153
  77. pygpt_net/ui/widget/node_editor/item.py +12 -11
  78. pygpt_net/ui/widget/node_editor/node.py +246 -13
  79. pygpt_net/ui/widget/node_editor/view.py +179 -63
  80. pygpt_net/ui/widget/tabs/output.py +1 -1
  81. pygpt_net/ui/widget/textarea/input.py +157 -23
  82. pygpt_net/utils.py +114 -2
  83. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +26 -100
  84. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +86 -85
  85. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
  86. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
  87. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.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,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.28 09:00:00 #
9
+ # Updated Date: 2025.09.25 13:04:51 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import Qt
@@ -156,6 +156,8 @@ class Preset(BaseConfigDialog):
156
156
  modes = QWidget()
157
157
  modes.setLayout(rows_mode)
158
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)
159
161
  self.window.ui.nodes['preset.editor.modes'] = modes
160
162
 
161
163
  # experts
@@ -208,7 +210,7 @@ class Preset(BaseConfigDialog):
208
210
  # special case with settings icon on right
209
211
  node_layout = QHBoxLayout()
210
212
  builder_btn = QPushButton(QIcon(":/icons/robot.svg"), "")
211
- builder_btn.setToolTip("Open Agents Builder (beta)")
213
+ builder_btn.setToolTip(trans("agent.builder.tooltip"))
212
214
  builder_btn.clicked.connect(lambda: self.window.tools.get("agent_builder").toggle())
213
215
  node_layout.setContentsMargins(0, 0, 0, 0)
214
216
  node_layout.addLayout(options[key])
@@ -265,6 +267,8 @@ class Preset(BaseConfigDialog):
265
267
  widget_base = QWidget()
266
268
  widget_base.setLayout(rows)
267
269
  widget_base.setMinimumWidth(300)
270
+ # Keep base column at content height; do not stretch vertically.
271
+ widget_base.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
268
272
 
269
273
  self.window.ui.nodes['preset.editor.experts'].setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
270
274
 
@@ -274,12 +278,16 @@ class Preset(BaseConfigDialog):
274
278
 
275
279
  widget_main = QWidget()
276
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)
277
283
 
278
284
  splitter = QSplitter(Qt.Vertical)
279
285
  splitter.addWidget(widget_main)
280
286
  splitter.addWidget(widget_prompt)
281
- splitter.setStretchFactor(0, 1)
282
- 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)
283
291
  self.window.ui.splitters['editor.presets'] = splitter
284
292
 
285
293
  widget_personalize = QWidget()
@@ -398,13 +406,17 @@ class AvatarWidget(QWidget):
398
406
  main_layout.setContentsMargins(0, 0, 0, 0)
399
407
 
400
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)
401
411
  main_layout.addWidget(current_avatar_label)
402
412
 
403
413
  self.avatar_preview = QLabel(self)
404
414
  self.avatar_preview.setFixedSize(200, 200)
405
- 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;")
406
417
  self.avatar_preview.setAlignment(Qt.AlignCenter)
407
- main_layout.addWidget(self.avatar_preview)
418
+ # Center the avatar widget within the column.
419
+ main_layout.addWidget(self.avatar_preview, 0, Qt.AlignHCenter)
408
420
 
409
421
  buttons_layout = QHBoxLayout()
410
422
 
@@ -417,6 +429,8 @@ class AvatarWidget(QWidget):
417
429
  self.remove_button.setEnabled(False)
418
430
  buttons_layout.addWidget(self.remove_button)
419
431
  buttons_layout.setContentsMargins(0, 10, 0, 0)
432
+ # Center the action buttons below the avatar.
433
+ buttons_layout.setAlignment(Qt.AlignCenter)
420
434
 
421
435
  main_layout.addLayout(buttons_layout)
422
436
  main_layout.setContentsMargins(0, 10, 0, 0)
@@ -440,7 +454,9 @@ class AvatarWidget(QWidget):
440
454
  pixmap = QPixmap(file_path)
441
455
  if not pixmap.isNull():
442
456
  cover_pix = self.get_cover_pixmap(pixmap, self.avatar_preview.width(), self.avatar_preview.height())
443
- 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)
444
460
  self.remove_button.setEnabled(True)
445
461
 
446
462
  def enable_remove_button(self, enabled: bool = True):
@@ -472,7 +488,30 @@ class AvatarWidget(QWidget):
472
488
  y = (scaled_pix.height() - target_height) // 2
473
489
  return scaled_pix.copy(x, y, target_width, target_height)
474
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
+
475
514
  def remove_avatar(self):
476
515
  """Remove the current avatar image."""
477
516
  self.avatar_preview.clear()
478
- self.remove_button.setEnabled(False)
517
+ self.remove_button.setEnabled(False)
@@ -1,3 +1,4 @@
1
+ # ui/layout/presets.py
1
2
  #!/usr/bin/env python3
2
3
  # -*- coding: utf-8 -*-
3
4
  # ================================================== #
@@ -6,11 +7,11 @@
6
7
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
8
  # MIT License #
8
9
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.05 18:00:00 #
10
+ # Updated Date: 2025.09.26 13:30:00 #
10
11
  # ================================================== #
11
12
 
12
13
  from PySide6 import QtCore
13
- from PySide6.QtGui import QStandardItemModel, Qt, QIcon
14
+ from PySide6.QtGui import QStandardItemModel, QStandardItem, Qt, QIcon
14
15
  from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QSizePolicy
15
16
 
16
17
  from pygpt_net.core.types import (
@@ -113,35 +114,52 @@ class Presets:
113
114
  nodes = self.window.ui.nodes
114
115
  models = self.window.ui.models
115
116
 
116
- view = nodes[self.id]
117
+ view: PresetList = nodes[self.id]
117
118
  model = models.get(self.id)
118
119
 
119
- view.backup_selection()
120
+ # If view requested selection override, do NOT override it by backup
121
+ selection_override_ids = getattr(view, "_selection_override_ids", None)
122
+ if not selection_override_ids:
123
+ view.backup_selection()
120
124
 
121
125
  if model is None:
122
126
  model = self.create_model(self.window)
123
127
  models[self.id] = model
124
128
  view.setModel(model)
125
129
 
130
+ # Preserve current scroll position across model rebuild to avoid a visible jump to the top.
131
+ # This is applied while updates are disabled, then restored just before re-enabling them.
132
+ try:
133
+ v = view.verticalScrollBar().value()
134
+ h = view.horizontalScrollBar().value()
135
+ view.set_pending_v_scroll(v)
136
+ view.set_pending_h_scroll(h)
137
+ except Exception:
138
+ pass
139
+
140
+ # Block user input during model rebuild to avoid crashes on quick clicks
141
+ view.begin_model_update()
142
+
143
+ # Turn off updates to avoid flicker and transient artifacts
126
144
  view.setUpdatesEnabled(False)
127
145
  try:
128
- if not data:
129
- model.setRowCount(0)
130
- else:
131
- count = len(data)
132
- model.setRowCount(count)
146
+ # Rebuild model cleanly to avoid any stale items causing visual glitches
147
+ model.clear()
148
+ model.setColumnCount(1)
133
149
 
150
+ if data:
134
151
  is_expert_mode = (mode == MODE_EXPERT)
135
152
  is_agent_mode = (mode == MODE_AGENT)
136
153
  count_experts = self.window.core.experts.count_experts if is_agent_mode else None
137
154
  startswith_current = "current."
138
155
 
139
- index_fn = model.index
140
- set_item_data = model.setItemData
141
- display_role = QtCore.Qt.DisplayRole
142
- tooltip_role = QtCore.Qt.ToolTipRole
156
+ role_uuid = QtCore.Qt.UserRole + 1
157
+ role_id = QtCore.Qt.UserRole + 2
158
+ role_is_special = QtCore.Qt.UserRole + 3
143
159
 
144
160
  for i, (key, item) in enumerate(data.items()):
161
+ qitem = QStandardItem()
162
+
145
163
  name = item.name
146
164
  if is_expert_mode and item.enabled and not key.startswith(startswith_current):
147
165
  name = f"[x] {name}"
@@ -153,9 +171,39 @@ class Presets:
153
171
  prompt = str(item.prompt)
154
172
  tooltip = prompt if len(prompt) <= 80 else f"{prompt[:80]}..."
155
173
 
156
- idx = index_fn(i, 0)
157
- set_item_data(idx, {display_role: name, tooltip_role: tooltip})
174
+ qitem.setText(name)
175
+ qitem.setToolTip(tooltip)
176
+ qitem.setData(item.uuid, role_uuid)
177
+ qitem.setData(key, role_id)
178
+ qitem.setData(key.startswith(startswith_current), role_is_special)
179
+
180
+ # Pin row 0 (no drag, no drop)
181
+ # Other rows: drag enabled only; drop is handled by view between rows
182
+ if i != 0 and not key.startswith(startswith_current):
183
+ flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled
184
+ else:
185
+ flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
186
+ qitem.setFlags(flags)
187
+
188
+ model.appendRow(qitem)
158
189
  finally:
190
+ # Apply pending scroll (if any) before re-enabling updates
191
+ view.apply_pending_scroll()
159
192
  view.setUpdatesEnabled(True)
160
193
 
161
- view.restore_selection()
194
+ dnd_enabled = bool(self.window.core.config.get('presets.drag_and_drop.enabled'))
195
+ view.set_dnd_enabled(dnd_enabled)
196
+
197
+ # If override requested, force saved selection IDs to those override IDs
198
+ if selection_override_ids:
199
+ view._saved_selection_ids = list(selection_override_ids)
200
+ view._selection_override_ids = None # consume one-shot override
201
+
202
+ # Restore selection by ID (so it follows the same item even if rows moved)
203
+ view.restore_selection()
204
+ # Force repaint in case Qt defers layout until next input
205
+ view.viewport().update()
206
+
207
+ # Clear one-shot pending scroll values and re-enable user interaction
208
+ view.clear_pending_scroll()
209
+ view.end_model_update()
pygpt_net/ui/main.py CHANGED
@@ -22,7 +22,7 @@ from pygpt_net.controller import Controller
22
22
  from pygpt_net.tools import Tools
23
23
  from pygpt_net.ui import UI
24
24
  from pygpt_net.ui.widget.textarea.web import ChatWebOutput
25
- from pygpt_net.utils import get_app_meta, freeze_updates, set_env, has_env, get_env
25
+ from pygpt_net.utils import get_app_meta, freeze_updates, set_env, has_env, get_env, trans
26
26
 
27
27
 
28
28
  class MainWindow(QMainWindow, QtStyleTools):
@@ -356,7 +356,7 @@ class MainWindow(QMainWindow, QtStyleTools):
356
356
  self.core.presets.save_all()
357
357
  print("Exiting...")
358
358
  print("")
359
- print("Do you like PyGPT? Support the development of the project: https://pygpt.net/#donate")
359
+ print(f"{trans('exit.msg')} https://pygpt.net/#donate")
360
360
 
361
361
  def changeEvent(self, event):
362
362
  """
@@ -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: 2024.04.12 10:00:00 #
9
+ # Updated Date: 2025.09.26 10:00:00 #
10
10
  # ================================================== #
11
11
 
12
+ import sys
12
13
  from PySide6.QtCore import Qt
13
14
  from PySide6.QtWidgets import QDialog, QLabel, QHBoxLayout, QVBoxLayout, QPushButton
14
15
 
@@ -32,6 +33,7 @@ class ConfirmDialog(QDialog):
32
33
  self.setWindowTitle(trans('dialog.confirm.title'))
33
34
  self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) # always on top
34
35
 
36
+ # Buttons
35
37
  self.window.ui.nodes['dialog.confirm.btn.yes'] = QPushButton(trans('dialog.confirm.yes'))
36
38
  self.window.ui.nodes['dialog.confirm.btn.yes'].clicked.connect(
37
39
  lambda: self.window.controller.dialogs.confirm.accept(self.type, self.id, self.parent_object))
@@ -40,9 +42,24 @@ class ConfirmDialog(QDialog):
40
42
  self.window.ui.nodes['dialog.confirm.btn.no'].clicked.connect(
41
43
  lambda: self.window.controller.dialogs.confirm.dismiss(self.type, self.id))
42
44
 
45
+ # Always make the neutral action (No/Cancel) the default/active one.
46
+ # This ensures Enter triggers the safe option by default.
47
+ self.window.ui.nodes['dialog.confirm.btn.no'].setAutoDefault(True)
48
+ self.window.ui.nodes['dialog.confirm.btn.no'].setDefault(True)
49
+ self.window.ui.nodes['dialog.confirm.btn.no'].setFocus()
50
+ self.window.ui.nodes['dialog.confirm.btn.yes'].setAutoDefault(False)
51
+ self.window.ui.nodes['dialog.confirm.btn.yes'].setDefault(False)
52
+
53
+ # Bottom button row with platform-specific ordering
54
+ # Windows: affirmative on the left, neutral on the right
55
+ # Linux/macOS: neutral on the left, affirmative on the right
43
56
  bottom = QHBoxLayout()
44
- bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.no'])
45
- bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.yes'])
57
+ if self._affirmative_on_left():
58
+ bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.yes'])
59
+ bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.no'])
60
+ else:
61
+ bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.no'])
62
+ bottom.addWidget(self.window.ui.nodes['dialog.confirm.btn.yes'])
46
63
 
47
64
  self.layout = QVBoxLayout()
48
65
  self.message = QLabel("")
@@ -54,6 +71,13 @@ class ConfirmDialog(QDialog):
54
71
  self.layout.addLayout(bottom)
55
72
  self.setLayout(self.layout)
56
73
 
74
+ def _affirmative_on_left(self) -> bool:
75
+ """
76
+ Decide button order depending on the platform.
77
+ Returns True on Windows, False otherwise (Linux/macOS).
78
+ """
79
+ return sys.platform.startswith('win')
80
+
57
81
  def closeEvent(self, event):
58
82
  """
59
83
  Close event handler
File without changes
@@ -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.02 15:00:00 #
9
+ # Updated Date: 2025.09.26 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
@@ -98,6 +98,11 @@ class PainterWidget(QWidget):
98
98
  self._autoScrollMinSpeed = 2 # px per tick (min)
99
99
  self._autoScrollMaxSpeed = 18 # px per tick (max)
100
100
 
101
+ # Pan (middle mouse) state
102
+ self._panning = False
103
+ self._panLastGlobalPos = QPoint()
104
+ self._cursorBeforePan = None # store/restore cursor shape while panning
105
+
101
106
  # Actions
102
107
  self._act_undo = QAction(QIcon(":/icons/undo.svg"), trans('action.undo'), self)
103
108
  self._act_undo.triggered.connect(self.undo)
@@ -1084,6 +1089,69 @@ class PainterWidget(QWidget):
1084
1089
  if scrolled or dx != 0 or dy != 0:
1085
1090
  self.update()
1086
1091
 
1092
+ # ---------- Pan (middle mouse drag) ----------
1093
+
1094
+ def _can_pan(self) -> bool:
1095
+ """
1096
+ Return True if widget is inside a scroll area and content is scrollable.
1097
+ """
1098
+ self._find_scroll_area()
1099
+ if self._scrollArea is None:
1100
+ return False
1101
+ hbar = self._scrollArea.horizontalScrollBar()
1102
+ vbar = self._scrollArea.verticalScrollBar()
1103
+ h_ok = hbar is not None and hbar.maximum() > hbar.minimum()
1104
+ v_ok = vbar is not None and vbar.maximum() > vbar.minimum()
1105
+ return h_ok or v_ok
1106
+
1107
+ def _start_pan(self, global_pos: QPoint):
1108
+ """
1109
+ Begin view panning with middle mouse button.
1110
+ """
1111
+ if self._panning:
1112
+ return
1113
+ self._panning = True
1114
+ self._panLastGlobalPos = QPoint(global_pos)
1115
+ # Store current cursor to restore later
1116
+ self._cursorBeforePan = QCursor(self.cursor())
1117
+ # Use a closed hand to indicate grabbing the canvas
1118
+ self.setCursor(QCursor(Qt.ClosedHandCursor))
1119
+ self.grabMouse()
1120
+
1121
+ def _update_pan(self, global_pos: QPoint):
1122
+ """
1123
+ Update scrollbars based on mouse movement delta in global coordinates.
1124
+ """
1125
+ if not self._panning or self._scrollArea is None:
1126
+ return
1127
+ dx = global_pos.x() - self._panLastGlobalPos.x()
1128
+ dy = global_pos.y() - self._panLastGlobalPos.y()
1129
+ self._panLastGlobalPos = QPoint(global_pos)
1130
+
1131
+ hbar = self._scrollArea.horizontalScrollBar()
1132
+ vbar = self._scrollArea.verticalScrollBar()
1133
+
1134
+ # Dragging the content to the right should reveal the left side -> subtract deltas
1135
+ if hbar is not None and hbar.maximum() > hbar.minimum():
1136
+ hbar.setValue(int(max(hbar.minimum(), min(hbar.maximum(), hbar.value() - dx))))
1137
+ if vbar is not None and vbar.maximum() > vbar.minimum():
1138
+ vbar.setValue(int(max(vbar.minimum(), min(vbar.maximum(), vbar.value() - dy))))
1139
+
1140
+ def _end_pan(self):
1141
+ """
1142
+ End panning and restore previous cursor.
1143
+ """
1144
+ if not self._panning:
1145
+ return
1146
+ self._panning = False
1147
+ self.releaseMouse()
1148
+ try:
1149
+ if self._cursorBeforePan is not None:
1150
+ # Restore previous cursor (do not guess based on mode/crop)
1151
+ self.setCursor(self._cursorBeforePan)
1152
+ finally:
1153
+ self._cursorBeforePan = None
1154
+
1087
1155
  # ---------- Events ----------
1088
1156
 
1089
1157
  def wheelEvent(self, event):
@@ -1109,6 +1177,14 @@ class PainterWidget(QWidget):
1109
1177
 
1110
1178
  :param event: Event
1111
1179
  """
1180
+ # Middle button: start panning if scrollable
1181
+ if event.button() == Qt.MiddleButton:
1182
+ if not (self.cropping and self._selecting) and not self.drawing and self._can_pan():
1183
+ gp = event.globalPosition().toPoint()
1184
+ self._start_pan(gp)
1185
+ event.accept()
1186
+ return
1187
+
1112
1188
  if event.button() == Qt.LeftButton:
1113
1189
  self._mouseDown = True
1114
1190
  if self.cropping:
@@ -1146,6 +1222,13 @@ class PainterWidget(QWidget):
1146
1222
 
1147
1223
  :param event: Event
1148
1224
  """
1225
+ # Update panning if active
1226
+ if self._panning and (event.buttons() & Qt.MiddleButton):
1227
+ gp = event.globalPosition().toPoint()
1228
+ self._update_pan(gp)
1229
+ event.accept()
1230
+ return
1231
+
1149
1232
  if self.cropping and self._selecting and (event.buttons() & Qt.LeftButton):
1150
1233
  self._selectionRect = QRect(self._selectionStart, self._to_canvas_point(event.position()))
1151
1234
  self.update()
@@ -1175,6 +1258,12 @@ class PainterWidget(QWidget):
1175
1258
 
1176
1259
  :param event: Event
1177
1260
  """
1261
+ # End panning on middle button release
1262
+ if event.button() == Qt.MiddleButton:
1263
+ self._end_pan()
1264
+ event.accept()
1265
+ return
1266
+
1178
1267
  if event.button() in (Qt.LeftButton, Qt.RightButton):
1179
1268
  self._mouseDown = False
1180
1269
  if self.cropping and self._selecting: