pygpt-net 2.7.7__py3-none-any.whl → 2.7.9__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 (98) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +5 -1
  4. pygpt_net/controller/assistant/batch.py +2 -2
  5. pygpt_net/controller/assistant/files.py +7 -6
  6. pygpt_net/controller/assistant/threads.py +0 -0
  7. pygpt_net/controller/chat/command.py +0 -0
  8. pygpt_net/controller/dialogs/confirm.py +35 -58
  9. pygpt_net/controller/lang/mapping.py +9 -9
  10. pygpt_net/controller/realtime/realtime.py +13 -1
  11. pygpt_net/controller/remote_store/{google/batch.py → batch.py} +209 -252
  12. pygpt_net/controller/remote_store/remote_store.py +982 -13
  13. pygpt_net/core/command/command.py +0 -0
  14. pygpt_net/core/db/viewer.py +1 -1
  15. pygpt_net/core/realtime/worker.py +3 -1
  16. pygpt_net/{controller/remote_store/google → core/remote_store/anthropic}/__init__.py +0 -1
  17. pygpt_net/core/remote_store/anthropic/files.py +211 -0
  18. pygpt_net/core/remote_store/anthropic/store.py +208 -0
  19. pygpt_net/core/remote_store/openai/store.py +5 -4
  20. pygpt_net/core/remote_store/remote_store.py +5 -1
  21. pygpt_net/{controller/remote_store/openai → core/remote_store/xai}/__init__.py +0 -1
  22. pygpt_net/core/remote_store/xai/files.py +225 -0
  23. pygpt_net/core/remote_store/xai/store.py +219 -0
  24. pygpt_net/data/config/config.json +10 -6
  25. pygpt_net/data/config/models.json +38 -22
  26. pygpt_net/data/config/settings.json +54 -1
  27. pygpt_net/data/icons/folder_eye.svg +1 -0
  28. pygpt_net/data/icons/folder_eye_filled.svg +1 -0
  29. pygpt_net/data/icons/folder_open.svg +1 -0
  30. pygpt_net/data/icons/folder_open_filled.svg +1 -0
  31. pygpt_net/data/locale/locale.de.ini +4 -3
  32. pygpt_net/data/locale/locale.en.ini +14 -4
  33. pygpt_net/data/locale/locale.es.ini +4 -3
  34. pygpt_net/data/locale/locale.fr.ini +4 -3
  35. pygpt_net/data/locale/locale.it.ini +4 -3
  36. pygpt_net/data/locale/locale.pl.ini +5 -4
  37. pygpt_net/data/locale/locale.uk.ini +4 -3
  38. pygpt_net/data/locale/locale.zh.ini +4 -3
  39. pygpt_net/icons.qrc +4 -0
  40. pygpt_net/icons_rc.py +282 -138
  41. pygpt_net/provider/api/anthropic/__init__.py +2 -0
  42. pygpt_net/provider/api/anthropic/chat.py +84 -1
  43. pygpt_net/provider/api/anthropic/store.py +307 -0
  44. pygpt_net/provider/api/anthropic/stream.py +75 -0
  45. pygpt_net/provider/api/anthropic/worker/__init__.py +0 -0
  46. pygpt_net/provider/api/anthropic/worker/importer.py +278 -0
  47. pygpt_net/provider/api/google/chat.py +59 -2
  48. pygpt_net/provider/api/google/realtime/client.py +70 -24
  49. pygpt_net/provider/api/google/realtime/realtime.py +48 -12
  50. pygpt_net/provider/api/google/store.py +124 -3
  51. pygpt_net/provider/api/google/stream.py +91 -24
  52. pygpt_net/provider/api/google/worker/importer.py +16 -28
  53. pygpt_net/provider/api/openai/assistants.py +2 -2
  54. pygpt_net/provider/api/openai/realtime/realtime.py +26 -6
  55. pygpt_net/provider/api/openai/store.py +4 -1
  56. pygpt_net/provider/api/openai/worker/importer.py +19 -61
  57. pygpt_net/provider/api/openai/worker/importer_assistants.py +230 -0
  58. pygpt_net/provider/api/x_ai/__init__.py +27 -6
  59. pygpt_net/provider/api/x_ai/audio.py +43 -11
  60. pygpt_net/provider/api/x_ai/chat.py +92 -4
  61. pygpt_net/provider/api/x_ai/realtime/__init__.py +12 -0
  62. pygpt_net/provider/api/x_ai/realtime/client.py +1864 -0
  63. pygpt_net/provider/api/x_ai/realtime/realtime.py +213 -0
  64. pygpt_net/provider/api/x_ai/remote_tools.py +102 -1
  65. pygpt_net/provider/api/x_ai/store.py +610 -0
  66. pygpt_net/provider/api/x_ai/stream.py +30 -9
  67. pygpt_net/provider/api/x_ai/tools.py +51 -0
  68. pygpt_net/provider/api/x_ai/worker/importer.py +308 -0
  69. pygpt_net/provider/audio_input/xai_grok_voice.py +390 -0
  70. pygpt_net/provider/audio_output/xai_tts.py +325 -0
  71. pygpt_net/provider/core/config/patch.py +29 -3
  72. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +2 -2
  73. pygpt_net/provider/core/model/patch.py +49 -1
  74. pygpt_net/tools/image_viewer/tool.py +334 -34
  75. pygpt_net/tools/image_viewer/ui/dialogs.py +317 -21
  76. pygpt_net/ui/dialog/assistant.py +1 -1
  77. pygpt_net/ui/dialog/plugins.py +13 -5
  78. pygpt_net/ui/dialog/remote_store.py +552 -0
  79. pygpt_net/ui/dialogs.py +3 -5
  80. pygpt_net/ui/layout/ctx/ctx_list.py +58 -7
  81. pygpt_net/ui/menu/tools.py +6 -13
  82. pygpt_net/ui/widget/dialog/{remote_store_google.py → remote_store.py} +10 -10
  83. pygpt_net/ui/widget/element/button.py +4 -4
  84. pygpt_net/ui/widget/image/display.py +2 -2
  85. pygpt_net/ui/widget/lists/context.py +2 -2
  86. {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.9.dist-info}/METADATA +14 -2
  87. {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.9.dist-info}/RECORD +87 -75
  88. pygpt_net/controller/remote_store/google/store.py +0 -615
  89. pygpt_net/controller/remote_store/openai/batch.py +0 -524
  90. pygpt_net/controller/remote_store/openai/store.py +0 -699
  91. pygpt_net/ui/dialog/remote_store_google.py +0 -539
  92. pygpt_net/ui/dialog/remote_store_openai.py +0 -539
  93. pygpt_net/ui/widget/dialog/remote_store_openai.py +0 -56
  94. pygpt_net/ui/widget/lists/remote_store_google.py +0 -248
  95. pygpt_net/ui/widget/lists/remote_store_openai.py +0 -317
  96. {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.9.dist-info}/LICENSE +0 -0
  97. {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.9.dist-info}/WHEEL +0 -0
  98. {pygpt_net-2.7.7.dist-info → pygpt_net-2.7.9.dist-info}/entry_points.txt +0 -0
@@ -362,6 +362,12 @@ class Chat:
362
362
  except Exception:
363
363
  pass
364
364
 
365
+ # Download Files API file_data parts if present
366
+ try:
367
+ self._maybe_download_response_files(response, ctx)
368
+ except Exception:
369
+ pass
370
+
365
371
  def extract_text(self, response) -> str:
366
372
  """
367
373
  Extract output text.
@@ -792,7 +798,7 @@ class Chat:
792
798
  return bytes(data)
793
799
  if isinstance(data, str):
794
800
  import base64
795
- return base64.b64decode(data)
801
+ return base64.b64encode(bytes()) if data == "" else base64.b64decode(data)
796
802
  except Exception:
797
803
  return None
798
804
  return None
@@ -1000,4 +1006,55 @@ class Chat:
1000
1006
  out.append({"type": typ, "uri": uri})
1001
1007
  continue
1002
1008
 
1003
- return out
1009
+ return out
1010
+
1011
+ def _maybe_download_response_files(self, response, ctx: CtxItem) -> None:
1012
+ """
1013
+ Inspect non-stream response parts for Files API references and download them.
1014
+ """
1015
+ try:
1016
+ cands = getattr(response, "candidates", None) or []
1017
+ if not cands:
1018
+ return
1019
+ first = cands[0]
1020
+ content = getattr(first, "content", None)
1021
+ parts = getattr(content, "parts", None) or []
1022
+ except Exception:
1023
+ parts = []
1024
+
1025
+ if not parts:
1026
+ return
1027
+
1028
+ downloaded: List[str] = []
1029
+ for p in parts:
1030
+ fdata = getattr(p, "file_data", None)
1031
+ if not fdata:
1032
+ continue
1033
+ try:
1034
+ uri = getattr(fdata, "file_uri", None) or getattr(fdata, "uri", None)
1035
+ prefer = getattr(fdata, "file_name", None) or getattr(fdata, "display_name", None)
1036
+ if not uri or not isinstance(uri, str):
1037
+ continue
1038
+ # Only Gemini Files API refs are supported for direct download
1039
+ save_path = self.window.core.api.google.store.download_to_dir(uri, prefer_name=prefer)
1040
+ if save_path:
1041
+ downloaded.append(save_path)
1042
+ except Exception:
1043
+ continue
1044
+
1045
+ if downloaded:
1046
+ downloaded = self.window.core.filesystem.make_local_list(downloaded)
1047
+ if not isinstance(ctx.files, list):
1048
+ ctx.files = []
1049
+ for path in downloaded:
1050
+ if path not in ctx.files:
1051
+ ctx.files.append(path)
1052
+ images = []
1053
+ for path in downloaded:
1054
+ ext = os.path.splitext(path)[1].lower().lstrip(".")
1055
+ if ext in ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]:
1056
+ images.append(path)
1057
+ if images:
1058
+ if not isinstance(ctx.images, list):
1059
+ ctx.images = []
1060
+ ctx.images += images
@@ -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: 2026.01.07 23:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import asyncio
@@ -338,20 +338,25 @@ class GoogleLiveClient:
338
338
  if sys_prompt:
339
339
  live_cfg["system_instruction"] = str(sys_prompt)
340
340
 
341
- # Session resumption: enable updates; resume when a different non-empty handle is given
341
+ # Save callbacks and ctx early so handle persistence can target the current context
342
+ self._on_text = on_text
343
+ self._on_audio = on_audio
344
+ self._should_stop = should_stop or (lambda: False)
345
+ self._ctx = ctx
346
+ self._last_opts = opts
347
+
348
+ # Session resumption: configure per docs; include handle when provided, otherwise None.
342
349
  try:
350
+ ph = None
343
351
  provided_handle = getattr(opts, "rt_session_id", None)
344
- resume_handle = None
345
352
  if isinstance(provided_handle, str):
346
- ph = provided_handle.strip()
347
- if ph and ph != (self._rt_session_id or ""):
348
- resume_handle = ph
353
+ ph = provided_handle.strip() or None
349
354
 
350
- live_cfg["session_resumption"] = gtypes.SessionResumptionConfig(handle=resume_handle)
355
+ sr_cfg = gtypes.SessionResumptionConfig(handle=ph)
356
+ live_cfg["session_resumption"] = sr_cfg
351
357
 
352
- if resume_handle:
353
- self._rt_session_id = resume_handle
354
- set_ctx_rt_handle(self._ctx, resume_handle, self.window)
358
+ if ph:
359
+ self._persist_rt_handle(ph)
355
360
  except Exception:
356
361
  pass
357
362
 
@@ -360,13 +365,6 @@ class GoogleLiveClient:
360
365
  apply_turn_mode_google(live_cfg, turn_mode)
361
366
  self._tune_google_vad(live_cfg, opts)
362
367
 
363
- # Save callbacks and ctx
364
- self._on_text = on_text
365
- self._on_audio = on_audio
366
- self._should_stop = should_stop or (lambda: False)
367
- self._ctx = ctx
368
- self._last_opts = opts
369
-
370
368
  # Control primitives
371
369
  self._response_done = asyncio.Event()
372
370
  self._send_lock = asyncio.Lock()
@@ -407,7 +405,7 @@ class GoogleLiveClient:
407
405
  self._rt_state = None
408
406
  self._last_tool_calls = []
409
407
 
410
- # Clear only in-memory handle; keep persisted ctx.extra["rt_session_id"]
408
+ # Clear in-memory handle as well to prevent unintended resumption
411
409
  self._rt_session_id = None
412
410
 
413
411
  # Clear cached tools signature
@@ -820,11 +818,10 @@ class GoogleLiveClient:
820
818
  try:
821
819
  sru = getattr(response, "session_resumption_update", None) or getattr(response, "sessionResumptionUpdate", None)
822
820
  if sru:
823
- resumable = bool(getattr(sru, "resumable", None))
824
- new_handle = getattr(sru, "new_handle", None) or getattr(sru, "newHandle", None)
825
- if resumable and isinstance(new_handle, str) and new_handle.strip():
826
- self._rt_session_id = new_handle.strip()
827
- set_ctx_rt_handle(self._ctx, self._rt_session_id, self.window)
821
+ # Prefer robustness: persist handle if present, regardless of 'resumable' flag inconsistencies
822
+ new_handle = self._extract_sru_handle(sru)
823
+ if isinstance(new_handle, str) and new_handle.strip():
824
+ self._persist_rt_handle(new_handle.strip())
828
825
  if self.debug:
829
826
  print(f"[google.live] session handle updated: {self._rt_session_id}")
830
827
  except Exception:
@@ -1740,6 +1737,10 @@ class GoogleLiveClient:
1740
1737
  """
