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.
- pygpt_net/CHANGELOG.txt +14 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/common.py +115 -6
- pygpt_net/controller/chat/input.py +4 -1
- pygpt_net/controller/chat/response.py +8 -2
- pygpt_net/controller/presets/presets.py +121 -6
- pygpt_net/controller/settings/editor.py +0 -15
- pygpt_net/controller/settings/profile.py +16 -4
- pygpt_net/controller/settings/workdir.py +30 -5
- pygpt_net/controller/theme/common.py +4 -2
- pygpt_net/controller/theme/markdown.py +4 -7
- pygpt_net/controller/theme/theme.py +2 -1
- pygpt_net/controller/ui/ui.py +32 -7
- pygpt_net/core/agents/custom/__init__.py +7 -1
- pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
- pygpt_net/core/agents/custom/llama_index/runner.py +52 -4
- pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
- pygpt_net/core/agents/custom/router.py +45 -6
- pygpt_net/core/agents/custom/runner.py +11 -5
- pygpt_net/core/agents/custom/schema.py +3 -1
- pygpt_net/core/agents/custom/utils.py +13 -1
- pygpt_net/core/agents/runners/llama_workflow.py +65 -5
- pygpt_net/core/agents/runners/openai_workflow.py +2 -1
- pygpt_net/core/db/viewer.py +11 -5
- pygpt_net/core/node_editor/graph.py +18 -9
- pygpt_net/core/node_editor/models.py +9 -2
- pygpt_net/core/node_editor/types.py +15 -1
- pygpt_net/core/presets/presets.py +216 -29
- pygpt_net/core/render/markdown/parser.py +0 -2
- pygpt_net/core/render/web/renderer.py +76 -11
- pygpt_net/data/config/config.json +5 -6
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +2 -38
- pygpt_net/data/css/style.dark.css +18 -0
- pygpt_net/data/css/style.light.css +20 -1
- pygpt_net/data/locale/locale.de.ini +66 -1
- pygpt_net/data/locale/locale.en.ini +64 -3
- pygpt_net/data/locale/locale.es.ini +66 -1
- pygpt_net/data/locale/locale.fr.ini +66 -1
- pygpt_net/data/locale/locale.it.ini +66 -1
- pygpt_net/data/locale/locale.pl.ini +67 -2
- pygpt_net/data/locale/locale.uk.ini +66 -1
- pygpt_net/data/locale/locale.zh.ini +66 -1
- pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
- pygpt_net/item/ctx.py +23 -1
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
- pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
- pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
- pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
- pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
- pygpt_net/provider/agents/openai/agent.py +3 -1
- pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
- pygpt_net/provider/agents/openai/agent_planner.py +6 -2
- pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
- pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
- pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
- pygpt_net/provider/agents/openai/evolve.py +6 -2
- pygpt_net/provider/agents/openai/supervisor.py +3 -1
- pygpt_net/provider/api/openai/agents/response.py +1 -0
- pygpt_net/provider/core/config/patch.py +18 -1
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
- pygpt_net/tools/agent_builder/tool.py +48 -26
- pygpt_net/tools/agent_builder/ui/dialogs.py +36 -28
- pygpt_net/ui/__init__.py +2 -4
- pygpt_net/ui/dialog/about.py +58 -38
- pygpt_net/ui/dialog/db.py +142 -3
- pygpt_net/ui/dialog/preset.py +47 -8
- pygpt_net/ui/layout/toolbox/presets.py +64 -16
- pygpt_net/ui/main.py +2 -2
- pygpt_net/ui/widget/dialog/confirm.py +27 -3
- pygpt_net/ui/widget/dialog/db.py +0 -0
- pygpt_net/ui/widget/draw/painter.py +90 -1
- pygpt_net/ui/widget/lists/preset.py +908 -60
- pygpt_net/ui/widget/node_editor/command.py +10 -10
- pygpt_net/ui/widget/node_editor/config.py +157 -0
- pygpt_net/ui/widget/node_editor/editor.py +223 -153
- pygpt_net/ui/widget/node_editor/item.py +12 -11
- pygpt_net/ui/widget/node_editor/node.py +246 -13
- pygpt_net/ui/widget/node_editor/view.py +179 -63
- pygpt_net/ui/widget/tabs/output.py +1 -1
- pygpt_net/ui/widget/textarea/input.py +157 -23
- pygpt_net/utils.py +114 -2
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +26 -100
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +86 -85
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/entry_points.txt +0 -0
|
@@ -6,15 +6,17 @@
|
|
|
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.26 03:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
from typing import Optional, Tuple
|
|
14
14
|
|
|
15
|
-
from PySide6.QtCore import Qt, QPointF, QRectF, QObject, Signal
|
|
16
|
-
from PySide6.QtGui import QColor, QPainter, QPen, QTransform
|
|
17
|
-
from PySide6.QtWidgets import QWidget, QGraphicsView, QGraphicsScene
|
|
15
|
+
from PySide6.QtCore import Qt, QPointF, QRectF, QObject, Signal, QSize, QEvent
|
|
16
|
+
from PySide6.QtGui import QColor, QPainter, QPen, QTransform, QIcon
|
|
17
|
+
from PySide6.QtWidgets import QWidget, QGraphicsView, QGraphicsScene, QPushButton, QHBoxLayout, QLabel
|
|
18
|
+
|
|
19
|
+
from .config import EditorConfig
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
# ------------------------ Graphics View / Scene ------------------------
|
|
@@ -26,14 +28,7 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
26
28
|
- Ctrl + Mouse Wheel zooming
|
|
27
29
|
- Middle Mouse Button panning
|
|
28
30
|
- Rubber band selection
|
|
29
|
-
|
|
30
|
-
Notes:
|
|
31
|
-
- Space-panning is intentionally disabled to not conflict with typing in editors.
|
|
32
|
-
- All keyboard shortcuts (e.g., Delete) are handled at the NodeEditor level.
|
|
33
|
-
|
|
34
|
-
Args:
|
|
35
|
-
scene: Shared QGraphicsScene instance for the editor.
|
|
36
|
-
parent: Optional parent widget.
|
|
31
|
+
- Optional left-button panning only when global grab mode is enabled
|
|
37
32
|
"""
|
|
38
33
|
def __init__(self, scene: QGraphicsScene, parent: Optional[QWidget] = None):
|
|
39
34
|
super().__init__(scene, parent)
|
|
@@ -53,17 +48,10 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
53
48
|
|
|
54
49
|
self._panning = False
|
|
55
50
|
self._last_pan_pos = None
|
|
51
|
+
self._global_grab_mode = False # when True, left button pans regardless of items
|
|
56
52
|
|
|
57
53
|
def drawBackground(self, painter: QPainter, rect: QRectF):
|
|
58
|
-
"""Draw the checker grid in the background.
|
|
59
|
-
|
|
60
|
-
The grid spacing is fixed (20 px). Colors are read from the owning NodeEditor
|
|
61
|
-
instance if available, which allows dynamic theming.
|
|
62
|
-
|
|
63
|
-
Args:
|
|
64
|
-
painter: Active QPainter provided by Qt.
|
|
65
|
-
rect: The exposed background rect to be filled.
|
|
66
|
-
"""
|
|
54
|
+
"""Draw the checker grid in the background."""
|
|
67
55
|
parent_editor = self.parent() # NodeEditor
|
|
68
56
|
color_back = getattr(parent_editor, "_grid_back_color", QColor(35, 35, 38))
|
|
69
57
|
color_pen = getattr(parent_editor, "_grid_pen_color", QColor(55, 55, 60))
|
|
@@ -83,15 +71,23 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
83
71
|
painter.drawLine(rect.left(), y, rect.right(), y)
|
|
84
72
|
y += grid
|
|
85
73
|
|
|
86
|
-
def
|
|
87
|
-
"""
|
|
74
|
+
def enterEvent(self, e: QEvent):
|
|
75
|
+
"""Ensure cursor reflects grab mode when entering the view."""
|
|
76
|
+
if self._global_grab_mode and not self._panning:
|
|
77
|
+
self.viewport().setCursor(Qt.OpenHandCursor)
|
|
78
|
+
else:
|
|
79
|
+
self.viewport().setCursor(Qt.ArrowCursor)
|
|
80
|
+
super().enterEvent(e)
|
|
81
|
+
|
|
82
|
+
def leaveEvent(self, e: QEvent):
|
|
83
|
+
"""Restore cursor on leave."""
|
|
84
|
+
self.viewport().setCursor(Qt.ArrowCursor)
|
|
85
|
+
super().leaveEvent(e)
|
|
88
86
|
|
|
89
|
-
|
|
90
|
-
"""
|
|
87
|
+
def keyPressEvent(self, e):
|
|
91
88
|
super().keyPressEvent(e)
|
|
92
89
|
|
|
93
90
|
def keyReleaseEvent(self, e):
|
|
94
|
-
"""Pass-through for key release."""
|
|
95
91
|
super().keyReleaseEvent(e)
|
|
96
92
|
|
|
97
93
|
def wheelEvent(self, e):
|
|
@@ -102,14 +98,47 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
102
98
|
return
|
|
103
99
|
super().wheelEvent(e)
|
|
104
100
|
|
|
101
|
+
def _begin_pan(self, e):
|
|
102
|
+
"""Start panning from current mouse event position."""
|
|
103
|
+
self._panning = True
|
|
104
|
+
self._last_pan_pos = e.position()
|
|
105
|
+
# Use 'grab' during drag
|
|
106
|
+
self.viewport().setCursor(Qt.ClosedHandCursor)
|
|
107
|
+
e.accept()
|
|
108
|
+
|
|
109
|
+
def _end_pan(self, e):
|
|
110
|
+
"""Stop panning and restore appropriate cursor."""
|
|
111
|
+
self._panning = False
|
|
112
|
+
if self._global_grab_mode:
|
|
113
|
+
self.viewport().setCursor(Qt.OpenHandCursor)
|
|
114
|
+
else:
|
|
115
|
+
self.viewport().setCursor(Qt.ArrowCursor)
|
|
116
|
+
e.accept()
|
|
117
|
+
|
|
118
|
+
def _clicked_on_empty(self, e) -> bool:
|
|
119
|
+
"""Return True if the click is on empty scene space (no items)."""
|
|
120
|
+
try:
|
|
121
|
+
item = self.itemAt(int(e.position().x()), int(e.position().y()))
|
|
122
|
+
return item is None
|
|
123
|
+
except Exception:
|
|
124
|
+
return False
|
|
125
|
+
|
|
105
126
|
def mousePressEvent(self, e):
|
|
106
|
-
"""
|
|
127
|
+
"""Panning: MMB always; LMB only in global grab mode. Also clear selection on empty click."""
|
|
107
128
|
if e.button() == Qt.MiddleButton:
|
|
108
|
-
self.
|
|
109
|
-
self._last_pan_pos = e.position()
|
|
110
|
-
self.setCursor(Qt.ClosedHandCursor)
|
|
111
|
-
e.accept()
|
|
129
|
+
self._begin_pan(e)
|
|
112
130
|
return
|
|
131
|
+
|
|
132
|
+
if e.button() == Qt.LeftButton:
|
|
133
|
+
if self._global_grab_mode:
|
|
134
|
+
# Global grab enabled -> pan on LMB anywhere
|
|
135
|
+
self._begin_pan(e)
|
|
136
|
+
return
|
|
137
|
+
else:
|
|
138
|
+
# No global grab: clicking empty clears selection
|
|
139
|
+
if self._clicked_on_empty(e) and self.scene():
|
|
140
|
+
self.scene().clearSelection()
|
|
141
|
+
|
|
113
142
|
super().mousePressEvent(e)
|
|
114
143
|
|
|
115
144
|
def mouseMoveEvent(self, e):
|
|
@@ -124,11 +153,9 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
124
153
|
super().mouseMoveEvent(e)
|
|
125
154
|
|
|
126
155
|
def mouseReleaseEvent(self, e):
|
|
127
|
-
"""Stop panning on Middle Mouse Button release; otherwise defer."""
|
|
128
|
-
if self._panning and e.button()
|
|
129
|
-
self.
|
|
130
|
-
self.setCursor(Qt.ArrowCursor)
|
|
131
|
-
e.accept()
|
|
156
|
+
"""Stop panning on Middle or Left Mouse Button release; otherwise defer."""
|
|
157
|
+
if self._panning and e.button() in (Qt.MiddleButton, Qt.LeftButton):
|
|
158
|
+
self._end_pan(e)
|
|
132
159
|
return
|
|
133
160
|
super().mouseReleaseEvent(e)
|
|
134
161
|
|
|
@@ -141,15 +168,7 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
141
168
|
self._apply_zoom(1.0 / self._zoom_step)
|
|
142
169
|
|
|
143
170
|
def _apply_zoom(self, factor: float):
|
|
144
|
-
"""Apply zoom scaling factor within configured bounds.
|
|
145
|
-
|
|
146
|
-
Args:
|
|
147
|
-
factor: Multiplicative factor to apply to the current zoom.
|
|
148
|
-
|
|
149
|
-
Notes:
|
|
150
|
-
The method clamps the result to [_min_zoom, _max_zoom] to prevent
|
|
151
|
-
excessive zooming.
|
|
152
|
-
"""
|
|
171
|
+
"""Apply zoom scaling factor within configured bounds."""
|
|
153
172
|
new_zoom = self._zoom * factor
|
|
154
173
|
if not (self._min_zoom <= new_zoom <= self._max_zoom):
|
|
155
174
|
return
|
|
@@ -169,7 +188,6 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
169
188
|
if keep_center and self.viewport() is not None and self.viewport().rect().isValid():
|
|
170
189
|
center_scene = self.mapToScene(self.viewport().rect().center())
|
|
171
190
|
|
|
172
|
-
# Reset and apply new transform to avoid cumulative floating errors
|
|
173
191
|
self.resetTransform()
|
|
174
192
|
self._zoom = 1.0
|
|
175
193
|
if abs(z - 1.0) > 1e-9:
|
|
@@ -180,25 +198,21 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
180
198
|
self.centerOn(center_scene)
|
|
181
199
|
|
|
182
200
|
def get_scroll_values(self) -> Tuple[int, int]:
|
|
183
|
-
"""Return (horizontal, vertical) scrollbar values."""
|
|
184
201
|
h = self.horizontalScrollBar().value() if self.horizontalScrollBar() else 0
|
|
185
202
|
v = self.verticalScrollBar().value() if self.verticalScrollBar() else 0
|
|
186
203
|
return int(h), int(v)
|
|
187
204
|
|
|
188
205
|
def set_scroll_values(self, h: int, v: int):
|
|
189
|
-
"""Set horizontal and vertical scrollbar values."""
|
|
190
206
|
if self.horizontalScrollBar():
|
|
191
207
|
self.horizontalScrollBar().setValue(int(h))
|
|
192
208
|
if self.verticalScrollBar():
|
|
193
209
|
self.verticalScrollBar().setValue(int(v))
|
|
194
210
|
|
|
195
211
|
def view_state(self) -> dict:
|
|
196
|
-
"""Return a serializable view state: zoom and scrollbars."""
|
|
197
212
|
h, v = self.get_scroll_values()
|
|
198
213
|
return {"zoom": float(self._zoom), "h": h, "v": v}
|
|
199
214
|
|
|
200
215
|
def set_view_state(self, state: dict):
|
|
201
|
-
"""Apply a view state previously produced by view_state()."""
|
|
202
216
|
if not isinstance(state, dict):
|
|
203
217
|
return
|
|
204
218
|
z = state.get("zoom") or state.get("scale")
|
|
@@ -213,35 +227,137 @@ class NodeGraphicsView(QGraphicsView):
|
|
|
213
227
|
if h is not None:
|
|
214
228
|
self.set_scroll_values(int(h), int(v if v is not None else 0))
|
|
215
229
|
elif v is not None:
|
|
216
|
-
# set vertical if only v present
|
|
217
230
|
self.set_scroll_values(self.get_scroll_values()[0], int(v))
|
|
218
231
|
except Exception:
|
|
219
232
|
pass
|
|
220
233
|
|
|
234
|
+
def set_global_grab_mode(self, enabled: bool):
|
|
235
|
+
"""Enable/disable global grab mode (left click pans anywhere)."""
|
|
236
|
+
self._global_grab_mode = bool(enabled)
|
|
237
|
+
if self._global_grab_mode:
|
|
238
|
+
self.viewport().setCursor(Qt.OpenHandCursor)
|
|
239
|
+
else:
|
|
240
|
+
if not self._panning:
|
|
241
|
+
self.viewport().setCursor(Qt.ArrowCursor)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class NodeViewOverlayControls(QWidget):
|
|
245
|
+
"""Small overlay with three buttons (Grab toggle, Zoom Out, Zoom In) anchored top-right."""
|
|
246
|
+
|
|
247
|
+
grabToggled = Signal(bool)
|
|
248
|
+
zoomInClicked = Signal()
|
|
249
|
+
zoomOutClicked = Signal()
|
|
250
|
+
|
|
251
|
+
def __init__(self, parent: Optional[QWidget] = None):
|
|
252
|
+
super().__init__(parent)
|
|
253
|
+
self.setObjectName("NodeViewOverlayControls")
|
|
254
|
+
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
255
|
+
|
|
256
|
+
layout = QHBoxLayout(self)
|
|
257
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
258
|
+
layout.setSpacing(8)
|
|
259
|
+
|
|
260
|
+
cfg = self._cfg()
|
|
261
|
+
|
|
262
|
+
# Grab (toggle)
|
|
263
|
+
self.btnGrab = QPushButton(self)
|
|
264
|
+
self.btnGrab.setCheckable(True)
|
|
265
|
+
self.btnGrab.setToolTip(cfg.overlay_grab_tooltip())
|
|
266
|
+
self.btnGrab.setIcon(QIcon(":/icons/drag.svg"))
|
|
267
|
+
self.btnGrab.setIconSize(QSize(20, 20))
|
|
268
|
+
self.btnGrab.setMinimumSize(25, 25)
|
|
269
|
+
|
|
270
|
+
# Zoom Out
|
|
271
|
+
self.btnZoomOut = QPushButton(self)
|
|
272
|
+
self.btnZoomOut.setToolTip(cfg.overlay_zoom_out_tooltip())
|
|
273
|
+
self.btnZoomOut.setIcon(QIcon(":/icons/zoom_out.svg"))
|
|
274
|
+
self.btnZoomOut.setIconSize(QSize(20, 20))
|
|
275
|
+
self.btnZoomOut.setMinimumSize(25, 25)
|
|
276
|
+
|
|
277
|
+
# Zoom In
|
|
278
|
+
self.btnZoomIn = QPushButton(self)
|
|
279
|
+
self.btnZoomIn.setToolTip(cfg.overlay_zoom_in_tooltip())
|
|
280
|
+
self.btnZoomIn.setIcon(QIcon(":/icons/zoom_in.svg"))
|
|
281
|
+
self.btnZoomIn.setIconSize(QSize(20, 20))
|
|
282
|
+
self.btnZoomIn.setMinimumSize(25, 25)
|
|
283
|
+
|
|
284
|
+
layout.addWidget(self.btnGrab)
|
|
285
|
+
layout.addWidget(self.btnZoomIn)
|
|
286
|
+
layout.addWidget(self.btnZoomOut)
|
|
287
|
+
|
|
288
|
+
self.btnGrab.toggled.connect(self.grabToggled.emit)
|
|
289
|
+
self.btnZoomIn.clicked.connect(self.zoomInClicked.emit)
|
|
290
|
+
self.btnZoomOut.clicked.connect(self.zoomOutClicked.emit)
|
|
291
|
+
|
|
292
|
+
self.show()
|
|
293
|
+
|
|
294
|
+
def _cfg(self) -> EditorConfig:
|
|
295
|
+
p = self.parent()
|
|
296
|
+
c = getattr(p, "config", None) if p is not None else None
|
|
297
|
+
return c if isinstance(c, EditorConfig) else EditorConfig()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class NodeViewStatusLabel(QWidget):
|
|
301
|
+
"""Fixed status overlay pinned to bottom-left that shows node type counts."""
|
|
302
|
+
|
|
303
|
+
def __init__(self, parent: Optional[QWidget] = None):
|
|
304
|
+
super().__init__(parent)
|
|
305
|
+
self.setObjectName("NodeViewStatusLabel")
|
|
306
|
+
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
307
|
+
|
|
308
|
+
self._lbl = QLabel(self)
|
|
309
|
+
cfg = self._cfg()
|
|
310
|
+
self._lbl.setText(cfg.status_no_nodes())
|
|
311
|
+
|
|
312
|
+
layout = QHBoxLayout(self)
|
|
313
|
+
layout.setContentsMargins(8, 4, 8, 4) # some padding around the text
|
|
314
|
+
layout.setSpacing(0)
|
|
315
|
+
layout.addWidget(self._lbl)
|
|
316
|
+
|
|
317
|
+
self.adjustSize()
|
|
318
|
+
self.show()
|
|
319
|
+
|
|
320
|
+
def _cfg(self) -> EditorConfig:
|
|
321
|
+
p = self.parent()
|
|
322
|
+
c = getattr(p, "config", None) if p is not None else None
|
|
323
|
+
return c if isinstance(c, EditorConfig) else EditorConfig()
|
|
324
|
+
|
|
325
|
+
def set_text(self, text: str):
|
|
326
|
+
self._lbl.setText(text)
|
|
327
|
+
# Safe: adjustSize() uses sizeHint(), which no longer calls adjustSize()
|
|
328
|
+
self.adjustSize()
|
|
329
|
+
|
|
330
|
+
def sizeHint(self):
|
|
331
|
+
"""Return hint based on layout/label without calling adjustSize()."""
|
|
332
|
+
if self.layout() is not None:
|
|
333
|
+
return self.layout().sizeHint()
|
|
334
|
+
return self._lbl.sizeHint()
|
|
335
|
+
|
|
221
336
|
|
|
222
337
|
class NodeGraphicsScene(QGraphicsScene):
|
|
223
338
|
"""Graphics scene extended with custom context menu emission."""
|
|
224
339
|
sceneContextRequested = Signal(QPointF)
|
|
225
340
|
|
|
226
341
|
def __init__(self, parent: Optional[QObject] = None):
|
|
227
|
-
"""Initialize the scene and set a very large scene rect.
|
|
228
|
-
|
|
229
|
-
Using a large default rect avoids sudden scene rect changes while panning/zooming.
|
|
230
|
-
"""
|
|
342
|
+
"""Initialize the scene and set a very large scene rect."""
|
|
231
343
|
super().__init__(parent)
|
|
232
344
|
self.setSceneRect(-5000, -5000, 10000, 10000)
|
|
233
345
|
|
|
234
346
|
def contextMenuEvent(self, event):
|
|
235
|
-
"""Emit a scene-level context menu request when clicking empty space.
|
|
236
|
-
|
|
237
|
-
If the click is not on any item, the signal sceneContextRequested is emitted with
|
|
238
|
-
the scene position. Otherwise, default handling is used (propagating to items).
|
|
239
|
-
"""
|
|
347
|
+
"""Emit a scene-level context menu request when clicking empty space."""
|
|
240
348
|
transform = self.views()[0].transform() if self.views() else QTransform()
|
|
241
349
|
item = self.itemAt(event.scenePos(), transform)
|
|
242
350
|
if item is None:
|
|
243
|
-
|
|
351
|
+
# Respect external edit permission if available on parent editor
|
|
352
|
+
editor = self.parent()
|
|
353
|
+
allowed = True
|
|
354
|
+
try:
|
|
355
|
+
if hasattr(editor, "editing_allowed") and callable(editor.editing_allowed):
|
|
356
|
+
allowed = bool(editor.editing_allowed())
|
|
357
|
+
except Exception:
|
|
358
|
+
allowed = False
|
|
359
|
+
if allowed:
|
|
360
|
+
self.sceneContextRequested.emit(event.scenePos())
|
|
244
361
|
event.accept()
|
|
245
362
|
return
|
|
246
|
-
super().contextMenuEvent(event)
|
|
247
|
-
|
|
363
|
+
super().contextMenuEvent(event)
|
|
@@ -311,7 +311,7 @@ class AddButton(QPushButton):
|
|
|
311
311
|
)
|
|
312
312
|
self.setObjectName('tab-add')
|
|
313
313
|
self.setProperty('tabAdd', True)
|
|
314
|
-
self.setToolTip(trans('action.tab.add.chat'))
|
|
314
|
+
self.setToolTip(trans('action.tab.add.chat.tooltip'))
|
|
315
315
|
|
|
316
316
|
def mousePressEvent(self, event):
|
|
317
317
|
"""
|
|
@@ -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.26 12:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from typing import Optional
|
|
@@ -62,8 +62,8 @@ class ChatInput(QTextEdit):
|
|
|
62
62
|
self._icons_margin = 6 # inner left/right padding around the bar
|
|
63
63
|
self._icons_spacing = 4 # spacing between buttons
|
|
64
64
|
self._icons_offset_y = -4 # small upward shift (visual alignment)
|
|
65
|
-
self._icon_size = QSize(18, 18) # icon size (matches
|
|
66
|
-
self._btn_size = QSize(24, 24) # button size (w x h), matches
|
|
65
|
+
self._icon_size = QSize(18, 18) # icon size (matches original)
|
|
66
|
+
self._btn_size = QSize(24, 24) # button size (w x h), matches QPushButton
|
|
67
67
|
|
|
68
68
|
# Storage for icon buttons and metadata
|
|
69
69
|
self._icons = {} # key -> QPushButton
|
|
@@ -94,8 +94,8 @@ class ChatInput(QTextEdit):
|
|
|
94
94
|
key="web",
|
|
95
95
|
icon=self.ICON_WEB_OFF,
|
|
96
96
|
alt_icon=self.ICON_WEB_ON,
|
|
97
|
-
tooltip=trans('icon.remote_tool.web'),
|
|
98
|
-
alt_tooltip=trans('icon.remote_tool.web'),
|
|
97
|
+
tooltip=trans('icon.remote_tool.web.disabled'),
|
|
98
|
+
alt_tooltip=trans('icon.remote_tool.web.enabled'),
|
|
99
99
|
callback=self.action_toggle_web,
|
|
100
100
|
visible=True,
|
|
101
101
|
)
|
|
@@ -124,6 +124,9 @@ class ChatInput(QTextEdit):
|
|
|
124
124
|
self._tokens_timer.timeout.connect(self.window.controller.ui.update_tokens)
|
|
125
125
|
self.textChanged.connect(self._on_text_changed_tokens)
|
|
126
126
|
|
|
127
|
+
# Paste/input safety limits
|
|
128
|
+
self._paste_max_chars = 100000000 # hard cap to prevent pathological pastes from freezing/crashing
|
|
129
|
+
|
|
127
130
|
def _on_text_changed_tokens(self):
|
|
128
131
|
"""Schedule token count update with debounce."""
|
|
129
132
|
self._tokens_timer.start()
|
|
@@ -142,15 +145,133 @@ class ChatInput(QTextEdit):
|
|
|
142
145
|
self._text_top_padding = max(0, int(px))
|
|
143
146
|
self._apply_margins()
|
|
144
147
|
|
|
148
|
+
def canInsertFromMimeData(self, source) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Restrict accepted MIME types to safe, explicitly handled ones.
|
|
151
|
+
This prevents Qt from trying to parse unknown/broken formats.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
if source is None:
|
|
155
|
+
return False
|
|
156
|
+
return source.hasText() or source.hasUrls() or source.hasImage()
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
|
|
145
160
|
def insertFromMimeData(self, source):
|
|
146
161
|
"""
|
|
147
162
|
Insert from mime data
|
|
148
163
|
|
|
149
164
|
:param source: source
|
|
150
165
|
"""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
166
|
+
# Always process attachments first; never break input pipeline on errors.
|
|
167
|
+
try:
|
|
168
|
+
self.handle_clipboard(source)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
try:
|
|
171
|
+
self.window.core.debug.log(e)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
# If an image is present, we treat it as attachment-only and do not insert textual representation.
|
|
176
|
+
try:
|
|
177
|
+
if source and source.hasImage():
|
|
178
|
+
return
|
|
179
|
+
except Exception:
|
|
180
|
+
# fallback to text extraction below
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
# Insert only sanitized plain text (no HTML, no custom formats).
|
|
184
|
+
try:
|
|
185
|
+
text = self._safe_text_from_mime(source)
|
|
186
|
+
if text:
|
|
187
|
+
self.insertPlainText(text)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
try:
|
|
190
|
+
self.window.core.debug.log(e)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
def _safe_text_from_mime(self, source) -> str:
|
|
195
|
+
"""
|
|
196
|
+
Extracts plain text from QMimeData safely, normalizes and sanitizes it.
|
|
197
|
+
Falls back to URLs joined by space if textual content is not provided.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
if source is None:
|
|
201
|
+
return ""
|
|
202
|
+
if source.hasText():
|
|
203
|
+
return self._sanitize_text(source.text())
|
|
204
|
+
if source.hasUrls():
|
|
205
|
+
parts = []
|
|
206
|
+
for url in source.urls():
|
|
207
|
+
try:
|
|
208
|
+
if url.isLocalFile():
|
|
209
|
+
parts.append(url.toLocalFile())
|
|
210
|
+
else:
|
|
211
|
+
parts.append(url.toString())
|
|
212
|
+
except Exception:
|
|
213
|
+
continue
|
|
214
|
+
return self._sanitize_text(" ".join([p for p in parts if p]))
|
|
215
|
+
except Exception as e:
|
|
216
|
+
try:
|
|
217
|
+
self.window.core.debug.log(e)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
def _sanitize_text(self, text: str) -> str:
|
|
223
|
+
"""
|
|
224
|
+
Sanitize pasted text:
|
|
225
|
+
- normalize newlines
|
|
226
|
+
- remove NUL and most control chars except tab/newline
|
|
227
|
+
- strip zero-width and bidi control characters
|
|
228
|
+
- hard-cap maximum length to avoid UI freeze
|
|
229
|
+
"""
|
|
230
|
+
if not text:
|
|
231
|
+
return ""
|
|
232
|
+
if not isinstance(text, str):
|
|
233
|
+
try:
|
|
234
|
+
text = str(text)
|
|
235
|
+
except Exception:
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
# Normalize line breaks
|
|
239
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
240
|
+
|
|
241
|
+
# Remove disallowed control chars, keep tab/newline
|
|
242
|
+
out = []
|
|
243
|
+
for ch in text:
|
|
244
|
+
code = ord(ch)
|
|
245
|
+
if code == 0:
|
|
246
|
+
continue # NUL
|
|
247
|
+
if code < 32:
|
|
248
|
+
if ch in ("\n", "\t"):
|
|
249
|
+
out.append(ch)
|
|
250
|
+
else:
|
|
251
|
+
out.append(" ")
|
|
252
|
+
continue
|
|
253
|
+
if code == 0x7F:
|
|
254
|
+
continue # DEL
|
|
255
|
+
# Remove zero-width and bidi controls
|
|
256
|
+
if (0x200B <= code <= 0x200F) or (0x202A <= code <= 0x202E) or (0x2066 <= code <= 0x2069):
|
|
257
|
+
continue
|
|
258
|
+
out.append(ch)
|
|
259
|
+
|
|
260
|
+
s = "".join(out)
|
|
261
|
+
|
|
262
|
+
# Cap very large pastes
|
|
263
|
+
try:
|
|
264
|
+
limit = int(self._paste_max_chars)
|
|
265
|
+
except Exception:
|
|
266
|
+
limit = 250000
|
|
267
|
+
if limit > 0 and len(s) > limit:
|
|
268
|
+
s = s[:limit]
|
|
269
|
+
try:
|
|
270
|
+
self.window.core.debug.log(f"Input paste truncated to {limit} chars")
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
return s
|
|
154
275
|
|
|
155
276
|
def handle_clipboard(self, source):
|
|
156
277
|
"""
|
|
@@ -158,20 +279,33 @@ class ChatInput(QTextEdit):
|
|
|
158
279
|
|
|
159
280
|
:param source: source
|
|
160
281
|
"""
|
|
161
|
-
if source
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
282
|
+
if source is None:
|
|
283
|
+
return
|
|
284
|
+
try:
|
|
285
|
+
if source.hasImage():
|
|
286
|
+
image = source.imageData()
|
|
287
|
+
if isinstance(image, QImage):
|
|
288
|
+
self.window.controller.attachment.from_clipboard_image(image)
|
|
289
|
+
elif source.hasUrls():
|
|
290
|
+
urls = source.urls()
|
|
291
|
+
for url in urls:
|
|
292
|
+
try:
|
|
293
|
+
if url.isLocalFile():
|
|
294
|
+
local_path = url.toLocalFile()
|
|
295
|
+
self.window.controller.attachment.from_clipboard_url(local_path)
|
|
296
|
+
except Exception:
|
|
297
|
+
# Ignore broken URL entries
|
|
298
|
+
continue
|
|
299
|
+
elif source.hasText():
|
|
300
|
+
text = self._sanitize_text(source.text())
|
|
301
|
+
if text:
|
|
302
|
+
self.window.controller.attachment.from_clipboard_text(text)
|
|
303
|
+
except Exception as e:
|
|
304
|
+
# Never propagate clipboard errors to UI thread
|
|
305
|
+
try:
|
|
306
|
+
self.window.core.debug.log(e)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
175
309
|
|
|
176
310
|
def contextMenuEvent(self, event):
|
|
177
311
|
"""
|
|
@@ -395,7 +529,7 @@ class ChatInput(QTextEdit):
|
|
|
395
529
|
btn.setCursor(Qt.PointingHandCursor)
|
|
396
530
|
btn.setToolTip(tooltip or key)
|
|
397
531
|
btn.setFocusPolicy(Qt.NoFocus)
|
|
398
|
-
btn.setFlat(True) # flat button style
|
|
532
|
+
btn.setFlat(True) # flat button style
|
|
399
533
|
# optional: no text
|
|
400
534
|
btn.setText("")
|
|
401
535
|
|