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