pygpt-net 2.6.61__py3-none-any.whl → 2.6.63__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 (86) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/response.py +8 -2
  4. pygpt_net/controller/presets/editor.py +65 -1
  5. pygpt_net/controller/settings/profile.py +16 -4
  6. pygpt_net/controller/settings/workdir.py +30 -5
  7. pygpt_net/controller/theme/common.py +4 -2
  8. pygpt_net/controller/theme/markdown.py +2 -2
  9. pygpt_net/controller/theme/theme.py +2 -1
  10. pygpt_net/controller/ui/ui.py +31 -3
  11. pygpt_net/core/agents/custom/llama_index/runner.py +30 -52
  12. pygpt_net/core/agents/custom/runner.py +199 -76
  13. pygpt_net/core/agents/runners/llama_workflow.py +122 -12
  14. pygpt_net/core/agents/runners/openai_workflow.py +2 -1
  15. pygpt_net/core/node_editor/types.py +13 -1
  16. pygpt_net/core/render/web/renderer.py +76 -11
  17. pygpt_net/data/config/config.json +3 -3
  18. pygpt_net/data/config/models.json +3 -3
  19. pygpt_net/data/config/presets/agent_openai_b2b.json +1 -15
  20. pygpt_net/data/config/presets/agent_openai_coder.json +1 -15
  21. pygpt_net/data/config/presets/agent_openai_evolve.json +1 -23
  22. pygpt_net/data/config/presets/agent_openai_planner.json +1 -21
  23. pygpt_net/data/config/presets/agent_openai_researcher.json +1 -21
  24. pygpt_net/data/config/presets/agent_openai_supervisor.json +1 -13
  25. pygpt_net/data/config/presets/agent_openai_writer.json +1 -15
  26. pygpt_net/data/config/presets/agent_supervisor.json +1 -11
  27. pygpt_net/data/css/style.dark.css +18 -0
  28. pygpt_net/data/css/style.light.css +20 -1
  29. pygpt_net/data/js/app/runtime.js +4 -1
  30. pygpt_net/data/js/app.min.js +3 -2
  31. pygpt_net/data/locale/locale.de.ini +2 -0
  32. pygpt_net/data/locale/locale.en.ini +7 -0
  33. pygpt_net/data/locale/locale.es.ini +2 -0
  34. pygpt_net/data/locale/locale.fr.ini +2 -0
  35. pygpt_net/data/locale/locale.it.ini +2 -0
  36. pygpt_net/data/locale/locale.pl.ini +3 -1
  37. pygpt_net/data/locale/locale.uk.ini +2 -0
  38. pygpt_net/data/locale/locale.zh.ini +2 -0
  39. pygpt_net/item/ctx.py +23 -1
  40. pygpt_net/js_rc.py +13 -10
  41. pygpt_net/provider/agents/base.py +0 -0
  42. pygpt_net/provider/agents/llama_index/flow_from_schema.py +0 -0
  43. pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
  44. pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
  45. pygpt_net/provider/agents/llama_index/workflow/planner.py +248 -28
  46. pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
  47. pygpt_net/provider/agents/openai/agent.py +3 -1
  48. pygpt_net/provider/agents/openai/agent_b2b.py +17 -13
  49. pygpt_net/provider/agents/openai/agent_planner.py +617 -258
  50. pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
  51. pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +8 -6
  52. pygpt_net/provider/agents/openai/agent_with_feedback.py +8 -6
  53. pygpt_net/provider/agents/openai/evolve.py +12 -8
  54. pygpt_net/provider/agents/openai/flow_from_schema.py +0 -0
  55. pygpt_net/provider/agents/openai/supervisor.py +292 -37
  56. pygpt_net/provider/api/openai/agents/response.py +1 -0
  57. pygpt_net/provider/api/x_ai/__init__.py +0 -0
  58. pygpt_net/provider/core/agent/__init__.py +0 -0
  59. pygpt_net/provider/core/agent/base.py +0 -0
  60. pygpt_net/provider/core/agent/json_file.py +0 -0
  61. pygpt_net/provider/core/config/patch.py +8 -0
  62. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -0
  63. pygpt_net/provider/llms/base.py +0 -0
  64. pygpt_net/provider/llms/deepseek_api.py +0 -0
  65. pygpt_net/provider/llms/google.py +0 -0
  66. pygpt_net/provider/llms/hugging_face_api.py +0 -0
  67. pygpt_net/provider/llms/hugging_face_router.py +0 -0
  68. pygpt_net/provider/llms/mistral.py +0 -0
  69. pygpt_net/provider/llms/perplexity.py +0 -0
  70. pygpt_net/provider/llms/x_ai.py +0 -0
  71. pygpt_net/tools/agent_builder/tool.py +6 -0
  72. pygpt_net/tools/agent_builder/ui/dialogs.py +0 -41
  73. pygpt_net/ui/layout/toolbox/presets.py +14 -2
  74. pygpt_net/ui/main.py +2 -2
  75. pygpt_net/ui/widget/dialog/confirm.py +55 -5
  76. pygpt_net/ui/widget/draw/painter.py +90 -1
  77. pygpt_net/ui/widget/lists/preset.py +289 -25
  78. pygpt_net/ui/widget/node_editor/editor.py +53 -15
  79. pygpt_net/ui/widget/node_editor/node.py +82 -104
  80. pygpt_net/ui/widget/node_editor/view.py +4 -5
  81. pygpt_net/ui/widget/textarea/input.py +155 -21
  82. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/METADATA +22 -8
  83. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/RECORD +70 -70
  84. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/LICENSE +0 -0
  85. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/WHEEL +0 -0
  86. {pygpt_net-2.6.61.dist-info → pygpt_net-2.6.63.dist-info}/entry_points.txt +0 -0