1741
1738
  self.debug = bool(enabled)
1742
1739
 
1740
+ def is_session(self) -> bool:
1741
+ """Check if the WS session is currently open."""
1742
+ return self._session is not None
1743
+
1743
1744
  def is_session_active(self) -> bool:
1744
1745
  """Check if the WS session is currently open."""
1745
1746
  return self._session is not None
@@ -1748,6 +1749,12 @@ class GoogleLiveClient:
1748
1749
  """Update the current CtxItem (for session handle persistence)."""
1749
1750
  self._ctx = ctx
1750
1751
 
1752
+ def get_current_rt_session_id(self) -> Optional[str]:
1753
+ """
1754
+ Return the current resumable session handle if known.
1755
+ """
1756
+ return self._rt_session_id
1757
+
1751
1758
  # -----------------------------
1752
1759
  # Internal: auto-turn receiver bootstrap
1753
1760
  # -----------------------------
@@ -1942,4 +1949,43 @@ class GoogleLiveClient:
1942
1949
  """
1943
1950
  Emit RT_OUTPUT_AUDIO_COMMIT on first sign of model output in auto-turn mode.
1944
1951
  """
1945
- self._emit_audio_commit_signal()
1952
+ self._emit_audio_commit_signal()
1953
+
1954
+ # -----------------------------
1955
+ # Internal: session handle helpers
1956
+ # -----------------------------
1957
+
1958
+ def _persist_rt_handle(self, handle: str) -> None:
1959
+ """
1960
+ Persist current session handle in-memory, to ctx.extra and into last opts for future restarts.
1961
+ """
1962
+ try:
1963
+ self._rt_session_id = handle
1964
+ set_ctx_rt_handle(self._ctx, handle, self.window)
1965
+ except Exception:
1966
+ pass
1967
+ try:
1968
+ if self._last_opts is not None:
1969
+ setattr(self._last_opts, "rt_session_id", handle)
1970
+ except Exception:
1971
+ pass
1972
+
1973
+ def _extract_sru_handle(self, sru: Any) -> Optional[str]:
1974
+ """
1975
+ Extract handle from SessionResumptionUpdate (supports snake_case and camelCase, and token alias).
1976
+ """
1977
+ # Objects (attrs)
1978
+ for attr in ("new_handle", "newHandle", "token"):
1979
+ try:
1980
+ v = getattr(sru, attr, None)
1981
+ if isinstance(v, str) and v.strip():
1982
+ return v.strip()
1983
+ except Exception:
1984
+ pass
1985
+ # Dicts
1986
+ if isinstance(sru, dict):
1987
+ for k in ("new_handle", "newHandle", "token"):
1988
+ v = sru.get(k)
1989
+ if isinstance(v, str) and v.strip():
1990
+ return v.strip()
1991
+ 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: 2026.01.02 19:00:00 #
9
+ # Updated Date: 2026.01.07 23:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
@@ -86,8 +86,55 @@ class Realtime:
86
86
  self.handler.send_tool_results_sync({
87
87
  tool_call_id: tool_results
88
88
  })
89
+ self.handler.update_ctx(context.ctx)
89
90
  return True # do not start new session, just send tool results
90
91
 
92
+ # Tools
93
+ tools = self.window.core.api.google.tools.prepare(model, context.external_functions)
94
+ remote_tools = self.window.core.api.google.remote_tools.build_remote_tools(model)
95
+ if tools:
96
+ remote_tools = [] # in Google, remote tools are not allowed if function calling is used
97
+
98
+ # Resolve last session ID, prefer history, then fallback to current ctx and in-memory handler handle
99
+ last_session_id = extract_last_session_id(context.history) if context.history else None
100
+ if not last_session_id:
101
+ try:
102
+ if context.ctx and isinstance(context.ctx.extra, dict):
103
+ sid = context.ctx.extra.get("rt_session_id")
104
+ if isinstance(sid, str) and sid.strip():
105
+ last_session_id = sid.strip()
106
+ except Exception:
107
+ pass
108
+ if not last_session_id and self.handler.is_session_active():
109
+ try:
110
+ sid = self.handler.get_current_rt_session_id()
111
+ if isinstance(sid, str) and sid.strip():
112
+ last_session_id = sid.strip()
113
+ except Exception:
114
+ pass
115
+
116
+ if is_debug:
117
+ print("[realtime session] Last ID", last_session_id)
118
+
119
+ # Enforce clean state rules:
120
+ # - No history: always reset to ensure a fresh server context.
121
+ # - If history exists, keep the current live session even if the resumable handle has not been captured yet.
122
+ # Gemini Live can emit the handle slightly after the first turn starts; closing here would drop context continuity.
123
+ try:
124
+ history_len = len(context.history) if context.history else 0
125
+ except Exception:
126
+ history_len = 0
127
+
128
+ if history_len == 0:
129
+ if self.handler.is_session_active():
130
+ self.handler.close_session_sync()
131
+ try:
132
+ if context.ctx and isinstance(context.ctx.extra, dict):
133
+ context.ctx.extra.pop("rt_session_id", None)
134
+ except Exception:
135
+ pass
136
+ last_session_id = None # force new session
137
+
91
138
  # update auto-turn in active session
92
139
  if (self.handler.is_session_active()
93
140
  and (auto_turn != self.prev_auto_turn
@@ -95,23 +142,12 @@ class Realtime:
95
142
  or opt_vad_prefix != self.prev_vad_prefix)):
96
143
  self.handler.update_session_autoturn_sync(auto_turn, opt_vad_silence, opt_vad_prefix)
97
144
 
98
- # Tools
99
- tools = self.window.core.api.google.tools.prepare(model, context.external_functions)
100
- remote_tools = self.window.core.api.google.remote_tools.build_remote_tools(model)
101
- if tools:
102
- remote_tools = [] # in Google, remote tools are not allowed if function calling is used
103
-
104
145
  # if auto-turn is enabled and prompt is empty, update session and context only
105
146
  if auto_turn and self.handler.is_session_active() and (context.prompt.strip() == "" or context.prompt == "..."):
106
147
  self.handler.update_session_tools_sync(tools, remote_tools)
107
148
  self.handler.update_ctx(context.ctx)
108
149
  return True # do not send new request if session is active
109
150
 
110
- # Last session ID
111
- last_session_id = extract_last_session_id(context.history)
112
- if is_debug:
113
- print("[realtime session] Last ID", last_session_id)
114
-
115
151
  # Voice
116
152
  voice_name = "Kore"
117
153
  try:
@@ -6,12 +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: 2026.01.02 20:00:00 #
9
+ # Updated Date: 2026.01.06 06:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
13
  import time
14
- from typing import Optional, List, Dict, Any
14
+ import mimetypes
15
+ from typing import Optional, List, Dict, Any, Union
15
16
 
16
17
  from pygpt_net.item.store import RemoteStoreItem
17
18
 
@@ -63,6 +64,53 @@ class Store:
63
64
  v = hi
64
65
  return v
65
66
 
67
+ def _download_dir(self) -> str:
68
+ """
69
+ Resolve target download directory (uses download.dir if set).
70
+ """
71
+ if self.window.core.config.has("download.dir") and self.window.core.config.get("download.dir") != "":
72
+ dir_path = os.path.join(
73
+ self.window.core.config.get_user_dir('data'),
74
+ self.window.core.config.get("download.dir"),
75
+ )
76
+ else:
77
+ dir_path = self.window.core.config.get_user_dir('data')
78
+ os.makedirs(dir_path, exist_ok=True)
79
+ return dir_path
80
+
81
+ def _ensure_unique_path(self, dir_path: str, filename: str) -> str:
82
+ """
83
+ Ensure unique filename in dir, add timestamp prefix if exists.
84
+ """
85
+ path = os.path.join(dir_path, filename)
86
+ if os.path.exists(path):
87
+ prefix = time.strftime("%Y%m%d_%H%M%S_")
88
+ path = os.path.join(dir_path, f"{prefix}{filename}")
89
+ return path
90
+
91
+ def _guess_filename(self, file_meta: Any, fallback: str = "downloaded.bin") -> str:
92
+ """
93
+ Best-effort filename from File metadata or URI.
94
+ """
95
+ name = None
96
+ for attr in ("display_name", "filename", "name", "file_name"):
97
+ try:
98
+ val = getattr(file_meta, attr, None)
99
+ if not name and isinstance(val, str) and val:
100
+ name = os.path.basename(val)
101
+ except Exception:
102
+ pass
103
+ if not name and isinstance(file_meta, dict):
104
+ val = file_meta.get(attr)
105
+ if isinstance(val, str) and val:
106
+ name = os.path.basename(val)
107
+
108
+ if not name:
109
+ # allow URI-like strings
110
+ if isinstance(file_meta, str):
111
+ name = os.path.basename(file_meta.split("?")[0].split("#")[0])
112
+ return name or fallback
113
+
66
114
  # -----------------------------
67
115
  # Files service (global)
68
116
  # -----------------------------
@@ -105,6 +153,79 @@ class Store:
105
153
  if res is not None:
106
154
  return file_name
107
155
 
156
+ def download(self, file: Union[str, Any], path: str) -> bool:
157
+ """
158
+ Download a Files API item into the given path.
159
+
160
+ :param file: file name ('files/...'), file object, or file URI
161
+ :param path: target local path
162
+ :return: True on success
163
+ """
164
+ client = self.get_client()
165
+ data = None
166
+ try:
167
+ data = client.files.download(file=file)
168
+ except Exception:
169
+ pass
170
+ if not data:
171
+ return False
172
+ # google-genai returns bytes
173
+ try:
174
+ with open(path, "wb") as f:
175
+ f.write(data if isinstance(data, (bytes, bytearray)) else bytes(data))
176
+ return True
177
+ except Exception:
178
+ return False
179
+
180
+ def download_to_dir(self, file: Union[str, Any], prefer_name: Optional[str] = None) -> Optional[str]:
181
+ """
182
+ Download a Files API item into configured download directory.
183
+
184
+ :param file: file name ('files/...'), file object, or file URI
185
+ :param prefer_name: optional preferred filename
186
+ :return: saved path or None
187
+ """
188
+ dir_path = self._download_dir()
189
+ filename = None
190
+
191
+ # Try to resolve filename from metadata
192
+ file_meta = None
193
+ try:
194
+ name = None
195
+ if isinstance(file, str) and file.startswith("files/"):
196
+ name = file
197
+ elif hasattr(file, "name"):
198
+ name = getattr(file, "name", None)
199
+
200
+ if name:
201
+ file_meta = self.get_file(name)
202
+ except Exception:
203
+ file_meta = None
204
+
205
+ if prefer_name and isinstance(prefer_name, str):
206
+ filename = os.path.basename(prefer_name)
207
+
208
+ if not filename:
209
+ filename = self._guess_filename(file_meta if file_meta is not None else file)
210
+
211
+ # Infer extension from mime, if missing
212
+ if not os.path.splitext(filename)[1] and file_meta is not None:
213
+ try:
214
+ mime = getattr(file_meta, "mime_type", None)
215
+ if isinstance(file_meta, dict):
216
+ mime = file_meta.get("mime_type", mime)
217
+ if mime:
218
+ ext = mimetypes.guess_extension(mime) or ""
219
+ if ext:
220
+ filename = filename + ext
221
+ except Exception:
222
+ pass
223
+
224
+ path = self._ensure_unique_path(dir_path, filename)
225
+ if self.download(file, path):
226
+ return path
227
+ return None
228
+
108
229
  def get_files_ids_all(
109
230
  self,
110
231
  items: list,
@@ -377,7 +498,7 @@ class Store:
377
498
  for doc_name in files:
378
499
  self.log("Removing document from store [{}]:{} ".format(store_id, doc_name))
379
500
  self.delete_store_file(store_id, doc_name)
380
- num += 1
501
+ num += 1
381
502
  return num
382
503
 
383
504
  def remove_all(self, callback: Optional[callable] = None) -> int:
@@ -93,6 +93,43 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
93
93
  except Exception:
94
94
  pass
95
95
 
96
+ def _try_download_uri(uri: Optional[str], prefer_name: Optional[str] = None) -> Optional[str]:
97
+ """
98
+ Attempt to download a Files API URI via store; return local path or None.
99
+ """
100
+ if not isinstance(uri, str) or not uri:
101
+ return None
102
+ try:
103
+ path = core.api.google.store.download_to_dir(uri, prefer_name=prefer_name)
104
+ return path
105
+ except Exception:
106
+ return None
107
+
108
+ def _append_downloaded(paths):
109
+ if not paths:
110
+ return
111
+ try:
112
+ loc = core.filesystem.make_local_list(paths)
113
+ except Exception:
114
+ loc = paths
115
+ if not isinstance(ctx.files, list):
116
+ ctx.files = []
117
+ for p in loc:
118
+ if p not in ctx.files:
119
+ ctx.files.append(p)
120
+ # images
121
+ imgs = []
122
+ for p in loc:
123
+ ext = p.lower().rsplit(".", 1)[-1] if "." in p else ""
124
+ if ext in ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]:
125
+ imgs.append(p)
126
+ if imgs:
127
+ if not isinstance(ctx.images, list):
128
+ ctx.images = []
129
+ for p in imgs:
130
+ if p not in ctx.images:
131
+ ctx.images.append(p)
132
+
96
133
  # Collect function calls from Responses API style stream
97
134
  if fc_list:
98
135
  for fc in fc_list:
@@ -114,6 +151,23 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
114
151
  content = getattr(cand, "content", None)
115
152
  parts = getattr(content, "parts", None) or []
116
153
  for p in parts:
154
+ # Download Files API file_data parts if present
155
+ try:
156
+ fdata = getattr(p, "file_data", None)
157
+ if fdata:
158
+ uri = getattr(fdata, "file_uri", None) or getattr(fdata, "uri", None)
159
+ name = getattr(fdata, "file_name", None) or getattr(fdata, "display_name", None)
160
+ if uri and isinstance(uri, str):
161
+ if not hasattr(state, "google_downloaded_uris"):
162
+ state.google_downloaded_uris = set()
163
+ if uri not in state.google_downloaded_uris:
164
+ save = _try_download_uri(uri, name)
165
+ if save:
166
+ _append_downloaded([save])
167
+ state.google_downloaded_uris.add(uri)
168
+ except Exception:
169
+ pass
170
+
117
171
  fn = getattr(p, "function_call", None)
118
172
  if not fn:
119
173
  continue
@@ -132,7 +186,6 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
132
186
  pass
133
187
 
134
188
  # Interactions API / Deep Research: collect streaming deltas and metadata
135
- # Handles event_type, event_id, interaction.start/complete/status_update, and content.delta variants
136
189
  try:
137
190
  event_type = _get(chunk, "event_type", None)
138
191
  if event_type:
@@ -215,10 +268,8 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
215
268
  content_obj = _get(delta, "content", None)
216
269
  thought_txt = None
217
270
  if content_obj is not None:
218
- # TextContent path
219
271
  thought_txt = _get(content_obj, "text", None)
220
272
  if thought_txt is None:
221
- # Some SDKs expose 'thought' or 'content.text' differently
222
273
  thought_txt = _get(delta, "thought", None)
223
274
  if thought_txt:
224
275
  _ensure_list_attr(state, "google_thought_summaries")
@@ -252,7 +303,6 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
252
303
 
253
304
  # Function result delta (optional store)
254
305
  elif delta_type == "function_result":
255
- # Can be used to log tool results; not altering UI text
256
306
  _ensure_list_attr(state, "google_function_results")
257
307
  try:
258
308
  state.google_function_results.append(_to_plain_dict(delta))
@@ -269,7 +319,6 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
269
319
  else:
270
320
  response_parts.append(str(code_txt))
271
321
  elif delta_type == "code_execution_result":
272
- # Close code block; keep output logging internal if needed
273
322
  if state.is_code:
274
323
  response_parts.append("\n\n```\n-----------\n")
275
324
  state.is_code = False
@@ -282,7 +331,7 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
282
331
  # Images in stream
283
332
  elif delta_type == "image":
284
333
  # ImageDelta may contain base64 data or uri
285
- mime = (_get(delta, "mime_type", "") or "").lower()
334
+ mime = _get(delta, "mime_type", None)
286
335
  data_b64 = _get(delta, "data", None)
287
336
  uri = _get(delta, "uri", None)
288
337
  if data_b64:
@@ -299,12 +348,17 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
299
348
  except Exception:
300
349
  pass
301
350
  elif uri:
302
- try:
303
- if not hasattr(ctx, "urls") or ctx.urls is None:
304
- ctx.urls = []
305
- ctx.urls.append(uri)
306
- except Exception:
307
- pass
351
+ # Try to download Files API content when URI is a file ref
352
+ save = _try_download_uri(uri)
353
+ if save:
354
+ _append_downloaded([save])
355
+ else:
356
+ try:
357
+ if not hasattr(ctx, "urls") or ctx.urls is None:
358
+ ctx.urls = []
359
+ ctx.urls.append(uri)
360
+ except Exception:
361
+ pass
308
362
 
309
363
  # URL context call/result (Deep Research tool)
310
364
  elif delta_type == "url_context_call":
@@ -368,7 +422,6 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
368
422
  except Exception:
369
423
  pass
370
424
 
371
- # Thought signature delta (optional, store)
372
425
  elif delta_type == "thought_signature":
373
426
  _ensure_list_attr(state, "google_thought_signatures")
374
427
  try:
@@ -380,12 +433,16 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
380
433
  elif delta_type in ("audio", "video", "document"):
381
434
  uri = _get(delta, "uri", None)
382
435
  if uri:
383
- try:
384
- if not hasattr(ctx, "urls") or ctx.urls is None:
385
- ctx.urls = []
386
- ctx.urls.append(uri)
387
- except Exception:
388
- pass
436
+ save = _try_download_uri(uri)
437
+ if save:
438
+ _append_downloaded([save])
439
+ else:
440
+ try:
441
+ if not hasattr(ctx, "urls") or ctx.urls is None:
442
+ ctx.urls = []
443
+ ctx.urls.append(uri)
444
+ except Exception:
445
+ pass
389
446
 
390
447
  except Exception:
391
448
  pass
@@ -459,11 +516,21 @@ def process_google_chunk(ctx, core, state, chunk) -> Optional[str]:
459
516
  fdata = getattr(p, "file_data", None)
460
517
  if fdata:
461
518
  uri = getattr(fdata, "file_uri", None) or getattr(fdata, "uri", None)
462
- mime = (getattr(fdata, "mime_type", "") or "").lower()
463
- if uri and mime.startswith("image/") and (uri.startswith("http://") or uri.startswith("https://")):
464
- if ctx.urls is None:
465
- ctx.urls = []
466
- ctx.urls.append(uri)
519
+ prefer = getattr(fdata, "file_name", None) or getattr(fdata, "display_name", None)
520
+ if uri:
521
+ if not hasattr(state, "google_downloaded_uris"):
522
+ state.google_downloaded_uris = set()
523
+ if uri not in state.google_downloaded_uris:
524
+ save = _try_download_uri(uri, prefer)
525
+ if save:
526
+ _append_downloaded([save])
527
+ state.google_downloaded_uris.add(uri)
528
+ # keep original behavior for image http links
529
+ mime = (getattr(fdata, "mime_type", "") or "").lower()
530
+ if uri.startswith(("http://", "https://")) and mime.startswith("image/"):
531
+ if ctx.urls is None:
532
+ ctx.urls = []
533
+ ctx.urls.append(uri)
467
534
 
468
535
  collect_google_citations(ctx, state, chunk)
469
536