pygpt-net 2.6.59__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 (91) hide show
  1. pygpt_net/CHANGELOG.txt +11 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +9 -5
  4. pygpt_net/controller/__init__.py +1 -0
  5. pygpt_net/controller/chat/common.py +115 -6
  6. pygpt_net/controller/chat/input.py +4 -1
  7. pygpt_net/controller/presets/editor.py +442 -39
  8. pygpt_net/controller/presets/presets.py +121 -6
  9. pygpt_net/controller/settings/editor.py +0 -15
  10. pygpt_net/controller/theme/markdown.py +2 -5
  11. pygpt_net/controller/ui/ui.py +4 -7
  12. pygpt_net/core/agents/custom/__init__.py +281 -0
  13. pygpt_net/core/agents/custom/debug.py +64 -0
  14. pygpt_net/core/agents/custom/factory.py +109 -0
  15. pygpt_net/core/agents/custom/graph.py +71 -0
  16. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  17. pygpt_net/core/agents/custom/llama_index/factory.py +100 -0
  18. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  19. pygpt_net/core/agents/custom/llama_index/runner.py +562 -0
  20. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  21. pygpt_net/core/agents/custom/llama_index/utils.py +253 -0
  22. pygpt_net/core/agents/custom/logging.py +50 -0
  23. pygpt_net/core/agents/custom/memory.py +51 -0
  24. pygpt_net/core/agents/custom/router.py +155 -0
  25. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  26. pygpt_net/core/agents/custom/runner.py +455 -0
  27. pygpt_net/core/agents/custom/schema.py +127 -0
  28. pygpt_net/core/agents/custom/utils.py +193 -0
  29. pygpt_net/core/agents/provider.py +72 -7
  30. pygpt_net/core/agents/runner.py +7 -4
  31. pygpt_net/core/agents/runners/helpers.py +1 -1
  32. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  33. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  34. pygpt_net/core/db/viewer.py +11 -5
  35. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  36. pygpt_net/core/{builder → node_editor}/graph.py +28 -226
  37. pygpt_net/core/node_editor/models.py +118 -0
  38. pygpt_net/core/node_editor/types.py +78 -0
  39. pygpt_net/core/node_editor/utils.py +17 -0
  40. pygpt_net/core/presets/presets.py +216 -29
  41. pygpt_net/core/render/markdown/parser.py +0 -2
  42. pygpt_net/core/render/web/renderer.py +10 -8
  43. pygpt_net/data/config/config.json +5 -6
  44. pygpt_net/data/config/models.json +3 -3
  45. pygpt_net/data/config/settings.json +2 -38
  46. pygpt_net/data/locale/locale.de.ini +64 -1
  47. pygpt_net/data/locale/locale.en.ini +63 -4
  48. pygpt_net/data/locale/locale.es.ini +64 -1
  49. pygpt_net/data/locale/locale.fr.ini +64 -1
  50. pygpt_net/data/locale/locale.it.ini +64 -1
  51. pygpt_net/data/locale/locale.pl.ini +65 -2
  52. pygpt_net/data/locale/locale.uk.ini +64 -1
  53. pygpt_net/data/locale/locale.zh.ini +64 -1
  54. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  55. pygpt_net/item/agent.py +5 -1
  56. pygpt_net/item/preset.py +19 -1
  57. pygpt_net/provider/agents/base.py +33 -2
  58. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  59. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  60. pygpt_net/provider/core/agent/json_file.py +11 -5
  61. pygpt_net/provider/core/config/patch.py +10 -1
  62. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  63. pygpt_net/tools/agent_builder/tool.py +233 -52
  64. pygpt_net/tools/agent_builder/ui/dialogs.py +172 -28
  65. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  66. pygpt_net/ui/__init__.py +2 -4
  67. pygpt_net/ui/dialog/about.py +58 -38
  68. pygpt_net/ui/dialog/db.py +142 -3
  69. pygpt_net/ui/dialog/preset.py +62 -8
  70. pygpt_net/ui/layout/toolbox/presets.py +52 -16
  71. pygpt_net/ui/main.py +1 -1
  72. pygpt_net/ui/widget/dialog/db.py +0 -0
  73. pygpt_net/ui/widget/lists/preset.py +644 -60
  74. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  75. pygpt_net/ui/widget/node_editor/command.py +373 -0
  76. pygpt_net/ui/widget/node_editor/config.py +157 -0
  77. pygpt_net/ui/widget/node_editor/editor.py +2070 -0
  78. pygpt_net/ui/widget/node_editor/item.py +493 -0
  79. pygpt_net/ui/widget/node_editor/node.py +1460 -0
  80. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  81. pygpt_net/ui/widget/node_editor/view.py +364 -0
  82. pygpt_net/ui/widget/tabs/output.py +1 -1
  83. pygpt_net/ui/widget/textarea/input.py +2 -2
  84. pygpt_net/utils.py +114 -2
  85. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +80 -93
  86. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +88 -61
  87. pygpt_net/core/agents/custom.py +0 -150
  88. pygpt_net/ui/widget/builder/editor.py +0 -2001
  89. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
  90. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
  91. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.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.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
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.25 14:00:00 #
10
+ # ================================================== #
11
+
12
+ import copy
13
+ from uuid import uuid4
14
+
15
+ from pygpt_net.core.types import MODEL_DEFAULT
16
+ from pygpt_net.item.agent import AgentItem
17
+ from pygpt_net.item.builder_layout import BuilderLayoutItem
18
+ from pygpt_net.provider.core.agent.json_file import JsonFileProvider
19
+ from pygpt_net.utils import trans
20
+
21
+
22
+ class Custom:
23
+
24
+ CUSTOM_AGENT_SUFFIX = " *" # suffix for custom agents in lists
25
+
26
+ def __init__(self, window=None):
27
+ """
28
+ Custom agents core
29
+
30
+ :param window: Window instance
31
+ """
32
+ self.window = window
33
+ self.provider = JsonFileProvider(window) # JSON file provider
34
+ self.agents = {} # dict of AgentItem
35
+ self.layout = None # BuilderLayoutItem
36
+ self.loaded = False
37
+
38
+ def load(self):
39
+ """Load custom agents from provider"""
40
+ data = self.provider.load()
41
+ if "layout" in data:
42
+ self.layout = data["layout"]
43
+ if "agents" in data:
44
+ self.agents = data["agents"]
45
+ self.loaded = True
46
+
47
+ def reload(self):
48
+ """Reload custom agents from provider"""
49
+ self.loaded = False
50
+ self.load()
51
+
52
+ def save(self):
53
+ """Save custom agents to provider"""
54
+ self.provider.save(self.layout, self.agents)
55
+
56
+ def is_custom(self, agent_id: str) -> bool:
57
+ """
58
+ Check if agent is custom
59
+
60
+ :param agent_id: agent ID
61
+ :return: True if custom
62
+ """
63
+ if not self.loaded:
64
+ self.load()
65
+ return agent_id in self.agents
66
+
67
+ def update_layout(self, layout: dict):
68
+ """
69
+ Update current layout of custom agents editor
70
+
71
+ :param layout: layout dict
72
+ """
73
+ if self.layout is None:
74
+ self.layout = BuilderLayoutItem()
75
+ self.layout.data = layout
76
+
77
+ def reset(self):
78
+ """Reset custom agents"""
79
+ self.agents = {}
80
+ self.layout = None
81
+ self.loaded = False
82
+ self.provider.truncate()
83
+
84
+ def get_layout(self) -> dict:
85
+ """
86
+ Get layout of custom agents
87
+
88
+ :return: layout dict
89
+ """
90
+ if not self.loaded:
91
+ self.load()
92
+ return self.layout
93
+
94
+ def get_agents(self) -> dict:
95
+ """
96
+ Get custom agents
97
+
98
+ :return: dict of AgentItem
99
+ """
100
+ if not self.loaded:
101
+ self.load()
102
+ return self.agents
103
+
104
+ def get_choices(self) -> list:
105
+ """
106
+ Get custom agents choices
107
+
108
+ :return: list of dict with 'id' and 'name'
109
+ """
110
+ if not self.loaded:
111
+ self.load()
112
+ return [{agent.id: f"{agent.name}{self.CUSTOM_AGENT_SUFFIX}"} for agent in self.agents.values()]
113
+
114
+ def get_agent(self, agent_id: str):
115
+ """
116
+ Get custom agent by ID
117
+
118
+ :param agent_id: agent ID
119
+ :return: AgentItem or None
120
+ """
121
+ if not self.loaded:
122
+ self.load()
123
+ return self.agents.get(agent_id)
124
+
125
+ def get_ids(self) -> list:
126
+ """
127
+ Get list of custom agent IDs
128
+
129
+ :return: list of agent IDs
130
+ """
131
+ if not self.loaded:
132
+ self.load()
133
+ return list(self.agents.keys())
134
+
135
+ def get_schema(self, agent_id: str) -> list:
136
+ """
137
+ Get schema of a specific custom agent
138
+
139
+ :param agent_id: agent ID
140
+ :return: list with schema or empty list
141
+ """
142
+ if not self.loaded:
143
+ self.load()
144
+ agent = self.agents.get(agent_id)
145
+ if agent:
146
+ return agent.schema
147
+ return []
148
+
149
+ def build_options(self, agent_id: str) -> dict:
150
+ """
151
+ Build options for a specific custom agent
152
+
153
+ :param agent_id: agent ID
154
+ :return: dict with options or empty dict
155
+ """
156
+ if not self.loaded:
157
+ self.load()
158
+
159
+ agent = self.agents.get(agent_id)
160
+ if not agent:
161
+ return {}
162
+
163
+ schema = agent.schema if agent else []
164
+ options = {}
165
+ for node in schema:
166
+ try:
167
+ if "type" in node and node["type"] == "agent":
168
+ if "id" in node:
169
+ sub_agent_id = node["id"]
170
+ tab = {
171
+ "label": sub_agent_id
172
+ }
173
+ opts = {
174
+ "model": {
175
+ "label": trans("agent.option.model"),
176
+ "type": "combo",
177
+ "use": "models",
178
+ "default": MODEL_DEFAULT,
179
+ }
180
+ }
181
+ if "slots" in node:
182
+ slots = node["slots"]
183
+ if "name" in slots and slots["name"]:
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
+ }
191
+ if "instruction" in slots:
192
+ opts["prompt"] = {
193
+ "type": "textarea",
194
+ "label": trans("agent.option.prompt"),
195
+ "default": slots["instruction"],
196
+ }
197
+ if "remote_tools" in slots:
198
+ opts["allow_remote_tools"] = {
199
+ "type": "bool",
200
+ "label": trans("agent.option.tools.remote"),
201
+ "description": trans("agent.option.tools.remote.desc"),
202
+ "default": slots["remote_tools"],
203
+ }
204
+ if "local_tools" in slots:
205
+ opts["allow_local_tools"] = {
206
+ "type": "bool",
207
+ "label": trans("agent.option.tools.local"),
208
+ "description": trans("agent.option.tools.local.desc"),
209
+ "default": slots["local_tools"],
210
+ }
211
+ tab["options"] = opts
212
+ options[sub_agent_id] = tab
213
+ except Exception as e:
214
+ self.window.core.debug.log(f"Failed to build options for custom agent '{agent_id}': {e}")
215
+ continue
216
+ return options
217
+
218
+ def new_agent(self, name: str):
219
+ """
220
+ Create new custom agent
221
+
222
+ :param name: agent name
223
+ """
224
+ if not self.loaded:
225
+ self.load()
226
+ new_id = str(uuid4())
227
+ new_agent = AgentItem()
228
+ new_agent.id = new_id
229
+ new_agent.name = name
230
+ self.agents[new_id] = new_agent
231
+ self.save()
232
+ return new_id
233
+
234
+ def duplicate_agent(self, agent_id: str, new_name: str):
235
+ """
236
+ Duplicate custom agent
237
+
238
+ :param agent_id: agent ID
239
+ :param new_name: new agent name
240
+ """
241
+ if not self.loaded:
242
+ self.load()
243
+ agent = self.agents.get(agent_id)
244
+ if agent:
245
+ new_agent = copy.deepcopy(agent)
246
+ new_agent.id = str(uuid4())
247
+ new_agent.name = new_name
248
+ self.agents[new_agent.id] = new_agent
249
+ self.save()
250
+
251
+ def delete_agent(self, agent_id: str):
252
+ """
253
+ Delete custom agent
254
+
255
+ :param agent_id: agent ID
256
+ """
257
+ if not self.loaded:
258
+ self.load()
259
+ if agent_id in self.agents:
260
+ del self.agents[agent_id]
261
+ self.save()
262
+
263
+ def update_agent(self, agent_id: str, layout: dict, schema: list):
264
+ """
265
+ Update layout and schema of a specific custom agent
266
+
267
+ :param agent_id: agent ID
268
+ :param layout: dictionary with new layout
269
+ :param schema: list with new schema
270
+ """
271
+ if not self.loaded:
272
+ self.load()
273
+ agent = self.agents.get(agent_id)
274
+ if agent:
275
+ if layout is None:
276
+ layout = {}
277
+ if schema is None:
278
+ schema = []
279
+ agent.layout = layout
280
+ agent.schema = schema
281
+ self.save()
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.24 23:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+ from typing import Any, List, Optional
14
+
15
+ from agents import TResponseInputItem
16
+
17
+
18
+ def ellipsize(text: str, limit: int = 280) -> str:
19
+ """Shorten text for logs."""
20
+ if text is None:
21
+ return ""
22
+ s = str(text).replace("\n", " ").replace("\r", " ")
23
+ return s if len(s) <= limit else s[: max(0, limit - 3)] + "..."
24
+
25
+
26
+ def content_to_text(content: Any) -> str:
27
+ """Convert 'content' which may be str or list[dict] to plain text for logs."""
28
+ if isinstance(content, str):
29
+ return content
30
+ if isinstance(content, list):
31
+ out: List[str] = []
32
+ for part in content:
33
+ if isinstance(part, dict):
34
+ if "text" in part and isinstance(part["text"], str):
35
+ out.append(part["text"])
36
+ elif part.get("type") in ("output_text", "input_text") and "text" in part:
37
+ out.append(str(part["text"]))
38
+ else:
39
+ # fallback – best-effort stringify
40
+ t = part.get("text") or ""
41
+ out.append(str(t))
42
+ else:
43
+ out.append(str(part))
44
+ return " ".join(out)
45
+ return str(content or "")
46
+
47
+
48
+ def items_preview(items: List[TResponseInputItem], total_chars: int = 280, max_items: int = 4) -> str:
49
+ """
50
+ Produce compact preview of last N messages with roles and truncated content.
51
+ """
52
+ if not items:
53
+ return "(empty)"
54
+ pick = items[-max_items:]
55
+ per = max(32, total_chars // max(1, len(pick)))
56
+ lines: List[str] = []
57
+ for it in pick:
58
+ if not isinstance(it, dict):
59
+ lines.append(ellipsize(str(it), per))
60
+ continue
61
+ role = it.get("role", "?")
62
+ text = content_to_text(it.get("content"))
63
+ lines.append(f"- {role}: {ellipsize(text, per)}")
64
+ return " | ".join(lines)