@@ -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.25 12:35:00 #
9
+ # Updated Date: 2025.09.26 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from __future__ import annotations
@@ -143,18 +143,6 @@ class NodeEditor(QWidget):
143
143
  # Centralized strings
144
144
  self.config: EditorConfig = config if isinstance(config, EditorConfig) else EditorConfig()
145
145
 
146
- # Theme and palette
147
- """
148
- if parent and hasattr(parent, "window"):
149
- theme = parent.window.core.config.get("theme")
150
- if theme.startswith("light"):
151
- print("[NodeEditor] Detected light theme from parent")
152
- self._grid_back_color = QColor(255, 255, 255)
153
- self._grid_pen_color = QColor(230, 230, 230)
154
- self.gridBackColor = Property(QColor, lambda self: self._grid_back_color, lambda self, v: self._q_set("_grid_back_color", v))
155
- self.gridPenColor = Property(QColor, lambda self: self._grid_pen_color, lambda self, v: self._q_set("_grid_pen_color", v))
156
- """
157
-
158
146
  self.graph = NodeGraph(registry)
159
147
  self.scene = NodeGraphicsScene(self)
160
148
  self.view = NodeGraphicsView(self.scene, self)
@@ -422,7 +410,12 @@ class NodeEditor(QWidget):
422
410
 
423
411
  def add_node(self, type_name: str, scene_pos: QPointF):
424
412
  """Add a new node of the given type at scene_pos (undoable)."""
413
+ # Enforce optional per-type limit configured in registry.
414
+ if not self._can_add_node_of_type(type_name):
415
+ self._dbg(f"add_node blocked: type='{type_name}' reached its max per-layout limit")
416
+ return False
425
417
  self._undo.push(AddNodeCommand(self, type_name, scene_pos))
418
+ return True
426
419
 
427
420
  def clear(self, ask_user: bool = True):
428
421
  """Clear the entire editor (undoable), optionally asking the user for confirmation."""
