pygpt-net 2.6.44__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.
pygpt_net/CHANGELOG.txt CHANGED
@@ -1,3 +1,8 @@
1
+ 2.6.45 (2025-09-13)
2
+
3
+ - Improved: Parsing of custom markup in the stream.
4
+ - Improved: Message block parsing moved to JavaScript.
5
+
1
6
  2.6.44 (2025-09-12)
2
7
 
3
8
  - Added: Auto-collapse for large user input blocks.
pygpt_net/__init__.py CHANGED
@@ -6,15 +6,15 @@
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.12 00:00:00 #
9
+ # Updated Date: 2025.09.13 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  __author__ = "Marcin Szczygliński"
13
13
  __copyright__ = "Copyright 2025, Marcin Szczygliński"
14
14
  __credits__ = ["Marcin Szczygliński"]
15
15
  __license__ = "MIT"
16
- __version__ = "2.6.44"
17
- __build__ = "2025-09-12"
16
+ __version__ = "2.6.45"
17
+ __build__ = "2025-09-13"
18
18
  __maintainer__ = "Marcin Szczygliński"
19
19
  __github__ = "https://github.com/szczyglis-dev/py-gpt"
20
20
  __report__ = "https://github.com/szczyglis-dev/py-gpt/issues"
pygpt_net/app.py CHANGED
@@ -38,7 +38,7 @@ os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = (
38
38
  # by default, optimize for low-end devices
39
39
  os.environ.setdefault(
40
40
  "QTWEBENGINE_CHROMIUM_FLAGS",
41
- "--disable-gpu --process-per-site --renderer-process-limit=1 --enable-low-end-device-mode"
41
+ "--enable-low-end-device-mode"
42
42
  )
43
43
 
44
44
  _original_open = builtins.open
@@ -1284,6 +1284,12 @@ class Ctx:
1284
1284
  """Clear selected list"""
1285
1285
  self.selected.clear()
1286
1286
 
1287
+ def fresh_current_output(self):
1288
+ """Fresh output for current context"""
1289
+ meta = self.window.core.ctx.get_meta_by_id(self.window.core.ctx.get_current())
1290
+ if meta is not None:
1291
+ self.fresh_output(meta)
1292
+
1287
1293
  def fresh_output(self, meta: CtxMeta):
1288
1294
  """
1289
1295
  Fresh output for new context
@@ -84,7 +84,7 @@ class Fixtures:
84
84
  :return: stream generator
85
85
  """
86
86
  ctx.use_responses_api = False
87
- path = os.path.join(self.window.core.config.get_app_path(), "data", "js", "app.js")
87
+ path = os.path.join(self.window.core.config.get_app_path(), "data", "fixtures", "fake_stream.txt")
88
88
  return FakeOpenAIStream(code_path=path).stream(
89
89
  api="raw",
90
90
  chunk="code",
@@ -56,7 +56,8 @@ class Console:
56
56
  res = "\n" + self.window.core.debug.mem("Console")
57
57
  self.log(res)
58
58
  elif msg == "free":
59
- mem_clean()
59
+ self.window.controller.ctx.fresh_current_output()
60
+ mem_clean(force=True)
60
61
  self.log("Memory cleaned")
61
62
  elif msg in ["quit", "exit", "/q"]:
62
63
  self.window.close()
@@ -412,7 +412,7 @@ class Debug:
412
412
  qobjects = sum(1 for obj in QApplication.allWidgets() if isinstance(obj, QObject))
413
413
  stats.append(f"QObjects: {qobjects}")
414
414
 
415
- res += "\n\n".join(stats)
415
+ res += "\n" + "\n".join(stats)
416
416
  print("\n".join(stats))
417
417
  return res
418
418
 
@@ -140,9 +140,8 @@ class FakeOpenAIStream:
140
140
  # Chat: often the first delta with a role
141
141
  if api == "chat" and cfg.include_chat_role:
142
142
  prefix_payloads.append(self._wrap_chat_delta({"role": "assistant", "content": ""}))
143
- # Code: we start with ```python\n
144
143
  if chunk == "code":
145
- prefix_payloads.append(self._wrap_payload(cfg, "```javascript\n"))
144
+ prefix_payloads.append(self._wrap_payload(cfg, ""))
146
145
 
147
146
  self._code_mode = (chunk == "code")
148
147
  return prefix_payloads, sleep_dt
@@ -6,14 +6,14 @@
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.12 23:00:00 #
9
+ # Updated Date: 2025.09.13 06:05:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
13
  from json import dumps as _json_dumps
14
14
  from random import shuffle as _shuffle
15
15
 
16
- from typing import Optional, List, Dict
16
+ from typing import Optional, List, Dict, Tuple
17
17
 
18
18
  from pygpt_net.core.text.utils import elide_filename
19
19
  from pygpt_net.core.events import Event
@@ -208,33 +208,34 @@ class Body:
208
208
  """
209
209
 
210
210
  def __init__(self, window=None):
211
+ """
212
+ Initialize Body with reference to main window and syntax highlighter.
213
+
214
+ :param window: Window reference
215
+ """
211
216
  self.window = window
212
217
  self.highlight = SyntaxHighlight(window)
213
218
  self._tip_keys = tuple(f"output.tips.{i}" for i in range(1, self.NUM_TIPS + 1))
214
219
  self._syntax_dark = (
215
- "dracula",
216
- "fruity",
217
- "github-dark",
218
- "gruvbox-dark",
219
- "inkpot",
220
- "material",
221
- "monokai",
222
- "native",
223
- "nord",
224
- "nord-darker",
225
- "one-dark",
226
- "paraiso-dark",
227
- "rrt",
228
- "solarized-dark",
229
- "stata-dark",
230
- "vim",
231
- "zenburn",
220
+ "dracula", "fruity", "github-dark", "gruvbox-dark", "inkpot", "material",
221
+ "monokai", "native", "nord", "nord-darker", "one-dark", "paraiso-dark",
222
+ "rrt", "solarized-dark", "stata-dark", "vim", "zenburn",
232
223
  )
233
224
 
234
225
  def is_timestamp_enabled(self) -> bool:
226
+ """
227
+ Check if timestamp display is enabled in config.
228
+
229
+ :return: True if enabled, False otherwise.
230
+ """
235
231
  return self.window.core.config.get('output_timestamp')
236
232
 
237
233
  def prepare_styles(self) -> str:
234
+ """
235
+ Prepare combined CSS styles for the web view.
236
+
237
+ :return: Combined CSS string.
238
+ """
238
239
  cfg = self.window.core.config
239
240
  fonts_path = os.path.join(cfg.get_app_path(), "data", "fonts").replace("\\", "/")
240
241
  syntax_style = self.window.core.config.get("render.code_syntax") or "default"
@@ -250,12 +251,25 @@ class Body:
250
251
  return "\n".join(parts)
251
252
 
252
253
  def prepare_action_icons(self, ctx: CtxItem) -> str:
254
+ """
255
+ Prepare HTML for message-level action icons.
256
+
257
+ :param ctx: CtxItem
258
+ :return: HTML string or empty if no icons.
259
+ """
253
260
  icons_html = "".join(self.get_action_icons(ctx, all=True))
254
261
  if icons_html:
255
262
  return f'<div class="action-icons" data-id="{ctx.id}">{icons_html}</div>'
256
263
  return ""
257
264
 
258
265
  def get_action_icons(self, ctx: CtxItem, all: bool = False) -> List[str]:
266
+ """
267
+ Return HTML snippets for message-level action icons.
268
+
269
+ :param ctx: CtxItem
270
+ :param all: If True, return all icons; otherwise, return only available ones.
271
+ :return: List of HTML strings for icons.
272
+ """
259
273
  icons: List[str] = []
260
274
  if ctx.output:
261
275
  cid = ctx.id
@@ -275,12 +289,71 @@ class Body:
275
289
  f'<a href="extra-join:{cid}" class="action-icon edit-icon" data-id="{cid}" role="button"><span class="cmd">{self.get_icon("playlist_add", t("ctx.extra.join"), ctx)}</span></a>')
276
290
  return icons
277
291
 
278
- def get_icon(self, icon: str, title: Optional[str] = None, item: Optional[CtxItem] = None) -> str:
292
+ def get_action_icon_data(self, ctx: CtxItem) -> List[Dict]:
293
+ """
294
+ Return raw data for message-level action icons (href/title/icon path).
295
+ This allows JS templates to render actions without Python-side HTML.
296
+
297
+ 1. extra-audio-read
298
+ 2. extra-copy
299
+ 3. extra-replay
300
+ 4. extra-edit
301
+ 5. extra-delete
302
+ 6. extra-join (if not the first item)
303
+ 7. (future actions...)
304
+
305
+ :param ctx: CtxItem
306
+ :return: List of action dicts
307
+ """
308
+ items: List[Dict] = []
309
+ if ctx.output:
310
+ cid = ctx.id
311
+ t = trans
312
+ app_path = self.window.core.config.get_app_path()
313
+ def icon_path(name: str) -> str:
314
+ return os.path.join(app_path, "data", "icons", f"{name}.svg").replace("\\", "/")
315
+
316
+ items.append({"href": f"extra-audio-read:{cid}", "title": t("ctx.extra.audio"), "icon": f"file://{icon_path('volume')}", "id": cid})
317
+ items.append({"href": f"extra-copy:{cid}", "title": t("ctx.extra.copy"), "icon": f"file://{icon_path('copy')}", "id": cid})
318
+ items.append({"href": f"extra-replay:{cid}", "title": t("ctx.extra.reply"), "icon": f"file://{icon_path('reload')}", "id": cid})
319
+ items.append({"href": f"extra-edit:{cid}", "title": t("ctx.extra.edit"), "icon": f"file://{icon_path('edit')}", "id": cid})
320
+ items.append({"href": f"extra-delete:{cid}", "title": t("ctx.extra.delete"), "icon": f"file://{icon_path('delete')}", "id": cid})
321
+ if not self.window.core.ctx.is_first_item(cid):
322
+ items.append({"href": f"extra-join:{cid}", "title": t("ctx.extra.join"), "icon": f"file://{icon_path('playlist_add')}", "id": cid})
323
+ return items
324
+
325
+ def get_icon(
326
+ self,
327
+ icon: str,
328
+ title: Optional[str] = None,
329
+ item: Optional[CtxItem] = None
330
+ ) -> str:
331
+ """
332
+ Get HTML for an icon image with title and optional data-id.
333
+
334
+ :param icon: Icon name (without extension)
335
+ :param title: Icon title (tooltip)
336
+ :param item: Optional CtxItem to get data-id from
337
+ :return: HTML string
338
+ """
279
339
  app_path = self.window.core.config.get_app_path()
280
340
  icon_path = os.path.join(app_path, "data", "icons", f"{icon}.svg")
281
341
  return f'<img src="file://{icon_path}" class="action-img" title="{title}" alt="{title}" data-id="{item.id}">'
282
342
 
283
- def get_image_html(self, url: str, num: Optional[int] = None, num_all: Optional[int] = None) -> str:
343
+ def get_image_html(
344
+ self,
345
+ url: str,
346
+ num: Optional[int] = None,
347
+ num_all: Optional[int] = None
348
+ ) -> str:
349
+ """
350
+ Get HTML for an image or video link with optional numbering.
351
+
352
+ :param url: Image or video URL
353
+ :param num: Optional index (1-based)
354
+ :param num_all: Optional total number of images/videos
355
+ :return: HTML string
356
+ """
284
357
  url, path = self.window.core.filesystem.extract_local_url(url)
285
358
  basename = os.path.basename(path)
286
359
  ext = os.path.splitext(basename)[1].lower()
@@ -301,7 +374,20 @@ class Body:
301
374
  '''
302
375
  return f'<div class="extra-src-img-box" title="{url}"><div class="img-outer"><div class="img-wrapper"><a href="{url}"><img src="{path}" class="image"></a></div><a href="{url}" class="title">{elide_filename(basename)}</a></div></div><br/>'
303
376
 
304
- def get_url_html(self, url: str, num: Optional[int] = None, num_all: Optional[int] = None) -> str:
377
+ def get_url_html(
378
+ self,
379
+ url: str,
380
+ num: Optional[int] = None,
381
+ num_all: Optional[int] = None
382
+ ) -> str:
383
+ """
384
+ Get HTML for a URL link with icon and optional numbering.
385
+
386
+ :param url: URL string
387
+ :param num: Optional index (1-based)
388
+ :param num_all: Optional total number of URLs
389
+ :return: HTML string
390
+ """
305
391
  app_path = self.window.core.config.get_app_path()
306
392
  icon_path = os.path.join(app_path, "data", "icons", "url.svg").replace("\\", "/")
307
393
  icon = f'<img src="file://{icon_path}" class="extra-src-icon">'
@@ -309,6 +395,12 @@ class Body:
309
395
  return f'{icon}<a href="{url}" title="{url}">{url}</a> <small>{num_str}</small>'
310
396
 
311
397
  def get_docs_html(self, docs: List[Dict]) -> str:
398
+ """
399
+ Get HTML for document references.
400
+
401
+ :param docs: List of document dicts {uuid: {meta_dict}}
402
+ :return: HTML string or empty if no docs.
403
+ """
312
404
  html_parts: List[str] = []
313
405
  src_parts: List[str] = []
314
406
  num = 1
@@ -338,7 +430,20 @@ class Body:
338
430
 
339
431
  return "".join(html_parts)
340
432
 
341
- def get_file_html(self, url: str, num: Optional[int] = None, num_all: Optional[int] = None) -> str:
433
+ def get_file_html(
434
+ self,
435
+ url: str,
436
+ num: Optional[int] = None,
437
+ num_all: Optional[int] = None
438
+ ) -> str:
439
+ """
440
+ Get HTML for a file link with icon and optional numbering.
441
+
442
+ :param url: File URL
443
+ :param num: Optional file index (1-based)
444
+ :param num_all: Optional total number of files
445
+ :return: HTML string
446
+ """
342
447
  app_path = self.window.core.config.get_app_path()
343
448
  icon_path = os.path.join(app_path, "data", "icons", "attachments.svg").replace("\\", "/")
344
449
  icon = f'<img src="file://{icon_path}" class="extra-src-icon">'
@@ -347,6 +452,12 @@ class Body:
347
452
  return f'{icon} <b>{num_str}</b> <a href="{url}">{path}</a>'
348
453
 
349
454
  def prepare_tool_extra(self, ctx: CtxItem) -> str:
455
+ """
456
+ Prepare extra HTML for tool/plugin output.
457
+
458
+ :param ctx: CtxItem
459
+ :return: HTML string or empty if no extra.
460
+ """
350
461
  extra = ctx.extra
351
462
  if not extra:
352
463
  return ""
@@ -384,6 +495,11 @@ class Body:
384
495
  return "".join(parts)
385
496
 
386
497
  def get_all_tips(self) -> str:
498
+ """
499
+ Get all tips as a JSON array string.
500
+
501
+ :return: JSON array string of tips or "[]" if disabled.
502
+ """
387
503
  if not self.window.core.config.get("layout.tooltips", False):
388
504
  return "[]"
389
505
 
@@ -396,7 +512,149 @@ class Body:
396
512
  _shuffle(tips)
397
513
  return _json_dumps(tips)
398
514
 
515
+ def _extract_local_url(self, url: str) -> Tuple[str, str]:
516
+ """
517
+ Extract local URL and path using filesystem helper.
518
+
519
+ On failure, return (url, url).
520
+
521
+ :param url: URL to extract.
522
+ :return: Tuple of (url, path).
523
+ """
524
+ try:
525
+ return self.window.core.filesystem.extract_local_url(url)
526
+ except Exception:
527
+ return url, url
528
+
529
+ def build_extras_dicts(self, ctx: CtxItem, pid: int) -> Tuple[dict, dict, dict, dict]:
530
+ """
531
+ Build images/files/urls raw dicts to be rendered by JS templates.
532
+
533
+ 1-based indexing for keys as strings: "1", "2", ...
534
+ 0-based indexing is inconvenient in JS templates.
535
+ 1-based indexing allows to show [n/m] in titles.
536
+
537
+ 1. images_dict = { "1": {url, path, basename, ext, is_video, webm_path}, ... }
538
+ 2. files_dict = { "1": {url, path}, ... }
539
+ 3. urls_dict = { "1": {url}, ... }
540
+ 4. actions_dict = { "actions": [ {href, title, icon, id}, ... ] } # message-level actions
541
+
542
+ :param ctx: CtxItem
543
+ :param pid: Process ID
544
+ :return: Tuple of (images_dict, files_dict, urls_dict, actions_dict)
545
+ """
546
+ images = {}
547
+ files = {}
548
+ urls = {}
549
+
550
+ # images
551
+ if ctx.images:
552
+ video_exts = (".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv")
553
+ n = 1
554
+ for img in ctx.images:
555
+ if img is None:
556
+ continue
557
+ try:
558
+ url, path = self._extract_local_url(img)
559
+ basename = os.path.basename(path)
560
+ ext = os.path.splitext(basename)[1].lower()
561
+ is_video = ext in video_exts
562
+ webm_path = ""
563
+ if is_video and ext != ".webm":
564
+ wp = os.path.splitext(path)[0] + ".webm"
565
+ if os.path.exists(wp):
566
+ webm_path = wp
567
+ images[str(n)] = {
568
+ "url": url,
569
+ "path": path,
570
+ "basename": basename,
571
+ "ext": ext,
572
+ "is_video": is_video,
573
+ "webm_path": webm_path,
574
+ }
575
+ n += 1
576
+ except Exception:
577
+ pass
578
+
579
+ # files
580
+ if ctx.files:
581
+ n = 1
582
+ for f in ctx.files:
583
+ try:
584
+ url, path = self._extract_local_url(f)
585
+ files[str(n)] = {
586
+ "url": url,
587
+ "path": path,
588
+ }
589
+ n += 1
590
+ except Exception:
591
+ pass
592
+
593
+ # urls
594
+ if ctx.urls:
595
+ n = 1
596
+ for u in ctx.urls:
597
+ try:
598
+ urls[str(n)] = {"url": u}
599
+ n += 1
600
+ except Exception:
601
+ pass
602
+
603
+ # actions (message-level) – raw data for icons (href/title/icon)
604
+ actions = self.get_action_icon_data(ctx)
605
+
606
+ return images, files, urls, {"actions": actions}
607
+
608
+ def normalize_docs(self, doc_ids) -> list[dict]:
609
+ """
610
+ Normalize ctx.doc_ids into a list of {"uuid": str, "meta": dict}.
611
+ Accepts original shape: List[Dict[uuid -> meta_dict]] or already normalized.
612
+
613
+ Returns empty list on failure.
614
+
615
+ Example input:
616
+ [
617
+ {"123e4567-e89b-12d3-a456-426614174000": {"title": "Document 1", "source": "file1.txt"}},
618
+ {"123e4567-e89b-12d3-a456-426614174001": {"title": "Document 2", "source": "file2.txt"}}
619
+ ]
620
+ Example output:
621
+ [
622
+ {"uuid": "123e4567-e89b-12d3-a456-426614174000", "meta": {"title": "Document 1", "source": "file1.txt"}},
623
+ {"uuid": "123e4567-e89b-12d3-a456-426614174001", "meta": {"title": "Document 2", "source": "file2.txt"}}
624
+ ]
625
+
626
+ :param doc_ids: List of document IDs in original or normalized shape.
627
+ :return: List of normalized document dicts.
628
+ """
629
+ normalized = []
630
+ try:
631
+ for item in doc_ids or []:
632
+ if isinstance(item, dict):
633
+ # Already normalized?
634
+ if 'uuid' in item and 'meta' in item and isinstance(item['meta'], dict):
635
+ normalized.append({
636
+ "uuid": str(item['uuid']),
637
+ "meta": dict(item['meta']),
638
+ })
639
+ continue
640
+ # Original shape: { uuid: { ... } }
641
+ keys = list(item.keys())
642
+ if len(keys) == 1:
643
+ uuid = str(keys[0])
644
+ meta = item[uuid]
645
+ if isinstance(meta, dict):
646
+ normalized.append({"uuid": uuid, "meta": dict(meta)})
647
+ except Exception:
648
+ pass
649
+ return normalized
650
+
399
651
  def get_html(self, pid: int) -> str:
652
+ """
653
+ Build full HTML for the web view body.
654
+
655
+ :param pid: Process ID to embed in JS.
656
+ :return: Full HTML string.
657
+ """
400
658
  cfg_get = self.window.core.config.get
401
659
  style = cfg_get("theme.style", "blocks")
402
660
  classes = ["theme-" + style]
@@ -419,6 +677,10 @@ class Body:
419
677
  run_path = os.path.join(app_path, "data", "icons", "play.svg").replace("\\", "/")
420
678
  menu_path = os.path.join(app_path, "data", "icons", "menu.svg").replace("\\", "/")
421
679
 
680
+ url_path = os.path.join(app_path, "data", "icons", "url.svg").replace("\\", "/")
681
+ attach_path = os.path.join(app_path, "data", "icons", "attachments.svg").replace("\\", "/")
682
+ db_path = os.path.join(app_path, "data", "icons", "db.svg").replace("\\", "/")
683
+
422
684
  icons_js = (
423
685
  f'window.ICON_EXPAND="file://{expand_path}";'
424
686
  f'window.ICON_COLLAPSE="file://{collapse_path}";'
@@ -426,6 +688,9 @@ class Body:
426
688
  f'window.ICON_CODE_PREVIEW="file://{preview_path}";'
427
689
  f'window.ICON_CODE_RUN="file://{run_path}";'
428
690
  f'window.ICON_CODE_MENU="file://{menu_path}";'
691
+ f'window.ICON_URL="file://{url_path}";'
692
+ f'window.ICON_ATTACHMENTS="file://{attach_path}";'
693
+ f'window.ICON_DB="file://{db_path}";'
429
694
  )
430
695
 
431
696
  t_copy = trans('ctx.extra.copy_code')
@@ -434,6 +699,7 @@ class Body:
434
699
  t_copied = trans('ctx.extra.copied')
435
700
  t_preview = trans('ctx.extra.preview')
436
701
  t_run = trans('ctx.extra.run')
702
+ t_doc_prefix = trans("chat.prefix.doc")
437
703
 
438
704
  locales_js = (
439
705
  f'window.LOCALE_COPY={_json_dumps(t_copy)};'
@@ -442,6 +708,7 @@ class Body:
442
708
  f'window.LOCALE_RUN={_json_dumps(t_run)};'
443
709
  f'window.LOCALE_COLLAPSE={_json_dumps(t_collapse)};'
444
710
  f'window.LOCALE_EXPAND={_json_dumps(t_expand)};'
711
+ f'window.LOCALE_DOC_PREFIX={_json_dumps(t_doc_prefix)};'
445
712
  )
446
713
 
447
714
  syntax_style = cfg_get("render.code_syntax") or "default"
@@ -24,6 +24,7 @@ class Helpers:
24
24
  #_RE_TOOL_TAG = re.compile(r"&lt;tool&gt;(.*?)&lt;/tool&gt;", re.DOTALL)
25
25
  _RE_TOOL_TAG = re.compile(r"<tool>(.*?)</tool>", re.DOTALL)
26
26
  _RE_THINK_TAG = re.compile(r"<think>(.*?)</think>", re.DOTALL)
27
+ _RE_EXECUTE_TAG = re.compile(r"<execute>(.*?)</execute>", re.DOTALL)
27
28
  #_RE_MATH_PARENS = re.compile(r"\\\((.*?)\\\)", re.DOTALL)
28
29
 
29
30
  def __init__(self, window=None):
@@ -80,6 +81,15 @@ class Helpers:
80
81
  g = m.group(1).replace("\n", "<br>")
81
82
  return f'[!think]{html.escape(g)}[/!think]'
82
83
 
84
+ def _repl_execute(self, m: re.Match) -> str:
85
+ """
86
+ Replace execute tags with HTML paragraph
87
+
88
+ :param m: regex match object
89
+ :return: formatted HTML string
90
+ """
91
+ return f'[!exec]{html.escape(m.group(1))}[/!exec]'
92
+
83
93
  def _repl_math_fix(self, m: re.Match) -> str:
84
94
  """
85
95
  Fix math formula by replacing &lt; and &gt; with < and > inside \\( ... \\)
@@ -127,6 +137,21 @@ class Helpers:
127
137
 
128
138
  return s
129
139
 
140
+ def replace_execute_tags(self, text: str) -> str:
141
+ """
142
+ Replace execute tags
143
+
144
+ :param text:
145
+ :return: replaced text
146
+ """
147
+ s = text
148
+
149
+ # --- execute tags ---
150
+ if "<execute>" in s and "</execute>" in s:
151
+ s = self._RE_EXECUTE_TAG.sub(self._repl_execute, s)
152
+
153
+ return s
154
+
130
155
  def pre_format_text(self, text: str) -> str:
131
156
  """
132
157
  Pre-format text
@@ -151,6 +176,7 @@ class Helpers:
151
176
  # replace tags with markdown placeholders (will be converted to HTML in JS runtime)
152
177
  s = self.replace_code_tags(text.strip())
153
178
  s = self.replace_think_tags(s)
179
+ s = self.replace_execute_tags(s)
154
180
 
155
181
  # replace workdir token
156
182
  if "%workdir%" in s: