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
@@ -0,0 +1,610 @@
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: 2026.01.06 06:00:00 #
10
+ # ================================================== #
11
+
12
+ import os
13
+ import time
14
+ import mimetypes
15
+ from typing import Optional, List, Dict, Any
16
+
17
+ from pygpt_net.item.store import RemoteStoreItem
18
+ from .worker.importer import Importer
19
+
20
+
21
+ class Store:
22
+ """
23
+ xAI API wrapper.
24
+
25
+ Notes:
26
+ - Existing Files API methods are kept intact for backward compatibility.
27
+ - New Collections API helpers are added with suffix "_collections".
28
+ The xAI Importer and Core use only the Collections methods.
29
+ """
30
+
31
+ def __init__(self, window=None):
32
+ """
33
+ xAI API store wrapper
34
+
35
+ :param window: Window instance
36
+ """
37
+ self.window = window
38
+ self.importer = Importer(window)
39
+
40
+ # -----------------------------
41
+ # Common helpers
42
+ # -----------------------------
43
+
44
+ def get_client(self):
45
+ """
46
+ Get xAI client (xai_sdk.Client or OpenAI-compatible client).
47
+ Requires management_api_key.
48
+ """
49
+ management_api_key = self.window.core.config.get("api_key_management_xai")
50
+ if management_api_key:
51
+ return self.window.core.api.xai.get_client(management_api_key=management_api_key)
52
+ return self.window.core.api.xai.get_client()
53
+
54
+ def log(self, msg: str, callback: Optional[callable] = None):
55
+ if callback is not None:
56
+ callback(msg)
57
+ else:
58
+ print(msg)
59
+
60
+ def _download_dir(self) -> str:
61
+ """
62
+ Resolve target download directory (uses download.dir if set).
63
+ """
64
+ if self.window.core.config.has("download.dir") and self.window.core.config.get("download.dir") != "":
65
+ dir_path = os.path.join(
66
+ self.window.core.config.get_user_dir('data'),
67
+ self.window.core.config.get("download.dir"),
68
+ )
69
+ else:
70
+ dir_path = self.window.core.config.get_user_dir('data')
71
+ os.makedirs(dir_path, exist_ok=True)
72
+ return dir_path
73
+
74
+ def _ensure_unique_path(self, dir_path: str, filename: str) -> str:
75
+ """
76
+ Ensure unique filename in dir, add timestamp prefix if exists.
77
+ """
78
+ path = os.path.join(dir_path, filename)
79
+ if os.path.exists(path):
80
+ prefix = time.strftime("%Y%m%d_%H%M%S_")
81
+ path = os.path.join(dir_path, f"{prefix}{filename}")
82
+ return path
83
+
84
+ def _extract_list(self, obj: Any, prefer: Optional[List[str]] = None) -> List[Any]:
85
+ """
86
+ Extract list-like data from xAI SDK responses.
87
+
88
+ Strategy:
89
+ - Check well-known attributes in preferred order (e.g. 'collections', 'documents', 'data', 'items', 'results')
90
+ - Accept dicts with those keys
91
+ - Accept already-iterables (list/tuple)
92
+ - Fallback to empty list
93
+ """
94
+ if obj is None:
95
+ return []
96
+
97
+ if isinstance(obj, (list, tuple)):
98
+ return list(obj)
99
+
100
+ attrs = prefer or []
101
+ # Append common candidates, keeping stable priority
102
+ for a in ['collections', 'documents', 'data', 'items', 'results']:
103
+ if a not in attrs:
104
+ attrs.append(a)
105
+
106
+ for a in attrs:
107
+ try:
108
+ val = getattr(obj, a, None)
109
+ except Exception:
110
+ val = None
111
+ if val is not None:
112
+ if isinstance(val, (list, tuple)):
113
+ return list(val)
114
+ try:
115
+ return list(val) # handle iterables
116
+ except TypeError:
117
+ pass
118
+
119
+ if isinstance(obj, dict):
120
+ for a in attrs:
121
+ if a in obj and isinstance(obj[a], (list, tuple)):
122
+ return list(obj[a])
123
+ # As last resort, try values
124
+ try:
125
+ return list(obj.values())
126
+ except Exception:
127
+ pass
128
+
129
+ try:
130
+ return list(obj)
131
+ except TypeError:
132
+ return []
133
+
134
+ # -----------------------------
135
+ # Files service (kept)
136
+ # -----------------------------
137
+
138
+ def get_file(self, file_id: str):
139
+ client = self.get_client()
140
+ # Try SDK variants
141
+ if hasattr(client, "files"):
142
+ try:
143
+ return client.files.get(file_id)
144
+ except Exception:
145
+ try:
146
+ return client.files.retrieve(file_id)
147
+ except Exception:
148
+ return client.files.info(file_id)
149
+
150
+ def upload(self, path: str):
151
+ """
152
+ Upload file to xAI Files API.
153
+
154
+ :param path: file path
155
+ :return: file object or None
156
+ """
157
+ client = self.get_client()
158
+ if not os.path.exists(path):
159
+ return None
160
+
161
+ # Prefer SDK upload helper supporting file path / bytes / file object
162
+ try:
163
+ return client.files.upload(path)
164
+ except Exception:
165
+ pass
166
+
167
+ # Fallbacks
168
+ with open(path, "rb") as f:
169
+ data = f.read()
170
+ try:
171
+ return client.files.upload(data, filename=os.path.basename(path))
172
+ except Exception:
173
+ try:
174
+ return client.files.upload(open(path, "rb"), filename=os.path.basename(path))
175
+ except Exception:
176
+ return None
177
+
178
+ def delete_file(self, file_id: str) -> Optional[str]:
179
+ client = self.get_client()
180
+ res = None
181
+ try:
182
+ res = client.files.delete(file_id)
183
+ except Exception:
184
+ try:
185
+ res = client.files.delete(id=file_id)
186
+ except Exception:
187
+ pass
188
+ if res is not None:
189
+ try:
190
+ return getattr(res, "id", file_id)
191
+ except Exception:
192
+ return file_id
193
+
194
+ def download(self, file_id: str, path: str) -> bool:
195
+ """
196
+ Download a file contents to a local path.
197
+
198
+ :param file_id: xAI file id
199
+ :param path: target path
200
+ :return: True on success
201
+ """
202
+ client = self.get_client()
203
+ data = None
204
+ try:
205
+ data = client.files.content(file_id)
206
+ except Exception:
207
+ data = None
208
+
209
+ if data is None:
210
+ try:
211
+ content = client.files.content(file_id)
212
+ data = content if isinstance(content, (bytes, bytearray)) else None
213
+ except Exception:
214
+ data = None
215
+
216
+ if data is None:
217
+ return False
218
+
219
+ try:
220
+ with open(path, "wb") as f:
221
+ f.write(data if isinstance(data, (bytes, bytearray)) else bytes(data))
222
+ return True
223
+ except Exception:
224
+ return False
225
+
226
+ def download_to_dir(self, file_id: str, prefer_name: Optional[str] = None) -> Optional[str]:
227
+ """
228
+ Download a file by ID into configured download directory.
229
+
230
+ :param file_id: xAI file id
231
+ :param prefer_name: optional preferred filename
232
+ :return: saved path or None
233
+ """
234
+ dir_path = self._download_dir()
235
+ filename = None
236
+
237
+ if prefer_name:
238
+ filename = os.path.basename(prefer_name)
239
+
240
+ meta = None
241
+ if not filename:
242
+ try:
243
+ meta = self.get_file(file_id)
244
+ except Exception:
245
+ meta = None
246
+
247
+ if meta is not None and not filename:
248
+ for attr in ("filename", "name", "id"):
249
+ try:
250
+ val = getattr(meta, attr, None)
251
+ if isinstance(val, str) and val:
252
+ filename = os.path.basename(val)
253
+ break
254
+ except Exception:
255
+ pass
256
+
257
+ if not filename:
258
+ filename = file_id
259
+
260
+ if not os.path.splitext(filename)[1] and meta is not None:
261
+ try:
262
+ mime = getattr(meta, "mime_type", None) or getattr(meta, "type", None)
263
+ ext = mimetypes.guess_extension(mime or "") or ""
264
+ if ext and not filename.endswith(ext):
265
+ filename = filename + ext
266
+ except Exception:
267
+ pass
268
+
269
+ path = self._ensure_unique_path(dir_path, filename)
270
+ if self.download(file_id, path):
271
+ return path
272
+ return None
273
+
274
+ def get_files_ids(self, limit: int = 1000) -> List[str]:
275
+ client = self.get_client()
276
+ items = []
277
+ try:
278
+ pager = client.files.list(limit=limit)
279
+ except Exception:
280
+ pager = client.files.list()
281
+ data = self._extract_list(pager, prefer=['data'])
282
+ for f in data:
283
+ fid = getattr(f, "id", None) or getattr(f, "name", None)
284
+ if fid and fid not in items:
285
+ items.append(fid)
286
+ return items
287
+
288
+ def remove_files(self, callback: Optional[callable] = None) -> int:
289
+ num = 0
290
+ files = self.get_files_ids()
291
+ for file_id in files:
292
+ self.log("Removing file: " + file_id, callback)
293
+ try:
294
+ res = self.delete_file(file_id)
295
+ if res:
296
+ num += 1
297
+ except Exception as e:
298
+ msg = "Error removing file {}: {}".format(file_id, str(e))
299
+ self.log(msg, callback)
300
+ return num
301
+
302
+ def remove_file(self, file_id: str, callback: Optional[callable] = None) -> bool:
303
+ self.log("Removing file: " + file_id, callback)
304
+ try:
305
+ res = self.delete_file(file_id)
306
+ return res is not None
307
+ except Exception as e:
308
+ msg = "Error removing file {}: {}".format(file_id, str(e))
309
+ self.log(msg, callback)
310
+ raise
311
+
312
+ def import_files(self, callback: Optional[callable] = None) -> int:
313
+ """
314
+ Import all Files-API files into local DB (workspace-level).
315
+ """
316
+ client = self.get_client()
317
+ total = 0
318
+ try:
319
+ pager = client.files.list(limit=1000)
320
+ except Exception:
321
+ pager = client.files.list()
322
+ data = self._extract_list(pager, prefer=['data'])
323
+ for f in data:
324
+ try:
325
+ self.window.core.remote_store.xai.files.insert("files", f)
326
+ total += 1
327
+ except Exception as e:
328
+ self.log("Error importing file {}: {}".format(getattr(f, "id", "?"), e), callback)
329
+ return total
330
+
331
+ def get_files_stats(self) -> Dict[str, Any]:
332
+ client = self.get_client()
333
+ count = 0
334
+ total_bytes = 0
335
+ try:
336
+ pager = client.files.list(limit=1000)
337
+ except Exception:
338
+ pager = client.files.list()
339
+ data = self._extract_list(pager, prefer=['data'])
340
+ for f in data:
341
+ count += 1
342
+ try:
343
+ size = getattr(f, "size_bytes", None)
344
+ if size is None and hasattr(f, "size"):
345
+ size = getattr(f, "size")
346
+ total_bytes += int(size or 0)
347
+ except Exception:
348
+ pass
349
+ return {"count": count, "total_bytes": total_bytes}
350
+
351
+ # -----------------------------
352
+ # Collections service (new)
353
+ # -----------------------------
354
+
355
+ # Collections: management
356
+
357
+ def create_collection_collections(self, name: str):
358
+ client = self.get_client()
359
+ return client.collections.create(name=name)
360
+
361
+ def update_collection_collections(self, id: str, name: Optional[str] = None):
362
+ client = self.get_client()
363
+ return client.collections.update(id, name=name) if name is not None else client.collections.get(id)
364
+
365
+ def get_collection_collections(self, id: str):
366
+ client = self.get_client()
367
+ return client.collections.get(id)
368
+
369
+ def remove_collection_collections(self, id: str):
370
+ client = self.get_client()
371
+ return client.collections.delete(collection_id=id)
372
+
373
+ def get_collections_ids_collections(self, items: list, limit: int = 100, after: Optional[str] = None) -> list:
374
+ client = self.get_client()
375
+ cols = client.collections.list()
376
+ data = self._extract_list(cols, prefer=['collections', 'data'])
377
+ for c in data:
378
+ cid = getattr(c, "collection_id", None) or (c.get("collection_id") if isinstance(c, dict) else None)
379
+ if cid and cid not in items:
380
+ items.append(cid)
381
+ return items
382
+
383
+ def import_collections_collections(
384
+ self,
385
+ items: Dict[str, RemoteStoreItem],
386
+ callback: Optional[callable] = None
387
+ ) -> Dict[str, RemoteStoreItem]:
388
+ """
389
+ Import Collections into RemoteStoreItem map.
390
+ """
391
+ client = self.get_client()
392
+ cols = client.collections.list()
393
+ data = self._extract_list(cols, prefer=['collections', 'data'])
394
+ for c in data:
395
+ cid = getattr(c, "collection_id", None) or (c.get("collection_id") if isinstance(c, dict) else None)
396
+ if not cid:
397
+ continue
398
+ if cid not in items:
399
+ items[cid] = RemoteStoreItem()
400
+ name = getattr(c, "collection_name", None)
401
+ if name is None and isinstance(c, dict):
402
+ name = c.get("collection_name")
403
+ name = name or ""
404
+ items[cid].id = cid
405
+ items[cid].name = name
406
+ items[cid].file_ids = []
407
+ items[cid].status = {
408
+ "status": "ready",
409
+ "remote_display_name": name,
410
+ }
411
+ items[cid].provider = "xai"
412
+ status, _ = self.window.core.remote_store.xai.get_status_data(cid)
413
+ self.window.core.remote_store.xai.append_status(items[cid], status)
414
+ self.log("Imported collection: " + cid, callback)
415
+ return items
416
+
417
+ def remove_all_collections_collections(self, callback: Optional[callable] = None) -> int:
418
+ """
419
+ Delete all collections.
420
+ """
421
+ num = 0
422
+ ids = self.get_collections_ids_collections([])
423
+ for cid in ids:
424
+ self.log("Removing collection: " + cid, callback)
425
+ try:
426
+ self.remove_collection_collections(cid)
427
+ num += 1
428
+ except Exception as e:
429
+ self.log("Error removing collection {}: {}".format(cid, e), callback)
430
+ return num
431
+
432
+ # Collections: documents membership
433
+
434
+ def add_file_to_collection_collections(self, collection_id: str, file_id: str):
435
+ client = self.get_client()
436
+ try:
437
+ return client.collections.add_document(collection_id=collection_id, file_id=file_id)
438
+ except Exception:
439
+ return client.collections.add_existing_document(collection_id=collection_id, file_id=file_id)
440
+
441
+ def delete_collection_file_collections(self, collection_id: str, file_id: str):
442
+ client = self.get_client()
443
+ return client.collections.remove_document(collection_id=collection_id, file_id=file_id)
444
+
445
+ def remove_store_file(self, store_id: str, file_id: str) -> bool:
446
+ """
447
+ Unified hook used by UI: remove a file from a specific 'store' (collection in xAI).
448
+ """
449
+ try:
450
+ res = self.delete_collection_file_collections(store_id, file_id)
451
+ return res is not None
452
+ except Exception:
453
+ return False
454
+
455
+ def list_collection_documents_raw_(self, collection_id: str, limit: int = 1000) -> List[Any]:
456
+ """
457
+ Internal: list documents of a collection with various SDK method shapes.
458
+ """
459
+ client = self.get_client()
460
+
461
+ # Variant 1: simple list method
462
+ try:
463
+ docs = client.collections.list_documents(collection_id=collection_id, limit=limit)
464
+ return self._extract_list(docs, prefer=['documents', 'data'])
465
+ except Exception:
466
+ pass
467
+
468
+ # Variant 2: nested resource .documents.list(...)
469
+ try:
470
+ docs = client.collections.documents.list(collection_id=collection_id, limit=limit)
471
+ return self._extract_list(docs, prefer=['documents', 'data'])
472
+ except Exception:
473
+ pass
474
+
475
+ # Variant 3: object + list()
476
+ try:
477
+ docs_obj = client.collections.documents(collection_id)
478
+ docs = docs_obj.list(limit=limit)
479
+ return self._extract_list(docs, prefer=['documents', 'data'])
480
+ except Exception:
481
+ pass
482
+
483
+ return []
484
+
485
+ def get_collection_files_ids_collections(self, collection_id: str, items: list, limit: int = 1000) -> list:
486
+ """
487
+ Return document file IDs for a collection.
488
+ """
489
+ data = self.list_collection_documents_raw_(collection_id, limit=limit)
490
+ for d in data:
491
+ fid = d.file_metadata.file_id
492
+ if fid and fid not in items:
493
+ items.append(fid)
494
+ return items
495
+
496
+ def remove_from_collection_collections(self, collection_id: str, callback: Optional[callable] = None) -> int:
497
+ """
498
+ Remove all documents from a specific collection.
499
+ """
500
+ num = 0
501
+ files = self.get_collection_files_ids_collections(collection_id, [])
502
+ for file_id in files:
503
+ self.log("Removing document from collection [{}]: {}".format(collection_id, file_id), callback)
504
+ try:
505
+ self.delete_collection_file_collections(collection_id, file_id)
506
+ num += 1
507
+ except Exception as e:
508
+ self.log("Error removing document {} from collection {}: {}".format(file_id, collection_id, e), callback)
509
+ return num
510
+
511
+ def remove_from_collections_collections(self, callback: Optional[callable] = None) -> int:
512
+ """
513
+ Remove all documents from all collections.
514
+ """
515
+ num = 0
516
+ col_ids = self.get_collections_ids_collections([])
517
+ for cid in col_ids:
518
+ num += self.remove_from_collection_collections(cid, callback=callback)
519
+ return num
520
+
521
+ # Collections: import / upload
522
+
523
+ def import_collection_files_collections(
524
+ self,
525
+ collection_id: str,
526
+ items: list,
527
+ limit: int = 1000,
528
+ callback: Optional[callable] = None
529
+ ) -> list:
530
+ """
531
+ Import and get all collection document IDs.
532
+ """
533
+ docs = self.list_collection_documents_raw_(collection_id, limit=limit)
534
+ for d in docs:
535
+ try:
536
+ fid = d.file_metadata.file_id
537
+ if not fid:
538
+ continue
539
+ if fid not in items:
540
+ items.append(fid)
541
+ data = self.get_file(fid)
542
+ self.window.core.remote_store.xai.files.insert(collection_id, data)
543
+ self.log("Imported file ID {} to collection {}".format(fid, collection_id), callback)
544
+ except Exception as e:
545
+ self.log("Error importing file {} to collection {}: {}".format(getattr(d, "id", "?"), collection_id, e), callback)
546
+ return items
547
+
548
+ def import_collections_files_collections(self, callback: Optional[callable] = None) -> int:
549
+ """
550
+ Import all documents for all collections into DB.
551
+ """
552
+ col_ids = self.get_collections_ids_collections([])
553
+ total = 0
554
+ for cid in col_ids:
555
+ items = []
556
+ try:
557
+ items = self.import_collection_files_collections(cid, items, callback=callback)
558
+ except Exception as e:
559
+ self.log("Error importing collection {} files: {}".format(cid, e), callback)
560
+ total += len(items)
561
+ return total
562
+
563
+ def upload_to_collection_collections(
564
+ self,
565
+ collection_id: str,
566
+ path: str,
567
+ fields: Optional[Dict[str, str]] = None
568
+ ):
569
+ """
570
+ Upload a file and attach to a collection (single-step helper).
571
+ """
572
+ client = self.get_client()
573
+ if not os.path.exists(path):
574
+ return None
575
+
576
+ content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
577
+ with open(path, "rb") as f:
578
+ data = f.read()
579
+
580
+ return client.collections.upload_document(
581
+ collection_id=collection_id,
582
+ name=os.path.basename(path),
583
+ data=data,
584
+ # content_type=content_type, # TODO: check if supported???
585
+ fields=fields or None,
586
+ )
587
+
588
+ def get_collection_stats_collections(self, collection_id: str) -> Dict[str, Any]:
589
+ """
590
+ Compute simple stats for a collection: total docs and total bytes.
591
+ """
592
+ count = 0
593
+ total_bytes = 0
594
+ docs = self.list_collection_documents_raw_(collection_id, limit=1000)
595
+ for d in docs:
596
+ count += 1
597
+ try:
598
+ fid = getattr(d, "id", None) or getattr(d, "file_id", None)
599
+ if not fid and isinstance(d, dict):
600
+ fid = d.get("id") or d.get("file_id")
601
+ meta = self.get_file(fid) if fid else None
602
+ size = None
603
+ if meta is not None:
604
+ size = getattr(meta, "size_bytes", None)
605
+ if size is None and hasattr(meta, "size"):
606
+ size = getattr(meta, "size")
607
+ total_bytes += int(size or 0)
608
+ except Exception:
609
+ pass
610
+ return {"count": count, "total_bytes": total_bytes}
@@ -69,10 +69,8 @@ def _extract_http_urls_from_text(text: Optional[str]) -> List[str]:
69
69
  """
70
70
  if not text or not isinstance(text, str):
71
71
  return []
72
- # Basic, conservative URL regex
73
72
  pattern = re.compile(r"(https?://[^\s)>\]\"']+)", re.IGNORECASE)
74
73
  urls = pattern.findall(text)
75
- # Deduplicate while preserving order
76
74
  out, seen = [], set()
77
75
  for u in urls:
78
76
  if u not in seen:
@@ -134,6 +132,7 @@ def _process_message_content_for_outputs(core, ctx, state, content):
134
132
  - If image_url.url is data:... -> save to file and append to state.image_paths + ctx.images
135
133
  - If image_url.url is http(s) -> append to ctx.urls
136
134
  - Extract URLs from adjacent text parts conservatively
135
+ - If file part present -> auto-download via Files API
137
136
  """
138
137
  if not isinstance(content, list):
139
138
  return
@@ -162,7 +161,28 @@ def _process_message_content_for_outputs(core, ctx, state, content):
162
161
  urls = _extract_http_urls_from_text(t)
163
162
  if urls:
164
163
  _append_urls(ctx, state, urls)
165
- # If images were added, mark flag similarly to Google path
164
+ elif ptype == "file":
165
+ fid = p.get("id") or p.get("file_id")
166
+ if isinstance(fid, str):
167
+ if not hasattr(state, "xai_downloaded_file_ids"):
168
+ state.xai_downloaded_file_ids = set()
169
+ if fid not in state.xai_downloaded_file_ids:
170
+ try:
171
+ path = core.api.xai.store.download_to_dir(fid)
172
+ except Exception:
173
+ path = None
174
+ if path:
175
+ if not isinstance(ctx.files, list):
176
+ ctx.files = []
177
+ if path not in ctx.files:
178
+ ctx.files.append(path)
179
+ ext = path.lower().rsplit(".", 1)[-1] if "." in path else ""
180
+ if ext in ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]:
181
+ if not isinstance(ctx.images, list):
182
+ ctx.images = []
183
+ if path not in ctx.images:
184
+ ctx.images.append(path)
185
+ state.xai_downloaded_file_ids.add(fid)
166
186
  if any_image:
167
187
  try:
168
188
  state.has_xai_inline_image = True
@@ -364,7 +384,6 @@ def process_xai_sdk_chunk(ctx, core, state, item) -> Optional[str]:
364
384
  if hasattr(chunk, "content"):
365
385
  t = _stringify_content(getattr(chunk, "content"))
366
386
  if t:
367
- # collect URLs from text content conservatively
368
387
  _append_urls(ctx, state, _extract_http_urls_from_text(t))
369
388
  return str(t)
370
389
  except Exception:
@@ -425,11 +444,8 @@ def process_xai_sdk_chunk(ctx, core, state, item) -> Optional[str]:
425
444
  if "content" in m and m["content"] is not None:
426
445
  mc = m["content"]
427
446
  # inspect for image_url outputs and URLs
428
- _process_message_content_for_outputs(core, ctx, state, mc if isinstance(mc, list) else [])
429
- if isinstance(mc, str):
430
- _append_urls(ctx, state, _extract_http_urls_from_text(mc))
431
- return mc
432
- elif isinstance(mc, list):
447
+ if isinstance(mc, list):
448
+ _process_message_content_for_outputs(core, ctx, state, mc)
433
449
  out_parts: List[str] = []
434
450
  for p in mc:
435
451
  if isinstance(p, dict) and p.get("type") == "text":
@@ -440,6 +456,11 @@ def process_xai_sdk_chunk(ctx, core, state, item) -> Optional[str]:
440
456
  txt = "".join(out_parts)
441
457
  _append_urls(ctx, state, _extract_http_urls_from_text(txt))
442
458
  return txt
459
+ else:
460
+ t = _stringify_content(mc)
461
+ if t:
462
+ _append_urls(ctx, state, _extract_http_urls_from_text(t))
463
+ return str(t)
443
464
 
444
465
  # root-level delta/message
445
466
  if isinstance(chunk.get("delta"), dict) and "content" in chunk["delta"]: