OpenRAG 0.5.0.dev30__py3-none-any.whl → 0.5.0.dev32__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.
api/settings.py CHANGED
@@ -1940,6 +1940,10 @@ async def rollback_onboarding(
1940
1940
  current_config.knowledge.embedding_model = ""
1941
1941
  current_config.onboarding.openrag_docs_ingested_version = None
1942
1942
  current_config.onboarding.openrag_docs_remote_signature = None
1943
+ current_config.onboarding.assistant_message = None
1944
+ current_config.onboarding.selected_nudge = None
1945
+ current_config.onboarding.card_steps = None
1946
+ current_config.onboarding.upload_steps = None
1943
1947
 
1944
1948
  embedding_only = body.embedding_only if body else False
1945
1949
 
@@ -3,9 +3,10 @@ import io
3
3
  import os
4
4
  import time
5
5
  from collections import deque
6
+ from collections.abc import Iterable
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Any, Dict, Iterable, List, Optional, Set
9
+ from typing import Any
9
10
 
10
11
  from googleapiclient.errors import HttpError
11
12
  from googleapiclient.http import MediaIoBaseDownload
@@ -17,6 +18,7 @@ from .oauth import GoogleDriveOAuth
17
18
 
18
19
  logger = get_logger(__name__)
19
20
 
21
+
20
22
  # -------------------------
21
23
  # Config model
22
24
  # -------------------------
@@ -27,26 +29,26 @@ class GoogleDriveConfig:
27
29
  token_file: str
28
30
 
29
31
  # Selective sync
30
- file_ids: Optional[List[str]] = None
31
- folder_ids: Optional[List[str]] = None
32
+ file_ids: list[str] | None = None
33
+ folder_ids: list[str] | None = None
32
34
  recursive: bool = True
33
35
 
34
36
  # Shared Drives control
35
- drive_id: Optional[str] = None # when set, we use corpora='drive'
36
- corpora: Optional[str] = None # 'user' | 'drive' | 'domain'; auto-picked if None
37
+ drive_id: str | None = None # when set, we use corpora='drive'
38
+ corpora: str | None = None # 'user' | 'drive' | 'domain'; auto-picked if None
37
39
 
38
40
  # Optional filtering
39
- include_mime_types: Optional[List[str]] = None
40
- exclude_mime_types: Optional[List[str]] = None
41
+ include_mime_types: list[str] | None = None
42
+ exclude_mime_types: list[str] | None = None
41
43
 
42
44
  # Export overrides for Google-native types
43
- export_format_overrides: Optional[Dict[str, str]] = None # mime -> export-mime
45
+ export_format_overrides: dict[str, str] | None = None # mime -> export-mime
44
46
 
45
47
  # Changes API state persistence (store these in your DB/kv if needed)
46
- changes_page_token: Optional[str] = None
48
+ changes_page_token: str | None = None
47
49
 
48
50
  # Optional: resource_id for webhook cleanup
49
- resource_id: Optional[str] = None
51
+ resource_id: str | None = None
50
52
 
51
53
 
52
54
  # -------------------------
@@ -89,7 +91,7 @@ class GoogleDriveConnector(BaseConnector):
89
91
  # Otherwise, implement your custom logic here.
90
92
  logger.debug(f"Emitting document: {doc.id} ({doc.filename})")
91
93
 
92
- def __init__(self, config: Dict[str, Any]) -> None:
94
+ def __init__(self, config: dict[str, Any]) -> None:
93
95
  # Read from config OR env (backend env, not NEXT_PUBLIC_*):
94
96
  env_client_id = os.getenv(self.CLIENT_ID_ENV_VAR)
95
97
  env_client_secret = os.getenv(self.CLIENT_SECRET_ENV_VAR)
@@ -99,6 +101,7 @@ class GoogleDriveConnector(BaseConnector):
99
101
 
100
102
  # Token file default - use data directory for persistence
101
103
  from config.paths import get_data_file
104
+
102
105
  token_file = config.get("token_file") or get_data_file("google_drive_token.json")
103
106
  Path(token_file).parent.mkdir(parents=True, exist_ok=True)
104
107
 
@@ -114,9 +117,7 @@ class GoogleDriveConnector(BaseConnector):
114
117
  )
115
118
 
116
119
  # Normalize incoming IDs from any of the supported alias keys
117
- def _first_present_list(
118
- cfg: Dict[str, Any], keys: Iterable[str]
119
- ) -> Optional[List[str]]:
120
+ def _first_present_list(cfg: dict[str, Any], keys: Iterable[str]) -> list[str] | None:
120
121
  for k in keys:
121
122
  v = cfg.get(k)
122
123
  if v: # accept non-empty list
@@ -153,11 +154,15 @@ class GoogleDriveConnector(BaseConnector):
153
154
  # Drive client is built in authenticate()
154
155
  from google.oauth2.credentials import Credentials
155
156
 
156
- self.creds: Optional[Credentials] = None
157
+ self.creds: Credentials | None = None
157
158
  self.service: Any = None
158
159
 
159
160
  # cache of resolved shortcutId -> target file metadata
160
- self._shortcut_cache: Dict[str, Dict[str, Any]] = {}
161
+ self._shortcut_cache: dict[str, dict[str, Any]] = {}
162
+
163
+ import threading
164
+
165
+ self._lock = threading.Lock()
161
166
 
162
167
  # Authentication state
163
168
  self._authenticated: bool = False
@@ -170,20 +175,20 @@ class GoogleDriveConnector(BaseConnector):
170
175
  self._shortcut_cache.clear()
171
176
 
172
177
  @property
173
- def _drives_get_flags(self) -> Dict[str, Any]:
178
+ def _drives_get_flags(self) -> dict[str, Any]:
174
179
  """
175
180
  Flags valid for GET-like calls (files.get, changes.getStartPageToken).
176
181
  """
177
182
  return {"supportsAllDrives": True}
178
183
 
179
184
  @property
180
- def _drives_list_flags(self) -> Dict[str, Any]:
185
+ def _drives_list_flags(self) -> dict[str, Any]:
181
186
  """
182
187
  Flags valid for LIST-like calls (files.list, changes.list).
183
188
  """
184
189
  return {"supportsAllDrives": True, "includeItemsFromAllDrives": True}
185
190
 
186
- def _pick_corpora_args(self) -> Dict[str, Any]:
191
+ def _pick_corpora_args(self) -> dict[str, Any]:
187
192
  """
188
193
  Decide corpora/driveId based on config.
189
194
 
@@ -197,7 +202,7 @@ class GoogleDriveConnector(BaseConnector):
197
202
  # Default to allDrives so Picker selections from Shared Drives work without explicit drive_id
198
203
  return {"corpora": "allDrives"}
199
204
 
200
- def _resolve_shortcut(self, file_obj: Dict[str, Any]) -> Dict[str, Any]:
205
+ def _resolve_shortcut(self, file_obj: dict[str, Any]) -> dict[str, Any]:
201
206
  """
202
207
  If a file is a shortcut, fetch and return the real target metadata.
203
208
  """
@@ -234,7 +239,7 @@ class GoogleDriveConnector(BaseConnector):
234
239
  # shortcut target not accessible
235
240
  return file_obj
236
241
 
237
- def _list_children(self, folder_id: str) -> List[Dict[str, Any]]:
242
+ def _list_children(self, folder_id: str) -> list[dict[str, Any]]:
238
243
  """
239
244
  List immediate children of a folder.
240
245
  """
@@ -245,7 +250,7 @@ class GoogleDriveConnector(BaseConnector):
245
250
 
246
251
  query = f"'{folder_id}' in parents and trashed = false"
247
252
  page_token = None
248
- results: List[Dict[str, Any]] = []
253
+ results: list[dict[str, Any]] = []
249
254
 
250
255
  while True:
251
256
  resp = (
@@ -272,13 +277,13 @@ class GoogleDriveConnector(BaseConnector):
272
277
 
273
278
  return results
274
279
 
275
- def _bfs_expand_folders(self, folder_ids: Iterable[str]) -> List[Dict[str, Any]]:
280
+ def _bfs_expand_folders(self, folder_ids: Iterable[str]) -> list[dict[str, Any]]:
276
281
  """
277
282
  Breadth-first traversal to expand folders to all descendant files (if recursive),
278
283
  or just immediate children (if not recursive). Folders themselves are returned
279
284
  as items too, but filtered later.
