pygpt-net 2.6.44__py3-none-any.whl → 2.6.46__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 (82) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +0 -5
  4. pygpt_net/controller/ctx/ctx.py +6 -0
  5. pygpt_net/controller/debug/debug.py +11 -9
  6. pygpt_net/controller/debug/fixtures.py +1 -1
  7. pygpt_net/controller/dialogs/debug.py +40 -29
  8. pygpt_net/core/debug/agent.py +19 -14
  9. pygpt_net/core/debug/assistants.py +22 -24
  10. pygpt_net/core/debug/attachments.py +11 -7
  11. pygpt_net/core/debug/config.py +22 -23
  12. pygpt_net/core/debug/console/console.py +2 -1
  13. pygpt_net/core/debug/context.py +63 -63
  14. pygpt_net/core/debug/db.py +1 -4
  15. pygpt_net/core/debug/debug.py +1 -1
  16. pygpt_net/core/debug/events.py +14 -11
  17. pygpt_net/core/debug/indexes.py +41 -76
  18. pygpt_net/core/debug/kernel.py +11 -8
  19. pygpt_net/core/debug/models.py +20 -15
  20. pygpt_net/core/debug/plugins.py +9 -6
  21. pygpt_net/core/debug/presets.py +16 -11
  22. pygpt_net/core/debug/tabs.py +28 -22
  23. pygpt_net/core/debug/ui.py +25 -22
  24. pygpt_net/core/fixtures/stream/generator.py +1 -2
  25. pygpt_net/core/render/web/body.py +290 -23
  26. pygpt_net/core/render/web/helpers.py +26 -0
  27. pygpt_net/core/render/web/renderer.py +459 -705
  28. pygpt_net/core/tabs/tab.py +14 -1
  29. pygpt_net/data/config/config.json +3 -3
  30. pygpt_net/data/config/models.json +3 -3
  31. pygpt_net/data/config/settings.json +15 -17
  32. pygpt_net/data/css/style.dark.css +6 -0
  33. pygpt_net/data/css/web-blocks.css +4 -0
  34. pygpt_net/data/css/web-blocks.light.css +1 -1
  35. pygpt_net/data/css/web-chatgpt.css +4 -0
  36. pygpt_net/data/css/web-chatgpt.light.css +1 -1
  37. pygpt_net/data/css/web-chatgpt_wide.css +4 -0
  38. pygpt_net/data/css/web-chatgpt_wide.light.css +1 -1
  39. pygpt_net/data/fixtures/fake_stream.txt +5733 -0
  40. pygpt_net/data/js/app.js +1921 -901
  41. pygpt_net/data/locale/locale.de.ini +1 -1
  42. pygpt_net/data/locale/locale.en.ini +5 -5
  43. pygpt_net/data/locale/locale.es.ini +1 -1
  44. pygpt_net/data/locale/locale.fr.ini +1 -1
  45. pygpt_net/data/locale/locale.it.ini +1 -1
  46. pygpt_net/data/locale/locale.pl.ini +2 -2
  47. pygpt_net/data/locale/locale.uk.ini +1 -1
  48. pygpt_net/data/locale/locale.zh.ini +1 -1
  49. pygpt_net/item/model.py +4 -1
  50. pygpt_net/js_rc.py +13076 -10198
  51. pygpt_net/provider/api/anthropic/__init__.py +3 -1
  52. pygpt_net/provider/api/anthropic/tools.py +1 -1
  53. pygpt_net/provider/api/google/__init__.py +7 -1
  54. pygpt_net/provider/api/x_ai/__init__.py +5 -1
  55. pygpt_net/provider/core/config/patch.py +14 -1
  56. pygpt_net/provider/llms/anthropic.py +37 -5
  57. pygpt_net/provider/llms/azure_openai.py +3 -1
  58. pygpt_net/provider/llms/base.py +13 -1
  59. pygpt_net/provider/llms/deepseek_api.py +13 -3
  60. pygpt_net/provider/llms/google.py +14 -1
  61. pygpt_net/provider/llms/hugging_face_api.py +105 -24
  62. pygpt_net/provider/llms/hugging_face_embedding.py +88 -0
  63. pygpt_net/provider/llms/hugging_face_router.py +28 -16
  64. pygpt_net/provider/llms/local.py +2 -0
  65. pygpt_net/provider/llms/mistral.py +60 -3
  66. pygpt_net/provider/llms/open_router.py +4 -2
  67. pygpt_net/provider/llms/openai.py +4 -1
  68. pygpt_net/provider/llms/perplexity.py +66 -5
  69. pygpt_net/provider/llms/utils.py +39 -0
  70. pygpt_net/provider/llms/voyage.py +50 -0
  71. pygpt_net/provider/llms/x_ai.py +70 -10
  72. pygpt_net/ui/layout/chat/output.py +1 -1
  73. pygpt_net/ui/widget/lists/db.py +1 -0
  74. pygpt_net/ui/widget/lists/debug.py +1 -0
  75. pygpt_net/ui/widget/tabs/body.py +12 -1
  76. pygpt_net/ui/widget/textarea/web.py +4 -4
  77. pygpt_net/utils.py +3 -2
  78. {pygpt_net-2.6.44.dist-info → pygpt_net-2.6.46.dist-info}/METADATA +73 -16
  79. {pygpt_net-2.6.44.dist-info → pygpt_net-2.6.46.dist-info}/RECORD +82 -78
  80. {pygpt_net-2.6.44.dist-info → pygpt_net-2.6.46.dist-info}/LICENSE +0 -0
  81. {pygpt_net-2.6.44.dist-info → pygpt_net-2.6.46.dist-info}/WHEEL +0 -0
  82. {pygpt_net-2.6.44.dist-info → pygpt_net-2.6.46.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.14 20:00: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,58 @@ 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)
887
+ :param replace: True if replace whole output (legacy HTML path)
1015
888
  """
1016
889
  if self.pids[pid].loaded and not self.pids[pid].use_buffer:
1017
890
  self.clear_chunks(pid)
1018
- if html:
1019
- self.flush_output(pid, html, replace)
891
+ if payload:
892
+ self.flush_output(pid, payload, replace)
1020
893
  self.pids[pid].clear()
1021
894
  else:
1022
895
  if not flush:
1023
- self.pids[pid].append_html(html)
1024
- html = None # free reference
896
+ self.pids[pid].append_html(payload)
1025
897
 
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
- ):
898
+ def append_context_item(self, meta: CtxMeta, ctx: CtxItem,
899
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None):
1033
900
  """
1034
- Append context item to output
901
+ Append context item as one RenderBlock with input+output (if present)
1035
902
 
1036
903
  :param meta: context meta
1037
904
  :param ctx: context item
1038
905
  :param prev_ctx: previous context item
1039
906
  :param next_ctx: next context item
1040
907
  """
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:
908
+ input_text = self.prepare_input(meta, ctx, flush=False, append=False)
909
+ output_text = self.prepare_output(meta, ctx, flush=False, prev_ctx=prev_ctx, next_ctx=next_ctx)
910
+ block = self._build_render_block(meta, ctx, input_text, output_text, prev_ctx=prev_ctx, next_ctx=next_ctx)
911
+ if block:
912
+ pid = self.get_or_create_pid(meta)
913
+ self.append(pid, block.to_json(wrap=True))
914
+
915
+ def append_extra(self, meta: CtxMeta, ctx: CtxItem, footer: bool = False, render: bool = True) -> str:
1061
916
  """
1062
- Append extra data (images, files, etc.) to output
917
+ Append extra data (legacy HTML way) kept for runtime calls that append later.
1063
918
 
1064
- :param meta: Context meta
919
+ :param meta: context meta
1065
920
  :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
921
+ :param footer: True if append at the end (legacy)
922
+ :param render: True if render to output (legacy)
923
+ :return: rendered HTML
1069
924
  """
1070
925
  self.tool_output_end()
1071
926
 
@@ -1086,7 +941,7 @@ class Renderer(BaseRenderer):
1086
941
  html_parts.append(self.body.get_image_html(image, n, c))
1087
942
  self.pids[pid].images_appended.append(image)
1088
943
  n += 1
1089
- except Exception as e:
944
+ except Exception:
1090
945
  pass
1091
946
 
1092
947
  c = len(ctx.files)
@@ -1101,7 +956,7 @@ class Renderer(BaseRenderer):
1101
956
  files_html.append(self.body.get_file_html(file, n, c))
1102
957
  self.pids[pid].files_appended.append(file)
1103
958
  n += 1
1104
- except Exception as e:
959
+ except Exception:
1105
960
  pass
1106
961
  if files_html:
1107
962
  html_parts.append("<br/><br/>".join(files_html))
@@ -1118,7 +973,7 @@ class Renderer(BaseRenderer):
1118
973
  urls_html.append(self.body.get_url_html(url, n, c))
1119
974
  self.pids[pid].urls_appended.append(url)
1120
975
  n += 1
1121
- except Exception as e:
976
+ except Exception:
1122
977
  pass
1123
978
  if urls_html:
1124
979
  html_parts.append("<br/><br/>".join(urls_html))
@@ -1128,7 +983,7 @@ class Renderer(BaseRenderer):
1128
983
  try:
1129
984
  docs = self.body.get_docs_html(ctx.doc_ids)
1130
985
  html_parts.append(docs)
1131
- except Exception as e:
986
+ except Exception:
1132
987
  pass
1133
988
 
1134
989
  html = "".join(html_parts)
@@ -1142,24 +997,19 @@ class Renderer(BaseRenderer):
1142
997
  self.sanitize_html(html)
1143
998
  )});"""
1144
999
  )
1145
- except Exception as e:
1000
+ except Exception:
1146
1001
  pass
1147
1002
 
1148
1003
  return html
1149
1004
 
1150
- def append_timestamp(
1151
- self,
1152
- ctx: CtxItem,
1153
- text: str,
1154
- type: Optional[int] = None
1155
- ) -> str:
1005
+ def append_timestamp(self, ctx: CtxItem, text: str, type: Optional[int] = None) -> str:
1156
1006
  """
1157
- Append timestamp to text
1007
+ Append timestamp to text (legacy HTML path)
1158
1008
 
1159
1009
  :param ctx: context item
1160
- :param text: Input text
1161
- :param type: Type of message
1162
- :return: Text with timestamp (if enabled)
1010
+ :param text: text to append timestamp to
1011
+ :param type: NODE_INPUT or NODE_OUTPUT
1012
+ :return: text with timestamp
1163
1013
  """
1164
1014
  if ctx is not None and ctx.input_timestamp is not None:
1165
1015
  timestamp = None
@@ -1173,16 +1023,12 @@ class Renderer(BaseRenderer):
1173
1023
  text = f'<span class="ts">{hour}: </span>{text}'
1174
1024
  return text
1175
1025
 
1176
- def reset(
1177
- self,
1178
- meta: Optional[CtxMeta] = None,
1179
- clear_nodes: bool = True
1180
- ):
1026
+ def reset(self, meta: Optional[CtxMeta] = None, clear_nodes: bool = True):
1181
1027
  """
1182
- Reset
1028
+ Reset current output
1183
1029
 
1184
- :param meta: Context meta
1185
- :param clear_nodes: True if clear nodes
1030
+ :param meta: context meta
1031
+ :param clear_nodes: True if clear nodes list
1186
1032
  """
1187
1033
  pid = self.get_pid(meta)
1188
1034
  if pid is not None and pid in self.pids:
@@ -1191,7 +1037,6 @@ class Renderer(BaseRenderer):
1191
1037
  if meta is not None:
1192
1038
  pid = self.get_or_create_pid(meta)
1193
1039
  self.reset_by_pid(pid, clear_nodes=clear_nodes)
1194
-
1195
1040
  self.clear_live(meta, CtxItem())
1196
1041
 
1197
1042
  def reset_by_pid(self, pid: Optional[int], clear_nodes: bool = True):
@@ -1199,7 +1044,7 @@ class Renderer(BaseRenderer):
1199
1044
  Reset by PID
1200
1045
 
1201
1046
  :param pid: context PID
1202
- :param clear_nodes: True if clear nodes
1047
+ :param clear_nodes: True if clear nodes list
1203
1048
  """
1204
1049
  self.pids[pid].item = None
1205
1050
  self.pids[pid].html = ""
@@ -1214,27 +1059,24 @@ class Renderer(BaseRenderer):
1214
1059
  node.reset_current_content()
1215
1060
  self.reset_names_by_pid(pid)
1216
1061
  self.prev_chunk_replace = False
1217
- self._stream_reset(pid) # NEW
1062
+ self._stream_reset(pid)
1218
1063
 
1219
1064
  def clear_input(self):
1220
1065
  """Clear input"""
1221
1066
  self.get_input_node().clear()
1222
1067
 
1223
- def clear_output(
1224
- self,
1225
- meta: Optional[CtxMeta] = None
1226
- ):
1068
+ def clear_output(self, meta: Optional[CtxMeta] = None):
1227
1069
  """
1228
1070
  Clear output
1229
1071
 
1230
- :param meta: Context meta
1072
+ :param meta: context meta
1231
1073
  """
1232
1074
  self.prev_chunk_replace = False
1233
1075
  self.reset(meta)
1234
1076
 
1235
- def clear_chunks(self, pid):
1077
+ def clear_chunks(self, pid: Optional[int]):
1236
1078
  """
1237
- Clear chunks from output
1079
+ Clear current chunks
1238
1080
 
1239
1081
  :param pid: context PID
1240
1082
  """
@@ -1243,14 +1085,11 @@ class Renderer(BaseRenderer):
1243
1085
  self.clear_chunks_input(pid)
1244
1086
  self.clear_chunks_output(pid)
1245
1087
 
1246
- def clear_chunks_input(
1247
- self,
1248
- pid: Optional[int]
1249
- ):
1088
+ def clear_chunks_input(self, pid: Optional[int]):
1250
1089
  """
1251
1090
  Clear chunks from input
1252
1091
 
1253
- :pid: context PID
1092
+ :param pid: context PID
1254
1093
  """
1255
1094
  if pid is None:
1256
1095
  return
@@ -1261,14 +1100,11 @@ class Renderer(BaseRenderer):
1261
1100
  except Exception:
1262
1101
  pass
1263
1102
 
1264
- def clear_chunks_output(
1265
- self,
1266
- pid: Optional[int]
1267
- ):
1103
+ def clear_chunks_output(self, pid: Optional[int]):
1268
1104
  """
1269
1105
  Clear chunks from output
1270
1106
 
1271
- :pid: context PID
1107
+ :param pid: context PID
1272
1108
  """
1273
1109
  self.prev_chunk_replace = False
1274
1110
  try:
@@ -1277,16 +1113,13 @@ class Renderer(BaseRenderer):
1277
1113
  )
1278
1114
  except Exception:
1279
1115
  pass
1280
- self._stream_reset(pid) # NEW
1116
+ self._stream_reset(pid)
1281
1117
 
1282
- def clear_nodes(
1283
- self,
1284
- pid: Optional[int]
1285
- ):
1118
+ def clear_nodes(self, pid: Optional[int]):
1286
1119
  """
1287
- Clear nodes from output
1120
+ Clear nodes list
1288
1121
 
1289
- :pid: context PID
1122
+ :param pid: context PID
1290
1123
  """
1291
1124
  try:
1292
1125
  self.get_output_node_by_pid(pid).page().runJavaScript(
@@ -1295,173 +1128,33 @@ class Renderer(BaseRenderer):
1295
1128
  except Exception:
1296
1129
  pass
1297
1130
 
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:
1131
+ # Legacy methods kept for compatibility with existing code paths
1132
+ def prepare_node(self, meta: CtxMeta, ctx: CtxItem, html: str, type: int = 1,
1133
+ prev_ctx: Optional[CtxItem] = None, next_ctx: Optional[CtxItem] = None) -> str:
1307
1134
  """
1308
- Prepare node HTML
1135
+ Compatibility shim: convert single input/output into markdown/raw for JS templates.
1309
1136
 
1310
1137
  :param meta: context meta
1311
- :param ctx: CtxItem instance
1312
- :param html: html text
1313
- :param type: type of message
1138
+ :param ctx: context item
1139
+ :param html: HTML content
1140
+ :param type: NODE_INPUT or NODE_OUTPUT
1314
1141
  :param prev_ctx: previous context item
1315
1142
  :param next_ctx: next context item
1316
- :return: prepared HTML
1143
+ :return: prepared text
1317
1144
  """
1318
1145
  pid = self.get_or_create_pid(meta)
1319
1146
  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
- )
1147
+ return str(html)
1327
1148
  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
- )
1149
+ return str(html)
1457
1150
 
1458
1151
  def get_name_header(self, ctx: CtxItem, stream: bool = False) -> str:
1459
1152
  """
1460
- Get name header for the bot
1153
+ Legacy - kept for stream header text (avatar + name string)
1461
1154
 
1462
- :param ctx: CtxItem instance
1463
- :param stream: True if it is a stream
1464
- :return: HTML name header
1155
+ :param ctx: context item
1156
+ :param stream: True if streaming mode
1157
+ :return: HTML string
1465
1158
  """
1466
1159
  meta = ctx.meta
1467
1160
  if meta is None:
@@ -1494,13 +1187,13 @@ class Renderer(BaseRenderer):
1494
1187
  else:
1495
1188
  return f"<div class=\"name-header name-bot\">{avatar_html}{output_name}</div>"
1496
1189
 
1497
- def flush_output(self, pid: int, html: str, replace: bool = False):
1190
+ def flush_output(self, pid: int, payload: str, replace: bool = False):
1498
1191
  """
1499
- Send content via QWebChannel when ready; otherwise queue with a safe fallback.
1192
+ Send content via QWebChannel (JSON or HTML string).
1500
1193
 
1501
1194
  :param pid: context PID
