pygpt-net 2.6.64__py3-none-any.whl → 2.6.66__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 (68) hide show
  1. pygpt_net/CHANGELOG.txt +21 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +5 -1
  4. pygpt_net/controller/chat/chat.py +0 -0
  5. pygpt_net/controller/chat/handler/openai_stream.py +137 -7
  6. pygpt_net/controller/chat/render.py +0 -0
  7. pygpt_net/controller/config/field/checkbox_list.py +34 -1
  8. pygpt_net/controller/files/files.py +71 -2
  9. pygpt_net/controller/media/media.py +20 -1
  10. pygpt_net/controller/presets/editor.py +137 -22
  11. pygpt_net/controller/presets/presets.py +4 -1
  12. pygpt_net/controller/ui/mode.py +14 -10
  13. pygpt_net/controller/ui/ui.py +18 -1
  14. pygpt_net/core/agents/custom/__init__.py +18 -2
  15. pygpt_net/core/agents/custom/runner.py +2 -2
  16. pygpt_net/core/attachments/clipboard.py +146 -0
  17. pygpt_net/core/image/image.py +34 -1
  18. pygpt_net/core/render/web/renderer.py +33 -11
  19. pygpt_net/core/tabs/tabs.py +0 -0
  20. pygpt_net/core/types/image.py +61 -3
  21. pygpt_net/data/config/config.json +4 -3
  22. pygpt_net/data/config/models.json +629 -41
  23. pygpt_net/data/css/style.dark.css +12 -0
  24. pygpt_net/data/css/style.light.css +12 -0
  25. pygpt_net/data/icons/pin2.svg +1 -0
  26. pygpt_net/data/icons/pin3.svg +3 -0
  27. pygpt_net/data/icons/point.svg +1 -0
  28. pygpt_net/data/icons/target.svg +1 -0
  29. pygpt_net/data/js/app/ui.js +19 -2
  30. pygpt_net/data/js/app/user.js +22 -54
  31. pygpt_net/data/js/app.min.js +7 -9
  32. pygpt_net/data/locale/locale.de.ini +4 -0
  33. pygpt_net/data/locale/locale.en.ini +8 -0
  34. pygpt_net/data/locale/locale.es.ini +4 -0
  35. pygpt_net/data/locale/locale.fr.ini +4 -0
  36. pygpt_net/data/locale/locale.it.ini +4 -0
  37. pygpt_net/data/locale/locale.pl.ini +4 -0
  38. pygpt_net/data/locale/locale.uk.ini +4 -0
  39. pygpt_net/data/locale/locale.zh.ini +4 -0
  40. pygpt_net/icons.qrc +4 -0
  41. pygpt_net/icons_rc.py +274 -137
  42. pygpt_net/item/model.py +15 -19
  43. pygpt_net/js_rc.py +2038 -2075
  44. pygpt_net/provider/agents/openai/agent.py +0 -0
  45. pygpt_net/provider/api/google/__init__.py +20 -9
  46. pygpt_net/provider/api/google/image.py +161 -28
  47. pygpt_net/provider/api/google/video.py +73 -36
  48. pygpt_net/provider/api/openai/__init__.py +21 -11
  49. pygpt_net/provider/api/openai/agents/client.py +0 -0
  50. pygpt_net/provider/api/openai/video.py +562 -0
  51. pygpt_net/provider/core/config/patch.py +15 -0
  52. pygpt_net/provider/core/model/patch.py +29 -3
  53. pygpt_net/provider/vector_stores/qdrant.py +117 -0
  54. pygpt_net/ui/__init__.py +6 -1
  55. pygpt_net/ui/dialog/preset.py +9 -4
  56. pygpt_net/ui/layout/chat/attachments.py +18 -1
  57. pygpt_net/ui/layout/status.py +3 -3
  58. pygpt_net/ui/layout/toolbox/raw.py +7 -1
  59. pygpt_net/ui/widget/element/status.py +55 -0
  60. pygpt_net/ui/widget/filesystem/explorer.py +116 -2
  61. pygpt_net/ui/widget/lists/context.py +26 -16
  62. pygpt_net/ui/widget/option/checkbox_list.py +14 -2
  63. pygpt_net/ui/widget/textarea/input.py +71 -17
  64. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.66.dist-info}/METADATA +76 -25
  65. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.66.dist-info}/RECORD +63 -55
  66. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.66.dist-info}/LICENSE +0 -0
  67. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.66.dist-info}/WHEEL +0 -0
  68. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.66.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.27 00:00:00 #
