pygpt-net 2.6.67__py3-none-any.whl → 2.7.0__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 (69) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/assistant.py +13 -8
  4. pygpt_net/controller/assistant/batch.py +29 -15
  5. pygpt_net/controller/assistant/files.py +19 -14
  6. pygpt_net/controller/assistant/store.py +63 -41
  7. pygpt_net/controller/attachment/attachment.py +45 -35
  8. pygpt_net/controller/chat/attachment.py +50 -39
  9. pygpt_net/controller/config/field/dictionary.py +26 -14
  10. pygpt_net/controller/ctx/common.py +27 -17
  11. pygpt_net/controller/ctx/ctx.py +182 -101
  12. pygpt_net/controller/files/files.py +101 -41
  13. pygpt_net/controller/idx/indexer.py +87 -31
  14. pygpt_net/controller/kernel/kernel.py +13 -2
  15. pygpt_net/controller/mode/mode.py +3 -3
  16. pygpt_net/controller/model/editor.py +70 -15
  17. pygpt_net/controller/model/importer.py +153 -54
  18. pygpt_net/controller/painter/painter.py +2 -2
  19. pygpt_net/controller/presets/experts.py +68 -15
  20. pygpt_net/controller/presets/presets.py +72 -36
  21. pygpt_net/controller/settings/profile.py +76 -35
  22. pygpt_net/controller/settings/workdir.py +70 -39
  23. pygpt_net/core/assistants/files.py +20 -18
  24. pygpt_net/core/filesystem/actions.py +111 -10
  25. pygpt_net/core/filesystem/filesystem.py +2 -1
  26. pygpt_net/core/idx/idx.py +12 -11
  27. pygpt_net/core/idx/worker.py +13 -1
  28. pygpt_net/core/models/models.py +4 -4
  29. pygpt_net/core/profile/profile.py +13 -3
  30. pygpt_net/data/config/config.json +3 -3
  31. pygpt_net/data/config/models.json +3 -3
  32. pygpt_net/data/css/style.dark.css +39 -1
  33. pygpt_net/data/css/style.light.css +39 -1
  34. pygpt_net/data/locale/locale.de.ini +3 -1
  35. pygpt_net/data/locale/locale.en.ini +3 -1
  36. pygpt_net/data/locale/locale.es.ini +3 -1
  37. pygpt_net/data/locale/locale.fr.ini +3 -1
  38. pygpt_net/data/locale/locale.it.ini +3 -1
  39. pygpt_net/data/locale/locale.pl.ini +4 -2
  40. pygpt_net/data/locale/locale.uk.ini +3 -1
  41. pygpt_net/data/locale/locale.zh.ini +3 -1
  42. pygpt_net/provider/api/openai/__init__.py +4 -2
  43. pygpt_net/provider/core/config/patch.py +9 -1
  44. pygpt_net/tools/image_viewer/tool.py +17 -0
  45. pygpt_net/tools/text_editor/tool.py +9 -0
  46. pygpt_net/ui/__init__.py +2 -2
  47. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  48. pygpt_net/ui/main.py +3 -1
  49. pygpt_net/ui/widget/calendar/select.py +3 -3
  50. pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
  51. pygpt_net/ui/widget/lists/assistant.py +185 -24
  52. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  53. pygpt_net/ui/widget/lists/attachment.py +230 -47
  54. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  55. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  56. pygpt_net/ui/widget/lists/context.py +1253 -70
  57. pygpt_net/ui/widget/lists/experts.py +110 -8
  58. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  59. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  60. pygpt_net/ui/widget/lists/preset.py +460 -71
  61. pygpt_net/ui/widget/lists/profile.py +149 -27
  62. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  63. pygpt_net/ui/widget/option/combo.py +1046 -32
  64. pygpt_net/ui/widget/option/dictionary.py +35 -7
  65. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
  66. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
  67. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
  68. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
  69. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -6,13 +6,13 @@
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.28 08:00:00 #
9
+ # Updated Date: 2025.12.27 17:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
13
13
  import os
