pygpt-net 2.6.60__py3-none-any.whl → 2.6.61__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 (60) hide show
  1. pygpt_net/CHANGELOG.txt +7 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/common.py +115 -6
  4. pygpt_net/controller/chat/input.py +4 -1
  5. pygpt_net/controller/presets/presets.py +121 -6
  6. pygpt_net/controller/settings/editor.py +0 -15
  7. pygpt_net/controller/theme/markdown.py +2 -5
  8. pygpt_net/controller/ui/ui.py +4 -7
  9. pygpt_net/core/agents/custom/__init__.py +7 -1
  10. pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
  11. pygpt_net/core/agents/custom/llama_index/runner.py +35 -2
  12. pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
  13. pygpt_net/core/agents/custom/router.py +45 -6
  14. pygpt_net/core/agents/custom/runner.py +2 -1
  15. pygpt_net/core/agents/custom/schema.py +3 -1
  16. pygpt_net/core/agents/custom/utils.py +13 -1
  17. pygpt_net/core/db/viewer.py +11 -5
  18. pygpt_net/core/node_editor/graph.py +18 -9
  19. pygpt_net/core/node_editor/models.py +9 -2
  20. pygpt_net/core/node_editor/types.py +3 -1
  21. pygpt_net/core/presets/presets.py +216 -29
  22. pygpt_net/core/render/markdown/parser.py +0 -2
  23. pygpt_net/data/config/config.json +5 -6
  24. pygpt_net/data/config/models.json +3 -3
  25. pygpt_net/data/config/settings.json +2 -38
  26. pygpt_net/data/locale/locale.de.ini +64 -1
  27. pygpt_net/data/locale/locale.en.ini +62 -3
  28. pygpt_net/data/locale/locale.es.ini +64 -1
  29. pygpt_net/data/locale/locale.fr.ini +64 -1
  30. pygpt_net/data/locale/locale.it.ini +64 -1
  31. pygpt_net/data/locale/locale.pl.ini +65 -2
  32. pygpt_net/data/locale/locale.uk.ini +64 -1
  33. pygpt_net/data/locale/locale.zh.ini +64 -1
  34. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  35. pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
  36. pygpt_net/provider/core/config/patch.py +10 -1
  37. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  38. pygpt_net/tools/agent_builder/tool.py +42 -26
  39. pygpt_net/tools/agent_builder/ui/dialogs.py +60 -11
  40. pygpt_net/ui/__init__.py +2 -4
  41. pygpt_net/ui/dialog/about.py +58 -38
  42. pygpt_net/ui/dialog/db.py +142 -3
  43. pygpt_net/ui/dialog/preset.py +47 -8
  44. pygpt_net/ui/layout/toolbox/presets.py +52 -16
  45. pygpt_net/ui/widget/dialog/db.py +0 -0
  46. pygpt_net/ui/widget/lists/preset.py +644 -60
  47. pygpt_net/ui/widget/node_editor/command.py +10 -10
  48. pygpt_net/ui/widget/node_editor/config.py +157 -0
  49. pygpt_net/ui/widget/node_editor/editor.py +183 -151
  50. pygpt_net/ui/widget/node_editor/item.py +12 -11
  51. pygpt_net/ui/widget/node_editor/node.py +267 -12
  52. pygpt_net/ui/widget/node_editor/view.py +180 -63
  53. pygpt_net/ui/widget/tabs/output.py +1 -1
  54. pygpt_net/ui/widget/textarea/input.py +2 -2
  55. pygpt_net/utils.py +114 -2
  56. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +11 -94
  57. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +59 -58
  58. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
  59. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
  60. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/entry_points.txt +0 -0
pygpt_net/CHANGELOG.txt CHANGED
@@ -1,3 +1,10 @@
1
+ 2.6.61 (2025-09-26)
2
+
3
+ - Enhanced the agents node editor, custom agent flow, and instruction following.
4
+ - Added drag-and-drop and reordering functionality to the presets list.
5
+ - Added statistics for response tokens, including time elapsed and tokens per second.
6
+ - Improved UI/UX.
7
+
1
8
  2.6.60 (2025-09-25)
2
9
 
