pygpt-net 2.6.43__py3-none-any.whl → 2.6.45__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pygpt_net/CHANGELOG.txt +13 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +1 -1
  4. pygpt_net/controller/ctx/ctx.py +6 -0
  5. pygpt_net/controller/debug/debug.py +7 -19
  6. pygpt_net/controller/debug/fixtures.py +103 -0
  7. pygpt_net/core/debug/console/console.py +2 -1
  8. pygpt_net/core/debug/debug.py +1 -1
  9. pygpt_net/core/fixtures/stream/__init__.py +0 -0
  10. pygpt_net/{provider/api/fake → core/fixtures/stream}/generator.py +2 -3
  11. pygpt_net/core/render/web/body.py +294 -23
  12. pygpt_net/core/render/web/helpers.py +26 -0
  13. pygpt_net/core/render/web/renderer.py +457 -704
  14. pygpt_net/data/config/config.json +10 -6
  15. pygpt_net/data/config/models.json +3 -3
  16. pygpt_net/data/config/settings.json +59 -19
  17. pygpt_net/data/fixtures/fake_stream.txt +5733 -0
  18. pygpt_net/data/js/app.js +2617 -1315
  19. pygpt_net/data/locale/locale.en.ini +12 -5
  20. pygpt_net/js_rc.py +14272 -10602
  21. pygpt_net/provider/api/openai/__init__.py +4 -12
  22. pygpt_net/provider/core/config/patch.py +14 -1
  23. pygpt_net/ui/base/context_menu.py +3 -2
  24. pygpt_net/ui/layout/chat/output.py +1 -1
  25. pygpt_net/ui/layout/ctx/ctx_list.py +3 -3
  26. pygpt_net/ui/menu/debug.py +36 -23
  27. pygpt_net/ui/widget/lists/context.py +233 -51
  28. pygpt_net/ui/widget/textarea/web.py +4 -4
  29. pygpt_net/utils.py +3 -2
  30. {pygpt_net-2.6.43.dist-info → pygpt_net-2.6.45.dist-info}/METADATA +72 -14
  31. {pygpt_net-2.6.43.dist-info → pygpt_net-2.6.45.dist-info}/RECORD +35 -32
  32. /pygpt_net/{provider/api/fake/__init__.py → core/fixtures/__init__} +0 -0
  33. {pygpt_net-2.6.43.dist-info → pygpt_net-2.6.45.dist-info}/LICENSE +0 -0
  34. {pygpt_net-2.6.43.dist-info → pygpt_net-2.6.45.dist-info}/WHEEL +0 -0
  35. {pygpt_net-2.6.43.dist-info → pygpt_net-2.6.45.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.11 08:00:00 #
9
+ # Updated Date: 2025.09.13 06:10:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import gc
@@ -17,7 +17,7 @@ import html as _html
17
17
  from dataclasses import dataclass, field
18
18
 
19
19
  from datetime import datetime
20
- from typing import Optional, List, Any
20
+ from typing import Optional, List, Any, Dict, Tuple
21
21
  from time import monotonic
22
22
  from io import StringIO
23
23
 
@@ -41,6 +41,55 @@ from .pid import PidData
41
41
  from pygpt_net.core.events import RenderEvent
42
42
 
43
43
 
44
+ @dataclass(slots=True)
45
+ class RenderBlock:
46
+ """
47
+ JSON payload for node rendering in JS templates.
48
+
49
+ Keep only raw data here. HTML is avoided except where there is
50
+ no easy way to keep raw (e.g. plugin-provided tool extras), which are
51
+ carried under extra.tool_extra_html.
52
+ """
53
+ id: int
54
+ meta_id: Optional[int] = None
55
+ input: Optional[dict] = None
56
+ output: Optional[dict] = None
57
+ files: dict = field(default_factory=dict)
58
+ images: dict = field(default_factory=dict)
59
+ urls: dict = field(default_factory=dict)
60
+ tools: dict = field(default_factory=dict)
61
+ tools_outputs: dict = field(default_factory=dict)
62
+ extra: dict = field(default_factory=dict)
63
+
64
+ def to_dict(self) -> dict:
65
+ return {
66
+ "id": self.id,
67
+ "meta_id": self.meta_id,
68
+ "input": self.input,
69
+ "output": self.output,
70
+ "files": self.files,
71
+ "images": self.images,
72
+ "urls": self.urls,
73
+ "tools": self.tools,
74
+ "tools_outputs": self.tools_outputs,
75
+ "extra": self.extra,
76
+ }
77
+
78
+ def to_json(self, wrap: bool = True) -> str:
79
+ """
80
+ Convert node to JSON string.
81
+
82
+ :param wrap: wrap into {"node": {...}} (single appendNode case)
83
+ """
84
+ data = self.to_dict()
85
+ if wrap:
86
+ return json.dumps({"node": data}, ensure_ascii=False, separators=(',', ':'))
87
+ return json.dumps(data, ensure_ascii=False, separators=(',', ':'))
88
+
89
+ def debug(self) -> str:
90
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
91
+
92
+
44
93
  class Renderer(BaseRenderer):
45
94
 
46
95
  NODE_INPUT = 0
@@ -71,7 +120,6 @@ class Renderer(BaseRenderer):
71
120
  return ""
72
121
  data = self._buf.getvalue()
73
122
  old = self._buf
74
- # Replace the internal buffer instance to drop capacity immediately
75
123
  self._buf = StringIO()
76
124
  self._size = 0
77
125
  try:
@@ -124,13 +172,10 @@ class Renderer(BaseRenderer):
124
172
  # ------------------------------------------------------------------
125
173
  # Python-side micro-batching for streaming
126
174
  # ------------------------------------------------------------------
127
- # render.stream.interval_ms (int, default 100)
128
- # render.stream.max_bytes (int, default 8192)
129
- # render.stream.emergency_bytes (int, default 524288)
130
175
 
131
- self._stream_interval_ms: int = _cfg('render.stream.interval_ms', 30) # ~100ms pacing
132
- self._stream_max_bytes: int = _cfg('render.stream.max_bytes', 8 * 1024) # idle flush threshold
133
- self._stream_emergency_bytes: int = _cfg('render.stream.emergency_bytes', 512 * 1024) # backstop
176
+ self._stream_interval_ms: int = _cfg('render.stream.interval_ms', 30)
177
+ self._stream_max_bytes: int = _cfg('render.stream.max_bytes', 8 * 1024)
178
+ self._stream_emergency_bytes: int = _cfg('render.stream.emergency_bytes', 512 * 1024)
134
179
 
135
180
  # Per-PID streaming state
136
181
  self._stream_acc: dict[int, Renderer._AppendBuffer] = {}
@@ -280,7 +325,7 @@ class Renderer(BaseRenderer):
280
325
  """
281
326
  On kernel state changed event
282
327
 
283
- :param state: state name
328
+ :param state: new state
284
329
  :param meta: context meta
285
330
  """
286
331
  if state == RenderEvent.STATE_BUSY:
@@ -292,7 +337,7 @@ class Renderer(BaseRenderer):
292
337
  node.page().runJavaScript(
293
338
  "if (typeof window.showLoading !== 'undefined') showLoading();"
294
339
  )
295
- except Exception as e:
340
+ except Exception:
296
341
  pass
297
342
 
298
343
  elif state == RenderEvent.STATE_IDLE:
@@ -303,7 +348,7 @@ class Renderer(BaseRenderer):
303
348
  node.page().runJavaScript(
304
349
  "if (typeof window.hideLoading !== 'undefined') hideLoading();"
305
350
  )
306
- except Exception as e:
351
+ except Exception:
307
352
  pass
308
353
 
309
354
  elif state == RenderEvent.STATE_ERROR:
@@ -314,21 +359,16 @@ class Renderer(BaseRenderer):
314
359
  node.page().runJavaScript(
315
360
  "if (typeof window.hideLoading !== 'undefined') hideLoading();"
316
361
  )
317
- except Exception as e:
362
+ except Exception:
318
363
  pass
319
364
 
320
- def begin(
321
- self,
322
- meta: CtxMeta,
323
- ctx: CtxItem,
324
- stream: bool = False
325
- ):
365
+ def begin(self, meta: CtxMeta, ctx: CtxItem, stream: bool = False):
326
366
  """
327
367
  Render begin
328
368
 
329
369
  :param meta: context meta
330
370
  :param ctx: context item
331
- :param stream: True if it is a stream
371
+ :param stream: True if streaming mode
332
372
  """
333
373
  pid = self.get_or_create_pid(meta)
334
374
  self.init(pid)
@@ -336,18 +376,13 @@ class Renderer(BaseRenderer):
336
376
  self.tool_output_end()
337
377
  self.prev_chunk_replace = False
338
378
 
339
- def end(
340
- self,
341
- meta: CtxMeta,
342
- ctx: CtxItem,
343
- stream: bool = False
344
- ):
379
+ def end(self, meta: CtxMeta, ctx: CtxItem, stream: bool = False):
345
380
  """
346
381
  Render end
347
382
 
348
383
  :param meta: context meta
349
384
  :param ctx: context item
350
- :param stream: True if it is a stream
385
+ :param stream: True if streaming mode
351
386
  """
352
387
  pid = self.get_or_create_pid(meta)
353
388
  if pid is None:
@@ -358,30 +393,19 @@ class Renderer(BaseRenderer):
358
393
  else:
359
394
  self.reload()
360
395
  self.pids[pid].clear()
361
-
362
- # memory cleanup if needed
363
396
  self.auto_cleanup(meta)
364
397
 
365
- def end_extra(
366
- self,
367
- meta: CtxMeta,
368
- ctx: CtxItem,
369
- stream: bool = False
370
- ):
398
+ def end_extra(self, meta: CtxMeta, ctx: CtxItem, stream: bool = False):
371
399
  """
372
400
  Render end extra
373
401
 
374
402
  :param meta: context meta
375
403
  :param ctx: context item
376
- :param stream: True if it is a stream
404
+ :param stream: True if streaming mode
377
405
  """
378
406
  self.to_end(ctx)
379
407
 
380
- def stream_begin(
381
- self,
382
- meta: CtxMeta,
383
- ctx: CtxItem
384
- ):
408
+ def stream_begin(self, meta: CtxMeta, ctx: CtxItem):
385
409
  """
386
410
  Render stream begin
387
411
 
@@ -401,18 +425,13 @@ class Renderer(BaseRenderer):
401
425
  except Exception:
402
426
  pass
403
427
 
404
- # cache name header once per stream (used by JS to show avatar/name)
405
428
  try:
406
429
  self.pids[pid].header = self.get_name_header(ctx, stream=True)
407
430
  except Exception:
408
431
  self.pids[pid].header = ""
409
432
  self.update_names(meta, ctx)
410
433
 
411
- def stream_end(
412
- self,
413
- meta: CtxMeta,
414
- ctx: CtxItem
415
- ):
434
+ def stream_end(self, meta: CtxMeta, ctx: CtxItem):
416
435
  """
417
436
  Render stream end
418
437
 
@@ -424,7 +443,6 @@ class Renderer(BaseRenderer):
424
443
  if pid is None:
425
444
  return
426
445
 
427
- # Flush any pending micro-batch before we close the stream
428
446
  self._stream_flush(pid, force=True)
429
447
  if self.window.controller.agent.legacy.enabled():
430
448
  if self.pids[pid].item is not None:
@@ -435,24 +453,21 @@ class Renderer(BaseRenderer):
435
453
  self.get_output_node(meta).page().runJavaScript("if (typeof window.endStream !== 'undefined') endStream();")
436
454
  except Exception:
437
455
  pass
438
- self._stream_reset(pid) # clean after end
439
-
440
- # memory cleanup if needed
456
+ self._stream_reset(pid)
441
457
  self.auto_cleanup(meta)
442
458
 
443
459
  def auto_cleanup(self, meta: CtxMeta):
444
460
  """
445
461
  Automatic cleanup after context is done
446
462
 
447
- :param meta: context meta
463
+ If memory limit is set, perform fresh() cleanup when exceeded.
448
464
  """
449
- # if memory limit reached - destroy old page view
450
465
  try:
451
466
  limit_bytes = parse_bytes(self.window.core.config.get('render.memory.limit', 0))
452
467
  except Exception as e:
453
468
  self.window.core.debug.log("[Renderer] auto-cleanup:", e)
454
469
  limit_bytes = 0
455
- #if limit_bytes <= 0 or limit_bytes < self._min_memory_cleanup_bytes:
470
+
456
471
  if limit_bytes <= 0:
457
472
  self.auto_cleanup_soft(meta)
458
473
  return
@@ -474,47 +489,36 @@ class Renderer(BaseRenderer):
474
489
 
475
490
  def auto_cleanup_soft(self, meta: CtxMeta = None):
476
491
  """
477
- Automatic soft cleanup (try to trim memory)
492
+ Try to trim memory on Linux
478
493
 
479
- :param meta: context meta
494
+ Soft cleanup, called after each context is done.
480
495
  """
481
496
  try:
482
497
  malloc_trim_linux()
483
498
  except Exception:
484
499
  pass
485
500
 
486
- def append_context(
487
- self,
488
- meta: CtxMeta,
489
- items: List[CtxItem],
490
- clear: bool = True
491
- ):
501
+ def append_context(self, meta: CtxMeta, items: List[CtxItem], clear: bool = True):
492
502
  """
493
503
  Append all context items to output
494
504
 
495
- :param meta: Context meta
496
- :param items: context items
497
- :param clear: True if clear all output before append
505
+ :param meta: context meta
506
+ :param items: list of context items
507
+ :param clear: clear previous content
498
508
  """
499
509
  self.tool_output_end()
500
- self.append_context_all(
501
- meta,
502
- items,
503
- clear=clear,
504
- )
510
+ self.append_context_all(meta, items, clear=clear)
505
511
 
506
- def append_context_partial(
507
- self,
508
- meta: CtxMeta,
509
- items: List[CtxItem],
510
- clear: bool = True
511
- ):
512
+ def append_context_partial(self, meta: CtxMeta, items: List[CtxItem], clear: bool = True):
512
513
  """
513
- Append all context items to output (part by part)
514
+ Append context items part-by-part
514
515
 
515
- :param meta: Context meta
516
- :param items: context items
517
- :param clear: True if clear all output before append
516
+ Append each item as it comes, useful for non-streaming mode when
517
+ context is built gradually.
518
+
519
+ :param meta: context meta
520
+ :param items: list of context items
521
+ :param clear: clear previous content
518
522
  """
519
523
  if len(items) == 0:
520
524
  if meta is None:
@@ -522,7 +526,6 @@ class Renderer(BaseRenderer):
522
526
 
523
527
  pid = self.get_or_create_pid(meta)
524
528
  self.init(pid)
525
-
526
529
  if clear:
527
530
  self.reset(meta)
528
531
 
@@ -537,36 +540,32 @@ class Renderer(BaseRenderer):
537
540
  if i == 0:
538
541
  item.first = True
539
542
  next_item = items[i + 1] if i + 1 < total else None
540
- self.append_context_item(
541
- meta,
542
- item,
543
- prev_ctx=prev_ctx,
544
- next_ctx=next_item,
545
- )
543
+
544
+ # ignore hidden
545
+ if item.hidden:
546
+ prev_ctx = item
547
+ continue
548
+
549
+ # build single RenderBlock with both input and output (if present)
550
+ input_text = self.prepare_input(meta, item, flush=False, append=False)
551
+ output_text = self.prepare_output(meta, item, flush=False, prev_ctx=prev_ctx, next_ctx=next_item)
552
+ block = self._build_render_block(meta, item, input_text, output_text, prev_ctx=prev_ctx, next_ctx=next_item)
553
+ if block:
554
+ self.append(pid, block.to_json(wrap=True))
555
+
546
556
  prev_ctx = item
547
557
 
548
- prev_ctx = None
549
- next_item = None
550
558
  self.pids[pid].use_buffer = False
551
559
  if self.pids[pid].html != "":
552
- self.append(
553
- pid,
554
- self.pids[pid].html,
555
- flush=True,
556
- )
560
+ self.append(pid, self.pids[pid].html, flush=True)
557
561
 
558
- def append_context_all(
559
- self,
560
- meta: CtxMeta,
561
- items: List[CtxItem],
562
- clear: bool = True
563
- ):
562
+ def append_context_all(self, meta: CtxMeta, items: List[CtxItem], clear: bool = True):
564
563
  """
565
- Append all context items to output (whole context at once)
564
+ Append whole context at once, using JSON nodes
566
565
 
567
- :param meta: Context meta
568
- :param items: context items
569
- :param clear: True if clear all output before append
566
+ :param meta: context meta
567
+ :param items: list of context items
568
+ :param clear: clear previous content
570
569
  """
571
570
  if len(items) == 0:
572
571
  if meta is None:
@@ -576,14 +575,16 @@ class Renderer(BaseRenderer):
576
575
  self.init(pid)
577
576
 
578
577
  if clear:
579
- self.reset(meta, clear_nodes=False) # nodes will be cleared later, in flush_output()
578
+ # nodes will be cleared on JS when replace flag is True
579
+ self.reset(meta, clear_nodes=False)
580
580
 
581
581
  self.pids[pid].use_buffer = True
582
582
  self.pids[pid].html = ""
583
583
  prev_ctx = None
584
584
  next_ctx = None
585
585
  total = len(items)
586
- html_parts = []
586
+ nodes: List[dict] = []
587
+
587
588
  for i, item in enumerate(items):
588
589
  self.update_names(meta, item)
589
590
  item.idx = i
@@ -591,82 +592,37 @@ class Renderer(BaseRenderer):
591
592
  item.first = True
592
593
  next_ctx = items[i + 1] if i + 1 < total else None
593
594
 
594
- # ignore hidden items
595
595
  if item.hidden:
596
596
  prev_ctx = item
597
597
  continue
598
598
 
599
- # input node
600
- data = self.prepare_input(meta, item, flush=False)
601
- if data:
602
- html = self.prepare_node(
603
- meta=meta,
604
- ctx=item,
605
- html=data,
606
- type=self.NODE_INPUT,
607
- prev_ctx=prev_ctx,
608
- next_ctx=next_ctx,
609
- )
610
- if html:
611
- html_parts.append(html)
612
-
613
- # output node
614
- data = self.prepare_output(
615
- meta,
616
- item,
617
- flush=False,
618
- prev_ctx=prev_ctx,
619
- next_ctx=next_ctx,
620
- )
621
- if data:
622
- html = self.prepare_node(
623
- meta=meta,
624
- ctx=item,
625
- html=data,
626
- type=self.NODE_OUTPUT,
627
- prev_ctx=prev_ctx,
628
- next_ctx=next_ctx,
629
- )
630
- if html:
631
- html_parts.append(html)
599
+ input_text = self.prepare_input(meta, item, flush=False, append=False)
600
+ output_text = self.prepare_output(meta, item, flush=False, prev_ctx=prev_ctx, next_ctx=next_ctx)
601
+ block = self._build_render_block(meta, item, input_text, output_text, prev_ctx=prev_ctx, next_ctx=next_ctx)
602
+ if block:
603
+ nodes.append(block.to_dict())
632
604
 
633
605
  prev_ctx = item
634
606
 
635
- # flush all nodes at once
636
- if html_parts:
637
- self.append(
638
- pid,
639
- "".join(html_parts),
640
- replace=True,
641
- )
607
+ if nodes:
608
+ payload = json.dumps({"nodes": nodes}, ensure_ascii=False, separators=(',', ':'))
609
+ self.append(pid, payload, replace=True)
642
610
 
643
- html_parts.clear()
644
- html_parts = None
645
611
  prev_ctx = None
646
612
  next_ctx = None
647
613
  self.pids[pid].use_buffer = False
648
614
  if self.pids[pid].html != "":
649
- self.append(
650
- pid,
651
- self.pids[pid].html,
652
- flush=True,
653
- replace=True,
654
- )
615
+ self.append(pid, self.pids[pid].html, flush=True, replace=True)
655
616
 
656
- def prepare_input(
657
- self, meta: CtxMeta,
658
- ctx: CtxItem,
659
- flush: bool = True,
660
- append: bool = False
661
- ) -> Optional[str]:
617
+ def prepare_input(self, meta: CtxMeta, ctx: CtxItem, flush: bool = True, append: bool = False) -> Optional[str]:
662
618
  """
663
- Prepare text input
619
+ Prepare text input (raw, no HTML)
664
620
 
665
621
  :param meta: context meta
666
622
  :param ctx: context item
667
- :param flush: flush HTML
668
- :param append: True if force append node
669
- :return: Prepared input text or None if internal or empty input
623
+ :param flush: True if flush input area (legacy)
624
+ :param append: True if append to input area (legacy)
625
+ :return: prepared text or None
670
626
  """
671
627
  if ctx.input is None or ctx.input == "":
672
628
  return
@@ -691,21 +647,16 @@ class Renderer(BaseRenderer):
691
647
  if ctx.internal and ctx.input.startswith("user: "):
692
648
  text = re.sub(r'^user: ', '> ', ctx.input)
693
649
 
694
- return text.strip()
650
+ return str(text).strip()
695
651
 
696
- def append_input(
697
- self, meta: CtxMeta,
698
- ctx: CtxItem,
699
- flush: bool = True,
700
- append: bool = False
701
- ):
652
+ def append_input(self, meta: CtxMeta, ctx: CtxItem, flush: bool = True, append: bool = False):
702
653
  """
703
- Append text input to output
654
+ Append user input as RenderBlock JSON
704
655
 
705
656
  :param meta: context meta
706
657
  :param ctx: context item
707
- :param flush: flush HTML
708
- :param append: True if force append node
658
+ :param flush: True if flush input area (legacy)
659
+ :param append: True if append to input area (legacy)
709
660
  """
710
661
  self.tool_output_end()
711
662
  pid = self.get_or_create_pid(meta)
@@ -717,35 +668,25 @@ class Renderer(BaseRenderer):
717
668
  if text:
718
669
  if flush:
719
670
  if self.is_stream() and not append:
671
+ # legacy streaming input (leave as-is)
720
672
  content = self.prepare_node(meta, ctx, text, self.NODE_INPUT)
721
673
  self.append_chunk_input(meta, ctx, content, begin=False)
722
- text = None
723
674
  return
724
- self.append_node(
725
- meta=meta,
726
- ctx=ctx,
727
- html=text,
728
- type=self.NODE_INPUT,
729
- )
730
- text = None # free reference
675
+ block = self._build_render_block(meta, ctx, input_text=text, output_text=None)
676
+ if block:
677
+ self.append(pid, block.to_json(wrap=True))
731
678
 
732
- def prepare_output(
733
- self,
734
- meta: CtxMeta,
735
- ctx: CtxItem,
736
- flush: bool = True,
737
- prev_ctx: Optional[CtxItem] = None,
738
- next_ctx: Optional[CtxItem] = None
739
- ) -> Optional[str]:
679
+ def prepare_output(self, meta: CtxMeta, ctx: CtxItem, flush: bool = True,
680
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None) -> Optional[str]:
740
681
  """
741
- Prepare text output
682
+ Prepare text output (raw markdown text)
742
683
 
743
684
  :param meta: context meta
744
685
  :param ctx: context item
745
- :param flush: flush HTML
746
- :param prev_ctx: previous context
747
- :param next_ctx: next context
748
- :return: Prepared output text or None if empty output
686
+ :param flush: True if flush output area (legacy)
687
+ :param prev_ctx: previous context item
688
+ :param next_ctx: next context item
689
+ :return: prepared text or None
749
690
  """
750
691
  output = ctx.output
751
692
  if isinstance(ctx.extra, dict) and ctx.extra.get("output"):
@@ -756,60 +697,36 @@ class Renderer(BaseRenderer):
756
697
  else:
757
698
  if not output:
758
699
  return
759
- return output.strip()
700
+ return str(output).strip()
760
701
 
761
- def append_output(
762
- self,
763
- meta: CtxMeta,
764
- ctx: CtxItem,
765
- flush: bool = True,
766
- prev_ctx: Optional[CtxItem] = None,
767
- next_ctx: Optional[CtxItem] = None
768
- ):
702
+ def append_output(self, meta: CtxMeta, ctx: CtxItem, flush: bool = True,
703
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None):
769
704
  """
770
- Append text output to output
705
+ Append bot output as RenderBlock JSON
771
706
 
772
707
  :param meta: context meta
773
708
  :param ctx: context item
774
- :param flush: flush HTML
775
- :param prev_ctx: previous context
776
- :param next_ctx: next context
709
+ :param flush: True if flush output area (legacy)
710
+ :param prev_ctx: previous context item
711
+ :param next_ctx: next context item
777
712
  """
778
713
  self.tool_output_end()
779
- output = self.prepare_output(
780
- meta=meta,
781
- ctx=ctx,
782
- flush=flush,
783
- prev_ctx=prev_ctx,
784
- next_ctx=next_ctx,
785
- )
714
+ output = self.prepare_output(meta=meta, ctx=ctx, flush=flush, prev_ctx=prev_ctx, next_ctx=next_ctx)
786
715
  if output:
787
- self.append_node(
788
- meta=meta,
789
- ctx=ctx,
790
- html=output,
791
- type=self.NODE_OUTPUT,
792
- prev_ctx=prev_ctx,
793
- next_ctx=next_ctx,
794
- )
716
+ block = self._build_render_block(meta, ctx, input_text=None, output_text=output,
717
+ prev_ctx=prev_ctx, next_ctx=next_ctx)
718
+ if block:
719
+ pid = self.get_or_create_pid(meta)
720
+ self.append(pid, block.to_json(wrap=True))
795
721
 
796
- def append_chunk(
797
- self,
798
- meta: CtxMeta,
799
- ctx: CtxItem,
800
- text_chunk: str,
801
- begin: bool = False
802
- ):
722
+ def append_chunk(self, meta: CtxMeta, ctx: CtxItem, text_chunk: str, begin: bool = False):
803
723
  """
804
- Append streamed Markdown chunk to JS with micro-batching.
805
- - No Python-side parsing
806
- - No Python-side full buffer retention
807
- - Minimal signal rate thanks to micro-batching (per PID)
724
+ Append streamed Markdown chunk to JS with micro-batching and typed chunk support.
808
725
 
809
726
  :param meta: context meta
810
727
  :param ctx: context item
811
- :param text_chunk: text chunk
812
- :param begin: if it is the beginning of the text
728
+ :param text_chunk: text chunk to append
729
+ :param begin: True if begin of stream
813
730
  """
814
731
  pid = self.get_or_create_pid(meta)
815
732
  if pid is None:
@@ -819,14 +736,12 @@ class Renderer(BaseRenderer):
819
736
  pctx.item = ctx
820
737
 
821
738
  if begin:
822
- # Reset stream area and loader on the JS side
823
739
  try:
824
740
  self.get_output_node(meta).page().runJavaScript(
825
741
  "if (typeof window.beginStream !== 'undefined') beginStream(true);"
826
742
  )
827
743
  except Exception:
828
744
  pass
829
- # Prepare name header for this stream (avatar/name)
830
745
  pctx.header = self.get_name_header(ctx, stream=True)
831
746
  self._stream_reset(pid)
832
747
  self.update_names(meta, ctx)
@@ -834,16 +749,9 @@ class Renderer(BaseRenderer):
834
749
  if not text_chunk:
835
750
  return
836
751
 
837
- # Push chunk into per-PID micro-batch buffer (will be flushed by a QTimer)
838
752
  self._stream_push(pid, pctx.header or "", str(text_chunk))
839
- # self.get_output_node(meta).page().bridge.chunk.emit(pctx.header or "", str(text_chunk))
840
- text_chunk = None # release ref
841
753
 
842
- def next_chunk(
843
- self,
844
- meta: CtxMeta,
845
- ctx: CtxItem,
846
- ):
754
+ def next_chunk(self, meta: CtxMeta, ctx: CtxItem):
847
755
  """
848
756
  Flush current stream and start with new chunks
849
757
 
@@ -851,7 +759,6 @@ class Renderer(BaseRenderer):
851
759
  :param ctx: context item
852
760
  """
853
761
  pid = self.get_or_create_pid(meta)
854
- # Ensure all pending chunks are delivered before switching message
855
762
  self._stream_flush(pid, force=True)
856
763
  self.pids[pid].item = ctx
857
764
  self.pids[pid].buffer = ""
@@ -864,26 +771,19 @@ class Renderer(BaseRenderer):
864
771
  )