9
+ # Updated Date: 2025.09.28 09:35:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
@@ -14,8 +14,9 @@ import os
14
14
  import shutil
15
15
  from typing import Any, Optional, Dict
16
16
 
17
- from PySide6.QtCore import Slot
18
- from PySide6.QtWidgets import QVBoxLayout, QWidget, QHBoxLayout
17
+ from PySide6.QtCore import Slot, Qt
18
+ from PySide6.QtWidgets import QVBoxLayout, QWidget, QHBoxLayout, QScrollArea, QFrame
19
+ from PySide6.QtGui import QImageReader
19
20
 
20
21
  from pygpt_net.core.types import (
21
22
  MODE_AGENT,
@@ -573,10 +574,19 @@ class Editor:
573
574
  layout.addStretch(1)
574
575
  layout.addLayout(checkbox_layout)
575
576
 
576
- # as tab
577
- tab_widget = QWidget()
578
- tab_widget.setLayout(layout)
579
- tabs.addTab(tab_widget, title)
577
+ # wrap the tab content in a scroll area to avoid vertical overlaps
578
+ tab_content = QWidget()
579
+ tab_content.setLayout(layout)
580
+
581
+ scroll = QScrollArea()
582
+ scroll.setWidgetResizable(True)
583
+ scroll.setFrameShape(QFrame.NoFrame)
584
+ scroll.setWidget(tab_content)
585
+ # Attach metadata on the tab widget itself for later mapping.
586
+ scroll.setProperty("agent_id", id)
587
+ scroll.setProperty("option_tab_id", option_tab_id)
588
+
589
+ tabs.addTab(scroll, title)
580
590
 
581
591
  # store mapping: agent id -> [tab index]
582
592
  if id not in self.tab_options_idx:
@@ -747,15 +757,20 @@ class Editor:
747
757
  layout.addStretch(1)
748
758
  layout.addLayout(checkbox_layout)
749
759
 
750
- # Assemble tab widget and tag it with metadata.
751
- tab_widget = QWidget()
752
- tab_widget.setLayout(layout)
753
- tab_widget.setProperty('agent_id', agent_id)
754
- tab_widget.setProperty('option_tab_id', option_tab_id)
760
+ # Assemble tab widget wrapped into a scroll area.
761
+ tab_content = QWidget()
762
+ tab_content.setLayout(layout)
763
+
764
+ scroll = QScrollArea()
765
+ scroll.setWidgetResizable(True)
766
+ scroll.setFrameShape(QFrame.NoFrame)
767
+ scroll.setWidget(tab_content)
768
+ scroll.setProperty('agent_id', agent_id)
769
+ scroll.setProperty('option_tab_id', option_tab_id)
755
770
 
756
771
  # Insert at a stable anchor to preserve general ordering between agents.
757
772
  insertion_index = min(insertion_index, tabs.count())
758
- tabs.insertTab(insertion_index, tab_widget, title)
773
+ tabs.insertTab(insertion_index, scroll, title)
759
774
  new_indices.append(insertion_index)
760
775
  insertion_index += 1
761
776
 
@@ -1297,17 +1312,26 @@ class Editor:
1297
1312
  os.remove(avatar_path)
1298
1313
  if os.path.exists(file_path):
1299
1314
  shutil.copy(file_path, avatar_path)
1315
+
1316
+ # create thumbnail next to original (max 350px on the longest side, prefix: thumb_)
1317
+ thumb_path = self._create_avatar_thumbnail(avatar_path)
1318
+
1300
1319
  if preset:
1301
1320
  preset.ai_avatar = store_name
1302
1321
  else:
1303
1322
  self.tmp_avatar = store_name
1323
+
1324
+ # keep storing original filename in config/preset (UI will prefer thumbnail only for preview if available)
1304
1325
  self.window.controller.config.apply_value(
1305
1326
  parent_id=self.id,
1306
1327
  key="ai_avatar",
1307
1328
  option=self.options["ai_avatar"],
1308
1329
  value=store_name,
1309
1330
  )
1310
- avatar_widget.load_avatar(avatar_path)
1331
+
1332
+ # prefer thumbnail in UI preview if it exists, fall back to original
1333
+ preview_path = thumb_path if thumb_path and os.path.exists(thumb_path) else avatar_path
1334
+ avatar_widget.load_avatar(preview_path)
1311
1335
  avatar_widget.enable_remove_button(True)
1312
1336
  return avatar_path
1313
1337
 
@@ -1325,11 +1349,19 @@ class Editor:
1325
1349
  "avatars",
1326
1350
  avatar_path,
1327
1351
  )
1352
+ # prefer thumbnail in UI preview if available
1353
+ thumb_path = os.path.join(
1354
+ self.window.core.config.get_user_dir("presets"),
1355
+ "avatars",
1356
+ f"thumb_{avatar_path}",
1357
+ )
1358
+ preview_path = thumb_path if os.path.exists(thumb_path) else file_path
1359
+
1328
1360
  if not os.path.exists(file_path):
1329
1361
  avatar_widget.remove_avatar()
1330
1362
  print("Avatar file does not exist:", file_path)
1331
1363
  return
1332
- avatar_widget.load_avatar(file_path)
1364
+ avatar_widget.load_avatar(preview_path)
1333
1365
  avatar_widget.enable_remove_button(True)
1334
1366
  else:
1335
1367
  avatar_widget.remove_avatar()
@@ -1354,10 +1386,25 @@ class Editor:
1354
1386
  presets_dir = self.window.core.config.get_user_dir("presets")
1355
1387
  avatars_dir = os.path.join(presets_dir, "avatars")
1356
1388
  avatar_path = os.path.join(avatars_dir, current)
1389
+ thumb_path = os.path.join(avatars_dir, f"thumb_{current}")
1390
+ # remove original
1357
1391
  if os.path.exists(avatar_path):
1358
1392
  os.remove(avatar_path)
1393
+ # remove thumbnail (if exists)
1394
+ if os.path.exists(thumb_path):
1395
+ os.remove(thumb_path)
1359
1396
  preset.ai_avatar = ""
1360
1397
  else:
1398
+ # if not yet persisted, also drop any staged temp avatar
1399
+ if self.tmp_avatar:
1400
+ presets_dir = self.window.core.config.get_user_dir("presets")
1401
+ avatars_dir = os.path.join(presets_dir, "avatars")
1402
+ avatar_path = os.path.join(avatars_dir, self.tmp_avatar)
1403
+ thumb_path = os.path.join(avatars_dir, f"thumb_{self.tmp_avatar}")
1404
+ if os.path.exists(avatar_path):
1405
+ os.remove(avatar_path)
1406
+ if os.path.exists(thumb_path):
1407
+ os.remove(thumb_path)
1361
1408
  self.tmp_avatar = None
1362
1409
 
1363
1410
  self.window.ui.nodes['preset.editor.avatar'].remove_avatar()
@@ -1500,15 +1547,20 @@ class Editor:
1500
1547
  layout.addStretch(1)
1501
1548
  layout.addLayout(checkbox_layout)
1502
1549
 
1503
- # Create tab widget and tag with metadata
1504
- tab_widget = QWidget()
1505
- tab_widget.setLayout(layout)
1506
- tab_widget.setProperty('agent_id', a_id)
1507
- tab_widget.setProperty('option_tab_id', option_tab_id)
1550
+ # Create tab widget wrapped in a scroll area and tag with metadata
1551
+ tab_content = QWidget()
1552
+ tab_content.setLayout(layout)
1553
+
1554
+ scroll = QScrollArea()
1555
+ scroll.setWidgetResizable(True)
1556
+ scroll.setFrameShape(QFrame.NoFrame)
1557
+ scroll.setWidget(tab_content)
1558
+ scroll.setProperty('agent_id', a_id)
1559
+ scroll.setProperty('option_tab_id', option_tab_id)
1508
1560
 
1509
1561
  # Insert tab and advance anchor
1510
1562
  insertion_index = min(insertion_index, tabs.count())
1511
- tabs.insertTab(insertion_index, tab_widget, title)
1563
+ tabs.insertTab(insertion_index, scroll, title)
1512
1564
  insertion_index += 1
1513
1565
 
1514
1566
  # Apply saved values (if present) or defaults
@@ -1592,4 +1644,67 @@ class Editor:
1592
1644
  widget.set_value(default_val)
1593
1645
  except Exception:
1594
1646
  # Silent fallback; apply_value above should already handle most cases
1595
- pass
1647
+ pass
1648
+
1649
+ # ---------- Avatar thumbnail helpers ----------
1650
+
1651
+ def _create_avatar_thumbnail(self, src_path: str, max_px: int = 350) -> Optional[str]:
1652
+ """
1653
+ Create a thumbnail next to the original avatar file with prefix 'thumb_'.
1654
+
1655
+ Notes:
1656
+ - Respects EXIF orientation via QImageReader.setAutoTransform(True).
1657
+ - Keeps aspect ratio; the longer side is limited to max_px.
1658
+ - Saves using the same extension as the original (derived from destination filename).
1659
+ - Returns full path to the thumbnail or None on failure.
1660
+ """
1661
+ try:
1662
+ if not os.path.exists(src_path):
1663
+ return None
1664
+
1665
+ dir_name, base_name = os.path.split(src_path)
1666
+ thumb_name = f"thumb_{base_name}"
1667
+ thumb_path = os.path.join(dir_name, thumb_name)
1668
+
1669
+ # Clean previous thumbnail (if any) to avoid stale previews
1670
+ if os.path.exists(thumb_path):
1671
+ try:
1672
+ os.remove(thumb_path)
1673
+ except Exception:
1674
+ pass
1675
+
1676
+ reader = QImageReader(src_path)
1677
+ reader.setAutoTransform(True) # apply EXIF rotation if present
1678
+ image = reader.read()
1679
+ if image.isNull():
1680
+ # If reading failed, fallback to copying original (not resized)
1681
+ shutil.copy(src_path, thumb_path)
1682
+ return thumb_path
1683
+
1684
+ w = image.width()
1685
+ h = image.height()
1686
+ if w <= 0 or h <= 0:
1687
+ return None
1688
+
1689
+ # Scale down only when needed; always keep aspect ratio and smooth transformation
1690
+ if max(w, h) > max_px:
1691
+ scaled = image.scaled(max_px, max_px, Qt.KeepAspectRatio, Qt.SmoothTransformation)
1692
+ ok = scaled.save(thumb_path)
1693
+ else:
1694
+ ok = image.save(thumb_path)
1695
+
1696
+ if ok:
1697
+ return thumb_path
1698
+
1699
+ # Last resort: copy original
1700
+ shutil.copy(src_path, thumb_path)
1701
+ return thumb_path
1702
+ except Exception as e:
1703
+ # As a safe fallback try to copy the original; ignore errors silently
1704
+ try:
1705
+ dir_name, base_name = os.path.split(src_path)
1706
+ thumb_path = os.path.join(dir_name, f"thumb_{base_name}")
1707
+ shutil.copy(src_path, thumb_path)
1708
+ return thumb_path
1709
+ except Exception:
1710
+ return None
@@ -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.26 03:00:00 #
9
+ # Updated Date: 2025.12.25 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import re
@@ -497,8 +497,10 @@ class Presets:
497
497
 
498
498
  :param no_scroll: do not scroll to current
