pygpt-net 2.6.43__py3-none-any.whl → 2.6.44__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/data/js/app.js CHANGED
@@ -553,8 +553,9 @@
553
553
  growth: 2.6, // Ramp step quickly if adaptive is enabled
554
554
  minInterval: 500, // Minimum time between snapshots (ms) to avoid churn
555
555
  softLatency: 1200, // Force snapshot only after a noticeable idle (ms)
556
- minLinesForHL: 50, minCharsForHL: 5000,
557
- promoteMinInterval: 300, promoteMaxLatency: 800, promoteMinLines: 50,
556
+ minLinesForHL: Utils.g('PROFILE_CODE_HL_N_LINE', 25),
557
+ minCharsForHL: Utils.g('PROFILE_CODE_HL_N_CHARS', 5000),
558
+ promoteMinInterval: 300, promoteMaxLatency: 800, promoteMinLines: Utils.g('PROFILE_CODE_HL_N_LINE', 25),
558
559
  adaptiveStep: Utils.g('PROFILE_CODE_ADAPTIVE_STEP', true),
559
560
  // Hard switches to plain streaming (no incremental hljs, minimal DOM churn)
560
561
  stopAfterLines: Utils.g('PROFILE_CODE_STOP_HL_AFTER_LINES', 300), // Stop incremental hljs after this many lines
@@ -736,175 +737,159 @@
736
737
  }
737
738
  }
738
739
 
739
- // ==========================================================================
740
- // 2) Code scroll state manager
741
- // ==========================================================================
742
-
743
- class CodeScrollState {
744
- constructor(cfg, raf) {
745
- this.cfg = cfg;
746
- this.raf = raf;
747
- this.map = new WeakMap();
748
- this.rafMap = new WeakMap();
749
- this.rafIds = new Set(); // legacy
750
- this.rafKeyMap = new WeakMap();
751
- }
752
- // Get or create per-code element state.
753
- state(el) {
754
- let s = this.map.get(el);
755
- if (!s) { s = { autoFollow: false, lastScrollTop: 0, userInteracted: false, freezeUntil: 0 }; this.map.set(el, s); }
756
- return s;
757
- }
758
- // Check if code block is already finalized (not streaming).
759
- isFinalizedCode(el) {
760
- if (!el || el.tagName !== 'CODE') return false;
761
- if (el.dataset && el.dataset._active_stream === '1') return false;
762
- const highlighted = (el.getAttribute('data-highlighted') === 'yes') || el.classList.contains('hljs');
763
- return highlighted;
764
- }
765
- // Is element scrolled close to the bottom by a margin?
766
- isNearBottomEl(el, margin = 100) {
767
- if (!el) return true;
768
- const distance = el.scrollHeight - el.clientHeight - el.scrollTop;
769
- return distance <= margin;
770
- }
771
- // Scroll code element to the bottom respecting interaction state.
772
- scrollToBottom(el, live = false, force = false) {
773
- if (!el || !el.isConnected) return;
774
- if (!force && this.isFinalizedCode(el)) return;
775
-
776
- const st = this.state(el);
777
- const now = Utils.now();
778
- if (!force && st.freezeUntil && now < st.freezeUntil) return;
779
-
780
- const distNow = el.scrollHeight - el.clientHeight - el.scrollTop;
781
- if (!force && distNow <= 1) { st.lastScrollTop = el.scrollTop; return; }
782
-
783
- const marginPx = live ? 96 : this.cfg.CODE_SCROLL.NEAR_MARGIN_PX;
784
- const behavior = 'instant';
785
-
786
- if (!force) {
787
- if (live && st.autoFollow !== true) return;
788
- if (!live && !(st.autoFollow === true || this.isNearBottomEl(el, marginPx) || !st.userInteracted)) return;
740
+ // ==========================================================================
741
+ // 2) Code scroll state manager
742
+ // ==========================================================================
743
+
744
+ class CodeScrollState {
745
+ constructor(cfg, raf) {
746
+ this.cfg = cfg;
747
+ this.raf = raf;
748
+ this.map = new WeakMap();
749
+ this.rafMap = new WeakMap();
750
+ this.rafIds = new Set(); // legacy
751
+ this.rafKeyMap = new WeakMap();
752
+ }
753
+ // Get or create per-code element state.
754
+ state(el) {
755
+ let s = this.map.get(el);
756
+ if (!s) { s = { autoFollow: false, lastScrollTop: 0, userInteracted: false, freezeUntil: 0 }; this.map.set(el, s); }
757
+ return s;
789
758
  }
759
+ // Check if code block is already finalized (not streaming).
760
+ isFinalizedCode(el) {
761
+ if (!el || el.tagName !== 'CODE') return false;
762
+ if (el.dataset && el.dataset._active_stream === '1') return false;
763
+ const highlighted = (el.getAttribute('data-highlighted') === 'yes') || el.classList.contains('hljs');
764
+ return highlighted;
765
+ }
766
+ // Is element scrolled close to the bottom by a margin?
767
+ isNearBottomEl(el, margin = 100) {
768
+ if (!el) return true;
769
+ const distance = el.scrollHeight - el.clientHeight - el.scrollTop;
770
+ return distance <= margin;
771
+ }
772
+ // Scroll code element to the bottom respecting interaction state.
773
+ scrollToBottom(el, live = false, force = false) {
774
+ if (!el || !el.isConnected) return;
775
+ if (!force && this.isFinalizedCode(el)) return;
790
776
 
791
- try { el.scrollTo({ top: el.scrollHeight, behavior }); } catch (_) { el.scrollTop = el.scrollHeight; }
792
- st.lastScrollTop = el.scrollTop;
793
- }
794
- // Schedule bottom scroll in rAF (coalesces multiple calls).
795
- scheduleScroll(el, live = false, force = false) {
796
- if (!el || !el.isConnected) return;
797
- if (!force && this.isFinalizedCode(el)) return;
798
- if (this.rafMap.get(el)) return;
799
- this.rafMap.set(el, true);
800
-
801
- let key = this.rafKeyMap.get(el);
802
- if (!key) { key = { t: 'codeScroll', el }; this.rafKeyMap.set(el, key); }
803
-
804
- this.raf.schedule(key, () => {
805
- this.rafMap.delete(el);
806
- this.scrollToBottom(el, live, force);
807
- }, 'CodeScroll', 0);
808
- }
809
- // Attach scroll/wheel/touch handlers to manage auto-follow state.
810
- attachHandlers(codeEl) {
811
- if (!codeEl || codeEl.dataset.csListeners === '1') return;
812
- codeEl.dataset.csListeners = '1';
813
- const st = this.state(codeEl);
814
-
815
- const onScroll = (ev) => {
816
- const top = codeEl.scrollTop;
817
- const isUser = !!(ev && ev.isTrusted === true);
777
+ const st = this.state(el);
818
778
  const now = Utils.now();
779
+ if (!force && st.freezeUntil && now < st.freezeUntil) return;
819
780
 
820
- if (this.isFinalizedCode(codeEl)) {
821
- if (isUser) st.userInteracted = true;
822
- st.autoFollow = false;
823
- st.lastScrollTop = top;
824
- return;
781
+ const distNow = el.scrollHeight - el.clientHeight - el.scrollTop;
782
+ if (!force && distNow <= 1) { st.lastScrollTop = el.scrollTop; return; }
783
+
784
+ const marginPx = live ? 96 : this.cfg.CODE_SCROLL.NEAR_MARGIN_PX;
785
+ const behavior = 'instant';
786
+
787
+ if (!force) {
788
+ if (live && st.autoFollow !== true) return;
789
+ if (!live && !(st.autoFollow === true || this.isNearBottomEl(el, marginPx) || !st.userInteracted)) return;
825
790
  }
826
791
 
827
- if (isUser) {
828
- if (top + 1 < st.lastScrollTop) {
829
- st.autoFollow = false; st.userInteracted = true; st.freezeUntil = now + 1000;
830
- } else if (this.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.AUTO_FOLLOW_REENABLE_PX)) {
831
- st.autoFollow = true;
792
+ try { el.scrollTo({ top: el.scrollHeight, behavior }); } catch (_) { el.scrollTop = el.scrollHeight; }
793
+ st.lastScrollTop = el.scrollTop;
794
+ }
795
+ // Schedule bottom scroll in rAF (coalesces multiple calls).
796
+ scheduleScroll(el, live = false, force = false) {
797
+ if (!el || !el.isConnected) return;
798
+ if (!force && this.isFinalizedCode(el)) return;
799
+ if (this.rafMap.get(el)) return;
800
+ this.rafMap.set(el, true);
801
+
802
+ let key = this.rafKeyMap.get(el);
803
+ if (!key) { key = { t: 'codeScroll', el }; this.rafKeyMap.set(el, key); }
804
+
805
+ this.raf.schedule(key, () => {
806
+ this.rafMap.delete(el);
807
+ this.scrollToBottom(el, live, force);
808
+ }, 'CodeScroll', 0);
809
+ }
810
+ // Attach scroll/wheel/touch handlers to manage auto-follow state.
811
+ attachHandlers(codeEl) {
812
+ if (!codeEl || codeEl.dataset.csListeners === '1') return;
813
+ codeEl.dataset.csListeners = '1';
814
+ const st = this.state(codeEl);
815
+
816
+ const onScroll = (ev) => {
817
+ const top = codeEl.scrollTop;
818
+ const isUser = !!(ev && ev.isTrusted === true);
819
+ const now = Utils.now();
820
+
821
+ if (this.isFinalizedCode(codeEl)) {
822
+ if (isUser) st.userInteracted = true;
823
+ st.autoFollow = false;
824
+ st.lastScrollTop = top;
825
+ return;
832
826
  }
833
- } else {
834
- if (this.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.AUTO_FOLLOW_REENABLE_PX)) st.autoFollow = true;
835
- }
836
- st.lastScrollTop = top;
837
- };
838
827
 
839
- const onWheel = (ev) => {
840
- st.userInteracted = true;
841
- const now = Utils.now();
828
+ if (isUser) {
829
+ if (top + 1 < st.lastScrollTop) {
830
+ st.autoFollow = false; st.userInteracted = true; st.freezeUntil = now + 1000;
831
+ } else if (this.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.AUTO_FOLLOW_REENABLE_PX)) {
832
+ st.autoFollow = true;
833
+ }
834
+ } else {
835
+ if (this.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.AUTO_FOLLOW_REENABLE_PX)) st.autoFollow = true;
836
+ }
837
+ st.lastScrollTop = top;
838
+ };
842
839
 
843
- if (this.isFinalizedCode(codeEl)) { st.autoFollow = false; return; }
840
+ const onWheel = (ev) => {
841
+ st.userInteracted = true;
842
+ const now = Utils.now();
844
843
 
845
- if (ev.deltaY < 0) { st.autoFollow = false; st.freezeUntil = now + 1000; }
846
- else if (this.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.AUTO_FOLLOW_REENABLE_PX)) { st.autoFollow = true; }
847
- };
844
+ if (this.isFinalizedCode(codeEl)) { st.autoFollow = false; return; }
848
845
 
849
- codeEl.addEventListener('scroll', onScroll, { passive: true });
850
- codeEl.addEventListener('wheel', onWheel, { passive: true });
851
- codeEl.addEventListener('touchstart', function () { st.userInteracted = true; }, { passive: true });
852
- }
853
- // Ensure code starts scrolled to bottom once after insert.
854
- initCodeBottomOnce(codeEl) {
855
- if (!codeEl || !codeEl.isConnected) return;
856
- if (codeEl.dataset && codeEl.dataset._active_stream === '1') return;
857
- if (codeEl.dataset && codeEl.dataset.csInitBtm === '1') return;
858
- const wrapper = codeEl.closest('.code-wrapper');
859
- if (!wrapper) return;
846
+ if (ev.deltaY < 0) { st.autoFollow = false; st.freezeUntil = now + 1000; }
847
+ else if (this.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.AUTO_FOLLOW_REENABLE_PX)) { st.autoFollow = true; }
848
+ };
860
849
 
861
- codeEl.dataset.csInitBtm = '1';
862
- const key = { t: 'codeInitBottom', el: codeEl };
863
- this.raf.schedule(key, () => {
864
- if (!codeEl.isConnected) return;
865
- try {
866
- this.scrollToBottom(codeEl, false, true);
867
- const st = this.state(codeEl);
868
- st.autoFollow = false;
869
- st.lastScrollTop = codeEl.scrollTop;
870
- } catch (_) {}
871
- }, 'CodeScroll', 0);
872
- }
873
- // Attach handlers to all bot code blocks under root (or document).
874
- initScrollableBlocks(root) {
875
- const scope = root || document;
876
- let nodes = [];
877
- if (scope.nodeType === 1 && scope.closest && scope.closest('.msg-box.msg-bot')) {
878
- nodes = scope.querySelectorAll('pre code');
879
- } else {
880
- nodes = document.querySelectorAll('.msg-box.msg-bot pre code');
850
+ codeEl.addEventListener('scroll', onScroll, { passive: true });
851
+ codeEl.addEventListener('wheel', onWheel, { passive: true });
852
+ codeEl.addEventListener('touchstart', function () { st.userInteracted = true; }, { passive: true });
881
853
  }
882
- if (!nodes.length) return;
883
- nodes.forEach((code) => {
884
- this.attachHandlers(code);
885
- if (code.dataset._active_stream === '1') {
886
- const st = this.state(code);
887
- st.autoFollow = true;
888
- this.scheduleScroll(code, true, false);
854
+ // Attach handlers to all bot code blocks under root (or document).
855
+ // IMPORTANT: We intentionally do NOT auto-scroll finalized/static code blocks to the bottom.
856
+ // Only actively streaming code blocks (data-_active_stream="1") are auto-followed live.
857
+ initScrollableBlocks(root) {
858
+ const scope = root || document;
859
+ let nodes = [];
860
+ if (scope.nodeType === 1 && scope.closest && scope.closest('.msg-box.msg-bot')) {
861
+ nodes = scope.querySelectorAll('pre code');
889
862
  } else {
890
- this.initCodeBottomOnce(code);
863
+ nodes = document.querySelectorAll('.msg-box.msg-bot pre code');
891
864
  }
892
- });
893
- }
894
- // Transfer stored scroll state between elements (after replace).
895
- transfer(oldEl, newEl) {
896
- if (!oldEl || !newEl || oldEl === newEl) return;
897
- const oldState = this.map.get(oldEl);
898
- if (oldState) this.map.set(newEl, { ...oldState });
899
- this.attachHandlers(newEl);
900
- }
901
- // Cancel any scheduled scroll tasks for code blocks.
902
- cancelAllScrolls() {
903
- try { this.raf.cancelGroup('CodeScroll'); } catch (_) {}
904
- this.rafMap = new WeakMap();
905
- this.rafIds.clear();
865
+ if (!nodes.length) return;
866
+
867
+ nodes.forEach((code) => {
868
+ this.attachHandlers(code);
869
+ // Live streaming blocks: enable auto-follow and keep them glued to bottom.
870
+ if (code.dataset._active_stream === '1') {
871
+ const st = this.state(code);
872
+ st.autoFollow = true;
873
+ this.scheduleScroll(code, true, false);
874
+ }
875
+ // Finalized/static blocks: do nothing (no initial scroll-to-bottom).
876
+ // This avoids surprising jumps when static content is rendered.
877
+ });
878
+ }
879
+ // Transfer stored scroll state between elements (after replace).
880
+ transfer(oldEl, newEl) {
881
+ if (!oldEl || !newEl || oldEl === newEl) return;
882
+ const oldState = this.map.get(oldEl);
883
+ if (oldState) this.map.set(newEl, { ...oldState });
884
+ this.attachHandlers(newEl);
885
+ }
886
+ // Cancel any scheduled scroll tasks for code blocks.
887
+ cancelAllScrolls() {
888
+ try { this.raf.cancelGroup('CodeScroll'); } catch (_) {}
889
+ this.rafMap = new WeakMap();
890
+ this.rafIds.clear();
891
+ }
906
892
  }
907
- }
908
893
 
909
894
  // ==========================================================================
910
895
  // 3) Highlighter (hljs) + rAF viewport scan
@@ -1175,7 +1160,6 @@
1175
1160
  // ==========================================================================
1176
1161
 
1177
1162
  class CustomMarkup {
1178
- // Logger-aware processor; no console usage.
1179
1163
  constructor(cfg, logger) {
1180
1164
  this.cfg = cfg || { CUSTOM_MARKUP_RULES: [] };
1181
1165
  this.logger = logger || new Logger(cfg);
@@ -1183,6 +1167,17 @@
1183
1167
  }
1184
1168
  _d(line, ctx) { try { this.logger.debug('CM', line, ctx); } catch (_) {} }
1185
1169
 
1170
+ // Decode HTML entities once (safe)
1171
+ // This addresses cases when linkify/full markdown path leaves literal "&quot;" etc. in text nodes.
1172
+ // We decode only for rules that explicitly opt-in (see compile()) to avoid changing semantics globally.
1173
+ decodeEntitiesOnce(s) {
1174
+ if (!s || s.indexOf('&') === -1) return String(s || '');
1175
+ // Using a shared <textarea> avoids DOM parsing side-effects and is fast enough for small JSON payloads.
1176
+ const ta = CustomMarkup._decTA || (CustomMarkup._decTA = document.createElement('textarea'));
1177
+ ta.innerHTML = s;
1178
+ return ta.value;
1179
+ }
1180
+
1186
1181
  // Compile rules once; also precompile strict and whitespace-tolerant "full match" regexes.
1187
1182
  compile(rules) {
1188
1183
  const src = Array.isArray(rules) ? rules : (window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES || []);
@@ -1193,11 +1188,26 @@
1193
1188
  const className = (r.className || r.class || '').trim();
1194
1189
  const innerMode = (r.innerMode === 'markdown-inline' || r.innerMode === 'text') ? r.innerMode : 'text';
1195
1190
 
1191
+ // Opt-in decoding: default to true for "cmd" to fix JSON quotes (&quot;) in static full-render path,
1192
+ // leave false for other tags unless explicitly requested by rule author.
1193
+ const decodeEntities = (typeof r.decodeEntities === 'boolean')
1194
+ ? r.decodeEntities
1195
+ : ((r.name || '').toLowerCase() === 'cmd' || className === 'cmd');
1196
+
1196
1197
  const re = new RegExp(Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close), 'g');
1197
1198
  const reFull = new RegExp('^' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '$');
1198
1199
  const reFullTrim = new RegExp('^\\s*' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '\\s*$');
1199
1200
 
1200
- const item = { name: r.name || tag, tag, className, innerMode, open: r.open, close: r.close, re, reFull, reFullTrim };
1201
+ const item = {
1202
+ name: r.name || tag,
1203
+ tag,
1204
+ className,
1205
+ innerMode,
1206
+ open: r.open,
1207
+ close: r.close,
1208
+ decodeEntities, // per-rule decode switch
1209
+ re, reFull, reFullTrim
1210
+ };
1201
1211
  compiled.push(item);
1202
1212
  this._d('COMPILE_RULE', { name: item.name, tag: item.tag, innerMode: item.innerMode, open: item.open, close: item.close });
1203
1213
  }
@@ -1205,6 +1215,7 @@
1205
1215
  const open = '[!cmd]', close = '[/!cmd]';
1206
1216
  const item = {
1207
1217
  name: 'cmd', tag: 'p', className: 'cmd', innerMode: 'text', open, close,
1218
+ decodeEntities: true, // Fallback rule for cmd also opts-in to decoding
1208
1219
  re: new RegExp(Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close), 'g'),
1209
1220
  reFull: new RegExp('^' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '$'),
1210
1221
  reFullTrim: new RegExp('^\\s*' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '\\s*$')
@@ -1279,16 +1290,22 @@
1279
1290
  return null;
1280
1291
  }
1281
1292
 
1282
- // Set inner content according to the rule's mode.
1283
- setInnerByMode(el, mode, text, MD) {
1293
+ // Set inner content according to the rule's mode, with optional entity decode.
1294
+ setInnerByMode(el, mode, text, MD, decodeEntities = false) {
1295
+ let payload = String(text || '');
1296
+ // Decode entities only when asked by the rule (prevents global behavior change).
1297
+ if (decodeEntities && payload && payload.indexOf('&') !== -1) {
1298
+ try { payload = this.decodeEntitiesOnce(payload); } catch (_) { /* keep original on failure */ }
1299
+ }
1300
+
1284
1301
  if (mode === 'markdown-inline' && typeof window.markdownit !== 'undefined') {
1285
1302
  try {
1286
- if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(text || ''); return; }
1303
+ if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(payload); return; }
1287
1304
  const tempMD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1288
- el.innerHTML = tempMD.renderInline(text || ''); return;
1305
+ el.innerHTML = tempMD.renderInline(payload); return;
1289
1306
  } catch (_) {}
1290
1307
  }
1291
- el.textContent = text || '';
1308
+ el.textContent = payload;
1292
1309
  }
1293
1310
 
1294
1311
  // Try to replace an entire <p> that is a full custom markup match.
@@ -1317,8 +1334,8 @@
1317
1334
  out.setAttribute('data-cm', rule.name);
1318
1335
 
1319
1336
  const innerText = m[1] || '';
1320
- // Use mode-driven inner content materialization (text or markdown-inline).
1321
- this.setInnerByMode(out, rule.innerMode, innerText, MD);
1337
+ // Use mode-driven inner content materialization (text or markdown-inline) with optional decoding.
1338
+ this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities);
1322
1339
 
1323
1340
  // Replace the original <p> with the desired container (<div>, <think>, <p>, etc.).
1324
1341
  try { el.replaceWith(out); } catch (_) {
@@ -1381,7 +1398,7 @@
1381
1398
  const out = document.createElement('p');
1382
1399
  if (fm.rule.className) out.className = fm.rule.className;
1383
1400
  out.setAttribute('data-cm', fm.rule.name);
1384
- this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD);
1401
+ this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities);
1385
1402
  try { parent.replaceWith(out); } catch (_) {
1386
1403
  const par = parent.parentNode; if (par) par.replaceChild(out, parent);
1387
1404
  }
@@ -1407,7 +1424,7 @@
1407
1424
  const el = document.createElement(tag);
1408
1425
  if (m.rule.className) el.className = m.rule.className;
1409
1426
  el.setAttribute('data-cm', m.rule.name);
1410
- this.setInnerByMode(el, m.rule.innerMode, m.inner, MD);
1427
+ this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities);
1411
1428
 
1412
1429
  frag.appendChild(el);
1413
1430
  this._d('WALKER_INLINE_MATCH', { rule: m.rule.name, start: m.start, end: m.end });
@@ -2497,237 +2514,586 @@
2497
2514
  }
2498
2515
  }
2499
2516
 
2500
- class NodesManager {
2501
- constructor(dom, renderer, highlighter, math) { this.dom = dom; this.renderer = renderer; this.highlighter = highlighter; this.math = math; }
2502
- // Check if HTML contains only user messages without any markdown or code features.
2503
- _isUserOnlyContent(html) {
2504
- try {
2505
- const tmp = document.createElement('div');
2506
- tmp.innerHTML = html;
2507
- const hasBot = !!tmp.querySelector('.msg-box.msg-bot');
2508
- const hasUser = !!tmp.querySelector('.msg-box.msg-user');
2509
- const hasMD64 = !!tmp.querySelector('[data-md64]');
2510
- const hasMDNative = !!tmp.querySelector('[md-block-markdown]');
2511
- const hasCode = !!tmp.querySelector('pre code');
2512
- const hasMath = !!tmp.querySelector('script[type^="math/tex"]');
2513
- return hasUser && !hasBot && !hasMD64 && !hasMDNative && !hasCode && !hasMath;
2514
- } catch (_) { return false; }
2515
- }
2516
- // Convert user markdown placeholders into plain text nodes.
2517
- _materializeUserMdAsPlainText(scopeEl) {
2518
- try {
2519
- const nodes = scopeEl.querySelectorAll('.msg-box.msg-user [data-md64], .msg-box.msg-user [md-block-markdown]');
2520
- nodes.forEach(el => {
2521
- let txt = '';
2522
- if (el.hasAttribute('data-md64')) {
2523
- const b64 = el.getAttribute('data-md64') || '';
2524
- el.removeAttribute('data-md64');
2525
- try { txt = this.renderer.b64ToUtf8(b64); } catch (_) { txt = ''; }
2526
- } else {
2527
- // Native Markdown block in user message: keep as plain text (no markdown-it)
2528
- try { txt = el.textContent || ''; } catch (_) { txt = ''; }
2529
- try { el.removeAttribute('md-block-markdown'); } catch (_) {}
2517
+ // UserCollapseManager – collapsible user messages (msg-box.msg-user)
2518
+ class UserCollapseManager {
2519
+ constructor(cfg) {
2520
+ this.cfg = cfg || {};
2521
+ // Collapse threshold in pixels (can be overridden via window.USER_MSG_COLLAPSE_HEIGHT_PX).
2522
+ this.threshold = Utils.g('USER_MSG_COLLAPSE_HEIGHT_PX', 1000);
2523
+ // Track processed .msg elements to allow cheap remeasure on resize if needed.
2524
+ this._processed = new Set();
2525
+
2526
+ // Visual indicator attached while collapsed (does not modify original text).
2527
+ this.ellipsisText = ' [...]';
2528
+ }
2529
+
2530
+ _icons() {
2531
+ const I = (this.cfg && this.cfg.ICONS) || {};
2532
+ return { expand: I.EXPAND || '', collapse: I.COLLAPSE || '' };
2533
+ }
2534
+ _labels() {
2535
+ const L = (this.cfg && this.cfg.LOCALE) || {};
2536
+ return { expand: L.EXPAND || 'Expand', collapse: L.COLLAPSE || 'Collapse' };
2537
+ }
2538
+
2539
+ // Schedule a function for next frame (ensures layout is up-to-date before scrolling).
2540
+ _afterLayout(fn) {
2541
+ try {
2542
+ if (typeof runtime !== 'undefined' && runtime.raf && typeof runtime.raf.schedule === 'function') {
2543
+ const key = { t: 'UC:afterLayout', i: Math.random() };
2544
+ runtime.raf.schedule(key, () => { try { fn && fn(); } catch (_) {} }, 'UserCollapse', 0);
2545
+ return;
2530
2546
  }
2531
- const span = document.createElement('span'); span.textContent = txt; el.replaceWith(span);
2547
+ } catch (_) {}
2548
+ try { requestAnimationFrame(() => { try { fn && fn(); } catch (_) {} }); }
2549
+ catch (_) { setTimeout(() => { try { fn && fn(); } catch (__){ } }, 0); }
2550
+ }
2551
+
2552
+ // Bring toggle into view with minimal scroll (upwards if it moved above after collapse).
2553
+ _scrollToggleIntoView(toggleEl) {
2554
+ if (!toggleEl || !toggleEl.isConnected) return;
2555
+ try { if (runtime && runtime.scrollMgr) { runtime.scrollMgr.userInteracted = true; runtime.scrollMgr.autoFollow = false; } } catch (_) {}
2556
+ this._afterLayout(() => {
2557
+ try {
2558
+ if (toggleEl.scrollIntoView) {
2559
+ // Prefer minimal movement; keep behavior non-animated and predictable.
2560
+ try { toggleEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); }
2561
+ catch (_) { toggleEl.scrollIntoView(false); }
2562
+ }
2563
+ } catch (_) {}
2532
2564
  });
2533
- } catch (_) {}
2534
- }
2535
- // Append HTML into message input container.
2536
- appendToInput(content) {
2537
- // Synchronous DOM update – message input must reflect immediately with no waiting.
2538
- const el = this.dom.get('_append_input_'); if (!el) return; el.insertAdjacentHTML('beforeend', content);
2539
- }
2540
- // Append nodes into messages list and perform post-processing (markdown, code, math).
2541
- appendNode(content, scrollMgr) {
2542
- // Keep scroll behavior consistent with existing logic
2543
- scrollMgr.userInteracted = false; scrollMgr.prevScroll = 0;
2544
- this.dom.clearStreamBefore();
2565
+ }
2545
2566
 
2546
- const el = this.dom.get('_nodes_'); if (!el) return;
2547
- el.classList.remove('empty_list');
2567
+ // Ensure wrapper and toggle exist for a given .msg element.
2568
+ _ensureStructure(msg) {
2569
+ if (!msg || !msg.isConnected) return null;
2570
+
2571
+ // Wrap all direct children into a dedicated content container to measure height accurately.
2572
+ let content = msg.querySelector('.uc-content');
2573
+ if (!content) {
2574
+ content = document.createElement('div');
2575
+ content.className = 'uc-content';
2576
+ const frag = document.createDocumentFragment();
2577
+ while (msg.firstChild) frag.appendChild(msg.firstChild);
2578
+ content.appendChild(frag);
2579
+ msg.appendChild(content);
2580
+ }
2548
2581
 
2549
- const userOnly = this._isUserOnlyContent(content);
2550
- if (userOnly) {
2551
- el.insertAdjacentHTML('beforeend', content);
2552
- this._materializeUserMdAsPlainText(el);
2553
- scrollMgr.scrollToBottom(false);
2554
- scrollMgr.scheduleScrollFabUpdate();
2555
- return;
2582
+ // Ensure a single toggle exists (click and keyboard accessible).
2583
+ let toggle = msg.querySelector('.uc-toggle');
2584
+ if (!toggle) {
2585
+ const icons = this._icons();
2586
+ const labels = this._labels();
2587
+
2588
+ toggle = document.createElement('div');
2589
+ toggle.className = 'uc-toggle';
2590
+ toggle.tabIndex = 0;
2591
+ toggle.setAttribute('role', 'button');
2592
+ toggle.setAttribute('aria-expanded', 'false');
2593
+ toggle.title = labels.expand;
2594
+
2595
+ const img = document.createElement('img');
2596
+ img.className = 'uc-toggle-icon';
2597
+ img.alt = labels.expand;
2598
+ img.src = icons.expand;
2599
+ toggle.appendChild(img);
2600
+
2601
+ // Attach local listeners (no global handler change; production-safe).
2602
+ toggle.addEventListener('click', (ev) => {
2603
+ ev.preventDefault();
2604
+ ev.stopPropagation();
2605
+ this.toggleFromToggle(toggle);
2606
+ });
2607
+ toggle.addEventListener('keydown', (ev) => {
2608
+ if (ev.key === 'Enter' || ev.key === ' ') {
2609
+ ev.preventDefault();
2610
+ ev.stopPropagation();
2611
+ this.toggleFromToggle(toggle);
2612
+ }
2613
+ }, { passive: false });
2614
+
2615
+ msg.appendChild(toggle);
2616
+ }
2617
+
2618
+ this._processed.add(msg);
2619
+ msg.dataset.ucInit = '1';
2620
+ return { content, toggle };
2556
2621
  }
2557
2622
 
2558
- el.insertAdjacentHTML('beforeend', content);
2623
+ // Create or update the ellipsis indicator inside content (absolute in the bottom-right corner).
2624
+ _ensureEllipsisEl(msg, contentEl) {
2625
+ const content = contentEl || (msg && msg.querySelector('.uc-content'));
2626
+ if (!content) return null;
2559
2627
 
2560
- try {
2561
- // Schedule all post-processing strictly after Markdown is materialized.
2562
- const maybePromise = this.renderer.renderPendingMarkdown(el);
2563
- const post = () => {
2564
- try { this.highlighter.scheduleScanVisibleCodes(null); } catch (_) {}
2628
+ // Ensure the content becomes a positioning context only when needed.
2629
+ if (getComputedStyle(content).position === 'static') {
2630
+ content.style.position = 'relative';
2631
+ }
2565
2632
 
2566
- // In finalize-only mode we must explicitly schedule KaTeX,
2567
- // and do it AFTER Markdown has produced <script type="math/tex"> nodes.
2568
- try { if (getMathMode() === 'finalize-only') this.math.schedule(el, 0, true); } catch (_) {}
2569
- };
2633
+ let dot = content.querySelector('.uc-ellipsis');
2634
+ if (!dot) {
2635
+ dot = document.createElement('span');
2636
+ dot.className = 'uc-ellipsis';
2637
+ dot.textContent = this.ellipsisText;
2638
+ // Inline, theme-agnostic styles; kept minimal and non-interactive.
2639
+ dot.style.position = 'absolute';
2640
+ dot.style.right = '0';
2641
+ dot.style.bottom = '0';
2642
+ dot.style.paddingLeft = '6px';
2643
+ dot.style.pointerEvents = 'none';
2644
+ dot.style.zIndex = '1';
2645
+ dot.style.fontWeight = '500';
2646
+ dot.style.opacity = '0.75';
2647
+
2648
+ content.appendChild(dot);
2649
+ }
2650
+ return dot;
2651
+ }
2570
2652
 
2571
- if (maybePromise && typeof maybePromise.then === 'function') {
2572
- maybePromise.then(post);
2573
- } else {
2574
- post();
2653
+ // Show ellipsis only when there is hidden overflow (collapsed).
2654
+ _showEllipsis(msg, contentEl) {
2655
+ const dot = this._ensureEllipsisEl(msg, contentEl);
2656
+ if (dot) dot.style.display = 'inline';
2657
+ }
2658
+ // Hide and clean ellipsis when not needed (expanded or short content).
2659
+ _hideEllipsis(msg) {
2660
+ const content = msg && msg.querySelector('.uc-content');
2661
+ if (!content) return;
2662
+ const dot = content.querySelector('.uc-ellipsis');
2663
+ if (dot && dot.parentNode) {
2664
+ // Remove the indicator to avoid accidental copy/select and keep DOM lean.
2665
+ dot.parentNode.removeChild(dot);
2666
+ }
2667
+ // Drop positioning context when no indicator is present (keep styles minimal).
2668
+ try {
2669
+ if (content && content.style && content.querySelector('.uc-ellipsis') == null) {
2670
+ content.style.position = '';
2671
+ }
2672
+ } catch (_) {}
2673
+ }
2674
+
2675
+ // Apply collapse to all user messages under root.
2676
+ apply(root) {
2677
+ const scope = root || document;
2678
+ let list;
2679
+ if (scope.nodeType === 1) list = scope.querySelectorAll('.msg-box.msg-user .msg');
2680
+ else list = document.querySelectorAll('.msg-box.msg-user .msg');
2681
+ if (!list || !list.length) return;
2682
+
2683
+ for (let i = 0; i < list.length; i++) {
2684
+ const msg = list[i];
2685
+ const st = this._ensureStructure(msg);
2686
+ if (!st) continue;
2687
+ this._update(msg, st.content, st.toggle);
2575
2688
  }
2576
- } catch (_) { /* swallow to keep append path resilient */ }
2689
+ }
2577
2690
 
2578
- // Keep scroll/fab logic identical (immediate; rendering completes shortly after)
2579
- scrollMgr.scrollToBottom(false);
2580
- scrollMgr.scheduleScrollFabUpdate();
2581
- }
2582
- // Replace messages list content entirely and re-run post-processing.
2583
- replaceNodes(content, scrollMgr) {
2584
- // Same semantics as appendNode, but using a hard clone reset
2585
- scrollMgr.userInteracted = false; scrollMgr.prevScroll = 0;
2586
- this.dom.clearStreamBefore();
2691
+ // Update collapsed/expanded state depending on content height.
2692
+ _update(msg, contentEl, toggleEl) {
2693
+ const c = contentEl || (msg && msg.querySelector('.uc-content'));
2694
+ if (!msg || !c) return;
2695
+
2696
+ // Special-case: when threshold = 0 (or '0'), auto-collapse is globally disabled.
2697
+ // We avoid any measurement, force the content to be fully expanded, and ensure the toggle is hidden.
2698
+ // This preserves public API while providing an explicit opt-out, without impacting existing behavior.
2699
+ if (this.threshold === 0 || this.threshold === '0') {
2700
+ const t = toggleEl || msg.querySelector('.uc-toggle');
2701
+ const labels = this._labels();
2702
+
2703
+ // Ensure expanded state and remove any limiting classes.
2704
+ c.classList.remove('uc-collapsed');
2705
+ c.classList.remove('uc-expanded'); // No class => fully expanded by default CSS.
2706
+ msg.dataset.ucState = 'expanded';
2707
+
2708
+ // Hide ellipsis in disabled mode.
2709
+ this._hideEllipsis(msg);
2710
+
2711
+ // Hide toggle in disabled mode to avoid user interaction.
2712
+ if (t) {
2713
+ t.classList.remove('visible');
2714
+ t.setAttribute('aria-expanded', 'false');
2715
+ t.title = labels.expand;
2716
+ const img = t.querySelector('img');
2717
+ if (img) { img.alt = labels.expand; }
2718
+ }
2719
+ return; // Do not proceed with measuring or collapsing.
2720
+ }
2587
2721
 
2588
- const el = this.dom.hardReplaceByClone('_nodes_'); if (!el) return;
2589
- el.classList.remove('empty_list');
2722
+ // Temporarily remove limiting classes for precise measurement.
2723
+ c.classList.remove('uc-collapsed');
2724
+ c.classList.remove('uc-expanded');
2590
2725
 
2591
- const userOnly = this._isUserOnlyContent(content);
2592
- if (userOnly) {
2593
- el.insertAdjacentHTML('beforeend', content);
2594
- this._materializeUserMdAsPlainText(el);
2595
- scrollMgr.scrollToBottom(false, true);
2596
- scrollMgr.scheduleScrollFabUpdate();
2597
- return;
2726
+ const fullHeight = Math.ceil(c.scrollHeight);
2727
+ const labels = this._labels();
2728
+ const icons = this._icons();
2729
+ const t = toggleEl || msg.querySelector('.uc-toggle');
2730
+
2731
+ if (fullHeight > this.threshold) {
2732
+ if (t) t.classList.add('visible');
2733
+ const desired = msg.dataset.ucState || 'collapsed';
2734
+ const expand = (desired === 'expanded');
2735
+
2736
+ if (expand) {
2737
+ c.classList.add('uc-expanded');
2738
+ this._hideEllipsis(msg); // Expanded => no ellipsis
2739
+ } else {
2740
+ c.classList.add('uc-collapsed');
2741
+ this._showEllipsis(msg, c); // Collapsed => show ellipsis overlay
2742
+ }
2743
+
2744
+ if (t) {
2745
+ const img = t.querySelector('img');
2746
+ if (img) {
2747
+ if (expand) { img.src = icons.collapse; img.alt = labels.collapse; }
2748
+ else { img.src = icons.expand; img.alt = labels.expand; }
2749
+ }
2750
+ t.setAttribute('aria-expanded', expand ? 'true' : 'false');
2751
+ t.title = expand ? labels.collapse : labels.expand;
2752
+ }
2753
+ } else {
2754
+ // Short content – ensure fully expanded and hide toggle + ellipsis.
2755
+ c.classList.remove('uc-collapsed');
2756
+ c.classList.remove('uc-expanded');
2757
+ msg.dataset.ucState = 'expanded';
2758
+ this._hideEllipsis(msg);
2759
+ if (t) {
2760
+ t.classList.remove('visible');
2761
+ t.setAttribute('aria-expanded', 'false');
2762
+ t.title = labels.expand;
2763
+ }
2764
+ }
2598
2765
  }
2599
2766
 
2600
- el.insertAdjacentHTML('beforeend', content);
2767
+ // Toggle handler via the toggle element (div.uc-toggle).
2768
+ toggleFromToggle(toggleEl) {
2769
+ const msg = toggleEl && toggleEl.closest ? toggleEl.closest('.msg-box.msg-user .msg') : null;
2770
+ if (!msg) return;
2771
+ this.toggle(msg);
2772
+ }
2601
2773
 
2602
- try {
2603
- // Defer KaTeX schedule to post-Markdown to avoid races.
2604
- const maybePromise = this.renderer.renderPendingMarkdown(el);
2605
- const post = () => {
2606
- try { this.highlighter.scheduleScanVisibleCodes(null); } catch (_) {}
2607
- try { if (getMathMode() === 'finalize-only') this.math.schedule(el, 0, true); } catch (_) {}
2608
- };
2774
+ // Core toggle logic.
2775
+ toggle(msg) {
2776
+ if (!msg || !msg.isConnected) return;
2777
+ const c = msg.querySelector('.uc-content'); if (!c) return;
2778
+ const t = msg.querySelector('.uc-toggle');
2779
+ const labels = this._labels();
2780
+ const icons = this._icons();
2609
2781
 
2610
- if (maybePromise && typeof maybePromise.then === 'function') {
2611
- maybePromise.then(post);
2782
+ const isCollapsed = c.classList.contains('uc-collapsed');
2783
+ if (isCollapsed) {
2784
+ // Expand – leave scroll as-is; remove ellipsis.
2785
+ c.classList.remove('uc-collapsed');
2786
+ c.classList.add('uc-expanded');
2787
+ msg.dataset.ucState = 'expanded';
2788
+ this._hideEllipsis(msg);
2789
+ if (t) {
2790
+ t.setAttribute('aria-expanded', 'true');
2791
+ t.title = labels.collapse;
2792
+ const img = t.querySelector('img'); if (img) { img.src = icons.collapse; img.alt = labels.collapse; }
2793
+ }
2612
2794
  } else {
2613
- post();
2795
+ // Collapse – apply classes, show ellipsis, then bring toggle into view (scroll up if needed).
2796
+ c.classList.remove('uc-expanded');
2797
+ c.classList.add('uc-collapsed');
2798
+ msg.dataset.ucState = 'collapsed';
2799
+ this._showEllipsis(msg, c);
2800
+ if (t) {
2801
+ t.setAttribute('aria-expanded', 'false');
2802
+ t.title = labels.expand;
2803
+ const img = t.querySelector('img'); if (img) { img.src = icons.expand; img.alt = labels.expand; }
2804
+ // Follow the collapsing content upward – keep the toggle visible.
2805
+ this._scrollToggleIntoView(t);
2806
+ }
2614
2807
  }
2615
- } catch (_) { /* swallow */ }
2808
+ }
2616
2809
 
2617
- scrollMgr.scrollToBottom(false, true);
2618
- scrollMgr.scheduleScrollFabUpdate();
2810
+ // Optional public method to re-evaluate height after layout/resize.
2811
+ remeasureAll() {
2812
+ const arr = Array.from(this._processed || []);
2813
+ for (let i = 0; i < arr.length; i++) {
2814
+ const msg = arr[i];
2815
+ if (!msg || !msg.isConnected) { this._processed.delete(msg); continue; }
2816
+ this._update(msg);
2817
+ }
2818
+ }
2619
2819
  }
2620
- // Append "extra" content into a specific bot message and post-process locally.
2621
- appendExtra(id, content, scrollMgr) {
2622
- const el = document.getElementById('msg-bot-' + id); if (!el) return;
2623
- const extra = el.querySelector('.msg-extra'); if (!extra) return;
2624
2820
 
2625
- extra.insertAdjacentHTML('beforeend', content);
2821
+ class NodesManager {
2822
+ constructor(dom, renderer, highlighter, math) {
2823
+ this.dom = dom;
2824
+ this.renderer = renderer;
2825
+ this.highlighter = highlighter;
2826
+ this.math = math;
2827
+ // User message collapse manager
2828
+ this._userCollapse = new UserCollapseManager(this.renderer.cfg);
2829
+ }
2626
2830
 
2627
- try {
2628
- const maybePromise = this.renderer.renderPendingMarkdown(extra);
2831
+ // Check if HTML contains only user messages without any markdown or code features.
2832
+ _isUserOnlyContent(html) {
2833
+ try {
2834
+ const tmp = document.createElement('div');
2835
+ tmp.innerHTML = html;
2836
+ const hasBot = !!tmp.querySelector('.msg-box.msg-bot');
2837
+ const hasUser = !!tmp.querySelector('.msg-box.msg-user');
2838
+ const hasMD64 = !!tmp.querySelector('[data-md64]');
2839
+ const hasMDNative = !!tmp.querySelector('[md-block-markdown]');
2840
+ const hasCode = !!tmp.querySelector('pre code');
2841
+ const hasMath = !!tmp.querySelector('script[type^="math/tex"]');
2842
+ return hasUser && !hasBot && !hasMD64 && !hasMDNative && !hasCode && !hasMath;
2843
+ } catch (_) { return false; }
2844
+ }
2629
2845
 
2630
- const post = () => {
2631
- const activeCode = (typeof runtime !== 'undefined' && runtime.stream) ? runtime.stream.activeCode : null;
2846
+ // Convert user markdown placeholders into plain text nodes.
2847
+ _materializeUserMdAsPlainText(scopeEl) {
2848
+ try {
2849
+ const nodes = scopeEl.querySelectorAll('.msg-box.msg-user [data-md64], .msg-box.msg-user [md-block-markdown]');
2850
+ nodes.forEach(el => {
2851
+ let txt = '';
2852
+ if (el.hasAttribute('data-md64')) {
2853
+ const b64 = el.getAttribute('data-md64') || '';
2854
+ el.removeAttribute('data-md64');
2855
+ try { txt = this.renderer.b64ToUtf8(b64); } catch (_) { txt = ''; }
2856
+ } else {
2857
+ // Native Markdown block in user message: keep as plain text (no markdown-it)
2858
+ try { txt = el.textContent || ''; } catch (_) { txt = ''; }
2859
+ try { el.removeAttribute('md-block-markdown'); } catch (_) {}
2860
+ }
2861
+ const span = document.createElement('span'); span.textContent = txt; el.replaceWith(span);
2862
+ });
2863
+ } catch (_) {}
2864
+ }
2632
2865
 
2633
- // Attach observers after Markdown produced the nodes
2634
- try {
2635
- this.highlighter.observeNewCode(extra, {
2636
- deferLastIfStreaming: true,
2637
- minLinesForLast: this.renderer.cfg.PROFILE_CODE.minLinesForHL,
2638
- minCharsForLast: this.renderer.cfg.PROFILE_CODE.minCharsForHL
2639
- }, activeCode);
2640
- this.highlighter.observeMsgBoxes(extra, (box) => this._onBox(box));
2641
- } catch (_) {}
2866
+ // Append HTML into message input container.
2867
+ appendToInput(content) {
2868
+ // Synchronous DOM update – message input must reflect immediately.
2869
+ const el = this.dom.get('_append_input_'); if (!el) return;
2870
+ el.insertAdjacentHTML('beforeend', content);
2871
+ // Apply collapse to any user messages in input area BEFORE the host schedules scroll.
2872
+ try { this._userCollapse.apply(el); } catch (_) {}
2873
+ }
2642
2874
 
2643
- // KaTeX: honor stream mode; in finalize-only force immediate schedule,
2644
- // now guaranteed to find <script type="math/tex"> nodes.
2645
- try {
2646
- const mm = getMathMode();
2647
- if (mm === 'finalize-only') this.math.schedule(extra, 0, true);
2648
- else this.math.schedule(extra);
2649
- } catch (_) {}
2650
- };
2875
+ // Append nodes into messages list and perform post-processing (markdown, code, math).
2876
+ appendNode(content, scrollMgr) {
2877
+ // Keep scroll behavior consistent with existing logic
2878
+ scrollMgr.userInteracted = false; scrollMgr.prevScroll = 0;
2879
+ this.dom.clearStreamBefore();
2880
+
2881
+ const el = this.dom.get('_nodes_'); if (!el) return;
2882
+ el.classList.remove('empty_list');
2883
+
2884
+ const userOnly = this._isUserOnlyContent(content);
2885
+ if (userOnly) {
2886
+ el.insertAdjacentHTML('beforeend', content);
2887
+ this._materializeUserMdAsPlainText(el);
2888
+ // Collapse before scrolling to ensure final height is used for scroll computations.
2889
+ try { this._userCollapse.apply(el); } catch (_) {}
2890
+ scrollMgr.scrollToBottom(false);
2891
+ scrollMgr.scheduleScrollFabUpdate();
2892
+ return;
2893
+ }
2651
2894
 
2652
- if (maybePromise && typeof maybePromise.then === 'function') {
2653
- maybePromise.then(post);
2654
- } else {
2655
- post();
2895
+ el.insertAdjacentHTML('beforeend', content);
2896
+
2897
+ try {
2898
+ // Defer post-processing (highlight/math/collapse) and perform scroll AFTER collapse.
2899
+ const maybePromise = this.renderer.renderPendingMarkdown(el);
2900
+ const post = () => {
2901
+ // Viewport highlight scheduling
2902
+ try { this.highlighter.scheduleScanVisibleCodes(null); } catch (_) {}
2903
+
2904
+ // In finalize-only mode we must explicitly schedule KaTeX
2905
+ try { if (getMathMode() === 'finalize-only') this.math.schedule(el, 0, true); } catch (_) {}
2906
+
2907
+ // Collapse user messages now that DOM is materialized (ensures correct height).
2908
+ try { this._userCollapse.apply(el); } catch (_) {}
2909
+
2910
+ // Only now scroll to bottom and update FAB – uses post-collapse heights.
2911
+ scrollMgr.scrollToBottom(false);
2912
+ scrollMgr.scheduleScrollFabUpdate();
2913
+ };
2914
+
2915
+ if (maybePromise && typeof maybePromise.then === 'function') {
2916
+ maybePromise.then(post);
2917
+ } else {
2918
+ post();
2919
+ }
2920
+ } catch (_) {
2921
+ // In case of error, do a conservative scroll to keep UX responsive.
2922
+ scrollMgr.scrollToBottom(false);
2923
+ scrollMgr.scheduleScrollFabUpdate();
2656
2924
  }
2657
- } catch (_) { /* swallow */ }
2925
+ }
2658
2926
 
2659
- scrollMgr.scheduleScroll(true);
2660
- }
2661
- // When a new message box appears, hook up code/highlight handlers.
2662
- _onBox(box) {
2663
- const activeCode = (typeof runtime !== 'undefined' && runtime.stream) ? runtime.stream.activeCode : null;
2664
- this.highlighter.observeNewCode(box, {
2665
- deferLastIfStreaming: true,
2666
- minLinesForLast: this.renderer.cfg.PROFILE_CODE.minLinesForHL,
2667
- minCharsForLast: this.renderer.cfg.PROFILE_CODE.minCharsForHL
2668
- }, activeCode);
2669
- this.renderer.hooks.codeScrollInit(box);
2670
- }
2671
- // Remove message by id and keep scroll consistent.
2672
- removeNode(id, scrollMgr) {
2673
- scrollMgr.prevScroll = 0;
2674
- let el = document.getElementById('msg-user-' + id); if (el) el.remove();
2675
- el = document.getElementById('msg-bot-' + id); if (el) el.remove();
2676
- this.dom.resetEphemeral();
2677
- try { this.renderer.renderPendingMarkdown(); } catch (_) {}
2678
- scrollMgr.scheduleScroll(true);
2679
- }
2680
- // Remove all messages from (and including) a given message id.
2681
- removeNodesFromId(id, scrollMgr) {
2682
- scrollMgr.prevScroll = 0;
2683
- const container = this.dom.get('_nodes_'); if (!container) return;
2684
- const elements = container.querySelectorAll('.msg-box');
2685
- let remove = false;
2686
- elements.forEach((element) => {
2687
- if (element.id && element.id.endsWith('-' + id)) remove = true;
2688
- if (remove) element.remove();
2689
- });
2690
- this.dom.resetEphemeral();
2691
- try { this.renderer.renderPendingMarkdown(container); } catch (_) {}
2692
- scrollMgr.scheduleScroll(true);
2927
+ // Replace messages list content entirely and re-run post-processing.
2928
+ replaceNodes(content, scrollMgr) {
2929
+ // Same semantics as appendNode, but using a hard clone reset
2930
+ scrollMgr.userInteracted = false; scrollMgr.prevScroll = 0;
2931
+ this.dom.clearStreamBefore();
2932
+
2933
+ const el = this.dom.hardReplaceByClone('_nodes_'); if (!el) return;
2934
+ el.classList.remove('empty_list');
2935
+
2936
+ const userOnly = this._isUserOnlyContent(content);
2937
+ if (userOnly) {
2938
+ el.insertAdjacentHTML('beforeend', content);
2939
+ this._materializeUserMdAsPlainText(el);
2940
+ // Collapse before scrolling to ensure final height is used for scroll computations.
2941
+ try { this._userCollapse.apply(el); } catch (_) {}
2942
+ scrollMgr.scrollToBottom(false, true);
2943
+ scrollMgr.scheduleScrollFabUpdate();
2944
+ return;
2945
+ }
2946
+
2947
+ el.insertAdjacentHTML('beforeend', content);
2948
+
2949
+ try {
2950
+ // Defer KaTeX schedule to post-Markdown to avoid races and collapse before scroll.
2951
+ const maybePromise = this.renderer.renderPendingMarkdown(el);
2952
+ const post = () => {
2953
+ try { this.highlighter.scheduleScanVisibleCodes(null); } catch (_) {}
2954
+ try { if (getMathMode() === 'finalize-only') this.math.schedule(el, 0, true); } catch (_) {}
2955
+
2956
+ // Collapse after materialization to compute final heights correctly.
2957
+ try { this._userCollapse.apply(el); } catch (_) {}
2958
+
2959
+ // Now scroll and update FAB using the collapsed layout.
2960
+ scrollMgr.scrollToBottom(false, true);
2961
+ scrollMgr.scheduleScrollFabUpdate();
2962
+ };
2963
+
2964
+ if (maybePromise && typeof maybePromise.then === 'function') {
2965
+ maybePromise.then(post);
2966
+ } else {
2967
+ post();
2968
+ }
2969
+ } catch (_) {
2970
+ scrollMgr.scrollToBottom(false, true);
2971
+ scrollMgr.scheduleScrollFabUpdate();
2972
+ }
2973
+ }
2974
+
2975
+ // Append "extra" content into a specific bot message and post-process locally.
2976
+ appendExtra(id, content, scrollMgr) {
2977
+ const el = document.getElementById('msg-bot-' + id); if (!el) return;
2978
+ const extra = el.querySelector('.msg-extra'); if (!extra) return;
2979
+
2980
+ extra.insertAdjacentHTML('beforeend', content);
2981
+
2982
+ try {
2983
+ const maybePromise = this.renderer.renderPendingMarkdown(extra);
2984
+
2985
+ const post = () => {
2986
+ const activeCode = (typeof runtime !== 'undefined' && runtime.stream) ? runtime.stream.activeCode : null;
2987
+
2988
+ // Attach observers after Markdown produced the nodes
2989
+ try {
2990
+ this.highlighter.observeNewCode(extra, {
2991
+ deferLastIfStreaming: true,
2992
+ minLinesForLast: this.renderer.cfg.PROFILE_CODE.minLinesForHL,
2993
+ minCharsForLast: this.renderer.cfg.PROFILE_CODE.minCharsForHL
2994
+ }, activeCode);
2995
+ this.highlighter.observeMsgBoxes(extra, (box) => this._onBox(box));
2996
+ } catch (_) {}
2997
+
2998
+ // KaTeX: honor stream mode; in finalize-only force immediate schedule
2999
+ try {
3000
+ const mm = getMathMode();
3001
+ if (mm === 'finalize-only') this.math.schedule(extra, 0, true);
3002
+ else this.math.schedule(extra);
3003
+ } catch (_) {}
3004
+ };
3005
+
3006
+ if (maybePromise && typeof maybePromise.then === 'function') {
3007
+ maybePromise.then(post);
3008
+ } else {
3009
+ post();
3010
+ }
3011
+ } catch (_) { /* swallow */ }
3012
+
3013
+ scrollMgr.scheduleScroll(true);
3014
+ }
3015
+
3016
+ // When a new message box appears, hook up code/highlight handlers.
3017
+ _onBox(box) {
3018
+ const activeCode = (typeof runtime !== 'undefined' && runtime.stream) ? runtime.stream.activeCode : null;
3019
+ this.highlighter.observeNewCode(box, {
3020
+ deferLastIfStreaming: true,
3021
+ minLinesForLast: this.renderer.cfg.PROFILE_CODE.minLinesForHL,
3022
+ minCharsForLast: this.renderer.cfg.PROFILE_CODE.minCharsForHL
3023
+ }, activeCode);
3024
+ this.renderer.hooks.codeScrollInit(box);
3025
+ }
3026
+
3027
+ // Remove message by id and keep scroll consistent.
3028
+ removeNode(id, scrollMgr) {
3029
+ scrollMgr.prevScroll = 0;
3030
+ let el = document.getElementById('msg-user-' + id); if (el) el.remove();
3031
+ el = document.getElementById('msg-bot-' + id); if (el) el.remove();
3032
+ this.dom.resetEphemeral();
3033
+ try { this.renderer.renderPendingMarkdown(); } catch (_) {}
3034
+ scrollMgr.scheduleScroll(true);
3035
+ }
3036
+
3037
+ // Remove all messages from (and including) a given message id.
3038
+ removeNodesFromId(id, scrollMgr) {
3039
+ scrollMgr.prevScroll = 0;
3040
+ const container = this.dom.get('_nodes_'); if (!container) return;
3041
+ const elements = container.querySelectorAll('.msg-box');
3042
+ let remove = false;
3043
+ elements.forEach((element) => {
3044
+ if (element.id && element.id.endsWith('-' + id)) remove = true;
3045
+ if (remove) element.remove();
3046
+ });
3047
+ this.dom.resetEphemeral();
3048
+ try { this.renderer.renderPendingMarkdown(container); } catch (_) {}
3049
+ scrollMgr.scheduleScroll(true);
3050
+ }
2693
3051
  }
2694
- }
2695
3052
 
2696
3053
  // ==========================================================================
2697
3054
  // 10) UI manager
2698
3055
  // ==========================================================================
2699
3056
 
2700
3057
  class UIManager {
2701
- // Replace or insert app-level CSS in a <style> tag.
2702
- updateCSS(styles) {
2703
- let style = document.getElementById('app-style');
2704
- if (!style) { style = document.createElement('style'); style.id = 'app-style'; document.head.appendChild(style); }
2705
- style.textContent = styles;
2706
- }
2707
- // Ensure base styles for code header sticky behavior exist.
2708
- ensureStickyHeaderStyle() {
2709
- let style = document.getElementById('code-sticky-style');
2710
- if (style) return;
2711
- style = document.createElement('style'); style.id = 'code-sticky-style';
2712
- style.textContent = [
2713
- '.code-wrapper { position: relative; }',
2714
- '.code-wrapper .code-header-wrapper { position: sticky; top: var(--code-header-sticky-top, 0px); z-index: 2; box-shadow: 0 1px 0 rgba(0,0,0,.06); }',
2715
- '.code-wrapper pre { overflow: visible; margin-top: 0; }',
2716
- '.code-wrapper pre code { display: block; white-space: pre; max-height: 100dvh; overflow: auto;',
2717
- ' overscroll-behavior: contain; -webkit-overflow-scrolling: touch; overflow-anchor: none; scrollbar-gutter: stable both-edges; scroll-behavior: auto; }',
2718
- '#_loader_.hidden { display: none !important; visibility: hidden !important; }',
2719
- '#_loader_.visible { display: block; visibility: visible; }'
2720
- ].join('\n');
2721
- document.head.appendChild(style);
2722
- }
2723
- // Toggle classes controlling optional UI features.
2724
- enableEditIcons() { document.body && document.body.classList.add('display-edit-icons'); }
2725
- disableEditIcons() { document.body && document.body.classList.remove('display-edit-icons'); }
2726
- enableTimestamp() { document.body && document.body.classList.add('display-timestamp'); }
2727
- disableTimestamp() { document.body && document.body.classList.remove('display-timestamp'); }
2728
- enableBlocks() { document.body && document.body.classList.add('display-blocks'); }
2729
- disableBlocks() { document.body && document.body.classList.remove('display-blocks'); }
2730
- }
3058
+ // Replace or insert app-level CSS in a <style> tag.
3059
+ updateCSS(styles) {
3060
+ let style = document.getElementById('app-style');
3061
+ if (!style) { style = document.createElement('style'); style.id = 'app-style'; document.head.appendChild(style); }
3062
+ style.textContent = styles;
3063
+ }
3064
+ // Ensure base styles for code header sticky behavior exist.
3065
+ ensureStickyHeaderStyle() {
3066
+ let style = document.getElementById('code-sticky-style');
3067
+ if (style) return;
3068
+ style = document.createElement('style'); style.id = 'code-sticky-style';
3069
+ style.textContent = [
3070
+ '.code-wrapper { position: relative; }',
3071
+ '.code-wrapper .code-header-wrapper { position: sticky; top: var(--code-header-sticky-top, 0px); z-index: 2; box-shadow: 0 1px 0 rgba(0,0,0,.06); }',
3072
+ '.code-wrapper pre { overflow: visible; margin-top: 0; }',
3073
+ '.code-wrapper pre code { display: block; white-space: pre; max-height: 100dvh; overflow: auto;',
3074
+ ' overscroll-behavior: contain; -webkit-overflow-scrolling: touch; overflow-anchor: none; scrollbar-gutter: stable both-edges; scroll-behavior: auto; }',
3075
+ '#_loader_.hidden { display: none !important; visibility: hidden !important; }',
3076
+ '#_loader_.visible { display: block; visibility: visible; }',
3077
+
3078
+ /* User message collapse (uc-*) */
3079
+ '.msg-box.msg-user .msg { position: relative; }',
3080
+ '.msg-box.msg-user .msg > .uc-content { display: block; overflow: visible; }',
3081
+ '.msg-box.msg-user .msg > .uc-content.uc-collapsed { max-height: 1000px; overflow: hidden; }',
3082
+ '.msg-box.msg-user .msg > .uc-toggle { display: none; margin-top: 8px; text-align: center; cursor: pointer; user-select: none; }',
3083
+ '.msg-box.msg-user .msg > .uc-toggle.visible { display: block; }',
3084
+ '.msg-box.msg-user .msg > .uc-toggle img { width: 20px; height: 20px; opacity: .8; }',
3085
+ '.msg-box.msg-user .msg > .uc-toggle:hover img { opacity: 1; }'
3086
+ ].join('\n');
3087
+ document.head.appendChild(style);
3088
+ }
3089
+ // Toggle classes controlling optional UI features.
3090
+ enableEditIcons() { document.body && document.body.classList.add('display-edit-icons'); }
3091
+ disableEditIcons() { document.body && document.body.classList.remove('display-edit-icons'); }
3092
+ enableTimestamp() { document.body && document.body.classList.add('display-timestamp'); }
3093
+ disableTimestamp() { document.body && document.body.classList.remove('display-timestamp'); }
3094
+ enableBlocks() { document.body && document.body.classList.add('display-blocks'); }
3095
+ disableBlocks() { document.body && document.body.classList.remove('display-blocks'); }
3096
+ }
2731
3097
 
2732
3098
  // ==========================================================================
2733
3099
  // 11) Stream snapshot engine + incremental code streaming