280
285
  """
281
- out: List[Dict[str, Any]] = []
286
+ out: list[dict[str, Any]] = []
282
287
  queue = deque(folder_ids)
283
288
 
284
289
  while queue:
@@ -295,7 +300,7 @@ class GoogleDriveConnector(BaseConnector):
295
300
 
296
301
  return out
297
302
 
298
- def _get_file_meta_by_id(self, file_id: str) -> Optional[Dict[str, Any]]:
303
+ def _get_file_meta_by_id(self, file_id: str) -> dict[str, Any] | None:
299
304
  """
300
305
  Fetch metadata for a file by ID (resolving shortcuts).
301
306
  """
@@ -304,30 +309,31 @@ class GoogleDriveConnector(BaseConnector):
304
309
  "Google Drive service is not initialized. Please authenticate first."
305
310
  )
306
311
  try:
307
- meta = (
308
- self.service.files()
309
- .get(
310
- fileId=file_id,
311
- fields=(
312
- "id, name, mimeType, modifiedTime, createdTime, size, "
313
- "webViewLink, parents, shortcutDetails, driveId"
314
- ),
315
- **self._drives_get_flags,
312
+ with self._lock:
313
+ meta = (
314
+ self.service.files()
315
+ .get(
316
+ fileId=file_id,
317
+ fields=(
318
+ "id, name, mimeType, modifiedTime, createdTime, size, "
319
+ "webViewLink, parents, shortcutDetails, driveId"
320
+ ),
321
+ **self._drives_get_flags,
322
+ )
323
+ .execute()
316
324
  )
317
- .execute()
318
- )
319
325
  return self._resolve_shortcut(meta)
320
326
  except HttpError:
321
327
  return None
322
328
 
323
- def _filter_by_mime(self, items: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
329
+ def _filter_by_mime(self, items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
324
330
  """
325
331
  Apply include/exclude mime filters if configured.
326
332
  """
327
333
  include = set(self.cfg.include_mime_types or [])
328
334
  exclude = set(self.cfg.exclude_mime_types or [])
329
335
 
330
- def keep(m: Dict[str, Any]) -> bool:
336
+ def keep(m: dict[str, Any]) -> bool:
331
337
  mt = m.get("mimeType")
332
338
  if exclude and mt in exclude:
333
339
  return False
@@ -337,7 +343,7 @@ class GoogleDriveConnector(BaseConnector):
337
343
 
338
344
  return [m for m in items if keep(m)]
339
345
 
340
- def _iter_selected_items(self) -> List[Dict[str, Any]]:
346
+ def _iter_selected_items(self) -> list[dict[str, Any]]:
341
347
  """
342
348
  Return a de-duplicated list of file metadata for the selected scope:
343
349
  - explicit file_ids (automatically expands folders to their contents)
@@ -351,16 +357,21 @@ class GoogleDriveConnector(BaseConnector):
351
357
  )
352
358
  self._clear_shortcut_cache()
353
359
 
354
- seen: Set[str] = set()
355
- items: List[Dict[str, Any]] = []
356
- folders_to_expand: List[str] = []
360
+ seen: set[str] = set()
361
+ items: list[dict[str, Any]] = []
362
+ folders_to_expand: list[str] = []
357
363
 
358
364
  if self.cfg.file_ids:
359
- logger.debug("[GoogleDrive] _iter_selected_items: processing %d file_id(s)", len(self.cfg.file_ids))
365
+ logger.debug(
366
+ "[GoogleDrive] _iter_selected_items: processing %d file_id(s)",
367
+ len(self.cfg.file_ids),
368
+ )
360
369
  for fid in self.cfg.file_ids:
361
370
  meta = self._get_file_meta_by_id(fid)
362
371
  if not meta:
363
- logger.debug("[GoogleDrive] _iter_selected_items: no metadata for file_id=%s", fid)
372
+ logger.debug(
373
+ "[GoogleDrive] _iter_selected_items: no metadata for file_id=%s", fid
374
+ )
364
375
  continue
365
376
 
366
377
  if meta.get("mimeType") == "application/vnd.google-apps.folder":
@@ -378,7 +389,9 @@ class GoogleDriveConnector(BaseConnector):
378
389
  folders_to_expand.extend(self.cfg.folder_ids)
379
390
 
380
391
  if folders_to_expand:
381
- logger.debug("[GoogleDrive] _iter_selected_items: expanding %d folder(s)", len(folders_to_expand))
392
+ logger.debug(
393
+ "[GoogleDrive] _iter_selected_items: expanding %d folder(s)", len(folders_to_expand)
394
+ )
382
395
  folder_children = self._bfs_expand_folders(folders_to_expand)
383
396
  for meta in folder_children:
384
397
  meta = self._resolve_shortcut(meta)
@@ -394,11 +407,7 @@ class GoogleDriveConnector(BaseConnector):
394
407
  return []
395
408
 
396
409
  items = self._filter_by_mime(items)
397
- items = [
398
- m
399
- for m in items
400
- if m.get("mimeType") != "application/vnd.google-apps.folder"
401
- ]
410
+ items = [m for m in items if m.get("mimeType") != "application/vnd.google-apps.folder"]
402
411
 
403
412
  if not items and (self.cfg.file_ids or self.cfg.folder_ids):
404
413
  logger.warning(
@@ -414,7 +423,7 @@ class GoogleDriveConnector(BaseConnector):
414
423
  # -------------------------
415
424
  # Download logic
416
425
  # -------------------------
417
- def _pick_export_mime(self, source_mime: str) -> Optional[str]:
426
+ def _pick_export_mime(self, source_mime: str) -> str | None:
418
427
  """
419
428
  Choose export mime for Google-native docs if needed.
420
429
  """
@@ -437,7 +446,7 @@ class GoogleDriveConnector(BaseConnector):
437
446
  # Return None for non-Google-native or unsupported types
438
447
  return overrides.get(source_mime)
439
448
 
440
- def _download_file_bytes(self, file_meta: Dict[str, Any]) -> bytes:
449
+ def _download_file_bytes(self, file_meta: dict[str, Any]) -> bytes:
441
450
  """
442
451
  Download bytes for a given file (exporting if Google-native).
443
452
  Raises ValueError if the item is a folder (folders cannot be downloaded).
@@ -478,57 +487,56 @@ class GoogleDriveConnector(BaseConnector):
478
487
  "application/vnd.google-apps.drawing", # Google Drawings
479
488
  }
480
489
 
481
- if mime_type in exportable_types:
482
- # This is an exportable Google Workspace file - must use export_media
483
- export_mime = self._pick_export_mime(mime_type)
484
- if not export_mime:
485
- # Default fallback for unsupported Google native types
486
- export_mime = "application/pdf"
490
+ with self._lock:
491
+ if mime_type in exportable_types:
492
+ # This is an exportable Google Workspace file - must use export_media
493
+ export_mime = self._pick_export_mime(mime_type)
494
+ if not export_mime:
495
+ # Default fallback for unsupported Google native types
496
+ export_mime = "application/pdf"
487
497
 
488
- logger.debug(
489
- "[GoogleDrive] _download_file_bytes: using export_media (%s -> %s)",
490
- mime_type,
491
- export_mime,
492
- )
493
- # NOTE: export_media does not accept supportsAllDrives/includeItemsFromAllDrives
494
- request = self.service.files().export_media(
495
- fileId=file_id, mimeType=export_mime
496
- )
497
- else:
498
- # This is a regular uploaded file (PDF, image, video, etc.) - use get_media
499
- # Also handles non-exportable Google Apps files (Forms, Sites, Maps, etc.)
500
- logger.debug("[GoogleDrive] _download_file_bytes: using get_media (%s)", mime_type)
501
- # Binary download (get_media also doesn't accept the Drive flags)
502
- request = self.service.files().get_media(fileId=file_id)
503
-
504
- # Download the file with error handling for misclassified Google Docs
505
- fh = io.BytesIO()
506
- downloader = MediaIoBaseDownload(fh, request, chunksize=1024 * 1024)
507
- done = False
508
-
509
- try:
510
- while not done:
511
- status, done = downloader.next_chunk()
512
- # Optional: you can log progress via status.progress()
513
- except HttpError as e:
514
- # If download fails with "fileNotDownloadable", it's a Docs Editor file
515
- # that wasn't properly detected. Retry with export_media.
516
- if "fileNotDownloadable" in str(e) and mime_type not in exportable_types:
517
- logger.warning(
518
- f"Download failed for {file_id} ({mime_type}) with fileNotDownloadable error. "
519
- f"Retrying with export_media (file might be a Google Doc)"
520
- )
521
- export_mime = "application/pdf"
522
- request = self.service.files().export_media(
523
- fileId=file_id, mimeType=export_mime
498
+ logger.debug(
499
+ "[GoogleDrive] _download_file_bytes: using export_media (%s -> %s)",
500
+ mime_type,
501
+ export_mime,
524
502
  )
525
- fh = io.BytesIO()
526
- downloader = MediaIoBaseDownload(fh, request, chunksize=1024 * 1024)
527
- done = False
503
+ # NOTE: export_media does not accept supportsAllDrives/includeItemsFromAllDrives
504
+ request = self.service.files().export_media(fileId=file_id, mimeType=export_mime)
505
+ else:
506
+ # This is a regular uploaded file (PDF, image, video, etc.) - use get_media
507
+ # Also handles non-exportable Google Apps files (Forms, Sites, Maps, etc.)
508
+ logger.debug("[GoogleDrive] _download_file_bytes: using get_media (%s)", mime_type)
509
+ # Binary download (get_media also doesn't accept the Drive flags)
510
+ request = self.service.files().get_media(fileId=file_id)
511
+
512
+ # Download the file with error handling for misclassified Google Docs
513
+ fh = io.BytesIO()
514
+ downloader = MediaIoBaseDownload(fh, request, chunksize=1024 * 1024)
515
+ done = False
516
+
517
+ try:
528
518
  while not done:
529
519
  status, done = downloader.next_chunk()
530
- else:
531
- raise
520
+ # Optional: you can log progress via status.progress()
521
+ except HttpError as e:
522
+ # If download fails with "fileNotDownloadable", it's a Docs Editor file
523
+ # that wasn't properly detected. Retry with export_media.
524
+ if "fileNotDownloadable" in str(e) and mime_type not in exportable_types:
525
+ logger.warning(
526
+ f"Download failed for {file_id} ({mime_type}) with fileNotDownloadable error. "
527
+ f"Retrying with export_media (file might be a Google Doc)"
528
+ )
529
+ export_mime = "application/pdf"
530
+ request = self.service.files().export_media(
531
+ fileId=file_id, mimeType=export_mime
532
+ )
533
+ fh = io.BytesIO()
534
+ downloader = MediaIoBaseDownload(fh, request, chunksize=1024 * 1024)
535
+ done = False
536
+ while not done:
537
+ status, done = downloader.next_chunk()
538
+ else:
539
+ raise
532
540
 
533
541
  data = fh.getvalue()
534
542
  logger.debug("[GoogleDrive] _download_file_bytes: done, %d bytes", len(data))
@@ -572,10 +580,10 @@ class GoogleDriveConnector(BaseConnector):
572
580
 
573
581
  async def list_files(
574
582
  self,
575
- page_token: Optional[str] = None,
576
- max_files: Optional[int] = None,
583
+ page_token: str | None = None,
584
+ max_files: int | None = None,
577
585
  **kwargs,
578
- ) -> Dict[str, Any]:
586
+ ) -> dict[str, Any]:
579
587
  """
580
588
  List files in the currently selected scope (file_ids/folder_ids/recursive).
581
589
  Returns a dict with 'files' and 'next_page_token'.
@@ -589,7 +597,9 @@ class GoogleDriveConnector(BaseConnector):
589
597
  "Google Drive service is not initialized. Please authenticate first."
590
598
  )