865
772
  except Exception:
866
773
  pass
867
- # Reset micro-batch header for the next message
868
774
  try:
869
775
  self.pids[pid].header = self.get_name_header(ctx, stream=True)
870
776
  except Exception:
871
777
  self.pids[pid].header = ""
872
778
 
873
- def append_chunk_input(
874
- self,
875
- meta: CtxMeta,
876
- ctx: CtxItem,
877
- text_chunk: str,
878
- begin: bool = False
879
- ):
779
+ def append_chunk_input(self, meta: CtxMeta, ctx: CtxItem, text_chunk: str, begin: bool = False):
880
780
  """
881
- Append output chunk to output
781
+ Append output chunk to input area (legacy)
882
782
 
883
783
  :param meta: context meta
884
784
  :param ctx: context item
885
- :param text_chunk: text chunk
886
- :param begin: if it is the beginning of the text
785
+ :param text_chunk: text chunk to append
786
+ :param begin: True if begin of stream
887
787
  """
888
788
  if not text_chunk:
889
789
  return
@@ -891,28 +791,19 @@ class Renderer(BaseRenderer):
891
791
  return
892
792
  try:
893
793
  self.get_output_node(meta).page().bridge.nodeInput.emit(
894
- self.sanitize_html(
895
- text_chunk
896
- )
794
+ self.sanitize_html(text_chunk)
897
795
  )