3
10
  - Added a new tool: Agents Builder - allowing visual design of agent workflows using nodes - available in Tools -> Agents Builder (beta).
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.25 00:00:00 #
9
+ # Updated Date: 2025.09.26 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.60"
17
- __build__ = "2025-09-25"
16
+ __version__ = "2.6.61"
17
+ __build__ = "2025-09-26"
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"
@@ -6,10 +6,12 @@
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.01 23:00:00 #
9
+ # Updated Date: 2025.09.25 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
+ from time import perf_counter
14
+ from typing import Callable, Optional
13
15
 
14
16
  from PySide6.QtGui import QTextCursor
15
17
  from PySide6.QtWidgets import QFileDialog, QApplication
@@ -18,7 +20,7 @@ from pygpt_net.core.events import Event, AppEvent, RenderEvent, KernelEvent
18
20
  from pygpt_net.core.types import MODE_ASSISTANT, MODE_AUDIO
19
21
  from pygpt_net.item.ctx import CtxItem
20
22
  from pygpt_net.item.model import ModelItem
21
- from pygpt_net.utils import trans
23
+ from pygpt_net.utils import trans, short_num
22
24
 
23
25
 
24
26
  class Common:
@@ -30,6 +32,12 @@ class Common:
30
32
  """
31
33
  self.window = window
32
34
  self.initialized = False
35
+ # Counter (seconds, float) – current/last measured time
36
+ self.counter: float = 0.0
37
+ # Private start timestamp
38
+ self._t0: Optional[float] = None
39
+ # Optional number-shortening function (e.g., 10000 -> "10k"); if None, a fallback is used
40
+ self._shortener = None
33
41
 
34
42
  def setup(self):
35
43
  """Set up UI"""
@@ -310,6 +318,7 @@ class Common:
310
318
  dispatch(AppEvent(AppEvent.INPUT_STOPPED)) # app event
311
319
 
312
320
  self.stop_client() # stop clients
321
+ self.reset_counter()
313
322
 
314
323
  def stop_client(self):
315
324
  """Stop all clients"""
@@ -457,15 +466,115 @@ class Common:
457
466
  f.write(str(text).strip())
458
467
  self.window.update_status(f"{trans('status.saved')}: {os.path.basename(file_name)}")
459
468
 
469
+ # --- Timer control ---
470
+
471
+ def start_counter(self) -> None:
472
+ """Start the timer – call when send the request."""
473
+ self._t0 = perf_counter()
474
+
475
+ def stop_counter(self) -> float:
476
+ """
477
+ Stop the timer. Returns elapsed seconds and stores it in self.counter.
478
+ """
479
+ if self._t0 is None:
480
+ raise RuntimeError("Timer was not started (start_counter()).")
481
+ self.counter = perf_counter() - self._t0
482
+ self._t0 = None
483
+ return self.counter
484
+
485
+ def get_counter(self) -> float:
486
+ """
487
+ Current elapsed time (if running) or last measured time (in seconds).
488
+ """
489
+ if self._t0 is not None:
490
+ return perf_counter() - self._t0
491
+ return self.counter
492
+
493
+ def reset_counter(self) -> None:
494
+ """Reset the timer."""
495
+ self._t0 = None
496
+ self.counter = 0.0
497
+
498
+ # --- Calculation and formatting ---
499
+
500
+ def tokens_per_second(self, tokens_generated: int, seconds: Optional[float] = None) -> float:
501
+ """
502
+ Average tokens/s for the given token count and time (defaults to current/last).
503
+ """
504
+ sec = self.get_counter() if seconds is None else max(float(seconds), 0.0)
505
+ if sec <= 0.0:
506
+ return 0.0
507
+ return float(tokens_generated) / sec
508
+
509
+ def format_duration(self, seconds: Optional[float] = None, max_units: int = 2) -> str:
510
+ """
511
+ Pretty duration: e.g., 800ms, 12s, 1m 12s, 3h 5m, 1d 2h (by default up to 2 units).
512
+ """
513
+ sec = self.get_counter() if seconds is None else max(float(seconds), 0.0)
514
+
515
+ if sec < 1.0:
516
+ return f"{int(round(sec * 1000)):d}ms"
517
+
518
+ total = int(sec) # floor to whole seconds
519
+ days, rem = divmod(total, 86400)
520
+ hours, rem = divmod(rem, 3600)
521
+ minutes, seconds = divmod(rem, 60)
522
+
523
+ parts = []
524
+ if days:
525
+ parts.append(f"{days}d")
526
+ if hours:
527
+ parts.append(f"{hours}h")
528
+ if minutes:
529
+ parts.append(f"{minutes}m")
530
+ if seconds or not parts:
531
+ parts.append(f"{seconds}s")
532
+
533
+ return " ".join(parts[:max_units])
534
+
535
+ def _fallback_shorten(self, value: float) -> str:
536
+ """
537
+ Simple fallback shortener (e.g., 15300 -> '15.3k').
538
+ """
539
+ n = abs(value)
540
+ sign = "-" if value < 0 else ""
541
+ if n < 1_000:
542
+ return f"{sign}{int(round(n))}"
543
+ elif n < 1_000_000:
544
+ v = n / 1_000
545
+ s = f"{v:.1f}".rstrip("0").rstrip(".")
546
+ return f"{sign}{s}k"
547
+ elif n < 1_000_000_000:
548
+ v = n / 1_000_000
549
+ s = f"{v:.1f}".rstrip("0").rstrip(".")
550
+ return f"{sign}{s}M"
551
+ else:
552
+ v = n / 1_000_000_000
553
+ s = f"{v:.1f}".rstrip("0").rstrip(".")
554
+ return f"{sign}{s}B"
555
+
556
+ def format_stats(self, tokens_generated: int) -> str:
557
+ """
558
+ Returns a string in the format: "<X> tokens/s - <duration>", e.g., "15k tokens/s - 1m 12s".
559
+ """
560
+ tps = self.tokens_per_second(tokens_generated)
561
+ if self._shortener:
562
+ tps_str = self._shortener(tps if isinstance(tps, (int, float)) else float(tps))
563
+ else:
564
+ tps_str = self._fallback_shorten(tps)
565
+ duration = self.format_duration()
566
+ return f"{tps_str} tokens/s - {duration}"
567
+
460
568
  def show_response_tokens(self, ctx: CtxItem):
461
569
  """