14
14
  import shutil
15
- from typing import Optional
15
+ from typing import Optional, Union
16
16
  from shutil import copy2
17
17
 
18
18
  from PySide6.QtWidgets import QFileDialog, QApplication
@@ -99,7 +99,7 @@ class Files:
99
99
 
100
100
  def delete(
101
101
  self,
102
- path: str,
102
+ path: Union[str, list],
103
103
  force: bool = False
104
104
  ):
105
105
  """
@@ -116,6 +116,11 @@ class Files:
116
116
  )
117
117
  return
118
118
 
119
+ if isinstance(path, list):
120
+ for p in path:
121
+ self.delete(p, True)
122
+ return
123
+
119
124
  if os.path.isdir(path):
120
125
  # check if directory is not empty
121
126
  if os.listdir(path):
@@ -136,17 +141,27 @@ class Files:
136
141
 
137
142
  def duplicate_local(
138
143
  self,
139
- path: str,
144
+ path: Union[str, list],
140
145
  name: str,
141
146
  force: bool = False
142
147
  ):
143
148
  """
144
149
  Duplicate file or directory
145
150
 
146
- :param path: path to file
151
+ :param path: path to file or list of files
147
152
  :param name: new file name
148
153
  :param force: force duplicate
149
154
  """
155
+ if isinstance(path, list):
156
+ for p in path:
157
+ if os.path.isdir(p):
158
+ new_name = os.path.basename(p) + "_copy"
159
+ else:
160
+ new_name = os.path.splitext(os.path.basename(p))[0] \
161
+ + "_copy" + os.path.splitext(p)[1]
162
+ self.duplicate_local(p, new_name, True)
163
+ return
164
+
150
165
  if not force:
151
166
  if os.path.isdir(path):
152
167
  new_name = os.path.basename(path) + "_copy"
@@ -168,10 +183,10 @@ class Files:
168
183
  new_path = os.path.join(parent_dir, name)
169
184
 
170
185
  if os.path.exists(new_path):
171
- self.window.update_status(
172
- f"[ERROR] File already exists: {os.path.basename(new_path)}"
173
- )
174
- return
186
+ # prefix with timestamp if file exists
187
+ ts_prefix = self.make_ts_prefix()
188
+ name = f"{ts_prefix}_{name}"
189
+ new_path = os.path.join(parent_dir, name)
175
190
 
176
191
  if os.path.isdir(path):
177
192
  shutil.copytree(path, new_path)
@@ -185,12 +200,16 @@ class Files:
185
200
  self.window.core.debug.log(e)
186
201
  print(f"Error duplicating file: {path} - {e}")
187
202
 
188
- def download_local(self, path: str):
203
+ def download_local(self, path: Union[str, list]):
189
204
  """
190
205
  Download (copy) file or directory to local filesystem
191
206
 
192
- :param path: path to source file
207
+ :param path: path to source file or list of files
193
208
  """
209
+ if isinstance(path, list):
210
+ for p in path:
211
+ self.download_local(p)
212
+ return
194
213
  last_dir = self.window.core.config.get_last_used_dir()
195
214
  dialog = QFileDialog(self.window)
196
215
  dialog.setDirectory(last_dir)
@@ -339,12 +358,16 @@ class Files:
339
358
  self.window.update_status(f"[OK] Uploaded: {copied} files.")
340
359
  self.update_explorer()
341
360
 
342
- def rename(self, path: str):
361
+ def rename(self, path: Union[str, list]):
343
362
  """
344
363
  Rename file or directory
345
364
 
346
- :param path: path to file
365
+ :param path: path to file or list of files
347
366
  """
367
+ if isinstance(path, list):
368
+ for p in path:
369
+ self.rename(p)
370
+ return
348
371
  self.window.ui.dialog['rename'].id = 'output_file'
349
372
  self.window.ui.dialog['rename'].input.setText(os.path.basename(path))
350
373
  self.window.ui.dialog['rename'].current = path
@@ -377,23 +400,36 @@ class Files:
377
400
 
378
401
  def open_dir(
379
402
  self,
380
- path: str,
403
+ path: Union[str, list],
381
404
  select: bool = False
382
405
  ):
383
406
  """
