pygpt-net 2.6.64__py3-none-any.whl → 2.6.65__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. pygpt_net/CHANGELOG.txt +10 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +3 -1
  4. pygpt_net/controller/files/files.py +71 -2
  5. pygpt_net/controller/presets/editor.py +137 -22
  6. pygpt_net/core/agents/custom/__init__.py +18 -2
  7. pygpt_net/core/agents/custom/runner.py +2 -2
  8. pygpt_net/core/attachments/clipboard.py +146 -0
  9. pygpt_net/core/render/web/renderer.py +33 -11
  10. pygpt_net/data/config/config.json +3 -3
  11. pygpt_net/data/config/models.json +3 -3
  12. pygpt_net/data/css/style.dark.css +12 -0
  13. pygpt_net/data/css/style.light.css +12 -0
  14. pygpt_net/data/icons/pin2.svg +1 -0
  15. pygpt_net/data/icons/pin3.svg +3 -0
  16. pygpt_net/data/icons/point.svg +1 -0
  17. pygpt_net/data/icons/target.svg +1 -0
  18. pygpt_net/data/js/app/ui.js +19 -2
  19. pygpt_net/data/js/app/user.js +22 -54
  20. pygpt_net/data/js/app.min.js +7 -9
  21. pygpt_net/data/locale/locale.en.ini +4 -0
  22. pygpt_net/icons.qrc +4 -0
  23. pygpt_net/icons_rc.py +274 -137
  24. pygpt_net/js_rc.py +2038 -2075
  25. pygpt_net/provider/core/config/patch.py +8 -0
  26. pygpt_net/ui/__init__.py +6 -1
  27. pygpt_net/ui/dialog/preset.py +9 -4
  28. pygpt_net/ui/layout/chat/attachments.py +18 -1
  29. pygpt_net/ui/layout/status.py +3 -3
  30. pygpt_net/ui/widget/element/status.py +55 -0
  31. pygpt_net/ui/widget/filesystem/explorer.py +116 -2
  32. pygpt_net/ui/widget/lists/context.py +26 -16
  33. pygpt_net/ui/widget/textarea/input.py +71 -17
  34. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.65.dist-info}/METADATA +12 -2
  35. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.65.dist-info}/RECORD +38 -32
  36. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.65.dist-info}/LICENSE +0 -0
  37. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.65.dist-info}/WHEEL +0 -0
  38. {pygpt_net-2.6.64.dist-info → pygpt_net-2.6.65.dist-info}/entry_points.txt +0 -0
pygpt_net/CHANGELOG.txt CHANGED
@@ -1,3 +1,13 @@
1
+ 2.6.65 (2025-09-28)
2
+
3
+ - Added drag and drop functionality for files and directories from the filesystem in attachments and file explorer.
4
+ - Added automatic thumbnail generation when uploading avatars.
5
+ - Added a last status timer.
6
+ - Added a fade effect to collapsed user messages.
7
+ - Added a scroll area to the agent options in the presets editor.
8
+ - Added a hover effect to lists.
9
+ - Improved UI/UX.
10
+
1
11
  2.6.64 (2025-09-27)
2
12
 
3
13
  - Added translations to agent headers.
pygpt_net/__init__.py CHANGED
@@ -6,15 +6,15 @@
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 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  __author__ = "Marcin Szczygliński"
13
13
  __copyright__ = "Copyright 2025, Marcin Szczygliński"
14
14
  __credits__ = ["Marcin Szczygliński"]
15
15
  __license__ = "MIT"
16
- __version__ = "2.6.64"
17
- __build__ = "2025-09-27"
16
+ __version__ = "2.6.65"
17
+ __build__ = "2025-09-28"
18
18
  __maintainer__ = "Marcin Szczygliński"
19
19
  __github__ = "https://github.com/szczyglis-dev/py-gpt"
20
20
  __report__ = "https://github.com/szczyglis-dev/py-gpt/issues"
pygpt_net/app.py CHANGED
@@ -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 19:00:00 #
9
+ # Updated Date: 2025.09.28 09:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
@@ -20,6 +20,8 @@ from pygpt_net.utils import set_env
20
20
 
21
21
  # app env
22
22
  set_env("PYGPT_APP_ENV", "prod", allow_overwrite=True) # dev | prod
23
+ # IF dev, JS will be loaded from `data/js/app/*` [js_rc.py], not from `data/js/app.min.js`
24
+ # recompile js_rc.py with: bin/resources.sh, minify to app.min.js with: bin/minify.sh
23
25
 
24
26
  # debug
25
27
  # set_env("QTWEBENGINE_REMOTE_DEBUGGING", 9222)
@@ -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.08.25 18:00:00 #
9
+ # Updated Date: 2025.09.28 08:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
@@ -270,6 +270,75 @@ class Files:
270
270
  self.window.update_status(f"[OK] Uploaded: {num} files.")
271
271
  self.update_explorer()
272
272
 
273
+ def upload_paths(
274
+ self,
275
+ paths: list,
276
+ target_directory: Optional[str] = None
277
+ ):
278
+ """
279
+ Upload provided local paths (files or directories) into target directory.
280
+ - Directories are copied recursively.
281
+ - Name collisions are resolved using timestamp prefix, consistent with upload_local().
282
+ - Skips copying directory into itself or its subdirectory.
283
+
284
+ :param paths: list of absolute local paths
285
+ :param target_directory: destination directory (defaults to user 'data' dir)
286
+ """
287
+ if not paths:
288
+ return
289
+ if target_directory is None:
290
+ target_directory = self.window.core.config.get_user_dir('data')
291
+
292
+ try:
293
+ if not os.path.exists(target_directory):
294
+ os.makedirs(target_directory, exist_ok=True)
295
+ except Exception as e:
296
+ self.window.core.debug.log(e)
297
+ return
298
+
299
+ copied = 0
300
+
301
+ def unique_dest(dest_path: str) -> str:
302
+ if not os.path.exists(dest_path):
303
+ return dest_path
304
+ base_dir = os.path.dirname(dest_path)
305
+ name = os.path.basename(dest_path)
306
+ new_name = self.make_ts_prefix() + "_" + name
307
+ return os.path.join(base_dir, new_name)
308
+
309
+ for src in paths:
310
+ try:
311
+ if not src or not os.path.exists(src):
312
+ continue
313
+
314
+ # Prevent copying a directory into itself or its subdirectory
315
+ try:
316
+ if os.path.isdir(src):
317
+ common = os.path.commonpath([os.path.abspath(src), os.path.abspath(target_directory)])
318
+ if common == os.path.abspath(src):
319
+ # target is inside src; skip
320
+ self.window.core.debug.log(f"Skipped copying directory into itself: {src} -> {target_directory}")
321
+ continue
322
+ except Exception:
323
+ pass
324
+
325
+ dest_base = os.path.join(target_directory, os.path.basename(src))
326
+ dest_path = unique_dest(dest_base)
327
+
328
+ if os.path.isdir(src):
329
+ shutil.copytree(src, dest_path)
330
+ copied += 1
331
+ else:
332
+ copy2(src, dest_path)
333
+ copied += 1
334
+ except Exception as e:
335
+ self.window.core.debug.log(e)
336
+ print(f"Error uploading path {src}: {e}")
337
+
338
+ if copied > 0:
339
+ self.window.update_status(f"[OK] Uploaded: {copied} files.")
340
+ self.update_explorer()
341
+
273
342
  def rename(self, path: str):
274
343
  """
275
344
  Rename file or directory
@@ -480,4 +549,4 @@ class Files:
480
549
 
481
550
  def reload(self):
482
551
  """Reload files"""
483
- self.update_explorer(reload=True)
552
+ self.update_explorer(reload=True)
@@ -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
@@ -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.26 17:00:00 #
9
+ # Updated Date: 2025.09.28 10:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -1216,11 +1216,10 @@ class Renderer(BaseRenderer):
1216
1216
  if preset.ai_name:
1217
1217
  output_name = preset.ai_name
1218
1218
  if preset.ai_avatar:
1219
- presets_dir = self.window.core.config.get_user_dir("presets")
1220
- avatars_dir = os.path.join(presets_dir, "avatars")
1221
- avatar_path = os.path.join(avatars_dir, preset.ai_avatar)
1222
- if os.path.exists(avatar_path):
1223
- avatar_html = f"<img src=\"{self._file_prefix}{avatar_path}\" class=\"avatar\"> "
1219
+ # prefer thumbnail "thumb_<name>" when available
1220
+ avatar_fs = self._resolve_avatar_fs_path(preset.ai_avatar)
1221
+ if avatar_fs:
1222
+ avatar_html = f"<img src=\"{self._file_prefix}{avatar_fs}\" class=\"avatar\"> "
1224
1223
 
1225
1224
  if not output_name and not avatar_html:
1226
1225
  return ""
@@ -1962,6 +1961,30 @@ class Renderer(BaseRenderer):
1962
1961
  pass
1963
1962
  return None
1964
1963
 
1964
+ def _resolve_avatar_fs_path(self, basename: str) -> Optional[str]:
1965
+ """
1966
+ Resolve avatar file system path preferring a local thumbnail named 'thumb_<basename>'.
1967
+
1968
+ Returns:
1969
+ - Absolute path to thumbnail when exists,
1970
+ - Otherwise absolute path to original when exists,
1971
+ - Otherwise None.
1972
+ """
1973
+ if not basename:
1974
+ return None
1975
+ try:
1976
+ presets_dir = self.window.core.config.get_user_dir("presets")
1977
+ avatars_dir = os.path.join(presets_dir, "avatars")
1978
+ thumb = os.path.join(avatars_dir, f"thumb_{basename}")
1979
+ original = os.path.join(avatars_dir, basename)
1980
+ if os.path.exists(thumb):
1981
+ return thumb
1982
+ if os.path.exists(original):
1983
+ return original
1984
+ except Exception:
1985
+ pass
1986
+ return None
1987
+
1965
1988
  def _output_identity(self, ctx: CtxItem) -> Tuple[str, Optional[str], bool]:
1966
1989
  """
1967
1990
  Resolve output identity (name, avatar file:// path) based on preset or ctx-provided agent name.
@@ -1999,11 +2022,10 @@ class Renderer(BaseRenderer):
1999
2022
  name = preset.ai_name or default_name
2000
2023
  avatar = None
2001
2024
  if preset.ai_avatar:
2002
- presets_dir = self.window.core.config.get_user_dir("presets")
2003
- avatars_dir = os.path.join(presets_dir, "avatars")
2004
- avatar_path = os.path.join(avatars_dir, preset.ai_avatar)
2005
- if os.path.exists(avatar_path):
2006
- avatar = f"{self._file_prefix}{avatar_path}"
2025
+ # prefer thumbnail URL if available
2026
+ avatar_fs = self._resolve_avatar_fs_path(preset.ai_avatar)
2027
+ if avatar_fs:
2028
+ avatar = f"{self._file_prefix}{avatar_fs}"
2007
2029
  return name, avatar, True
2008
2030
 
2009
2031
  def _build_render_block(
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "__meta__": {
3
- "version": "2.6.64",
4
- "app.version": "2.6.64",
5
- "updated_at": "2025-09-27T00:00:00"
3
+ "version": "2.6.65",
4
+ "app.version": "2.6.65",
5
+ "updated_at": "2025-09-28T00:00:00"
6
6
  },
7
7
  "access.audio.event.speech": false,
8
8
  "access.audio.event.speech.disabled": [],
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "__meta__": {
3
- "version": "2.6.64",
4
- "app.version": "2.6.64",
5
- "updated_at": "2025-09-27T00:00:00"
3
+ "version": "2.6.65",
4
+ "app.version": "2.6.65",
5
+ "updated_at": "2025-09-28T00:00:00"
6
6
  },
7
7
  "items": {
8
8
  "SpeakLeash/bielik-11b-v2.3-instruct:Q4_K_M": {