591
599
 
592
- logger.debug("[GoogleDrive] list_files: entry (page_token=%s, max_files=%s)", page_token, max_files)
600
+ logger.debug(
601
+ "[GoogleDrive] list_files: entry (page_token=%s, max_files=%s)", page_token, max_files
602
+ )
593
603
 
594
604
  try:
595
605
  items = await asyncio.to_thread(self._iter_selected_items)
@@ -609,7 +619,7 @@ class GoogleDriveConnector(BaseConnector):
609
619
  logger.error("[GoogleDrive] list_files failed: %s", e, exc_info=True)
610
620
  raise
611
621
 
612
- def _extract_google_drive_acl(self, file_meta: Dict) -> DocumentACL:
622
+ def _extract_google_drive_acl(self, file_meta: dict) -> DocumentACL:
613
623
  """
614
624
  Extract ACL from Google Drive file metadata.
615
625
 
@@ -624,10 +634,15 @@ class GoogleDriveConnector(BaseConnector):
624
634
  """
625
635
  try:
626
636
  # Fetch permissions (requires additional API call)
627
- permissions_list = self.service.permissions().list(
628
- fileId=file_meta["id"],
629
- fields="permissions(emailAddress,role,type,deleted,displayName)"
630
- ).execute()
637
+ with self._lock:
638
+ permissions_list = (
639
+ self.service.permissions()
640
+ .list(
641
+ fileId=file_meta["id"],
642
+ fields="permissions(emailAddress,role,type,deleted,displayName)",
643
+ )
644
+ .execute()
645
+ )
631
646
 
632
647
  allowed_users = []
633
648
  allowed_groups = []
@@ -730,12 +745,12 @@ class GoogleDriveConnector(BaseConnector):
730
745
  metadata={
731
746
  "parents": meta.get("parents"),
732
747
  "driveId": meta.get("driveId"),
733
- "size": int(meta.get("size", 0))
734
- if str(meta.get("size", "")).isdigit()
735
- else None,
748
+ "size": int(meta.get("size", 0)) if str(meta.get("size", "")).isdigit() else None,
736
749
  },
737
750
  )
738
- logger.debug("[GoogleDrive] get_file_content: done for file_id=%s (%d bytes)", file_id, len(blob))
751
+ logger.debug(
752
+ "[GoogleDrive] get_file_content: done for file_id=%s (%d bytes)", file_id, len(blob)
753
+ )
739
754
  return doc
740
755
 
741
756
  async def setup_subscription(self) -> str:
@@ -752,9 +767,7 @@ class GoogleDriveConnector(BaseConnector):
752
767
  # 1) Ensure we are authenticated and have a live Drive service
753
768
  ok = await self.authenticate()
754
769
  if not ok:
755
- raise RuntimeError(
756
- "GoogleDriveConnector.setup_subscription: not authenticated"
757
- )
770
+ raise RuntimeError("GoogleDriveConnector.setup_subscription: not authenticated")
758
771
 
759
772
  # 2) Resolve webhook address (no param in ABC, so pull from config/env)
760
773
  webhook_address = getattr(self.cfg, "webhook_address", None) or os.getenv(
@@ -810,9 +823,7 @@ class GoogleDriveConnector(BaseConnector):
810
823
  }
811
824
 
812
825
  if not isinstance(channel_id, str) or not channel_id:
813
- raise RuntimeError(
814
- f"Drive watch returned invalid channel id: {channel_id!r}"
815
- )
826
+ raise RuntimeError(f"Drive watch returned invalid channel id: {channel_id!r}")
816
827
 
817
828
  return channel_id
818
829
 
@@ -850,13 +861,13 @@ class GoogleDriveConnector(BaseConnector):
850
861
 
851
862
  # Single-channel memory
852
863
  if getattr(self, "_active_channel", None):
853
- ch = getattr(self, "_active_channel")
864
+ ch = self._active_channel
854
865
  if isinstance(ch, dict) and ch.get("channel_id") == subscription_id:
855
866
  resource_id = ch.get("resource_id")
856
867
 
857
868
  # Multi-channel memory
858
869
  if resource_id is None and hasattr(self, "_subscriptions"):
859
- subs = getattr(self, "_subscriptions")
870
+ subs = self._subscriptions
860
871
  if isinstance(subs, dict):
861
872
  entry = subs.get(subscription_id)
862
873
  if isinstance(entry, dict):
@@ -888,9 +899,7 @@ class GoogleDriveConnector(BaseConnector):
888
899
  ):
889
900
  self._active_channel = {}
890
901
 
891
- if hasattr(self, "_subscriptions") and isinstance(
892
- self._subscriptions, dict
893
- ):
902
+ if hasattr(self, "_subscriptions") and isinstance(self._subscriptions, dict):
894
903
  self._subscriptions.pop(subscription_id, None)
895
904
 
896
905
  return True
@@ -902,7 +911,7 @@ class GoogleDriveConnector(BaseConnector):
902
911
  pass
903
912
  return False
904
913
 
905
- async def handle_webhook(self, payload: Dict[str, Any]) -> List[str]:
914
+ async def handle_webhook(self, payload: dict[str, Any]) -> list[str]:
906
915
  """
907
916
  Process a Google Drive Changes webhook.
908
917
  Drive push notifications do NOT include the changed files themselves; they merely tell us
@@ -915,7 +924,7 @@ class GoogleDriveConnector(BaseConnector):
915
924
  Returns:
916
925
  List[str]: unique list of affected file IDs (filtered to our selected scope).
917
926
  """
918
- affected: List[str] = []
927
+ affected: list[str] = []
919
928
  try:
920
929
  # 1) Ensure we're authenticated / service ready
921
930
  ok = await self.authenticate()
@@ -941,9 +950,7 @@ class GoogleDriveConnector(BaseConnector):
941
950
  except Exception as e:
942
951
  selected_ids = set()
943
952
  try:
944
- logger.error(
945
- f"handle_webhook: scope build failed, proceeding unfiltered: {e}"
946
- )
953
+ logger.error(f"handle_webhook: scope build failed, proceeding unfiltered: {e}")
947
954
  except Exception:
948
955
  pass
949
956
 
@@ -980,11 +987,7 @@ class GoogleDriveConnector(BaseConnector):
980
987
  # Filter to our selected scope if we have one; otherwise accept all
981
988
  if selected_ids and (rid not in selected_ids):
982
989
  # Shortcut target might be in scope even if the shortcut isn't
983
- tgt = (
984
- fobj.get("shortcutDetails", {}).get("targetId")
985
- if fobj
986
- else None
987
- )
990
+ tgt = fobj.get("shortcutDetails", {}).get("targetId") if fobj else None
988
991
  if not (tgt and tgt in selected_ids):
989
992
  continue
990
993
 
@@ -1007,7 +1010,7 @@ class GoogleDriveConnector(BaseConnector):
1007
1010
 
1008
1011
  # Deduplicate while preserving order
1009
1012
  seen = set()
1010
- deduped: List[str] = []
1013
+ deduped: list[str] = []
1011
1014
  for x in affected:
1012
1015
  if x not in seen:
1013
1016
  seen.add(x)
@@ -1033,9 +1036,7 @@ class GoogleDriveConnector(BaseConnector):
1033
1036
  blob = self._download_file_bytes(meta)
1034
1037
  except HttpError as e:
1035
1038
  # Skip/record failures
1036
- logger.error(
1037
- f"Failed to download {meta.get('name')} ({meta.get('id')}): {e}"
1038
- )
1039
+ logger.error(f"Failed to download {meta.get('name')} ({meta.get('id')}): {e}")
1039
1040
  continue
1040
1041
 
1041
1042
  from datetime import datetime
@@ -1081,12 +1082,10 @@ class GoogleDriveConnector(BaseConnector):
1081
1082
  # -------------------------
1082
1083
  def get_start_page_token(self) -> str:
1083
1084
  # getStartPageToken accepts supportsAllDrives (not includeItemsFromAllDrives)
1084
- resp = (
1085
- self.service.changes().getStartPageToken(**self._drives_get_flags).execute()
1086
- )
1085
+ resp = self.service.changes().getStartPageToken(**self._drives_get_flags).execute()
1087
1086
  return resp["startPageToken"]
1088
1087
 
1089
- def poll_changes_and_sync(self) -> Optional[str]:
1088
+ def poll_changes_and_sync(self) -> str | None:
1090
1089
  """
1091
1090
  Incrementally process changes since the last page token in cfg.changes_page_token.
1092
1091
 
@@ -1122,10 +1121,7 @@ class GoogleDriveConnector(BaseConnector):
1122
1121
  # Match scope
1123
1122
  if fid not in selected_ids:
1124
1123
  # also consider shortcut target
1125
- if (
1126
- file_obj.get("mimeType")
1127
- == "application/vnd.google-apps.shortcut"
1128
- ):
1124
+ if file_obj.get("mimeType") == "application/vnd.google-apps.shortcut":
1129
1125
  tgt = file_obj.get("shortcutDetails", {}).get("targetId")
1130
1126
  if tgt and tgt in selected_ids:
1131
1127
  pass
@@ -1189,8 +1185,8 @@ class GoogleDriveConnector(BaseConnector):
1189
1185
  # Optional: webhook stubs
1190
1186
  # -------------------------
1191
1187
  def build_watch_body(
1192
- self, webhook_address: str, channel_id: Optional[str] = None
1193
- ) -> Dict[str, Any]:
1188
+ self, webhook_address: str, channel_id: str | None = None
1189
+ ) -> dict[str, Any]:
1194
1190
  """
1195
1191
  Prepare the request body for changes.watch if you use webhooks.
1196
1192
  """
@@ -1200,7 +1196,7 @@ class GoogleDriveConnector(BaseConnector):
1200
1196
  "address": webhook_address,
1201
1197
  }
1202
1198
 
1203
- def start_watch(self, webhook_address: str) -> Dict[str, Any]:
1199
+ def start_watch(self, webhook_address: str) -> dict[str, Any]:
1204
1200
  """
1205
1201
  Start a webhook watch on changes using the current page token.
1206
1202
  Persist the returned resourceId/expiration on your side.
@@ -53,9 +53,13 @@ class LangflowConnectorService:
53
53
  filename=document.filename,
54
54
  )
55
55
 
56
+ import os
57
+
56
58
  from utils.file_utils import auto_cleanup_tempfile
57
59
 
58
- suffix = get_file_extension(document.mimetype)
60
+ suffix = os.path.splitext(document.filename)[1]
61
+ if not suffix:
62
+ suffix = get_file_extension(document.mimetype)
59
63
 
60
64
  # Create temporary file from document content
61
65
  with auto_cleanup_tempfile(suffix=suffix) as tmp_path:
connectors/service.py CHANGED
@@ -52,11 +52,15 @@ class ConnectorService:
52
52
  """Process a document from a connector using existing processing pipeline"""
53
53
 
54
54
  # Create temporary file from document content
55
+ import os
56
+
55
57
  from utils.file_utils import auto_cleanup_tempfile
56
58
 
57
- with auto_cleanup_tempfile(
58
- suffix=get_file_extension(document.mimetype)
59
- ) as tmp_path:
59
+ suffix = os.path.splitext(document.filename)[1]
60
+ if not suffix:
61
+ suffix = get_file_extension(document.mimetype)
62
+
63
+ with auto_cleanup_tempfile(suffix=suffix) as tmp_path:
60
64
  # Write document content to temp file
61
65
  with open(tmp_path, "wb") as f:
62
66
  f.write(document.content)
@@ -363,6 +367,7 @@ class ConnectorService:
363
367
  original_folder_ids = getattr(connector.cfg, "folder_ids", None)
364
368
 
365
369
  expanded_file_ids = file_ids # Default to original IDs
370
+ expanded_files_info = []
366
371
 
367
372
  try:
368
373
  # Set the file_ids we want to sync in the connector's config
@@ -373,7 +378,12 @@ class ConnectorService:
373
378
  # Get the expanded list of file IDs (folders will be expanded to their contents)
374
379
  # This uses the connector's list_files() which calls _iter_selected_items()
375
380
  result = await connector.list_files()
376
- expanded_file_ids = [f["id"] for f in result.get("files", [])]
381
+ expanded_files = result.get("files", [])
382
+ expanded_file_ids = [f["id"] for f in expanded_files]
383
+
384
+ # Save the expanded files info so we can set correct names in the task UI
385
+ for f in expanded_files:
386
+ expanded_files_info.append(f)
377
387
 
378
388
  if not expanded_file_ids:
379
389
  logger.warning(
@@ -423,12 +433,15 @@ class ConnectorService:
423
433
 
424
434
  # Create custom task using TaskService
425
435
  original_filenames = {}
426
- if file_infos:
436
+
437
+ # Combine file_infos and expanded_files_info
438
+ all_infos = (file_infos or []) + expanded_files_info
439
+ if all_infos:
427
440
  original_filenames = {
428
441
  f["id"]: clean_connector_filename(
429
- f["name"], f.get("mimeType") or f.get("mimetype")
442
+ f["name"], f.get("mimeType") or f.get("mimetype", "")
430
443
  )
431
- for f in file_infos
444
+ for f in all_infos
432
445
  if "id" in f and "name" in f
433
446
  }
434
447
 
models/processors.py CHANGED
@@ -257,15 +257,16 @@ class TaskProcessor:
257
257
  file_hash=file_hash,
258
258
  )
259
259
  slim_doc = process_text_file(file_path)
260
- # Override filename with original_filename if provided
261
- if original_filename:
262
- slim_doc["filename"] = original_filename
263
260
  else:
264
261
  from utils.docling_client import convert_file
265
262
 
266
263
  full_doc = await convert_file(file_path, httpx_client=clients.docling_http_client)