384
407
  Open file or directory in file manager
385
408
 
386
- :param path: path to file or directory
409
+ :param path: path to file or directory or list of paths
387
410
  :param select: select file in file manager
388
411
  """
412
+ if isinstance(path, list):
413
+ parents = []
414
+ for p in path:
415
+ parent = os.path.dirname(p)
416
+ if parent not in parents:
417
+ self.open_in_file_manager(p, select)
418
+ if parent not in parents:
419
+ parents.append(parent)
420
+ return
389
421
  self.open_in_file_manager(path, select)
390
422
 
391
- def open(self, path: str):
423
+ def open(self, path: Union[str, list]):
392
424
  """
393
425
  Open path in file manager or with default application
394
426
 
395
- :param path: path to file or directory
427
+ :param path: path to file or directory or list of paths
396
428
  """
429
+ if isinstance(path, list):
430
+ for p in path:
431
+ self.open(p)
432
+ return
397
433
  path = self.window.core.filesystem.get_path(path)
398
434
  Opener.open_path(path, reveal=False)
399
435
 
@@ -468,52 +504,76 @@ class Files:
468
504
  self.window.ui.nodes['output_files'].path_label.setText(root)
469
505
  self.window.ui.nodes['output_files'].model.update_idx_status(data)
470
506
 
471
- def use_attachment(self, path: str):
507
+ def use_attachment(self, path: Union[str, list]):
472
508
  """
473
509
  Use file as attachment
474
510
 
475
- :param path: path to file
476
- """
477
- title = os.path.basename(path)
478
- mode = self.window.core.config.get("mode")
479
- self.window.core.attachments.new(
480
- mode=mode,
481
- name=title,
482
- path=path,
483
- auto_save=False,
484
- )
511
+ :param path: path to file or list of files
512
+ """
513
+ paths = path if isinstance(path, list) else [path]
514
+ for p in paths:
515
+ title = os.path.basename(p)
516
+ mode = self.window.core.config.get("mode")
517
+ self.window.core.attachments.new(
518
+ mode=mode,
519
+ name=title,
520
+ path=p,
521
+ auto_save=False,
522
+ )
485
523
  self.window.core.attachments.save()
486
524
  self.window.controller.attachment.update()
487
525
 
488
- def copy_sys_path(self, path: str):
526
+ def copy_sys_path(self, path: Union[str, list]):
489
527
  """
490
528
  Copy system path to clipboard
491
529
 
492
- :param path: path to file
530
+ :param path: path to file or list of files
493
531
  """
494
- QApplication.clipboard().setText(path)
495
- self.window.controller.chat.common.append_to_input(path)
532
+ if isinstance(path, list):
533
+ paths = [self.window.core.filesystem.get_path(p) for p in path]
534
+ path_str = "\n".join(paths)
535
+ else:
536
+ path_str = self.window.core.filesystem.get_path(path)
537
+ QApplication.clipboard().setText(path_str)
538
+ self.window.controller.chat.common.append_to_input(path_str)
496
539
 
497
- def copy_work_path(self, path: str):
540
+ def copy_work_path(self, path: Union[str, list]):
498
541
  """
499
542
  Copy work path to clipboard
500
543
 
501
- :param path: path to file
544
+ :param path: path to file or list of files
502
545
  """
503
- path = self.strip_work_path(path)
546
+ if isinstance(path, list):
547
+ paths = [self.strip_work_path(p) for p in path]
548
+ path = "\n".join(paths)
549
+ else:
550
+ path = self.strip_work_path(path)
504
551
  QApplication.clipboard().setText(path)
505
552
  self.window.controller.chat.common.append_to_input(path)
506
553
 
507
- def make_read_cmd(self, path: str):
554
+ def make_read_cmd(self, path: Union[str, list]):
508
555
  """
509
556
  Make read command for file or directory and append to input
510
557
 
511
- :param path: path to file
558
+ :param path: path to file or list of files
512
559
  """
