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.
- pygpt_net/CHANGELOG.txt +18 -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 +483 -40
- pygpt_net/core/render/web/pid.py +39 -24
- pygpt_net/core/render/web/renderer.py +142 -36
- 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 +4 -3
- pygpt_net/data/css/web-chatgpt.css +4 -2
- pygpt_net/data/css/web-chatgpt_wide.css +4 -2
- 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 +18 -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.36.dist-info}/METADATA +20 -2
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/RECORD +64 -63
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.36.dist-info}/WHEEL +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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("
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
|
585
|
-
contentEl.style.display
|
|
586
|
-
|
|
587
|
-
|
|
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('
|
|
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
|
-
|
|
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 = [
|
|
1003
|
-
|
|
1004
|
-
|
|
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,
|