pygpt-net 2.7.3__py3-none-any.whl → 2.7.4__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 (46) hide show
  1. pygpt_net/CHANGELOG.txt +8 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +382 -350
  4. pygpt_net/controller/chat/attachment.py +5 -1
  5. pygpt_net/controller/chat/image.py +15 -3
  6. pygpt_net/controller/files/files.py +3 -1
  7. pygpt_net/controller/layout/layout.py +2 -2
  8. pygpt_net/controller/theme/nodes.py +2 -1
  9. pygpt_net/controller/ui/mode.py +5 -1
  10. pygpt_net/controller/ui/ui.py +17 -2
  11. pygpt_net/core/filesystem/url.py +4 -1
  12. pygpt_net/core/render/web/helpers.py +5 -0
  13. pygpt_net/data/config/config.json +3 -4
  14. pygpt_net/data/config/models.json +3 -3
  15. pygpt_net/data/config/settings.json +0 -14
  16. pygpt_net/data/css/web-blocks.css +3 -0
  17. pygpt_net/data/css/web-chatgpt.css +3 -0
  18. pygpt_net/data/locale/locale.de.ini +2 -0
  19. pygpt_net/data/locale/locale.en.ini +3 -1
  20. pygpt_net/data/locale/locale.es.ini +2 -0
  21. pygpt_net/data/locale/locale.fr.ini +2 -0
  22. pygpt_net/data/locale/locale.it.ini +2 -0
  23. pygpt_net/data/locale/locale.pl.ini +2 -0
  24. pygpt_net/data/locale/locale.uk.ini +2 -0
  25. pygpt_net/data/locale/locale.zh.ini +2 -0
  26. pygpt_net/launcher.py +115 -55
  27. pygpt_net/preload.py +243 -0
  28. pygpt_net/provider/api/google/image.py +74 -6
  29. pygpt_net/provider/api/google/video.py +9 -4
  30. pygpt_net/provider/api/openai/image.py +42 -19
  31. pygpt_net/provider/api/openai/video.py +27 -2
  32. pygpt_net/provider/api/x_ai/image.py +25 -2
  33. pygpt_net/provider/core/config/patch.py +7 -0
  34. pygpt_net/ui/layout/chat/input.py +20 -2
  35. pygpt_net/ui/layout/chat/painter.py +6 -4
  36. pygpt_net/ui/layout/toolbox/image.py +5 -5
  37. pygpt_net/ui/layout/toolbox/video.py +5 -4
  38. pygpt_net/ui/main.py +84 -3
  39. pygpt_net/ui/widget/dialog/base.py +3 -10
  40. pygpt_net/ui/widget/option/combo.py +119 -1
  41. pygpt_net/ui/widget/textarea/input_extra.py +664 -0
  42. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/METADATA +17 -9
  43. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/RECORD +46 -44
  44. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/LICENSE +0 -0
  45. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/WHEEL +0 -0
  46. {pygpt_net-2.7.3.dist-info → pygpt_net-2.7.4.dist-info}/entry_points.txt +0 -0
pygpt_net/preload.py ADDED
@@ -0,0 +1,243 @@
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.12.31 17:00:00 #
10
+ # ================================================== #
11
+
12
+ # -------------------------------------------------- #
13
+ # Lightweight splash window (separate process)
14
+ # -------------------------------------------------- #
15
+ def _splash_main(conn, title="PyGPT", message="Loading…"):
16
+ """
17
+ Minimal splash process using PySide6. Runs its own event loop and
18
+ listens for commands on a Pipe: {"type": "msg", "text": "..."} or {"type": "quit"}.
19
+ """
20
+ try:
21
+ # Import locally to keep the main process import path untouched
22
+ from PySide6 import QtCore, QtWidgets
23
+ except Exception:
24
+ return
25
+
26
+ try:
27
+ # Enable HiDPI (safe defaults)
28
+ try:
29
+ QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
30
+ QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True)
31
+ except Exception:
32
+ pass
33
+
34
+ app = QtWidgets.QApplication(["pygpt_splash"])
35
+
36
+ # Root window styled as splash
37
+ root = QtWidgets.QWidget(
38
+ None,
39
+ QtCore.Qt.SplashScreen
40
+ | QtCore.Qt.FramelessWindowHint
41
+ | QtCore.Qt.WindowStaysOnTopHint
42
+ )
43
+ root.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
44
+ root.setObjectName("SplashRoot")
45
+
46
+ panel = QtWidgets.QFrame(root)
47
+ panel.setObjectName("SplashPanel")
48
+ panel.setStyleSheet("""
49
+ #SplashPanel {
50
+ background-color: rgba(30, 30, 30, 230);
51
+ border-radius: 12px;
52
+ }
53
+ QLabel { color: #ffffff; }
54
+ """)
55
+ layout = QtWidgets.QVBoxLayout(panel)
56
+ layout.setContentsMargins(16, 16, 16, 16)
57
+ layout.setSpacing(8)
58
+
59
+ lbl_title = QtWidgets.QLabel(title, panel)
60
+ lbl_title.setAlignment(QtCore.Qt.AlignCenter)
61
+ lbl_title.setStyleSheet("font-size: 16px; font-weight: 600;")
62
+
63
+ lbl_wait = QtWidgets.QLabel("Initializing...")
64
+ lbl_wait.setAlignment(QtCore.Qt.AlignCenter)
65
+ lbl_wait.setStyleSheet("font-size: 12px;")
66
+
67
+ lbl_msg = QtWidgets.QLabel(message, panel)
68
+ lbl_msg.setAlignment(QtCore.Qt.AlignCenter)
69
+ lbl_msg.setStyleSheet("font-size: 12px;")
70
+
71
+ bar = QtWidgets.QProgressBar(panel)
72
+ bar.setRange(0, 0)
73
+ bar.setTextVisible(False)
74
+ bar.setFixedHeight(8)
75
+ bar.setStyleSheet("QProgressBar { border: 0px; border-radius: 4px; } "
76
+ "QProgressBar::chunk { background-color: #3f3f3f; }")
77
+
78
+ layout.addWidget(lbl_title)
79
+ layout.addWidget(lbl_msg)
80
+ layout.addWidget(bar)
81
+ layout.addWidget(lbl_wait)
82
+
83
+ panel.setFixedSize(360, 120)
84
+ panel.move(0, 0)
85
+ root.resize(panel.size())
86
+
87
+ # Center on primary screen
88
+ screen = app.primaryScreen()
89
+ if screen:
90
+ geo = screen.availableGeometry()
91
+ root.move(geo.center() - root.rect().center())
92
+
93
+ # Ensure initial transparency for fade-in
94
+ try:
95
+ root.setWindowOpacity(0.0)
96
+ except Exception:
97
+ pass
98
+
99
+ root.show()
100
+
101
+ # Fade-in on start (non-blocking, resilient to platform limitations)
102
+ try:
103
+ def _start_fade_in():
104
+ try:
105
+ anim = QtCore.QPropertyAnimation(root, b"windowOpacity")
106
+ anim.setDuration(300)
107
+ anim.setStartValue(0.0)
108
+ anim.setEndValue(1.0)
109
+ root._fade_in_anim = anim # keep reference to avoid GC
110
+ anim.start()
111
+ except Exception:
112
+ try:
113
+ root.setWindowOpacity(1.0)
114
+ except Exception:
115
+ pass
116
+
117
+ QtCore.QTimer.singleShot(0, _start_fade_in)
118
+ except Exception:
119
+ try:
120
+ root.setWindowOpacity(1.0)
121
+ except Exception:
122
+ pass
123
+
124
+ # Poll the pipe for messages and close requests
125
+ timer = QtCore.QTimer()
126
+ timer.setInterval(80)
127
+
128
+ def poll():
129
+ try:
130
+ if conn.poll():
131
+ msg = conn.recv()
132
+ if isinstance(msg, dict):
133
+ t = msg.get("type")
134
+ if t == "quit":
135
+ # Stop fade-in if it is running to avoid conflicting animations
136
+ try:
137
+ if hasattr(root, "_fade_in_anim") and root._fade_in_anim is not None:
138
+ root._fade_in_anim.stop()
139
+ except Exception:
140
+ pass
141
+ # Fade-out and quit
142
+ try:
143
+ anim = QtCore.QPropertyAnimation(root, b"windowOpacity")
144
+ anim.setDuration(180)
145
+ anim.setStartValue(root.windowOpacity())
146
+ anim.setEndValue(0.0)
147
+ anim.finished.connect(app.quit)
148
+ # Keep reference to avoid GC
149
+ root._fade_anim = anim
150
+ anim.start()
151
+ except Exception:
152
+ app.quit()
153
+ elif t == "msg":
154
+ text = msg.get("text", "")
155
+ if text:
156
+ lbl_msg.setText(text)
157
+ elif isinstance(msg, str):
158
+ if msg.lower() == "quit":
159
+ app.quit()
160
+ except (EOFError, OSError):
161
+ # Parent died or pipe closed: exit
162
+ app.quit()
163
+
164
+ timer.timeout.connect(poll)
165
+ timer.start()
166
+
167
+ # Failsafe timeout (can be overridden via env)
168
+ import os as _os
169
+ timeout_ms = int(_os.environ.get("PYGPT_SPLASH_TIMEOUT", "120000"))
170
+ killer = QtCore.QTimer()
171
+ killer.setSingleShot(True)
172
+ killer.timeout.connect(app.quit)
173
+ killer.start(timeout_ms)
174
+
175
+ app.exec()
176
+ except Exception:
177
+ # No crash propagation to main app
178
+ pass
179
+
180
+
181
+ class _Preloader:
182
+ """
183
+ Controller for the splash subprocess.
184
+ """
185
+
186
+ def __init__(self, proc, conn):
187
+ self._proc = proc
188
+ self._conn = conn
189
+
190
+ def set_message(self, text):
191
+ try:
192
+ if self._conn:
193
+ self._conn.send({"type": "msg", "text": str(text)})
194
+ except Exception:
195
+ pass
196
+
197
+ def close(self, wait=True, timeout=2.0):
198
+ try:
199
+ if self._conn:
200
+ try:
201
+ self._conn.send({"type": "quit"})
202
+ except Exception:
203
+ pass
204
+ finally:
205
+ if wait and self._proc is not None:
206
+ self._proc.join(timeout=timeout)
207
+ if self._proc is not None and self._proc.is_alive():
208
+ try:
209
+ self._proc.terminate()
210
+ except Exception:
211
+ pass
212
+ self._conn = None
213
+ self._proc = None
214
+
215
+
216
+ def _start_preloader(title="PyGPT", message="Loading…"):
217
+ """
218
+ Start splash as a separate process using 'spawn' on every OS.
219
+ Returns a _Preloader controller or None if failed.
220
+ """
221
+ try:
222
+ import multiprocessing as mp
223
+ try:
224
+ ctx = mp.get_context("spawn")
225
+ except ValueError:
226
+ ctx = mp
227
+
228
+ parent_conn, child_conn = ctx.Pipe(duplex=True)
229
+ proc = ctx.Process(
230
+ target=_splash_main,
231
+ args=(child_conn, title, message),
232
+ daemon=True
233
+ )
234
+ proc.start()
235
+
236
+ try:
237
+ child_conn.close()
238
+ except Exception:
239
+ pass
240
+
241
+ return _Preloader(proc, parent_conn)
242
+ except Exception:
243
+ return None
@@ -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.12.30 22:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import mimetypes
@@ -55,6 +55,7 @@ class Image:
55
55
  prompt = context.prompt
56
56
  num = int(extra.get("num", 1))
57
57
  inline = bool(extra.get("inline", False))
58
+ extra_prompt = extra.get("extra_prompt", "")
58
59
 
59
60
  # decide sub-mode based on attachments
60
61
  sub_mode = self.MODE_GENERATE
@@ -79,6 +80,7 @@ class Image:
79
80
  worker.raw = self.window.core.config.get('img_raw')
80
81
  worker.num = num
81
82
  worker.inline = inline
83
+ worker.extra_prompt = extra_prompt
82
84
 
83
85
  # remix: previous image reference (ID/URI/path) from extra
84
86
  worker.image_id = extra.get("image_id")
@@ -129,6 +131,7 @@ class ImageWorker(QRunnable):
129
131
  self.input_prompt = ""
130
132
  self.system_prompt = ""
131
133
  self.inline = False
134
+ self.extra_prompt: Optional[str] = None
132
135
  self.raw = False
133
136
  self.num = 1
134
137
  self.resolution = "1024x1024" # used to derive aspect ratio or image_size
@@ -178,6 +181,18 @@ class ImageWorker(QRunnable):
178
181
  self.signals.error.emit(e)
179
182
  self.signals.status.emit(trans('img.status.prompt.error') + ": " + str(e))
180
183
 
184
+ # Decide how to apply negative prompt: native param on Vertex Imagen 3.0 (-001) or inline fallback.
185
+ use_param = (
186
+ bool(self.extra_prompt and str(self.extra_prompt).strip())
187
+ and self._using_vertex()
188
+ and self._imagen_supports_negative_prompt(self.model)
189
+ )
190
+ if (self.extra_prompt and str(self.extra_prompt).strip()) and not use_param:
191
+ try:
192
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt or "", self.extra_prompt)
193
+ except Exception:
194
+ pass
195
+
181
196
  paths: List[str] = []
182
197
 
183
198
  # Remix path: if image_id provided, prefer image-to-image remix using the given identifier.
@@ -198,11 +213,21 @@ class ImageWorker(QRunnable):
198
213
  mask_dilation=0.0,
199
214
  ),
200
215
  )
201
- cfg = gtypes.EditImageConfig(
216
+ # Prepare edit config with optional negative prompt when supported
217
+ cfg_kwargs = dict(
202
218
  edit_mode="EDIT_MODE_DEFAULT",
203
219
  number_of_images=min(self.num, self.imagen_max_num),
204
220
  include_rai_reason=True,
205
221
  )
222
+ if self.extra_prompt and self._imagen_supports_negative_prompt(self.model):
223
+ cfg_kwargs["negative_prompt"] = self.extra_prompt
224
+ try:
225
+ cfg = gtypes.EditImageConfig(**cfg_kwargs)
226
+ except Exception:
227
+ # Fallback without negative_prompt if SDK doesn't recognize it
228
+ cfg_kwargs.pop("negative_prompt", None)
229
+ cfg = gtypes.EditImageConfig(**cfg_kwargs)
230
+
206
231
  resp = self.client.models.edit_image(
207
232
  model="imagen-3.0-capability-001",
208
233
  prompt=self.input_prompt or "",
@@ -355,12 +380,34 @@ class ImageWorker(QRunnable):
355
380
  mid = str(model_id).lower()
356
381
  return "imagen" in mid and "generate" in mid
357
382
 
383
+ def _imagen_supports_negative_prompt(self, model_id: str) -> bool:
384
+ """
385
+ Return True if the Imagen model supports native negative_prompt.
386
+ Supported: imagen-3.0-generate-001, imagen-3.0-fast-generate-001, imagen-3.0-capability-001.
387
+ """
388
+ mid = str(model_id or "").lower()
389
+ return any(x in mid for x in (
390
+ "imagen-3.0-generate-001",
391
+ "imagen-3.0-fast-generate-001",
392
+ "imagen-3.0-capability-001",
393
+ ))
394
+
358
395
  def _imagen_generate(self, prompt: str, num: int, resolution: str):
359
396
  """Imagen text-to-image."""
360
397
  aspect = self._aspect_from_resolution(resolution)
361
- cfg = gtypes.GenerateImagesConfig(number_of_images=num)
398
+ # Build config with optional negative_prompt when supported by model and provided.
399
+ cfg_kwargs: Dict[str, Any] = {"number_of_images": num}
362
400
  if aspect:
363
- cfg.aspect_ratio = aspect
401
+ cfg_kwargs["aspect_ratio"] = aspect
402
+ if self.extra_prompt and self._imagen_supports_negative_prompt(self.model):
403
+ cfg_kwargs["negative_prompt"] = self.extra_prompt
404
+ try:
405
+ cfg = gtypes.GenerateImagesConfig(**cfg_kwargs)
406
+ except Exception:
407
+ # Fallback without negative_prompt if SDK doesn't recognize it
408
+ cfg_kwargs.pop("negative_prompt", None)
409
+ cfg = gtypes.GenerateImagesConfig(**cfg_kwargs)
410
+
364
411
  return self.client.models.generate_images(
365
412
  model=self.model,
366
413
  prompt=prompt,
@@ -401,11 +448,19 @@ class ImageWorker(QRunnable):
401
448
  )
402
449
  edit_mode = "EDIT_MODE_BGSWAP"
403
450
 
404
- cfg = gtypes.EditImageConfig(
451
+ # Build edit config with optional negative_prompt
452
+ cfg_kwargs = dict(
405
453
  edit_mode=edit_mode,
406
454
  number_of_images=min(num, self.imagen_max_num),
407
455
  include_rai_reason=True,
408
456
  )
457
+ if self.extra_prompt and self._imagen_supports_negative_prompt(self.model):
458
+ cfg_kwargs["negative_prompt"] = self.extra_prompt
459
+ try:
460
+ cfg = gtypes.EditImageConfig(**cfg_kwargs)
461
+ except Exception:
462
+ cfg_kwargs.pop("negative_prompt", None)
463
+ cfg = gtypes.EditImageConfig(**cfg_kwargs)
409
464
 
410
465
  # Ensure capability model for edit
411
466
  model_id = "imagen-3.0-capability-001"
@@ -806,4 +861,17 @@ class ImageWorker(QRunnable):
806
861
  try:
807
862
  sig.deleteLater()
808
863
  except RuntimeError:
809
- pass
864
+ pass
865
+
866
+ # ---------- prompt utilities ----------
867
+
868
+ @staticmethod
869
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
870
+ """
871
+ Append a negative prompt to the main text prompt when the provider has no native negative_prompt field.
872
+ """
873
+ base = (prompt or "").strip()
874
+ neg = (negative or "").strip()
875
+ if not neg:
876
+ return base
877
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()
@@ -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.12.30 22:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import base64, datetime, os, requests
@@ -55,6 +55,7 @@ class Video:
55
55
  num = int(extra.get("num", 1))
56
56
  inline = bool(extra.get("inline", False))
57
57
  video_id = extra.get("video_id")
58
+ extra_prompt = extra.get("extra_prompt", "")
58
59
 
59
60
  # decide sub-mode based on attachments (image-to-video when image is attached)
60
61
  sub_mode = self.MODE_GENERATE
@@ -81,6 +82,7 @@ class Video:
81
82
  worker.raw = self.window.core.config.get('img_raw')
82
83
  worker.num = num
83
84
  worker.inline = inline
85
+ worker.extra_prompt = extra_prompt
84
86
  worker.video_id = video_id
85
87
 
86
88
  # optional params
@@ -143,6 +145,7 @@ class VideoWorker(QRunnable):
143
145
  self.input_prompt = ""
144
146
  self.system_prompt = ""
145
147
  self.inline = False
148
+ self.extra_prompt: Optional[str] = None
146
149
  self.video_id = None
147
150
  self.raw = False
148
151
  self.num = 1
@@ -152,7 +155,6 @@ class VideoWorker(QRunnable):
152
155
  self.duration_seconds = 8
153
156
  self.fps = 24
154
157
  self.seed: Optional[int] = None
155
- self.negative_prompt: Optional[str] = None
156
158
  self.generate_audio: bool = False # generation includes audio by default on Veo 3.x
157
159
  self.resolution: str = "720p" # Veo supports 720p/1080p depending on variant
158
160
 
@@ -205,8 +207,8 @@ class VideoWorker(QRunnable):
205
207
  # set optional controls
206
208
  if self.seed is not None:
207
209
  cfg_kwargs["seed"] = int(self.seed)
208
- if self.negative_prompt:
209
- cfg_kwargs["negative_prompt"] = self.negative_prompt
210
+ if self.extra_prompt:
211
+ cfg_kwargs["negative_prompt"] = self.extra_prompt
210
212
 
211
213
  # set durationSeconds when supported; fall back gracefully if rejected by model
212
214
  cfg_try = dict(cfg_kwargs)
@@ -228,6 +230,9 @@ class VideoWorker(QRunnable):
228
230
 
229
231
  # Minimal config for extension to avoid server-side rejections
230
232
  ext_config = gtypes.GenerateVideosConfig(number_of_videos=1)
233
+ # Pass negative prompt to extension when provided
234
+ if self.extra_prompt:
235
+ ext_config.negative_prompt = self.extra_prompt # supported in python-genai
231
236
 
232
237
  label = trans('vid.status.generating') + " (remix)"
233
238
  self.signals.status.emit(label + f": {self.input_prompt or ''}...")
@@ -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.12.30 22:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import base64
@@ -59,6 +59,7 @@ class Image:
59
59
  inline = extra.get("inline", False)
60
60
  sub_mode = self.MODE_GENERATE
61
61
  image_id = extra.get("image_id") # previous image reference for remix
62
+ extra_prompt = extra.get("extra_prompt", "")
62
63
 
63
64
  # if attachments then switch mode to EDIT
64
65
  attachments = context.attachments
@@ -74,28 +75,29 @@ class Image:
74
75
  prompt_model = self.window.core.models.get(tmp_model)
75
76
 
76
77
  # worker
77
- self.worker = ImageWorker()
78
- self.worker.window = self.window
79
- self.worker.client = self.window.core.api.openai.get_client()
80
- self.worker.ctx = ctx
81
- self.worker.mode = sub_mode # mode can be "generate" or "edit"
82
- self.worker.attachments = attachments # attachments for edit mode
83
- self.worker.raw = self.window.core.config.get('img_raw')
84
- self.worker.model = model.id # model ID for generate image, e.g. "dall-e-3"
85
- self.worker.model_prompt = prompt_model # model for generate prompt, not image!
86
- self.worker.input_prompt = prompt
87
- self.worker.system_prompt = self.window.core.prompt.get('img')
88
- self.worker.num = num
89
- self.worker.inline = inline
90
- self.worker.image_id = image_id # remix: previous image path/identifier
78
+ worker = ImageWorker()
79
+ worker.window = self.window
80
+ worker.client = self.window.core.api.openai.get_client()
81
+ worker.ctx = ctx
82
+ worker.mode = sub_mode # mode can be "generate" or "edit"
83
+ worker.attachments = attachments # attachments for edit mode
84
+ worker.raw = self.window.core.config.get('img_raw')
85
+ worker.model = model.id # model ID for generate image, e.g. "dall-e-3"
86
+ worker.model_prompt = prompt_model # model for generate prompt, not image!
87
+ worker.input_prompt = prompt
88
+ worker.system_prompt = self.window.core.prompt.get('img')
89
+ worker.num = num
90
+ worker.inline = inline
91
+ worker.extra_prompt = extra_prompt
92
+ worker.image_id = image_id # remix: previous image path/identifier
91
93
 
92
94
  # config
93
95
  if self.window.core.config.has('img_quality'):
94
- self.worker.quality = self.window.core.config.get('img_quality')
96
+ worker.quality = self.window.core.config.get('img_quality')
95
97
  if self.window.core.config.has('img_resolution'):
96
- self.worker.resolution = self.window.core.config.get('img_resolution')
98
+ worker.resolution = self.window.core.config.get('img_resolution')
97
99
 
98
- # signals
100
+ self.worker = worker
99
101
  self.worker.signals.finished.connect(self.window.core.image.handle_finished)
100
102
  self.worker.signals.finished_inline.connect(self.window.core.image.handle_finished_inline)
101
103
  self.worker.signals.status.connect(self.window.core.image.handle_status)
@@ -145,6 +147,7 @@ class ImageWorker(QRunnable):
145
147
  self.input_prompt: Optional[str] = None
146
148
  self.system_prompt = None
147
149
  self.inline = False
150
+ self.extra_prompt: Optional[str] = None
148
151
  self.num = 1
149
152
  self.image_id: Optional[str] = None # previous image reference for remix
150
153
 
@@ -245,6 +248,13 @@ class ImageWorker(QRunnable):
245
248
  self.signals.error.emit(e)
246
249
  self.signals.status.emit(trans('img.status.prompt.error') + ": " + str(e))
247
250
 
251
+ # Fallback negative prompt injection (OpenAI Images API has no native negative_prompt field)
252
+ if self.extra_prompt and str(self.extra_prompt).strip():
253
+ try:
254
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt or "", self.extra_prompt)
255
+ except Exception:
256
+ pass
257
+
248
258
  self.signals.status.emit(trans('img.status.generating') + ": {}...".format(self.input_prompt))
249
259
 
250
260
  paths: List[str] = [] # downloaded images paths
@@ -407,4 +417,17 @@ class ImageWorker(QRunnable):
407
417
  try:
408
418
  sig.deleteLater()
409
419
  except RuntimeError:
410
- pass
420
+ pass
421
+
422
+ # ---------- prompt utilities ----------
423
+
424
+ @staticmethod
425
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
426
+ """
427
+ Append a negative prompt to the main text prompt for providers without a native negative_prompt field.
428
+ """
429
+ base = (prompt or "").strip()
430
+ neg = (negative or "").strip()
431
+ if not neg:
432
+ return base
433
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()
@@ -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.12.30 22:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
@@ -60,6 +60,7 @@ class Video:
60
60
  num = int(extra.get("num", 1))
61
61
  inline = bool(extra.get("inline", False))
62
62
  video_id = extra.get("video_id")
63
+ extra_prompt = extra.get("extra_prompt", "")
63
64
 
64
65
  # decide sub-mode based on attachments (image-to-video when image is attached)
65
66
  sub_mode = self.MODE_GENERATE
@@ -86,6 +87,7 @@ class Video:
86
87
  worker.raw = self.window.core.config.get('img_raw')
87
88
  worker.num = num
88
89
  worker.inline = inline
90
+ worker.extra_prompt = extra_prompt
89
91
  worker.video_id = video_id
90
92
 
91
93
  # optional params (app-level options)
@@ -157,6 +159,7 @@ class VideoWorker(QRunnable):
157
159
  self.input_prompt = ""
158
160
  self.system_prompt = ""
159
161
  self.inline = False
162
+ self.extra_prompt: Optional[str] = None
160
163
  self.video_id = None
161
164
  self.raw = False
162
165
  self.num = 1
@@ -193,6 +196,14 @@ class VideoWorker(QRunnable):
193
196
  self.signals.error.emit(e)
194
197
  self.signals.status.emit(trans('vid.status.prompt.error') + ": " + str(e))
195
198
 
199
+ # Negative prompt fallback: inject constraints into the text prompt (Sora has no native negative_prompt field)
200
+ if self.extra_prompt and str(self.extra_prompt).strip():
201
+ try:
202
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt, self.extra_prompt)
203
+ except Exception:
204
+ # do not fail generation if merge fails
205
+ pass
206
+
196
207
  # Sora API accepts a single video per create call; honor app's num but cap to 1 per job
197
208
  _ = max(1, min(self.num, self.max_per_job))
198
209
 
@@ -609,4 +620,18 @@ class VideoWorker(QRunnable):
609
620
  except Exception:
610
621
  pass
611
622
 
612
- return " ".join(parts).strip()
623
+ return " ".join(parts).strip()
624
+
625
+ # ---------- prompt utilities ----------
626
+
627
+ @staticmethod
628
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
629
+ """
630
+ Append a negative prompt to the main text prompt for providers without a native negative_prompt field.
631
+ """
632
+ base = (prompt or "").strip()
633
+ neg = (negative or "").strip()
634
+ if not neg:
635
+ return base
636
+ # Keep the user's original prompt intact and add clear constraint instructions.
637
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()
@@ -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 01:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import base64
@@ -49,6 +49,7 @@ class Image:
49
49
  prompt = context.prompt
50
50
  num = int(extra.get("num", 1))
51
51
  inline = bool(extra.get("inline", False))
52
+ extra_prompt = extra.get("extra_prompt", "")
52
53
 
53
54
  # Optional prompt enhancement model (same as in your Google path)
54
55
  prompt_model = self.window.core.models.from_defaults()
@@ -66,6 +67,7 @@ class Image:
66
67
  worker.raw = self.window.core.config.get('img_raw')
67
68
  worker.num = num
68
69
  worker.inline = inline
70
+ worker.extra_prompt = extra_prompt
69
71
 
70
72
  self.worker = worker
71
73
  self.worker.signals.finished.connect(self.window.core.image.handle_finished)
@@ -102,6 +104,7 @@ class ImageWorker(QRunnable):
102
104
  self.input_prompt = ""
103
105
  self.system_prompt = ""
104
106
  self.inline = False
107
+ self.extra_prompt: Optional[str] = None
105
108
  self.raw = False
106
109
  self.num = 1
107
110
 
@@ -131,6 +134,13 @@ class ImageWorker(QRunnable):
131
134
  self.signals.error.emit(e)
132
135
  self.signals.status.emit(trans('img.status.prompt.error') + ": " + str(e))
133
136
 
137
+ # Negative prompt fallback: append as textual instruction (xAI has no native field for it)
138
+ if self.extra_prompt and str(self.extra_prompt).strip():
139
+ try:
140
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt or "", self.extra_prompt)
141
+ except Exception:
142
+ pass
143
+
134
144
  self.signals.status.emit(trans('img.status.generating') + f": {self.input_prompt}...")
135
145
 
136
146
  cfg = self.window.core.config
@@ -205,4 +215,17 @@ class ImageWorker(QRunnable):
205
215
  try:
206
216
  sig.deleteLater()
207
217
  except RuntimeError:
208
- pass
218
+ pass
219
+
220
+ # ---------- prompt utilities ----------
221
+
222
+ @staticmethod
223
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
224
+ """
225
+ Append a negative prompt to the main text prompt for providers without a native negative_prompt field.
226
+ """
227
+ base = (prompt or "").strip()
228
+ neg = (negative or "").strip()
229
+ if not neg:
230
+ return base
231
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()