513
- if os.path.isdir(path):
514
- cmd = f"Please list files from directory: {self.strip_work_path(path)}"
515
- else:
516
- cmd = f"Please read this file from current directory: {self.strip_work_path(path)}"
560
+ files_list = path if isinstance(path, list) else [path]
561
+ cmd = ""
562
+ cmd_dir = []
563
+ cmd_current = []
564
+ for path in files_list:
565
+ if os.path.isdir(path):
566
+ cmd_dir.append(self.strip_work_path(path))
567
+ else:
568
+ cmd_current.append(self.strip_work_path(path))
569
+ if len(cmd_dir) > 1:
570
+ cmd = "Please list files from directories: " + ", ".join(cmd_dir)
571
+ elif len(cmd_dir) == 1:
572
+ cmd = f"Please list files from directory: {cmd_dir[0]}"
573
+ if len(cmd_current) > 1:
574
+ cmd = "Please read these files from current directory: " + ", ".join(cmd_current)
575
+ elif len(cmd_current) == 1:
576
+ cmd = f"Please read this file from current directory: {cmd_current[0]}"
517
577
  self.window.controller.chat.common.append_to_input(cmd)
518
578
 
519
579
  def make_ts_prefix(self) -> str:
@@ -6,12 +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.08.24 23:00:00 #
9
+ # Updated Date: 2025.12.27 19:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
13
13
  import os
14
- from typing import Any, List, Dict
14
+ from typing import Any, List, Dict, Union
15
15
 
16
16
  from PySide6.QtCore import Slot, QObject
17
17
  from PySide6.QtWidgets import QApplication
@@ -70,14 +70,14 @@ class Indexer(QObject):
70
70
 
71
71
  def index_ctx_meta(
72
72
  self,
73
- meta_id: int,
73
+ meta_id: Union[int, list],
74
74
  idx: str,
75
75
  force: bool = False
76
76
  ):
77
77
  """
78
78
  Index context meta (threaded)
79
79
 
80
- :param meta_id: context meta id
80
+ :param meta_id: context meta id or list of ids
81
81
  :param idx: index name
82
82
  :param force: force index
83
83
  """
@@ -91,20 +91,36 @@ class Indexer(QObject):
91
91
  )
92
92
  return
93
93
 
94
- meta = self.window.core.ctx.get_meta_by_id(meta_id)
95
- self.window.update_status(trans('idx.status.indexing'))
96
-
97
94
  worker = IndexWorker()
98
95
  worker.window = self.window
99
- worker.content = meta_id
100
- worker.idx = idx
101
- worker.type = "db_meta"
102
- worker.from_ts = meta.indexed
96
+ self.window.update_status(trans('idx.status.indexing'))
97
+
98
+ # batch indexing
99
+ if isinstance(meta_id, list):
100
+ ts_batch = {}
101
+ ids = []
102
+ for mid in meta_id:
103
+ meta = self.window.core.ctx.get_meta_by_id(mid)
104
+ if meta is None:
105
+ continue
106
+ ids.append(mid)
107
+ ts_batch[mid] = meta.indexed
108
+ worker.content = ids
109
+ worker.idx = idx
110
+ worker.type = "db_meta_batch"
111
+ worker.from_ts_batch = ts_batch
112
+ else:
113
+ # single indexing
114
+ meta = self.window.core.ctx.get_meta_by_id(meta_id)
115
+ worker.content = meta_id
116
+ worker.idx = idx
117
+ worker.type = "db_meta"
118
+ worker.from_ts = meta.indexed
119
+
103
120
  worker.signals.finished.connect(self.handle_finished_db_meta)
104
121
  worker.signals.error.connect(self.handle_error)
105
122
  self.window.threadpool.start(worker)
106
123
  self.worker = worker
107
-
108
124
  self.window.controller.idx.on_idx_start() # on start
109
125
 
110
126
  def index_ctx_current(
@@ -306,34 +322,46 @@ class Indexer(QObject):
306
322
  return
307
323
  self.index_path(path, idx)
308
324
 
309
- def index_file_confirm(self, path: str):
325
+ def index_file_confirm(self, path: Union[str, list]):
310
326
  """
311
327
  Index file (force execute)
312
328
 
313
- :param path: path to file or directory
329
+ :param path: path to file or directory or list of paths
314
330
  """
315
331
  # get stored index name
316
332
  if self.tmp_idx is None:
317
333
  return
318
334
  self.window.update_status(trans('idx.status.indexing'))
335
+ if isinstance(path, list):
336
+ self.index_paths(
337
+ path,
338
+ self.tmp_idx,
339
+ replace=False,
340
+ recursive=False,
341
+ )
342
+ return
319
343
  self.index_path(path, self.tmp_idx)
320
344
 
321
345
  def index_file(
322
346
  self,
323
- path: str,
347
+ path: Union[str, list],
324
348
  idx: str = "base",
325
349
  force: bool = False
326
350
  ):
327
351
  """
328
352
  Index file or directory (threaded)
329
353
 
330
- :param path: path to file or directory
354
+ :param path: path to file or directory or list of paths
331
355
  :param idx: index name
332
356
  :param force: force index
333
357
  """
334
358
  self.tmp_idx = idx # store tmp index name (for confirmation)
335
359
  if not force:
336
- content = trans('idx.confirm.file.content').replace('{dir}', path) \
360
+ dir_srt = str(path)
361
+ # strip to max 50 chars
362
+ if len(dir_srt) > 50:
363
+ dir_srt = dir_srt[:25] + "..." + dir_srt[-25:]
364
+ content = trans('idx.confirm.file.content').replace('{dir}', dir_srt) \
337
365
  + "\n" + trans('idx.token.warn')
338
366
  self.window.ui.dialogs.confirm(
339
367
  type='idx.index.file',
@@ -341,23 +369,42 @@ class Indexer(QObject):
341
369
  msg=content,
342
370
  )
343
371
  return
372
+ if isinstance(path, list):
373
+ self.index_paths(
374
+ path,
375
+ idx,
376
+ replace=False,
377
+ recursive=False,
378
+ )
379
+ return
344
380
  self.index_path(path, idx)
345
381
 
346
- def index_file_remove_confirm(self, path: str):
382
+ def index_file_remove_confirm(self, path: Union[str, list]):
347
383
  """
348
384
  Remove file (force execute)
349
385
 
350
- :param path: path to index
386
+ :param path: path to indexed file or directory or list of paths
351
387
  """
352
388
  # get stored index name
353
389
  if self.tmp_idx is None:
354
390
  return
355
391
 
356
- self.window.core.idx.remove_file(
357
- self.tmp_idx,
358
- path,
359
- )
360
- self.window.update_status(trans('status.deleted') + ": " + path)
392
+ dir_srt = str(path)
393
+ # strip to max 50 chars
394
+ if len(dir_srt) > 50:
395
+ dir_srt = dir_srt[:25] + "..." + dir_srt[-25:]
396
+
397
+ paths = []
398
+ if isinstance(path, list):
399
+ paths = path
400
+ else:
401
+ paths = [path]
402
+ for path in paths:
403
+ self.window.core.idx.remove_file(
404
+ self.tmp_idx,
405
+ path,
406
+ )
407
+ self.window.update_status(trans('status.deleted') + ": " + dir_srt)
361
408
  self.tmp_idx = None
362
409
  self.update_explorer() # update file status in explorer
363
410
 
@@ -376,7 +423,11 @@ class Indexer(QObject):
376
423
  """
377
424
  self.tmp_idx = idx # store tmp index name (for confirmation)
378
425
  if not force:
379
- content = trans('idx.confirm.file.remove.content').replace('{dir}', path)
426
+ dir_srt = str(path)
427
+ # strip to max 50 chars
428
+ if len(dir_srt) > 50:
429
+ dir_srt = dir_srt[:25] + "..." + dir_srt[-25:]
430
+ content = trans('idx.confirm.file.remove.content').replace('{dir}', dir_srt)
380
431
  self.window.ui.dialogs.confirm(
381
432
  type='idx.index.file.remove',
382
433
  id=path,
@@ -423,14 +474,14 @@ class Indexer(QObject):
423
474
  def index_ctx_meta_remove(
424
475
  self,
425
476
  idx: str,
426
- meta_id: int,
477
+ meta_id: Union[int, list],
427
478
  force: bool = False
428
479
  ):
429
480
  """
430
481
  Remove ctx meta from index
431
482
 
432
483
  :param idx: index name
433
- :param meta_id: meta id
484
+ :param meta_id: meta id or list of ids
434
485
  :param force: force index
435
486
  """
436
487
  if not force:
@@ -444,11 +495,16 @@ class Indexer(QObject):
444
495
  return
445
496
 
446
497
  store = self.window.core.idx.get_current_store()
447
- if self.window.core.ctx.idx.remove_meta_from_indexed(store, meta_id, self.tmp_idx):
448
- self.window.update_status(trans('status.deleted') + ": " + str(meta_id))
498
+ ids = meta_id if isinstance(meta_id, list) else [meta_id]
499
+ updated = False
500
+ for meta_id in ids:
501
+ if self.window.core.ctx.idx.remove_meta_from_indexed(store, meta_id, self.tmp_idx):
502
+ self.window.update_status(trans('status.deleted') + ": " + str(meta_id))
503
+ updated = True
504
+
505
+ if updated:
449
506
  self.window.controller.ctx.update() # update ctx list
450
-
451
- self.window.tools.get("indexer").refresh()
507
+ self.window.tools.get("indexer").refresh()
452
508
 
453
509
  def clear_by_idx(self, idx: int):
454
510
  """
@@ -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.31 23:00:00 #
9
+ # Updated Date: 2025.12.28 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import threading
@@ -401,4 +401,15 @@ class Kernel:
401
401
 
402
402
  :return: bool: True if the current thread is the main thread, False otherwise.
403
403
  """
404
- return threading.current_thread() is threading.main_thread()
404
+ return threading.current_thread() is threading.main_thread()
405
+
406
+ def close_clients(self):
407
+ """
408
+ Close all active clients associated with the kernel.
409
+ """
410
+ w = self.window
411
+ try:
412
+ w.core.api.openai.safe_close()
413
+ w.core.api.google.safe_close()
414
+ except Exception:
415
+ pass
@@ -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.17 07:00:00 #
9
+ # Updated Date: 2025.12.28 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from pygpt_net.core.events import Event, AppEvent
@@ -81,6 +81,8 @@ class Mode:
81
81
  c = w.controller
82
82
  core = w.core
83
83
  cfg = core.config
84
+ cfg.set('mode', mode) # must be set before assistant prompt update!
85
+
84
86
  try:
85
87
  if mode == MODE_ASSISTANT:
86
88
  c.presets.select_default()
@@ -95,8 +97,6 @@ class Mode:
95
97
  elif mode == MODE_AUDIO:
96
98
  c.audio.set_muted(False) # un-mute and show audio output icon by default
97
99
 
98
- cfg.set('mode', mode)
99
-
100
100
  # reset model and preset at start
101
101
  cfg.set('model', "")
102
102
  cfg.set('preset', "")
@@ -6,12 +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.12.26 13:00:00 #
9
+ # Updated Date: 2025.12.27 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import copy
13
13
  import json
14
- from typing import Optional, Any
14
+ from typing import Optional, Any, Union
15
15
 
16
16
  from pygpt_net.core.events import Event
17
17
  from pygpt_net.utils import trans
@@ -242,7 +242,8 @@ class Editor:
242
242
  height=self.height,
243
243
  )
244
244
  self.dialog = True
245
- self.window.ui.nodes['models.editor.search'].setFocus() # focus on search
245
+ if "models.editor.search" in self.window.ui.nodes:
246
+ self.window.ui.nodes['models.editor.search'].setFocus() # focus on search
246
247
  self.locked = False
247
248
 
248
249
  def undo(self):
@@ -394,15 +395,15 @@ class Editor:
394
395
  """Create new model"""
395
396
  self.locked = True
396
397
  self.save(persist=False)
397
- model = self.window.core.models.create_empty()
398
- model.provider = self.provider
398
+ model, new_id = self.window.core.models.create_empty()
399
+ if self.provider != "-":
400
+ model.provider = self.provider
399
401
  self.window.core.models.sort_items()
400
402
  self.window.core.models.save()
401
403
  self.reload_items()
402
404
 
403
405
  # switch to created model
404
406
  self.current = model.id
405
- # sort here
406
407
  idx = self.get_tab_by_id(self.current)
407
408
  self.set_by_tab(idx)
408
409
  self.init()
@@ -410,17 +411,15 @@ class Editor:
410
411
 
411
412
  def delete_by_idx(
412
413
  self,
413
- idx: int,
414
+ idx: Union[int, list],
414
415
  force: bool = False
415
416
  ):
416
417
  """
417
418
  Delete model by idx
418
419
 
419
- :param idx: model idx
420
+ :param idx: model idx or list of idxs
420
421
  :param force: force delete
421
422
  """
422
- self.locked = True
423
- model = self.get_model_by_tab_idx(idx)
424
423
  if not force:
425
424
  self.window.ui.dialogs.confirm(
426
425
  type="models.editor.delete",
@@ -428,14 +427,70 @@ class Editor:
428
427
  msg=trans("dialog.models.editor.delete.confirm"),
429
428
  )
430
429
  return
431
- self.window.core.models.delete(model)
432
- self.window.core.models.save()
433
- self.reload_items()
434
- if self.current == model:
435
- self.current = None
430
+
431
+ self.locked = True
432
+ models = []
433
+ last_idx = None
434
+ ids = idx if isinstance(idx, list) else [idx]
435
+ for i in ids:
436
+ model = self.get_model_by_tab_idx(i)
437
+ if model:
438
+ models.append(model)
439
+ last_idx = i
440
+
441
+ for model in models:
442
+ self.window.core.models.delete(model)
443
+ self.window.core.models.save()
444
+ self.reload_items()
445
+ if self.current == model:
446
+ self.current = None
447
+
448
+ # switch to previous model if available
449
+ items = self.prepare_items()
450
+ if len(items) > 0:
451
+ model = self.get_model_by_tab_idx(last_idx - 1)
452
+ if model:
453
+ self.current = model
454
+
436
455
  self.init()
437
456
  self.locked = False
438
457
 
458
+ def duplicate_by_idx(
459
+ self,
460
+ idx: Union[int, list]
461
+ ):
462
+ """
463
+ Duplicate model by idx
464
+
465
+ :param idx: model idx or list of idxs
466
+ """
467
+ self.locked = True
468
+ self.save(persist=False)
469
+ ids = idx if isinstance(idx, list) else [idx]
470
+ models = []
471
+ for i in ids:
472
+ model = self.get_model_by_tab_idx(i)
473
+ if model:
474
+ models.append(model)
475
+
476
+ for model in models:
477
+ if model:
478
+ new_model, new_id = self.window.core.models.create_empty()
479
+ new_model.from_dict(self.window.core.models.items[model].to_dict())
480
+ new_model.name += " (Copy)"
481
+ self.window.core.models.sort_items()
482
+ self.window.core.models.save()
483
+ self.reload_items()
484
+
485
+ # switch to created model if only one duplicated
486
+ if len(models) == 1:
487
+ self.current = new_id
488
+ idx = self.get_tab_by_id(self.current)
489
+ self.set_by_tab(idx)
490
+ self.init()
491
+
492
+ self.locked = False
493
+
439
494
  def load_defaults_user(self, force: bool = False):
440
495
  """
441
496
  Load models editor user defaults