898
796
  except Exception:
899
797
  pass
900
- text_chunk = None # free reference
901
798
 
902
- def append_live(
903
- self,
904
- meta: CtxMeta,
905
- ctx: CtxItem,
906
- text_chunk: str,
907
- begin: bool = False
908
- ):
799
+ def append_live(self, meta: CtxMeta, ctx: CtxItem, text_chunk: str, begin: bool = False):
909
800
  """
910
- Append live output chunk to output
801
+ Append live output chunk to output (legacy live preview)
911
802
 
912
803
  :param meta: context meta
913
804
  :param ctx: context item
914
- :param text_chunk: text chunk
915
- :param begin: if it is the beginning of the text
805
+ :param text_chunk: text chunk to append
806
+ :param begin: True if begin of stream
916
807
  """
917
808
  pid = self.get_or_create_pid(meta)
918
809
  self.pids[pid].item = ctx
@@ -933,7 +824,6 @@ class Renderer(BaseRenderer):
933
824
  self.pids[pid].is_cmd = False
934
825
  self.clear_live(meta, ctx)
935
826
  self.pids[pid].append_live_buffer(raw_chunk)
936
- raw_chunk = None # free reference
937
827
 
938
828
  try:
939
829
  self.get_output_node(meta).page().runJavaScript(
@@ -943,7 +833,7 @@ class Renderer(BaseRenderer):
943
833
  )
944
834
  )});"""
945
835
  )
946
- except Exception as e:
836
+ except Exception:
947
837
  pass
948
838
 
949
839
  def clear_live(self, meta: CtxMeta, ctx: CtxItem):
@@ -963,22 +853,15 @@ class Renderer(BaseRenderer):
963
853
  except Exception:
964
854
  pass
965
855
 
966
- def append_node(
967
- self,
968
- meta: CtxMeta,
969
- ctx: CtxItem,
970
- html: str,
971
- type: int = 1,
972
- prev_ctx: Optional[CtxItem] = None,
973
- next_ctx: Optional[CtxItem] = None
974
- ):
856
+ def append_node(self, meta: CtxMeta, ctx: CtxItem, html: str, type: int = 1,
857
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None):
975
858
  """
976
- Append and format raw text to output
859
+ Backward compatible: when called, convert HTML-like path to RenderBlock and send JSON.
977
860
 
978
861
  :param meta: context meta
979
- :param html: text to append
980
- :param type: type of message
981
- :param ctx: CtxItem instance
862
+ :param ctx: context item
863
+ :param html: HTML content
864
+ :param type: NODE_INPUT or NODE_OUTPUT
982
865
  :param prev_ctx: previous context item
983
866
  :param next_ctx: next context item
984
867
  """
@@ -986,86 +869,57 @@ class Renderer(BaseRenderer):
986
869
  return
987
870
 
988
871
  pid = self.get_or_create_pid(meta)
989
- self.append(
990
- pid,
991
- self.prepare_node(
992
- meta=meta,
993
- ctx=ctx,
994
- html=html,
995
- type=type,
996
- prev_ctx=prev_ctx,
997
- next_ctx=next_ctx,
998
- )
999
- )
1000
872
 
1001
- def append(
1002
- self,
1003
- pid,
1004
- html: str,
1005
- flush: bool = False,
1006
- replace: bool = False,
1007
- ):
873
+ # Convert to RenderBlock JSON
874
+ input_text = html if type == self.NODE_INPUT else None
875
+ output_text = html if type == self.NODE_OUTPUT else None
876
+ block = self._build_render_block(meta, ctx, input_text, output_text, prev_ctx=prev_ctx, next_ctx=next_ctx)
877
+ if block:
878
+ self.append(pid, block.to_json(wrap=True))
879
+
880
+ def append(self, pid, payload: str, flush: bool = False, replace: bool = False):
1008
881
  """
1009
- Append text to output
882
+ Append payload (HTML legacy or JSON string) to output.
1010
883
 
1011
- :param pid: ctx pid
1012
- :param html: HTML code
1013
- :param flush: True if flush only
1014
- :param replace: True if replace current content
884
+ :param pid: context PID
885
+ :param payload: payload to append
886
+ :param flush: True if flush immediately (legacy HTML path)
1015
887
  """
1016
888
  if self.pids[pid].loaded and not self.pids[pid].use_buffer:
1017
889
  self.clear_chunks(pid)
1018
- if html:
1019
- self.flush_output(pid, html, replace)
890
+ if payload:
891
+ self.flush_output(pid, payload, replace)
1020
892
  self.pids[pid].clear()
1021
893
  else:
1022
894
  if not flush:
1023
- self.pids[pid].append_html(html)
1024
- html = None # free reference
895
+ self.pids[pid].append_html(payload)
1025
896
 
1026
- def append_context_item(
1027
- self,
1028
- meta: CtxMeta,
1029
- ctx: CtxItem,
1030
- prev_ctx: Optional[CtxItem] = None,
1031
- next_ctx: Optional[CtxItem] = None
1032
- ):
897
+ def append_context_item(self, meta: CtxMeta, ctx: CtxItem,
898
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None):
1033
899
  """
1034
- Append context item to output
900
+ Append context item as one RenderBlock with input+output (if present)
1035
901
 
1036
902
  :param meta: context meta
1037
903
  :param ctx: context item
1038
904
  :param prev_ctx: previous context item
1039
905
  :param next_ctx: next context item
1040
906
  """
1041
- self.append_input(
1042
- meta,
1043
- ctx,
1044
- flush=False,
1045
- )
1046
- self.append_output(
1047
- meta,
1048
- ctx,
1049
- flush=False,
1050
- prev_ctx=prev_ctx,
1051
- next_ctx=next_ctx,
1052
- )
1053
-
1054
- def append_extra(
1055
- self,
1056
- meta: CtxMeta,
1057
- ctx: CtxItem,
1058
- footer: bool = False,
1059
- render: bool = True
1060
- ) -> str:
907
+ input_text = self.prepare_input(meta, ctx, flush=False, append=False)
908
+ output_text = self.prepare_output(meta, ctx, flush=False, prev_ctx=prev_ctx, next_ctx=next_ctx)
909
+ block = self._build_render_block(meta, ctx, input_text, output_text, prev_ctx=prev_ctx, next_ctx=next_ctx)
910
+ if block:
911
+ pid = self.get_or_create_pid(meta)
912
+ self.append(pid, block.to_json(wrap=True))
913
+
914
+ def append_extra(self, meta: CtxMeta, ctx: CtxItem, footer: bool = False, render: bool = True) -> str:
1061
915
  """
1062
- Append extra data (images, files, etc.) to output
916
+ Append extra data (legacy HTML way) kept for runtime calls that append later.
1063
917
 
1064
- :param meta: Context meta
918
+ :param meta: context meta
1065
919
  :param ctx: context item
1066
- :param footer: True if it is a footer
1067
- :param render: True if render, False if only return HTML
1068
- :return: HTML code
920
+ :param footer: True if append at the end (legacy)
921
+ :param render: True if render to output (legacy)
922
+ :return: rendered HTML
1069
923
  """
1070
924
  self.tool_output_end()
1071
925
 
@@ -1086,7 +940,7 @@ class Renderer(BaseRenderer):
1086
940
  html_parts.append(self.body.get_image_html(image, n, c))
1087
941
  self.pids[pid].images_appended.append(image)
1088
942
  n += 1
1089
- except Exception as e:
943
+ except Exception:
1090
944
  pass
1091
945
 
1092
946
  c = len(ctx.files)
@@ -1101,7 +955,7 @@ class Renderer(BaseRenderer):
1101
955
  files_html.append(self.body.get_file_html(file, n, c))
1102
956
  self.pids[pid].files_appended.append(file)
1103
957
  n += 1
1104
- except Exception as e:
958
+ except Exception:
1105
959
  pass
1106
960
  if files_html:
1107
961
  html_parts.append("<br/><br/>".join(files_html))
@@ -1118,7 +972,7 @@ class Renderer(BaseRenderer):
1118
972
  urls_html.append(self.body.get_url_html(url, n, c))
1119
973
  self.pids[pid].urls_appended.append(url)
1120
974
  n += 1
1121
- except Exception as e:
975
+ except Exception:
1122
976
  pass
1123
977
  if urls_html:
1124
978
  html_parts.append("<br/><br/>".join(urls_html))
@@ -1128,7 +982,7 @@ class Renderer(BaseRenderer):
1128
982
  try:
1129
983
  docs = self.body.get_docs_html(ctx.doc_ids)
1130
984
  html_parts.append(docs)
1131
- except Exception as e:
985
+ except Exception:
1132
986
  pass
1133
987
 
1134
988
  html = "".join(html_parts)
@@ -1142,24 +996,19 @@ class Renderer(BaseRenderer):
1142
996
  self.sanitize_html(html)
1143
997
  )});"""
1144
998
  )
1145
- except Exception as e:
999
+ except Exception:
1146
1000
  pass
1147
1001
 
1148
1002
  return html
1149
1003
 
1150
- def append_timestamp(
1151
- self,
1152
- ctx: CtxItem,
1153
- text: str,
1154
- type: Optional[int] = None
1155
- ) -> str:
1004
+ def append_timestamp(self, ctx: CtxItem, text: str, type: Optional[int] = None) -> str:
1156
1005
  """
1157
- Append timestamp to text
1006
+ Append timestamp to text (legacy HTML path)
1158
1007
 
1159
1008
  :param ctx: context item
1160
- :param text: Input text
1161
- :param type: Type of message
1162
- :return: Text with timestamp (if enabled)
1009
+ :param text: text to append timestamp to
1010
+ :param type: NODE_INPUT or NODE_OUTPUT
1011
+ :return: text with timestamp
1163
1012
  """
1164
1013
  if ctx is not None and ctx.input_timestamp is not None:
1165
1014
  timestamp = None
@@ -1173,16 +1022,12 @@ class Renderer(BaseRenderer):
1173
1022
  text = f'<span class="ts">{hour}: </span>{text}'
1174
1023
  return text
1175
1024
 
1176
- def reset(
1177
- self,
1178
- meta: Optional[CtxMeta] = None,
1179
- clear_nodes: bool = True
1180
- ):
1025
+ def reset(self, meta: Optional[CtxMeta] = None, clear_nodes: bool = True):
1181
1026
  """
1182
- Reset
1027
+ Reset current output
1183
1028
 
1184
- :param meta: Context meta
1185
- :param clear_nodes: True if clear nodes
1029
+ :param meta: context meta
1030
+ :param clear_nodes: True if clear nodes list
1186
1031
  """
1187
1032
  pid = self.get_pid(meta)
1188
1033
  if pid is not None and pid in self.pids:
@@ -1191,7 +1036,6 @@ class Renderer(BaseRenderer):
1191
1036
  if meta is not None:
1192
1037
  pid = self.get_or_create_pid(meta)
1193
1038
  self.reset_by_pid(pid, clear_nodes=clear_nodes)
1194
-
1195
1039
  self.clear_live(meta, CtxItem())
1196
1040
 
1197
1041
  def reset_by_pid(self, pid: Optional[int], clear_nodes: bool = True):
@@ -1199,7 +1043,7 @@ class Renderer(BaseRenderer):
1199
1043
  Reset by PID
1200
1044
 
1201
1045
  :param pid: context PID
1202
- :param clear_nodes: True if clear nodes
1046
+ :param clear_nodes: True if clear nodes list
1203
1047
  """
1204
1048
  self.pids[pid].item = None
1205
1049
  self.pids[pid].html = ""
@@ -1214,27 +1058,24 @@ class Renderer(BaseRenderer):
1214
1058
  node.reset_current_content()
1215
1059
  self.reset_names_by_pid(pid)
1216
1060
  self.prev_chunk_replace = False
1217
- self._stream_reset(pid) # NEW
1061
+ self._stream_reset(pid)
1218
1062
 
1219
1063
  def clear_input(self):
1220
1064
  """Clear input"""
1221
1065
  self.get_input_node().clear()
1222
1066
 
1223
- def clear_output(
1224
- self,
1225
- meta: Optional[CtxMeta] = None
1226
- ):
1067
+ def clear_output(self, meta: Optional[CtxMeta] = None):
1227
1068
  """
1228
1069
  Clear output
1229
1070
 
1230
- :param meta: Context meta
1071
+ :param meta: context meta
1231
1072
  """
1232
1073
  self.prev_chunk_replace = False
1233
1074
  self.reset(meta)
1234
1075
 
1235
- def clear_chunks(self, pid):
1076
+ def clear_chunks(self, pid: Optional[int]):
1236
1077
  """
1237
- Clear chunks from output
1078
+ Clear current chunks
1238
1079
 
1239
1080
  :param pid: context PID
1240
1081
  """
@@ -1243,14 +1084,11 @@ class Renderer(BaseRenderer):
1243
1084
  self.clear_chunks_input(pid)
1244
1085
  self.clear_chunks_output(pid)
1245
1086
 
1246
- def clear_chunks_input(
1247
- self,
1248
- pid: Optional[int]
1249
- ):
1087
+ def clear_chunks_input(self, pid: Optional[int]):
1250
1088
  """
1251
1089
  Clear chunks from input
1252
1090
 
1253
- :pid: context PID
1091
+ :param pid: context PID
1254
1092
  """
1255
1093
  if pid is None:
1256
1094
  return
@@ -1261,14 +1099,11 @@ class Renderer(BaseRenderer):
1261
1099
  except Exception:
1262
1100
  pass
1263
1101
 
1264
- def clear_chunks_output(
1265
- self,
1266
- pid: Optional[int]
1267
- ):
1102
+ def clear_chunks_output(self, pid: Optional[int]):
1268
1103
  """
1269
1104
  Clear chunks from output
1270
1105
 
1271
- :pid: context PID
1106
+ :param pid: context PID
1272
1107
  """
1273
1108
  self.prev_chunk_replace = False
1274
1109
  try:
@@ -1277,16 +1112,13 @@ class Renderer(BaseRenderer):
1277
1112
  )
1278
1113
  except Exception:
1279
1114
  pass
1280
- self._stream_reset(pid) # NEW
1115
+ self._stream_reset(pid)
1281
1116
 
1282
- def clear_nodes(
1283
- self,
1284
- pid: Optional[int]
1285
- ):
1117
+ def clear_nodes(self, pid: Optional[int]):
1286
1118
  """
1287
- Clear nodes from output
1119
+ Clear nodes list
1288
1120
 
1289
- :pid: context PID
1121
+ :param pid: context PID
1290
1122
  """
1291
1123
  try:
1292
1124
  self.get_output_node_by_pid(pid).page().runJavaScript(
@@ -1295,173 +1127,33 @@ class Renderer(BaseRenderer):
1295
1127
  except Exception:
1296
1128
  pass
1297
1129
 
1298
- def prepare_node(
1299
- self,
1300
- meta: CtxMeta,
1301
- ctx: CtxItem,
1302
- html: str,
1303
- type: int = 1,
1304
- prev_ctx: Optional[CtxItem] = None,
1305
- next_ctx: Optional[CtxItem] = None
1306
- ) -> str:
1130
+ # Legacy methods kept for compatibility with existing code paths
1131
+ def prepare_node(self, meta: CtxMeta, ctx: CtxItem, html: str, type: int = 1,
1132
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None) -> str:
1307
1133
  """
1308
- Prepare node HTML
1134
+ Compatibility shim: convert single input/output into markdown/raw for JS templates.
1309
1135
 
1310
1136
  :param meta: context meta
1311
- :param ctx: CtxItem instance
1312
- :param html: html text
1313
- :param type: type of message
1137
+ :param ctx: context item
1138
+ :param html: HTML content
1139
+ :param type: NODE_INPUT or NODE_OUTPUT
1314
1140
  :param prev_ctx: previous context item
1315
1141
  :param next_ctx: next context item
1316
- :return: prepared HTML
1142
+ :return: prepared text
1317
1143
  """
1318
1144
  pid = self.get_or_create_pid(meta)
1319
1145
  if type == self.NODE_OUTPUT:
1320
- return self.prepare_node_output(
1321
- meta=meta,
1322
- ctx=ctx,
1323
- html=html,
1324
- prev_ctx=prev_ctx,
1325
- next_ctx=next_ctx,
1326
- )
1146
+ return str(html)
1327
1147
  elif type == self.NODE_INPUT:
1328
- return self.prepare_node_input(
1329
- pid=pid,
1330
- ctx=ctx,
1331
- html=html,
1332
- prev_ctx=prev_ctx,
1333
- next_ctx=next_ctx,
1334
- )
1335
-
1336
- def prepare_node_input(
1337
- self,
1338
- pid,
1339
- ctx: CtxItem,
1340
- html: str,
1341
- prev_ctx: Optional[CtxItem] = None,
1342
- next_ctx: Optional[CtxItem] = None
1343
- ) -> str:
1344
- """
1345
- Prepare input node
1346
-
1347
- :param pid: context PID
1348
- :param ctx: CtxItem instance
1349
- :param html: html text
1350
- :param prev_ctx: previous context item
1351
- :param next_ctx: next context item
1352
- :return: prepared HTML
1353
- """
1354
- msg_id = "msg-user-" + str(ctx.id) if ctx is not None else ""
1355
- content = self.append_timestamp(
1356
- ctx,
1357
- self.helpers.format_user_text(html),
1358
- type=self.NODE_INPUT
1359
- )
1360
- html = f"<p>{content}</p>"
1361
- html = self.helpers.post_format_text(html)
1362
- name = self.pids[pid].name_user
1363
-
1364
- if ctx.internal and ctx.input.startswith("[{"):
1365
- name = trans("msg.name.system")
1366
- if type(ctx.extra) is dict and "agent_evaluate" in ctx.extra:
1367
- name = trans("msg.name.evaluation")
1368
-
1369
- debug = ""
1370
- if self.is_debug():
1371
- debug = self.append_debug(ctx, pid, "input")
1372
-
1373
- extra_style = ""
1374
- extra = ""
1375
- if ctx.extra is not None and "footer" in ctx.extra:
1376
- extra = ctx.extra["footer"]
1377
- extra_style = "display:block;"
1378
-
1379
- return f'<div class="msg-box msg-user" id="{msg_id}"><div class="name-header name-user">{name}</div><div class="msg">{html}<div class="msg-extra" style="{extra_style}">{extra}</div>{debug}</div></div>'
1380
-
1381
- def prepare_node_output(
1382
- self,
1383
- meta: CtxMeta,
1384
- ctx: CtxItem,
1385
- html: str,
1386
- prev_ctx: Optional[CtxItem] = None,
1387
- next_ctx: Optional[CtxItem] = None
1388
- ) -> str:
1389
- """
1390
- Prepare output node wrapper; content stays as raw Markdown in HTML (md-block-markdown=1).
1391
- JS (markdown-it) will render it and decorate code blocks.
1392
- Backward compatible with previous JS pipeline and public API.
1393
-
1394
- :param meta: context meta
1395
- :param ctx: CtxItem instance
1396
- :param html: raw markdown text (pre/post formatted)
1397
- :param prev_ctx: previous context item
1398
- :param next_ctx: next context item
1399
- :return: prepared HTML
1400
- """
1401
- is_cmd = (
1402
- next_ctx is not None and
1403
- next_ctx.internal and
1404
- (len(ctx.cmds) > 0 or (ctx.extra_ctx is not None and len(ctx.extra_ctx) > 0))
1405
- )
1406
-
1407
- pid = self.get_or_create_pid(meta)
1408
- msg_id = f"msg-bot-{ctx.id}" if ctx is not None else ""
1409
-
1410
- # raw Markdown (no Python-side HTML rendering); keep existing pre/post formatting hooks
1411
- md_src = self.helpers.pre_format_text(html)
1412
- md_text = self.helpers.post_format_text(md_src)
1413
-
1414
- # Escape Markdown for safe inclusion as textContent (browser will decode entities back to chars)
1415
- # This ensures no HTML is parsed before markdown-it processes the content on the JS side.
1416
- md_text_escaped = _html.escape(md_text, quote=False)
1417
-
1418
- # extras/footer
1419
- extra = self.append_extra(meta, ctx, footer=True, render=False)
1420
- footer = self.body.prepare_action_icons(ctx)
1421
-
1422
- tool_output = ""
1423
- output_class = "display:none"
1424
- if is_cmd:
1425
- if ctx.results is not None and len(ctx.results) > 0 \
1426
- and isinstance(ctx.extra, dict) and "agent_step" in ctx.extra:
1427
- tool_output = self.helpers.format_cmd_text(str(ctx.input), indent=True)
1428
- output_class = ""
1429
- else:
1430
- tool_output = self.helpers.format_cmd_text(str(next_ctx.input), indent=True)
1431
- output_class = ""
1432
- elif ctx.results is not None and len(ctx.results) > 0 \
1433
- and isinstance(ctx.extra, dict) and "agent_step" in ctx.extra:
1434
- tool_output = self.helpers.format_cmd_text(str(ctx.input), indent=True)
1435
-
1436
- tool_extra = self.body.prepare_tool_extra(ctx)
1437
- debug = self.append_debug(ctx, pid, "output") if self.is_debug() else ""
1438
- name_header = self.get_name_header(ctx)
1439
-
1440
- # Native Markdown block: JS runtime will pick [md-block-markdown] and render via markdown-it.
1441
- return (
1442
- f"<div class='msg-box msg-bot' id='{msg_id}'>"
1443
- f"{name_header}"
1444
- f"<div class='msg'>"
1445
- f"<div class='md-block' md-block-markdown='1'>{md_text_escaped}</div>"
1446
- f"<div class='msg-tool-extra'>{tool_extra}</div>"
1447
- f"<div class='tool-output' style='{output_class}'>"
1448
- f"<span class='toggle-cmd-output' onclick='toggleToolOutput({ctx.id});' "
1449
- f"title='{trans('action.cmd.expand')}' role='button'>"
1450
- f"<img src='{self._file_prefix}{self._icon_expand}' width='25' height='25' valign='middle'></span>"
1451
- f"<div class='content' style='display:none'>{tool_output}</div>"
1452
- f"</div>"
1453
- f"<div class='msg-extra'>{extra}</div>"
1454
- f"{footer}{debug}"
1455
- f"</div></div>"
1456
- )
1148
+ return str(html)
1457
1149
 
1458
1150
  def get_name_header(self, ctx: CtxItem, stream: bool = False) -> str:
1459
1151
  """
1460
- Get name header for the bot
1152
+ Legacy - kept for stream header text (avatar + name string)
1461
1153
 
1462
- :param ctx: CtxItem instance
1463
- :param stream: True if it is a stream
1464
- :return: HTML name header
1154
+ :param ctx: context item
1155
+ :param stream: True if streaming mode
1156
+ :return: HTML string
1465
1157
  """
1466
1158
  meta = ctx.meta
1467
1159
  if meta is None:
@@ -1494,13 +1186,13 @@ class Renderer(BaseRenderer):
1494
1186
  else:
1495
1187
  return f"<div class=\"name-header name-bot\">{avatar_html}{output_name}</div>"
1496
1188
 
1497
- def flush_output(self, pid: int, html: str, replace: bool = False):
1189
+ def flush_output(self, pid: int, payload: str, replace: bool = False):
1498
1190
  """