267
264
  slim_doc = extract_relevant(full_doc)
268
265
 
266
+ # Override filename with original_filename if provided
267
+ if original_filename:
268
+ slim_doc["filename"] = original_filename
269
+
269
270
  if chunk_size is not None:
270
271
  try:
271
272
  cs = int(chunk_size)
@@ -527,10 +528,13 @@ class ConnectorFileProcessor(TaskProcessor):
527
528
  if not self.user_id:
528
529
  raise ValueError("user_id not provided to ConnectorFileProcessor")
529
530
 
530
- # Create temporary file from document content
531
531
  from utils.file_utils import auto_cleanup_tempfile
532
+ # Create temporary file from document content import os
533
+
534
+ suffix = os.path.splitext(document.filename)[1]
535
+ if not suffix:
536
+ suffix = get_file_extension(document.mimetype)
532
537
 
533
- suffix = get_file_extension(document.mimetype)
534
538
  with auto_cleanup_tempfile(suffix=suffix) as tmp_path:
535
539
  # Write content to temp file
536
540
  with open(tmp_path, 'wb') as f:
@@ -654,7 +658,10 @@ class LangflowConnectorFileProcessor(TaskProcessor):
654
658
  # Create temporary file and compute hash to check for duplicates
655
659
  from utils.file_utils import auto_cleanup_tempfile
656
660
 
657
- suffix = get_file_extension(document.mimetype)
661
+ suffix = os.path.splitext(document.filename)[1]
662
+ if not suffix:
663
+ suffix = get_file_extension(document.mimetype)
664
+
658
665
  with auto_cleanup_tempfile(suffix=suffix) as tmp_path:
659
666
  # Write content to temp file
660
667
  with open(tmp_path, 'wb') as f:
@@ -740,7 +747,8 @@ class S3FileProcessor(TaskProcessor):
740
747
  from utils.hash_utils import hash_id
741
748
 
742
749
  try:
743
- with auto_cleanup_tempfile() as tmp_path:
750
+ suffix = os.path.splitext(item)[1]
751
+ with auto_cleanup_tempfile(suffix=suffix) as tmp_path:
744
752
  # Download object to temporary file
745
753
  with open(tmp_path, 'wb') as tmp_file:
746
754
  self.s3_client.download_fileobj(self.bucket, item, tmp_file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrag
3
- Version: 0.5.0.dev30
3
+ Version: 0.5.0.dev32
4
4
  Summary: OpenRAG is a comprehensive Retrieval-Augmented Generation platform that enables intelligent document search and AI-powered conversations.
5
5
  Classifier: Development Status :: 4 - Beta
6
6
  Classifier: Environment :: Console
@@ -22,7 +22,7 @@ api/provider_health.py,sha256=u4gNGhEAqsQY1ml6xWzWWAOfLHDrSVYPkw1NAmBCMsI,9690
22
22
  api/provider_validation.py,sha256=ik00rpXjL6pPCyJt3gEdeNhyoYlhIPGz_Kigmhf0zLw,31430
23
23
  api/router.py,sha256=nOusNdc-eOMRZo6a5eqNb91oOoleYNomHTGcKuPsMmU,6229
24
24
  api/search.py,sha256=z6M7PaO1oIdZtp_yU0eGkz0dTmDJItc7O4ULc67VIHk,1982
25
- api/settings.py,sha256=9ViBKRiyenlQdV_rZIbL_FQoZQEuMjR2-RxUH6bJl80,92480
25
+ api/settings.py,sha256=UNvTkZSI1hHOKYzmNe1yPFBby-Mn22R0fDyeCwy1lyM,92701
26
26
  api/tasks.py,sha256=j3tAfpE_X4UxhiGPrgea5jkxILF6HZK9Hoy3QTFGG0g,1524
27
27
  api/upload.py,sha256=vh8pxlf7LnTIlRH8gsJhogTbFq93-SQ-1UNIQTtaer4,7288
28
28
  api/v1/__init__.py,sha256=A7dLY63RcJv6ii-Xh7WgREcwXixjzOXz2w5C0LGWTCc,190
@@ -43,8 +43,8 @@ config/settings.py,sha256=zhvJ3TAmdZwoWk55BPX4OuAAJiDTHlJg4_MoSWPhZMI,39298
43
43
  connectors/__init__.py,sha256=6ijOhF7jG-zJ3bIxQ4HIHZHqpT127cNvCDaaqdN_Pm8,390
44
44
  connectors/base.py,sha256=s9gBEIxl8_nZVtCeMKz7LrlOV-H0rCxvN_cFH8B9DMg,4532
45
45
  connectors/connection_manager.py,sha256=LrfiuoBB-hqNGjXhH-5x4uL6msU-OVOp7--4V302a14,28187
46
- connectors/langflow_connector_service.py,sha256=0455CdQMeFE320Le81e9fA40CduuPgIqodwkcisjI1U,17149
47
- connectors/service.py,sha256=1RQ1JnAJl0LeGwG9_NBjN1D3hae1j4NZRBdaS_bLoO8,17760
46
+ connectors/langflow_connector_service.py,sha256=HjVgTmlpZCWsoR2msEuCouw3snX0HBHNv6Szm4A0Iik,17251
47
+ connectors/service.py,sha256=_qyRDskMiNfPa2OyOan9gAmOgB5JjcLTidzzg-rhLWs,18229
48
48
  connectors/aws_s3/__init__.py,sha256=ZRXSKmYvYK7Kr7Rzcd0_YIMpqfAm5qjm06zF-YfPPhA,369
49
49
  connectors/aws_s3/api.py,sha256=CDpsdOZaOM7hT93Je4O_JD7GxJjPN0-_qgQPPSPiy40,6861
50
50
  connectors/aws_s3/auth.py,sha256=qvrjxNM5MvgK-I5F5Njl8B7pSJTrGIZ0wuZImFP9UVg,3060
@@ -52,7 +52,7 @@ connectors/aws_s3/connector.py,sha256=QZPFt0PycyIMp72ut2yxXrEYl45_N5MUloZXrw_xvf
52
52
  connectors/aws_s3/models.py,sha256=olSwBxzvVKqn6j3M5tENq7ksLQIuTfhGPGIEKmqxbR4,398
53
53
  connectors/aws_s3/support.py,sha256=vwsy6Q9EAssb2FeeT9NVwlJ9K_NWTulGh8HK4hxwmrg,1458
54
54
  connectors/google_drive/__init__.py,sha256=y3HqyvfVvNV8PYMgFF_MeM7djjWxkyDw3hJivLfhGXc,136
55
- connectors/google_drive/connector.py,sha256=QB4sxHN7FVqCKZlgbte5T4F3N1NKQAcxzMt09g4ZOcA,47544
55
+ connectors/google_drive/connector.py,sha256=UCSpGxOtCGRtTtiF4p8cZLkWAEOvdymzGhRYLYztaMY,47609
56
56
  connectors/google_drive/oauth.py,sha256=7zRf5zuFNbrREGI516BJuOzuMpGEaqhAtyzENFyPp-U,8179
57
57
  connectors/ibm_cos/__init__.py,sha256=gp6L4nW7EIHsEYIlbZQUDE9o90JO-0CEojTUtEv5-Po,369
58
58
  connectors/ibm_cos/api.py,sha256=vsCGoJnEfUMWqrWAdw0znpJQUcex7Lwx1ODWZeuY7m8,8440
@@ -70,13 +70,13 @@ connectors/sharepoint/utils.py,sha256=OBNEbXb8uAfR-iR-ML0aOojGfGj03vJnKm9DbYf2hW
70
70
  mcp_http/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
71
71
  mcp_http/server.py,sha256=JutP94fQRCizeCN69kKkdYDFE71L3DqLTCcb397T8Eo,7738
72
72
  models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
- models/processors.py,sha256=e6qpstQq9DyHM4UMEUfjQqBoVO4xeLmSLkQcyt4oca0,35539
73
+ models/processors.py,sha256=Vc6a1HIHpIe9t3Jx7HJ_lCRrSmWtzw63C5bzJpxTz8s,35793
74
74
  models/tasks.py,sha256=9iINXQZxA9-hcYzG8xC_baBfLa2w6gcRPJdQHsx_Kms,1643
75
75
  models/url.py,sha256=92rsrLLyIqUErYipYz5QwUNTdHwJ2jBRV7PIVpncTyo,5764
76
- openrag-0.5.0.dev30.dist-info/licenses/LICENSE,sha256=jWcnPXkUxEinX60Yn-_zjsY9i84vz2QSb0fi53djOkg,11334
76
+ openrag-0.5.0.dev32.dist-info/licenses/LICENSE,sha256=jWcnPXkUxEinX60Yn-_zjsY9i84vz2QSb0fi53djOkg,11334
77
77
  services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
78
78
  services/api_key_service.py,sha256=NMz2ohJo0jcuHjDN3tHuzhCTVo6lsOqAkUhozzmWSv0,11720
79
- services/auth_service.py,sha256=nNxKuCHh0ziHuHU18EGEUnf242h1XYsfbrbfWTmCmQE,22125
79
+ services/auth_service.py,sha256=nAKTvMyX-YUtiNfKF06Dq4E4iwHPBhfKR6nliqGpOSs,22090
80
80
  services/chat_service.py,sha256=Pg34Wg1krrRcZeVR0dGnZ_PU6mPB-CW4W39-2JwQAjQ,28366
81
81
  services/conversation_persistence_service.py,sha256=zfNMcgbD7GbQ_W8tB5jV_NDa_78l6fAmaIiiICg1YH8,6092
82
82
  services/document_service.py,sha256=-Lq5fYLnLz4mCsU4aS3tKXVTiDZ3P1jzyuNsxH-QdGw,7397
@@ -117,7 +117,7 @@ tui/screens/__init__.py,sha256=ZzB_lbAnSTwDqiG0ppafOE-sAiOUVaNy4V6x5AJvqyM,27
117
117
  tui/screens/config.py,sha256=_XcUisNNYR4-IVOhfXIZ9lCY1bPe8eaZY9JMh5nRi0g,27975
118
118
  tui/screens/diagnostics.py,sha256=ZMDD9KSY0GoXo67ft6j7x4RGWzI_Hy83_a3FovnstrM,24864
119
119
  tui/screens/logs.py,sha256=3fM6fAqjdFXVI_laiF5yl7lH8n0bSmWcdozMAWW3SLM,11853
120
- tui/screens/monitor.py,sha256=YOkrny2DdpDjqe6ChiFtjnygh4c8ECjy_9tHgEV4Gmk,46731
120
+ tui/screens/monitor.py,sha256=kTDaaDjoJT5Lr4kGYwNeVV9jsOMLLYfapZnDcJjjbcE,46900
121
121
  tui/screens/welcome.py,sha256=YYotbQOVbiLZjnyvcZ5OJqYNrh0i_bI22T9WdXqMjHQ,28452
122
122
  tui/utils/__init__.py,sha256=MIui8sXgp8IUvEkaCNuI5kRME8XfwCnGsapBIldaY_s,29
123
123
  tui/utils/clipboard.py,sha256=tUoesmliVEVgrWarsNkEGWj3fHc2L_IsKEMIhwrgG9c,1901
@@ -159,8 +159,8 @@ utils/telemetry/__init__.py,sha256=xoStY8hoYWnVe0B_jNthSdzRb7AxCT0rhM8ba9Q0Obg,2
159
159
  utils/telemetry/category.py,sha256=wei3V5WyUaiadYjEE5s0ys9PY0TjnebIvTykQU0P_2k,1114
160
160
  utils/telemetry/client.py,sha256=RhjW1a27w7W6gHt9X7AxdRPdZGmCEQDPskkxj5nsuiQ,12500
161
161
  utils/telemetry/message_id.py,sha256=IAu0wBVNOGPcSlQ0bAzvqIYr1Ac_n8wSTSxXzq_tiV0,9634
162
- openrag-0.5.0.dev30.dist-info/METADATA,sha256=2p00OaaKrPEu6whS45WHl9IEz3Nc8VOrEcYpcM0DJeg,7819
163
- openrag-0.5.0.dev30.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
164
- openrag-0.5.0.dev30.dist-info/entry_points.txt,sha256=ArKNHB63I3K8vynNkogvZOLmmQFpIsHDL7MvreCmju4,45
165
- openrag-0.5.0.dev30.dist-info/top_level.txt,sha256=5vfLBIkzuwBJrizhCSZiXV7NTzTRIMlNLsyzYDEeCF8,125
166
- openrag-0.5.0.dev30.dist-info/RECORD,,
162
+ openrag-0.5.0.dev32.dist-info/METADATA,sha256=UfANP1bsEv8iz1SeZw2O9gH1qIOiUWpP_1YB8xttsE4,7819
163
+ openrag-0.5.0.dev32.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
164
+ openrag-0.5.0.dev32.dist-info/entry_points.txt,sha256=ArKNHB63I3K8vynNkogvZOLmmQFpIsHDL7MvreCmju4,45
165
+ openrag-0.5.0.dev32.dist-info/top_level.txt,sha256=5vfLBIkzuwBJrizhCSZiXV7NTzTRIMlNLsyzYDEeCF8,125
166
+ openrag-0.5.0.dev32.dist-info/RECORD,,
services/auth_service.py CHANGED
@@ -324,9 +324,7 @@ class AuthService:
324
324
 
325
325
  # Add expiry if provided
326
326
  if token_data.get("expires_in"):
327
- expiry = datetime.now() + timedelta(
328
- seconds=int(token_data["expires_in"])
329
- )
327
+ expiry = datetime.utcnow() + timedelta(seconds=int(token_data["expires_in"]))
330
328
  token_file_data["expiry"] = expiry.isoformat()
331
329
 
332
330
  # Save tokens to file
tui/screens/monitor.py CHANGED
@@ -3,29 +3,34 @@
3
3
  import asyncio
4
4
  import re
5
5
  import shutil
6
+ from collections.abc import AsyncIterator
6
7
  from pathlib import Path
7
- from typing import Literal, Any, Optional, AsyncIterator
8
-
9
- # Define button variant type
10
- ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
8
+ from typing import Any, Literal, cast
11
9
 
10
+ from rich.text import Text
12
11
  from textual.app import ComposeResult
13
- from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
12
+ from textual.containers import Horizontal, ScrollableContainer
13
+ from textual.coordinate import Coordinate
14
14
  from textual.screen import Screen
15
- from textual.widgets import Header, Footer, Static, Button, DataTable
16
- from textual.timer import Timer
17
- from rich.text import Text
18
- from rich.table import Table
19
-
20
- from ..managers.container_manager import ContainerManager, ServiceStatus, ServiceInfo, format_port_conflict_message
15
+ from textual.widgets import Button, DataTable, Footer, Static
16
+
17
+ from ..managers.container_manager import (
18
+ ContainerManager,
19
+ ServiceInfo,
20
+ ServiceStatus,
21
+ format_port_conflict_message,
22
+ )
21
23
  from ..managers.docling_manager import DoclingManager
22
24
  from ..utils.platform import RuntimeType
23
25
  from ..widgets.command_modal import CommandOutputModal
24
- from ..widgets.flow_backup_warning_modal import FlowBackupWarningModal
26
+ from ..widgets.diagnostics_notification import notify_with_diagnostics
25
27
  from ..widgets.factory_reset_warning_modal import FactoryResetWarningModal
26
- from ..widgets.version_mismatch_warning_modal import VersionMismatchWarningModal
28
+ from ..widgets.flow_backup_warning_modal import FlowBackupWarningModal
27
29
  from ..widgets.upgrade_instructions_modal import UpgradeInstructionsModal
28
- from ..widgets.diagnostics_notification import notify_with_diagnostics
30
+ from ..widgets.version_mismatch_warning_modal import VersionMismatchWarningModal
31
+
32
+ # Define button variant type
33
+ ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
29
34
 
30
35
 
31
36
  class MonitorScreen(Screen):
@@ -55,6 +60,8 @@ class MonitorScreen(Screen):
55
60
  self.refresh_timer = None
56
61
  self.operation_in_progress = False
57
62
  self._follow_task = None
63
+ self._follow_service: str | None = None
64
+ self._logs_buffer: list[str] = []
58
65
 
59
66
  # Track which table was last selected for mutual exclusion
60
67
  self._last_selected_table = None
@@ -63,10 +70,9 @@ class MonitorScreen(Screen):
63
70
  def container_manager(self) -> ContainerManager:
64
71
  """Get the shared container manager from the app."""
65
72
  if self._container_manager is None:
66
- self._container_manager = self.app.container_manager
73
+ self._container_manager = cast(Any, self.app).container_manager
67
74
  return self._container_manager
68
75
 
69
-
70
76
  def compose(self) -> ComposeResult:
71
77
  """Create the monitoring screen layout."""
72
78
  # Just show the services content directly (no header, no tabs)
@@ -98,9 +104,7 @@ class MonitorScreen(Screen):
98
104
  yield Horizontal(id="services-controls", classes="button-row")
99
105
  # Create services table with image + digest info
100
106
  self.services_table = DataTable(id="services-table")
101
- self.services_table.add_columns(
102
- "Service", "Status", "Health", "Ports", "Image", "Digest"
103
- )
107
+ self.services_table.add_columns("Service", "Status", "Health", "Ports", "Image", "Digest")
104
108
  yield self.services_table
105
109
  yield Static(" ")
106
110
 
@@ -118,12 +122,8 @@ class MonitorScreen(Screen):
118
122
  status_text = Text()
119
123
 
120
124
  if not self.container_manager.is_available():
121
- status_text.append(
122
- "WARNING: No container runtime available\n", style="bold red"
123
- )
124
- status_text.append(
125
- "Please install Docker or Podman to continue.\n", style="dim"
126
- )
125
+ status_text.append("WARNING: No container runtime available\n", style="bold red")
126
+ status_text.append("Please install Docker or Podman to continue.\n", style="dim")
127
127
  return status_text
128
128
 
129
129
  runtime_info = self.container_manager.get_runtime_info()
@@ -150,7 +150,7 @@ class MonitorScreen(Screen):
150
150
  """Initialize the screen when mounted."""
151
151
  await self._refresh_services()
152
152
  # Set up auto-refresh every 5 seconds
153
- self.refresh_timer = self.set_interval(5.0, self._auto_refresh)
153
+ self.refresh_timer = self.set_interval(5.0, self._auto_refresh_services)
154
154
 
155
155
  self._focus_services_table()
156
156
 
@@ -186,9 +186,9 @@ class MonitorScreen(Screen):
186
186
  images_set.add(img)
187
187
  # Ensure compose-declared images are also shown (e.g., langflow when stopped)
188
188
  try:
189
- for img in (
190
- self.container_manager._parse_compose_images()
191
- ): # best-effort, no YAML dep
189
+ for (
190
+ img
191
+ ) in await self.container_manager._parse_compose_images(): # best-effort, no YAML dep
192
192
  if img:
193
193
  images_set.add(img)
194
194
  except Exception:
@@ -205,7 +205,7 @@ class MonitorScreen(Screen):
205
205
  self.images_table.clear()
206
206
 
207
207
  # Add container service rows
208
- for service_name, service_info in services.items():
208
+ for _service_name, service_info in services.items():
209
209
  status_style = self._get_status_style(service_info.status)
210
210
 
211
211
  self.services_table.add_row(
@@ -238,9 +238,7 @@ class MonitorScreen(Screen):
238
238
  if (docling_running or docling_starting)
239
239
  else "N/A"
240
240
  )
241
- docling_pid = (
242
- str(docling_status.get("pid")) if docling_status.get("pid") else "N/A"
243
- )
241
+ docling_pid = str(docling_status.get("pid")) if docling_status.get("pid") else "N/A"
244
242
 
245
243
  if self.docling_table:
246
244
  self.docling_table.add_row(
@@ -275,7 +273,7 @@ class MonitorScreen(Screen):
275
273
  }
276
274
  return status_styles.get(status, "white")
277
275
 
278
- async def _auto_refresh(self) -> None:
276
+ async def _auto_refresh_services(self) -> None:
279
277
  """Auto-refresh services if not in operation."""
280
278
  if not self.operation_in_progress:
281
279
  await self._refresh_services()
@@ -283,7 +281,6 @@ class MonitorScreen(Screen):
283
281
  def on_button_pressed(self, event: Button.Pressed) -> None:
284
282
  """Handle button presses."""
285
283
  button_id = event.button.id or ""
286
- button_label = event.button.label or ""
287
284
 
288
285
  # Use button ID prefixes to determine action, ignoring any random suffix
289
286
  if button_id.startswith("start-btn"):
@@ -328,7 +325,7 @@ class MonitorScreen(Screen):
328
325
  self.run_worker(self._show_logs(service_name))
329
326
  self._start_follow(service_name)
330
327
 
331
- async def _start_services(self, cpu_mode: Optional[bool] = None) -> None:
328
+ async def _start_services(self, cpu_mode: bool | None = None) -> None:
332
329
  """Start services with progress updates."""
333
330
  self.operation_in_progress = True
334
331
  try:
@@ -365,6 +362,7 @@ class MonitorScreen(Screen):
365
362
  # This ensures docker compose reads the correct version
366
363
  try:
367
364
  from ..managers.env_manager import EnvManager
365
+
368
366
  env_manager = EnvManager()
369
367
  env_manager.ensure_openrag_version()
370
368
  # Small delay to ensure .env file is written and flushed
@@ -433,7 +431,7 @@ class MonitorScreen(Screen):
433
431
  # Show "this is the latest version" toast
434
432
  self.notify(
435
433
  f"You are running the latest version ({current_version}).",
436
- severity="success",
434
+ severity="information",
437
435
  timeout=5,
438
436
  )
439
437
  else:
@@ -442,9 +440,7 @@ class MonitorScreen(Screen):
442
440
  UpgradeInstructionsModal(current_version, latest_version)
443
441
  )
444
442
  except Exception as e:
445
- self.notify(
446
- f"Error checking version: {str(e)}", severity="error", timeout=10
447
- )
443
+ self.notify(f"Error checking version: {str(e)}", severity="error", timeout=10)
448
444
  finally:
449
445
  self.operation_in_progress = False
450
446
 
@@ -453,9 +449,7 @@ class MonitorScreen(Screen):
453
449
  self.operation_in_progress = True
454
450
  try:
455
451
  # Show factory reset warning modal first
456
- should_continue = await self.app.push_screen_wait(
457
- FactoryResetWarningModal()
458
- )
452
+ should_continue = await self.app.push_screen_wait(FactoryResetWarningModal())
459
453
  if not should_continue:
460
454
  self.notify("Factory reset cancelled", severity="information")
461
455
  return
@@ -475,6 +469,7 @@ class MonitorScreen(Screen):
475
469
  try:
476
470
  # Get paths from env config
477
471
  from ..managers.env_manager import EnvManager
472
+
478
473
  env_manager = EnvManager()
479
474
  env_manager.load_existing_env()
480
475
 
@@ -487,7 +482,9 @@ class MonitorScreen(Screen):
487
482
 
488
483
  if config_path.exists():
489
484
  # Use container to handle files owned by container user
490
- success, msg = await self.container_manager.clear_directory_with_container(config_path)
485
+ success, msg = await self.container_manager.clear_directory_with_container(
486
+ config_path
487
+ )
491
488
  if not success:
492
489
  # Fallback to regular rmtree if container method fails
493
490
  shutil.rmtree(config_path)
@@ -497,18 +494,32 @@ class MonitorScreen(Screen):
497
494
  # Also delete legacy TUI config folder if it exists (~/.openrag/tui/config/)
498
495
  tui_config_path = expand_path(env_manager.config.openrag_tui_config_path_legacy)
499
496
  if tui_config_path.exists():
500
- success, msg = await self.container_manager.clear_directory_with_container(tui_config_path)
497
+ success, msg = await self.container_manager.clear_directory_with_container(
498
+ tui_config_path
499
+ )
501
500
  if not success:
502
501
  # Fallback to regular rmtree if container method fails
503
502
  shutil.rmtree(tui_config_path)
504
503
  # Recreate empty config directory
505
504
  tui_config_path.mkdir(parents=True, exist_ok=True)
506
505
 
506
+ # Clear backend data directory (database, session ownership, conversations, oauth tokens)
507
+ data_path = expand_path(env_manager.config.openrag_data_path)
508
+ if data_path.exists():
509
+ success, msg = await self.container_manager.clear_directory_with_container(
510
+ data_path
511
+ )
512
+ if not success:
513
+ shutil.rmtree(data_path)
514
+ data_path.mkdir(parents=True, exist_ok=True)
515
+
507
516
  # Delete flow backups only if user chose to (and they actually exist)
508
517
  if self._check_flow_backups():
509
518
  if delete_backups:
510
519
  # Use container to handle files owned by container user
511
- success, msg = await self.container_manager.clear_directory_with_container(flows_backup_path)
520
+ success, msg = await self.container_manager.clear_directory_with_container(
521
+ flows_backup_path
522
+ )
512
523
  if not success:
513
524
  # Fallback to regular rmtree if container method fails
514
525
  shutil.rmtree(flows_backup_path)
@@ -516,8 +527,10 @@ class MonitorScreen(Screen):
516
527
  flows_backup_path.mkdir(parents=True, exist_ok=True)
