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
@@ -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.24 00:00:00 #
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 keyPressEvent(self, e):
87
- """Pass-through: the view does not handle special keys.
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
- ESC and other keys are intentionally left for the host application/editor.
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
- """Start panning with Middle Mouse Button; otherwise defer to base implementation."""
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._panning = True
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() == Qt.MiddleButton:
129
- self._panning = False
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
- self.sceneContextRequested.emit(event.scenePos())
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.22 12:00:00 #
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 your original)
66
- self._btn_size = QSize(24, 24) # button size (w x h), matches previous QPushButton
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
- self.handle_clipboard(source)
152
- if not source.hasImage():
153
- super().insertFromMimeData(source)
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.hasImage():
162
- image = source.imageData()
163
- if isinstance(image, QImage):
164
- self.window.controller.attachment.from_clipboard_image(image)
165
- elif source.hasUrls():
166
- urls = source.urls()
167
- for url in urls:
168
- if url.isLocalFile():
169
- local_path = url.toLocalFile()
170
- self.window.controller.attachment.from_clipboard_url(local_path)
171
- elif source.hasText():
172
- text = source.text()
173
- if text:
174
- self.window.controller.attachment.from_clipboard_text(text)
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 like your original
532
+ btn.setFlat(True) # flat button style
399
533
  # optional: no text
400
534
  btn.setText("")
401
535