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
@@ -0,0 +1,17 @@
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 00:00:00 #
10
+ # ================================================== #
11
+
12
+ # Safety: check C++ pointer validity to avoid calling methods on deleted Qt objects
13
+ try:
14
+ from shiboken6 import isValid as _qt_is_valid
15
+ except Exception:
16
+ def _qt_is_valid(obj) -> bool:
17
+ return obj is not None
@@ -0,0 +1,364 @@
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.26 03:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+ from typing import Optional, Tuple
14
+
15
+ from PySide6.QtCore import Qt, QPointF, QRectF, QObject, Signal, QSize, QEvent
16
+ from PySide6.QtGui import QColor, QPainter, QPen, QTransform, QIcon
17
+ from PySide6.QtWidgets import QWidget, QGraphicsView, QGraphicsScene, QPushButton, QHBoxLayout, QLabel
18
+
19
+ from .config import EditorConfig
20
+
21
+
22
+ # ------------------------ Graphics View / Scene ------------------------
23
+
24
+ class NodeGraphicsView(QGraphicsView):
25
+ """Zoomable, pannable view with a grid background.
26
+
27
+ The view renders a lightweight grid and supports:
28
+ - Ctrl + Mouse Wheel zooming
29
+ - Middle Mouse Button panning
30
+ - Rubber band selection
31
+ - Optional left-button panning only when global grab mode is enabled
32
+ """
33
+ def __init__(self, scene: QGraphicsScene, parent: Optional[QWidget] = None):
34
+ super().__init__(scene, parent)
35
+ self.setRenderHints(self.renderHints() |
36
+ QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
37
+ self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
38
+ self.setDragMode(QGraphicsView.RubberBandDrag)
39
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
40
+ a = QGraphicsView.AnchorViewCenter
41
+ self.setResizeAnchor(a)
42
+ self.setFocusPolicy(Qt.StrongFocus)
43
+
44
+ self._zoom = 1.0
45
+ self._zoom_step = 1.15
46
+ self._min_zoom = 0.2
47
+ self._max_zoom = 3.0
48
+
49
+ self._panning = False
50
+ self._last_pan_pos = None
51
+ self._global_grab_mode = False # when True, left button pans regardless of items
52
+
53
+ def drawBackground(self, painter: QPainter, rect: QRectF):
54
+ """Draw the checker grid in the background."""
55
+ parent_editor = self.parent() # NodeEditor
56
+ color_back = getattr(parent_editor, "_grid_back_color", QColor(35, 35, 38))
57
+ color_pen = getattr(parent_editor, "_grid_pen_color", QColor(55, 55, 60))
58
+ painter.fillRect(rect, color_back)
59
+ pen = QPen(color_pen)
60
+ pen.setWidthF(1.0)
61
+ painter.setPen(pen)
62
+ grid = 20
63
+ left = int(rect.left()) - (int(rect.left()) % grid)
64
+ top = int(rect.top()) - (int(rect.top()) % grid)
65
+ x = left
66
+ while x < rect.right():
67
+ painter.drawLine(x, rect.top(), x, rect.bottom())
68
+ x += grid
69
+ y = top
70
+ while y < rect.bottom():
71
+ painter.drawLine(rect.left(), y, rect.right(), y)
72
+ y += grid
73
+
74
+ def enterEvent(self, e: QEvent):
75
+ """Ensure cursor reflects grab mode when entering the view."""
76
+ if self._global_grab_mode and not self._panning:
77
+ self.viewport().setCursor(Qt.OpenHandCursor)
78
+ else:
79
+ self.viewport().setCursor(Qt.ArrowCursor)
80
+ super().enterEvent(e)
81
+
82
+ def leaveEvent(self, e: QEvent):
83
+ """Restore cursor on leave."""
84
+ self.viewport().setCursor(Qt.ArrowCursor)
85
+ super().leaveEvent(e)
86
+
87
+ def keyPressEvent(self, e):
88
+ super().keyPressEvent(e)
89
+
90
+ def keyReleaseEvent(self, e):
91
+ super().keyReleaseEvent(e)
92
+
93
+ def wheelEvent(self, e):
94
+ """Handle Ctrl + Wheel zoom. Otherwise, default wheel behavior (scroll)."""
95
+ if e.modifiers() & Qt.ControlModifier:
96
+ self._apply_zoom(self._zoom_step if e.angleDelta().y() > 0 else 1.0 / self._zoom_step)
97
+ e.accept()
98
+ return
99
+ super().wheelEvent(e)
100
+
101
+ def _begin_pan(self, e):
102
+ """Start panning from current mouse event position."""
103
+ self._panning = True
104
+ self._last_pan_pos = e.position()
105
+ # Use 'grab' during drag
106
+ self.viewport().setCursor(Qt.ClosedHandCursor)
107
+ e.accept()
108
+
109
+ def _end_pan(self, e):
110
+ """Stop panning and restore appropriate cursor."""
111
+ self._panning = False
112
+ if self._global_grab_mode:
113
+ self.viewport().setCursor(Qt.OpenHandCursor)
114
+ else:
115
+ self.viewport().setCursor(Qt.ArrowCursor)
116
+ e.accept()
117
+
118
+ def _clicked_on_empty(self, e) -> bool:
119
+ """Return True if the click is on empty scene space (no items)."""
120
+ try:
121
+ item = self.itemAt(int(e.position().x()), int(e.position().y()))
122
+ return item is None
123
+ except Exception:
124
+ return False
125
+
126
+ def mousePressEvent(self, e):
127
+ """Panning: MMB always; LMB only in global grab mode. Also clear selection on empty click."""
128
+ if e.button() == Qt.MiddleButton:
129
+ self._begin_pan(e)
130
+ return
131
+
132
+ if e.button() == Qt.LeftButton:
133
+ if self._global_grab_mode:
134
+ # Global grab enabled -> pan on LMB anywhere
135
+ self._begin_pan(e)
136
+ return
137
+ else:
138
+ # No global grab: clicking empty clears selection
139
+ if self._clicked_on_empty(e) and self.scene():
140
+ self.scene().clearSelection()
141
+
142
+ super().mousePressEvent(e)
143
+
144
+ def mouseMoveEvent(self, e):
145
+ """While panning, translate scrollbars. Otherwise defer."""
146
+ if self._panning and self._last_pan_pos is not None:
147
+ delta = e.position() - self._last_pan_pos
148
+ self._last_pan_pos = e.position()
149
+ self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - int(delta.x()))
150
+ self.verticalScrollBar().setValue(self.verticalScrollBar().value() - int(delta.y()))
151
+ e.accept()
152
+ return
153
+ super().mouseMoveEvent(e)
154
+
155
+ def mouseReleaseEvent(self, e):
156
+ """Stop panning on Middle or Left Mouse Button release; otherwise defer."""
157
+ if self._panning and e.button() in (Qt.MiddleButton, Qt.LeftButton):
158
+ self._end_pan(e)
159
+ return
160
+ super().mouseReleaseEvent(e)
161
+
162
+ def zoom_in(self):
163
+ """Programmatically zoom in by a predefined step."""
164
+ self._apply_zoom(self._zoom_step)
165
+
166
+ def zoom_out(self):
167
+ """Programmatically zoom out by a predefined step."""
168
+ self._apply_zoom(1.0 / self._zoom_step)
169
+
170
+ def _apply_zoom(self, factor: float):
171
+ """Apply zoom scaling factor within configured bounds."""
172
+ new_zoom = self._zoom * factor
173
+ if not (self._min_zoom <= new_zoom <= self._max_zoom):
174
+ return
175
+ self._zoom = new_zoom
176
+ self.scale(factor, factor)
177
+
178
+ def zoom_value(self) -> float:
179
+ """Return the current zoom factor."""
180
+ return float(self._zoom)
181
+
182
+ def set_zoom_value(self, zoom: float, keep_center: bool = False):
183
+ """Set an absolute zoom factor and optionally keep the current viewport center."""
184
+ if zoom is None:
185
+ return
186
+ z = max(self._min_zoom, min(self._max_zoom, float(zoom)))
187
+ center_scene = None
188
+ if keep_center and self.viewport() is not None and self.viewport().rect().isValid():
189
+ center_scene = self.mapToScene(self.viewport().rect().center())
190
+
191
+ self.resetTransform()
192
+ self._zoom = 1.0
193
+ if abs(z - 1.0) > 1e-9:
194
+ self.scale(z, z)
195
+ self._zoom = z
196
+
197
+ if keep_center and center_scene is not None:
198
+ self.centerOn(center_scene)
199
+
200
+ def get_scroll_values(self) -> Tuple[int, int]:
201
+ h = self.horizontalScrollBar().value() if self.horizontalScrollBar() else 0
202
+ v = self.verticalScrollBar().value() if self.verticalScrollBar() else 0
203
+ return int(h), int(v)
204
+
205
+ def set_scroll_values(self, h: int, v: int):
206
+ if self.horizontalScrollBar():
207
+ self.horizontalScrollBar().setValue(int(h))
208
+ if self.verticalScrollBar():
209
+ self.verticalScrollBar().setValue(int(v))
210
+
211
+ def view_state(self) -> dict:
212
+ h, v = self.get_scroll_values()
213
+ return {"zoom": float(self._zoom), "h": h, "v": v}
214
+
215
+ def set_view_state(self, state: dict):
216
+ if not isinstance(state, dict):
217
+ return
218
+ z = state.get("zoom") or state.get("scale")
219
+ if z is not None:
220
+ try:
221
+ self.set_zoom_value(float(z), keep_center=False)
222
+ except Exception:
223
+ pass
224
+ h = state.get("h") or state.get("hScroll") or state.get("x")
225
+ v = state.get("v") or state.get("vScroll") or state.get("y")
226
+ try:
227
+ if h is not None:
228
+ self.set_scroll_values(int(h), int(v if v is not None else 0))
229
+ elif v is not None:
230
+ self.set_scroll_values(self.get_scroll_values()[0], int(v))
231
+ except Exception:
232
+ pass
233
+
234
+ def set_global_grab_mode(self, enabled: bool):
235
+ """Enable/disable global grab mode (left click pans anywhere)."""
236
+ self._global_grab_mode = bool(enabled)
237
+ if self._global_grab_mode:
238
+ self.viewport().setCursor(Qt.OpenHandCursor)
239
+ else:
240
+ if not self._panning:
241
+ self.viewport().setCursor(Qt.ArrowCursor)
242
+
243
+
244
+ class NodeViewOverlayControls(QWidget):
245
+ """Small overlay with three buttons (Grab toggle, Zoom Out, Zoom In) anchored top-right."""
246
+
247
+ grabToggled = Signal(bool)
248
+ zoomInClicked = Signal()
249
+ zoomOutClicked = Signal()
250
+
251
+ def __init__(self, parent: Optional[QWidget] = None):
252
+ super().__init__(parent)
253
+ self.setObjectName("NodeViewOverlayControls")
254
+ self.setAttribute(Qt.WA_StyledBackground, True)
255
+
256
+ layout = QHBoxLayout(self)
257
+ # Bigger spacing to visually add padding around buttons
258
+ layout.setContentsMargins(0, 0, 0, 0)
259
+ layout.setSpacing(8)
260
+
261
+ cfg = self._cfg()
262
+
263
+ # Grab (toggle)
264
+ self.btnGrab = QPushButton(self)
265
+ self.btnGrab.setCheckable(True)
266
+ self.btnGrab.setToolTip(cfg.overlay_grab_tooltip())
267
+ self.btnGrab.setIcon(QIcon(":/icons/drag.svg"))
268
+ self.btnGrab.setIconSize(QSize(20, 20))
269
+ self.btnGrab.setMinimumSize(32, 32)
270
+
271
+ # Zoom Out (placed before Zoom In)
272
+ self.btnZoomOut = QPushButton(self)
273
+ self.btnZoomOut.setToolTip(cfg.overlay_zoom_out_tooltip())
274
+ self.btnZoomOut.setIcon(QIcon(":/icons/zoom_out.svg"))
275
+ self.btnZoomOut.setIconSize(QSize(20, 20))
276
+ self.btnZoomOut.setMinimumSize(32, 32)
277
+
278
+ # Zoom In
279
+ self.btnZoomIn = QPushButton(self)
280
+ self.btnZoomIn.setToolTip(cfg.overlay_zoom_in_tooltip())
281
+ self.btnZoomIn.setIcon(QIcon(":/icons/zoom_in.svg"))
282
+ self.btnZoomIn.setIconSize(QSize(20, 20))
283
+ self.btnZoomIn.setMinimumSize(32, 32)
284
+
285
+ layout.addWidget(self.btnGrab)
286
+ layout.addWidget(self.btnZoomIn)
287
+ layout.addWidget(self.btnZoomOut)
288
+
289
+ self.btnGrab.toggled.connect(self.grabToggled.emit)
290
+ self.btnZoomIn.clicked.connect(self.zoomInClicked.emit)
291
+ self.btnZoomOut.clicked.connect(self.zoomOutClicked.emit)
292
+
293
+ self.show()
294
+
295
+ def _cfg(self) -> EditorConfig:
296
+ p = self.parent()
297
+ c = getattr(p, "config", None) if p is not None else None
298
+ return c if isinstance(c, EditorConfig) else EditorConfig()
299
+
300
+
301
+ class NodeViewStatusLabel(QWidget):
302
+ """Fixed status overlay pinned to bottom-left that shows node type counts."""
303
+
304
+ def __init__(self, parent: Optional[QWidget] = None):
305
+ super().__init__(parent)
306
+ self.setObjectName("NodeViewStatusLabel")
307
+ self.setAttribute(Qt.WA_StyledBackground, True)
308
+
309
+ self._lbl = QLabel(self)
310
+ cfg = self._cfg()
311
+ self._lbl.setText(cfg.status_no_nodes())
312
+
313
+ layout = QHBoxLayout(self)
314
+ layout.setContentsMargins(8, 4, 8, 4) # some padding around the text
315
+ layout.setSpacing(0)
316
+ layout.addWidget(self._lbl)
317
+
318
+ self.adjustSize()
319
+ self.show()
320
+
321
+ def _cfg(self) -> EditorConfig:
322
+ p = self.parent()
323
+ c = getattr(p, "config", None) if p is not None else None
324
+ return c if isinstance(c, EditorConfig) else EditorConfig()
325
+
326
+ def set_text(self, text: str):
327
+ self._lbl.setText(text)
328
+ # Safe: adjustSize() uses sizeHint(), which no longer calls adjustSize()
329
+ self.adjustSize()
330
+
331
+ def sizeHint(self):
332
+ """Return hint based on layout/label without calling adjustSize()."""
333
+ if self.layout() is not None:
334
+ return self.layout().sizeHint()
335
+ return self._lbl.sizeHint()
336
+
337
+
338
+ class NodeGraphicsScene(QGraphicsScene):
339
+ """Graphics scene extended with custom context menu emission."""
340
+ sceneContextRequested = Signal(QPointF)
341
+
342
+ def __init__(self, parent: Optional[QObject] = None):
343
+ """Initialize the scene and set a very large scene rect."""
344
+ super().__init__(parent)
345
+ self.setSceneRect(-5000, -5000, 10000, 10000)
346
+
347
+ def contextMenuEvent(self, event):
348
+ """Emit a scene-level context menu request when clicking empty space."""
349
+ transform = self.views()[0].transform() if self.views() else QTransform()
350
+ item = self.itemAt(event.scenePos(), transform)
351
+ if item is None:
352
+ # Respect external edit permission if available on parent editor
353
+ editor = self.parent()
354
+ allowed = True
355
+ try:
356
+ if hasattr(editor, "editing_allowed") and callable(editor.editing_allowed):
357
+ allowed = bool(editor.editing_allowed())
358
+ except Exception:
359
+ allowed = False
360
+ if allowed:
361
+ self.sceneContextRequested.emit(event.scenePos())
362
+ event.accept()
363
+ return
364
+ super().contextMenuEvent(event)
@@ -311,7 +311,7 @@ class AddButton(QPushButton):
311
311
  )
312
312
  self.setObjectName('tab-add')
313
313
  self.setProperty('tabAdd', True)
314
- self.setToolTip(trans('action.tab.add.chat'))
314
+ self.setToolTip(trans('action.tab.add.chat.tooltip'))
315
315
 
316
316
  def mousePressEvent(self, event):
317
317
  """
@@ -94,8 +94,8 @@ class ChatInput(QTextEdit):
94
94
  key="web",
95
95
  icon=self.ICON_WEB_OFF,
96
96
  alt_icon=self.ICON_WEB_ON,
97
- tooltip=trans('icon.remote_tool.web'),
98
- alt_tooltip=trans('icon.remote_tool.web'),
97
+ tooltip=trans('icon.remote_tool.web.disabled'),
98
+ alt_tooltip=trans('icon.remote_tool.web.enabled'),
99
99
  callback=self.action_toggle_web,
100
100
  visible=True,
101
101
  )
pygpt_net/utils.py CHANGED
@@ -6,15 +6,19 @@
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.19 07:00:00 #
9
+ # Updated Date: 2025.09.25 12:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
13
13
  import os
14
14
  import re
15
+ import math
16
+
15
17
  from datetime import datetime
16
18
  from contextlib import contextmanager
17
19
  from typing import Any
20
+ from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
21
+ from typing import Sequence
18
22
 
19
23
  from PySide6 import QtCore, QtGui
20
24
  from PySide6.QtWidgets import QApplication
@@ -381,4 +385,112 @@ def mem_clean(force: bool = False) -> bool:
381
385
  '''
382
386
  except Exception as e:
383
387
  print(e)
384
- return ok
388
+ return ok
389
+
390
+ def short_num(value,
391
+ *,
392
+ base: int = 1000,
393
+ suffixes: Sequence[str] = ("", "k", "M", "B", "T", "P", "E"),
394
+ max_decimals: int = 1,
395
+ decimal_sep: str = ",") -> str:
396
+ """
397
+ Compact human-readable formatter for numbers with suffixes (k, M, B, ...).
398
+
399
+ Rules:
400
+ - abs(value) < base -> return the value without a suffix
401
+ - otherwise -> divide by base^n and append suffix
402
+ - decimals:
403
+ < 10 -> up to 2 (bounded by max_decimals)
404
+ < 100 -> up to 1 (bounded by max_decimals)
405
+ >= 100 -> 0
406
+ - rounding: ROUND_HALF_UP
407
+ - auto "carry" to the next suffix after rounding (e.g., 999.95k -> 1M)
408
+
409
+ Params:
410
+ - base: 1000 for general numbers, 1024 for bytes, etc.
411
+ - suffixes: first item must be "" (for < base)
412
+ - max_decimals: upper bound for fractional digits
413
+ - decimal_sep: decimal separator in the output
414
+ """
415
+
416
+ # --- helpers hidden inside (closure) ---
417
+
418
+ def _to_decimal(v) -> Decimal:
419
+ """Convert supported inputs to Decimal; keep NaN/Inf as-is."""
420
+ if isinstance(v, Decimal):
421
+ return v
422
+ if isinstance(v, int):
423
+ return Decimal(v)
424
+ if isinstance(v, float):
425
+ if math.isnan(v):
426
+ return Decimal("NaN")
427
+ if math.isinf(v):
428
+ return Decimal("Infinity") if v > 0 else Decimal("-Infinity")
429
+ # str() to avoid binary float artefacts
430
+ return Decimal(str(v))
431
+ try:
432
+ return Decimal(str(v))
433
+ except (InvalidOperation, ValueError, TypeError):
434
+ raise TypeError("short_num(value): value must be numeric (int/float/Decimal) "
435
+ "or a string parsable to a number.")
436
+
437
+ def _decimals_for(scaled: Decimal) -> int:
438
+ """Pick number of decimals based on magnitude."""
439
+ if max_decimals <= 0:
440
+ return 0
441
+ if scaled < 10:
442
+ return min(2, max_decimals)
443
+ if scaled < 100:
444
+ return min(1, max_decimals)
445
+ return 0
446
+
447
+ def _round_dec(d: Decimal, decimals: int) -> Decimal:
448
+ """ROUND_HALF_UP to the requested decimals."""
449
+ if decimals <= 0:
450
+ return d.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
451
+ q = Decimal(1).scaleb(-decimals) # 10**(-decimals)
452
+ return d.quantize(q, rounding=ROUND_HALF_UP)
453
+
454
+ def _strip_trailing_zeros(s: str) -> str:
455
+ """Remove trailing zeros and trailing decimal point if needed."""
456
+ if "." in s:
457
+ s = s.rstrip("0").rstrip(".")
458
+ return s
459
+
460
+ # --- main logic ---
461
+
462
+ d = _to_decimal(value)
463
+ if not d.is_finite():
464
+ # For NaN/Inf just echo back Python-ish text
465
+ return str(value)
466
+
467
+ sign = "-" if d < 0 else ""
468
+ d = abs(d)
469
+
470
+ # For values below base, return "as is" (normalized, no suffix)
471
+ if d < base:
472
+ s = _strip_trailing_zeros(f"{d.normalize():f}")
473
+ return (sign + s).replace(".", decimal_sep)
474
+
475
+ # Find initial suffix tier
476
+ idx = 0
477
+ last_idx = len(suffixes) - 1
478
+ while d >= base and idx < last_idx:
479
+ d = d / base
480
+ idx += 1
481
+
482
+ # Choose decimals, round, then handle possible carry to next suffix
483
+ decimals = _decimals_for(d)
484
+ d = _round_dec(d, decimals)
485
+
486
+ while d >= base and idx < last_idx:
487
+ # Carry over (e.g., 999.95k -> 1000.00k -> 1.00M)
488
+ d = d / base
489
+ idx += 1
490
+ decimals = _decimals_for(d)
491
+ d = _round_dec(d, decimals)
492
+
493
+ # Format, trim zeros, apply custom decimal separator, attach suffix
494
+ out = f"{d:.{decimals}f}"
495
+ out = _strip_trailing_zeros(out).replace(".", decimal_sep)
496
+ return f"{sign}{out}{suffixes[idx]}"