pygpt-net 2.6.33__py3-none-any.whl → 2.6.36__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 (64) hide show
  1. pygpt_net/CHANGELOG.txt +18 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/batch.py +14 -4
  4. pygpt_net/controller/assistant/files.py +1 -0
  5. pygpt_net/controller/assistant/store.py +195 -1
  6. pygpt_net/controller/camera/camera.py +1 -1
  7. pygpt_net/controller/chat/common.py +58 -48
  8. pygpt_net/controller/chat/handler/stream_worker.py +55 -43
  9. pygpt_net/controller/config/placeholder.py +95 -75
  10. pygpt_net/controller/dialogs/confirm.py +3 -1
  11. pygpt_net/controller/media/media.py +11 -3
  12. pygpt_net/controller/painter/common.py +243 -13
  13. pygpt_net/controller/painter/painter.py +11 -2
  14. pygpt_net/core/assistants/files.py +18 -0
  15. pygpt_net/core/bridge/bridge.py +1 -5
  16. pygpt_net/core/bridge/context.py +81 -36
  17. pygpt_net/core/bridge/worker.py +3 -1
  18. pygpt_net/core/camera/camera.py +31 -402
  19. pygpt_net/core/camera/worker.py +430 -0
  20. pygpt_net/core/ctx/bag.py +4 -0
  21. pygpt_net/core/events/app.py +10 -17
  22. pygpt_net/core/events/base.py +17 -25
  23. pygpt_net/core/events/control.py +9 -17
  24. pygpt_net/core/events/event.py +9 -62
  25. pygpt_net/core/events/kernel.py +8 -17
  26. pygpt_net/core/events/realtime.py +8 -17
  27. pygpt_net/core/events/render.py +9 -17
  28. pygpt_net/core/filesystem/url.py +3 -0
  29. pygpt_net/core/render/web/body.py +483 -40
  30. pygpt_net/core/render/web/pid.py +39 -24
  31. pygpt_net/core/render/web/renderer.py +142 -36
  32. pygpt_net/core/text/utils.py +3 -0
  33. pygpt_net/data/config/config.json +4 -3
  34. pygpt_net/data/config/models.json +3 -3
  35. pygpt_net/data/config/settings.json +10 -5
  36. pygpt_net/data/css/web-blocks.css +4 -3
  37. pygpt_net/data/css/web-chatgpt.css +4 -2
  38. pygpt_net/data/css/web-chatgpt_wide.css +4 -2
  39. pygpt_net/data/locale/locale.de.ini +9 -7
  40. pygpt_net/data/locale/locale.en.ini +10 -6
  41. pygpt_net/data/locale/locale.es.ini +9 -7
  42. pygpt_net/data/locale/locale.fr.ini +9 -7
  43. pygpt_net/data/locale/locale.it.ini +9 -7
  44. pygpt_net/data/locale/locale.pl.ini +9 -7
  45. pygpt_net/data/locale/locale.uk.ini +9 -7
  46. pygpt_net/data/locale/locale.zh.ini +9 -7
  47. pygpt_net/item/assistant.py +13 -1
  48. pygpt_net/provider/api/google/__init__.py +46 -28
  49. pygpt_net/provider/api/openai/__init__.py +13 -10
  50. pygpt_net/provider/api/openai/store.py +45 -1
  51. pygpt_net/provider/core/config/patch.py +18 -0
  52. pygpt_net/provider/llms/google.py +4 -0
  53. pygpt_net/ui/dialog/assistant_store.py +213 -203
  54. pygpt_net/ui/layout/chat/input.py +3 -3
  55. pygpt_net/ui/layout/chat/painter.py +63 -4
  56. pygpt_net/ui/widget/draw/painter.py +715 -104
  57. pygpt_net/ui/widget/option/combo.py +5 -1
  58. pygpt_net/ui/widget/textarea/input.py +273 -3
  59. pygpt_net/ui/widget/textarea/web.py +2 -0
  60. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/METADATA +20 -2
  61. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/RECORD +64 -63
  62. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/LICENSE +0 -0
  63. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/WHEEL +0 -0
  64. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.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.01 23:00:00 #
9
+ # Updated Date: 2025.09.04 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
@@ -35,6 +35,8 @@ class Body:
35
35
  <!DOCTYPE html>
36
36
  <html>
37
37
  <head>
38
+ <meta charset="utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1">
38
40
  <style>
39
41
  """
40
42
  _HTML_P1 = """
@@ -44,7 +46,14 @@ class Body:
44
46
  <script type="text/javascript" src="qrc:///js/highlight.min.js"></script>
45
47
  <script type="text/javascript" src="qrc:///js/katex.min.js"></script>
46
48
  <script>
49
+ hljs.configure({
50
+ ignoreUnescapedHTML: true,
51
+ });
47
52
  const DEBUG_MODE = false;
53
+ let bridgeConnected = false;
54
+ let streamHandler;
55
+ let nodeHandler;
56
+ let nodeReplaceHandler;
48
57
  let scrollTimeout = null;
49
58
  let prevScroll = 0;
50
59
  let bridge;
@@ -67,11 +76,34 @@ class Body:
67
76
  let highlightScheduled = false;
68
77
  let pendingHighlightRoot = null;
69
78
  let pendingHighlightMath = false;
79
+ let highlightRAF = 0; // RAF id for highlight batcher
70
80
  let scrollScheduled = false;
71
81
 
82
+ // Auto-follow state: when false, live stream auto-scroll is suppressed
83
+ let autoFollow = true;
84
+ let lastScrollTop = 0;
85
+ // Tracks whether user has performed any scroll-related interaction
86
+ let userInteracted = false;
87
+ const AUTO_FOLLOW_REENABLE_PX = 8; // px from bottom to re-enable auto-follow
88
+
89
+ // FAB thresholds
90
+ const SHOW_DOWN_THRESHOLD_PX = 0; // show "down" only when farther than this from bottom
91
+ let currentFabAction = 'none'; // tracks current FAB state to avoid redundant work
92
+
72
93
  // timers
73
94
  let tipsTimers = [];
74
95
 
96
+ // observers
97
+ let roDoc = null;
98
+ let roContainer = null;
99
+
100
+ // FAB (scroll-to-top/bottom) scheduling
101
+ let scrollFabUpdateScheduled = false;
102
+
103
+ // Streaming micro-batching config
104
+ const STREAM_MAX_PER_FRAME = 64; // defensive upper bound of operations per frame
105
+ const STREAM_EMERGENCY_COALESCE_LEN = 1500; // when queue length is high, coalesce aggressively
106
+
75
107
  // clear previous references
76
108
  function resetEphemeralDomRefs() {
77
109
  domLastCodeBlock = null;
@@ -86,6 +118,26 @@ class Body:
86
118
  tipsTimers = [];
87
119
  }
88
120
 
121
+ function teardown() {
122
+ // Cancel timers/RAF/observers to prevent background CPU usage
123
+ stopTipsTimers();
124
+ try {
125
+ if (streamRAF) {
126
+ cancelAnimationFrame(streamRAF);
127
+ streamRAF = 0;
128
+ }
129
+ if (highlightRAF) {
130
+ cancelAnimationFrame(highlightRAF);
131
+ highlightRAF = 0;
132
+ }
133
+ } catch (e) { /* ignore */ }
134
+ // Clear streaming queue to release memory immediately
135
+ streamQ.length = 0;
136
+ scrollFabUpdateScheduled = false;
137
+ scrollScheduled = false;
138
+ highlightScheduled = false;
139
+ }
140
+
89
141
  history.scrollRestoration = "manual";
90
142
  document.addEventListener('keydown', function(event) {
91
143
  if (event.ctrlKey && event.key === 'f') {
@@ -117,6 +169,60 @@ class Body:
117
169
  els.footer = document.getElementById('_footer_');
118
170
  els.loader = document.getElementById('_loader_');
119
171
  els.tips = document.getElementById('tips');
172
+ // FAB refs
173
+ els.scrollFab = document.getElementById('scrollFab');
174
+ els.scrollFabIcon = document.getElementById('scrollFabIcon');
175
+ }
176
+ function bridgeConnect() {
177
+ // Idempotent connect
178
+ if (!bridge || !bridge.chunk || typeof bridge.chunk.connect !== 'function') return false;
179
+ if (bridgeConnected) return true;
180
+
181
+ // Ensure handler exists and is stable (same identity for disconnect/connect)
182
+ if (!streamHandler) {
183
+ streamHandler = (name, html, chunk, replace, isCode) => {
184
+ appendStream(name, html, chunk, replace, isCode);
185
+ };
186
+ nodeHandler = (html) => {
187
+ appendNode(html);
188
+ };
189
+ nodeReplaceHandler = (html) => {
190
+ replaceNodes(html);
191
+ };
192
+ }
193
+ try {
194
+ bridge.chunk.connect(streamHandler);
195
+ bridge.node.connect(nodeHandler);
196
+ bridge.nodeReplace.connect(nodeReplaceHandler);
197
+ bridgeConnected = true;
198
+ return true;
199
+ } catch (e) {
200
+ log(e);
201
+ return false;
202
+ }
203
+ }
204
+
205
+ function bridgeDisconnect() {
206
+ // Idempotent disconnect
207
+ if (!bridge || !bridge.chunk || typeof bridge.chunk.disconnect !== 'function') return false;
208
+ if (!bridgeConnected) return true;
209
+
210
+ try {
211
+ bridge.chunk.disconnect(streamHandler);
212
+ bridge.node.disconnect(nodeHandler);
213
+ bridge.nodeReplace.disconnect(nodeReplaceHandler);
214
+ } catch (e) { /* ignore */ }
215
+ bridgeConnected = false;
216
+
217
+ // Stop scheduled work and release pending chunks immediately
218
+ try { if (streamRAF) { cancelAnimationFrame(streamRAF); streamRAF = 0; } } catch (e) { /* ignore */ }
219
+ streamQ.length = 0;
220
+ return true;
221
+ }
222
+
223
+ function bridgeReconnect() {
224
+ bridgeDisconnect();
225
+ return bridgeConnect();
120
226
  }
121
227
  function scheduleHighlight(root, withMath = true) {
122
228
  const scope = root && root.nodeType === 1 ? root : document;
@@ -128,13 +234,19 @@ class Body:
128
234
  if (withMath) pendingHighlightMath = true;
129
235
  if (highlightScheduled) return;
130
236
  highlightScheduled = true;
131
- requestAnimationFrame(function() {
237
+ if (highlightRAF) {
238
+ // Ensure we do not queue multiple highlight frames
239
+ cancelAnimationFrame(highlightRAF);
240
+ highlightRAF = 0;
241
+ }
242
+ highlightRAF = requestAnimationFrame(function() {
132
243
  try {
133
244
  highlightCodeInternal(pendingHighlightRoot || document, pendingHighlightMath);
134
245
  } finally {
135
246
  highlightScheduled = false;
136
247
  pendingHighlightRoot = null;
137
248
  pendingHighlightMath = false;
249
+ highlightRAF = 0;
138
250
  }
139
251
  });
140
252
  }
@@ -144,13 +256,14 @@ class Body:
144
256
  });
145
257
  if (withMath) {
146
258
  renderMath(root);
259
+ if (DEBUG_MODE) log("math");
147
260
  }
148
261
  if (DEBUG_MODE) log("execute highlight");
149
- restoreCollapsedCode(root);
150
262
  }
151
263
  function highlightCode(withMath = true, root = null) {
152
264
  if (DEBUG_MODE) log("queue highlight, withMath: " + withMath);
153
- scheduleHighlight(root || document, withMath);
265
+ highlightCodeInternal(root || document, withMath); // prevent blink on fast updates
266
+ // scheduleHighlight(root || document, withMath); // disabled
154
267
  }
155
268
  function hideTips() {
156
269
  if (tips_hidden) return;
@@ -214,28 +327,43 @@ class Body:
214
327
  return distanceToBottom <= marginPx;
215
328
  }
216
329
  function scheduleScroll(live = false) {
330
+ // Skip scheduling live auto-scroll when user disabled follow
331
+ if (live === true && autoFollow !== true) return;
217
332
  if (scrollScheduled) return;
218
333
  scrollScheduled = true;
219
334
  requestAnimationFrame(function() {
220
335
  scrollScheduled = false;
221
336
  scrollToBottom(live);
337
+ // keep FAB state in sync after any programmatic scroll
338
+ scheduleScrollFabUpdate();
222
339
  });
223
340
  }
224
- function scrollToBottom(live = false) {
341
+ // Force immediate scroll to bottom (pre-interaction bootstrap)
342
+ function forceScrollToBottomImmediate() {
343
+ const el = document.scrollingElement || document.documentElement;
344
+ el.scrollTop = el.scrollHeight; // no behavior, no RAF, deterministic
345
+ prevScroll = el.scrollHeight;
346
+ }
347
+ function scrollToBottom(live = false, force = false) {
225
348
  const el = document.scrollingElement || document.documentElement;
226
349
  const marginPx = 450;
227
- let behavior = 'instant';
228
- if (live == true) {
229
- behavior = 'instant';
230
- } else {
231
- behavior = 'smooth';
350
+ const behavior = (live === true) ? 'instant' : 'smooth';
351
+
352
+ // Respect user-follow state during live updates
353
+ if (live === true && autoFollow !== true) {
354
+ // Keep prevScroll consistent for potential consumers
355
+ prevScroll = el.scrollHeight;
356
+ return;
232
357
  }
233
- if (isNearBottom(marginPx) || live == false) {
358
+
359
+ // Allow initial auto-follow before any user interaction
360
+ if ((live === true && userInteracted === false) || isNearBottom(marginPx) || live == false || force) {
234
361
  el.scrollTo({ top: el.scrollHeight, behavior });
235
362
  }
236
363
  prevScroll = el.scrollHeight;
237
364
  }
238
365
  function appendToInput(content) {
366
+ userInteracted = false;
239
367
  const element = els.appendInput || document.getElementById('_append_input_');
240
368
  if (element) {
241
369
  element.insertAdjacentHTML('beforeend', content);
@@ -254,6 +382,7 @@ class Body:
254
382
  return element;
255
383
  }
256
384
  function appendNode(content) {
385
+ userInteracted = false;
257
386
  if (DEBUG_MODE) {
258
387
  log("APPEND NODE: {" + content + "}");
259
388
  }
@@ -265,9 +394,12 @@ class Body:
265
394
  element.insertAdjacentHTML('beforeend', content);
266
395
  highlightCode(true, element);
267
396
  scrollToBottom(false); // without schedule
268
- }
397
+ scheduleScrollFabUpdate();
398
+ }
399
+ clearHighlightCache();
269
400
  }
270
401
  function replaceNodes(content) {
402
+ userInteracted = false;
271
403
  if (DEBUG_MODE) {
272
404
  log("REPLACE NODES: {" + content + "}");
273
405
  }
@@ -279,13 +411,16 @@ class Body:
279
411
  element.replaceChildren();
280
412
  element.insertAdjacentHTML('beforeend', content);
281
413
  highlightCode(true, element);
282
- scrollToBottom(false); // without schedule
414
+ scrollToBottom(false, true); // without schedule
415
+ scheduleScrollFabUpdate();
283
416
  }
417
+ clearHighlightCache();
284
418
  }
285
419
  function clean() {
286
420
  if (DEBUG_MODE) {
287
421
  log("-- CLEAN DOM --");
288
422
  }
423
+ userInteracted = false;
289
424
  const el = els.nodes || document.getElementById('_nodes_');
290
425
  if (el) {
291
426
  el.replaceChildren();
@@ -302,6 +437,9 @@ class Body:
302
437
  }
303
438
  */
304
439
  }
440
+ function clearHighlightCache() {
441
+ //
442
+ }
305
443
  function appendExtra(id, content) {
306
444
  hideTips();
307
445
  prevScroll = 0;
@@ -381,7 +519,10 @@ class Body:
381
519
  if (DEBUG_MODE) {
382
520
  log("STREAM BEGIN");
383
521
  }
522
+ userInteracted = false;
384
523
  clearOutput();
524
+ // Ensure initial auto-follow baseline before any chunks overflow
525
+ forceScrollToBottomImmediate();
385
526
  scheduleScroll();
386
527
  }
387
528
  function endStream() {
@@ -389,8 +530,10 @@ class Body:
389
530
  log("STREAM END");
390
531
  }
391
532
  clearOutput();
533
+ bridgeReconnect();
392
534
  }
393
535
  function enqueueStream(name_header, content, chunk, replace = false, is_code_block = false) {
536
+ // Push incoming chunk; scheduling is done with RAF to batch DOM ops
394
537
  streamQ.push({name_header, content, chunk, replace, is_code_block});
395
538
  if (!streamRAF) {
396
539
  streamRAF = requestAnimationFrame(drainStream);
@@ -398,16 +541,53 @@ class Body:
398
541
  }
399
542
  function drainStream() {
400
543
  streamRAF = 0;
401
- while (streamQ.length) {
402
- const {name_header, content, chunk, replace, is_code_block} = streamQ.shift();
403
- appendStream(name_header, content, chunk, replace, is_code_block);
544
+ let processed = 0;
545
+
546
+ // Emergency coalescing if queue grows too large
547
+ const shouldAggressiveCoalesce = streamQ.length >= STREAM_EMERGENCY_COALESCE_LEN;
548
+
549
+ while (streamQ.length && processed < STREAM_MAX_PER_FRAME) {
550
+ let {name_header, content, chunk, replace, is_code_block} = streamQ.shift();
551
+
552
+ // Coalesce contiguous simple appends to reduce DOM churn
553
+ if (!replace && !content && (chunk && chunk.length > 0)) {
554
+ // Collect chunks into an array to avoid O(n^2) string concatenation
555
+ const chunks = [chunk];
556
+ while (streamQ.length) {
557
+ const next = streamQ[0];
558
+ if (!next.replace && !next.content && next.is_code_block === is_code_block && next.name_header === name_header) {
559
+ chunks.push(next.chunk);
560
+ streamQ.shift();
561
+ if (!shouldAggressiveCoalesce) {
562
+ // Light coalescing per frame is enough under normal conditions
563
+ break;
564
+ }
565
+ } else {
566
+ break;
567
+ }
568
+ }
569
+ chunk = chunks.join('');
570
+ }
571
+
572
+ applyStream(name_header, content, chunk, replace, is_code_block);
573
+ processed++;
574
+ }
575
+
576
+ // If there are remaining items re-schedule next frame
577
+ if (streamQ.length) {
578
+ streamRAF = requestAnimationFrame(drainStream);
404
579
  }
405
580
  }
581
+ // Public API: enqueue and process in the next animation frame
406
582
  function appendStream(name_header, content, chunk, replace = false, is_code_block = false) {
583
+ enqueueStream(name_header, content, chunk, replace, is_code_block);
584
+ }
585
+ // Internal: performs actual DOM updates for a single merged chunk
586
+ function applyStream(name_header, content, chunk, replace = false, is_code_block = false) {
407
587
  dropIfDetached(); // clear references to detached elements
408
588
  hideTips();
409
589
  if (DEBUG_MODE) {
410
- log("APPEND CHUNK: {" + chunk + "}, CONTENT: {"+content+"}, replace: " + replace + ", is_code_block: " + is_code_block);
590
+ log("APPLY CHUNK: {" + chunk + "}, CONTENT: {"+content+"}, replace: " + replace + ", is_code_block: " + is_code_block);
411
591
  }
412
592
  const element = getStreamContainer();
413
593
  let msg;
@@ -432,7 +612,9 @@ class Body:
432
612
  msg = box.querySelector('.msg');
433
613
  }
434
614
  if (msg) {
435
- if (replace) {
615
+ if (replace) {
616
+ domLastCodeBlock = null;
617
+ domLastParagraphBlock = null;
436
618
  msg.replaceChildren();
437
619
  if (content) {
438
620
  msg.insertAdjacentHTML('afterbegin', content);
@@ -442,17 +624,23 @@ class Body:
442
624
  doMath = false;
443
625
  }
444
626
  highlightCode(doMath, msg);
445
- domLastCodeBlock = null;
446
- domLastParagraphBlock = null;
447
627
  } else {
448
628
  if (is_code_block) {
449
- let lastCodeBlock;
450
- if (domLastCodeBlock) {
451
- lastCodeBlock = domLastCodeBlock;
452
- } else {
453
- const msgBlocks = msg.querySelectorAll('pre');
454
- if (msgBlocks.length > 0) {
455
- lastCodeBlock = msgBlocks[msgBlocks.length - 1].querySelector('code');
629
+ // Try to reuse cached last code block; fallback to cheap lastElementChild check
630
+ let lastCodeBlock = domLastCodeBlock;
631
+ if (!lastCodeBlock || !msg.contains(lastCodeBlock)) {
632
+ const last = msg.lastElementChild;
633
+ if (last && last.tagName === 'PRE') {
634
+ const codeEl = last.querySelector('code');
635
+ if (codeEl) {
636
+ lastCodeBlock = codeEl;
637
+ }
638
+ } else {
639
+ // Fallback scan only when necessary
640
+ const codes = msg.querySelectorAll('pre code');
641
+ if (codes.length > 0) {
642
+ lastCodeBlock = codes[codes.length - 1];
643
+ }
456
644
  }
457
645
  }
458
646
  if (lastCodeBlock) {
@@ -481,7 +669,12 @@ class Body:
481
669
  }
482
670
  }
483
671
  }
484
- scheduleScroll(true);
672
+ // Initial auto-follow until first user interaction
673
+ if (userInteracted === false) {
674
+ forceScrollToBottomImmediate();
675
+ } else {
676
+ scheduleScroll(true);
677
+ }
485
678
  }
486
679
  function nextStream() {
487
680
  hideTips();
@@ -581,10 +774,12 @@ class Body:
581
774
  const outputEl = element.querySelector('.tool-output');
582
775
  if (outputEl) {
583
776
  const contentEl = outputEl.querySelector('.content');
584
- if (contentEl.style.display === 'none') {
585
- contentEl.style.display = 'block';
586
- } else {
587
- contentEl.style.display = 'none';
777
+ if (contentEl) {
778
+ if (contentEl.style.display === 'none') {
779
+ contentEl.style.display = 'block';
780
+ } else {
781
+ contentEl.style.display = 'none';
782
+ }
588
783
  }
589
784
  const toggleEl = outputEl.querySelector('.toggle-cmd-output img');
590
785
  if (toggleEl) {
@@ -631,10 +826,12 @@ class Body:
631
826
  clearStreamBefore();
632
827
  domLastCodeBlock = null;
633
828
  domLastParagraphBlock = null;
829
+ domOutputStream = null; // release handle to allow GC on old container subtree
634
830
  const element = els.appendOutput || document.getElementById('_append_output_');
635
831
  if (element) {
636
832
  element.replaceChildren();
637
833
  }
834
+ clearHighlightCache();
638
835
  }
639
836
  function clearLive() {
640
837
  const element = els.appendLive || document.getElementById('_append_live_');
@@ -707,7 +904,7 @@ class Body:
707
904
  const source = wrapper.querySelector('code');
708
905
  if (source && collapsed_idx.includes(index)) {
709
906
  source.style.display = 'none';
710
- const collapseBtn = wrapper.querySelector('.code-header-collapse');
907
+ const collapseBtn = wrapper.querySelector('code-header-collapse');
711
908
  if (collapseBtn) {
712
909
  const collapseSpan = collapseBtn.querySelector('span');
713
910
  if (collapseSpan) {
@@ -774,12 +971,122 @@ class Body:
774
971
  el.classList.add('hidden');
775
972
  }
776
973
  }
974
+
975
+ // ---------- scroll bottom ----------
976
+ function hasVerticalScroll() {
977
+ const el = document.scrollingElement || document.documentElement;
978
+ return (el.scrollHeight - el.clientHeight) > 1;
979
+ }
980
+ function distanceToBottomPx() {
981
+ const el = document.scrollingElement || document.documentElement;
982
+ return el.scrollHeight - el.clientHeight - el.scrollTop;
983
+ }
984
+ function isAtBottom(thresholdPx = 2) {
985
+ return distanceToBottomPx() <= thresholdPx;
986
+ }
987
+ function isLoaderHidden() {
988
+ const el = els.loader || document.getElementById('_loader_');
989
+ // If loader element is missing treat as hidden to avoid blocking FAB unnecessarily
990
+ return !el || el.classList.contains('hidden');
991
+ }
992
+ function updateScrollFab() {
993
+ const btn = els.scrollFab || document.getElementById('scrollFab');
994
+ const icon = els.scrollFabIcon || document.getElementById('scrollFabIcon');
995
+ if (!btn || !icon) return;
996
+
997
+ const hasScroll = hasVerticalScroll();
998
+ if (!hasScroll) {
999
+ btn.classList.remove('visible');
1000
+ currentFabAction = 'none';
1001
+ return;
1002
+ }
1003
+
1004
+ const atBottom = isAtBottom();
1005
+ const dist = distanceToBottomPx();
1006
+ const loaderHidden = isLoaderHidden();
1007
+
1008
+ // Determine desired action and visibility based on requirements:
1009
+ // - Show "down" only when at least SHOW_DOWN_THRESHOLD_PX away from bottom.
1010
+ // - Show "up" only when loader-global has the 'hidden' class.
1011
+ // - Otherwise hide the FAB to prevent overlap and noise.
1012
+ let action = 'none'; // 'up' | 'down' | 'none'
1013
+ if (atBottom) {
1014
+ if (loaderHidden) {
1015
+ action = 'up';
1016
+ } else {
1017
+ action = 'none';
1018
+ }
1019
+ } else {
1020
+ if (dist >= SHOW_DOWN_THRESHOLD_PX) {
1021
+ action = 'down';
1022
+ } else {
1023
+ action = 'none';
1024
+ }
1025
+ }
1026
+
1027
+ if (action === 'none') {
1028
+ btn.classList.remove('visible');
1029
+ currentFabAction = 'none';
1030
+ return;
1031
+ }
1032
+
1033
+ // Update icon and semantics only if changed to avoid redundant 'load' events
1034
+ if (action !== currentFabAction) {
1035
+ if (action === 'up') {
1036
+ if (icon.src !== ICON_COLLAPSE) icon.src = ICON_COLLAPSE;
1037
+ btn.title = "Go to top";
1038
+ } else {
1039
+ if (icon.src !== ICON_EXPAND) icon.src = ICON_EXPAND;
1040
+ btn.title = "Go to bottom";
1041
+ }
1042
+ btn.setAttribute('aria-label', btn.title);
1043
+ currentFabAction = action;
1044
+ }
1045
+
1046
+ // Finally show
1047
+ btn.classList.add('visible');
1048
+ }
1049
+ function scheduleScrollFabUpdate() {
1050
+ if (scrollFabUpdateScheduled) return;
1051
+ scrollFabUpdateScheduled = true;
1052
+ requestAnimationFrame(function() {
1053
+ scrollFabUpdateScheduled = false;
1054
+ updateScrollFab();
1055
+ });
1056
+ }
1057
+ function scrollToTopUser() {
1058
+ // Explicit user-driven scroll disables auto-follow
1059
+ userInteracted = true;
1060
+ autoFollow = false;
1061
+ try {
1062
+ const el = document.scrollingElement || document.documentElement;
1063
+ el.scrollTo({ top: 0, behavior: 'smooth' });
1064
+ } catch (e) {
1065
+ // Fallback in environments without smooth scrolling support
1066
+ const el = document.scrollingElement || document.documentElement;
1067
+ el.scrollTop = 0;
1068
+ }
1069
+ scheduleScrollFabUpdate();
1070
+ }
1071
+ function scrollToBottomUser() {
1072
+ // User action to go to bottom re-enables auto-follow
1073
+ userInteracted = true;
1074
+ autoFollow = true;
1075
+ try {
1076
+ const el = document.scrollingElement || document.documentElement;
1077
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
1078
+ } catch (e) {
1079
+ const el = document.scrollingElement || document.documentElement;
1080
+ el.scrollTop = el.scrollHeight;
1081
+ }
1082
+ scheduleScrollFabUpdate();
1083
+ }
1084
+ // ---------- end of scroll bottom ----------
1085
+
777
1086
  document.addEventListener('DOMContentLoaded', function() {
778
1087
  new QWebChannel(qt.webChannelTransport, function (channel) {
779
1088
  bridge = channel.objects.bridge;
780
- bridge.chunk.connect((name, html, chunk, replace, isCode) => {
781
- appendStream(name, html, chunk, replace, isCode);
782
- });
1089
+ bridgeConnect();
783
1090
  if (bridge.js_ready) bridge.js_ready();
784
1091
  });
785
1092
  initDomRefs();
@@ -808,6 +1115,48 @@ class Body:
808
1115
  removeClassFromMsg(id, 'msg-highlight');
809
1116
  }
810
1117
  });
1118
+ // Wheel up disables auto-follow immediately (works even at absolute bottom)
1119
+ document.addEventListener('wheel', function(ev) {
1120
+ userInteracted = true;
1121
+ if (ev.deltaY < 0) {
1122
+ autoFollow = false;
1123
+ }
1124
+ }, { passive: true });
1125
+
1126
+ // Track scroll direction and restore auto-follow when user returns to bottom
1127
+ window.addEventListener('scroll', function() {
1128
+ const el = document.scrollingElement || document.documentElement;
1129
+ const top = el.scrollTop;
1130
+
1131
+ // User scrolled up (ignore tiny jitter)
1132
+ if (top + 1 < lastScrollTop) {
1133
+ autoFollow = false;
1134
+ } else if (!autoFollow) {
1135
+ const distanceToBottom = el.scrollHeight - el.clientHeight - top;
1136
+ if (distanceToBottom <= AUTO_FOLLOW_REENABLE_PX) {
1137
+ autoFollow = true;
1138
+ }
1139
+ }
1140
+ lastScrollTop = top;
1141
+ }, { passive: true });
1142
+
1143
+ // Scroll-to-top/bottom FAB wiring
1144
+ if (els.scrollFab) {
1145
+ els.scrollFab.addEventListener('click', function(ev) {
1146
+ ev.preventDefault();
1147
+ if (isAtBottom()) {
1148
+ scrollToTopUser();
1149
+ } else {
1150
+ scrollToBottomUser();
1151
+ }
1152
+ }, { passive: false });
1153
+ }
1154
+ window.addEventListener('scroll', scheduleScrollFabUpdate, { passive: true });
1155
+ window.addEventListener('resize', scheduleScrollFabUpdate, { passive: true });
1156
+
1157
+ // Initial state
1158
+ scheduleScrollFabUpdate();
1159
+
811
1160
  container.addEventListener('click', function(event) {
812
1161
  const copyButton = event.target.closest('.code-header-copy');
813
1162
  if (copyButton) {
@@ -882,6 +1231,10 @@ class Body:
882
1231
  }
883
1232
  }
884
1233
  });
1234
+
1235
+ // Cleanup on page lifecycle changes
1236
+ window.addEventListener('pagehide', teardown, { passive: true });
1237
+ window.addEventListener('beforeunload', teardown, { passive: true });
885
1238
  });
886
1239
  setTimeout(cycleTips, 10000); // after 10 seconds
887
1240
  </script>
@@ -900,6 +1253,9 @@ class Body:
900
1253
  </div>
901
1254
  <div id="tips" class="tips"></div>
902
1255
  </div>
1256
+ <button id="scrollFab" class="scroll-fab" type="button" title="Go to top" aria-label="Go to top">
1257
+ <img id="scrollFabIcon" src="" alt="Scroll">
1258
+ </button>
903
1259
  </body>
904
1260
  </html>
905
1261
  """
@@ -950,6 +1306,51 @@ class Body:
950
1306
  }
951
1307
  """
952
1308
 
1309
+ # CSS for the scroll-to-top/bottom
1310
+ _SCROLL_FAB_CSS = """
1311
+ #scrollFab.scroll-fab {
1312
+ position: fixed;
1313
+ //left: 50%;
1314
+ right: 16px;
1315
+ bottom: 16px;
1316
+ width: 40px;
1317
+ height: 40px;
1318
+ border: none;
1319
+ background: transparent;
1320
+ padding: 0;
1321
+ margin: 0;
1322
+ display: none;
1323
+ align-items: center;
1324
+ justify-content: center;
1325
+ z-index: 2147483647;
1326
+ cursor: pointer;
1327
+ opacity: .65;
1328
+ transition: opacity .2s ease, transform .2s ease;
1329
+ //transform: translate(-50%, 0);
1330
+ will-change: transform, opacity;
1331
+ pointer-events: auto;
1332
+ -webkit-tap-highlight-color: transparent;
1333
+ }
1334
+ #scrollFab.scroll-fab.visible {
1335
+ display: inline-flex;
1336
+ }
1337
+ #scrollFab.scroll-fab:hover {
1338
+ opacity: 1;
1339
+ //transform: translate(-50%, -1px);
1340
+ }
1341
+ #scrollFab.scroll-fab img {
1342
+ width: 100%;
1343
+ height: 100%;
1344
+ display: block;
1345
+ pointer-events: none;
1346
+ }
1347
+ @media (prefers-reduced-motion: reduce) {
1348
+ #scrollFab.scroll-fab {
1349
+ transition: none;
1350
+ }
1351
+ }
1352
+ """
1353
+
953
1354
  def __init__(self, window=None):
954
1355
  """
955
1356
  HTML Body
@@ -997,11 +1398,43 @@ class Body:
997
1398
  cfg = self.window.core.config
998
1399
  fonts_path = os.path.join(cfg.get_app_path(), "data", "fonts").replace("\\", "/")
999
1400
  syntax_style = self.window.core.config.get("render.code_syntax") or "default"
1401
+ perf_css = """
1402
+ #container, #_nodes_, #_append_output_, #_append_output_before_ {
1403
+ contain: layout paint;
1404
+ overscroll-behavior: contain;
1405
+ }
1406
+ .msg-box {
1407
+ contain: layout paint style;
1408
+ contain-intrinsic-size: 1px 600px;
1409
+ }
1410
+ .msg-box:not(:last-child) {
1411
+ content-visibility: auto;
1412
+ }
1413
+ #container,
1414
+ #_nodes_,
1415
+ #_append_output_,
1416
+ #_append_output_before_ {
1417
+ backface-visibility: hidden;
1418
+ transform: translateZ(0);
1419
+ }
1420
+ .msg-box {
1421
+ box-shadow: none !important;
1422
+ filter: none !important;
1423
+ }
1424
+ .msg {
1425
+ text-rendering: optimizeSpeed;
1426
+ }
1427
+ """
1000
1428
 
1001
1429
  theme_css = self.window.controller.theme.markdown.get_web_css().replace('%fonts%', fonts_path)
1002
- parts = [self._SPINNER, theme_css,
1003
- "pre { color: #fff; }" if syntax_style in self._syntax_dark else "pre { color: #000; }",
1004
- self.highlight.get_style_defs()]
1430
+ parts = [
1431
+ self._SPINNER,
1432
+ theme_css,
1433
+ "pre { color: #fff; }" if syntax_style in self._syntax_dark else "pre { color: #000; }",
1434
+ self.highlight.get_style_defs(),
1435
+ perf_css,
1436
+ self._SCROLL_FAB_CSS, # keep FAB styles last to ensure precedence
1437
+ ]
1005
1438
  return "\n".join(parts)
1006
1439
 
1007
1440
  def prepare_action_icons(self, ctx: CtxItem) -> str:
@@ -1093,7 +1526,7 @@ class Body:
1093
1526
  <video class="video-player" controls>
1094
1527
  <source src="{path}" type="video/{ext[1:]}">
1095
1528
  </video>
1096
- <p><a href="{url}" class="title">{elide_filename(basename)}</a></p>
1529
+ <p><a href="bridge://play_video/{url}" class="title">{elide_filename(basename)}</a></p>
1097
1530
  </div>
1098
1531
  '''
1099
1532
  return f'<div class="extra-src-img-box" title="{url}"><div class="img-outer"><div class="img-wrapper"><a href="{url}"><img src="{path}" class="image"></a></div><a href="{url}" class="title">{elide_filename(basename)}</a></div></div><br/>'
@@ -1239,6 +1672,9 @@ class Body:
1239
1672
  def get_html(self, pid: int) -> str:
1240
1673
  """
1241
1674
  Build webview HTML code (fast path, minimal allocations)
1675
+
1676
+ :param pid: process ID
1677
+ :return: HTML code
1242
1678
  """
1243
1679
  cfg_get = self.window.core.config.get
1244
1680
  style = cfg_get("theme.style", "blocks")
@@ -1253,11 +1689,18 @@ class Body:
1253
1689
  styles_css = self.prepare_styles()
1254
1690
  tips_json = self.get_all_tips()
1255
1691
 
1692
+ # Build file:// paths for FAB icons
1693
+ app_path = self.window.core.config.get_app_path().replace("\\", "/")
1694
+ expand_path = os.path.join(app_path, "data", "icons", "expand.svg").replace("\\", "/")
1695
+ collapse_path = os.path.join(app_path, "data", "icons", "collapse.svg").replace("\\", "/")
1696
+ icons_js = f';const ICON_EXPAND="file://{expand_path}";const ICON_COLLAPSE="file://{collapse_path}";'
1697
+
1256
1698
  return ''.join((
1257
1699
  self._HTML_P0,
1258
1700
  styles_css,
1259
1701
  self._HTML_P1,
1260
1702
  str(pid),
1703
+ icons_js,
1261
1704
  self._HTML_P2,
1262
1705
  tips_json,
1263
1706
  self._HTML_P3,