@@ -438,6 +431,7 @@ class NodeEditor(QWidget):
438
431
  if reply != QMessageBox.Yes:
439
432
  return False
440
433
  self._undo.push(ClearGraphCommand(self))
434
+ self._update_status_label()
441
435
  return True
442
436
 
443
437
  def undo(self):
@@ -668,6 +662,35 @@ class NodeEditor(QWidget):
668
662
  vp_pt = self.view.mapFromScene(scene_pos)
669
663
  return self.view.viewport().mapToGlobal(vp_pt)
670
664
 
665
+ # ---------- Per-type limit helpers ----------
666
+
667
+ def _type_limit(self, type_name: str) -> Optional[int]:
668
+ """Return configured max_num for type or None if unlimited."""
669
+ spec = self.graph.registry.get(type_name) if self.graph and self.graph.registry else None
670
+ if not spec:
671
+ return None
672
+ try:
673
+ limit = getattr(spec, "max_num", None)
674
+ if isinstance(limit, int) and limit <= 0:
675
+ return None
676
+ return limit if isinstance(limit, int) else None
677
+ except Exception:
678
+ return None
679
+
680
+ def _count_nodes_of_type(self, type_name: str) -> int:
681
+ """Count current nodes of a given type in the live graph."""
682
+ try:
683
+ return sum(1 for n in self.graph.nodes.values() if getattr(n, "type", None) == type_name)
684
+ except Exception:
685
+ return 0
686
+
687
+ def _can_add_node_of_type(self, type_name: str) -> bool:
688
+ """Check whether adding another node of given type is allowed by max_num."""
689
+ limit = self._type_limit(type_name)
690
+ if limit is None:
691
+ return True
692
+ return self._count_nodes_of_type(type_name) < int(limit)
693
+
671
694
  def _on_scene_context_menu(self, scene_pos: QPointF):
672
695
  """Show context menu for adding nodes and undo/redo/clear at empty scene position."""
673
696
  try:
@@ -685,7 +708,21 @@ class NodeEditor(QWidget):
685
708
  add_menu = menu.addMenu(self.config.menu_add())
686
709
  action_by_type: Dict[QAction, str] = {}
687
710
  for tname in self.graph.registry.types():
688
- act = add_menu.addAction(tname)
711
+ # Prefer UI-only display name; fallback to identifier (type_name)
712
+ try:
713
+ label = self.graph.registry.display_name(tname)
714
+ except Exception:
715
+ label = tname
716
+ act = add_menu.addAction(label)
717
+ # Show the underlying identifier in tooltip to make UI/ID relation explicit
718
+ if label != tname:
719
+ try:
720
+ act.setToolTip(tname)
721
+ except Exception:
722
+ pass
723
+ # Disable when limit reached (evaluated in real-time, per layout)
724
+ if not self._can_add_node_of_type(tname):
725
+ act.setEnabled(False)
689
726
  action_by_type[act] = tname
690
727
 
691
728
  menu.addSeparator()
@@ -711,7 +748,8 @@ class NodeEditor(QWidget):
711
748
  self.clear(ask_user=True)
712
749
  elif chosen in action_by_type:
713
750
  type_name = action_by_type[chosen]
714
- self._undo.push(AddNodeCommand(self, type_name, scene_pos))
751
+ # Route through editor.add_node() to enforce limit guards
752
+ self.add_node(type_name, scene_pos)
715
753
 
716
754
  # ---------- Z-order helpers ----------
717
755
 
@@ -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.25 15:32:39 #
9
+ # Updated Date: 2025.09.26 10:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from __future__ import annotations
@@ -44,6 +44,9 @@ class SingleLineTextEdit(QTextEdit):
44
44
  self.setTabChangesFocus(True)
45
45
  self.setFrameStyle(QFrame.NoFrame)
46
46
  self.document().setDocumentMargin(2)
47
+ # Enforce line-edit-like behavior with no context menu
48
+ # (widget menus are disabled by design; only node menu remains)
49
+ self.setContextMenuPolicy(Qt.NoContextMenu)
47
50
  self._update_height()
48
51
 
49
52
  def _update_height(self):
@@ -86,58 +89,13 @@ class SingleLineTextEdit(QTextEdit):
86
89
  c.setPosition(min(pos, len(t2)))
87
90
  self.setTextCursor(c)
88
91
 
89
- def _apply_menu_theme(self, menu: QMenu):
90
- """Apply app/window stylesheet + palette + font to context menu."""
91
- try:
92
- wnd = self.window()
93
- except Exception:
94
- wnd = None
95
- stylesheet = ""
96
- pal = None
97
- font = None
98
- try:
99
- if wnd:
100
- stylesheet = wnd.styleSheet() or ""
101
- pal = wnd.palette()
102
- font = wnd.font()
103
- except Exception:
104
- pass
105
- try:
106
- app = QApplication.instance()
107
- if app:
108
- if not stylesheet and app.styleSheet():
109
- stylesheet = app.styleSheet()
110
- if pal is None:
111
- pal = app.palette()
112
- if font is None:
113
- font = app.font()
114
- except Exception:
115
- pass
116
- try:
117
- if pal:
118
- menu.setPalette(pal)
119
- if font:
120
- menu.setFont(font)
121
- if stylesheet:
122
- menu.setStyleSheet(stylesheet)
123
- menu.ensurePolished()
124
- except Exception:
125
- pass
126
-
127
92
  def contextMenuEvent(self, e):
128
- """Ensure standard context menu follows app-wide (e.g., Qt Material) styling."""
93
+ """Widget-level context menu is intentionally disabled."""
129
94
  try:
130
- menu = self.createStandardContextMenu()
95
+ e.ignore()
131
96
  except Exception:
132
- return super().contextMenuEvent(e)
133
- self._apply_menu_theme(menu)
134
- try:
135
- menu.exec(e.globalPos())
136
- finally:
137
- try:
138
- menu.deleteLater()
139
- except Exception:
140
- pass
97
+ pass
98
+ return
141
99
 
142
100
 
143
101
  class NodeContentWidget(QWidget):
@@ -229,6 +187,8 @@ class NodeContentWidget(QWidget):
229
187
  elif pm.type == "text":
230
188
  te = QTextEdit()
231
189
  te.setFocusPolicy(Qt.StrongFocus)
190
+ # Disable widget-level context menu completely (only node menu is available)
191
+ te.setContextMenuPolicy(Qt.NoContextMenu)
232
192
  if pm.value is not None:
233
193
  te.setPlainText(str(pm.value))
234
194
  te.setReadOnly(not editable)
@@ -238,8 +198,6 @@ class NodeContentWidget(QWidget):
238
198
  except Exception:
239
199
  pass
240
200
  te.textChanged.connect(lambda pid=pid, te=te: self.valueChanged.emit(pid, te.toPlainText()))
241
- # Ensure context menu follows global (Material) style
242
- self._install_styled_context_menu(te)
243
201
  w = te
244
202
 
245
203
  elif pm.type == "int":
@@ -314,57 +272,6 @@ class NodeContentWidget(QWidget):
314
272
  except Exception:
315
273
  pass
316
274
 
317
- def _install_styled_context_menu(self, te: QTextEdit):
318
- """Install a custom context menu handler that applies global styles."""
319
- try:
320
- te.setContextMenuPolicy(Qt.CustomContextMenu)
321
- te.customContextMenuRequested.connect(
322
- lambda pos, _te=te: self._show_styled_standard_menu(_te, pos)
323
- )
324
- except Exception:
325
- pass
326
-
327
- def _show_styled_standard_menu(self, te: QTextEdit, pos):
328
- """Create standard menu and apply app/window stylesheet + palette + font."""
329
- try:
330
- menu = te.createStandardContextMenu()
331
- except Exception:
332
- return
333
- stylesheet = ""
334
- pal = None
335
- font = None
336
- try:
337
- # Prefer editor helpers (consistent with the rest of NodeEditor)
338
- stylesheet = self.editor._current_stylesheet()
339
- pal = self.editor._current_palette()
340
- font = self.editor._current_font()
341
- except Exception:
342
- try:
343
- wnd = te.window()
344
- if wnd:
345
- stylesheet = wnd.styleSheet() or ""
346
- pal = wnd.palette()
347
- font = wnd.font()
348
- except Exception:
349
- pass
350
- try:
351
- if pal:
352
- menu.setPalette(pal)
353
- if font:
354
- menu.setFont(font)
355
- if stylesheet:
356
- menu.setStyleSheet(stylesheet)
357
- menu.ensurePolished()
358
- except Exception:
359
- pass
360
- try:
361
- menu.exec(te.mapToGlobal(pos))
362
- finally:
363
- try:
364
- menu.deleteLater()
365
- except Exception:
366
- pass
367
-
368
275
  def event(self, e):