1499
- Send content via QWebChannel when ready; otherwise queue with a safe fallback.
1191
+ Send content via QWebChannel (JSON or HTML string).
1500
1192
 
1501
1193
  :param pid: context PID
1502
- :param html: HTML code
1503
- :param replace: True if replace current content
1194
+ :param payload: payload to send
1195
+ :param replace: True if replace nodes list
1504
1196
  """
1505
1197
  if pid is None:
1506
1198
  return
@@ -1516,23 +1208,23 @@ class Renderer(BaseRenderer):
1516
1208
  if br is not None:
1517
1209
  if replace and hasattr(br, "nodeReplace"):
1518
1210
  self.clear_nodes(pid)
1519
- br.nodeReplace.emit(html)
1211
+ br.nodeReplace.emit(payload)
1520
1212
  return
1521
1213
  if not replace and hasattr(br, "node"):
1522
- br.node.emit(html)
1214
+ br.node.emit(payload)
1523
1215
  return
1524
- # Not ready yet -> queue
1525
- self._queue_node(pid, html, replace)
1216
+ # Not ready -> queue
1217
+ self._queue_node(pid, payload, replace)
1526
1218
  except Exception:
1527
- # JS fallback
1219
+ # Fallback to runJavaScript path
1528
1220
  try:
1529
1221
  if replace:
1530
1222
  node.page().runJavaScript(
1531
- f"if (typeof window.replaceNodes !== 'undefined') replaceNodes({self.to_json(html)});"
1223
+ f"if (typeof window.replaceNodes !== 'undefined') replaceNodes({self.to_json(payload)});"
1532
1224
  )
1533
1225
  else:
1534
1226
  node.page().runJavaScript(
1535
- f"if (typeof window.appendNode !== 'undefined') appendNode({self.to_json(html)});"
1227
+ f"if (typeof window.appendNode !== 'undefined') appendNode({self.to_json(payload)});"
1536
1228
  )
1537
1229
  except Exception:
1538
1230
  pass
@@ -1541,13 +1233,9 @@ class Renderer(BaseRenderer):
1541
1233
  """Reload output, called externally only on theme change to redraw content"""
1542
1234
  self.window.controller.ctx.refresh_output()
1543
1235
 
1544
- def flush(
1545
- self,
1546
- pid:
1547
- Optional[int]
1548
- ):
1236
+ def flush(self, pid: Optional[int]):
1549
1237
  """
1550
- Flush output
1238
+ Flush output page HTML (initial)
1551
1239
 
1552
1240
  :param pid: context PID
1553
1241
  """
@@ -1559,20 +1247,16 @@ class Renderer(BaseRenderer):
1559
1247
  if node is not None:
1560
1248
  node.setHtml(html, baseUrl="file://")
1561
1249
 
1562
- def fresh(
1563
- self,
1564
- meta: Optional[CtxMeta] = None,
1565
- force: bool = False
1566
- ):
1250
+ def fresh(self, meta: Optional[CtxMeta] = None, force: bool = False):
1567
1251
  """
1568
1252
  Reset page / unload old renderer from memory
1569
1253
 
1570
1254
  :param meta: context meta
1571
- :param force: True if force recycle even if not loaded
1255
+ :param force: True if force even when not needed
1572
1256
  """
1573
1257
  plain = self.window.core.config.get('render.plain')
1574
1258
  if plain:
1575
- return # plain text mode, no need to recycle
1259
+ return
1576
1260
 
1577
1261
  pid = self.get_or_create_pid(meta)
1578
1262
  if pid is None:
@@ -1599,26 +1283,20 @@ class Renderer(BaseRenderer):
1599
1283
  self.recycle(node, meta)
1600
1284
  self.flush(pid)
1601
1285
 
1602
- def recycle(
1603
- self,
1604
- node: ChatWebOutput,
1605
- meta: Optional[CtxMeta] = None
1606
- ):
1286
+ def recycle(self, node: ChatWebOutput, meta: Optional[CtxMeta] = None):
1607
1287
  """
1608
- Recycle renderer to free memory and avoid leaks
1609
-
1610
- Swaps out the old QWebEngineView with a fresh instance.
1288
+ Recycle renderer to avoid leaks
1611
1289
 
1612
- :param node: output node
1290
+ :param node: output node to recycle
1613
1291
  :param meta: context meta
1614
1292
  """
1615
1293
  tab = node.get_tab()
1616
- layout = tab.child.layout() # layout of TabBody
1294
+ layout = tab.child.layout()
1617
1295
  layout.removeWidget(node)
1618
1296
  self.window.ui.nodes['output'].pop(tab.pid, None)
1619
1297
  tab.child.delete_refs()
1620
1298
 
1621
- node.on_delete() # destroy old node
1299
+ node.on_delete()
1622
1300
 
1623
1301
  view = ChatWebOutput(self.window)
1624
1302
  view.set_tab(tab)
@@ -1633,31 +1311,25 @@ class Renderer(BaseRenderer):
1633
1311
  gc.collect()
1634
1312
  except Exception:
1635
1313
  pass
1636
- self.auto_cleanup_soft(meta) # trim memory in Linux
1314
+ self.auto_cleanup_soft(meta)
1637
1315
 
1638
- def get_output_node(
1639
- self,
1640
- meta: Optional[CtxMeta] = None
1641
- ) -> Optional[ChatWebOutput]:
1316
+ def get_output_node(self, meta: Optional[CtxMeta] = None) -> Optional[ChatWebOutput]:
1642
1317
  """
1643
1318
  Get output node
1644
1319
 
1645
1320
  :param meta: context meta
1646
- :return: output node
1321
+ :return: output node or None
1647
1322
  """
1648
1323
  if self._get_output_node_by_meta is None:
1649
1324
  self._get_output_node_by_meta = self.window.core.ctx.output.get_output_node_by_meta
1650
1325
  return self._get_output_node_by_meta(meta)
1651
1326
 
1652
- def get_output_node_by_pid(
1653
- self,
1654
- pid: Optional[int]
1655
- ) -> Optional[ChatWebOutput]:
1327
+ def get_output_node_by_pid(self, pid: Optional[int]) -> Optional[ChatWebOutput]:
1656
1328
  """
1657
1329
  Get output node by PID
1658
1330
 
1659
- :param pid: context pid
1660
- :return: output node
1331
+ :param pid: context PID
1332
+ :return: output node or None
1661
1333
  """
1662
1334
  if self._get_output_node_by_pid is None:
1663
1335
  self._get_output_node_by_pid = self.window.core.ctx.output.get_output_node_by_pid
@@ -1701,7 +1373,7 @@ class Renderer(BaseRenderer):
1701
1373
  """
1702
1374
  Reset names
1703
1375
 
1704
- :param meta: context meta
1376
+ :param meta: context meta
1705
1377
  """
1706
1378
  if meta is None:
1707
1379
  return
@@ -1712,7 +1384,7 @@ class Renderer(BaseRenderer):
1712
1384
  """
1713
1385
  Reset names by PID
1714
1386
 
1715
- :param pid: context pid
1387
+ :param pid: context PID
1716
1388
  """
1717
1389
  self.pids[pid].name_user = trans("chat.name.user")
1718
1390
  self.pids[pid].name_bot = trans("chat.name.bot")
@@ -1727,7 +1399,7 @@ class Renderer(BaseRenderer):
1727
1399
 
1728
1400
  def on_edit_submit(self, ctx: CtxItem):
1729
1401
  """
1730
- On regenerate submit
1402
+ On edit submit
1731
1403
 
1732
1404
  :param ctx: context item
1733
1405
  """
@@ -1737,7 +1409,7 @@ class Renderer(BaseRenderer):
1737
1409
  """
1738
1410
  On enable edit icons
1739
1411
 
1740
- :param live: True if live update
1412
+ :param live: True if live mode
1741
1413
  """
1742
1414
  if not live:
1743
1415
  return
@@ -1752,7 +1424,7 @@ class Renderer(BaseRenderer):
1752
1424
  """
1753
1425
  On disable edit icons
1754
1426
 
1755
- :param live: True if live update
1427
+ :param live: True if live mode
1756
1428
  """
1757
1429
  if not live:
1758
1430
  return
@@ -1767,7 +1439,7 @@ class Renderer(BaseRenderer):
1767
1439
  """
1768
1440
  On enable timestamp
1769
1441
 
1770
- :param live: True if live update
1442
+ :param live: True if live mode
1771
1443
  """
1772
1444
  if not live:
1773
1445
  return
@@ -1782,7 +1454,7 @@ class Renderer(BaseRenderer):
1782
1454
  """
1783
1455
  On disable timestamp
1784
1456
 
1785
- :param live: True if live update
1457
+ :param live: True if live mode
1786
1458
  """
1787
1459
  if not live:
1788
1460
  return
@@ -1793,13 +1465,9 @@ class Renderer(BaseRenderer):
1793
1465
  except Exception:
1794
1466
  pass
1795
1467
 
1796
- def update_names(
1797
- self,
1798
- meta: CtxMeta,
1799
- ctx: CtxItem
1800
- ):
1468
+ def update_names(self, meta: CtxMeta, ctx: CtxItem):
1801
1469
  """
1802
- Update names
1470
+ Update names from ctx
1803
1471
 
1804
1472
  :param meta: context meta
1805
1473
  :param ctx: context item
@@ -1813,7 +1481,7 @@ class Renderer(BaseRenderer):
1813
1481
  self.pids[pid].name_bot = ctx.output_name
1814
1482
 
1815
1483
  def clear_all(self):
1816
- """Clear all"""
1484
+ """Clear all tabs"""
1817
1485
  for pid in self.pids:
1818
1486
  self.clear_chunks(pid)
1819
1487
  self.clear_nodes(pid)
@@ -1821,33 +1489,23 @@ class Renderer(BaseRenderer):
1821
1489
  self._stream_reset(pid)
1822
1490
 
1823
1491
  def scroll_to_bottom(self):
1824
- """Scroll to bottom"""
1492
+ """Scroll to bottom placeholder"""
1825
1493
  pass
1826
1494
 
1827
1495
  def append_block(self):
1828
- """Append block to output"""
1496
+ """Append block placeholder"""
1829
1497
  pass
1830
1498
 
1831
1499
  def to_end(self, ctx: CtxItem):
1832
- """
1833
- Move cursor to end of output
1834
-
1835
- :param ctx: context item
1836
- """
1500
+ """Move cursor to end placeholder"""
1837
1501
  pass
1838
1502
 
1839
1503
  def get_all_nodes(self) -> list:
1840
- """
1841
- Return all registered nodes
1842
-
1843
- :return: list of ChatOutput nodes (tabs)
1844
- """
1504
+ """Return all registered nodes"""
1845
1505
  return self.window.core.ctx.output.get_all()
1846
1506
 
1847
- # TODO: on lang change
1848
-
1849
1507
  def reload_css(self):
1850
- """Reload CSS - all, global"""
1508
+ """Reload CSS propagate theme and config to runtime"""
1851
1509
  to_json = self.to_json(self.body.prepare_styles())
1852
1510
  nodes = self.get_all_nodes()
1853
1511
  for pid in self.pids:
@@ -1865,7 +1523,7 @@ class Renderer(BaseRenderer):
1865
1523
  node.page().runJavaScript(
1866
1524
  "if (typeof window.disableBlocks !== 'undefined') disableBlocks();"
1867
1525
  )
1868
- except Exception as e:
1526
+ except Exception:
1869
1527
  pass
1870
1528
  return
1871
1529
 
@@ -1877,16 +1535,12 @@ class Renderer(BaseRenderer):
1877
1535
  self.reload_css()
1878
1536
  return
1879
1537
 
1880
- def tool_output_append(
1881
- self,
1882
- meta: CtxMeta,
1883
- content: str
1884
- ):
1538
+ def tool_output_append(self, meta: CtxMeta, content: str):
1885
1539
  """
1886
1540
  Add tool output (append)
1887
1541
 
1888
1542
  :param meta: context meta
1889
- :param content: content
1543
+ :param content: content to append
1890
1544
  """
1891
1545
  try:
1892
1546
  self.get_output_node(meta).page().runJavaScript(
@@ -1897,16 +1551,12 @@ class Renderer(BaseRenderer):
1897
1551
  except Exception:
1898
1552
  pass
1899
1553
 
1900
- def tool_output_update(
1901
- self,
1902
- meta: CtxMeta,
1903
- content: str
1904
- ):
1554
+ def tool_output_update(self, meta: CtxMeta, content: str):
1905
1555
  """
1906
1556
  Replace tool output
1907
1557
 
1908
1558
  :param meta: context meta
1909
- :param content: content
1559
+ :param content: content to set
1910
1560
  """
1911
1561
  try:
1912
1562
  self.get_output_node(meta).page().runJavaScript(
@@ -1954,10 +1604,9 @@ class Renderer(BaseRenderer):
1954
1604
 
1955
1605
  def sanitize_html(self, html: str) -> str:
1956
1606
  """
1957
- Sanitize HTML to prevent XSS attacks
1607
+ Sanitize HTM
1958
1608
 
1959
- :param html: HTML string to sanitize
1960
- :return: sanitized HTML string
1609
+ :param html: HTML string
1961
1610
  """
1962
1611
  return html
1963
1612
  if not html:
@@ -1966,35 +1615,30 @@ class Renderer(BaseRenderer):
1966
1615
  return html
1967
1616
  return self.RE_AMP_LT_GT.sub(r'&\1;', html)
1968
1617
 
1969
- def append_debug(
1970
- self,
1971
- ctx: CtxItem,
1972
- pid,
1973
- title: Optional[str] = None
1974
- ) -> str:
1618
+ def append_debug(self, ctx: CtxItem, pid, title: Optional[str] = None) -> str:
1975
1619
  """
1976
- Append debug info
1620
+ Append debug info HTML (legacy path)
1977
1621
 
1978
1622
  :param ctx: context item
1979
1623
  :param pid: context PID
1980
- :param title: debug title
1981
- :return: HTML debug info
1624
+ :param title: optional title
1625
+ :return: HTML string
1982
1626
  """
1983
1627
  if title is None:
1984
1628
  title = "debug"
1985
- return f"<div class='debug'><b>{title}:</b> pid: {pid}, ctx: {ctx.to_dict()}</div>"
1629
+ return f"<div class='debug'><b>{title}:</b> pid: {pid}, ctx: {_html.escape(ctx.to_debug())}</div>"
1986
1630
 
1987
1631
  def is_debug(self) -> bool:
1988
1632
  """
1989
- Check if debug mode is enabled
1633
+ Check debug flag
1990
1634
 
1991
- :return: True if debug mode is enabled
1635
+ :return: True if debug enabled
1992
1636
  """
1993
1637
  return self.window.core.config.get("debug.render", False)
1994
1638
 
1995
1639
  def js_stream_queue_len(self, pid: int):
1996
1640
  """
1997
- Ask the JS side how many items are currently queued for streaming (streamQ.length).
1641
+ Ask JS side for stream queue length
1998
1642
 
1999
1643
  :param pid: context PID
2000
1644
  """
@@ -2012,13 +1656,12 @@ class Renderer(BaseRenderer):
2012
1656
 
2013
1657
  def remove_pid(self, pid: int):
2014
1658
  """
2015
- Remove PID from renderer
1659
+ Remove PID and clean resources
2016
1660
 
2017
1661
  :param pid: context PID
2018
1662
  """
2019
1663
  if pid in self.pids:
2020
1664
  del self.pids[pid]
2021
- # Clean micro-batch resources
2022
1665
  self._stream_reset(pid)
2023
1666
  t = self._stream_timer.pop(pid, None)
2024
1667
  if t:
@@ -2032,7 +1675,7 @@ class Renderer(BaseRenderer):
2032
1675
 
2033
1676
  def on_js_ready(self, pid: int) -> None:
2034
1677
  """
2035
- On JS ready - called from JS side via QWebChannel when bridge is ready.
1678
+ Called by JS via bridge when ready
2036
1679
 
2037
1680
  :param pid: context PID
2038
1681
  """
@@ -2044,7 +1687,7 @@ class Renderer(BaseRenderer):
2044
1687
 
2045
1688
  def _drain_pending_nodes(self, pid: int):
2046
1689
  """
2047
- Drain any pending nodes queued while bridge was not ready.
1690
+ Flush queued node payloads
2048
1691
 
2049
1692
  :param pid: context PID
2050
1693
  """
@@ -2064,9 +1707,7 @@ class Renderer(BaseRenderer):
2064
1707
  elif not replace and hasattr(br, "node"):
2065
1708
  br.node.emit(payload)
2066
1709
  except Exception:
2067
- # If something goes wrong, stop draining to avoid dropping further items.
2068
1710
  break
2069
- # stop/clear fallback timer if any
2070
1711
  t = self._pending_timer.pop(pid, None)
2071
1712
  if t:
2072
1713
  try:
@@ -2076,11 +1717,11 @@ class Renderer(BaseRenderer):
2076
1717
 
2077
1718
  def _queue_node(self, pid: int, payload: str, replace: bool):
2078
1719
  """
2079
- Queue node payload for later delivery when bridge is ready, with a safe fallback.
1720
+ Queue node payload until bridge is ready (with safe fallback)
2080
1721
 
2081
1722
  :param pid: context PID
2082
- :param payload: sanitized HTML payload
2083
- :param replace: True if replace current content
1723
+ :param payload: payload to queue
1724
+ :param replace: True if replace nodes list
2084
1725
  """
2085
1726
  q = self._pending_nodes.setdefault(pid, [])
2086
1727
  q.append((replace, payload))
@@ -2090,7 +1731,6 @@ class Renderer(BaseRenderer):
2090
1731
  t.setInterval(1200) # ms
2091
1732
 
2092
1733
  def on_timeout(pid=pid):
2093
- # Still not ready? Fallback: flush queued via runJavaScript once.
2094
1734
  node = self.get_output_node_by_pid(pid)
2095
1735
  if node:
2096
1736
  while self._pending_nodes.get(pid):
@@ -2118,11 +1758,10 @@ class Renderer(BaseRenderer):
2118
1758
 
2119
1759
  def _stream_get(self, pid: int) -> tuple[_AppendBuffer, QTimer]:
2120
1760
  """
2121
- Get or create per-PID append buffer and timer for micro-batching.
2122
- Timer is single-shot and (re)started only when there is pending data.
1761
+ Get/create per-PID append buffer and timer
2123
1762
 
2124
1763
  :param pid: context PID
2125
- :return: (buffer, timer) tuple
1764
+ :return: (buffer, timer)
2126
1765
  """
2127
1766
  buf = self._stream_acc.get(pid)
2128
1767
  if buf is None:
@@ -2136,7 +1775,6 @@ class Renderer(BaseRenderer):
2136
1775
  t.setInterval(self._stream_interval_ms)
2137
1776
 
2138
1777
  def on_timeout(pid=pid):
2139
- # Flush pending batch and, if more data arrives later, timer will be restarted by _stream_push()
2140
1778
  self._stream_flush(pid, force=False)
2141
1779
 
2142
1780
  t.timeout.connect(on_timeout)
@@ -2146,9 +1784,7 @@ class Renderer(BaseRenderer):
2146
1784
 
2147
1785
  def _stream_reset(self, pid: Optional[int]):
2148
1786
  """
2149
- Reset micro-batch resources for a PID. Safe to call frequently.
2150
-
2151
- Clear buffer, stop timer, clear header and last-flush timestamp.
1787
+ Reset micro-batch state for PID
2152
1788
 
2153
1789
  :param pid: context PID
2154
1790
  """
@@ -2168,56 +1804,42 @@ class Renderer(BaseRenderer):
2168
1804
 
2169
1805
  def _stream_push(self, pid: int, header: str, chunk: str):
2170
1806
  """
2171
- Append chunk to per-PID buffer. Start the micro-batch timer if it's not running.
2172
- If accumulated size crosses thresholds, flush immediately.
1807
+ Push chunk into buffer and schedule flush
2173
1808
 
2174
1809
  :param pid: context PID
2175
- :param header: header/name for this stream (only used if first chunk)
2176
- :param chunk: chunk of text to append
1810
+ :param header: optional header (first chunk only)
1811
+ :param chunk: chunk to append
2177
1812
  """
2178
1813
  if not chunk:
2179
1814
  return
2180
1815
 
2181
1816
  buf, timer = self._stream_get(pid)
2182
- # Remember last known header for this PID
2183
1817
  if header and not self._stream_header.get(pid):
2184
1818
  self._stream_header[pid] = header
2185
1819
 
2186
- # Append chunk cheaply
2187
1820
  buf.append(chunk)
2188
-
2189
- # Emergency backstop: if buffer is getting too large, flush now
2190
1821
  pending_size = getattr(buf, "_size", 0)
2191
1822
  if pending_size >= self._stream_emergency_bytes:
2192
1823
  self._stream_flush(pid, force=True)
2193
1824
  return
2194
-
2195
- # Size-based early flush for responsiveness
2196
1825
  if pending_size >= self._stream_max_bytes:
2197
1826
  self._stream_flush(pid, force=True)
2198
1827
  return
2199
-
2200
- # Start timer if not active to flush at ~frame rate
2201
1828
  if not timer.isActive():
2202
1829
  try:
2203
1830
  timer.start()
2204
1831
  except Exception:
2205
- # As a fallback, if timer cannot start, flush synchronously
2206
1832
  self._stream_flush(pid, force=True)
2207
1833
 
2208
1834
  def _stream_flush(self, pid: int, force: bool = False):
2209
1835
  """
2210
- Flush buffered chunks for a PID via QWebChannel (bridge.chunk.emit(name, chunk)).
2211
- If the bridge is not available, fall back to runJavaScript appendStream(name, chunk).
2212
-
2213
- If no data is pending, do nothing. Stop the timer if running.
1836
+ Flush buffered chunks via QWebChannel (bridge.chunk.emit(name, chunk, type))
2214
1837
 
2215
1838
  :param pid: context PID
2216
- :param force: True to force flush ignoring interval
1839
+ :param force: True if force flush ignoring interval
2217
1840
  """
2218
1841
  buf = self._stream_acc.get(pid)
2219
1842
  if buf is None or buf.is_empty():
2220
- # Nothing to send; stop timer if any
2221
1843
  t = self._stream_timer.get(pid)
2222
1844
  if t and t.isActive():
2223
1845
  try:
@@ -2228,11 +1850,9 @@ class Renderer(BaseRenderer):
2228
1850
 
2229
1851
  node = self.get_output_node_by_pid(pid)
2230
1852
  if node is None:
2231
- # Drop buffer if node is gone to avoid leaks
2232
1853
  buf.clear()
2233
1854
  return
2234
1855
 
2235
- # Stop timer for this flush; next push will re-arm it
2236
1856
  t = self._stream_timer.get(pid)
2237
1857
  if t and t.isActive():
2238
1858
  try:
@@ -2240,22 +1860,18 @@ class Renderer(BaseRenderer):
2240
1860
  except Exception:
2241
1861
  pass
2242
1862
 
2243
- # Gather and clear the pending data in one allocation
2244
1863
  data = buf.get_and_clear()
2245
1864
  name = self._stream_header.get(pid, "") or ""
2246
1865
 
2247
1866
  try:
2248
1867
  br = getattr(node.page(), "bridge", None)
2249
1868
  if br is not None and hasattr(br, "chunk"):
2250
- # Send as raw text; JS runtime handles buffering/rendering
2251
- br.chunk.emit(name, data)
1869
+ br.chunk.emit(name, data, "text_delta")
2252
1870
  else:
2253
- # Fallback path if bridge not yet connected on this page
2254
1871
  node.page().runJavaScript(
2255
1872
  f"if (typeof window.appendStream !== 'undefined') appendStream({self.to_json(name)},{self.to_json(data)});"
2256
1873
  )
2257
1874
  except Exception:
2258
- # If something goes wrong, attempt a JS fallback once
2259
1875
  try:
2260
1876
  node.page().runJavaScript(
2261
1877
  f"if (typeof window.appendStream !== 'undefined') appendStream({self.to_json(name)},{self.to_json(data)});"
@@ -2263,16 +1879,13 @@ class Renderer(BaseRenderer):
2263
1879
  except Exception:
2264
1880
  pass
2265
1881
 
2266
- # Opportunistic memory release on Linux for very large flushes
2267
1882
  try:
2268
1883
  if len(data) >= (256 * 1024):
2269
1884
  self.auto_cleanup_soft()
2270
1885
  except Exception:
2271
1886
  pass
2272
1887
 
2273
- # Explicitly drop reference to large string so GC/malloc_trim can reclaim sooner
2274
1888
  del data
2275
-
2276
1889
  self._stream_last_flush[pid] = monotonic()
2277
1890
 
2278
1891
  def eval_js(self, script: str):
@@ -2292,4 +1905,144 @@ class Renderer(BaseRenderer):
2292
1905
  try:
2293
1906
  node.page().runJavaScript(script, callback)
2294
1907
  except Exception:
2295
- pass
1908
+ pass
1909
+
1910
+ # ------------------------- Helpers: build JSON blocks -------------------------
1911
+
1912
+ def _output_identity(self, ctx: CtxItem) -> Tuple[str, Optional[str], bool]:
1913
+ """
1914
+ Resolve output identity (name, avatar file:// path) based on preset.
1915
+
1916
+ :param ctx: context item
1917
+ :return: (name, avatar, personalize)
1918
+ """
1919
+ meta = ctx.meta
1920
+ if meta is None:
1921
+ return self.pids[self.get_or_create_pid(meta)].name_bot if meta else "", None, False
1922
+ preset_id = meta.preset
1923
+ if not preset_id:
1924
+ return self.pids[self.get_or_create_pid(meta)].name_bot, None, False
1925
+ preset = self.window.core.presets.get(preset_id)
1926
+ if preset is None or not preset.ai_personalize:
1927
+ return self.pids[self.get_or_create_pid(meta)].name_bot, None, False
1928
+ name = preset.ai_name or self.pids[self.get_or_create_pid(meta)].name_bot
1929
+ avatar = None
1930
+ if preset.ai_avatar:
1931
+ presets_dir = self.window.core.config.get_user_dir("presets")
1932
+ avatars_dir = os.path.join(presets_dir, "avatars")
1933
+ avatar_path = os.path.join(avatars_dir, preset.ai_avatar)
1934
+ if os.path.exists(avatar_path):
1935
+ avatar = f"{self._file_prefix}{avatar_path}"
1936
+ return name, avatar, bool(preset.ai_personalize)
1937
+
1938
+ def _build_render_block(
1939
+ self,
1940
+ meta: CtxMeta,
1941
+ ctx: CtxItem,
1942
+ input_text: Optional[str],
1943
+ output_text: Optional[str],
1944
+ prev_ctx: Optional[CtxItem] = None,
1945
+ next_ctx: Optional[CtxItem] = None
1946
+ ) -> Optional[RenderBlock]:
1947
+ """
1948
+ Build RenderBlock for given ctx and payloads (input/output).
1949
+
1950
+ - if input_text or output_text is None, that part is skipped.
1951
+ - if ctx.hidden is True, returns None.
1952
+ - if both input_text and output_text are None, returns None.
1953
+ - if meta is None or invalid, returns None.
1954
+
1955
+ :param meta: CtxMeta object
1956
+ :param ctx: CtxItem object
1957
+ :param input_text: Input text (raw, un-formatted)
1958
+ :param prev_ctx: Previous CtxItem (for context, optional)
1959
+ :param next_ctx: Next CtxItem (for context, optional)
1960
+ :return: RenderBlock object or None
1961
+ """
1962
+ pid = self.get_or_create_pid(meta)
1963
+ if pid is None:
1964
+ return
1965
+
1966
+ block = RenderBlock(id=getattr(ctx, "id", None), meta_id=getattr(meta, "id", None))
1967
+
1968
+ # input
1969
+ if input_text:
1970
+ # Keep raw; formatting is a template duty (escape/BR etc.)
1971
+ block.input = {
1972
+ "type": "user",
1973
+ "name": self.pids[pid].name_user,
1974
+ "avatar_img": None, # no user avatar by default
1975
+ "text": str(input_text),
1976
+ "timestamp": ctx.input_timestamp if hasattr(ctx, "input_timestamp") else None,
1977
+ }
1978
+
1979
+ # output
1980
+ if output_text:
1981
+ # Pre/post format raw markdown via Helpers to preserve placeholders ([!cmd], think) and workdir tokens.
1982
+ md_src = self.helpers.pre_format_text(output_text)
1983
+ md_text = self.helpers.post_format_text(md_src)
1984
+ name, avatar, personalize = self._output_identity(ctx)
1985
+
1986
+ # tool output visibility (agent step / commands)
1987
+ is_cmd = (
1988
+ next_ctx is not None and
1989
+ next_ctx.internal and
1990
+ (len(ctx.cmds) > 0 or (ctx.extra_ctx is not None and len(ctx.extra_ctx) > 0))
1991
+ )
1992
+ tool_output = ""
1993
+ tool_output_visible = False
1994
+ if is_cmd:
1995
+ if ctx.results is not None and len(ctx.results) > 0 \
1996
+ and isinstance(ctx.extra, dict) and "agent_step" in ctx.extra:
1997
+ tool_output = self.helpers.format_cmd_text(str(ctx.input), indent=True)
1998
+ tool_output_visible = True
1999
+ else:
2000
+ tool_output = self.helpers.format_cmd_text(str(next_ctx.input), indent=True)
2001
+ tool_output_visible = True
2002
+ elif ctx.results is not None and len(ctx.results) > 0 \
2003
+ and isinstance(ctx.extra, dict) and "agent_step" in ctx.extra:
2004
+ tool_output = self.helpers.format_cmd_text(str(ctx.input), indent=True)
2005
+
2006
+ # plugin-driven extra (HTML) – keep as-is to preserve functionality
2007
+ tool_extra_html = self.body.prepare_tool_extra(ctx)
2008
+
2009
+ block.output = {
2010
+ "type": "bot",
2011
+ "name": name or self.pids[pid].name_bot,
2012
+ "avatar_img": avatar,
2013
+ "text": md_text,
2014
+ "timestamp": ctx.output_timestamp if hasattr(ctx, "output_timestamp") else None,
2015
+ }
2016
+
2017
+ # extras (images/files/urls/actions)
2018
+ images, files, urls, extra_actions = self.body.build_extras_dicts(ctx, pid)
2019
+ block.images = images
2020
+ block.files = files
2021
+ block.urls = urls
2022
+
2023
+ # docs as raw data -> rendered in JS
2024
+ docs_norm = []
2025
+ if self.window.core.config.get('ctx.sources'):
2026
+ if ctx.doc_ids is not None and len(ctx.doc_ids) > 0:
2027
+ docs_norm = self.body.normalize_docs(ctx.doc_ids)
2028
+
2029
+ block.extra.update({
2030
+ "tool_output": tool_output,
2031
+ "tool_output_visible": tool_output_visible,
2032
+ "tool_extra_html": tool_extra_html,
2033
+ "docs": docs_norm,
2034
+ "footer_icons": True,
2035
+ "personalize": personalize,
2036
+ })
2037
+ block.extra.update(extra_actions)
2038
+
2039
+ # carry ctx.extra flags as-is (do not collide with our own keys)
2040
+ if isinstance(ctx.extra, dict):
2041
+ block.extra.setdefault("ctx_extra", ctx.extra)
2042
+
2043
+ # debug
2044
+ if self.is_debug():
2045
+ print(block.debug())
2046
+ block.extra["debug_html"] = self.append_debug(ctx, pid, "output")
2047
+
2048
+ return block