517
528
  self.notify("Flow backups deleted", severity="information")
518
529
  else:
519
- self.notify(f"Flow backups preserved in {flows_backup_path}", severity="information")
520
-
530
+ self.notify(
531
+ f"Flow backups preserved in {flows_backup_path}", severity="information"
532
+ )
533
+
521
534
  except Exception as e:
522
535
  self.notify(
523
536
  f"Error clearing config: {str(e)}",
@@ -548,12 +561,14 @@ class MonitorScreen(Screen):
548
561
 
549
562
  # Get data paths from env config
550
563
  from ..managers.env_manager import EnvManager
564
+
551
565
  env_manager = EnvManager()
552
566
  env_manager.load_existing_env()
553
567
 
554
568
  # Delete langflow-data directory (mirrors Makefile factory-reset behaviour)
555
569
  yield False, "Clearing Langflow data..."
556
570
  from tui.main import _resolve_langflow_data_path
571
+
557
572
  langflow_data_path = _resolve_langflow_data_path(Path.home() / ".openrag").resolve()
558
573
  home = Path.home().resolve()
559
574
  if not str(langflow_data_path).startswith(str(home) + "/"):
@@ -575,13 +590,13 @@ class MonitorScreen(Screen):
575
590
  try:
576
591
  # Show prune options modal
577
592
  from tui.widgets.prune_options_modal import PruneOptionsModal
578
-
593
+
579
594
  prune_choice = await self.app.push_screen_wait(PruneOptionsModal())
580
-
595
+
581
596
  if prune_choice == "cancel":
582
597
  self.notify("Prune cancelled", severity="information")
583
598
  return
584
-
599
+
585
600
  # Choose the appropriate pruning method based on user choice
586
601
  if prune_choice == "all":
587
602
  # Stop services and prune all images
@@ -591,7 +606,7 @@ class MonitorScreen(Screen):
591
606
  # Prune only unused images (default)
592
607
  command_generator = self.container_manager.prune_old_images()
593
608
  modal_title = "Pruning Unused Images"
594
-
609
+
595
610
  # Show command output in modal dialog
596
611
  modal = CommandOutputModal(
597
612
  modal_title,
@@ -605,12 +620,15 @@ class MonitorScreen(Screen):
605
620
  def _check_flow_backups(self) -> bool:
606
621
  """Check if there are any flow backups in flows/backup directory."""
607
622
  from pathlib import Path
623
+
608
624
  from ..managers.env_manager import EnvManager
609
625
 
610
626
  # Get flows path from env config
611
627
  env_manager = EnvManager()
612
628
  env_manager.load_existing_env()
613
- flows_path = Path(env_manager.config.openrag_flows_path.replace("$HOME", str(Path.home()))).expanduser()
629
+ flows_path = Path(
630
+ env_manager.config.openrag_flows_path.replace("$HOME", str(Path.home()))
631
+ ).expanduser()
614
632
  backup_dir = flows_path / "backup"
615
633
  if not backup_dir.exists():
616
634
  return False
@@ -651,9 +669,7 @@ class MonitorScreen(Screen):
651
669
  if success:
652
670
  self.notify(message, severity="information")
653
671
  else:
654
- self.notify(
655
- f"Failed to start docling serve: {message}", severity="error"
656
- )
672
+ self.notify(f"Failed to start docling serve: {message}", severity="error")
657
673
  # Refresh again to show final status (running or stopped)
658
674
  await self._refresh_services()
659
675
  except Exception as e:
@@ -671,9 +687,7 @@ class MonitorScreen(Screen):
671
687
  if success:
672
688
  self.notify(message, severity="information")
673
689
  else:
674
- self.notify(
675
- f"Failed to stop docling serve: {message}", severity="error"
676
- )
690
+ self.notify(f"Failed to stop docling serve: {message}", severity="error")
677
691
  # Refresh the services table to show updated status
678
692
  await self._refresh_services()
679
693
  except Exception as e:
@@ -689,9 +703,7 @@ class MonitorScreen(Screen):
689
703
  if success:
690
704
  self.notify(message, severity="information")
691
705
  else:
692
- self.notify(
693
- f"Failed to restart docling serve: {message}", severity="error"
694
- )
706
+ self.notify(f"Failed to restart docling serve: {message}", severity="error")
695
707
  # Refresh the services table to show updated status
696
708
  await self._refresh_services()
697
709
  except Exception as e:
@@ -780,9 +792,7 @@ class MonitorScreen(Screen):
780
792
  except Exception:
781
793
  pass
782
794
  except Exception as e:
783
- notify_with_diagnostics(
784
- self.app, f"Error following logs: {e}", severity="error"
785
- )
795
+ notify_with_diagnostics(self.app, f"Error following logs: {e}", severity="error")
786
796
 
787
797
  def action_refresh(self) -> None:
788
798
  """Refresh services manually."""
@@ -861,9 +871,7 @@ class MonitorScreen(Screen):
861
871
  self._update_mode_row()
862
872
  self.action_refresh()
863
873
  except Exception as e:
864
- notify_with_diagnostics(
865
- self.app, f"Failed to toggle mode: {e}", severity="error"
866
- )
874
+ notify_with_diagnostics(self.app, f"Failed to toggle mode: {e}", severity="error")
867
875
 
868
876
  def _update_controls(self, services: list[ServiceInfo]) -> None:
869
877
  """Update control buttons based on running state."""
@@ -886,31 +894,19 @@ class MonitorScreen(Screen):
886
894
  # Add appropriate buttons based on service state
887
895
  if any_running:
888
896
  # When services are running, show stop and restart
889
- controls.mount(
890
- Button("Stop Services", variant="error", id=f"stop-btn{suffix}")
891
- )
892
- controls.mount(
893
- Button("Restart", variant="primary", id=f"restart-btn{suffix}")
894
- )
897
+ controls.mount(Button("Stop Services", variant="error", id=f"stop-btn{suffix}"))
898
+ controls.mount(Button("Restart", variant="primary", id=f"restart-btn{suffix}"))
895
899
  else:
896
900
  # When services are not running, show start
897
- controls.mount(
898
- Button("Start Services", variant="success", id=f"start-btn{suffix}")
899
- )
901
+ controls.mount(Button("Start Services", variant="success", id=f"start-btn{suffix}"))
900
902
 
901
903
  # Always show upgrade, prune, and reset buttons
902
- controls.mount(
903
- Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}")
904
- )
905
- controls.mount(
906
- Button("Prune Images", variant="default", id=f"prune-btn{suffix}")
907
- )
904
+ controls.mount(Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}"))
905
+ controls.mount(Button("Prune Images", variant="default", id=f"prune-btn{suffix}"))
908
906
  controls.mount(Button("Factory Reset", variant="error", id=f"reset-btn{suffix}"))
909
907
 
910
908
  except Exception as e:
911
- notify_with_diagnostics(
912
- self.app, f"Error updating controls: {e}", severity="error"
913
- )
909
+ notify_with_diagnostics(self.app, f"Error updating controls: {e}", severity="error")
914
910
 
915
911
  # Update docling controls separately
916
912
  self._update_docling_controls()
@@ -940,9 +936,7 @@ class MonitorScreen(Screen):
940
936
  Button("Stop", variant="error", id=f"docling-stop-btn{suffix}")
941
937
  )
942
938
  docling_controls.mount(
943
- Button(
944
- "Restart", variant="primary", id=f"docling-restart-btn{suffix}"
945
- )
939
+ Button("Restart", variant="primary", id=f"docling-restart-btn{suffix}")
946
940
  )
947
941
  elif docling_starting:
948
942
  # Show disabled button or no button when starting
@@ -1102,7 +1096,7 @@ class MonitorScreen(Screen):
1102
1096
  return
1103
1097
 
1104
1098
  try:
1105
- table.cursor_coordinate = (row, 0)
1099
+ table.cursor_coordinate = Coordinate(row, 0)
1106
1100
  except Exception:
1107
1101
  move_cursor = getattr(table, "move_cursor", None)
1108
1102
  if callable(move_cursor):
@@ -1111,9 +1105,7 @@ class MonitorScreen(Screen):
1111
1105
  except Exception:
1112
1106
  pass
1113
1107
 
1114
- def _focus_services_table(
1115
- self, row: str | None = None, set_last: bool = True
1116
- ) -> None:
1108
+ def _focus_services_table(self, row: str | None = None, set_last: bool = True) -> None:
1117
1109
  """Focus the services table and update selection."""
1118
1110
  if not self.services_table:
1119
1111
  return