499
499
  """
500
+ self.locked = True
500
501
  w = self.window
501
502
  if w.core.config.get('mode') == MODE_ASSISTANT:
503
+ self.locked = False
502
504
  return
503
505
  if no_scroll:
504
506
  w.ui.nodes['preset.presets'].store_scroll_position()
@@ -511,6 +513,7 @@ class Presets:
511
513
  if no_scroll:
512
514
  w.ui.nodes['preset.presets'].restore_scroll_position()
513
515
  self.on_changed()
516
+ self.locked = False
514
517
 
515
518
  def update_list(self):
516
519
  """Update presets list"""
@@ -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.27 15:00:00 #
9
+ # Updated Date: 2025.12.25 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from pygpt_net.core.types import (
@@ -39,7 +39,8 @@ class Mode:
39
39
  def update(self):
40
40
  """Update mode, model, preset and rest of the toolbox"""
41
41
 
42
- mode = self.window.core.config.data['mode']
42
+ mode = self.window.core.config.get("mode")
43
+ model = self.window.core.config.get("model")
43
44
 
44
45
  ui_nodes = self.window.ui.nodes
45
46
  ui_tabs = self.window.ui.tabs
@@ -53,7 +54,7 @@ class Mode:
53
54
  is_agent_llama = mode == MODE_AGENT_LLAMA
54
55
  is_agent_openai = mode == MODE_AGENT_OPENAI
55
56
  is_expert = mode == MODE_EXPERT
56
- is_image = mode == MODE_IMAGE
57
+ is_media = mode == MODE_IMAGE
57
58
  is_llama_index = mode == MODE_LLAMA_INDEX
58
59
  is_completion = mode == MODE_COMPLETION
59
60
  is_audio = mode == MODE_AUDIO
@@ -161,15 +162,18 @@ class Mode:
161
162
  ui_nodes['preset.editor.modes'].setVisible(True)
162
163
  ui_tabs['preset.editor.extra'].setTabText(0, trans("preset.prompt"))
163
164
 
164
- # image options visibility
165
- if is_image:
165
+ # media options visibility
166
+ if is_media:
166
167
  ui_nodes['media.raw'].setVisible(True)
167
- if ctrl.media.is_video_model():
168
+ if ctrl.media.is_video_model() and ctrl.media.get_mode() == "video":
168
169
  ui_nodes['video.options'].setVisible(True)
169
170
  ui_nodes['dalle.options'].setVisible(False)
170
- elif ctrl.media.is_image_model():
171
+ elif ctrl.media.is_image_model() and ctrl.media.get_mode() == "image":
171
172
  ui_nodes['dalle.options'].setVisible(True)
172
173
  ui_nodes['video.options'].setVisible(False)
174
+ elif ctrl.media.get_mode() == "music":
175
+ ui_nodes['dalle.options'].setVisible(False)
176
+ ui_nodes['video.options'].setVisible(False)
173
177
  else:
174
178
  ui_nodes['media.raw'].setVisible(False)
175
179
  ui_nodes['dalle.options'].setVisible(False)
@@ -199,7 +203,7 @@ class Mode:
199
203
  else:
200
204
  ui_nodes['idx.options'].setVisible(False)
201
205
 
202
- if is_image:
206
+ if is_media:
203
207
  ui_nodes['input.stream'].setVisible(False)
204
208
  else:
205
209
  ui_nodes['input.stream'].setVisible(True)
@@ -213,13 +217,13 @@ class Mode:
213
217
  ui_tabs['input'].setTabVisible(1, show)
214
218
 
215
219
  # remote tools icon visibility
216
- if not is_image and not is_completion:
220
+ if not is_media and not is_completion:
217
221
  self.window.ui.nodes['input'].set_icon_visible("web", True)
218
222
  else:
219
223
  self.window.ui.nodes['input'].set_icon_visible("web", False)
220
224
 
221
225
  ui_tabs['input'].setTabVisible(2, is_assistant)
222
- ui_tabs['input'].setTabVisible(3, (not is_assistant) and (not is_image))
226
+ ui_tabs['input'].setTabVisible(3, (not is_assistant) and (not is_media))
223
227
 
224
228
  presets_editor.toggle_extra_options()
225
229
 
@@ -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.26 17:00:00 #
9
+ # Updated Date: 2025.12.25 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from typing import Optional
@@ -68,6 +68,7 @@ class UI:
68
68
  self.update_tokens()
69
69
  self.vision.update()
70
70
  self.window.controller.agent.legacy.update()
71
+ self.img_update_available_modes()
71
72
  self.img_update_available_resolutions()
72
73
 
73
74
  def handle(self, event: BaseEvent):
@@ -258,4 +259,20 @@ class UI:
258
259
  key="img_resolution",
259
260
  option=self.window.core.image.get_resolution_option(),
260
261
  value=current,
262
+ )
263
+
264
+ def img_update_available_modes(self):
265
+ """Update available modes for images"""
266
+ mode = self.window.core.config.get('mode')
267
+ if mode != MODE_IMAGE:
268
+ return
269
+ model = self.window.core.config.get('model')
270
+ keys = self.window.core.image.get_available_modes(model)
271
+ current = self.window.core.config.get('img_mode', 'image')
272
+ self.window.ui.config['global']['img_mode'].set_keys(keys, lock=False)
273
+ self.window.controller.config.apply_value(
274
+ parent_id="global",
275
+ key="img_mode",
276
+ option=self.window.core.image.get_mode_option(),
277
+ value=current,
261
278
  )
@@ -184,8 +184,9 @@ class Custom:
184
184
  tab["label"] = slots["name"]
185
185
  if "role" in slots:
186
186
  opts["role"] = {
187
- "type": "str",
188
- "label": trans("agent.option.role"),
187
+ "type": "text",
188
+ "label": trans("agent.option.role.label"),
189
+ "description": trans("agent.option.role"),
189
190
  "default": slots["role"],
190
191
  }
191
192
  if "instruction" in slots:
@@ -213,6 +214,21 @@ class Custom:
213
214
  except Exception as e:
214
215
  self.window.core.debug.log(f"Failed to build options for custom agent '{agent_id}': {e}")
215
216
  continue
217
+
218
+ # debug tab - trace_id, etc.
219
+ """
220
+ options["debug"] = {
221
+ "label": trans("agent.tab.debug"),
222
+ "options": {
223
+ "trace_id": {
224
+ "type": "text",
225
+ "label": trans("agent.option.debug.trace_id"),
226
+ "description": trans("agent.option.debug.trace_id.desc"),
227
+ "default": "",
228
+ }
229
+ }
230
+ }
231
+ """
216
232
  return options
217
233
 
218
234
  def new_agent(self, name: str):
@@ -337,8 +337,8 @@ class FlowOrchestrator:
337
337
  "input": prepared_items,
338
338
  "max_turns": int(agent_kwargs.get("max_iterations", max_iterations)),
339
339
  }
340
- if trace_id:
341
- run_kwargs["trace_id"] = trace_id
340
+ # if trace_id:
341
+ # run_kwargs["trace_id"] = trace_id
342
342
 
343
343
  # Header for UI
344
344
  ctx.set_agent_name(agent.name)
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.28 08:00:00 #
10
+ # ================================================== #
11
+
12
+ from PySide6.QtCore import QObject, QEvent, Qt
13
+
14
+
15
+ class AttachmentDropHandler(QObject):
16
+ """
17
+ Generic drag & drop handler for attaching files/images/urls/text.
18
+
19
+ Policies:
20
+ - SWALLOW_ALL: always consume the drop (e.g., attachments list).
21
+ - INPUT_MIX : for ChatInput; process attachments and:
22
+ * swallow image payloads (no text insert),
23
+ * allow default handling for non-image payloads so paths/text get inserted.
24
+ """
25
+ SWALLOW_ALL = 0
26
+ INPUT_MIX = 1
27
+
28
+ def __init__(self, window, target_widget, policy=SWALLOW_ALL):
29
+ super().__init__(target_widget)
30
+ self.window = window
31
+ self._target = target_widget
32
+ self._policy = policy
33
+
34
+ # Accept drops on target and its viewport (important for QTextEdit/QAbstractScrollArea)
35
+ self._enable_drops(self._target)
36
+ vp = self._get_viewport(self._target)
37
+ if vp is not None:
38
+ self._enable_drops(vp)
39
+
40
+ # Install filters on both
41
+ self._target.installEventFilter(self)
42
+ if vp is not None:
43
+ vp.installEventFilter(self)
44
+
45
+ def _enable_drops(self, w):
46
+ try:
47
+ w.setAcceptDrops(True)
48
+ except Exception:
49
+ pass
50
+
51
+ def _get_viewport(self, w):
52
+ try:
53
+ vp = getattr(w, "viewport", None)
54
+ if callable(vp):
55
+ return vp()
56
+ return None
57
+ except Exception:
58
+ return None
59
+
60
+ def _mime_supported(self, md) -> bool:
61
+ try:
62
+ if md is None:
63
+ return False
64
+ return md.hasUrls() or md.hasImage() or md.hasText()
65
+ except Exception:
66
+ return False
67
+
68
+ def _process_drop(self, md):
69
+ """
70
+ Route to ChatInput.handle_clipboard() to reuse existing attach pipeline.
71
+ """
72
+ try:
73
+ chat_input = self.window.ui.nodes.get('input')
74
+ except Exception:
75
+ chat_input = None
76
+
77
+ if chat_input is not None and hasattr(chat_input, 'handle_clipboard'):
78
+ try:
79
+ chat_input.handle_clipboard(md)
80
+ return chat_input
81
+ except Exception as e:
82
+ try:
83
+ self.window.core.debug.log(e)
84
+ except Exception:
85
+ pass
86
+ return None
87
+
88
+ def _allow_default_text_insert_for_non_image(self, md) -> bool:
89
+ try:
90
+ return not (md and md.hasImage())
91
+ except Exception:
92
+ return True
93
+
94
+ def eventFilter(self, obj, event):
95
+ # Only handle events coming to the target or its viewport
96
+ if obj is not self._target and obj is not self._get_viewport(self._target):
97
+ return False
98
+
99
+ et = event.type()
100
+
101
+ if et in (QEvent.DragEnter, QEvent.DragMove):
102
+ md = getattr(event, 'mimeData', lambda: None)()
103
+ if self._mime_supported(md):
104
+ try:
105
+ event.setDropAction(Qt.CopyAction)
106
+ event.acceptProposedAction()
107
+ except Exception:
108
+ event.accept()
109
+ return True
110
+ return False
111
+
112
+ if et == QEvent.Drop:
113
+ md = getattr(event, 'mimeData', lambda: None)()
114
+ if not self._mime_supported(md):
115
+ return False
116
+
117
+ chat_input = self._process_drop(md)
118
+
119
+ try:
120
+ event.setDropAction(Qt.CopyAction)
121
+ event.acceptProposedAction()
122
+ except Exception:
123
+ event.accept()
124
+
125
+ # Policy decision:
126
+ if self._policy == self.SWALLOW_ALL:
127
+ # Consume the event; nothing else should handle it.
128
+ return True
129
+
130
+ if self._policy == self.INPUT_MIX:
131
+ # For non-image payloads we allow default to insert text/paths into input.
132
+ # To avoid duplicate attachments (insertFromMimeData calls handle_clipboard),
133
+ # set a one-shot guard flag.
134
+ if chat_input is not None and self._allow_default_text_insert_for_non_image(md):
135
+ try:
136
+ chat_input._skip_clipboard_on_next_insert = True
137
+ except Exception:
138
+ pass
139
+ return False # let default drop insert text/paths
140
+ else:
141
+ return True # swallow images
142
+
143
+ # Default: swallow
144
+ return True
145
+
146
+ return False
@@ -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.01 23:00:00 #
9
+ # Updated Date: 2025.12.25 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import uuid
@@ -158,6 +158,39 @@ class Image(QObject):
158
158
  "keys": self.get_available_resolutions(),
159
159
  }
160
160
 
161
+ def get_mode_option(self) -> dict:
162
+ """
163
+ Get image mode option for UI
164
+
165
+ :return: dict
166
+ """
167
+ return {
168
+ "type": "combo",
169
+ "slider": True,
170
+ "label": "img_mode",
171
+ "value": "image",
172
+ "keys": self.get_available_modes(),
173
+ }
174
+
175
+ def get_available_modes(self, model_name: str = None) -> Dict[str, str]:
176
+ """
177
+ Get available modes (image / video)
178
+
179
+ :param model_name: model name
180
+ :return: dict of available modes
181
+ """
182
+ options = {}
183
+ if model_name:
184
+ model = self.window.core.models.get(model_name)
185
+ if model:
186
+ if model.is_image_output():
187
+ options["image"] = trans("mode.img.image")
188
+ if model.is_video_output():
189
+ options["video"] = trans("mode.img.video")
190
+ if model.is_music_output():
191
+ options["music"] = trans("mode.img.music")
192
+ return options
193
+
161
194
  def get_available_resolutions(self, model: str = None) -> Dict[str, str]:
162
195
  """
163
196
  Get available image resolutions