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.
- pygpt_net/CHANGELOG.txt +16 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +3 -1
- pygpt_net/controller/attachment/attachment.py +17 -8
- pygpt_net/controller/camera/camera.py +4 -4
- pygpt_net/controller/files/files.py +71 -2
- pygpt_net/controller/lang/custom.py +2 -2
- pygpt_net/controller/presets/editor.py +137 -22
- pygpt_net/controller/ui/mode.py +18 -3
- pygpt_net/core/agents/custom/__init__.py +18 -2
- pygpt_net/core/agents/custom/runner.py +2 -2
- pygpt_net/core/attachments/clipboard.py +146 -0
- pygpt_net/core/render/web/renderer.py +44 -11
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/presets/agent_openai_coder.json +15 -1
- pygpt_net/data/css/style.dark.css +12 -0
- pygpt_net/data/css/style.light.css +12 -0
- pygpt_net/data/icons/pin2.svg +1 -0
- pygpt_net/data/icons/pin3.svg +3 -0
- pygpt_net/data/icons/point.svg +1 -0
- pygpt_net/data/icons/target.svg +1 -0
- pygpt_net/data/js/app/runtime.js +11 -4
- pygpt_net/data/js/app/scroll.js +14 -0
- pygpt_net/data/js/app/ui.js +19 -2
- pygpt_net/data/js/app/user.js +22 -54
- pygpt_net/data/js/app.min.js +13 -14
- pygpt_net/data/locale/locale.de.ini +32 -0
- pygpt_net/data/locale/locale.en.ini +38 -2
- pygpt_net/data/locale/locale.es.ini +32 -0
- pygpt_net/data/locale/locale.fr.ini +32 -0
- pygpt_net/data/locale/locale.it.ini +32 -0
- pygpt_net/data/locale/locale.pl.ini +34 -2
- pygpt_net/data/locale/locale.uk.ini +32 -0
- pygpt_net/data/locale/locale.zh.ini +32 -0
- pygpt_net/icons.qrc +4 -0
- pygpt_net/icons_rc.py +274 -137
- pygpt_net/js_rc.py +8262 -8230
- pygpt_net/provider/agents/llama_index/planner_workflow.py +15 -3
- pygpt_net/provider/agents/llama_index/workflow/planner.py +69 -41
- pygpt_net/provider/agents/openai/agent_planner.py +57 -35
- pygpt_net/provider/agents/openai/evolve.py +0 -3
- pygpt_net/provider/api/google/__init__.py +9 -3
- pygpt_net/provider/api/google/image.py +11 -1
- pygpt_net/provider/api/google/music.py +375 -0
- pygpt_net/provider/core/config/patch.py +8 -0
- pygpt_net/ui/__init__.py +6 -1
- pygpt_net/ui/dialog/preset.py +9 -4
- pygpt_net/ui/layout/chat/attachments.py +18 -1
- pygpt_net/ui/layout/status.py +3 -3
- pygpt_net/ui/widget/element/status.py +55 -0
- pygpt_net/ui/widget/filesystem/explorer.py +116 -2
- pygpt_net/ui/widget/lists/context.py +26 -16
- pygpt_net/ui/widget/option/combo.py +149 -11
- pygpt_net/ui/widget/textarea/input.py +71 -17
- pygpt_net/ui/widget/textarea/web.py +1 -1
- pygpt_net/ui/widget/vision/camera.py +135 -12
- {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/METADATA +18 -2
- {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/RECORD +62 -55
- {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/WHEEL +0 -0
- {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.
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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 (
|
|
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 #
|
|
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
|
|
669
|
-
# 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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
172
|
-
except Exception:
|
|
173
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|