pygpt-net 2.6.63__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 (62) hide show
  1. pygpt_net/CHANGELOG.txt +16 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +3 -1
  4. pygpt_net/controller/attachment/attachment.py +17 -8
  5. pygpt_net/controller/camera/camera.py +4 -4
  6. pygpt_net/controller/files/files.py +71 -2
  7. pygpt_net/controller/lang/custom.py +2 -2
  8. pygpt_net/controller/presets/editor.py +137 -22
  9. pygpt_net/controller/ui/mode.py +18 -3
  10. pygpt_net/core/agents/custom/__init__.py +18 -2
  11. pygpt_net/core/agents/custom/runner.py +2 -2
  12. pygpt_net/core/attachments/clipboard.py +146 -0
  13. pygpt_net/core/render/web/renderer.py +44 -11
  14. pygpt_net/data/config/config.json +3 -3
  15. pygpt_net/data/config/models.json +3 -3
  16. pygpt_net/data/config/presets/agent_openai_coder.json +15 -1
  17. pygpt_net/data/css/style.dark.css +12 -0
  18. pygpt_net/data/css/style.light.css +12 -0
  19. pygpt_net/data/icons/pin2.svg +1 -0
  20. pygpt_net/data/icons/pin3.svg +3 -0
  21. pygpt_net/data/icons/point.svg +1 -0
  22. pygpt_net/data/icons/target.svg +1 -0
  23. pygpt_net/data/js/app/runtime.js +11 -4
  24. pygpt_net/data/js/app/scroll.js +14 -0
  25. pygpt_net/data/js/app/ui.js +19 -2
  26. pygpt_net/data/js/app/user.js +22 -54
  27. pygpt_net/data/js/app.min.js +13 -14
  28. pygpt_net/data/locale/locale.de.ini +32 -0
  29. pygpt_net/data/locale/locale.en.ini +38 -2
  30. pygpt_net/data/locale/locale.es.ini +32 -0
  31. pygpt_net/data/locale/locale.fr.ini +32 -0
  32. pygpt_net/data/locale/locale.it.ini +32 -0
  33. pygpt_net/data/locale/locale.pl.ini +34 -2
  34. pygpt_net/data/locale/locale.uk.ini +32 -0
  35. pygpt_net/data/locale/locale.zh.ini +32 -0
  36. pygpt_net/icons.qrc +4 -0
  37. pygpt_net/icons_rc.py +274 -137
  38. pygpt_net/js_rc.py +8262 -8230
  39. pygpt_net/provider/agents/llama_index/planner_workflow.py +15 -3
  40. pygpt_net/provider/agents/llama_index/workflow/planner.py +69 -41
  41. pygpt_net/provider/agents/openai/agent_planner.py +57 -35
  42. pygpt_net/provider/agents/openai/evolve.py +0 -3
  43. pygpt_net/provider/api/google/__init__.py +9 -3
  44. pygpt_net/provider/api/google/image.py +11 -1
  45. pygpt_net/provider/api/google/music.py +375 -0
  46. pygpt_net/provider/core/config/patch.py +8 -0
  47. pygpt_net/ui/__init__.py +6 -1
  48. pygpt_net/ui/dialog/preset.py +9 -4
  49. pygpt_net/ui/layout/chat/attachments.py +18 -1
  50. pygpt_net/ui/layout/status.py +3 -3
  51. pygpt_net/ui/widget/element/status.py +55 -0
  52. pygpt_net/ui/widget/filesystem/explorer.py +116 -2
  53. pygpt_net/ui/widget/lists/context.py +26 -16
  54. pygpt_net/ui/widget/option/combo.py +149 -11
  55. pygpt_net/ui/widget/textarea/input.py +71 -17
  56. pygpt_net/ui/widget/textarea/web.py +1 -1
  57. pygpt_net/ui/widget/vision/camera.py +135 -12
  58. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/METADATA +18 -2
  59. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/RECORD +62 -55
  60. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/LICENSE +0 -0
  61. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/WHEEL +0 -0
  62. {pygpt_net-2.6.63.dist-info → pygpt_net-2.6.65.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -386,6 +386,11 @@ class Renderer(BaseRenderer):
386
386
  except Exception:
387
387
  pass
388
388
 
389
+ try:
390
+ self.get_output_node(meta).page().runJavaScript("if (typeof window.begin !== 'undefined') begin();")
391
+ except Exception:
392
+ pass
393
+
389
394
  def end(self, meta: CtxMeta, ctx: CtxItem, stream: bool = False):
390
395
  """
391
396
  Render end
@@ -402,6 +407,12 @@ class Renderer(BaseRenderer):
402
407
  self.pids[pid].item = None
403
408
  else:
404
409
  self.reload()
410
+
411
+ try:
412
+ self.get_output_node(meta).page().runJavaScript("if (typeof window.end !== 'undefined') end();")
413
+ except Exception:
414
+ pass
415
+
405
416
  self.pids[pid].clear()
406
417
  self.auto_cleanup(meta)
407
418
 
@@ -1205,11 +1216,10 @@ class Renderer(BaseRenderer):
1205
1216
  if preset.ai_name:
1206
1217
  output_name = preset.ai_name
1207
1218
  if preset.ai_avatar:
1208
- presets_dir = self.window.core.config.get_user_dir("presets")
1209
- avatars_dir = os.path.join(presets_dir, "avatars")
1210
- avatar_path = os.path.join(avatars_dir, preset.ai_avatar)
1211
- if os.path.exists(avatar_path):
1212
- 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\"> "
1213
1223
 
1214
1224
  if not output_name and not avatar_html:
1215
1225
  return ""
@@ -1951,6 +1961,30 @@ class Renderer(BaseRenderer):
1951
1961
  pass
1952
1962
  return None
1953
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
+
1954
1988
  def _output_identity(self, ctx: CtxItem) -> Tuple[str, Optional[str], bool]:
1955
1989
  """
1956
1990
  Resolve output identity (name, avatar file:// path) based on preset or ctx-provided agent name.
@@ -1988,11 +2022,10 @@ class Renderer(BaseRenderer):
1988
2022
  name = preset.ai_name or default_name
1989
2023
  avatar = None
1990
2024
  if preset.ai_avatar:
1991
- presets_dir = self.window.core.config.get_user_dir("presets")
1992
- avatars_dir = os.path.join(presets_dir, "avatars")
1993
- avatar_path = os.path.join(avatars_dir, preset.ai_avatar)
1994
- if os.path.exists(avatar_path):
1995
- 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}"
1996
2029
  return name, avatar, True
1997
2030
 
1998
2031
  def _build_render_block(
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "__meta__": {
3
- "version": "2.6.63",
4
- "app.version": "2.6.63",
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.63",
4
- "app.version": "2.6.63",
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": {
@@ -31,7 +31,21 @@
31
31
  "enabled": true,
32
32
  "description": "",
33
33
  "remote_tools": "",
34
- "extra": {},
34
+ "extra": {
35
+ "openai_agent_feedback": {
36
+ "base": {
37
+ "prompt": "You are senior programmer and expert in coding. Use markdown for code blocks. If there is any feedback provided, use it to improve the code.",
38
+ "allow_local_tools": false,
39
+ "allow_remote_tools": false
40
+ },
41
+ "feedback": {
42
+ "model": "o3-mini-low",
43
+ "prompt": "You evaluate a code and decide if it's correct. If it's not correct, you provide feedback on what needs to be fixed and improved. Never give it a pass on the first try. After 5 attempts, you can give it a pass if the code is good enough. You can use tools for checking the code, running tests, etc.",
44
+ "allow_local_tools": false,
45
+ "allow_remote_tools": false
46
+ }
47
+ }
48
+ },
35
49
  "__meta__": {
36
50
  "version": "2.5.81",
37
51
  "app.version": "2.5.81",
@@ -29,6 +29,13 @@ QListView::item {{
29
29
  padding: 4px;
30
30
  min-height: 20px;
31
31
  }}
32
+
33
+ /* Subtle hover for list/tree items (non-selected) */
34
+ QListView::item:hover:!selected,
35
+ QTreeView::item:hover:!selected {{
36
+ background-color: rgba(255, 255, 255, 5);
37
+ }}
38
+
32
39
  QListView::item:selected,
33
40
  QTreeView::item:selected {{
34
41
  color: #ffffff;
@@ -162,4 +169,9 @@ NodeEditor {{
162
169
 
163
170
  qproperty-edgeColor: #c0c0c0;
164
171
  qproperty-edgeSelectedColor: #ff8a5c;
172
+ }}
173
+
174
+ /* Status Bar */
175
+ #StatusBarTimer {{
176
+ color: #999 !important;
165
177
  }}
@@ -82,6 +82,13 @@ QListView::item {{
82
82
  padding: 4px;
83
83
  min-height: 20px;
84
84
  }}
85
+
86
+ /* Subtle hover for list/tree items (non-selected) */
87
+ QListView::item:hover:!selected,
88
+ QTreeView::item:hover:!selected {{
89
+ background-color: #f6f8fb;
90
+ }}
91
+
85
92
  QCalendarWidget QWidget {{
86
93
  background: #fff;
87
94
  }}
@@ -278,4 +285,9 @@ NodeEditor {{
278
285
 
279
286
  qproperty-edgeColor: #c0c0c0;
280
287
  qproperty-edgeSelectedColor: #ff8a5c;
288
+ }}
289
+
290
+ /* Status Bar */
291
+ #StatusBarTimer {{
292
+ color: #5d5d5d !important;
281
293
  }}
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#686868"><path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/></svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24px" height="24px" fill="#686868" version="1.1" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="m621.42-355.15v113.14l-56.569 56.569-141.42-141.42-169.71 169.71h-56.569v-56.569l169.71-169.71-141.42-141.42 56.569-56.569h113.14l197.99-197.99-28.284-28.284 56.569-56.569 282.84 282.84-56.569 56.569-28.284-28.284zm-258.8-145.66 178.19 178.19v-65.054l222.03-222.03-113.14-113.14-222.03 222.03z"/>
3
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#686868"><path d="M480-400q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm-40-240v-200h80v200h-80Zm0 520v-200h80v200h-80Zm200-320v-80h200v80H640Zm-520 0v-80h200v80H120Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#686868"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-80q-100 0-170-70t-70-170q0-100 70-170t170-70q100 0 170 70t70 170q0 100-70 170t-170 70Zm0-80q66 0 113-47t47-113q0-66-47-113t-113-47q-66 0-113 47t-47 113q0 66 47 113t113 47Zm0-80q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Z"/></svg>
@@ -197,6 +197,7 @@ class Runtime {
197
197
  api_appendNode = (payload) => {
198
198
  this.resetStreamState('appendNode');
199
199
  this.data.append(payload);
200
+ this.scrollMgr.scheduleScroll();
200
201
  };
201
202
 
202
203
  api_replaceNodes = (payload) => {
@@ -267,10 +268,7 @@ class Runtime {
267
268
  api_updateToolOutput = (c) => this.toolOutput.update(c);
268
269
  api_clearToolOutput = () => this.toolOutput.clear();
269
270
  api_beginToolOutput = () => this.toolOutput.begin();
270
- api_endToolOutput = () => {
271
- this.toolOutput.end();
272
- this.scrollMgr.scheduleScroll();
273
- }
271
+ api_endToolOutput = () => this.toolOutput.end();
274
272
  api_enableToolOutput = () => this.toolOutput.enable();
275
273
  api_disableToolOutput = () => this.toolOutput.disable();
276
274
  api_toggleToolOutput = (id) => this.toolOutput.toggle(id);
@@ -377,6 +375,12 @@ class Runtime {
377
375
  api_showTips = () => this.tips.show();
378
376
  api_hideTips = () => this.tips.hide();
379
377
 
378
+ // API: begin/end.
379
+ api_begin = () => {};
380
+ api_end = () => {
381
+ this.scrollMgr.forceScrollToBottomImmediateAtEnd();
382
+ }
383
+
380
384
  // API: custom markup rules control.
381
385
  api_getCustomMarkupRules = () => this.customMarkup.getRules();
382
386
  api_setCustomMarkupRules = (rules) => {
@@ -481,6 +485,9 @@ window.appendStreamTyped = (type, name, chunk) => runtime.api_onChunk(name, chun
481
485
  window.nextStream = () => runtime.api_nextStream();
482
486
  window.clearStream = () => runtime.api_clearStream();
483
487
 
488
+ window.begin = () => runtime.api_begin();
489
+ window.end = () => runtime.api_end();
490
+
484
491
  window.appendNode = (payload) => runtime.api_appendNode(payload);
485
492
  window.replaceNodes = (payload) => runtime.api_replaceNodes(payload);
486
493
  window.appendToInput = (html) => runtime.api_appendToInput(html);
@@ -58,6 +58,20 @@ class ScrollManager {
58
58
  this.prevScroll = el.scrollHeight;
59
59
  }
60
60
 
61
+ // Jump to bottom immediately (no smooth behavior).
62
+ forceScrollToBottomImmediateAtEnd() {
63
+ if (this.userInteracted === true || !this.isNearBottom(200)) return;
64
+ const el = Utils.SE;
65
+ setTimeout(() => {
66
+ el.scrollTo({
67
+ top: el.scrollHeight,
68
+ behavior: 'instant'
69
+ });
70
+ this.lastScrollTop = el.scrollTop;
71
+ this.prevScroll = el.scrollHeight;
72
+ }, 100);
73
+ }
74
+
61
75
  // Scroll window to bottom based on auto-follow and margins.
62
76
  scrollToBottom(live = false, force = false) {
63
77
  const el = Utils.SE;
@@ -1,3 +1,5 @@
1
+ // app/ui.js
2
+
1
3
  // ==========================================================================
2
4
  // UI manager
3
5
  // ==========================================================================
@@ -30,10 +32,25 @@ class UIManager {
30
32
  '#_loader_.hidden { display: none !important; visibility: hidden !important; }',
31
33
  '#_loader_.visible { display: block; visibility: visible; }',
32
34
 
33
- /* User message collapse (uc-*) */
35
+ /* User message collapse (uc-*)
36
+ Collapsed content now fades out towards the bottom using a CSS mask.
37
+ This removes the need for an inline "..." overlay and works in light/dark themes. */
34
38
  '.msg-box.msg-user .msg { position: relative; }',
35
39
  '.msg-box.msg-user .msg > .uc-content { display: block; overflow: visible; }',
36
- '.msg-box.msg-user .msg > .uc-content.uc-collapsed { max-height: 1000px; overflow: hidden; }',
40
+ '.msg-box.msg-user .msg > .uc-content.uc-collapsed {',
41
+ ' max-height: var(--user-msg-collapse-max-h, 1000px);',
42
+ ' overflow: hidden;',
43
+ ' -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) calc(100% - var(--uc-fade-height, 64px)), rgba(0,0,0,0) 100%);',
44
+ ' mask-image: linear-gradient(to bottom, rgba(0,0,0,1) calc(100% - var(--uc-fade-height, 64px)), rgba(0,0,0,0) 100%);',
45
+ ' -webkit-mask-size: 100% 100%;',
46
+ ' mask-size: 100% 100%;',
47
+ ' -webkit-mask-repeat: no-repeat;',
48
+ ' mask-repeat: no-repeat;',
49
+ '}',
50
+ '.msg-box.msg-user .msg > .uc-content.uc-expanded {',
51
+ ' -webkit-mask-image: none;',
52
+ ' mask-image: none;',
53
+ '}',
37
54
  '.msg-box.msg-user .msg > .uc-toggle { display: none; margin-top: 8px; text-align: center; cursor: pointer; user-select: none; }',
38
55
  '.msg-box.msg-user .msg > .uc-toggle.visible { display: block; }',
39
56
 
@@ -1,3 +1,5 @@
1
+ // app/user.js
2
+
1
3
  // ==========================================================================
2
4
  // User collapse manager
3
5
  // ==========================================================================
@@ -12,8 +14,7 @@ class UserCollapseManager {
12
14
  // Track processed .msg elements to allow cheap remeasure on resize if needed.
13
15
  this._processed = new Set();
14
16
 
15
- // Visual indicator attached while collapsed (does not modify original text).
16
- this.ellipsisText = ' [...]';
17
+ // Visual indicator is now purely CSS-based (mask fade) no inline "..." text injected anymore.
17
18
  }
18
19
 
19
20
  // Icon paths for the collapse/expand buttons.
@@ -158,60 +159,27 @@ class UserCollapseManager {
158
159
  };
159
160
  }
160
161
 
161
- // Create or update the ellipsis indicator inside content (absolute in the bottom-right corner).
162
+ // Previously created an inline "..." indicator; now acts as a no-op and cleans legacy nodes if present.
162
163
  _ensureEllipsisEl(msg, contentEl) {
163
164
  const content = contentEl || (msg && msg.querySelector('.uc-content'));
164
165
  if (!content) return null;
165
-
166
- // Ensure the content becomes a positioning context only when needed.
167
- if (getComputedStyle(content).position === 'static') {
168
- content.style.position = 'relative';
169
- }
170
-
171
- let dot = content.querySelector('.uc-ellipsis');
172
- if (!dot) {
173
- dot = document.createElement('span');
174
- dot.className = 'uc-ellipsis';
175
- dot.textContent = this.ellipsisText;
176
- // Inline, theme-agnostic styles; kept minimal and non-interactive.
177
- dot.style.position = 'absolute';
178
- dot.style.right = '0';
179
- dot.style.bottom = '0';
180
- dot.style.paddingLeft = '6px';
181
- dot.style.pointerEvents = 'none';
182
- dot.style.zIndex = '1';
183
- dot.style.fontWeight = '500';
184
- dot.style.opacity = '0.75';
185
- // Do not include in copy-to-clipboard.
186
- dot.setAttribute('aria-hidden', 'true');
187
- dot.setAttribute('data-copy-ignore', '1');
188
-
189
- content.appendChild(dot);
190
- }
191
- return dot;
166
+ try {
167
+ const legacy = content.querySelector('.uc-ellipsis');
168
+ if (legacy && legacy.parentNode) {
169
+ legacy.parentNode.removeChild(legacy);
170
+ }
171
+ } catch (_) {}
172
+ return null;
192
173
  }
193
174
 
194
- // Show ellipsis only when there is hidden overflow (collapsed).
175
+ // No visual node is needed anymore; fade is applied via CSS on .uc-collapsed.
195
176
  _showEllipsis(msg, contentEl) {
196
- const dot = this._ensureEllipsisEl(msg, contentEl);
197
- if (dot) dot.style.display = 'inline';
177
+ this._ensureEllipsisEl(msg, contentEl);
198
178
  }
199
-
200
- // Hide and clean ellipsis when not needed (expanded or short content).
179
+
180
+ // No visual node is needed anymore; keep DOM lean and clean any legacy nodes.
201
181
  _hideEllipsis(msg) {
202
- const content = msg && msg.querySelector('.uc-content');
203
- if (!content) return;
204
- const dot = content.querySelector('.uc-ellipsis');
205
- if (dot && dot.parentNode) {
206
- // Remove the indicator to avoid accidental copy/select and keep DOM lean.
207
- dot.parentNode.removeChild(dot);
208
- }
209
- // Drop positioning context when no indicator is present (keep styles minimal).
210
- try {
211
- if (content && content.style && content.querySelector('.uc-ellipsis') == null) {
212
- content.style.position = '';
213
- }
214
- } catch (_) {}
182
+ this._ensureEllipsisEl(msg, null);
215
183
  }
216
184
 
217
185
  // Apply collapse to all user messages under root.
@@ -247,7 +215,7 @@ class UserCollapseManager {
247
215
  c.classList.remove('uc-expanded'); // No class => fully expanded by default CSS.
248
216
  msg.dataset.ucState = 'expanded';
249
217
 
250
- // Hide ellipsis in disabled mode.
218
+ // Hide ellipsis in disabled mode (no-op, CSS fade not applied without .uc-collapsed).
251
219
  this._hideEllipsis(msg);
252
220
 
253
221
  // Hide toggle in disabled mode to avoid user interaction.
@@ -279,10 +247,10 @@ class UserCollapseManager {
279
247
 
280
248
  if (expand) {
281
249
  c.classList.add('uc-expanded');
282
- this._hideEllipsis(msg); // Expanded => no ellipsis
250
+ this._hideEllipsis(msg); // Expanded => nothing to show (CSS fade applies only to .uc-collapsed)
283
251
  } else {
284
252
  c.classList.add('uc-collapsed');
285
- this._showEllipsis(msg, c); // Collapsed => show ellipsis overlay
253
+ this._showEllipsis(msg, c); // Collapsed => CSS fade handled by stylesheet
286
254
  }
287
255
 
288
256
  if (t) {
@@ -300,7 +268,7 @@ class UserCollapseManager {
300
268
  t.title = expand ? labels.collapse : labels.expand;
301
269
  }
302
270
  } else {
303
- // Short content – ensure fully expanded and hide toggle + ellipsis.
271
+ // Short content – ensure fully expanded and hide toggle + (legacy) ellipsis cleanup.
304
272
  c.classList.remove('uc-collapsed');
305
273
  c.classList.remove('uc-expanded');
306
274
  msg.dataset.ucState = 'expanded';
@@ -331,7 +299,7 @@ class UserCollapseManager {
331
299
 
332
300
  const isCollapsed = c.classList.contains('uc-collapsed');
333
301
  if (isCollapsed) {
334
- // Expand – leave scroll as-is; remove ellipsis.
302
+ // Expand – leave scroll as-is.
335
303
  c.classList.remove('uc-collapsed');
336
304
  c.classList.add('uc-expanded');
337
305
  msg.dataset.ucState = 'expanded';
@@ -346,7 +314,7 @@ class UserCollapseManager {
346
314
  }
347
315
  }
348
316
  } else {
349
- // Collapse – apply classes, show ellipsis, then bring toggle into view (scroll up if needed).
317
+ // Collapse – apply classes, then bring toggle into view (scroll up if needed).
350
318
  c.classList.remove('uc-expanded');
351
319
  c.classList.add('uc-collapsed');
352
320
  msg.dataset.ucState = 'collapsed';