1502
- :param html: HTML code
1503
- :param replace: True if replace current content
1195
+ :param payload: payload to send
1196
+ :param replace: True if replace nodes list
1504
1197
  """
1505
1198
  if pid is None:
1506
1199
  return
@@ -1516,23 +1209,23 @@ class Renderer(BaseRenderer):
1516
1209
  if br is not None:
1517
1210
  if replace and hasattr(br, "nodeReplace"):
1518
1211
  self.clear_nodes(pid)
1519
- br.nodeReplace.emit(html)
1212
+ br.nodeReplace.emit(payload)
1520
1213
  return
1521
1214
  if not replace and hasattr(br, "node"):
1522
- br.node.emit(html)
1215
+ br.node.emit(payload)
1523
1216
  return
1524
- # Not ready yet -> queue
1525
- self._queue_node(pid, html, replace)
1217
+ # Not ready -> queue
1218
+ self._queue_node(pid, payload, replace)
1526
1219
  except Exception:
1527
- # JS fallback
1220
+ # Fallback to runJavaScript path
1528
1221
  try:
1529
1222
  if replace:
1530
1223
  node.page().runJavaScript(
1531
- f"if (typeof window.replaceNodes !== 'undefined') replaceNodes({self.to_json(html)});"
1224
+ f"if (typeof window.replaceNodes !== 'undefined') replaceNodes({self.to_json(payload)});"
1532
1225
  )
1533
1226
  else:
1534
1227
  node.page().runJavaScript(
1535
- f"if (typeof window.appendNode !== 'undefined') appendNode({self.to_json(html)});"
1228
+ f"if (typeof window.appendNode !== 'undefined') appendNode({self.to_json(payload)});"
1536
1229
  )
1537
1230
  except Exception:
1538
1231
  pass
@@ -1541,13 +1234,9 @@ class Renderer(BaseRenderer):
1541
1234
  """Reload output, called externally only on theme change to redraw content"""
1542
1235
  self.window.controller.ctx.refresh_output()
1543
1236
 
1544
- def flush(
1545
- self,
1546
- pid:
1547
- Optional[int]
1548
- ):
1237
+ def flush(self, pid: Optional[int]):
1549
1238
  """
1550
- Flush output
1239
+ Flush output page HTML (initial)
1551
1240
 
1552
1241
  :param pid: context PID
1553
1242
  """
@@ -1559,20 +1248,16 @@ class Renderer(BaseRenderer):
1559
1248
  if node is not None:
1560
1249
  node.setHtml(html, baseUrl="file://")
1561
1250
 
1562
- def fresh(
1563
- self,
1564
- meta: Optional[CtxMeta] = None,
1565
- force: bool = False
1566
- ):
1251
+ def fresh(self, meta: Optional[CtxMeta] = None, force: bool = False):
1567
1252
  """
1568
1253
  Reset page / unload old renderer from memory
1569
1254
 
1570
1255
  :param meta: context meta
1571
- :param force: True if force recycle even if not loaded
1256
+ :param force: True if force even when not needed
1572
1257
  """
1573
1258
  plain = self.window.core.config.get('render.plain')
1574
1259
  if plain:
1575
- return # plain text mode, no need to recycle
1260
+ return
1576
1261
 
1577
1262
  pid = self.get_or_create_pid(meta)
1578
1263
  if pid is None:
@@ -1599,26 +1284,20 @@ class Renderer(BaseRenderer):
1599
1284
  self.recycle(node, meta)
1600
1285
  self.flush(pid)
1601
1286
 
1602
- def recycle(
1603
- self,
1604
- node: ChatWebOutput,
1605
- meta: Optional[CtxMeta] = None
1606
- ):
1287
+ def recycle(self, node: ChatWebOutput, meta: Optional[CtxMeta] = None):
1607
1288
  """
1608
- Recycle renderer to free memory and avoid leaks
1609
-
1610
- Swaps out the old QWebEngineView with a fresh instance.
1289
+ Recycle renderer to avoid leaks
1611
1290
 
1612
- :param node: output node
1291
+ :param node: output node to recycle
1613
1292
  :param meta: context meta
1614
1293
  """
1615
1294
  tab = node.get_tab()
1616
- layout = tab.child.layout() # layout of TabBody
1295
+ tab.delete_ref(node)
1296
+ layout = tab.child.layout()
1617
1297
  layout.removeWidget(node)
1618
1298
  self.window.ui.nodes['output'].pop(tab.pid, None)
1619
- tab.child.delete_refs()
1620
1299
 
1621
- node.on_delete() # destroy old node
1300
+ node.on_delete()
1622
1301
 
1623
1302
  view = ChatWebOutput(self.window)
1624
1303
  view.set_tab(tab)
@@ -1633,31 +1312,25 @@ class Renderer(BaseRenderer):
1633
1312
  gc.collect()
1634
1313
  except Exception:
1635
1314
  pass
1636
- self.auto_cleanup_soft(meta) # trim memory in Linux
1315
+ self.auto_cleanup_soft(meta)
1637
1316
 
1638
- def get_output_node(
1639
- self,
1640
- meta: Optional[CtxMeta] = None
1641
- ) -> Optional[ChatWebOutput]:
1317
+ def get_output_node(self, meta: Optional[CtxMeta] = None) -> Optional[ChatWebOutput]:
1642
1318
  """
1643
1319
  Get output node
1644
1320
 
1645
1321
  :param meta: context meta
1646
- :return: output node
1322
+ :return: output node or None
1647
1323
  """
1648
1324
  if self._get_output_node_by_meta is None:
1649
1325
  self._get_output_node_by_meta = self.window.core.ctx.output.get_output_node_by_meta
1650
1326
  return self._get_output_node_by_meta(meta)
1651
1327
 
1652
- def get_output_node_by_pid(
1653
- self,
1654
- pid: Optional[int]
1655
- ) -> Optional[ChatWebOutput]:
1328
+ def get_output_node_by_pid(self, pid: Optional[int]) -> Optional[ChatWebOutput]:
1656
1329
  """
1657
1330
  Get output node by PID
1658
1331
 
1659
- :param pid: context pid
1660
- :return: output node
1332
+ :param pid: context PID
1333
+ :return: output node or None
1661
1334
  """
1662
1335
  if self._get_output_node_by_pid is None:
1663
1336
  self._get_output_node_by_pid = self.window.core.ctx.output.get_output_node_by_pid
@@ -1701,7 +1374,7 @@ class Renderer(BaseRenderer):
1701
1374
  """
1702
1375
  Reset names
1703
1376
 
1704
- :param meta: context meta
1377
+ :param meta: context meta
1705
1378
  """
1706
1379
  if meta is None:
1707
1380
  return
@@ -1712,7 +1385,7 @@ class Renderer(BaseRenderer):
1712
1385
  """
1713
1386
  Reset names by PID
1714
1387
 
1715
- :param pid: context pid
1388
+ :param pid: context PID
1716
1389
  """
1717
1390
  self.pids[pid].name_user = trans("chat.name.user")
1718
1391
  self.pids[pid].name_bot = trans("chat.name.bot")
@@ -1727,7 +1400,7 @@ class Renderer(BaseRenderer):
1727
1400
 
1728
1401
  def on_edit_submit(self, ctx: CtxItem):
1729
1402
  """
1730
- On regenerate submit
1403
+ On edit submit
1731
1404
 
1732
1405
  :param ctx: context item
1733
1406
  """
@@ -1737,7 +1410,7 @@ class Renderer(BaseRenderer):
1737
1410
  """
1738
1411
  On enable edit icons
1739
1412
 
1740
- :param live: True if live update
1413
+ :param live: True if live mode
1741
1414
  """
1742
1415
  if not live:
1743
1416
  return
@@ -1752,7 +1425,7 @@ class Renderer(BaseRenderer):
1752
1425
  """
1753
1426
  On disable edit icons
1754
1427
 
1755
- :param live: True if live update
1428
+ :param live: True if live mode
1756
1429
  """
1757
1430
  if not live:
1758
1431
  return
@@ -1767,7 +1440,7 @@ class Renderer(BaseRenderer):
1767
1440
  """
1768
1441
  On enable timestamp
1769
1442
 
1770
- :param live: True if live update
1443
+ :param live: True if live mode
1771
1444
  """
1772
1445
  if not live:
1773
1446
  return
@@ -1782,7 +1455,7 @@ class Renderer(BaseRenderer):
1782
1455
  """
1783
1456
  On disable timestamp
1784
1457
 
1785
- :param live: True if live update
1458
+ :param live: True if live mode
1786
1459
  """
1787
1460
  if not live:
1788
1461
  return
@@ -1793,13 +1466,9 @@ class Renderer(BaseRenderer):
1793
1466
  except Exception:
1794
1467
  pass
1795
1468
 
1796
- def update_names(
1797
- self,
1798
- meta: CtxMeta,
1799
- ctx: CtxItem
1800
- ):
1469
+ def update_names(self, meta: CtxMeta, ctx: CtxItem):
1801
1470
  """
1802
- Update names
1471
+ Update names from ctx
1803
1472
 
1804
1473
  :param meta: context meta
1805
1474
  :param ctx: context item
@@ -1813,7 +1482,7 @@ class Renderer(BaseRenderer):
1813
1482
  self.pids[pid].name_bot = ctx.output_name
1814
1483
 
1815
1484
  def clear_all(self):
1816
- """Clear all"""
1485
+ """Clear all tabs"""
1817
1486
  for pid in self.pids:
1818
1487
  self.clear_chunks(pid)
1819
1488
  self.clear_nodes(pid)
@@ -1821,33 +1490,23 @@ class Renderer(BaseRenderer):
1821
1490
  self._stream_reset(pid)
1822
1491
 
1823
1492
  def scroll_to_bottom(self):
1824
- """Scroll to bottom"""
1493
+ """Scroll to bottom placeholder"""
1825
1494
  pass
1826
1495
 
1827
1496
  def append_block(self):
1828
- """Append block to output"""
1497
+ """Append block placeholder"""
1829
1498
  pass
1830
1499
 
1831
1500
  def to_end(self, ctx: CtxItem):
1832
- """
1833
- Move cursor to end of output
1834
-
1835
- :param ctx: context item
1836
- """
1501
+ """Move cursor to end placeholder"""
1837
1502
  pass
1838
1503
 
1839
1504
  def get_all_nodes(self) -> list:
1840
- """
1841
- Return all registered nodes
1842
-
1843
- :return: list of ChatOutput nodes (tabs)
1844
- """
1505
+ """Return all registered nodes"""
1845
1506
  return self.window.core.ctx.output.get_all()
1846
1507
 
1847
- # TODO: on lang change
1848
-
1849
1508
  def reload_css(self):
1850
- """Reload CSS - all, global"""
1509
+ """Reload CSS propagate theme and config to runtime"""
1851
1510
  to_json = self.to_json(self.body.prepare_styles())
1852
1511
  nodes = self.get_all_nodes()
1853
1512
  for pid in self.pids:
@@ -1865,7 +1524,7 @@ class Renderer(BaseRenderer):
1865
1524
  node.page().runJavaScript(
1866
1525
  "if (typeof window.disableBlocks !== 'undefined') disableBlocks();"
1867
1526
  )
1868
- except Exception as e:
1527
+ except Exception:
1869
1528
  pass
1870
1529
  return
1871
1530
 
@@ -1877,16 +1536,12 @@ class Renderer(BaseRenderer):
1877
1536
  self.reload_css()
1878
1537
  return
1879
1538
 
1880
- def tool_output_append(
1881
- self,
1882
- meta: CtxMeta,
1883
- content: str
1884
- ):
1539
+ def tool_output_append(self, meta: CtxMeta, content: str):
1885
1540
  """
1886
1541
  Add tool output (append)
1887
1542
 
1888
1543
  :param meta: context meta
1889
- :param content: content
1544
+ :param content: content to append
1890
1545
  """
1891
1546
  try:
1892
1547
  self.get_output_node(meta).page().runJavaScript(
@@ -1897,16 +1552,12 @@ class Renderer(BaseRenderer):
1897
1552
  except Exception:
1898
1553
  pass
1899
1554
 
1900
- def tool_output_update(
1901
- self,
1902
- meta: CtxMeta,
1903
- content: str
1904
- ):
1555
+ def tool_output_update(self, meta: CtxMeta, content: str):
1905
1556
  """
1906
1557
  Replace tool output
1907
1558
 
1908
1559
  :param meta: context meta
1909
- :param content: content
1560
+ :param content: content to set
1910
1561
  """
1911
1562
  try:
1912
1563
  self.get_output_node(meta).page().runJavaScript(
@@ -1954,10 +1605,9 @@ class Renderer(BaseRenderer):
1954
1605
 
1955
1606
  def sanitize_html(self, html: str) -> str:
1956
1607
  """
1957
- Sanitize HTML to prevent XSS attacks
1608
+ Sanitize HTM
1958
1609
 
1959
- :param html: HTML string to sanitize
1960
- :return: sanitized HTML string
1610
+ :param html: HTML string
1961
1611
  """
1962
1612
  return html
1963
1613
  if not html:
@@ -1966,35 +1616,30 @@ class Renderer(BaseRenderer):
1966
1616
  return html
1967
1617
  return self.RE_AMP_LT_GT.sub(r'&\1;', html)
1968
1618
 
1969
- def append_debug(
1970
- self,
1971
- ctx: CtxItem,
1972
- pid,
1973
- title: Optional[str] = None
1974
- ) -> str:
1619
+ def append_debug(self, ctx: CtxItem, pid, title: Optional[str] = None) -> str:
1975
1620
  """
1976
- Append debug info
1621
+ Append debug info HTML (legacy path)
1977
1622
 
1978
1623
  :param ctx: context item
1979
1624
  :param pid: context PID
1980
- :param title: debug title
1981
- :return: HTML debug info
1625
+ :param title: optional title
1626
+ :return: HTML string
1982
1627
  """
1983
1628
  if title is None:
1984
1629
  title = "debug"
1985
- return f"<div class='debug'><b>{title}:</b> pid: {pid}, ctx: {ctx.to_dict()}</div>"
1630
+ return f"<div class='debug'><b>{title}:</b> pid: {pid}, ctx: {_html.escape(ctx.to_debug())}</div>"
1986
1631
 
1987
1632
  def is_debug(self) -> bool:
1988
1633
  """
1989
- Check if debug mode is enabled
1634
+ Check debug flag
1990
1635
 
1991
- :return: True if debug mode is enabled
1636
+ :return: True if debug enabled
1992
1637
  """
1993
1638
  return self.window.core.config.get("debug.render", False)
1994
1639
 
1995
1640
  def js_stream_queue_len(self, pid: int):
1996
1641
  """
1997
- Ask the JS side how many items are currently queued for streaming (streamQ.length).
1642
+ Ask JS side for stream queue length
1998
1643
 
1999
1644
  :param pid: context PID
2000
1645
  """
@@ -2012,13 +1657,12 @@ class Renderer(BaseRenderer):
2012
1657
 
2013
1658
  def remove_pid(self, pid: int):
2014
1659
  """
2015
- Remove PID from renderer
1660
+ Remove PID and clean resources
2016
1661
 
2017
1662
  :param pid: context PID
2018
1663
  """
2019
1664
  if pid in self.pids:
2020
1665
  del self.pids[pid]
2021
- # Clean micro-batch resources
2022
1666
  self._stream_reset(pid)
2023
1667
  t = self._stream_timer.pop(pid, None)
2024
1668
  if t:
@@ -2032,7 +1676,7 @@ class Renderer(BaseRenderer):
2032
1676
 
2033
1677
  def on_js_ready(self, pid: int) -> None:
2034
1678
  """
2035
- On JS ready - called from JS side via QWebChannel when bridge is ready.
1679
+ Called by JS via bridge when ready
2036
1680
 
2037
1681
  :param pid: context PID
2038
1682
  """
@@ -2044,7 +1688,7 @@ class Renderer(BaseRenderer):
2044
1688
 
2045
1689
  def _drain_pending_nodes(self, pid: int):
2046
1690
  """
2047
- Drain any pending nodes queued while bridge was not ready.
1691
+ Flush queued node payloads
2048
1692
 
2049
1693
  :param pid: context PID
2050
1694
  """
@@ -2064,9 +1708,7 @@ class Renderer(BaseRenderer):
2064
1708
  elif not replace and hasattr(br, "node"):
2065
1709
  br.node.emit(payload)
2066
1710
  except Exception:
2067
- # If something goes wrong, stop draining to avoid dropping further items.
2068
1711
  break
2069
- # stop/clear fallback timer if any
2070
1712
  t = self._pending_timer.pop(pid, None)
2071
1713
  if t:
2072
1714
  try:
@@ -2076,11 +1718,11 @@ class Renderer(BaseRenderer):
2076
1718
 
2077
1719
  def _queue_node(self, pid: int, payload: str, replace: bool):
2078
1720
  """
2079
- Queue node payload for later delivery when bridge is ready, with a safe fallback.
1721
+ Queue node payload until bridge is ready (with safe fallback)
2080
1722
 
2081
1723
  :param pid: context PID
2082
- :param payload: sanitized HTML payload
2083
- :param replace: True if replace current content
1724
+ :param payload: payload to queue
1725
+ :param replace: True if replace nodes list
2084
1726
  """
2085
1727
  q = self._pending_nodes.setdefault(pid, [])
2086
1728
  q.append((replace, payload))
@@ -2090,7 +1732,6 @@ class Renderer(BaseRenderer):
2090
1732
  t.setInterval(1200) # ms
2091
1733
 
2092
1734
  def on_timeout(pid=pid):
2093
- # Still not ready? Fallback: flush queued via runJavaScript once.
2094
1735
  node = self.get_output_node_by_pid(pid)
2095
1736
  if node:
2096
1737
  while self._pending_nodes.get(pid):
@@ -2118,11 +1759,10 @@ class Renderer(BaseRenderer):
2118
1759
 
2119
1760
  def _stream_get(self, pid: int) -> tuple[_AppendBuffer, QTimer]:
2120
1761
  """
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.
1762
+ Get/create per-PID append buffer and timer
2123
1763
 
2124
1764
  :param pid: context PID
2125
- :return: (buffer, timer) tuple
1765
+ :return: (buffer, timer)
2126
1766
  """
2127
1767
  buf = self._stream_acc.get(pid)
2128
1768
  if buf is None:
@@ -2136,7 +1776,6 @@ class Renderer(BaseRenderer):
2136
1776
  t.setInterval(self._stream_interval_ms)
2137
1777
 
2138
1778
  def on_timeout(pid=pid):
2139
- # Flush pending batch and, if more data arrives later, timer will be restarted by _stream_push()
2140
1779
  self._stream_flush(pid, force=False)
2141
1780
 
2142
1781
  t.timeout.connect(on_timeout)
@@ -2146,9 +1785,7 @@ class Renderer(BaseRenderer):
2146
1785
 
2147
1786
  def _stream_reset(self, pid: Optional[int]):
2148
1787
  """
2149
- Reset micro-batch resources for a PID. Safe to call frequently.
2150
-
2151
- Clear buffer, stop timer, clear header and last-flush timestamp.
1788
+ Reset micro-batch state for PID
2152
1789
 
2153
1790
  :param pid: context PID
2154
1791
  """
@@ -2168,56 +1805,42 @@ class Renderer(BaseRenderer):
2168
1805
 
2169
1806
  def _stream_push(self, pid: int, header: str, chunk: str):
2170
1807
  """
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.
1808
+ Push chunk into buffer and schedule flush
2173
1809
 
2174
1810
  :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
1811
+ :param header: optional header (first chunk only)
1812
+ :param chunk: chunk to append
2177
1813
  """
2178
1814
  if not chunk:
2179
1815
  return
2180
1816
 
2181
1817
  buf, timer = self._stream_get(pid)
2182
- # Remember last known header for this PID
2183
1818
  if header and not self._stream_header.get(pid):
2184
1819
  self._stream_header[pid] = header
2185
1820
 
2186
- # Append chunk cheaply
2187
1821
  buf.append(chunk)
2188
-
2189
- # Emergency backstop: if buffer is getting too large, flush now
2190
1822
  pending_size = getattr(buf, "_size", 0)
2191
1823
  if pending_size >= self._stream_emergency_bytes:
2192
1824
  self._stream_flush(pid, force=True)
2193
1825
  return
2194
-
2195
- # Size-based early flush for responsiveness
2196
1826
  if pending_size >= self._stream_max_bytes:
2197
1827
  self._stream_flush(pid, force=True)
2198
1828
  return
2199
-
2200
- # Start timer if not active to flush at ~frame rate
2201
1829
  if not timer.isActive():
2202
1830
  try:
2203
1831
  timer.start()
2204
1832
  except Exception:
2205
- # As a fallback, if timer cannot start, flush synchronously
2206
1833
  self._stream_flush(pid, force=True)
2207
1834
 
2208
1835
  def _stream_flush(self, pid: int, force: bool = False):
2209
1836
  """
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.
1837
+ Flush buffered chunks via QWebChannel (bridge.chunk.emit(name, chunk, type))
2214
1838
 
2215
1839
  :param pid: context PID
2216
- :param force: True to force flush ignoring interval
1840
+ :param force: True if force flush ignoring interval
2217
1841
  """
2218
1842
  buf = self._stream_acc.get(pid)
2219
1843
  if buf is None or buf.is_empty():
2220
- # Nothing to send; stop timer if any
2221
1844
  t = self._stream_timer.get(pid)
2222
1845
  if t and t.isActive():
2223
1846
  try:
@@ -2228,11 +1851,9 @@ class Renderer(BaseRenderer):
2228
1851
 
2229
1852
  node = self.get_output_node_by_pid(pid)
2230
1853
  if node is None:
2231
- # Drop buffer if node is gone to avoid leaks
2232
1854
  buf.clear()
2233
1855
  return
2234
1856
 
2235
- # Stop timer for this flush; next push will re-arm it
2236
1857
  t = self._stream_timer.get(pid)
2237
1858
  if t and t.isActive():
2238
1859
  try:
@@ -2240,22 +1861,18 @@ class Renderer(BaseRenderer):
2240
1861
  except Exception:
2241
1862
  pass
2242
1863
 
2243
- # Gather and clear the pending data in one allocation
2244
1864
  data = buf.get_and_clear()
2245
1865
  name = self._stream_header.get(pid, "") or ""
2246
1866
 
2247
1867
  try:
2248
1868
  br = getattr(node.page(), "bridge", None)
2249
1869
  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)
1870
+ br.chunk.emit(name, data, "text_delta")
2252
1871
  else:
2253
- # Fallback path if bridge not yet connected on this page
2254
1872
  node.page().runJavaScript(
2255
1873
  f"if (typeof window.appendStream !== 'undefined') appendStream({self.to_json(name)},{self.to_json(data)});"
2256
1874
  )
2257
1875
  except Exception:
2258
- # If something goes wrong, attempt a JS fallback once
2259
1876
  try:
2260
1877
  node.page().runJavaScript(
2261
1878
  f"if (typeof window.appendStream !== 'undefined') appendStream({self.to_json(name)},{self.to_json(data)});"
@@ -2263,16 +1880,13 @@ class Renderer(BaseRenderer):
2263
1880
  except Exception:
2264
1881
  pass
2265
1882
 
2266
- # Opportunistic memory release on Linux for very large flushes
2267
1883
  try:
2268
1884
  if len(data) >= (256 * 1024):
2269
1885
  self.auto_cleanup_soft()
2270
1886
  except Exception:
2271
1887
  pass
2272
1888
 
2273
- # Explicitly drop reference to large string so GC/malloc_trim can reclaim sooner
2274
1889
  del data
2275
-
2276
1890
  self._stream_last_flush[pid] = monotonic()
2277
1891
 
2278
1892
  def eval_js(self, script: str):
@@ -2292,4 +1906,144 @@ class Renderer(BaseRenderer):
2292
1906
  try:
2293
1907
  node.page().runJavaScript(script, callback)
2294
1908
  except Exception:
2295
- pass
1909
+ pass
1910
+
1911
+ # ------------------------- Helpers: build JSON blocks -------------------------
1912
+
1913
+ def _output_identity(self, ctx: CtxItem) -> Tuple[str, Optional[str], bool]:
1914
+ """
1915
+ Resolve output identity (name, avatar file:// path) based on preset.
1916
+
1917
+ :param ctx: context item
1918
+ :return: (name, avatar, personalize)
1919
+ """
1920
+ meta = ctx.meta
1921
+ if meta is None:
1922
+ return self.pids[self.get_or_create_pid(meta)].name_bot if meta else "", None, False
1923
+ preset_id = meta.preset
1924
+ if not preset_id:
1925
+ return self.pids[self.get_or_create_pid(meta)].name_bot, None, False
1926
+ preset = self.window.core.presets.get(preset_id)
1927
+ if preset is None or not preset.ai_personalize:
1928
+ return self.pids[self.get_or_create_pid(meta)].name_bot, None, False
1929
+ name = preset.ai_name or self.pids[self.get_or_create_pid(meta)].name_bot
1930
+ avatar = None
1931
+ if preset.ai_avatar:
1932
+ presets_dir = self.window.core.config.get_user_dir("presets")
1933
+ avatars_dir = os.path.join(presets_dir, "avatars")
1934
+ avatar_path = os.path.join(avatars_dir, preset.ai_avatar)
1935
+ if os.path.exists(avatar_path):
1936
+ avatar = f"{self._file_prefix}{avatar_path}"
1937
+ return name, avatar, bool(preset.ai_personalize)
1938
+
1939
+ def _build_render_block(
1940
+ self,
1941
+ meta: CtxMeta,
1942
+ ctx: CtxItem,
1943
+ input_text: Optional[str],
1944
+ output_text: Optional[str],
1945
+ prev_ctx: Optional[CtxItem] = None,
1946
+ next_ctx: Optional[CtxItem] = None
1947
+ ) -> Optional[RenderBlock]:
1948
+ """
1949
+ Build RenderBlock for given ctx and payloads (input/output).
1950
+
1951
+ - if input_text or output_text is None, that part is skipped.
1952
+ - if ctx.hidden is True, returns None.
1953
+ - if both input_text and output_text are None, returns None.
1954
+ - if meta is None or invalid, returns None.
1955
+
1956
+ :param meta: CtxMeta object
1957
+ :param ctx: CtxItem object
1958
+ :param input_text: Input text (raw, un-formatted)
1959
+ :param prev_ctx: Previous CtxItem (for context, optional)
1960
+ :param next_ctx: Next CtxItem (for context, optional)
1961
+ :return: RenderBlock object or None
1962
+ """
1963
+ pid = self.get_or_create_pid(meta)
1964
+ if pid is None:
1965
+ return
1966
+
1967
+ block = RenderBlock(id=getattr(ctx, "id", None), meta_id=getattr(meta, "id", None))
1968
+
1969
+ # input
1970
+ if input_text:
1971
+ # Keep raw; formatting is a template duty (escape/BR etc.)
1972
+ block.input = {
1973
+ "type": "user",
1974
+ "name": self.pids[pid].name_user,
1975
+ "avatar_img": None, # no user avatar by default
1976
+ "text": str(input_text),
1977
+ "timestamp": ctx.input_timestamp if hasattr(ctx, "input_timestamp") else None,
1978
+ }
1979
+
1980
+ # output
1981
+ if output_text:
1982
+ # Pre/post format raw markdown via Helpers to preserve placeholders ([!cmd], think) and workdir tokens.
1983
+ md_src = self.helpers.pre_format_text(output_text)
1984
+ md_text = self.helpers.post_format_text(md_src)
1985
+ name, avatar, personalize = self._output_identity(ctx)
1986
+
1987
+ # tool output visibility (agent step / commands)
1988
+ is_cmd = (
1989
+ next_ctx is not None and
1990
+ next_ctx.internal and
1991
+ (len(ctx.cmds) > 0 or (ctx.extra_ctx is not None and len(ctx.extra_ctx) > 0))
1992
+ )
1993
+ tool_output = ""
1994
+ tool_output_visible = False
1995
+ if is_cmd:
1996
+ if ctx.results is not None and len(ctx.results) > 0 \
1997
+ and isinstance(ctx.extra, dict) and "agent_step" in ctx.extra:
1998
+ tool_output = self.helpers.format_cmd_text(str(ctx.input), indent=True)
1999
+ tool_output_visible = True
2000
+ else:
2001
+ tool_output = self.helpers.format_cmd_text(str(next_ctx.input), indent=True)
2002
+ tool_output_visible = True
2003
+ elif ctx.results is not None and len(ctx.results) > 0 \
2004
+ and isinstance(ctx.extra, dict) and "agent_step" in ctx.extra:
2005
+ tool_output = self.helpers.format_cmd_text(str(ctx.input), indent=True)
2006
+
2007
+ # plugin-driven extra (HTML) – keep as-is to preserve functionality
2008
+ tool_extra_html = self.body.prepare_tool_extra(ctx)
2009
+
2010
+ block.output = {
2011
+ "type": "bot",
2012
+ "name": name or self.pids[pid].name_bot,
2013
+ "avatar_img": avatar,
2014
+ "text": md_text,
2015
+ "timestamp": ctx.output_timestamp if hasattr(ctx, "output_timestamp") else None,
2016
+ }
2017
+
2018
+ # extras (images/files/urls/actions)
2019
+ images, files, urls, extra_actions = self.body.build_extras_dicts(ctx, pid)
2020
+ block.images = images
2021
+ block.files = files
2022
+ block.urls = urls
2023
+
2024
+ # docs as raw data -> rendered in JS
2025
+ docs_norm = []
2026
+ if self.window.core.config.get('ctx.sources'):
2027
+ if ctx.doc_ids is not None and len(ctx.doc_ids) > 0:
2028
+ docs_norm = self.body.normalize_docs(ctx.doc_ids)
2029
+
2030
+ block.extra.update({
2031
+ "tool_output": tool_output,
2032
+ "tool_output_visible": tool_output_visible,
2033
+ "tool_extra_html": tool_extra_html,
2034
+ "docs": docs_norm,
2035
+ "footer_icons": True,
2036
+ "personalize": personalize,
2037
+ })
2038
+ block.extra.update(extra_actions)
2039
+
2040
+ # carry ctx.extra flags as-is (do not collide with our own keys)
2041
+ if isinstance(ctx.extra, dict):
2042
+ block.extra.setdefault("ctx_extra", ctx.extra)
2043
+
2044
+ # debug
2045
+ if self.is_debug():
2046
+ print(block.debug())
2047
+ block.extra["debug_html"] = self.append_debug(ctx, pid, "output")
2048
+
2049
+ return block