pygpt-net 2.6.63__py3-none-any.whl → 2.6.65__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 (62) hide show
  1. pygpt_net/CHANGELOG.txt +16 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +3 -1
  4. pygpt_net/controller/attachment/attachment.py +17 -8
  5. pygpt_net/controller/camera/camera.py +4 -4
  6. pygpt_net/controller/files/files.py +71 -2
  7. pygpt_net/controller/lang/custom.py +2 -2
  8. pygpt_net/controller/presets/editor.py +137 -22
  9. pygpt_net/controller/ui/mode.py +18 -3
  10. pygpt_net/core/agents/custom/__init__.py +18 -2
  11. pygpt_net/core/agents/custom/runner.py +2 -2
  12. pygpt_net/core/attachments/clipboard.py +146 -0
  13. pygpt_net/core/render/web/renderer.py +44 -11
  14. pygpt_net/data/config/config.json +3 -3
  15. pygpt_net/data/config/models.json +3 -3
  16. pygpt_net/data/config/presets/agent_openai_coder.json +15 -1
  17. pygpt_net/data/css/style.dark.css +12 -0
  18. pygpt_net/data/css/style.light.css +12 -0
  19. pygpt_net/data/icons/pin2.svg +1 -0
  20. pygpt_net/data/icons/pin3.svg +3 -0
  21. pygpt_net/data/icons/point.svg +1 -0
  22. pygpt_net/data/icons/target.svg +1 -0
  23. pygpt_net/data/js/app/runtime.js +11 -4
  24. pygpt_net/data/js/app/scroll.js +14 -0
  25. pygpt_net/data/js/app/ui.js +19 -2
  26. pygpt_net/data/js/app/user.js +22 -54
  27. pygpt_net/data/js/app.min.js +13 -14
  28. pygpt_net/data/locale/locale.de.ini +32 -0
  29. pygpt_net/data/locale/locale.en.ini +38 -2
  30. pygpt_net/data/locale/locale.es.ini +32 -0
  31. pygpt_net/data/locale/locale.fr.ini +32 -0
  32. pygpt_net/data/locale/locale.it.ini +32 -0
  33. pygpt_net/data/locale/locale.pl.ini +34 -2
  34. pygpt_net/data/locale/locale.uk.ini +32 -0
  35. pygpt_net/data/locale/locale.zh.ini +32 -0
  36. pygpt_net/icons.qrc +4 -0
  37. pygpt_net/icons_rc.py +274 -137
  38. pygpt_net/js_rc.py +8262 -8230
  39. pygpt_net/provider/agents/llama_index/planner_workflow.py +15 -3
  40. pygpt_net/provider/agents/llama_index/workflow/planner.py +69 -41
  41. pygpt_net/provider/agents/openai/agent_planner.py +57 -35
  42. pygpt_net/provider/agents/openai/evolve.py +0 -3
  43. pygpt_net/provider/api/google/__init__.py +9 -3
  44. pygpt_net/provider/api/google/image.py +11 -1
  45. pygpt_net/provider/api/google/music.py +375 -0
  46. pygpt_net/provider/core/config/patch.py +8 -0
  47. pygpt_net/ui/__init__.py +6 -1
  48. pygpt_net/ui/dialog/preset.py +9 -4
  49. pygpt_net/ui/layout/chat/attachments.py +18 -1
  50. pygpt_net/ui/layout/status.py +3 -3
  51. pygpt_net/ui/widget/element/status.py +55 -0
  52. pygpt_net/ui/widget/filesystem/explorer.py +116 -2
  53. pygpt_net/ui/widget/lists/context.py +26 -16
  54. pygpt_net/ui/widget/option/combo.py +149 -11
  55. pygpt_net/ui/widget/textarea/input.py +71 -17
  56. pygpt_net/ui/widget/textarea/web.py +1 -1
  57. pygpt_net/ui/widget/vision/camera.py +135 -12
  58. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/METADATA +18 -2
  59. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/RECORD +62 -55
  60. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/LICENSE +0 -0
  61. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/WHEEL +0 -0
  62. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/entry_points.txt +0 -0
@@ -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.08.24 23:00:00 #
9
+ # Updated Date: 2025.09.28 08:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
13
13
  import os
14
14
 