462
570
  Update response tokens
463
571
 
464
572
  :param ctx: CtxItem
465
573
  """
466
- extra_data = ""
467
- if ctx.is_vision:
468
- extra_data = " (VISION)"
574
+ stats = ""
575
+ if ctx.output_tokens > 0 and self.get_counter() > 0:
576
+ stats = f" | {self.format_stats(ctx.output_tokens)}"
577
+ self.reset_counter()
469
578
  self.window.update_status(
470
- f"{trans('status.tokens')}: {ctx.input_tokens} + {ctx.output_tokens} = {ctx.total_tokens}{extra_data}"
579
+ f"{trans('status.tokens')}: {short_num(ctx.input_tokens)} + {short_num(ctx.output_tokens)} = {short_num(ctx.total_tokens)}{stats}"
471
580
  )
@@ -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.08.23 15:00:00 #
9
+ # Updated Date: 2025.09.25 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from typing import Optional, Any, Dict
@@ -206,6 +206,9 @@ class Input:
206
206
  'mode': mode,
207
207
  }))
208
208
 
209
+ # start timer
210
+ self.window.controller.chat.common.start_counter()
211
+
209
212
  # send input to API
210
213
  if mode == MODE_IMAGE:
211
214
  controller.chat.image.send(
@@ -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.05 18:00:00 #
9
+ # Updated Date: 2025.09.26 03:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import re
@@ -24,12 +24,14 @@ from pygpt_net.core.types import (
24
24
  MODE_AGENT_OPENAI,
25
25
  )
26
26
  from pygpt_net.controller.presets.editor import Editor
27
+ # Editor controller
27
28
  from pygpt_net.core.events import AppEvent
28
29
  from pygpt_net.item.preset import PresetItem
29
30
  from pygpt_net.utils import trans
30
31
 
31
32
 
32
33
  _FILENAME_SANITIZE_RE = re.compile(r'[^a-zA-Z0-9_\-\.]')
34
+ # keep original validation (do not break other parts)
33
35
  _VALIDATE_FILENAME_RE = re.compile(r'[^\w\s\-\.]')
34
36
 
35
37
 
@@ -76,7 +78,7 @@ class Presets:
76
78
 
77
79
  def select(self, idx: int):
78
80
  """
79
- Select preset
81
+ Select preset by list index (legacy)
80
82
 
81
83
  :param idx: value of the list (row idx)
82
84
  """
@@ -94,6 +96,32 @@ class Presets:
94
96
  if editor_ctrl.opened and editor_ctrl.current != preset_id:
95
97
  self.editor.init(preset_id)
96
98
 
99
+ def select_by_id(self, preset_id: str):
100
+ """
101
+ Select preset by explicit ID (robust for DnD-ordered views).
102
+ """
103
+ if self.preset_change_locked():
104
+ return
105
+ if not preset_id:
106
+ return
107
+ w = self.window
108
+ mode = w.core.config.get('mode')
109
+ if not w.core.presets.has(mode, preset_id):
110
+ return
111
+ w.core.config.set("preset", preset_id)
112
+ if 'current_preset' not in w.core.config.data:
113
+ w.core.config.data['current_preset'] = {}
114
+ w.core.config.data['current_preset'][mode] = preset_id
115
+ self.select_model()
116
+ w.controller.ui.update()
117
+ w.controller.model.select_current()
118
+ w.dispatch(AppEvent(AppEvent.PRESET_SELECTED))
119
+ idx = w.core.presets.get_idx_by_id(mode, preset_id)
120
+ self.set_selected(idx)
121
+ editor_ctrl = w.controller.presets.editor
122
+ if editor_ctrl.opened and editor_ctrl.current != preset_id:
123
+ self.editor.init(preset_id)
124
+
97
125
  def get_current(self) -> Optional[PresetItem]:
98
126
  """
99
127
  Get current preset
@@ -584,6 +612,43 @@ class Presets:
584
612
  if mode == MODE_ASSISTANT:
585
613
  w.core.assistants.load()
586
614
 
615
+ def _nearest_id_after_delete(self, mode: str, idx: Optional[int], deleting_id: Optional[str]) -> Optional[str]:
616
+ """
617
+ Compute the nearest neighbor to select after deletion:
618
+ - Prefer the next item (below) if exists;
619
+ - Otherwise choose the previous one (above);
620
+ - Returns None when no neighbor can be determined.
621
+ This uses the current view order for the given mode, including pinned current.<mode> at index 0.
622
+ """
623
+ try:
624
+ w = self.window
625
+ data = w.core.presets.get_by_mode(mode) or {}
626
+ ids = list(data.keys())
627
+ if not ids:
628
+ return None
629
+
630
+ # If idx is invalid, try to resolve from deleting_id
631
+ if idx is None or idx < 0 or idx >= len(ids):
632
+ if deleting_id and deleting_id in ids:
633
+ idx = ids.index(deleting_id)
634
+ else:
635
+ return None
636
+
637
+ # Prefer below
638
+ if idx + 1 < len(ids):
639
+ cand = ids[idx + 1]
640
+ if cand and cand != deleting_id:
641
+ return cand
642
+
643
+ # Otherwise above
644
+ if idx - 1 >= 0:
645
+ cand = ids[idx - 1]
646
+ if cand and cand != deleting_id:
647
+ return cand
648
+ except Exception:
649
+ pass
650
+ return None
651
+
587
652
  def delete(
588
653
  self,
589
654
  idx: Optional[int] = None,
@@ -607,10 +672,31 @@ class Presets:
607
672
  msg=trans('confirm.preset.delete'),
608
673
  )
609
674
  return
610
- if preset_id == w.core.config.get('preset'):
611
- w.core.config.set('preset', None)
612
- w.ui.nodes['preset.prompt'].setPlainText("")
675
+
676
+ # Determine neighbor only if the deleted preset is currently active.
677
+ # This keeps API semantics untouched and prevents unexpected selection changes.
678
+ is_current = (preset_id == w.core.config.get('preset'))
679
+ target_id = None
680
+ if is_current:
681
+ target_id = self._nearest_id_after_delete(mode, idx, preset_id)
682
+
683
+ # Remove from core (also removes file when True)
613
684
  w.core.presets.remove(preset_id, True)
685
+
686
+ # When removing the active preset, jump to the nearest neighbor (below or above).
687
+ # If no neighbor can be determined, fall back to previous behavior (clear and let defaults apply).
688
+ if is_current:
689
+ if target_id and target_id in w.core.presets.items:
690
+ # Persist new selection in config (and keep current_preset mapping coherent)
691
+ w.core.config.set('preset', target_id)
692
+ if 'current_preset' not in w.core.config.data:
693
+ w.core.config.data['current_preset'] = {}
694
+ w.core.config.data['current_preset'][mode] = target_id
695
+ else:
696
+ # Fallback: clear selection to allow select_default() to pick the first available
697
+ w.core.config.set('preset', None)
698
+ w.ui.nodes['preset.prompt'].setPlainText("")
699
+
614
700
  self.refresh(no_scroll=True)
615
701
  w.update_status(trans('status.preset.deleted'))
616
702
 
@@ -715,4 +801,33 @@ class Presets:
715
801
 
716
802
  def clear_selected(self):
717
803
  """Clear selected list"""
718
- self.selected = []
804
+ self.selected = []
805
+
806
+ # ----------------------------
807
+ # Drag & drop ordering helpers
808
+ # ----------------------------
809
+
810
+ def persist_order_for_mode(self, mode: str, uuids: List[str]):
811
+ """
812
+ Persist new order (by UUIDs) for given mode.
813
+
814
+ The special '*' preset (current.<mode>) is not included here and always pinned at index 0.
815
+ """
816
+ w = self.window
817
+ cfg = w.core.config
818
+ order = cfg.get('presets_order') or {}
819
+ # Normalize to lists
820
+ if isinstance(order.get(mode), dict):
821
+ mapped = order.get(mode)
822
+ order[mode] = [mapped[k] for k in sorted(mapped.keys(), key=lambda x: int(x))]
823
+ order[mode] = [u for u in uuids if u]
824
+ cfg.set('presets_order', order)
825
+
826
+ def dnd_enabled(self) -> bool:
827
+ """
828
+ Check if drag and drop is globally enabled in config.
829
+ """
830
+ try:
831
+ return bool(self.window.core.config.get('presets.drag_and_drop.enabled'))
832
+ except Exception:
833
+ return False
@@ -51,11 +51,9 @@ class Editor:
51
51
  self.window.ui.add_hook("update.config.font_size.ctx", self.hook_update)
52
52
  self.window.ui.add_hook("update.config.font_size.toolbox", self.hook_update)
53
53
  self.window.ui.add_hook("update.config.zoom", self.hook_update)
54
- self.window.ui.add_hook("update.config.theme.markdown", self.hook_update)
55
54
  self.window.ui.add_hook("update.config.vision.capture.enabled", self.hook_update)
56
55
  self.window.ui.add_hook("update.config.vision.capture.auto", self.hook_update)
57
56
  self.window.ui.add_hook("update.config.ctx.records.limit", self.hook_update)
58
- self.window.ui.add_hook("update.config.ctx.convert_lists", self.hook_update)
59
57
  self.window.ui.add_hook("update.config.ctx.records.separators", self.hook_update)
60
58
  self.window.ui.add_hook("update.config.ctx.records.groups.separators", self.hook_update)
61
59
  self.window.ui.add_hook("update.config.ctx.records.pinned.separators", self.hook_update)
@@ -176,10 +174,6 @@ class Editor:
176
174
  value = self.window.core.config.get('theme.style')
177
175
  self.window.controller.theme.toggle_style(value)
178
176
 
179
- # convert lists
180
- if self.config_changed('ctx.convert_lists'):
181
- self.window.controller.ctx.refresh()
182
-
183
177
  # access: voice control
184
178
  if self.config_changed('access.voice_control'):
185
179
  self.window.controller.access.voice.update()
@@ -266,19 +260,10 @@ class Editor:
266
260
  self.window.core.config.set(key, value)
267
261
  self.window.controller.ui.update_font_size()
268
262
 
269
- # update markdown
270
- elif key == "theme.markdown":
271
- self.window.core.config.set(key, value)
272
- self.window.controller.theme.markdown.update(force=True)
273
-
274
263
  elif key == "render.code_syntax":
275
264
  self.window.core.config.set(key, value)
276
265
  self.window.controller.theme.toggle_syntax(value, update_menu=True)
277
266
 
278
- elif key == "ctx.convert_lists":
279
- self.window.core.config.set(key, value)
280
- self.window.controller.ctx.refresh()
281
-
282
267
  elif key == "ctx.records.separators":
283
268
  self.window.core.config.set(key, value)
284
269
  self.window.controller.ctx.update()
@@ -33,11 +33,8 @@ class Markdown:
33
33
  """
34
34
  if force:
35
35
  self.window.controller.ui.store_state() # store state before theme change
36
-
37
- if self.window.core.config.get('theme.markdown'):
38
- self.load()
39
- else:
40
- self.set_default()
36
+
37
+ self.load()
41
38
  self.apply()
42
39
 
43
40
  if force:
@@ -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.08.23 15:00:00 #
9
+ # Updated Date: 2025.09.25 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from typing import Optional
@@ -15,7 +15,7 @@ from PySide6.QtGui import QColor
15
15
 
16
16
  from pygpt_net.core.types import MODE_IMAGE
17
17
  from pygpt_net.core.events import BaseEvent, Event
18
- from pygpt_net.utils import trans
18
+ from pygpt_net.utils import trans, short_num
19
19
 
20
20
  from .mode import Mode
21
21
  from .tabs import Tabs
@@ -156,15 +156,12 @@ class UI:
156
156
  attachments_tokens = self.window.controller.chat.attachment.get_current_tokens()
157
157
  sum_tokens += attachments_tokens
158
158
 
159
- ctx_string = f"{ctx_len} / {ctx_len_all} - {ctx_tokens} {trans('ctx.tokens')}"
159
+ ctx_string = f"{short_num(ctx_len)} / {short_num(ctx_len_all)} - {short_num(ctx_tokens)} {trans('ctx.tokens')}"
160
160
  if ctx_string != self._last_ctx_string:
161
161
  ui_nodes['prompt.context'].setText(ctx_string)
162
162
  self._last_ctx_string = ctx_string
163
163
 
164
- parsed_sum = self.format_tokens(sum_tokens)
165
- parsed_max_current = self.format_tokens(max_current)
166
-
167
- input_string = f"{input_tokens} + {system_tokens} + {ctx_tokens} + {extra_tokens} + {attachments_tokens} = {parsed_sum} / {parsed_max_current}"
164
+ input_string = f"{short_num(input_tokens)} + {short_num(system_tokens)} + {short_num(ctx_tokens)} + {short_num(extra_tokens)} + {short_num(attachments_tokens)} = {short_num(sum_tokens)} / {short_num(max_current)}"
168
165
  if input_string != self._last_input_string:
169
166
  ui_nodes['input.counter'].setText(input_string)
170
167
  self._last_input_string = input_string
@@ -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.24 23:00:00 #
9
+ # Updated Date: 2025.09.25 14:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import copy
@@ -182,6 +182,12 @@ class Custom:
182
182
  slots = node["slots"]
183
183
  if "name" in slots and slots["name"]:
184
184
  tab["label"] = slots["name"]
185
+ if "role" in slots:
186
+ opts["role"] = {
187
+ "type": "str",
188
+ "label": trans("agent.option.role"),
189
+ "default": slots["role"],
190
+ }
185
191
  if "instruction" in slots:
186
192
  opts["prompt"] = {
187
193
  "type": "textarea",
@@ -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.24 23:00:00 #
9
+ # Updated Date: 2025.09.25 14:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from __future__ import annotations
@@ -32,7 +32,6 @@ class BuiltAgentLI:
32
32
  class AgentFactoryLI:
33
33
  """
34
34
  Build LlamaIndex ReActAgent/FunctionAgent from AgentNode + NodeRuntime and explicit LLM/tools.
35
- Best practice: chat_history/max_iterations przekazujemy do konstruktora agenta.
36
35
  """
37
36
  def __init__(self, window, logger) -> None:
38
37
  self.window = window
@@ -45,7 +44,7 @@ class AgentFactoryLI:
45
44
  node_runtime: NodeRuntime,
46
45
  llm: Any, # LLM instance (z appki lub resolve_llm)
47
46
  tools: List[Any], # BaseTool list
48
- friendly_map: Dict[str, str],
47
+ friendly_map: Dict[str, Any],
49
48
  force_router: bool = False,
50
49
  chat_history: List[Any] = None,
51
50
  max_iterations: int = 10,
@@ -62,23 +61,35 @@ class AgentFactoryLI:
62
61
 
63
62
  node_tools = tools if (node_runtime.allow_local_tools or node_runtime.allow_remote_tools) else []
64
63
 
64
+ # Prefer FunctionAgent if the underlying LLM supports function-calling (recommended by LI).
65
+ # This yields more direct compliance with system_prompt for simple single-output tasks.
66
+ is_fc_model = False
67
+ try:
68
+ is_fc_model = bool(getattr(getattr(llm, "metadata", None), "is_function_calling_model", False))
69
+ except Exception:
70
+ is_fc_model = False
71
+
65
72
  if multi_output:
66
- agent_cls = FunctionAgent # routers behave better with FunctionAgent (JSON compliance)
73
+ agent_cls = FunctionAgent # routers: keep JSON compliance
67
74
  else:
68
- agent_cls = FunctionAgent if node_tools else ReActAgent
75
+ agent_cls = FunctionAgent if is_fc_model else ReActAgent
76
+
69
77
  kwargs: Dict[str, Any] = {
70
78
  "name": agent_name,
71
79
  "system_prompt": instr,
72
80
  "llm": llm,
73
81
  "chat_history": chat_history or [],
74
82
  "max_iterations": int(max_iterations),
83
+ # Provide a short description to reinforce the agent's purpose (if role present).
84
+ "description": (node_runtime.role or agent_name),
75
85
  }
76
86
  if node_tools:
77
87
  kwargs["tools"] = coerce_li_tools(node_tools)
78
88
 
79
89
  instance = agent_cls(**kwargs)
80
90
  self.logger.debug(
81
- f"[li] Built agent {node.id} ({agent_name}), multi_output={multi_output}, routes={allowed_routes}"
91
+ f"[li] Built agent {node.id} ({agent_name}), multi_output={multi_output}, "
92
+ f"routes={allowed_routes}, agent_cls={agent_cls.__name__}"
82
93
  )
83
94
  return BuiltAgentLI(
84
95
  instance=instance,
@@ -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.24 23:00:00 #
9
+ # Updated Date: 2025.09.25 14:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from __future__ import annotations
@@ -210,6 +210,34 @@ class DynamicFlowWorkflowLI(Workflow):
210
210
  def _friendly_map(self) -> Dict[str, str]:
211
211
  return {aid: a.name or aid for aid, a in self.fs.agents.items()}
212
212
 
213
+ def _friendly_map_for_routes(self, route_ids: List[str]) -> Dict[str, Any]:
214
+ """
215
+ Build a friendly map for the given route ids:
216
+ - Always include a human-friendly name.
217
+ - Include role only if provided in preset options or schema and non-empty.
218
+ """
219
+ out: Dict[str, Any] = {}
220
+ for rid in route_ids or []:
221
+ a = self.fs.agents.get(rid)
222
+ name = (a.name if a and a.name else rid)
223
+ # Prefer preset option, then schema role
224
+ role_opt = None
225
+ try:
226
+ role_opt = self.option_get(rid, "role", None)
227
+ except Exception:
228
+ role_opt = None
229
+ role_schema = getattr(a, "role", None) if a is not None else None
230
+ role_val = None
231
+ if isinstance(role_opt, str) and role_opt.strip():
232
+ role_val = role_opt.strip()
233
+ elif isinstance(role_schema, str) and role_schema.strip():
234
+ role_val = role_schema.strip()
235
+ item = {"name": name}
236
+ if role_val:
237
+ item["role"] = role_val
238
+ out[rid] = item
239
+ return out
240
+
213
241
  async def _emit(self, ctx: Context, ev: Any):
214
242
  if self.dbg.event_echo:
215
243
  self.logger.debug(f"[event] emit {ev.__class__.__name__}")
@@ -412,6 +440,7 @@ class DynamicFlowWorkflowLI(Workflow):
412
440
  f"[runtime] model={getattr(node_rt.model,'name',str(node_rt.model))} "
413
441
  f"allow_local={node_rt.allow_local_tools} allow_remote={node_rt.allow_remote_tools} "
414
442
  f"instructions='{ellipsize(node_rt.instructions, self.dbg.preview_chars)}'"
443
+ f" role='{ellipsize(node_rt.role or '', self.dbg.preview_chars)}'"
415
444
  )
416
445
 
417
446
  llm_node = resolve_llm(self.window, node_rt.model, self.llm_base, self.stream)
@@ -430,13 +459,17 @@ class DynamicFlowWorkflowLI(Workflow):
430
459
  f"user='{ellipsize(user_msg_text, self.dbg.preview_chars)}'"
431
460
  )
432
461
 
462
+ # Prepare friendly map with optional roles for this node's allowed routes
463
+ allowed_routes_now = list(node.outputs or [])
464
+ friendly_map = self._friendly_map_for_routes(allowed_routes_now)
465
+
433
466
  # Build agent (chat_history/max_iterations in ctor – best practice)
434
467
  built = self.factory.build(
435
468
  node=node,
436
469
  node_runtime=node_rt,
437
470
  llm=llm_node,
438
471
  tools=tools_node,
439
- friendly_map=self._friendly_map(),
472
+ friendly_map=friendly_map,
440
473
  chat_history=chat_history_msgs,
441
474
  max_iterations=self.max_iterations,
442
475
  )