pygpt-net 2.6.11__py3-none-any.whl → 2.6.13__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.
@@ -6,14 +6,16 @@
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.08.18 01:00:00 #
9
+ # Updated Date: 2025.08.19 07:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
13
13
  import os
14
14
  import re
15
+
15
16
  from datetime import datetime
16
17
  from typing import Optional, List, Any
18
+ from time import monotonic
17
19
 
18
20
  from pygpt_net.core.render.base import BaseRenderer
19
21
  from pygpt_net.core.text.utils import has_unclosed_code_tag
@@ -57,7 +59,7 @@ class Renderer(BaseRenderer):
57
59
  self.body = Body(window)
58
60
  self.helpers = Helpers(window)
59
61
  self.parser = Parser(window)
60
- self.pids = {} # per node data
62
+ self.pids = {}
61
63
  self.prev_chunk_replace = False
62
64
  self.prev_chunk_newline = False
63
65
 
@@ -66,6 +68,9 @@ class Renderer(BaseRenderer):
66
68
  self._icon_sync = os.path.join(app_path, "data", "icons", "sync.svg")
67
69
  self._file_prefix = 'file:///' if self.window and self.window.core.platforms.is_windows() else 'file://'
68
70
 
71
+ self._thr = {}
72
+ self._throttle_interval = 0.01 # 10 ms delay
73
+
69
74
  def prepare(self):
70
75
  """
71
76
  Prepare renderer
@@ -103,9 +108,8 @@ class Renderer(BaseRenderer):
103
108
  pid = tab.pid
104
109
  if pid is None or pid not in self.pids:
105
110
  return
106
- self.pids[pid].loaded = True
107
- node = self.get_output_node(meta)
108
111
 
112
+ self.pids[pid].loaded = True
109
113
  if self.pids[pid].html != "" and not self.pids[pid].use_buffer:
110
114
  self.clear_chunks_input(pid)
111
115
  self.clear_chunks_output(pid)
@@ -113,8 +117,6 @@ class Renderer(BaseRenderer):
113
117
  self.append(pid, self.pids[pid].html, flush=True)
114
118
  self.pids[pid].html = ""
115
119
 
116
- node.setUpdatesEnabled(True)
117
-
118
120
  def get_pid(self, meta: CtxMeta):
119
121
  """
120
122
  Get PID for context meta
@@ -188,7 +190,8 @@ class Renderer(BaseRenderer):
188
190
  node = self.get_output_node_by_pid(pid)
189
191
  try:
190
192
  node.page().runJavaScript(
191
- "if (typeof window.showLoading !== 'undefined') showLoading();")
193
+ "if (typeof window.showLoading !== 'undefined') showLoading();"
194
+ )
192
195
  except Exception as e:
193
196
  pass
194
197
 
@@ -198,7 +201,8 @@ class Renderer(BaseRenderer):
198
201
  if node is not None:
199
202
  try:
200
203
  node.page().runJavaScript(
201
- "if (typeof window.hideLoading !== 'undefined') hideLoading();")
204
+ "if (typeof window.hideLoading !== 'undefined') hideLoading();"
205
+ )
202
206
  except Exception as e:
203
207
  pass
204
208
 
@@ -208,7 +212,8 @@ class Renderer(BaseRenderer):
208
212
  if node is not None:
209
213
  try:
210
214
  node.page().runJavaScript(
211
- "if (typeof window.hideLoading !== 'undefined') hideLoading();")
215
+ "if (typeof window.hideLoading !== 'undefined') hideLoading();"
216
+ )
212
217
  except Exception as e:
213
218
  pass
214
219
 
@@ -250,6 +255,8 @@ class Renderer(BaseRenderer):
250
255
  if self.pids[pid].item is not None and stream:
251
256
  self.append_context_item(meta, self.pids[pid].item)
252
257
  self.pids[pid].item = None
258
+ else:
259
+ self.reload()
253
260
  self.pids[pid].clear()
254
261
 
255
262
  def end_extra(
@@ -299,6 +306,9 @@ class Renderer(BaseRenderer):
299
306
  pid = self.get_or_create_pid(meta)
300
307
  if pid is None:
301
308
  return
309
+
310
+ self._throttle_emit(pid, force=True)
311
+ self._throttle_reset(pid)
302
312
  if self.window.controller.agent.legacy.enabled():
303
313
  if self.pids[pid].item is not None:
304
314
  self.append_context_item(meta, self.pids[pid].item)
@@ -316,7 +326,27 @@ class Renderer(BaseRenderer):
316
326
  clear: bool = True
317
327
  ):
318
328
  """
319
- Append all context to output
329
+ Append all context items to output
330
+
331
+ :param meta: Context meta
332
+ :param items: context items
333
+ :param clear: True if clear all output before append
334
+ """
335
+ self.tool_output_end()
336
+ self.append_context_all(
337
+ meta,
338
+ items,
339
+ clear=clear,
340
+ )
341
+
342
+ def append_context_partial(
343
+ self,
344
+ meta: CtxMeta,
345
+ items: List[CtxItem],
346
+ clear: bool = True
347
+ ):
348
+ """
349
+ Append all context items to output (part by part)
320
350
 
321
351
  :param meta: Context meta
322
352
  :param items: context items
@@ -335,6 +365,7 @@ class Renderer(BaseRenderer):
335
365
  self.pids[pid].use_buffer = True
336
366
  self.pids[pid].html = ""
337
367
  prev_ctx = None
368
+ next_item = None
338
369
  total = len(items)
339
370
  for i, item in enumerate(items):
340
371
  self.update_names(meta, item)
@@ -346,43 +377,137 @@ class Renderer(BaseRenderer):
346
377
  meta,
347
378
  item,
348
379
  prev_ctx=prev_ctx,
349
- next_ctx=next_item
380
+ next_ctx=next_item,
350
381
  )
351
382
  prev_ctx = item
383
+
384
+ prev_ctx = None
385
+ next_item = None
352
386
  self.pids[pid].use_buffer = False
387
+ if self.pids[pid].html != "":
388
+ self.append(
389
+ pid,
390
+ self.pids[pid].html,
391
+ flush=True,
392
+ )
393
+ self.parser.reset()
394
+
395
+ def append_context_all(
396
+ self,
397
+ meta: CtxMeta,
398
+ items: List[CtxItem],
399
+ clear: bool = True
400
+ ):
401
+ """
402
+ Append all context items to output (whole context at once)
403
+
404
+ :param meta: Context meta
405
+ :param items: context items
406
+ :param clear: True if clear all output before append
407
+ """
408
+ if len(items) == 0:
409
+ if meta is None:
410
+ return
353
411
 
412
+ pid = self.get_or_create_pid(meta)
413
+ self.init(pid)
414
+
415
+ if clear:
416
+ self.reset(meta)
417
+
418
+ self.pids[pid].use_buffer = True
419
+ self.pids[pid].html = ""
420
+ prev_ctx = None
421
+ next_ctx = None
422
+ total = len(items)
423
+ html_parts = []
424
+ for i, item in enumerate(items):
425
+ self.update_names(meta, item)
426
+ item.idx = i
427
+ if i == 0:
428
+ item.first = True
429
+ next_ctx = items[i + 1] if i + 1 < total else None
430
+
431
+ # ignore hidden items
432
+ if item.hidden:
433
+ prev_ctx = item
434
+ continue
435
+
436
+ # input node
437
+ data = self.prepare_input(meta, item, flush=False)
438
+ if data:
439
+ html = self.prepare_node(
440
+ meta=meta,
441
+ ctx=item,
442
+ html=data,
443
+ type=self.NODE_INPUT,
444
+ prev_ctx=prev_ctx,
445
+ next_ctx=next_ctx,
446
+ )
447
+ if html:
448
+ html_parts.append(html)
449
+
450
+ # output node
451
+ data = self.prepare_output(
452
+ meta,
453
+ item,
454
+ flush=False,
455
+ prev_ctx=prev_ctx,
456
+ next_ctx=next_ctx,
457
+ )
458
+ if data:
459
+ html = self.prepare_node(
460
+ meta=meta,
461
+ ctx=item,
462
+ html=data,
463
+ type=self.NODE_OUTPUT,
464
+ prev_ctx=prev_ctx,
465
+ next_ctx=next_ctx,
466
+ )
467
+ if html:
468
+ html_parts.append(html)
469
+
470
+ prev_ctx = item
471
+
472
+ # flush all nodes at once
473
+ if html_parts:
474
+ self.append(
475
+ pid,
476
+ "".join(html_parts)
477
+ )
478
+
479
+ html_parts.clear()
480
+ html_parts = None
481
+ prev_ctx = None
482
+ next_ctx = None
483
+ self.pids[pid].use_buffer = False
354
484
  if self.pids[pid].html != "":
355
485
  self.append(
356
486
  pid,
357
487
  self.pids[pid].html,
358
- flush=True
488
+ flush=True,
359
489
  )
490
+ self.parser.reset()
360
491
 
361
- def append_input(
492
+ def prepare_input(
362
493
  self, meta: CtxMeta,
363
494
  ctx: CtxItem,
364
495
  flush: bool = True,
365
496
  append: bool = False
366
- ):
497
+ ) -> Optional[str]:
367
498
  """