15
- from PySide6.QtCore import Qt, QModelIndex, QDir
15
+ from PySide6.QtCore import Qt, QModelIndex, QDir, QObject, QEvent
16
16
  from PySide6.QtGui import QAction, QIcon, QCursor, QResizeEvent
17
17
  from PySide6.QtWidgets import QTreeView, QMenu, QWidget, QVBoxLayout, QFileSystemModel, QLabel, QHBoxLayout, \
18
18
  QPushButton, QSizePolicy
@@ -23,6 +23,117 @@ from pygpt_net.ui.widget.element.labels import HelpLabel
23
23
  from pygpt_net.utils import trans
24
24
 
25
25
 
26
+ class ExplorerDropHandler(QObject):
27
+ """
28
+ Drag & drop handler for FileExplorer (uploads into target directory).
29
+ - Accepts local file and directory URLs.
30
+ - Determines target directory from drop position:
31
+ * directory item -> that directory
32
+ * file item -> parent directory
33
+ * empty area -> explorer root directory
34
+ - Uses Files controller to perform the actual copy and refresh view.
35
+ """
36
+ def __init__(self, explorer):
37
+ super().__init__(explorer)
38
+ self.explorer = explorer
39
+ self.view = explorer.treeView
40
+
41
+ # Enable drops on both the view and its viewport
42
+ try:
43
+ self.view.setAcceptDrops(True)
44
+ except Exception:
45
+ pass
46
+ vp = self.view.viewport()
47
+ if vp is not None:
48
+ try:
49
+ vp.setAcceptDrops(True)
50
+ except Exception:
51
+ pass
52
+ vp.installEventFilter(self)
53
+ self.view.installEventFilter(self)
54
+
55
+ def _mime_has_local_urls(self, md) -> bool:
56
+ try:
57
+ if md and md.hasUrls():
58
+ for url in md.urls():
59
+ if url.isLocalFile():
60
+ return True
61
+ except Exception:
62
+ pass
63
+ return False
64
+
65
+ def _local_paths_from_mime(self, md) -> list:
66
+ out = []
67
+ try:
68
+ if not (md and md.hasUrls()):
69
+ return out
70
+ for url in md.urls():
71
+ try:
72
+ if url.isLocalFile():
73
+ p = url.toLocalFile()
74
+ if p:
75
+ out.append(p)
76
+ except Exception:
77
+ continue
78
+ except Exception:
79
+ pass
80
+ return out
81
+
82
+ def _target_dir_from_pos(self, event) -> str:
83
+ # QDropEvent in Qt6: use position() -> QPointF
84
+ try:
85
+ pos = event.position().toPoint()
86
+ except Exception:
87
+ pos = event.pos()
88
+ idx = self.view.indexAt(pos)
89
+ if idx.isValid():
90
+ path = self.explorer.model.filePath(idx)
91
+ if os.path.isdir(path):
92
+ return path
93
+ return os.path.dirname(path)
94
+ # Fallback: explorer root directory
95
+ return self.explorer.directory
96
+
97
+ def eventFilter(self, obj, event):
98
+ et = event.type()
99
+
100
+ if et in (QEvent.DragEnter, QEvent.DragMove):
101
+ md = getattr(event, 'mimeData', lambda: None)()
102
+ if self._mime_has_local_urls(md):
103
+ try:
104
+ event.setDropAction(Qt.CopyAction)
105
+ event.acceptProposedAction()
106
+ except Exception:
107
+ event.accept()
108
+ return True
109
+ return False
110
+
111
+ if et == QEvent.Drop:
112
+ md = getattr(event, 'mimeData', lambda: None)()
113
+ if not self._mime_has_local_urls(md):
114
+ return False
115
+
116
+ paths = self._local_paths_from_mime(md)
117
+ target_dir = self._target_dir_from_pos(event)
118
+ try:
119
+ self.explorer.window.controller.files.upload_paths(paths, target_dir)
120
+ except Exception as e:
121
+ try:
122
+ self.explorer.window.core.debug.log(e)
123
+ except Exception:
124
+ pass
125
+
126
+ try:
127
+ event.setDropAction(Qt.CopyAction)
128
+ event.acceptProposedAction()
129
+ except Exception:
130
+ event.accept()
131
+ # Swallow so the view does not try to handle the drop itself
132
+ return True
133
+
134
+ return False
135
+
136
+
26
137
  class FileExplorer(QWidget):
27
138
  def __init__(self, window, directory, index_data):
28
139
  """
@@ -138,6 +249,9 @@ class FileExplorer(QWidget):
138
249
  'db': QIcon(":/icons/db.svg"),
139
250
  }
140
251
 
252
+ # Drag & Drop upload support
253
+ self._dnd_handler = ExplorerDropHandler(self)
254
+
141
255
  def eventFilter(self, source, event):
142
256
  """
143
257
  Focus event filter
@@ -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.15 22:00:00 #
9
+ # Updated Date: 2025.09.28 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
@@ -40,7 +40,7 @@ class ContextList(BaseList):
40
40
  'chat': QIcon(":/icons/chat.svg"),
41
41
  'copy': QIcon(":/icons/copy.svg"),
42
42
  'close': QIcon(":/icons/close.svg"),
43
- 'pin': QIcon(":/icons/pin.svg"),
43
+ 'pin': QIcon(":/icons/pin3.svg"),
44
44
  'clock': QIcon(":/icons/clock.svg"),
45
45
  'db': QIcon(":/icons/db.svg"),
46
46
  'folder': QIcon(":/icons/folder_filled.svg"),
@@ -49,7 +49,8 @@ class ContextList(BaseList):
49
49
  self._color_icon_cache = {}
50
50
 
51
51
  # Use a custom delegate for labels/pinned/attachment indicators and group border indicator
52
- self.setItemDelegate(ImportantItemDelegate(self, self._icons['attachment']))
52
+ # Pass both: attachment icon and pin icon (pin2.svg) for pinned indicator rendering
53
+ self.setItemDelegate(ImportantItemDelegate(self, self._icons['attachment'], self._icons['pin']))
53
54
 
54
55
  # Ensure context menu works as before
55
56
  self.setContextMenuPolicy(Qt.CustomContextMenu)
@@ -467,15 +468,17 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
467
468
  """
468
469
  Item delegate that paints:
469
470
  - Attachment icon on the right side (centered vertically),
470
- - Pinned indicator (small circle) in the top-right corner (overlays if needed),
471
+ - Pinned indicator (pin.svg icon) in the top-right corner (overlays if needed),
471
472
  - Label color as a full-height vertical bar on the left for labeled items,
472
473
  - Group enclosure indicator for expanded groups:
473
474
  - thin vertical bar (default 2 px) on the left side of child rows area,
474
475
  - thin horizontal bar (default 2 px) at the bottom of the last child row.
475
476
  """
476
- def __init__(self, parent=None, attachment_icon: QIcon = None):
477
+ def __init__(self, parent=None, attachment_icon: QIcon = None, pin_icon: QIcon = None):
477
478
  super().__init__(parent)
478
479
  self._attachment_icon = attachment_icon or QIcon(":/icons/attachment.svg")
480
+ # Use provided pin icon (transparent background) as pinned indicator
481
+ self._pin_icon = pin_icon or QIcon(":/icons/pin.svg")
479
482
 
480
483
  # Predefined label colors (status -> QColor)
481
484
  self._status_colors = {
@@ -490,8 +493,8 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
490
493
  }
491
494
 
492
495
  # Visual tuning constants
493
- self._pin_pen = QtGui.QPen(QtCore.Qt.black, 0.5, QtCore.Qt.SolidLine)
494
- self._pin_diameter = 4 # Small pinned circle diameter
496
+ self._pin_pen = QtGui.QPen(QtCore.Qt.black, 0.5, QtCore.Qt.SolidLine) # kept for compatibility
497
+ self._pin_diameter = 4 # legacy circle diameter (not used anymore)
495
498
  self._pin_margin = 3 # Margin from top and right edges
496
499
  self._attach_spacing = 4 # Kept for potential future layout tweaks
497
500
  self._label_bar_width = 4 # Full-height label bar width (left side)
@@ -507,6 +510,10 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
507
510
  self._group_indicator_gap = 6 # gap between child content left and the vertical bar
508
511
  self._group_indicator_bottom_offset = 6
509
512
 
513
+ # Pinned icon sizing (kept deliberately small, similar to previous yellow dot)
514
+ # The actual painted size is min(max_size, availableHeightWithMargins)
515
+ self._pin_icon_max_size = 12 # px
516
+
510
517
  # Try to load customization from application config (safe if missing)
511
518
  self._init_group_indicator_from_config()
512
519
 
@@ -665,20 +672,23 @@ class ImportantItemDelegate(QtWidgets.QStyledItemDelegate):
665
672
  )
666
673
  self._attachment_icon.paint(painter, icon_rect, QtCore.Qt.AlignCenter)
667
674
 
668
- # Pinned indicator (small circle) kept at a fixed top-right position.
669
- # It does not shift left when the attachment is present; it overlays above it.
675
+ # Pinned indicator: small pin.svg painted at fixed top-right position.
676
+ # It overlays above any other right-side icons.
670
677
  if is_important:
671
678
  painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
672
679
  painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
673
- color = self.get_color_for_status(3)
674
680
 
675
- x = option.rect.x() + option.rect.width() - self._pin_margin - self._pin_diameter
676
- y = option.rect.y() + self._pin_margin
677
- pin_rect = QtCore.QRect(x, y, self._pin_diameter, self._pin_diameter)
681
+ # Compute a compact size similar in footprint to previous circle,
682
+ # but readable for vector icon; clamp to available height.
683
+ available = max(8, option.rect.height() - 2 * self._pin_margin)
684
+ pin_size = min(self._pin_icon_max_size, available)
678
685
 
679
- painter.setBrush(color)
680
- painter.setPen(self._pin_pen)
681
- painter.drawEllipse(pin_rect)
686
+ x = option.rect.right() - self._pin_margin - pin_size
687
+ y = option.rect.top() + self._pin_margin
688
+ pin_rect = QtCore.QRect(x, y, pin_size, pin_size)
689
+
690
+ # Paint the pin icon (transparent background)
691
+ self._pin_icon.paint(painter, pin_rect, QtCore.Qt.AlignCenter)
682
692
 
683
693
  # Label bar on the left with 3px vertical margins
684
694
  if label > 0:
@@ -11,26 +11,118 @@
11
11
 
12
12
  from PySide6.QtCore import Qt
13
13
  from PySide6.QtWidgets import QHBoxLayout, QWidget, QComboBox
14
- from PySide6.QtGui import QFontMetrics
14
+ from PySide6.QtGui import QFontMetrics, QStandardItem, QStandardItemModel # keep existing imports, extend with items
15
15
 
16
16
  from pygpt_net.utils import trans
17
17
 
18
18
  class SeparatorComboBox(QComboBox):
19
- """A combo box that supports adding separator items."""
19
+ """A combo box that supports adding separator items and prevents selecting them."""
20
+
21
+ def __init__(self, parent=None):
22
+ super().__init__(parent)
23
+ # Custom role used to mark separator rows without interfering with existing UserRole data
24
+ self._SEP_ROLE = Qt.UserRole + 1000
25
+ # Internal guard to avoid recursive index changes
26
+ self._block_guard = False
20
27
 
21
28
  def addSeparator(self, text):
22
29
  """
23
- Adds a separator item to the combo box.
30
+ Adds a separator item to the combo box that cannot be selected.
31
+ This keeps separators visible but disabled/unselectable.
24
32
 
25
33
  :param text: The text to display for the separator.
26
34
  """
27
- index = self.count()
28
- self.addItem(text)
35
+ model = self.model()
36
+ if isinstance(model, QStandardItemModel):
37
+ item = QStandardItem(text)
38
+ # Disable and make the row unselectable
39
+ item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
40
+ # Mark explicitly as separator using custom role
41
+ item.setData(True, self._SEP_ROLE)
42
+ model.appendRow(item)
43
+ else:
44
+ # Fallback: keep previous behavior and additionally tag item with custom role
45
+ index = self.count()
46
+ self.addItem(text)
47
+ try:
48
+ role = Qt.UserRole - 1
49
+ self.setItemData(index, 0, role) # legacy approach used sometimes to indicate non-enabled
50
+ except Exception:
51
+ pass
52
+ # Tag as separator via custom role for later checks
53
+ self.setItemData(index, True, self._SEP_ROLE)
54
+
55
+ def is_separator(self, index: int) -> bool:
56
+ """Returns True if item at index is a separator."""
57
+ if index < 0 or index >= self.count():
58
+ return False
29
59
  try:
30
- role = Qt.UserRole - 1
31
- self.setItemData(index, 0, role)
32
- except:
60
+ if self.itemData(index, self._SEP_ROLE):
61
+ return True
62
+ except Exception:
33
63
  pass
64
+ # Fallback: check flags (works with item models)
65
+ try:
66
+ idx = self.model().index(index, self.modelColumn(), self.rootModelIndex())
67
+ flags = self.model().flags(idx)
68
+ if not (flags & Qt.ItemIsEnabled) or not (flags & Qt.ItemIsSelectable):
69
+ return True
70
+ except Exception:
71
+ pass
72
+ return False
73
+
74
+ def first_valid_index(self) -> int:
75
+ """Returns the first non-separator index, or -1 if none."""
76
+ for i in range(self.count()):
77
+ if not self.is_separator(i):
78
+ return i
79
+ return -1
80
+
81
+ def _sanitize_index(self, index: int) -> int:
82
+ """Returns a corrected non-separator index, or -1 if none available."""
83
+ if index is None:
84
+ index = -1
85
+ if index < 0 or index >= self.count():
86
+ return self.first_valid_index()
87
+ if self.is_separator(index):
88
+ # Prefer the next valid item; if none, scan backwards; else -1
89
+ for i in range(index + 1, self.count()):
90
+ if not self.is_separator(i):
91
+ return i
92
+ for i in range(index - 1, -1, -1):
93
+ if not self.is_separator(i):
94
+ return i
95
+ return -1
96
+ return index
97
+
98
+ def ensure_valid_current(self) -> int:
99
+ """
100
+ Ensures the current index is not a separator.
101
+ Returns the final valid index (or -1) after correction.
102
+ """
103
+ current = super().currentIndex()
104
+ corrected = self._sanitize_index(current)
105
+ if corrected != current:
106
+ try:
107
+ self._block_guard = True
108
+ super().setCurrentIndex(corrected if corrected != -1 else -1)
109
+ finally:
110
+ self._block_guard = False
111
+ return corrected
112
+
113
+ def setCurrentIndex(self, index: int) -> None:
114
+ """
115
+ Prevent setting the current index to a separator from any caller.
116
+ """
117
+ if self._block_guard:
118
+ # When guarded, pass through without checks to avoid recursion
119
+ return super().setCurrentIndex(index)
120
+ corrected = self._sanitize_index(index)
121
+ try:
122
+ self._block_guard = True
123
+ super().setCurrentIndex(corrected if corrected != -1 else -1)
124
+ finally:
125
+ self._block_guard = False
34
126
 
35
127
 
36
128
  class NoScrollCombo(SeparatorComboBox):
@@ -116,7 +208,11 @@ class OptionCombo(QWidget):
116
208
  else:
117
209
  self.combo.addItem(value, key)
118
210
  else:
119
- self.combo.addItem(item, item)
211
+ # Support simple string keys including "separator::" entries
212
+ if isinstance(item, str) and item.startswith("separator::"):
213
+ self.combo.addSeparator(item.split("separator::", 1)[1])
214
+ else:
215
+ self.combo.addItem(item, item)
120
216
  elif type(self.keys) is dict:
121
217
  for key, value in self.keys.items():
122
218
  if not isinstance(key, str):
@@ -126,6 +222,32 @@ class OptionCombo(QWidget):
126
222
  else:
127
223
  self.combo.addItem(value, key)
128
224
 
225
+ # Ensure a valid non-separator selection after population
226
+ self._apply_initial_selection()
227
+
228
+ def _apply_initial_selection(self):
229
+ """
230
+ Ensures that after building the list the combobox does not end up on a separator.
231
+ Prefers self.current_id if present; otherwise selects the first valid non-separator.
232
+ Signals are suppressed during this operation.
233
+ """
234
+ # lock on_change during initial selection
235
+ prev_locked = self.locked
236
+ self.locked = True
237
+ try:
238
+ index = -1
239
+ if self.current_id is not None and self.current_id != "":
240
+ index = self.combo.findData(self.current_id)
241
+ if index == -1:
242
+ index = self.combo.first_valid_index()
243
+ if index != -1:
244
+ self.combo.setCurrentIndex(index)
245
+ else:
246
+ # No valid items, clear selection
247
+ self.combo.setCurrentIndex(-1)
248
+ finally:
249
+ self.locked = prev_locked
250
+
129
251
  def set_value(self, value):
130
252
  """
131
253
  Set value
@@ -137,6 +259,9 @@ class OptionCombo(QWidget):
137
259
  index = self.combo.findData(value)
138
260
  if index != -1:
139
261
  self.combo.setCurrentIndex(index)
262
+ else:
263
+ # If requested value is not present, keep current selection but make sure it is valid.
264
+ self.combo.ensure_valid_current()
140
265
 
141
266
  def get_value(self):
142
267
  """
@@ -159,6 +284,8 @@ class OptionCombo(QWidget):
159
284
  self.option["keys"] = keys
160
285
  self.combo.clear()
161
286
  self.update()
287
+ # After rebuilding, guarantee a non-separator selection
288
+ self.combo.ensure_valid_current()
162
289
  if lock:
163
290
  self.locked = False
164
291
 
@@ -171,10 +298,21 @@ class OptionCombo(QWidget):
171
298
  """
172
299
  if self.locked:
173
300
  return
301
+
302
+ # If somehow a separator got focus, correct it immediately and do not propagate invalid IDs
303
+ if self.combo.is_separator(index):
304
+ self.locked = True
305
+ corrected = self.combo.ensure_valid_current()
306
+ self.locked = False
307
+ if corrected == -1:
308
+ # Nothing valid to select
309
+ self.current_id = None
310
+ return
311
+ index = corrected
312
+
174
313
  self.current_id = self.combo.itemData(index)
175
314
  self.window.controller.config.combo.on_update(self.parent_id, self.id, self.option, self.current_id)
176
315
 
177
316
  def fit_to_content(self):
178
317
  """Fit to content"""
179
- self.combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
180
-
318
+ self.combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
@@ -6,11 +6,12 @@
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.26 12:00:00 #
9
+ # Updated Date: 2025.09.28 08:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from typing import Optional
13
13
  import math
14
+ import os
14
15
 
15
16
  from PySide6.QtCore import Qt, QSize, QTimer, QEvent
16
17
  from PySide6.QtGui import QAction, QIcon, QImage
@@ -24,6 +25,8 @@ from PySide6.QtWidgets import (
24
25
 
25
26
  from pygpt_net.core.events import Event
26
27
  from pygpt_net.utils import trans
28
+ from pygpt_net.core.attachments.clipboard import AttachmentDropHandler
29
+
27
30
 
28
31
  class ChatInput(QTextEdit):
29
32
 
@@ -127,6 +130,12 @@ class ChatInput(QTextEdit):
127
130
  # Paste/input safety limits
128
131
  self._paste_max_chars = 1000000000 # hard cap to prevent pathological pastes from freezing/crashing
129
132
 
133
+ # One-shot guard to avoid duplicate attachment processing on drops that also insert text.
134
+ self._skip_clipboard_on_next_insert = False
135
+
136
+ # Drag & Drop: add as attachments; do not insert file paths into text
137
+ self._dnd_handler = AttachmentDropHandler(self.window, self, policy=AttachmentDropHandler.INPUT_MIX)
138
+
130
139
  def _on_text_changed_tokens(self):
131
140
  """Schedule token count update with debounce."""
132
141
  self._tokens_timer.start()
@@ -157,27 +166,46 @@ class ChatInput(QTextEdit):
157
166
  except Exception:
158
167
  return False
159
168
 
169
+ def _mime_has_local_file_urls(self, source) -> bool:
170
+ """
171
+ Detects whether mime data contains any local file/directory URLs.
172
+ """
173
+ try:
174
+ if source and source.hasUrls():
175
+ for url in source.urls():
176
+ if url.isLocalFile():
177
+ return True
178
+ except Exception:
179
+ pass
180
+ return False
181
+
160
182
  def insertFromMimeData(self, source):
161
183
  """
162
184
  Insert from mime data
163
185
 
164
186
  :param source: source
165
187
  """
166
- # Always process attachments first; never break input pipeline on errors.
167
- try:
168
- self.handle_clipboard(source)
169
- except Exception as e:
188
+ has_local_files = self._mime_has_local_file_urls(source)
189
+
190
+ # Avoid double-processing when drop is allowed to fall through to default insertion.
191
+ should_skip = bool(getattr(self, "_skip_clipboard_on_next_insert", False))
192
+ if should_skip:
193
+ self._skip_clipboard_on_next_insert = False
194
+ else:
195
+ # Always process attachments first; never break input pipeline on errors.
170
196
  try:
171
- self.window.core.debug.log(e)
172
- except Exception:
173
- pass
197
+ self.handle_clipboard(source)
198
+ except Exception as e:
199
+ try:
200
+ self.window.core.debug.log(e)
201
+ except Exception:
202
+ pass
174
203
 
175
- # If an image is present, we treat it as attachment-only and do not insert textual representation.
204
+ # Do not insert textual representation for images nor local file URLs (including directories).
176
205
  try:
177
- if source and source.hasImage():
206
+ if source and (source.hasImage() or has_local_files):
178
207
  return
179
208
  except Exception:
180
- # fallback to text extraction below
181
209
  pass
182
210
 
183
211
  # Insert only sanitized plain text (no HTML, no custom formats).
@@ -194,24 +222,27 @@ class ChatInput(QTextEdit):
194
222
  def _safe_text_from_mime(self, source) -> str:
195
223
  """
196
224
  Extracts plain text from QMimeData safely, normalizes and sanitizes it.
197
- Falls back to URLs joined by space if textual content is not provided.
225
+ Falls back to URLs joined by space only for non-local URLs.
198
226
  """
199
227
  try:
200
228
  if source is None:
201
229
  return ""
230
+ # Prefer real text if present
202
231
  if source.hasText():
203
232
  return self._sanitize_text(source.text())
233
+ # Fallback: for non-local URLs we allow insertion as text (e.g., http/https)
204
234
  if source.hasUrls():
205
235
  parts = []
206
236
  for url in source.urls():
207
237
  try:
208
238
  if url.isLocalFile():
209
- parts.append(url.toLocalFile())
210
- else:
211
- parts.append(url.toString())
239
+ # Skip local files/dirs textual fallback; they are handled as attachments
240
+ continue
241
+ parts.append(url.toString())
212
242
  except Exception:
213
243
  continue
214
- return self._sanitize_text(" ".join([p for p in parts if p]))
244
+ if parts:
245
+ return self._sanitize_text(" ".join([p for p in parts if p]))
215
246
  except Exception as e:
216
247
  try:
217
248
  self.window.core.debug.log(e)
@@ -286,13 +317,36 @@ class ChatInput(QTextEdit):
286
317
  image = source.imageData()
287
318
  if isinstance(image, QImage):
288
319
  self.window.controller.attachment.from_clipboard_image(image)
320
+ else:
321
+ # Some platforms provide QPixmap; convert to QImage if possible
322
+ try:
323
+ img = image.toImage()
324
+ if isinstance(img, QImage):
325
+ self.window.controller.attachment.from_clipboard_image(img)
326
+ except Exception:
327
+ pass
289
328
  elif source.hasUrls():
290
329
  urls = source.urls()
291
330
  for url in urls:
292
331
  try:
293
332
  if url.isLocalFile():
294
333
  local_path = url.toLocalFile()
295
- self.window.controller.attachment.from_clipboard_url(local_path)
334
+ if not local_path:
335
+ continue
336
+ if os.path.isdir(local_path):
337
+ # Recursively add all files from the dropped directory
338
+ for root, _, files in os.walk(local_path):
339
+ for name in files:
340
+ fpath = os.path.join(root, name)
341
+ try:
342
+ self.window.controller.attachment.from_clipboard_url(fpath, all=True)
343
+ except Exception:
344
+ continue
345
+ else:
346
+ self.window.controller.attachment.from_clipboard_url(local_path, all=True)
347
+ else:
348
+ # Non-local URLs are handled as text (if any) by _safe_text_from_mime
349
+ pass
296
350
  except Exception:
297
351
  # Ignore broken URL entries
298
352
  continue
@@ -531,7 +531,7 @@ class CustomWebEnginePage(QWebEnginePage):
531
531
  return super().acceptNavigationRequest(url, _type, isMainFrame)
532
532
 
533
533
  def javaScriptConsoleMessage(self, level, message, line_number, source_id):
534
- # print("[JS CONSOLE] Line", line_number, ":", message)
534
+ print("[JS CONSOLE] Line", line_number, ":", message)
535
535
  self.signals.js_message.emit(line_number, message, source_id) # handled in debug controller
536
536
 
537
537
  def cleanup(self):