369
276
  """
370
277
  Filter ShortcutOverride so editor keystrokes are not eaten by the scene.
@@ -825,7 +732,7 @@ class NodeItem(QGraphicsWidget):
825
732
  def _effective_hit_margin(self) -> float:
826
733
  """Return the effective resize-grip 'hit' margin (visual margin minus inset)."""
827
734
  margin = float(getattr(self.editor, "_resize_grip_margin", 12.0) or 12.0)
828
- inset = float(getattr(self.editor, "_resize_grip_hit_inset", 3.0) or 0.0)
735
+ inset = float(getattr(self.editor, "_resize_grip_hit_inset", 5.0) or 0.0)
829
736
  hit = max(4.0, margin - inset)
830
737
  return hit
831
738
 
@@ -1216,6 +1123,77 @@ class NodeItem(QGraphicsWidget):
1216
1123
  """Filter events on proxy/content to keep hover/ports/overlay in sync."""
1217
1124
  et = e.type()
1218
1125
  try:
1126
+ # Forward RMB on the proxy (covers all embedded editors) to node menu
1127
+ if obj is self._proxy:
1128
+ if et == QEvent.GraphicsSceneMousePress:
1129
+ try:
1130
+ if e.button() == Qt.RightButton:
1131
+ # Use screenPos if available; fallback to view mapping
1132
+ gp = e.screenPos() if hasattr(e, "screenPos") else None
1133
+ if gp is None:
1134
+ sp = e.scenePos()
1135
+ vp = self.editor.view.mapFromScene(sp)
1136
+ gp = self.editor.view.viewport().mapToGlobal(vp)
1137
+ # Reuse node menu logic here for consistency
1138
+ menu = QMenu(self.editor.window())
1139
+ ss = self.editor.window().styleSheet()
1140
+ if ss:
1141
+ menu.setStyleSheet(ss)
1142
+ act_rename = QAction(self.editor.config.node_context_rename(), menu)
1143
+ act_delete = QAction(self.editor.config.node_context_delete(), menu)
1144
+ menu.addAction(act_rename)
1145
+ menu.addSeparator()
1146
+ menu.addAction(act_delete)
1147
+ chosen = menu.exec(gp.toPoint() if hasattr(gp, "toPoint") else gp)
1148
+ if chosen == act_rename:
1149
+ from PySide6.QtWidgets import QInputDialog
1150
+ new_name, ok = QInputDialog.getText(
1151
+ self.editor.window(),
1152
+ self.editor.config.rename_dialog_title(),
1153
+ self.editor.config.rename_dialog_label(),
1154
+ text=self.node.name
1155
+ )
1156
+ if ok and new_name:
1157
+ self.node.name = new_name
1158
+ self.update()
1159
+ elif chosen == act_delete:
1160
+ self.editor._delete_node_item(self)
1161
+ e.accept()
1162
+ return True
1163
+ except Exception:
1164
+ pass
1165
+
1166
+ if et == QEvent.GraphicsSceneContextMenu:
1167
+ try:
1168
+ gp = e.screenPos() if hasattr(e, "screenPos") else None
1169
+ menu = QMenu(self.editor.window())
1170
+ ss = self.editor.window().styleSheet()
1171
+ if ss:
1172
+ menu.setStyleSheet(ss)
1173
+ act_rename = QAction(self.editor.config.node_context_rename(), menu)
1174
+ act_delete = QAction(self.editor.config.node_context_delete(), menu)
1175
+ menu.addAction(act_rename)
1176
+ menu.addSeparator()
1177
+ menu.addAction(act_delete)
1178
+ chosen = menu.exec(gp)
1179
+ if chosen == act_rename:
1180
+ from PySide6.QtWidgets import QInputDialog
1181
+ new_name, ok = QInputDialog.getText(
1182
+ self.editor.window(),
1183
+ self.editor.config.rename_dialog_title(),
1184
+ self.editor.config.rename_dialog_label(),
1185
+ text=self.node.name
1186
+ )
1187
+ if ok and new_name:
1188
+ self.node.name = new_name
1189
+ self.update()
1190
+ elif chosen == act_delete:
1191
+ self.editor._delete_node_item(self)
1192
+ e.accept()
1193
+ return True
1194
+ except Exception:
1195
+ pass
1196
+
1219
1197
  if obj is self._proxy and et in (QEvent.GraphicsSceneMouseMove, QEvent.GraphicsSceneHoverMove):
1220
1198
  local = self.mapFromScene(e.scenePos())
1221
1199
  self._apply_hover_from_pos(local)
@@ -254,7 +254,6 @@ class NodeViewOverlayControls(QWidget):
254
254
  self.setAttribute(Qt.WA_StyledBackground, True)
255
255
 
256
256
  layout = QHBoxLayout(self)
257
- # Bigger spacing to visually add padding around buttons
258
257
  layout.setContentsMargins(0, 0, 0, 0)
259
258
  layout.setSpacing(8)
260
259
 
@@ -266,21 +265,21 @@ class NodeViewOverlayControls(QWidget):
266
265
  self.btnGrab.setToolTip(cfg.overlay_grab_tooltip())
267
266
  self.btnGrab.setIcon(QIcon(":/icons/drag.svg"))
268
267
  self.btnGrab.setIconSize(QSize(20, 20))
269
- self.btnGrab.setMinimumSize(32, 32)
268
+ self.btnGrab.setMinimumSize(25, 25)
270
269
 
271
- # Zoom Out (placed before Zoom In)
270
+ # Zoom Out
272
271
  self.btnZoomOut = QPushButton(self)
273
272
  self.btnZoomOut.setToolTip(cfg.overlay_zoom_out_tooltip())
274
273
  self.btnZoomOut.setIcon(QIcon(":/icons/zoom_out.svg"))
275
274
  self.btnZoomOut.setIconSize(QSize(20, 20))
276
- self.btnZoomOut.setMinimumSize(32, 32)
275
+ self.btnZoomOut.setMinimumSize(25, 25)
277
276
 
278
277
  # Zoom In
279
278
  self.btnZoomIn = QPushButton(self)
280
279
  self.btnZoomIn.setToolTip(cfg.overlay_zoom_in_tooltip())
281
280
  self.btnZoomIn.setIcon(QIcon(":/icons/zoom_in.svg"))
282
281
  self.btnZoomIn.setIconSize(QSize(20, 20))
283
- self.btnZoomIn.setMinimumSize(32, 32)
282
+ self.btnZoomIn.setMinimumSize(25, 25)
284
283
 
285
284
  layout.addWidget(self.btnGrab)
286
285
  layout.addWidget(self.btnZoomIn)
@@ -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
@@ -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 = 1000000000 # 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