368
- Append text input to output
499
+ Prepare text input
369
500
 
370
501
  :param meta: context meta
371
502
  :param ctx: context item
372
503
  :param flush: flush HTML
373
504
  :param append: True if force append node
505
+ :return: Prepared input text or None if internal or empty input
374
506
  """
375
- self.tool_output_end()
376
- pid = self.get_or_create_pid(meta)
377
- if not flush:
378
- self.clear_chunks_input(pid)
379
-
380
- self.update_names(meta, ctx)
381
507
  if ctx.input is None or ctx.input == "":
382
508
  return
383
509
 
384
510
  text = ctx.input
385
-
386
511
  if isinstance(ctx.extra, dict) and "sub_reply" in ctx.extra and ctx.extra["sub_reply"]:
387
512
  try:
388
513
  json_encoded = json.loads(text)
@@ -402,32 +527,60 @@ class Renderer(BaseRenderer):
402
527
  if ctx.internal and ctx.input.startswith("user: "):
403
528
  text = re.sub(r'^user: ', '> ', ctx.input)
404
529
 
405
- if flush:
406
- if self.is_stream() and not append:
407
- content = self.prepare_node(meta, ctx, text.strip(), self.NODE_INPUT)
408
- self.append_chunk_input(meta, ctx, content, False)
409
- return
530
+ return text.strip()
410
531
 
411
- self.append_node(meta, ctx, text.strip(), self.NODE_INPUT)
532
+ def append_input(
533
+ self, meta: CtxMeta,
534
+ ctx: CtxItem,
535
+ flush: bool = True,
536
+ append: bool = False
537
+ ):
538
+ """
539
+ Append text input to output
412
540
 
413
- def append_output(
541
+ :param meta: context meta
542
+ :param ctx: context item
543
+ :param flush: flush HTML
544
+ :param append: True if force append node
545
+ """
546
+ self.tool_output_end()
547
+ pid = self.get_or_create_pid(meta)
548
+ if not flush:
549
+ self.clear_chunks_input(pid)
550
+
551
+ self.update_names(meta, ctx)
552
+ text = self.prepare_input(meta, ctx, flush, append)
553
+ if text:
554
+ if flush:
555
+ if self.is_stream() and not append:
556
+ content = self.prepare_node(meta, ctx, text, self.NODE_INPUT)
557
+ self.append_chunk_input(meta, ctx, content, begin=False)
558
+ return
559
+ self.append_node(
560
+ meta=meta,
561
+ ctx=ctx,
562
+ html=text,
563
+ type=self.NODE_INPUT,
564
+ )
565
+
566
+ def prepare_output(
414
567
  self,
415
568
  meta: CtxMeta,
416
569
  ctx: CtxItem,
417
570
  flush: bool = True,
418
571
  prev_ctx: Optional[CtxItem] = None,
419
572
  next_ctx: Optional[CtxItem] = None
420
- ):
573
+ ) -> Optional[str]:
421
574
  """
422
- Append text output to output
575
+ Prepare text output
423
576
 
424
577
  :param meta: context meta
425
578
  :param ctx: context item
426
579
  :param flush: flush HTML
427
580
  :param prev_ctx: previous context
428
581
  :param next_ctx: next context
582
+ :return: Prepared output text or None if empty output
429
583
  """
430
- self.tool_output_end()
431
584
  output = ctx.output
432
585
  if isinstance(ctx.extra, dict) and ctx.extra.get("output"):
433
586
  if self.window.core.config.get("llama.idx.chat.agent.render.all", False):
@@ -437,14 +590,42 @@ class Renderer(BaseRenderer):
437
590
  else:
438
591
  if not output:
439
592
  return
440
- self.append_node(
593
+ return output.strip()
594
+
595
+ def append_output(
596
+ self,
597
+ meta: CtxMeta,
598
+ ctx: CtxItem,
599
+ flush: bool = True,
600
+ prev_ctx: Optional[CtxItem] = None,
601
+ next_ctx: Optional[CtxItem] = None
602
+ ):
603
+ """
604
+ Append text output to output
605
+
606
+ :param meta: context meta
607
+ :param ctx: context item
608
+ :param flush: flush HTML
609
+ :param prev_ctx: previous context
610
+ :param next_ctx: next context
611
+ """
612
+ self.tool_output_end()
613
+ output = self.prepare_output(
441
614
  meta=meta,
442
615
  ctx=ctx,
443
- html=output.strip(),
444
- type=self.NODE_OUTPUT,
616
+ flush=flush,
445
617
  prev_ctx=prev_ctx,
446
- next_ctx=next_ctx
618
+ next_ctx=next_ctx,
447
619
  )
620
+ if output:
621
+ self.append_node(
622
+ meta=meta,
623
+ ctx=ctx,
624
+ html=output,
625
+ type=self.NODE_OUTPUT,
626
+ prev_ctx=prev_ctx,
627
+ next_ctx=next_ctx,
628
+ )
448
629
 
449
630
  def append_chunk(
450
631
  self,
@@ -467,10 +648,15 @@ class Renderer(BaseRenderer):
467
648
  if not text_chunk:
468
649
  if begin:
469
650
  pctx.clear()
651
+ self._throttle_emit(pid, force=True)
652
+ self._throttle_reset(pid)
470
653
  return
471
654
 
472
- name_header_str = self.get_name_header(ctx)
473
- self.update_names(meta, ctx)
655
+ if begin: # prepare name and avatar header only at the beginning to avoid unnecessary checks
656
+ pctx.header = self.get_name_header(ctx)
657
+ self.update_names(meta, ctx)
658
+
659
+ name_header_str = pctx.header
474
660
  text_chunk = text_chunk if isinstance(text_chunk, str) else str(text_chunk)
475
661
  text_chunk = text_chunk.translate({ord('<'): '&lt;', ord('>'): '&gt;'})
476
662
 
@@ -479,8 +665,10 @@ class Renderer(BaseRenderer):
479
665
  debug = self.append_debug(ctx, pid, "stream")
480
666
  if debug:
481
667
  text_chunk = debug + text_chunk
482
- pctx.clear() # reset buffer
483
- pctx.is_cmd = False # reset command flag
668
+ self._throttle_emit(pid, force=True)
669
+ self._throttle_reset(pid)
670
+ pctx.clear()
671
+ pctx.is_cmd = False
484
672
  self.clear_chunks_output(pid)
485
673
  self.prev_chunk_replace = False
486
674
 
@@ -496,11 +684,12 @@ class Renderer(BaseRenderer):
496
684
  del buffer_to_parse
497
685
  is_code_block = html.endswith(self.ENDINGS_CODE)
498
686
  is_list = html.endswith(self.ENDINGS_LIST)
499
- is_newline = ("\n" in text_chunk) or buffer.endswith("\n") or is_code_block
687
+ is_n = "\n" in text_chunk
688
+ is_newline = is_n or buffer.endswith("\n") or is_code_block
500
689
  force_replace = False
501
690
  if self.prev_chunk_newline:
502
691
  force_replace = True
503
- if "\n" in text_chunk:
692
+ if is_n:
504
693
  self.prev_chunk_newline = True
505
694
  else:
506
695
  self.prev_chunk_newline = False
@@ -509,38 +698,26 @@ class Renderer(BaseRenderer):
509
698
  if is_newline or force_replace or is_list:
510
699
  replace = True
511
700
  if is_code_block:
512
- # don't replace if it is a code block
513
- if "\n" not in text_chunk:
514
- # if there is no newline in raw_chunk, then don't replace
701
+ if not is_n:
515
702
  replace = False
516
703
 
517
704
  if not is_code_block:
518
- text_chunk = text_chunk.replace("\n", "<br/>")
705
+ if is_n:
706
+ text_chunk = text_chunk.replace("\n", "<br/>")
519
707
  else:
520
- if self.prev_chunk_replace and not has_unclosed_code_tag(text_chunk):
521
- # if previous chunk was replaced and current is code block, then add \n to chunk
522
- text_chunk = "".join(("\n", text_chunk)) # add newline to chunk
708
+ if self.prev_chunk_replace and (is_code_block and not has_unclosed_code_tag(text_chunk)):
709
+ text_chunk = "\n" + text_chunk
523
710
 
524
711
  self.prev_chunk_replace = replace
525
712
 
526
- # hide loading spinner if it is the beginning of the text
527
713
  if begin:
528
714
  try:
529
715
  self.get_output_node(meta).page().runJavaScript("hideLoading();")
530
716
  except Exception:
531
717
  pass
532
718
 
533
- # emit chunk to output node
534
- try:
535
- self.get_output_node(meta).page().bridge.chunk.emit(
536
- name_header_str or "",
537
- self.sanitize_html(html) if replace else "",
538
- self.sanitize_html(text_chunk) if not replace else "",
539
- bool(replace),
540
- bool(is_code_block),
541
- )
542
- except Exception:
543
- pass
719
+ self._throttle_queue(pid, name_header_str or "", html, text_chunk, replace, is_code_block)
720
+ self._throttle_emit(pid, force=False)
544
721
 
545
722
  def next_chunk(
546
723
  self,
@@ -554,6 +731,8 @@ class Renderer(BaseRenderer):
554
731
  :param ctx: context item
555
732
  """
556
733
  pid = self.get_or_create_pid(meta)
734
+ self._throttle_emit(pid, force=True)
735
+ self._throttle_reset(pid)
557
736
  self.pids[pid].item = ctx
558
737
  self.pids[pid].buffer = ""
559
738
  self.update_names(meta, ctx)
@@ -561,7 +740,8 @@ class Renderer(BaseRenderer):
561
740
  self.prev_chunk_newline = False
562
741
  try:
563
742
  self.get_output_node(meta).page().runJavaScript(
564
- "nextStream();")
743
+ "nextStream();"
744
+ )
565
745
  except Exception:
566
746
  pass
567
747
 
@@ -619,6 +799,7 @@ class Renderer(BaseRenderer):
619
799
  if begin:
620
800
  self.pids[pid].live_buffer = ""
621
801
  return
802
+
622
803
  self.update_names(meta, ctx)
623
804
  raw_chunk = str(text_chunk).translate({ord('<'): '&lt;', ord('>'): '&gt;'})
624
805
  if begin:
@@ -635,7 +816,6 @@ class Renderer(BaseRenderer):
635
816
  to_append = self.pids[pid].live_buffer
636
817
  if has_unclosed_code_tag(self.pids[pid].live_buffer):
637
818
  to_append += "\n```"
638
- print(to_append)
639
819
  try:
640
820
  self.get_output_node(meta).page().runJavaScript(
641
821
  f"""replaceLive({self.to_json(
@@ -645,7 +825,6 @@ class Renderer(BaseRenderer):
645
825
  )});"""
646
826
  )
647
827
  except Exception as e:
648
- print(e)
649
828
  pass
650
829
 
651
830
  def clear_live(self, meta: CtxMeta, ctx: CtxItem):
@@ -698,7 +877,7 @@ class Renderer(BaseRenderer):
698
877
  html=html,
699
878
  type=type,
700
879
  prev_ctx=prev_ctx,
701
- next_ctx=next_ctx
880
+ next_ctx=next_ctx,
702
881
  )
703
882
  )
704
883
 
@@ -717,8 +896,9 @@ class Renderer(BaseRenderer):
717
896
  """
718
897
  if self.pids[pid].loaded and not self.pids[pid].use_buffer:
719
898
  self.clear_chunks(pid)
720
- self.flush_output(pid, html)
721
- self.pids[pid].html = ""
899
+ if html:
900
+ self.flush_output(pid, html)
901
+ self.pids[pid].clear()
722
902
  else:
723
903
  if not flush:
724
904
  self.pids[pid].append_html(html)
@@ -741,14 +921,14 @@ class Renderer(BaseRenderer):
741
921
  self.append_input(
742
922
  meta,
743
923
  ctx,
744
- flush=False
924
+ flush=False,
745
925
  )
746
926
  self.append_output(
747
927
  meta,
748
928
  ctx,
749
929
  flush=False,
750
930
  prev_ctx=prev_ctx,
751
- next_ctx=next_ctx
931
+ next_ctx=next_ctx,
752
932
  )
753
933
 
754
934
  def append_extra(
@@ -911,6 +1091,7 @@ class Renderer(BaseRenderer):
911
1091
  node.reset_current_content()
912
1092
  self.reset_names_by_pid(pid)
913
1093
  self.prev_chunk_replace = False
1094
+ self._throttle_reset(pid)
914
1095
 
915
1096
  def clear_input(self):
916
1097
  """Clear input"""
@@ -977,6 +1158,7 @@ class Renderer(BaseRenderer):
977
1158
  self.get_output_node_by_pid(pid).page().runJavaScript(js)
978
1159
  except Exception:
979
1160
  pass
1161
+ self._throttle_reset(pid)
980
1162
 
981
1163
  def clear_nodes(
982
1164
  self,
@@ -1023,7 +1205,7 @@ class Renderer(BaseRenderer):
1023
1205
  ctx=ctx,
1024
1206
  html=html,
1025
1207
  prev_ctx=prev_ctx,
1026
- next_ctx=next_ctx
1208
+ next_ctx=next_ctx,
1027
1209
  )
1028
1210
  elif type == self.NODE_INPUT:
1029
1211
  return self.prepare_node_input(
@@ -1031,7 +1213,7 @@ class Renderer(BaseRenderer):
1031
1213
  ctx=ctx,
1032
1214
  html=html,
1033
1215
  prev_ctx=prev_ctx,
1034
- next_ctx=next_ctx
1216
+ next_ctx=next_ctx,
1035
1217
  )
1036
1218
 
1037
1219
  def prepare_node_input(
@@ -1191,12 +1373,11 @@ class Renderer(BaseRenderer):
1191
1373
  """
1192
1374
  try:
1193
1375
  self.get_output_node_by_pid(pid).page().runJavaScript(
1194
- f"""if (typeof window.appendNode !== 'undefined') appendNode({self.to_json(
1195
- self.sanitize_html(html)
1196
- )});"""
1376
+ f"""if (typeof window.appendNode !== 'undefined') appendNode({self.to_json(self.sanitize_html(html))});"""
1197
1377
  )
1198
1378
  except Exception:
1199
1379
  pass
1380
+ html = None
1200
1381
 
1201
1382
  def reload(self):
1202
1383
  """Reload output, called externally only on theme change to redraw content"""
@@ -1232,17 +1413,11 @@ class Renderer(BaseRenderer):
1232
1413
  pid = self.get_or_create_pid(meta)
1233
1414
  if pid is None:
1234
1415
  return
1235
- html = self.body.get_html(pid)
1236
- self.pids[pid].loaded = False
1237
1416
  node = self.get_output_node_by_pid(pid)
1238
1417
  if node is not None:
1239
- # hard reset
1240
- # old_view = node
1241
- # new_view = old_view.hard_reset()
1242
- # self.window.ui.nodes['output'][pid] = new_view
1243
1418
  node.resetPage()
1244
- node.setHtml(html, baseUrl="file://")
1245
- self.pids[pid].html = ""
1419
+
1420
+ self._throttle_reset(pid)
1246
1421
 
1247
1422
  def get_output_node(
1248
1423
  self,
@@ -1284,7 +1459,8 @@ class Renderer(BaseRenderer):
1284
1459
  """
1285
1460
  try:
1286
1461
  self.get_output_node(ctx.meta).page().runJavaScript(
1287
- f"if (typeof window.removeNode !== 'undefined') removeNode({self.to_json(ctx.id)});")
1462
+ f"if (typeof window.removeNode !== 'undefined') removeNode({self.to_json(ctx.id)});"
1463
+ )
1288
1464
  except Exception:
1289
1465
  pass
1290
1466
 
@@ -1296,7 +1472,8 @@ class Renderer(BaseRenderer):
1296
1472
  """
1297
1473
  try:
1298
1474
  self.get_output_node(ctx.meta).page().runJavaScript(
1299
- f"if (typeof window.removeNodesFromId !== 'undefined') removeNodesFromId({self.to_json(ctx.id)});")
1475
+ f"if (typeof window.removeNodesFromId !== 'undefined') removeNodesFromId({self.to_json(ctx.id)});"
1476
+ )
1300
1477
  except Exception:
1301
1478
  pass
1302
1479
 
@@ -1421,6 +1598,7 @@ class Renderer(BaseRenderer):
1421
1598
  self.clear_chunks(pid)
1422
1599
  self.clear_nodes(pid)
1423
1600
  self.pids[pid].html = ""
1601
+ self._throttle_reset(pid)
1424
1602
 
1425
1603
  def scroll_to_bottom(self):
1426
1604
  """Scroll to bottom"""
@@ -1457,12 +1635,16 @@ class Renderer(BaseRenderer):
1457
1635
  for node in nodes:
1458
1636
  try:
1459
1637
  node.page().runJavaScript(
1460
- f"if (typeof window.updateCSS !== 'undefined') updateCSS({to_json});")
1638
+ f"if (typeof window.updateCSS !== 'undefined') updateCSS({to_json});"
1639
+ )
1461
1640
  if self.window.core.config.get('render.blocks'):
1462
- node.page().runJavaScript("if (typeof window.enableBlocks !== 'undefined') enableBlocks();")
1641
+ node.page().runJavaScript(
1642
+ "if (typeof window.enableBlocks !== 'undefined') enableBlocks();"
1643
+ )
1463
1644
  else:
1464
1645
  node.page().runJavaScript(
1465
- "if (typeof window.disableBlocks !== 'undefined') disableBlocks();")
1646
+ "if (typeof window.disableBlocks !== 'undefined') disableBlocks();"
1647
+ )
1466
1648
  except Exception as e:
1467
1649
  pass
1468
1650
  return
@@ -1573,6 +1755,7 @@ class Renderer(BaseRenderer):
1573
1755
  :param ctx: context item
1574
1756
  :param pid: context PID
1575
1757
  :param title: debug title
1758
+ :return: HTML debug info
1576
1759
  """
1577
1760
  if title is None:
1578
1761
  title = "debug"
@@ -1589,6 +1772,130 @@ class Renderer(BaseRenderer):
1589
1772
  def remove_pid(self, pid: int):
1590
1773
  """
1591
1774
  Remove PID from renderer
1775
+
1776
+ :param pid: context PID
1592
1777
  """
1593
1778
  if pid in self.pids:
1594
- del self.pids[pid]
1779
+ del self.pids[pid]
1780
+ self._thr.pop(pid, None)
1781
+
1782
+ def _throttle_get(self, pid: int) -> dict:
1783
+ """
1784
+ Return per-pid throttle state
1785
+
1786
+ :param pid: context PID
1787
+ :return: throttle state dictionary
1788
+ """
1789
+ thr = self._thr.get(pid)
1790
+ if thr is None:
1791
+ thr = {"last": 0.0, "op": 0, "name": "", "replace_html": "", "append": [], "code": False}
1792
+ self._thr[pid] = thr
1793
+ return thr
1794
+
1795
+ def _throttle_reset(self, pid: Optional[int]):
1796
+ """
1797
+ Reset throttle state
1798
+
1799
+ :param pid: context PID
1800
+ """
1801
+ if pid is None:
1802
+ return
1803
+ thr = self._thr.get(pid)
1804
+ if thr is None:
1805
+ return
1806
+ thr["op"] = 0
1807
+ thr["name"] = ""
1808
+ thr["replace_html"] = ""
1809
+ thr["append"].clear()
1810
+ thr["code"] = False
1811
+
1812
+ def _throttle_queue(
1813
+ self,
1814
+ pid: int,
1815
+ name: str,
1816
+ html: str,
1817
+ text_chunk: str,
1818
+ replace: bool,
1819
+ is_code_block: bool
1820
+ ):
1821
+ """
1822
+ Queue text chunk for throttled output
1823
+
1824
+ :param pid: context PID
1825
+ :param name: name header string
1826
+ :param html: HTML content to replace or append
1827
+ :param text_chunk: text chunk to append
1828
+ :param replace: True if the chunk should replace existing content
1829
+ :param is_code_block: True if the chunk is a code block
1830
+ """
1831
+ thr = self._throttle_get(pid)
1832
+ if name:
1833
+ thr["name"] = name
1834
+
1835
+ if replace:
1836
+ thr["op"] = 1
1837
+ thr["replace_html"] = html
1838
+ thr["append"].clear()
1839
+ thr["code"] = bool(is_code_block)
1840
+ else:
1841
+ if thr["op"] == 1:
1842
+ thr["replace_html"] = html
1843
+ thr["code"] = bool(is_code_block)
1844
+ return
1845
+ thr["op"] = 2
1846
+ thr["append"].append(text_chunk)
1847
+ thr["code"] = bool(is_code_block)
1848
+
1849
+ def _throttle_emit(self, pid: int, force: bool = False):
1850
+ """
1851
+ Emit throttled output to the node
1852
+
1853
+ :param pid: context PID
1854
+ :param force: Force emit even if throttle interval has not passed
1855
+ """
1856
+ thr = self._throttle_get(pid)
1857
+ now = monotonic()
1858
+ if not force and (now - thr["last"] < self._throttle_interval):
1859
+ return
1860
+
1861
+ node = self.get_output_node_by_pid(pid)
1862
+ if node is None:
1863
+ return
1864
+
1865
+ try:
1866
+ if thr["op"] == 1:
1867
+ node.page().bridge.chunk.emit(
1868
+ thr["name"],
1869
+ self.sanitize_html(thr["replace_html"]),
1870
+ "",
1871
+ True,
1872
+ bool(thr["code"]),
1873
+ )
1874
+ thr["last"] = now
1875
+
1876
+ if thr["append"]:
1877
+ append_str = "".join(thr["append"])
1878
+ node.page().bridge.chunk.emit(
1879
+ thr["name"],
1880
+ "",
1881
+ self.sanitize_html(append_str),
1882
+ False,
1883
+ bool(thr["code"]),
1884
+ )
1885
+ thr["last"] = now
1886
+
1887
+ self._throttle_reset(pid)
1888
+
1889
+ elif thr["op"] == 2 and thr["append"]:
1890
+ append_str = "".join(thr["append"])
1891
+ node.page().bridge.chunk.emit(
1892
+ thr["name"],
1893
+ "",
1894
+ self.sanitize_html(append_str),
1895
+ False,
1896
+ bool(thr["code"]),
1897
+ )
1898
+ thr["last"] = now
1899
+ self._throttle_reset(pid)
1900
+ except Exception:
1901
+ pass