mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-11 22:52:54 +00:00
refactor: google drive service in knowledge base route into generic content sources standalone folders
This commit is contained in:
parent
3cc1c70432
commit
40e80113ca
28 changed files with 4210 additions and 852 deletions
24
backend/open_webui/content_sources/__init__.py
Normal file
24
backend/open_webui/content_sources/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""
|
||||
Content Sources Module
|
||||
|
||||
This module provides a unified interface for integrating external content sources
|
||||
(Google Drive, OneDrive, Dropbox, etc.) with Open WebUI's knowledge base system.
|
||||
"""
|
||||
|
||||
from .factory import content_source_factory
|
||||
from .registry import content_source_registry
|
||||
from .scheduler import scheduler as content_source_scheduler
|
||||
|
||||
# Initialize Google Drive provider in the registry
|
||||
try:
|
||||
gdrive_provider = content_source_factory.get_provider('google_drive')
|
||||
content_source_registry.register_provider('google_drive', gdrive_provider)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to initialize Google Drive provider: {e}")
|
||||
|
||||
__all__ = [
|
||||
"content_source_factory",
|
||||
"content_source_registry",
|
||||
"content_source_scheduler"
|
||||
]
|
||||
245
backend/open_webui/content_sources/base.py
Normal file
245
backend/open_webui/content_sources/base.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""
|
||||
Base Content Source Provider
|
||||
|
||||
Defines the abstract interface for all content source providers.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Callable, Optional, AsyncGenerator
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSourceProvider(ABC):
|
||||
"""
|
||||
Abstract base class for content source providers.
|
||||
|
||||
Providers implement methods to list, download, and sync files from external sources.
|
||||
They emit hooks to allow the knowledge system to process files without tight coupling.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
|
||||
def register_hook(self, event: str, handler: Callable) -> None:
|
||||
"""
|
||||
Register a hook handler for a specific event.
|
||||
|
||||
Args:
|
||||
event: The event name (e.g., 'file_ready', 'sync_started')
|
||||
handler: Async callable that will be invoked when the event is emitted
|
||||
"""
|
||||
if event not in self._hooks:
|
||||
self._hooks[event] = []
|
||||
self._hooks[event].append(handler)
|
||||
logger.debug(f"Registered hook for event '{event}'")
|
||||
|
||||
async def emit_hook(self, event: str, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Emit an event to all registered handlers.
|
||||
|
||||
Args:
|
||||
event: The event name
|
||||
data: Data to pass to the handlers
|
||||
"""
|
||||
handlers = self._hooks.get(event, [])
|
||||
for handler in handlers:
|
||||
try:
|
||||
await handler(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in hook handler for event '{event}': {e}")
|
||||
|
||||
@abstractmethod
|
||||
async def list_files(self, path: str = "", recursive: bool = True) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List files in the content source.
|
||||
|
||||
Args:
|
||||
path: The path to list files from (provider-specific format)
|
||||
recursive: Whether to list files recursively
|
||||
|
||||
Returns:
|
||||
List of file metadata dictionaries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def download_file(self, file_id: str) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
Download a file from the content source.
|
||||
|
||||
Args:
|
||||
file_id: Provider-specific file identifier
|
||||
|
||||
Yields:
|
||||
File content in chunks
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_service_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the content source service.
|
||||
|
||||
Returns:
|
||||
Dictionary with service information (e.g., account email, quota)
|
||||
"""
|
||||
pass
|
||||
|
||||
async def sync_folder(self, folder_id: str, context: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""
|
||||
Sync a folder from the content source.
|
||||
|
||||
This is a high-level method that lists files and emits hooks for processing.
|
||||
Subclasses can override for provider-specific behavior.
|
||||
|
||||
Args:
|
||||
folder_id: Provider-specific folder identifier
|
||||
context: Optional context to pass through hooks (e.g., kb_id, user_id)
|
||||
"""
|
||||
context = context or {}
|
||||
|
||||
# Emit sync started event
|
||||
await self.emit_hook('sync_started', {
|
||||
'folder_id': folder_id,
|
||||
'context': context
|
||||
})
|
||||
|
||||
try:
|
||||
# List files in the folder
|
||||
files = await self.list_files(folder_id, recursive=True)
|
||||
|
||||
# Process each file
|
||||
for file_info in files:
|
||||
try:
|
||||
# Download file content
|
||||
content_chunks = []
|
||||
async for chunk in self.download_file(file_info['id']):
|
||||
content_chunks.append(chunk)
|
||||
content = b''.join(content_chunks)
|
||||
|
||||
# Emit file ready event
|
||||
await self.emit_hook('file_ready', {
|
||||
'file_info': file_info,
|
||||
'content': content,
|
||||
'context': context
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file {file_info.get('name', 'unknown')}: {e}")
|
||||
await self.emit_hook('file_error', {
|
||||
'file_info': file_info,
|
||||
'error': str(e),
|
||||
'context': context
|
||||
})
|
||||
|
||||
# Emit sync completed event
|
||||
await self.emit_hook('sync_completed', {
|
||||
'folder_id': folder_id,
|
||||
'file_count': len(files),
|
||||
'context': context
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during sync: {e}")
|
||||
await self.emit_hook('sync_error', {
|
||||
'folder_id': folder_id,
|
||||
'error': str(e),
|
||||
'context': context
|
||||
})
|
||||
raise
|
||||
|
||||
async def sync_content(self, source_id: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Generic sync method that handles file synchronization and returns results.
|
||||
|
||||
This method provides a unified interface for the knowledge router to sync content.
|
||||
It handles both simple sync (download all) and advanced sync (with change detection).
|
||||
|
||||
Args:
|
||||
source_id: Provider-specific source identifier (folder ID, etc.)
|
||||
context: Context including file_ids, user_id, options, etc.
|
||||
|
||||
Returns:
|
||||
Dictionary with sync results including added, updated, removed files and errors
|
||||
"""
|
||||
context = context or {}
|
||||
sync_results = {
|
||||
"added_files": [],
|
||||
"updated_files": [],
|
||||
"removed_files": [],
|
||||
"errors": [],
|
||||
"changes": False
|
||||
}
|
||||
|
||||
# Build provider file map from existing files
|
||||
provider_file_map = {}
|
||||
existing_file_ids = context.get("file_ids", [])
|
||||
if existing_file_ids:
|
||||
from open_webui.models.files import Files
|
||||
existing_files = Files.get_files_by_ids(existing_file_ids)
|
||||
for file in existing_files:
|
||||
if file.data and file.data.get("provider") == context.get("provider_name"):
|
||||
provider_id = file.data.get("provider_file_id")
|
||||
if provider_id:
|
||||
provider_file_map[provider_id] = {
|
||||
"id": file.id,
|
||||
"name": file.filename,
|
||||
"modified_time": file.data.get("provider_modified_time"),
|
||||
"data": file.data
|
||||
}
|
||||
|
||||
# Track sync progress
|
||||
async def track_file_processed(data: Dict[str, Any]):
|
||||
file_id = data.get("file_id")
|
||||
if file_id:
|
||||
sync_results["added_files"].append(file_id)
|
||||
sync_results["changes"] = True
|
||||
|
||||
async def track_file_updated(data: Dict[str, Any]):
|
||||
sync_results["updated_files"].append(data.get("file_id", "unknown"))
|
||||
sync_results["changes"] = True
|
||||
|
||||
async def track_file_removed(data: Dict[str, Any]):
|
||||
file_info = data.get("file_info", {})
|
||||
file_id = file_info.get("id")
|
||||
if file_id:
|
||||
sync_results["removed_files"].append(file_id)
|
||||
sync_results["changes"] = True
|
||||
|
||||
async def track_error(data: Dict[str, Any]):
|
||||
sync_results["errors"].append({
|
||||
"file": data.get("file_info", {}).get("name", "unknown"),
|
||||
"error": data.get("error", "Unknown error")
|
||||
})
|
||||
|
||||
# Register internal tracking hooks
|
||||
self.register_hook("file_processed", track_file_processed)
|
||||
self.register_hook("file_updated", track_file_updated)
|
||||
self.register_hook("file_removed", track_file_removed)
|
||||
self.register_hook("file_error", track_error)
|
||||
|
||||
try:
|
||||
# Use advanced sync if available, otherwise basic sync
|
||||
if hasattr(self, 'sync_folder_with_metadata'):
|
||||
await self.sync_folder_with_metadata(
|
||||
folder_id=source_id,
|
||||
existing_files=provider_file_map,
|
||||
context=context
|
||||
)
|
||||
else:
|
||||
await self.sync_folder(source_id, context)
|
||||
|
||||
finally:
|
||||
# Clean up tracking hooks
|
||||
if "file_processed" in self._hooks:
|
||||
self._hooks["file_processed"] = [h for h in self._hooks["file_processed"] if h != track_file_processed]
|
||||
if "file_updated" in self._hooks:
|
||||
self._hooks["file_updated"] = [h for h in self._hooks["file_updated"] if h != track_file_updated]
|
||||
if "file_removed" in self._hooks:
|
||||
self._hooks["file_removed"] = [h for h in self._hooks["file_removed"] if h != track_file_removed]
|
||||
if "file_error" in self._hooks:
|
||||
self._hooks["file_error"] = [h for h in self._hooks["file_error"] if h != track_error]
|
||||
|
||||
return sync_results
|
||||
84
backend/open_webui/content_sources/factory.py
Normal file
84
backend/open_webui/content_sources/factory.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
Content Source Factory
|
||||
|
||||
Factory for creating content source provider instances.
|
||||
"""
|
||||
|
||||
from typing import Dict, Type, Optional
|
||||
import logging
|
||||
|
||||
from .base import ContentSourceProvider
|
||||
from .providers.google_drive import GoogleDriveProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSourceFactory:
|
||||
"""
|
||||
Factory for creating content source provider instances.
|
||||
|
||||
Follows the pattern established by storage providers in the codebase.
|
||||
"""
|
||||
|
||||
# Registry of available providers
|
||||
_providers: Dict[str, Type[ContentSourceProvider]] = {
|
||||
'google_drive': GoogleDriveProvider,
|
||||
# Future providers can be added here:
|
||||
# 'onedrive': OneDriveContentSource,
|
||||
# 'dropbox': DropboxContentSource,
|
||||
# 'sharepoint': SharePointContentSource,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, provider_type: str) -> ContentSourceProvider:
|
||||
"""
|
||||
Get a content source provider instance.
|
||||
|
||||
Args:
|
||||
provider_type: The type of provider to create
|
||||
|
||||
Returns:
|
||||
ContentSourceProvider instance
|
||||
|
||||
Raises:
|
||||
ValueError: If provider type is not supported
|
||||
"""
|
||||
if provider_type not in cls._providers:
|
||||
available = ', '.join(cls._providers.keys())
|
||||
raise ValueError(
|
||||
f"Unknown content source provider type: {provider_type}. "
|
||||
f"Available providers: {available}"
|
||||
)
|
||||
|
||||
provider_class = cls._providers[provider_type]
|
||||
logger.info(f"Creating content source provider: {provider_type}")
|
||||
|
||||
return provider_class()
|
||||
|
||||
@classmethod
|
||||
def register_provider(cls, provider_type: str, provider_class: Type[ContentSourceProvider]) -> None:
|
||||
"""
|
||||
Register a new content source provider.
|
||||
|
||||
This allows for dynamic registration of custom providers.
|
||||
|
||||
Args:
|
||||
provider_type: The identifier for the provider
|
||||
provider_class: The provider class to register
|
||||
"""
|
||||
cls._providers[provider_type] = provider_class
|
||||
logger.info(f"Registered content source provider: {provider_type}")
|
||||
|
||||
@classmethod
|
||||
def get_available_providers(cls) -> Dict[str, Type[ContentSourceProvider]]:
|
||||
"""
|
||||
Get all available content source providers.
|
||||
|
||||
Returns:
|
||||
Dictionary of provider types to their classes
|
||||
"""
|
||||
return cls._providers.copy()
|
||||
|
||||
|
||||
# Create a singleton instance for easy import
|
||||
content_source_factory = ContentSourceFactory()
|
||||
1
backend/open_webui/content_sources/providers/__init__.py
Normal file
1
backend/open_webui/content_sources/providers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Content source providers."""
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""Google Drive content source provider."""
|
||||
from .provider import GoogleDriveProvider
|
||||
|
||||
__all__ = ["GoogleDriveProvider"]
|
||||
|
|
@ -79,10 +79,13 @@ class GoogleDriveService:
|
|||
|
||||
def __init__(self) -> None:
|
||||
self.service: Optional[Resource] = None
|
||||
self._initialize_service()
|
||||
self._credentials: Optional[service_account.Credentials] = None
|
||||
self._service_initialized: bool = False
|
||||
# Only prepare credentials, don't build the service yet
|
||||
self._prepare_credentials()
|
||||
|
||||
def _initialize_service(self) -> None:
|
||||
"""Initialize Google Drive service with service account credentials."""
|
||||
def _prepare_credentials(self) -> None:
|
||||
"""Prepare credentials without building the service."""
|
||||
try:
|
||||
if not GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON.value:
|
||||
log.warning("Google Drive service account JSON not configured")
|
||||
|
|
@ -91,28 +94,48 @@ class GoogleDriveService:
|
|||
# Parse service account JSON
|
||||
service_account_info = json.loads(GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON.value)
|
||||
|
||||
# Create credentials
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
# Create credentials (this is fast and doesn't make network calls)
|
||||
self._credentials = service_account.Credentials.from_service_account_info(
|
||||
service_account_info,
|
||||
scopes=["https://www.googleapis.com/auth/drive.readonly"],
|
||||
)
|
||||
|
||||
# Build service
|
||||
self.service = build("drive", "v3", credentials=credentials)
|
||||
log.info("Google Drive service initialized successfully")
|
||||
log.debug("Google Drive credentials prepared")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(f"Invalid service account JSON: {e}")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to prepare Google Drive credentials: {e}")
|
||||
|
||||
def _initialize_service(self) -> None:
|
||||
"""Initialize Google Drive service with service account credentials."""
|
||||
try:
|
||||
if not self._credentials:
|
||||
log.warning("Google Drive credentials not available")
|
||||
return
|
||||
|
||||
# Build service (this makes a network call to Google's discovery API)
|
||||
self.service = build("drive", "v3", credentials=self._credentials)
|
||||
self._service_initialized = True
|
||||
log.info("Google Drive service initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize Google Drive service: {e}")
|
||||
|
||||
def _ensure_service(self) -> None:
|
||||
"""Ensure the service is initialized before use."""
|
||||
if not self._service_initialized and self._credentials:
|
||||
self._initialize_service()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Google Drive service is properly configured."""
|
||||
return self.service is not None
|
||||
return self._credentials is not None
|
||||
|
||||
def refresh_configuration(self) -> None:
|
||||
"""Refresh the service configuration. Call this when config is updated."""
|
||||
self._initialize_service()
|
||||
self.service = None
|
||||
self._service_initialized = False
|
||||
self._credentials = None
|
||||
self._prepare_credentials()
|
||||
|
||||
def get_service_account_email(self) -> Optional[str]:
|
||||
"""Get the service account email address."""
|
||||
|
|
@ -139,6 +162,9 @@ class GoogleDriveService:
|
|||
Returns:
|
||||
List of file information dictionaries
|
||||
"""
|
||||
# Ensure service is initialized before use
|
||||
self._ensure_service()
|
||||
|
||||
if not self.service:
|
||||
raise Exception("Google Drive service not configured")
|
||||
|
||||
|
|
@ -204,6 +230,9 @@ class GoogleDriveService:
|
|||
self, folder_id: str, path: str = ""
|
||||
) -> List[GoogleDriveFile]:
|
||||
"""Get files directly in a specific folder."""
|
||||
# Ensure service is initialized
|
||||
self._ensure_service()
|
||||
|
||||
files = []
|
||||
page_token = None
|
||||
|
||||
|
|
@ -270,8 +299,14 @@ class GoogleDriveService:
|
|||
# Filter out folders and only keep files
|
||||
for item in items:
|
||||
if item.get("mimeType") != "application/vnd.google-apps.folder":
|
||||
file_id = item["id"]
|
||||
# Ensure file ID is clean (no extra parameters)
|
||||
if "?" in file_id or "&" in file_id:
|
||||
log.warning(f"File ID contains query parameters: {file_id}")
|
||||
file_id = file_id.split("?")[0].split("&")[0]
|
||||
|
||||
file_info: GoogleDriveFile = {
|
||||
"id": item["id"],
|
||||
"id": file_id,
|
||||
"name": item["name"],
|
||||
"mimeType": item["mimeType"],
|
||||
"modifiedTime": item["modifiedTime"],
|
||||
|
|
@ -283,7 +318,7 @@ class GoogleDriveService:
|
|||
if "parents" in item:
|
||||
file_info["parents"] = item["parents"]
|
||||
files.append(file_info)
|
||||
log.info(f"Google Drive: Added file '{item['name']}' to results")
|
||||
log.info(f"Google Drive: Added file '{item['name']}' (ID: {file_id}) to results")
|
||||
|
||||
page_token = results.get("nextPageToken")
|
||||
if not page_token:
|
||||
|
|
@ -293,6 +328,9 @@ class GoogleDriveService:
|
|||
|
||||
def _get_subfolders(self, folder_id: str) -> List[Dict[str, str]]:
|
||||
"""Get subfolders in a specific folder."""
|
||||
# Ensure service is initialized
|
||||
self._ensure_service()
|
||||
|
||||
subfolders = []
|
||||
page_token = None
|
||||
|
||||
|
|
@ -451,12 +489,39 @@ class GoogleDriveService:
|
|||
Returns:
|
||||
Tuple of (file_content_bytes, filename)
|
||||
"""
|
||||
# Ensure service is initialized
|
||||
self._ensure_service()
|
||||
|
||||
if not self.service:
|
||||
raise Exception("Google Drive service not configured")
|
||||
|
||||
try:
|
||||
mime_type = file_info.get("mimeType", "")
|
||||
file_name = file_info.get("name", "")
|
||||
|
||||
# Log file details for debugging
|
||||
log.info(f"Attempting to download file: '{file_name}' (ID: {file_id}, Type: {mime_type})")
|
||||
|
||||
# First, verify we can access the file's metadata with full details
|
||||
try:
|
||||
assert self.service is not None
|
||||
file_check = self.service.files().get(
|
||||
fileId=file_id,
|
||||
fields="id,name,mimeType,permissions,capabilities,driveId,teamDriveId,parents",
|
||||
supportsAllDrives=True
|
||||
).execute()
|
||||
|
||||
log.info(f"File metadata check successful. Capabilities: {file_check.get('capabilities', {})}")
|
||||
|
||||
# Check if we have export/download capability
|
||||
can_download = file_check.get('capabilities', {}).get('canDownload', True)
|
||||
if not can_download:
|
||||
log.warning(f"File '{file_name}' cannot be downloaded due to permissions")
|
||||
raise Exception("File cannot be downloaded due to permissions")
|
||||
|
||||
except HttpError as e:
|
||||
log.error(f"Failed to access file metadata: {e}")
|
||||
raise
|
||||
|
||||
# Handle Google Workspace files (export)
|
||||
if mime_type.startswith("application/vnd.google-apps"):
|
||||
|
|
@ -519,5 +584,4 @@ class GoogleDriveService:
|
|||
return file_content, file_name
|
||||
|
||||
|
||||
# Global instance
|
||||
google_drive_service = GoogleDriveService()
|
||||
# Note: Provider should create instance as needed, not use singleton
|
||||
|
|
@ -0,0 +1,460 @@
|
|||
"""
|
||||
Google Drive Content Source Provider
|
||||
|
||||
Implements the ContentSourceProvider interface for Google Drive integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator
|
||||
import io
|
||||
|
||||
from .client import GoogleDriveService
|
||||
from open_webui.content_sources.base import ContentSourceProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoogleDriveProvider(ContentSourceProvider):
|
||||
"""
|
||||
Google Drive content source provider implementation.
|
||||
|
||||
Uses the existing GoogleDriveService for API interactions and
|
||||
emits hooks for knowledge base integration.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.service = GoogleDriveService()
|
||||
|
||||
def refresh_configuration(self) -> None:
|
||||
"""Refresh the Google Drive service configuration."""
|
||||
self.service.refresh_configuration()
|
||||
|
||||
async def list_files(self, path: str = "", recursive: bool = True) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List files in a Google Drive folder.
|
||||
|
||||
Args:
|
||||
path: The folder ID to list files from
|
||||
recursive: Whether to include files from nested folders
|
||||
|
||||
Returns:
|
||||
List of file metadata dictionaries
|
||||
"""
|
||||
if not self.service.is_configured():
|
||||
raise ValueError("Google Drive service is not configured")
|
||||
|
||||
# Use the existing service method
|
||||
gdrive_files = self.service.list_folder_files(path, include_nested=recursive)
|
||||
|
||||
# Convert to our standard format
|
||||
files = []
|
||||
for gdrive_file in gdrive_files:
|
||||
files.append({
|
||||
'id': gdrive_file['id'],
|
||||
'name': gdrive_file['name'],
|
||||
'mime_type': gdrive_file['mimeType'],
|
||||
'modified_time': gdrive_file['modifiedTime'],
|
||||
'size': gdrive_file.get('size'),
|
||||
'path': gdrive_file.get('path', ''),
|
||||
'provider': 'google_drive',
|
||||
'metadata': gdrive_file # Keep original metadata
|
||||
})
|
||||
|
||||
return files
|
||||
|
||||
async def download_file(self, file_id: str, chunk_size: int = 1024 * 1024) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
Download a file from Google Drive with chunked streaming.
|
||||
|
||||
Args:
|
||||
file_id: Google Drive file ID
|
||||
chunk_size: Size of chunks to yield (default 1MB)
|
||||
|
||||
Yields:
|
||||
File content in chunks
|
||||
"""
|
||||
if not self.service.is_configured():
|
||||
raise ValueError("Google Drive service is not configured")
|
||||
|
||||
try:
|
||||
# Use the existing service method to get file info with shared drive support
|
||||
file_info = self.service.service.files().get(
|
||||
fileId=file_id,
|
||||
supportsAllDrives=True
|
||||
).execute()
|
||||
|
||||
# Check if it's a Google Workspace file that needs export
|
||||
mime_type = file_info.get('mimeType', '')
|
||||
file_name = file_info.get('name', 'unknown')
|
||||
file_size = int(file_info.get('size', 0))
|
||||
|
||||
# Log file info for debugging
|
||||
logger.info(f"Downloading file: {file_name} (ID: {file_id}, Type: {mime_type}, Size: {file_size} bytes)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get file info for {file_id}: {e}")
|
||||
raise
|
||||
|
||||
# Define export formats for Google Workspace files
|
||||
# Prioritize text formats for knowledge base usage - text is searchable, PDF would be base64
|
||||
export_formats = {
|
||||
'application/vnd.google-apps.document': [
|
||||
'text/plain', # Primary: Plain text for knowledge base search
|
||||
'application/pdf' # Fallback: PDF if text export fails
|
||||
],
|
||||
'application/vnd.google-apps.spreadsheet': [
|
||||
'text/csv' # CSV is already a text format
|
||||
],
|
||||
'application/vnd.google-apps.presentation': [
|
||||
'application/pdf', # Keep PDF for presentations (no text export available)
|
||||
'text/plain' # Fallback attempt (may not work for all slides)
|
||||
],
|
||||
}
|
||||
|
||||
if mime_type in export_formats:
|
||||
# Google Workspace files need to be exported
|
||||
async for chunk in self._download_workspace_file(file_id, file_name, export_formats[mime_type], chunk_size):
|
||||
yield chunk
|
||||
else:
|
||||
# Regular files can be streamed directly
|
||||
async for chunk in self._download_regular_file(file_id, file_name, chunk_size):
|
||||
yield chunk
|
||||
|
||||
async def _download_workspace_file(self, file_id: str, file_name: str, export_mime_types: list, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
Download and export a Google Workspace file with streaming.
|
||||
|
||||
Args:
|
||||
file_id: Google Drive file ID
|
||||
file_name: Name of the file for logging
|
||||
export_mime_types: List of MIME types to try for export
|
||||
chunk_size: Size of chunks to yield
|
||||
|
||||
Yields:
|
||||
File content in chunks
|
||||
"""
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
last_error = None
|
||||
content_downloaded = False
|
||||
|
||||
for export_mime_type in export_mime_types:
|
||||
try:
|
||||
request = self.service.service.files().export_media(
|
||||
fileId=file_id,
|
||||
mimeType=export_mime_type
|
||||
)
|
||||
logger.info(f"Attempting to export '{file_name}' (ID: {file_id}) as {export_mime_type}")
|
||||
|
||||
# Use a custom IO stream that yields chunks
|
||||
class ChunkedDownloadStream:
|
||||
def __init__(self, chunk_size):
|
||||
self.chunks = []
|
||||
self.chunk_size = chunk_size
|
||||
self.position = 0
|
||||
|
||||
def write(self, data):
|
||||
self.chunks.append(data)
|
||||
return len(data)
|
||||
|
||||
def read_chunks(self):
|
||||
"""Read and yield accumulated chunks."""
|
||||
if self.chunks:
|
||||
data = b''.join(self.chunks)
|
||||
self.chunks = []
|
||||
|
||||
# Yield data in specified chunk sizes
|
||||
for i in range(0, len(data), self.chunk_size):
|
||||
yield data[i:i + self.chunk_size]
|
||||
|
||||
stream = ChunkedDownloadStream(chunk_size)
|
||||
downloader = MediaIoBaseDownload(stream, request, chunksize=chunk_size)
|
||||
|
||||
done = False
|
||||
while not done:
|
||||
status, done = downloader.next_chunk()
|
||||
if status:
|
||||
logger.debug(f"Export progress for '{file_name}': {int(status.progress() * 100)}%")
|
||||
|
||||
# Yield accumulated chunks
|
||||
for chunk in stream.read_chunks():
|
||||
yield chunk
|
||||
|
||||
# Yield any remaining data
|
||||
for chunk in stream.read_chunks():
|
||||
yield chunk
|
||||
|
||||
logger.info(f"Successfully exported '{file_name}' as {export_mime_type}")
|
||||
content_downloaded = True
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
# Check if it's a size limit error
|
||||
if hasattr(e, 'resp') and e.resp.status == 403:
|
||||
error_content = e.content.decode('utf-8') if hasattr(e, 'content') else str(e)
|
||||
if 'exportSizeLimitExceeded' in error_content:
|
||||
logger.warning(f"File '{file_name}' too large for {export_mime_type} export (limit ~10MB), trying next format...")
|
||||
continue
|
||||
# For other errors, log but try next format
|
||||
logger.warning(f"Failed to export '{file_name}' as {export_mime_type}: {e}")
|
||||
|
||||
if not content_downloaded:
|
||||
# All formats failed
|
||||
error_msg = f"Failed to export '{file_name}' in any format"
|
||||
if last_error:
|
||||
error_msg += f": {last_error}"
|
||||
raise ValueError(error_msg)
|
||||
|
||||
async def _download_regular_file(self, file_id: str, file_name: str, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
Download a regular (non-Workspace) file from Google Drive with streaming.
|
||||
|
||||
Args:
|
||||
file_id: Google Drive file ID
|
||||
file_name: Name of the file for logging
|
||||
chunk_size: Size of chunks to yield
|
||||
|
||||
Yields:
|
||||
File content in chunks
|
||||
"""
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
request = self.service.service.files().get_media(fileId=file_id)
|
||||
|
||||
# Use chunked download for memory efficiency
|
||||
class ChunkedDownloadStream:
|
||||
def __init__(self, chunk_size):
|
||||
self.buffer = bytearray()
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
def write(self, data):
|
||||
self.buffer.extend(data)
|
||||
return len(data)
|
||||
|
||||
def read_chunks(self):
|
||||
"""Read and yield accumulated chunks."""
|
||||
while len(self.buffer) >= self.chunk_size:
|
||||
chunk = bytes(self.buffer[:self.chunk_size])
|
||||
del self.buffer[:self.chunk_size]
|
||||
yield chunk
|
||||
|
||||
def read_remaining(self):
|
||||
"""Read any remaining data."""
|
||||
if self.buffer:
|
||||
yield bytes(self.buffer)
|
||||
self.buffer.clear()
|
||||
|
||||
stream = ChunkedDownloadStream(chunk_size)
|
||||
downloader = MediaIoBaseDownload(stream, request, chunksize=chunk_size)
|
||||
|
||||
done = False
|
||||
while not done:
|
||||
status, done = downloader.next_chunk()
|
||||
if status:
|
||||
logger.debug(f"Download progress for '{file_name}': {int(status.progress() * 100)}%")
|
||||
|
||||
# Yield accumulated chunks
|
||||
for chunk in stream.read_chunks():
|
||||
yield chunk
|
||||
|
||||
# Yield any remaining data
|
||||
for chunk in stream.read_remaining():
|
||||
yield chunk
|
||||
|
||||
logger.info(f"Successfully downloaded '{file_name}' (ID: {file_id})")
|
||||
|
||||
async def get_service_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the Google Drive service.
|
||||
|
||||
Returns:
|
||||
Dictionary with service information
|
||||
"""
|
||||
if not self.service.is_configured():
|
||||
return {
|
||||
'configured': False,
|
||||
'error': 'Google Drive service is not configured'
|
||||
}
|
||||
|
||||
email = self.service.get_service_account_email()
|
||||
|
||||
return {
|
||||
'configured': True,
|
||||
'provider': 'google_drive',
|
||||
'service_account_email': email,
|
||||
'scopes': ['https://www.googleapis.com/auth/drive.readonly']
|
||||
}
|
||||
|
||||
async def sync_folder_with_metadata(
|
||||
self,
|
||||
folder_id: str,
|
||||
existing_files: Dict[str, Any],
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Sync a Google Drive folder with change detection.
|
||||
|
||||
This method extends the base sync_folder to handle:
|
||||
- Change detection based on modification times
|
||||
- File updates and removals
|
||||
- Progress tracking
|
||||
|
||||
Args:
|
||||
folder_id: Google Drive folder ID
|
||||
existing_files: Map of Google Drive file IDs to local file metadata
|
||||
context: Optional context to pass through hooks
|
||||
"""
|
||||
context = context or {}
|
||||
|
||||
# Emit sync started event
|
||||
await self.emit_hook('sync_started', {
|
||||
'folder_id': folder_id,
|
||||
'context': context
|
||||
})
|
||||
|
||||
try:
|
||||
# List files in the folder
|
||||
gdrive_files = await self.list_files(folder_id, recursive=context.get('include_nested', True))
|
||||
|
||||
logger.info(f"Google Drive sync: Found {len(gdrive_files)} files in folder {folder_id}")
|
||||
|
||||
# Track files to process
|
||||
gdrive_file_ids = set()
|
||||
files_to_process = []
|
||||
|
||||
for file_info in gdrive_files:
|
||||
gdrive_id = file_info['id']
|
||||
gdrive_file_ids.add(gdrive_id)
|
||||
|
||||
# Check if file exists and needs updating
|
||||
if gdrive_id in existing_files:
|
||||
existing_file = existing_files[gdrive_id]
|
||||
local_modified = existing_file.get('modified_time')
|
||||
gdrive_modified = file_info['modified_time']
|
||||
|
||||
if local_modified != gdrive_modified:
|
||||
# File was modified, mark for update
|
||||
await self.emit_hook('file_updated', {
|
||||
'file_info': file_info,
|
||||
'old_file': existing_file,
|
||||
'context': context
|
||||
})
|
||||
files_to_process.append(file_info)
|
||||
else:
|
||||
# File is up to date
|
||||
await self.emit_hook('file_unchanged', {
|
||||
'file_info': file_info,
|
||||
'context': context
|
||||
})
|
||||
else:
|
||||
# New file
|
||||
await self.emit_hook('file_new', {
|
||||
'file_info': file_info,
|
||||
'context': context
|
||||
})
|
||||
files_to_process.append(file_info)
|
||||
|
||||
# Identify removed files
|
||||
existing_gdrive_ids = set(existing_files.keys())
|
||||
removed_file_ids = existing_gdrive_ids - gdrive_file_ids
|
||||
|
||||
for removed_id in removed_file_ids:
|
||||
await self.emit_hook('file_removed', {
|
||||
'file_id': removed_id,
|
||||
'file_info': existing_files[removed_id],
|
||||
'context': context
|
||||
})
|
||||
|
||||
# Process new and updated files
|
||||
for i, file_info in enumerate(files_to_process):
|
||||
try:
|
||||
# Emit progress
|
||||
await self.emit_hook('sync_progress', {
|
||||
'current': i + 1,
|
||||
'total': len(files_to_process),
|
||||
'file_name': file_info['name'],
|
||||
'context': context
|
||||
})
|
||||
|
||||
# Skip Google Workspace files that can't be processed
|
||||
mime_type = file_info['mime_type']
|
||||
if mime_type in [
|
||||
'application/vnd.google-apps.site',
|
||||
'application/vnd.google-apps.form',
|
||||
'application/vnd.google-apps.map',
|
||||
'application/vnd.google-apps.drawing'
|
||||
]:
|
||||
logger.warning(f"Skipping unsupported Google Workspace file: {file_info['name']}")
|
||||
await self.emit_hook('file_skipped', {
|
||||
'file_info': file_info,
|
||||
'reason': 'Unsupported Google Workspace file type',
|
||||
'context': context
|
||||
})
|
||||
continue
|
||||
|
||||
# Download file content
|
||||
content_chunks = []
|
||||
async for chunk in self.download_file(file_info['id']):
|
||||
content_chunks.append(chunk)
|
||||
content = b''.join(content_chunks)
|
||||
|
||||
original_mime = file_info.get('mime_type', '')
|
||||
if original_mime == 'application/vnd.google-apps.document':
|
||||
# Google Docs are exported as text for knowledge base
|
||||
file_info['mime_type'] = 'text/plain'
|
||||
file_info['export_format'] = 'text'
|
||||
elif original_mime == 'application/vnd.google-apps.spreadsheet':
|
||||
# Sheets are exported as CSV
|
||||
file_info['mime_type'] = 'text/csv'
|
||||
file_info['export_format'] = 'csv'
|
||||
elif original_mime == 'application/vnd.google-apps.presentation':
|
||||
# Presentations are still exported as PDF (no text export available)
|
||||
file_info['mime_type'] = 'application/pdf'
|
||||
file_info['export_format'] = 'pdf'
|
||||
|
||||
# Log content info for debugging
|
||||
logger.info(f"Downloaded {file_info['name']}: {len(content)} bytes, original_mime: {original_mime}, final_mime: {file_info.get('mime_type', 'unknown')}")
|
||||
|
||||
# Skip if content is empty
|
||||
if not content:
|
||||
logger.warning(f"File {file_info['name']} has no content, skipping")
|
||||
await self.emit_hook('file_skipped', {
|
||||
'file_info': file_info,
|
||||
'reason': 'Empty content',
|
||||
'context': context
|
||||
})
|
||||
continue
|
||||
|
||||
# Emit file ready event
|
||||
await self.emit_hook('file_ready', {
|
||||
'file_info': file_info,
|
||||
'content': content,
|
||||
'context': context
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file {file_info['name']}: {e}")
|
||||
await self.emit_hook('file_error', {
|
||||
'file_info': file_info,
|
||||
'error': str(e),
|
||||
'context': context
|
||||
})
|
||||
|
||||
# Emit sync completed event
|
||||
await self.emit_hook('sync_completed', {
|
||||
'folder_id': folder_id,
|
||||
'total_files': len(gdrive_files),
|
||||
'processed_files': len(files_to_process),
|
||||
'removed_files': len(removed_file_ids),
|
||||
'context': context
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during sync: {e}")
|
||||
await self.emit_hook('sync_error', {
|
||||
'folder_id': folder_id,
|
||||
'error': str(e),
|
||||
'context': context
|
||||
})
|
||||
raise
|
||||
125
backend/open_webui/content_sources/registry.py
Normal file
125
backend/open_webui/content_sources/registry.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""
|
||||
Content Source Registry
|
||||
|
||||
Global registry for content source providers with hook support.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from .base import ContentSourceProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSourceRegistry:
|
||||
"""Registry for content source providers with global hook support."""
|
||||
|
||||
def __init__(self):
|
||||
self._providers: Dict[str, ContentSourceProvider] = {}
|
||||
self._global_hooks: Dict[str, List[Callable]] = {}
|
||||
|
||||
def register_provider(self, name: str, provider: ContentSourceProvider) -> None:
|
||||
"""Register a content source provider."""
|
||||
self._providers[name] = provider
|
||||
logger.info(f"Registered content source provider: {name}")
|
||||
|
||||
def unregister_provider(self, name: str) -> None:
|
||||
"""Unregister a content source provider."""
|
||||
if name in self._providers:
|
||||
self._providers.pop(name)
|
||||
logger.info(f"Unregistered content source provider: {name}")
|
||||
|
||||
def get_provider(self, name: str) -> Optional[ContentSourceProvider]:
|
||||
"""Get a content source provider by name."""
|
||||
return self._providers.get(name)
|
||||
|
||||
def get_all_providers(self) -> Dict[str, ContentSourceProvider]:
|
||||
"""Get all registered providers."""
|
||||
return self._providers.copy()
|
||||
|
||||
def register_global_hook(self, event: str, handler: Callable) -> None:
|
||||
"""
|
||||
Register a global hook that will be called for all providers.
|
||||
|
||||
Args:
|
||||
event: The event name to listen for
|
||||
handler: Async callable that will be invoked when the event is emitted
|
||||
"""
|
||||
if event not in self._global_hooks:
|
||||
self._global_hooks[event] = []
|
||||
self._global_hooks[event].append(handler)
|
||||
logger.debug(f"Registered global hook for event '{event}'")
|
||||
|
||||
async def emit_hook(self, event: str, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Emit a hook event to all global handlers and registered providers.
|
||||
|
||||
Args:
|
||||
event: The event name
|
||||
data: Data to pass to the handlers
|
||||
"""
|
||||
# Call global hooks first
|
||||
for handler in self._global_hooks.get(event, []):
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler(data)
|
||||
else:
|
||||
handler(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in global hook handler for event '{event}': {e}")
|
||||
|
||||
# Then emit to all registered providers
|
||||
for provider_name, provider in self._providers.items():
|
||||
try:
|
||||
await provider.emit_hook(event, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error emitting hook '{event}' to provider '{provider_name}': {e}")
|
||||
|
||||
|
||||
# Global registry instance
|
||||
content_source_registry = ContentSourceRegistry()
|
||||
|
||||
# Define standard hook events
|
||||
HOOK_EVENTS = {
|
||||
# File operations
|
||||
'before_file_add': 'Before file is added to knowledge base',
|
||||
'after_file_add': 'After file is added to knowledge base',
|
||||
'before_file_remove': 'Before file is removed from knowledge base',
|
||||
'after_file_remove': 'After file is removed from knowledge base',
|
||||
'before_file_update': 'Before file is updated in knowledge base',
|
||||
'after_file_update': 'After file is updated in knowledge base',
|
||||
|
||||
# Knowledge base operations
|
||||
'before_knowledge_create': 'Before knowledge base is created',
|
||||
'after_knowledge_create': 'After knowledge base is created',
|
||||
'before_knowledge_update': 'Before knowledge base is updated',
|
||||
'after_knowledge_update': 'After knowledge base is updated',
|
||||
'before_knowledge_delete': 'Before knowledge base is deleted',
|
||||
'after_knowledge_delete': 'After knowledge base is deleted',
|
||||
'before_knowledge_reset': 'Before knowledge base is reset',
|
||||
'after_knowledge_reset': 'After knowledge base is reset',
|
||||
|
||||
# Batch operations
|
||||
'before_files_batch_add': 'Before multiple files are added',
|
||||
'after_files_batch_add': 'After multiple files are added',
|
||||
|
||||
# Sync operations (for content sources)
|
||||
'sync_started': 'Sync operation started',
|
||||
'sync_completed': 'Sync operation completed',
|
||||
'sync_error': 'Error during sync operation',
|
||||
'file_ready': 'File downloaded and ready for processing',
|
||||
'file_error': 'Error processing file',
|
||||
|
||||
# Content sync operations
|
||||
'before_content_sync': 'Before content sync starts',
|
||||
'after_content_sync': 'After content sync completes',
|
||||
'content_sync_error': 'Error during content sync',
|
||||
'sync_progress': 'Progress update during sync',
|
||||
'file_new': 'New file detected during sync',
|
||||
'file_updated': 'File updated during sync',
|
||||
'file_unchanged': 'File unchanged during sync',
|
||||
'file_removed': 'File removed detected during sync',
|
||||
'file_skipped': 'File skipped during sync',
|
||||
}
|
||||
261
backend/open_webui/content_sources/scheduler.py
Normal file
261
backend/open_webui/content_sources/scheduler.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""Generic content source sync scheduler for all providers."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from open_webui.env import ENV
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.content_sources import content_source_factory, content_source_registry
|
||||
from open_webui.models.files import Files
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSourceScheduler:
|
||||
"""Generic background scheduler for automatic content source sync.
|
||||
|
||||
This scheduler handles sync for all content source providers (Google Drive,
|
||||
Dropbox, OneDrive, etc.) in a unified way.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.running: bool = False
|
||||
self.sync_tasks: Dict[str, Any] = {}
|
||||
self.scheduler_task: Optional[asyncio.Task] = None
|
||||
# In dev environment, check every minute for faster testing
|
||||
# In production, check every hour
|
||||
self.check_interval: int = 60 if ENV == "dev" else 3600
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the background sync scheduler."""
|
||||
if self.running:
|
||||
log.warning("Sync scheduler already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
log.info("Starting content source sync scheduler")
|
||||
|
||||
# Start the main scheduler loop as a background task
|
||||
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the background sync scheduler."""
|
||||
log.info("Stopping content source sync scheduler")
|
||||
self.running = False
|
||||
|
||||
# Cancel the scheduler task if it exists
|
||||
if self.scheduler_task and not self.scheduler_task.done():
|
||||
self.scheduler_task.cancel()
|
||||
try:
|
||||
await self.scheduler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Cancel all running sync tasks
|
||||
for task_id, task in self.sync_tasks.items():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
log.info(f"Cancelled sync task: {task_id}")
|
||||
|
||||
async def _scheduler_loop(self) -> None:
|
||||
"""Main scheduler loop that checks for sync tasks."""
|
||||
while self.running:
|
||||
try:
|
||||
await self._check_and_sync_knowledge_bases()
|
||||
except Exception as e:
|
||||
log.error(f"Error in sync scheduler loop: {e}", exc_info=True)
|
||||
|
||||
# Wait before next check
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
async def _check_and_sync_knowledge_bases(self) -> None:
|
||||
"""Check all knowledge bases for content source sync needs."""
|
||||
try:
|
||||
# Run synchronous database operation in a thread pool to avoid blocking
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
knowledge_bases = await loop.run_in_executor(None, Knowledges.get_knowledge_bases)
|
||||
except Exception as e:
|
||||
log.error(f"Error fetching knowledge bases: {e}")
|
||||
return
|
||||
|
||||
if ENV == "dev":
|
||||
log.info(f"Checking {len(knowledge_bases)} knowledge bases for sync needs")
|
||||
|
||||
for kb in knowledge_bases:
|
||||
if not kb.data:
|
||||
continue
|
||||
|
||||
# Check for sync metadata (new structure)
|
||||
sync_metadata = kb.data.get("sync_metadata", {})
|
||||
if not sync_metadata:
|
||||
continue
|
||||
|
||||
if ENV == "dev":
|
||||
log.info(f"KB {kb.id} has sync_metadata for providers: {list(sync_metadata.keys())}")
|
||||
|
||||
# Check each provider's sync configuration
|
||||
for provider_name, provider_config in sync_metadata.items():
|
||||
if not provider_config:
|
||||
continue
|
||||
|
||||
# Get sync interval from options
|
||||
options = provider_config.get("options", {})
|
||||
sync_interval_days = options.get("sync_interval_days", 1)
|
||||
last_sync = provider_config.get("last_sync", 0)
|
||||
|
||||
# Convert sync interval to seconds
|
||||
sync_interval_seconds = sync_interval_days * 24 * 60 * 60
|
||||
current_time = time.time()
|
||||
|
||||
# Log for debugging in dev mode
|
||||
if ENV == "dev":
|
||||
time_since_last_sync = current_time - last_sync
|
||||
time_until_next_sync = sync_interval_seconds - time_since_last_sync
|
||||
log.info(f"KB {kb.id} - Provider {provider_name}: "
|
||||
f"sync_interval_days={sync_interval_days} ({sync_interval_seconds}s), "
|
||||
f"last_sync={datetime.fromtimestamp(last_sync).isoformat() if last_sync else 'never'}, "
|
||||
f"time_since_last={time_since_last_sync:.1f}s, "
|
||||
f"time_until_next={time_until_next_sync:.1f}s, "
|
||||
f"needs_sync={time_since_last_sync >= sync_interval_seconds}")
|
||||
|
||||
if current_time - last_sync >= sync_interval_seconds:
|
||||
# Schedule sync task
|
||||
task_id = f"{kb.id}_{provider_name}_{int(current_time)}"
|
||||
|
||||
if task_id not in self.sync_tasks or self.sync_tasks[task_id].done():
|
||||
log.info(f"Scheduling sync for knowledge base {kb.id} with provider {provider_name}")
|
||||
|
||||
# Build content source config for sync
|
||||
content_source_config = {
|
||||
"provider": provider_name,
|
||||
"source_id": provider_config.get("source_id"),
|
||||
"options": options
|
||||
}
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._sync_knowledge_base(kb.id, provider_name, content_source_config)
|
||||
)
|
||||
self.sync_tasks[task_id] = task
|
||||
|
||||
async def _sync_knowledge_base(
|
||||
self,
|
||||
kb_id: str,
|
||||
provider_name: str,
|
||||
content_source_config: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Sync a specific knowledge base with its content source provider."""
|
||||
try:
|
||||
log.info(f"Starting scheduled sync for knowledge base {kb_id} with {provider_name}")
|
||||
|
||||
# Import the sync service
|
||||
from open_webui.content_sources.syncer import content_syncer
|
||||
|
||||
# Get knowledge base to get user ID (run in thread pool to avoid blocking)
|
||||
loop = asyncio.get_event_loop()
|
||||
kb = await loop.run_in_executor(None, Knowledges.get_knowledge_by_id, kb_id)
|
||||
if not kb:
|
||||
log.error(f"Knowledge base {kb_id} not found")
|
||||
return
|
||||
|
||||
# Perform actual sync using the sync service
|
||||
source_id = content_source_config.get("source_id")
|
||||
options = content_source_config.get("options", {})
|
||||
|
||||
# Add auto_sync flag for scheduled syncs
|
||||
options["auto_sync"] = True
|
||||
options["rollback_on_error"] = False # Don't rollback on scheduled syncs
|
||||
|
||||
try:
|
||||
# Use the sync service to perform the actual sync
|
||||
sync_result = await content_syncer.sync_provider_files(
|
||||
provider_name=provider_name,
|
||||
source_id=source_id,
|
||||
options=options,
|
||||
request=None, # No request object in scheduler context
|
||||
user_id=kb.user_id,
|
||||
knowledge_base_id=kb_id
|
||||
)
|
||||
|
||||
# Update knowledge base with sync results
|
||||
data = kb.data or {}
|
||||
|
||||
# Get event loop for async operations
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Update file IDs with successfully synced files
|
||||
existing_file_ids = set(data.get("file_ids", []))
|
||||
new_file_ids = set(sync_result.successful_files)
|
||||
updated_file_ids = list(existing_file_ids | new_file_ids)
|
||||
data["file_ids"] = updated_file_ids
|
||||
|
||||
# Update sync metadata in new structure
|
||||
data.setdefault("sync_metadata", {})
|
||||
data["sync_metadata"].setdefault(provider_name, {})
|
||||
|
||||
# Preserve existing configuration
|
||||
existing_config = data["sync_metadata"][provider_name]
|
||||
|
||||
# Update with new sync results
|
||||
data["sync_metadata"][provider_name].update({
|
||||
"source_id": source_id,
|
||||
"last_sync": time.time(),
|
||||
"options": options,
|
||||
"results": {
|
||||
"status": sync_result.status.value,
|
||||
"added": len(sync_result.added_files),
|
||||
"updated": len(sync_result.updated_files),
|
||||
"failed": len(sync_result.failed_files),
|
||||
"duplicates": len(sync_result.duplicate_files),
|
||||
"changes": sync_result.changes
|
||||
}
|
||||
})
|
||||
|
||||
# Store any errors for monitoring
|
||||
if sync_result.errors:
|
||||
data["sync_metadata"][provider_name]["last_sync_errors"] = sync_result.errors[:5] # Keep last 5 errors
|
||||
|
||||
await loop.run_in_executor(None, Knowledges.update_knowledge_data_by_id, kb_id, data)
|
||||
|
||||
log.info(f"Completed scheduled sync for knowledge base {kb_id}: "
|
||||
f"status={sync_result.status.value}, "
|
||||
f"added={len(sync_result.added_files)}, "
|
||||
f"updated={len(sync_result.updated_files)}, "
|
||||
f"failed={len(sync_result.failed_files)}")
|
||||
|
||||
except Exception as sync_error:
|
||||
log.error(f"Sync service error for knowledge base {kb_id}: {sync_error}")
|
||||
|
||||
# Get event loop for async operations
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Update knowledge base with error status
|
||||
data = kb.data or {}
|
||||
data.setdefault("sync_metadata", {})
|
||||
data["sync_metadata"].setdefault(provider_name, {})
|
||||
|
||||
# Preserve existing configuration
|
||||
existing_config = data["sync_metadata"][provider_name]
|
||||
source_id = existing_config.get("source_id", source_id)
|
||||
existing_options = existing_config.get("options", {})
|
||||
|
||||
data["sync_metadata"][provider_name].update({
|
||||
"source_id": source_id,
|
||||
"last_sync": time.time(),
|
||||
"options": existing_options,
|
||||
"results": {
|
||||
"status": "failed",
|
||||
"error": str(sync_error)
|
||||
}
|
||||
})
|
||||
await loop.run_in_executor(None, Knowledges.update_knowledge_data_by_id, kb_id, data)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in scheduled sync for knowledge base {kb_id}: {e}", exc_info=True)
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
scheduler = ContentSourceScheduler()
|
||||
1385
backend/open_webui/content_sources/syncer.py
Normal file
1385
backend/open_webui/content_sources/syncer.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -89,6 +89,7 @@ from open_webui.routers import (
|
|||
memories,
|
||||
models,
|
||||
knowledge,
|
||||
content_sources,
|
||||
prompts,
|
||||
evaluations,
|
||||
skills,
|
||||
|
|
@ -564,7 +565,7 @@ from open_webui.tasks import (
|
|||
list_tasks,
|
||||
) # Import from tasks.py
|
||||
|
||||
from open_webui.services.google_drive_scheduler import google_drive_scheduler
|
||||
from open_webui.content_sources.scheduler import scheduler as content_source_scheduler
|
||||
|
||||
from open_webui.utils.redis import get_sentinels_from_env
|
||||
|
||||
|
|
@ -707,30 +708,26 @@ async def lifespan(app: FastAPI):
|
|||
# Mark application as ready to accept traffic from a startup perspective.
|
||||
app.state.startup_complete = True
|
||||
|
||||
# Start Google Drive sync scheduler if enabled
|
||||
if app.state.config.ENABLE_GOOGLE_DRIVE_FOLDER_SYNC:
|
||||
try:
|
||||
await google_drive_scheduler.start()
|
||||
log.info("Google Drive sync scheduler started successfully")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to start Google Drive sync scheduler: {e}")
|
||||
# Non-critical failure - continue application startup
|
||||
# Start content source sync scheduler if any providers are configured
|
||||
# The scheduler will automatically check which providers need syncing
|
||||
try:
|
||||
await content_source_scheduler.start()
|
||||
log.info("Content source sync scheduler started successfully")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to start content source sync scheduler: {e}")
|
||||
# Non-critical failure - continue application startup
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(app.state, 'redis_task_command_listener'):
|
||||
app.state.redis_task_command_listener.cancel()
|
||||
|
||||
# Stop Google Drive sync scheduler
|
||||
if (
|
||||
hasattr(app.state, "config")
|
||||
and app.state.config.ENABLE_GOOGLE_DRIVE_FOLDER_SYNC
|
||||
):
|
||||
try:
|
||||
await google_drive_scheduler.stop()
|
||||
log.info("Google Drive sync scheduler stopped")
|
||||
except Exception as e:
|
||||
log.error(f"Error stopping Google Drive sync scheduler: {e}")
|
||||
# Stop content source sync scheduler
|
||||
try:
|
||||
await content_source_scheduler.stop()
|
||||
log.info("Content source sync scheduler stopped")
|
||||
except Exception as e:
|
||||
log.error(f"Error stopping content source sync scheduler: {e}")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
|
@ -1537,6 +1534,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
|
|||
|
||||
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
|
||||
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
|
||||
app.include_router(content_sources.router, prefix='/api/v1/content-sources', tags=['content_sources'])
|
||||
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
|
||||
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
|
||||
app.include_router(skills.router, prefix='/api/v1/skills', tags=['skills'])
|
||||
|
|
|
|||
|
|
@ -48,6 +48,28 @@ class FileModel(BaseModel):
|
|||
|
||||
created_at: Optional[int] # timestamp in epoch
|
||||
updated_at: Optional[int] # timestamp in epoch
|
||||
|
||||
@property
|
||||
def provider_info(self) -> dict:
|
||||
"""Get provider information from the data field."""
|
||||
if self.data and isinstance(self.data, dict):
|
||||
return self.data.get("provider_info", {})
|
||||
return {}
|
||||
|
||||
@property
|
||||
def provider(self) -> Optional[str]:
|
||||
"""Get the provider name."""
|
||||
return self.provider_info.get("provider")
|
||||
|
||||
@property
|
||||
def provider_file_id(self) -> Optional[str]:
|
||||
"""Get the provider's file ID."""
|
||||
return self.provider_info.get("provider_file_id")
|
||||
|
||||
@property
|
||||
def provider_sync_enabled(self) -> bool:
|
||||
"""Check if provider sync is enabled."""
|
||||
return self.provider_info.get("provider_sync_enabled", False)
|
||||
|
||||
|
||||
####################
|
||||
|
|
@ -177,6 +199,14 @@ class FilesTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_provider_info(self, id: str, db: Optional[Session] = None) -> Optional[dict]:
|
||||
"""Get provider information for a file."""
|
||||
with get_db_context(db) as db:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
if file and file.data and isinstance(file.data, dict):
|
||||
return file.data.get("provider_info", {})
|
||||
return None
|
||||
|
||||
def get_file_metadata_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FileMetadataResponse]:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
|
|
@ -315,6 +345,24 @@ class FilesTable:
|
|||
self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None
|
||||
) -> Optional[FileModel]:
|
||||
with get_db_context(db) as db:
|
||||
|
||||
def get_files_by_provider(
|
||||
self, provider: str, user_id: Optional[str] = None, db: Optional[Session] = None
|
||||
) -> list[FileModel]:
|
||||
"""Get all files from a specific provider, optionally filtered by user."""
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(File)
|
||||
if user_id:
|
||||
query = query.filter_by(user_id=user_id)
|
||||
files = []
|
||||
for file in query.all():
|
||||
if file.data and isinstance(file.data, dict):
|
||||
provider_info = file.data.get("provider_info", {})
|
||||
if provider_info.get("provider") == provider:
|
||||
files.append(FileModel.model_validate(file))
|
||||
return files
|
||||
|
||||
|
||||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
|
||||
|
|
@ -372,6 +420,45 @@ class FilesTable:
|
|||
|
||||
def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
|
||||
def update_file_provider_info_by_id(
|
||||
self,
|
||||
id: str,
|
||||
provider: Optional[str] = None,
|
||||
provider_file_id: Optional[str] = None,
|
||||
provider_modified_time: Optional[str] = None,
|
||||
provider_sync_enabled: Optional[bool] = None,
|
||||
provider_metadata: Optional[dict] = None,
|
||||
db: Optional[Session] = None
|
||||
) -> Optional[FileModel]:
|
||||
"""Update provider information for a file."""
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
file = db.query(File).filter_by(id=id).first()
|
||||
if not file:
|
||||
return None
|
||||
if not file.data:
|
||||
file.data = {}
|
||||
if "provider_info" not in file.data:
|
||||
file.data["provider_info"] = {}
|
||||
updates = {
|
||||
"provider": provider,
|
||||
"provider_file_id": provider_file_id,
|
||||
"provider_modified_time": provider_modified_time,
|
||||
"provider_sync_enabled": provider_sync_enabled,
|
||||
"provider_metadata": provider_metadata,
|
||||
}
|
||||
for key, value in updates.items():
|
||||
if value is not None:
|
||||
file.data["provider_info"][key] = value
|
||||
file.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return FileModel.model_validate(file)
|
||||
except Exception as e:
|
||||
log.exception(f'Error updating file provider info: {e}')
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
db.query(File).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
|
|
|||
279
backend/open_webui/routers/content_sources.py
Normal file
279
backend/open_webui/routers/content_sources.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""
|
||||
Content Sources Router
|
||||
|
||||
Generic router for managing content source providers.
|
||||
Provides provider-agnostic endpoints for listing providers and getting provider information.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.content_sources import content_source_factory, content_source_registry
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# Response Models
|
||||
############################
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
"""Information about a content source provider."""
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
configured: bool
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProviderServiceInfo(BaseModel):
|
||||
"""Service information for a specific provider."""
|
||||
provider: str
|
||||
configured: bool
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class ProviderListResponse(BaseModel):
|
||||
"""Response containing list of available providers."""
|
||||
providers: List[ProviderInfo]
|
||||
|
||||
|
||||
############################
|
||||
# Helper Functions
|
||||
############################
|
||||
|
||||
|
||||
def get_provider_display_info(provider_name: str) -> Dict[str, str]:
|
||||
"""Get display information for providers."""
|
||||
# This could be expanded with more providers
|
||||
provider_info = {
|
||||
"google_drive": {
|
||||
"display_name": "Google Drive",
|
||||
"description": "Sync files from Google Drive folders"
|
||||
},
|
||||
"onedrive": {
|
||||
"display_name": "Microsoft OneDrive",
|
||||
"description": "Sync files from OneDrive folders"
|
||||
},
|
||||
"dropbox": {
|
||||
"display_name": "Dropbox",
|
||||
"description": "Sync files from Dropbox folders"
|
||||
},
|
||||
"sharepoint": {
|
||||
"display_name": "SharePoint",
|
||||
"description": "Sync files from SharePoint document libraries"
|
||||
}
|
||||
}
|
||||
|
||||
return provider_info.get(provider_name, {
|
||||
"display_name": provider_name.replace("_", " ").title(),
|
||||
"description": f"Content source provider: {provider_name}"
|
||||
})
|
||||
|
||||
|
||||
############################
|
||||
# Endpoints
|
||||
############################
|
||||
|
||||
|
||||
@router.get("/", response_model=ProviderListResponse)
|
||||
async def list_content_source_providers(
|
||||
user: UserModel = Depends(get_verified_user)
|
||||
) -> ProviderListResponse:
|
||||
"""
|
||||
List all available content source providers.
|
||||
|
||||
Returns information about each provider including whether it's configured.
|
||||
"""
|
||||
providers = []
|
||||
|
||||
# Get available provider types from factory
|
||||
available_providers = content_source_factory.get_available_providers()
|
||||
|
||||
for provider_name in available_providers:
|
||||
try:
|
||||
# Try to get the provider instance from registry
|
||||
provider = content_source_registry.get_provider(provider_name)
|
||||
|
||||
if provider:
|
||||
# Provider is registered, check if it's configured
|
||||
try:
|
||||
service_info = await provider.get_service_info()
|
||||
configured = service_info.get('configured', False)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to get service info for {provider_name}: {e}")
|
||||
configured = False
|
||||
else:
|
||||
# Provider is available but not registered
|
||||
configured = False
|
||||
|
||||
# Get display information
|
||||
display_info = get_provider_display_info(provider_name)
|
||||
|
||||
providers.append(ProviderInfo(
|
||||
name=provider_name,
|
||||
display_name=display_info["display_name"],
|
||||
description=display_info["description"],
|
||||
configured=configured
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing provider {provider_name}: {e}")
|
||||
continue
|
||||
|
||||
return ProviderListResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get("/{provider}/info", response_model=ProviderServiceInfo)
|
||||
async def get_provider_info(
|
||||
provider: str,
|
||||
user: UserModel = Depends(get_verified_user)
|
||||
) -> ProviderServiceInfo:
|
||||
"""
|
||||
Get provider-specific information.
|
||||
|
||||
This endpoint returns provider-specific metadata such as service account emails,
|
||||
API endpoints, quotas, or any other provider-specific information.
|
||||
|
||||
Args:
|
||||
provider: The provider name (e.g., 'google_drive', 'onedrive')
|
||||
|
||||
Returns:
|
||||
Provider service information including configuration status and metadata
|
||||
"""
|
||||
# Check if provider exists in factory
|
||||
available_providers = content_source_factory.get_available_providers()
|
||||
if provider not in available_providers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unknown content source provider: {provider}. Available providers: {', '.join(available_providers.keys())}"
|
||||
)
|
||||
|
||||
# Try to get provider from registry
|
||||
provider_instance = content_source_registry.get_provider(provider)
|
||||
|
||||
if not provider_instance:
|
||||
# Provider is available but not initialized/registered
|
||||
try:
|
||||
# Try to initialize the provider
|
||||
provider_instance = content_source_factory.get_provider(provider)
|
||||
content_source_registry.register_provider(provider, provider_instance)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize provider {provider}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to initialize provider {provider}: {str(e)}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get service information from the provider
|
||||
service_info = await provider_instance.get_service_info()
|
||||
|
||||
# Extract metadata, removing the 'configured' field to avoid duplication
|
||||
metadata = {k: v for k, v in service_info.items() if k not in ['configured', 'provider']}
|
||||
|
||||
return ProviderServiceInfo(
|
||||
provider=provider,
|
||||
configured=service_info.get('configured', False),
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error getting service info for provider {provider}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get service information: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{provider}/capabilities", response_model=Dict[str, Any])
|
||||
async def get_provider_capabilities(
|
||||
provider: str,
|
||||
user: UserModel = Depends(get_verified_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get capabilities and features supported by a provider.
|
||||
|
||||
This endpoint returns information about what operations the provider supports,
|
||||
such as folder sync, file filtering, nested folder support, etc.
|
||||
|
||||
Args:
|
||||
provider: The provider name
|
||||
|
||||
Returns:
|
||||
Dictionary of provider capabilities
|
||||
"""
|
||||
# Check if provider exists
|
||||
available_providers = content_source_factory.get_available_providers()
|
||||
if provider not in available_providers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unknown content source provider: {provider}"
|
||||
)
|
||||
|
||||
# Define capabilities for known providers
|
||||
# This could be moved to each provider class as a method
|
||||
capabilities = {
|
||||
"google_drive": {
|
||||
"supports_folder_sync": True,
|
||||
"supports_nested_folders": True,
|
||||
"supports_file_filtering": True,
|
||||
"supports_incremental_sync": True,
|
||||
"supports_oauth": False, # Currently using service account
|
||||
"supports_webhooks": False,
|
||||
"file_size_limit": "5TB",
|
||||
"supported_file_types": ["documents", "spreadsheets", "presentations", "pdfs", "text", "images"],
|
||||
"export_formats": {
|
||||
"google-docs": ["docx", "pdf", "txt", "html"],
|
||||
"google-sheets": ["xlsx", "csv", "pdf"],
|
||||
"google-slides": ["pptx", "pdf"]
|
||||
}
|
||||
},
|
||||
# Add more providers as they are implemented
|
||||
# This is an example, MUST be changed in the real implementation
|
||||
"onedrive": {
|
||||
"supports_folder_sync": True,
|
||||
"supports_nested_folders": True,
|
||||
"supports_file_filtering": True,
|
||||
"supports_incremental_sync": True,
|
||||
"supports_oauth": True,
|
||||
"supports_webhooks": True,
|
||||
"file_size_limit": "250GB",
|
||||
"supported_file_types": ["documents", "spreadsheets", "presentations", "pdfs", "text", "images"]
|
||||
},
|
||||
"dropbox": {
|
||||
"supports_folder_sync": True,
|
||||
"supports_nested_folders": True,
|
||||
"supports_file_filtering": True,
|
||||
"supports_incremental_sync": True,
|
||||
"supports_oauth": True,
|
||||
"supports_webhooks": True,
|
||||
"file_size_limit": "50GB",
|
||||
"supported_file_types": ["documents", "spreadsheets", "presentations", "pdfs", "text", "images"]
|
||||
}
|
||||
}
|
||||
|
||||
provider_capabilities = capabilities.get(provider, {
|
||||
"supports_folder_sync": False,
|
||||
"supports_nested_folders": False,
|
||||
"supports_file_filtering": False,
|
||||
"supports_incremental_sync": False,
|
||||
"supports_oauth": False,
|
||||
"supports_webhooks": False,
|
||||
"message": "Capabilities not defined for this provider"
|
||||
})
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"capabilities": provider_capabilities
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -8,6 +8,7 @@ import time
|
|||
import io
|
||||
import zipfile
|
||||
from urllib.parse import quote
|
||||
import base64
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import get_session
|
||||
|
|
@ -19,7 +20,7 @@ from open_webui.models.knowledge import (
|
|||
KnowledgeResponse,
|
||||
KnowledgeUserResponse,
|
||||
)
|
||||
from open_webui.models.files import Files, FileModel, FileMetadataResponse
|
||||
from open_webui.models.files import Files, FileModel, FileMetadataResponse, FileForm
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.routers.retrieval import (
|
||||
|
|
@ -28,13 +29,14 @@ from open_webui.routers.retrieval import (
|
|||
process_files_batch,
|
||||
BatchProcessFilesForm,
|
||||
)
|
||||
from open_webui.services.google_drive import google_drive_service
|
||||
from open_webui.storage.provider import Storage
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_verified_user, get_admin_user
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.misc import calculate_sha256_string
|
||||
from open_webui.content_sources import content_source_registry, content_source_factory
|
||||
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
|
@ -44,6 +46,41 @@ log = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# Helper Functions
|
||||
############################
|
||||
|
||||
def check_duplicate_in_vector_db(collection_name: str, file_hash: str) -> bool:
|
||||
"""Check if a file with the given hash already exists in the vector DB."""
|
||||
if not file_hash or not collection_name:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=collection_name,
|
||||
filter={"hash": file_hash},
|
||||
)
|
||||
|
||||
if result is not None and result.ids and result.ids[0]:
|
||||
return True
|
||||
except Exception as e:
|
||||
log.debug(f"Error checking duplicate in vector DB: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
############################
|
||||
# Response Models
|
||||
############################
|
||||
|
||||
|
||||
class KnowledgeFilesResponse(KnowledgeResponse):
|
||||
"""Knowledge base response with files list."""
|
||||
files: List[FileMetadataResponse] = []
|
||||
warnings: Optional[Dict[str, Any]] = None
|
||||
sync_results: Optional[Dict[str, Any]] = None
|
||||
|
||||
############################
|
||||
# getKnowledgeBases
|
||||
############################
|
||||
|
|
@ -270,6 +307,12 @@ async def create_new_knowledge(
|
|||
'sharing.public_knowledge',
|
||||
)
|
||||
|
||||
# Emit before hook
|
||||
await content_source_registry.emit_hook('before_knowledge_create', {
|
||||
'form_data': form_data.model_dump() if hasattr(form_data, 'model_dump') else form_data.__dict__,
|
||||
'user_id': user.id
|
||||
})
|
||||
|
||||
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
|
||||
|
||||
if knowledge:
|
||||
|
|
@ -280,6 +323,13 @@ async def create_new_knowledge(
|
|||
knowledge.name,
|
||||
knowledge.description,
|
||||
)
|
||||
|
||||
# Emit after hook
|
||||
await content_source_registry.emit_hook('after_knowledge_create', {
|
||||
'knowledge_base_id': knowledge.id,
|
||||
'knowledge_base': knowledge.model_dump() if hasattr(knowledge, 'model_dump') else knowledge.__dict__,
|
||||
'user_id': user.id
|
||||
})
|
||||
return knowledge
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
@ -385,6 +435,7 @@ async def reindex_knowledge_base_metadata_embeddings(
|
|||
class KnowledgeFilesResponse(KnowledgeResponse):
|
||||
files: Optional[list[FileMetadataResponse]] = None
|
||||
write_access: Optional[bool] = False
|
||||
sync_results: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.get('/{id}', response_model=Optional[KnowledgeFilesResponse])
|
||||
|
|
@ -660,6 +711,15 @@ def add_file_to_knowledge_by_id(
|
|||
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
|
||||
)
|
||||
|
||||
# Emit before hook
|
||||
await content_source_registry.emit_hook('before_file_add', {
|
||||
'knowledge_base_id': id,
|
||||
'file_id': form_data.file_id,
|
||||
'user_id': user.id,
|
||||
'file': file.model_dump() if hasattr(file, 'model_dump') else file.__dict__,
|
||||
'knowledge_base': knowledge.model_dump() if hasattr(knowledge, 'model_dump') else knowledge.__dict__
|
||||
})
|
||||
|
||||
# Add content to the vector database
|
||||
try:
|
||||
process_file(
|
||||
|
|
@ -888,6 +948,13 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: S
|
|||
|
||||
log.info(f'Deleting knowledge base: {id} (name: {knowledge.name})')
|
||||
|
||||
# Emit before hook
|
||||
await content_source_registry.emit_hook('before_knowledge_delete', {
|
||||
'knowledge_base_id': id,
|
||||
'knowledge_base': knowledge.model_dump() if hasattr(knowledge, 'model_dump') else knowledge.__dict__,
|
||||
'user_id': user.id
|
||||
})
|
||||
|
||||
# Get all models
|
||||
models = Models.get_all_models(db=db)
|
||||
log.info(f'Found {len(models)} models to check for knowledge base {id}')
|
||||
|
|
@ -959,6 +1026,13 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Se
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Emit before hook
|
||||
await content_source_registry.emit_hook('before_knowledge_reset', {
|
||||
'knowledge_base_id': id,
|
||||
'knowledge_base': knowledge.model_dump() if hasattr(knowledge, 'model_dump') else knowledge.__dict__,
|
||||
'user_id': user.id
|
||||
})
|
||||
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
except Exception as e:
|
||||
|
|
@ -1022,6 +1096,14 @@ async def add_files_to_knowledge_batch(
|
|||
detail=f'File {missing_ids[0]} not found',
|
||||
)
|
||||
|
||||
# Emit before hook
|
||||
await content_source_registry.emit_hook('before_files_batch_add', {
|
||||
'knowledge_base_id': id,
|
||||
'file_ids': [form.file_id for form in form_data],
|
||||
'user_id': user.id,
|
||||
'knowledge_base': knowledge.model_dump() if hasattr(knowledge, 'model_dump') else knowledge.__dict__
|
||||
})
|
||||
|
||||
# Process files
|
||||
try:
|
||||
result = await process_files_batch(
|
||||
|
|
@ -1039,6 +1121,14 @@ async def add_files_to_knowledge_batch(
|
|||
for file_id in successful_file_ids:
|
||||
Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=file_id, user_id=user.id, db=db)
|
||||
|
||||
# Emit after hook
|
||||
await content_source_registry.emit_hook('after_files_batch_add', {
|
||||
'knowledge_base_id': id,
|
||||
'file_ids': successful_file_ids,
|
||||
'user_id': user.id,
|
||||
'knowledge_base': knowledge.model_dump() if hasattr(knowledge, 'model_dump') else knowledge.__dict__
|
||||
})
|
||||
|
||||
# If there were any errors, include them in the response
|
||||
if result.errors:
|
||||
error_details = [f'{err.file_id}: {err.error}' for err in result.errors]
|
||||
|
|
@ -1109,59 +1199,42 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Sess
|
|||
|
||||
|
||||
############################
|
||||
# Google Drive Sync
|
||||
# Sync Content from Provider
|
||||
############################
|
||||
|
||||
|
||||
class GoogleDriveSyncForm(BaseModel):
|
||||
folder_id: str
|
||||
include_nested: bool = True
|
||||
sync_interval_days: float = 1.0 # Float to support fractional days
|
||||
# Import the sync service
|
||||
from open_webui.content_sources.syncer import content_syncer
|
||||
|
||||
|
||||
class GoogleDriveServiceAccountResponse(BaseModel):
|
||||
email: str
|
||||
class ContentSourceSyncForm(BaseModel):
|
||||
"""Form for syncing content from a content source provider."""
|
||||
provider: str
|
||||
source_id: str
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/google-drive/service-account-email",
|
||||
response_model=GoogleDriveServiceAccountResponse,
|
||||
)
|
||||
async def get_google_drive_service_account_email(
|
||||
user: UserModel = Depends(get_verified_user),
|
||||
) -> GoogleDriveServiceAccountResponse:
|
||||
"""Get the Google Drive service account email for sharing folders."""
|
||||
if not google_drive_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Google Drive service account not configured",
|
||||
)
|
||||
|
||||
email = google_drive_service.get_service_account_email()
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to get service account email",
|
||||
)
|
||||
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@router.post("/{id}/google-drive/sync", response_model=Optional[KnowledgeFilesResponse])
|
||||
async def sync_google_drive_folder(
|
||||
id: str,
|
||||
form_data: GoogleDriveSyncForm,
|
||||
@router.post("/{id}/sync", response_model=Optional[KnowledgeFilesResponse])
|
||||
async def sync_content_from_provider(
|
||||
request: Request,
|
||||
user: UserModel = Depends(get_verified_user),
|
||||
) -> Optional[KnowledgeFilesResponse]:
|
||||
"""Sync a Google Drive folder with the knowledge base."""
|
||||
id: str,
|
||||
form_data: ContentSourceSyncForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""
|
||||
Sync content from a content source provider to a knowledge base.
|
||||
|
||||
This endpoint now acts as a thin orchestrator, delegating the heavy
|
||||
sync logic to the ContentSyncService.
|
||||
"""
|
||||
# Validate knowledge base and permissions
|
||||
knowledge = Knowledges.get_knowledge_by_id(id=id)
|
||||
if not knowledge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control)
|
||||
|
|
@ -1171,240 +1244,79 @@ async def sync_google_drive_folder(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if not google_drive_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Google Drive service account not configured",
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Get current files in knowledge base
|
||||
current_file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
|
||||
current_files = Files.get_files_by_ids(current_file_ids)
|
||||
|
||||
# Create a map of Google Drive file IDs to local file IDs
|
||||
gdrive_file_map = {}
|
||||
for file in current_files:
|
||||
if file.meta and file.meta.get("google_drive_id"):
|
||||
gdrive_file_map[file.meta["google_drive_id"]] = file.id
|
||||
|
||||
# Get files from Google Drive folder
|
||||
gdrive_files = google_drive_service.list_folder_files(
|
||||
form_data.folder_id, form_data.include_nested
|
||||
# Use the sync service to perform the sync
|
||||
log.info(f"Starting sync for knowledge base {id} with provider {form_data.provider}")
|
||||
|
||||
sync_result = await content_syncer.sync_provider_files(
|
||||
provider_name=form_data.provider,
|
||||
source_id=form_data.source_id,
|
||||
options=form_data.options or {},
|
||||
request=request,
|
||||
user_id=user.id,
|
||||
knowledge_base_id=id
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Google Drive sync: Found {len(gdrive_files)} files in folder {form_data.folder_id}"
|
||||
)
|
||||
for gdrive_file in gdrive_files:
|
||||
log.info(
|
||||
f"Google Drive file: {gdrive_file.get('name', 'Unknown')} (ID: {gdrive_file.get('id', 'Unknown')}, Type: {gdrive_file.get('mimeType', 'Unknown')})"
|
||||
)
|
||||
|
||||
# Track files to keep and files to add
|
||||
files_to_keep = set()
|
||||
files_to_add = []
|
||||
|
||||
for gdrive_file in gdrive_files:
|
||||
gdrive_id = gdrive_file["id"]
|
||||
|
||||
if gdrive_id in gdrive_file_map:
|
||||
# File exists, check if it needs updating
|
||||
local_file_id = gdrive_file_map[gdrive_id]
|
||||
local_file = Files.get_file_by_id(local_file_id)
|
||||
|
||||
if local_file and local_file.meta:
|
||||
local_modified = local_file.meta.get("google_drive_modified")
|
||||
gdrive_modified = gdrive_file["modifiedTime"]
|
||||
|
||||
if local_modified != gdrive_modified:
|
||||
# File was modified, re-download and update
|
||||
files_to_add.append(gdrive_file)
|
||||
# Remove old file
|
||||
Files.delete_file_by_id(local_file_id)
|
||||
else:
|
||||
# File is up to date, keep it
|
||||
files_to_keep.add(local_file_id)
|
||||
else:
|
||||
# Local file metadata is missing, re-download
|
||||
files_to_add.append(gdrive_file)
|
||||
else:
|
||||
# New file, add it
|
||||
files_to_add.append(gdrive_file)
|
||||
|
||||
# Remove files that are no longer in Google Drive
|
||||
files_to_remove = set(current_file_ids) - files_to_keep
|
||||
for file_id in files_to_remove:
|
||||
Files.delete_file_by_id(file_id)
|
||||
|
||||
# Download and add new/updated files
|
||||
new_file_ids = list(files_to_keep)
|
||||
|
||||
log.info(
|
||||
f"Google Drive sync: Processing {len(files_to_add)} files to add/update"
|
||||
)
|
||||
|
||||
for gdrive_file in files_to_add:
|
||||
try:
|
||||
log.info(
|
||||
f"Google Drive sync: Processing file '{gdrive_file.get('name', 'Unknown')}' (ID: {gdrive_file.get('id', 'Unknown')})"
|
||||
)
|
||||
|
||||
# Download file from Google Drive
|
||||
file_content, filename = google_drive_service.download_file(
|
||||
gdrive_file["id"], gdrive_file
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Google Drive sync: Downloaded file '{filename}', size: {len(file_content)} bytes"
|
||||
)
|
||||
|
||||
# Debug: Check if content is actually text
|
||||
try:
|
||||
content_preview = (
|
||||
file_content.decode("utf-8")[:200]
|
||||
if isinstance(file_content, bytes)
|
||||
else str(file_content)[:200]
|
||||
)
|
||||
log.info(
|
||||
f"Google Drive sync: File content preview: {content_preview}..."
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Google Drive sync: Could not decode file content: {e}")
|
||||
|
||||
# Create file object
|
||||
file_obj = io.BytesIO(file_content)
|
||||
file_obj.name = filename
|
||||
|
||||
# Upload to storage
|
||||
import uuid
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
tags = {
|
||||
"OpenWebUI-User-Email": user.email,
|
||||
"OpenWebUI-User-Id": user.id,
|
||||
"OpenWebUI-User-Name": user.name,
|
||||
"OpenWebUI-File-Id": file_id,
|
||||
}
|
||||
|
||||
contents, file_path = Storage.upload_file(
|
||||
file_obj, f"{file_id}_{filename}", tags
|
||||
)
|
||||
log.info(
|
||||
f"Google Drive sync: Uploaded to storage, contents size: {len(contents)} bytes"
|
||||
)
|
||||
|
||||
# Create file record with content in data field
|
||||
from open_webui.models.files import FileForm
|
||||
|
||||
# Convert bytes to string for text content
|
||||
text_content = (
|
||||
file_content.decode("utf-8")
|
||||
if isinstance(file_content, bytes)
|
||||
else str(file_content)
|
||||
)
|
||||
|
||||
file_item = Files.insert_new_file(
|
||||
user.id,
|
||||
FileForm(
|
||||
id=file_id,
|
||||
filename=filename,
|
||||
path=file_path,
|
||||
data={
|
||||
"content": text_content, # Store the actual text content
|
||||
},
|
||||
meta={
|
||||
"name": filename,
|
||||
"content_type": "text/plain", # Changed from application/octet-stream
|
||||
"size": len(contents),
|
||||
"google_drive_id": gdrive_file["id"],
|
||||
"google_drive_modified": gdrive_file["modifiedTime"],
|
||||
"google_drive_path": gdrive_file["path"],
|
||||
"collection_name": id,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if file_item:
|
||||
new_file_ids.append(file_id)
|
||||
log.info(
|
||||
f"Google Drive sync: Created file record for '{filename}' (File ID: {file_id})"
|
||||
)
|
||||
|
||||
# Process file for vector storage
|
||||
try:
|
||||
from open_webui.routers.retrieval import process_file
|
||||
|
||||
process_file(
|
||||
request,
|
||||
ProcessFileForm(
|
||||
file_id=file_id,
|
||||
collection_name=id,
|
||||
),
|
||||
user,
|
||||
)
|
||||
log.info(
|
||||
f"Google Drive sync: Successfully processed file '{filename}' for vector storage"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Failed to process file {file_id} ('{filename}') for vector storage: {e}"
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
f"Google Drive sync: Failed to create file record for '{filename}'"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to sync file {gdrive_file['name']}: {e}")
|
||||
continue
|
||||
|
||||
# Update knowledge base data
|
||||
sync_data = {
|
||||
"google_drive_folder_id": form_data.folder_id,
|
||||
"google_drive_include_nested": form_data.include_nested,
|
||||
"google_drive_sync_interval_days": form_data.sync_interval_days,
|
||||
"google_drive_last_sync": int(time.time()),
|
||||
|
||||
# Update knowledge base with sync results
|
||||
data = knowledge.data or {}
|
||||
|
||||
# Update file IDs with successfully synced files and remove deleted ones
|
||||
existing_file_ids = set(data.get("file_ids", []))
|
||||
new_file_ids = set(sync_result.successful_files)
|
||||
removed_file_ids = set(sync_result.removed_files) if hasattr(sync_result, 'removed_files') else set()
|
||||
|
||||
# Add new files and remove deleted ones
|
||||
updated_file_ids = list((existing_file_ids | new_file_ids) - removed_file_ids)
|
||||
data["file_ids"] = updated_file_ids
|
||||
|
||||
# Update sync metadata
|
||||
data.setdefault("sync_metadata", {})[form_data.provider] = {
|
||||
"source_id": form_data.source_id,
|
||||
"last_sync": time.time(),
|
||||
"options": form_data.options,
|
||||
"results": {
|
||||
"status": sync_result.status.value,
|
||||
"added": len(sync_result.added_files),
|
||||
"updated": len(sync_result.updated_files),
|
||||
"removed": len(sync_result.removed_files) if hasattr(sync_result, 'removed_files') else 0,
|
||||
"failed": len(sync_result.failed_files),
|
||||
"duplicates": len(sync_result.duplicate_files),
|
||||
"changes": sync_result.changes
|
||||
}
|
||||
}
|
||||
|
||||
updated_data = knowledge.data.copy() if knowledge.data else {}
|
||||
updated_data.update(sync_data)
|
||||
updated_data["file_ids"] = new_file_ids
|
||||
|
||||
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=updated_data)
|
||||
|
||||
log.info(
|
||||
f"Google Drive sync completed: {len(new_file_ids)} total files in knowledge base"
|
||||
|
||||
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
|
||||
|
||||
# Prepare response
|
||||
files = Files.get_file_metadatas_by_ids(updated_file_ids)
|
||||
|
||||
# Build sync results for response
|
||||
sync_results_dict = None
|
||||
if sync_result.changes or sync_result.errors:
|
||||
sync_results_dict = {
|
||||
"status": sync_result.status.value,
|
||||
"added_files": sync_result.added_files,
|
||||
"updated_files": sync_result.updated_files,
|
||||
"duplicate_files": sync_result.duplicate_files,
|
||||
"errors": sync_result.errors,
|
||||
"warnings": sync_result.warnings,
|
||||
"changes": sync_result.changes,
|
||||
"total_processed": sync_result.total_processed
|
||||
}
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge.model_dump(),
|
||||
files=files,
|
||||
sync_results=sync_results_dict
|
||||
)
|
||||
log.info(f"Google Drive sync: Knowledge object type: {type(knowledge)}")
|
||||
log.info(f"Google Drive sync: Knowledge object: {knowledge}")
|
||||
|
||||
if knowledge:
|
||||
files = Files.get_file_metadatas_by_ids(new_file_ids)
|
||||
log.info(f"Google Drive sync: Returning {len(files)} file metadata records")
|
||||
|
||||
# Handle both dict and model cases
|
||||
if hasattr(knowledge, "model_dump"):
|
||||
knowledge_dict = knowledge.model_dump()
|
||||
else:
|
||||
knowledge_dict = (
|
||||
knowledge if isinstance(knowledge, dict) else knowledge.__dict__
|
||||
)
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
**knowledge_dict,
|
||||
files=files,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to update knowledge base",
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"Google Drive sync error: {e}")
|
||||
log.error(f"Sync failed for {form_data.provider}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to sync Google Drive folder: {str(e)}",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Sync failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ from open_webui.utils.misc import (
|
|||
)
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.services.google_drive import google_drive_service
|
||||
from open_webui.utils.content_sources import refresh_provider_configuration
|
||||
|
||||
from open_webui.config import (
|
||||
ENV,
|
||||
|
|
@ -1061,9 +1061,9 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
|||
else request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION
|
||||
)
|
||||
|
||||
# Refresh Google Drive service if service account JSON was updated
|
||||
# Refresh Google Drive provider if service account JSON was updated
|
||||
if form_data.GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON is not None:
|
||||
google_drive_service.refresh_configuration()
|
||||
refresh_provider_configuration('google_drive')
|
||||
|
||||
if form_data.web is not None:
|
||||
# Web search settings
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
# Services module for Open WebUI
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import io
|
||||
|
||||
from open_webui.env import ENV
|
||||
from open_webui.models.knowledge import Knowledges, KnowledgeModel
|
||||
from open_webui.services.google_drive import google_drive_service, GoogleDriveFile
|
||||
from open_webui.models.files import Files, FileModel, FileForm
|
||||
from open_webui.routers.retrieval import process_file, ProcessFileForm
|
||||
from open_webui.storage.provider import Storage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoogleDriveSyncScheduler:
|
||||
"""Background scheduler for automatic Google Drive folder sync."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.running: bool = False
|
||||
self.sync_tasks: Dict[str, Any] = {}
|
||||
# In dev environment, check every minute for faster testing
|
||||
# In production, check every hour
|
||||
self.check_interval: int = 60 if ENV == "dev" else 3600
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the background sync scheduler."""
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.running = True
|
||||
log.info(f"Google Drive sync scheduler started (ENV: {ENV}, check_interval: {self.check_interval}s)")
|
||||
|
||||
# Start the background task
|
||||
asyncio.create_task(self._sync_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the background sync scheduler."""
|
||||
self.running = False
|
||||
log.info("Google Drive sync scheduler stopped")
|
||||
|
||||
async def _sync_loop(self) -> None:
|
||||
"""Main sync loop that runs in the background."""
|
||||
while self.running:
|
||||
try:
|
||||
await self._check_and_sync_knowledge_bases()
|
||||
await asyncio.sleep(self.check_interval)
|
||||
except Exception as e:
|
||||
log.error(f"Error in sync loop: {e}")
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
async def _check_and_sync_knowledge_bases(self) -> None:
|
||||
"""Check all knowledge bases for Google Drive sync requirements."""
|
||||
if not google_drive_service.is_configured():
|
||||
return
|
||||
|
||||
try:
|
||||
# Get all knowledge bases
|
||||
knowledge_bases = Knowledges.get_knowledge_bases()
|
||||
|
||||
for kb in knowledge_bases:
|
||||
if not kb.data:
|
||||
continue
|
||||
|
||||
# Check if this knowledge base has Google Drive sync configured
|
||||
folder_id = kb.data.get("google_drive_folder_id")
|
||||
sync_interval_days = kb.data.get("google_drive_sync_interval_days", 1)
|
||||
last_sync = kb.data.get("google_drive_last_sync", 0)
|
||||
|
||||
if not folder_id:
|
||||
continue
|
||||
|
||||
# Check if sync is due
|
||||
current_time = int(time.time())
|
||||
sync_interval_seconds = sync_interval_days * 24 * 3600
|
||||
|
||||
if current_time - last_sync >= sync_interval_seconds:
|
||||
log.info(
|
||||
f"Auto-syncing knowledge base {kb.id} with Google Drive folder {folder_id}"
|
||||
)
|
||||
await self._sync_knowledge_base(kb)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error checking knowledge bases for sync: {e}")
|
||||
|
||||
async def _sync_knowledge_base(self, knowledge_base: KnowledgeModel) -> None:
|
||||
"""Sync a specific knowledge base with its Google Drive folder."""
|
||||
try:
|
||||
if not knowledge_base.data:
|
||||
return
|
||||
|
||||
# Type guard for mypy
|
||||
assert knowledge_base.data is not None
|
||||
|
||||
folder_id = knowledge_base.data.get("google_drive_folder_id")
|
||||
include_nested = knowledge_base.data.get(
|
||||
"google_drive_include_nested", True
|
||||
)
|
||||
|
||||
if not folder_id:
|
||||
return
|
||||
|
||||
# Get current files in knowledge base
|
||||
current_file_ids = knowledge_base.data.get("file_ids", [])
|
||||
current_files = Files.get_files_by_ids(current_file_ids)
|
||||
|
||||
# Create a map of Google Drive file IDs to local file IDs
|
||||
gdrive_file_map = {}
|
||||
for file in current_files:
|
||||
if file.meta and file.meta.get("google_drive_id"):
|
||||
gdrive_file_map[file.meta["google_drive_id"]] = file.id
|
||||
|
||||
# Get files from Google Drive folder
|
||||
gdrive_files = google_drive_service.list_folder_files(
|
||||
folder_id, include_nested
|
||||
)
|
||||
|
||||
# Track files to keep and files to add
|
||||
files_to_keep = set()
|
||||
files_to_add = []
|
||||
|
||||
for gdrive_file in gdrive_files:
|
||||
gdrive_id = gdrive_file["id"]
|
||||
|
||||
if gdrive_id in gdrive_file_map:
|
||||
# File exists, check if it needs updating
|
||||
local_file_id = gdrive_file_map[gdrive_id]
|
||||
local_file = Files.get_file_by_id(local_file_id)
|
||||
|
||||
if local_file and local_file.meta:
|
||||
local_modified = local_file.meta.get("google_drive_modified")
|
||||
gdrive_modified = gdrive_file["modifiedTime"]
|
||||
|
||||
if local_modified != gdrive_modified:
|
||||
# File was modified, re-download and update
|
||||
files_to_add.append(gdrive_file)
|
||||
# Remove old file
|
||||
Files.delete_file_by_id(local_file_id)
|
||||
else:
|
||||
# File is up to date, keep it
|
||||
files_to_keep.add(local_file_id)
|
||||
else:
|
||||
# Local file metadata is missing, re-download
|
||||
files_to_add.append(gdrive_file)
|
||||
else:
|
||||
# New file, add it
|
||||
files_to_add.append(gdrive_file)
|
||||
|
||||
# Remove files that are no longer in Google Drive
|
||||
files_to_remove = set(current_file_ids) - files_to_keep
|
||||
for file_id in files_to_remove:
|
||||
Files.delete_file_by_id(file_id)
|
||||
|
||||
# Download and add new/updated files
|
||||
new_file_ids = list(files_to_keep)
|
||||
|
||||
for gdrive_file in files_to_add:
|
||||
try:
|
||||
# Download file from Google Drive
|
||||
file_content, filename = google_drive_service.download_file(
|
||||
gdrive_file["id"], gdrive_file
|
||||
)
|
||||
|
||||
# Create file object
|
||||
file_obj = io.BytesIO(file_content)
|
||||
file_obj.name = filename
|
||||
|
||||
# Upload to storage
|
||||
file_id = str(uuid.uuid4())
|
||||
tags = {
|
||||
"OpenWebUI-User-Email": "system",
|
||||
"OpenWebUI-User-Id": "system",
|
||||
"OpenWebUI-User-Name": "Google Drive Sync",
|
||||
"OpenWebUI-File-Id": file_id,
|
||||
}
|
||||
|
||||
contents, file_path = Storage.upload_file(
|
||||
file_obj, f"{file_id}_{filename}", tags
|
||||
)
|
||||
|
||||
# Create file record
|
||||
file_form = FileForm(
|
||||
id=file_id,
|
||||
filename=filename,
|
||||
path=file_path,
|
||||
meta={
|
||||
"name": filename,
|
||||
"content_type": "application/octet-stream",
|
||||
"size": len(contents),
|
||||
"google_drive_id": gdrive_file["id"],
|
||||
"google_drive_modified": gdrive_file["modifiedTime"],
|
||||
"google_drive_path": gdrive_file["path"],
|
||||
"collection_name": knowledge_base.id,
|
||||
},
|
||||
)
|
||||
file_item = Files.insert_new_file(knowledge_base.user_id, file_form)
|
||||
|
||||
if file_item:
|
||||
new_file_ids.append(file_id)
|
||||
|
||||
# Process file for vector storage
|
||||
try:
|
||||
process_file(
|
||||
ProcessFileForm(
|
||||
file_id=file_id,
|
||||
collection_name=knowledge_base.id,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to process file {file_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed to sync file {gdrive_file['name']}: {e}")
|
||||
continue
|
||||
|
||||
# Update knowledge base data
|
||||
assert knowledge_base.data is not None # Type guard for mypy
|
||||
updated_data = knowledge_base.data.copy()
|
||||
updated_data["file_ids"] = new_file_ids
|
||||
updated_data["google_drive_last_sync"] = int(time.time())
|
||||
|
||||
Knowledges.update_knowledge_data_by_id(
|
||||
id=knowledge_base.id, data=updated_data
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Successfully synced knowledge base {knowledge_base.id} with {len(files_to_add)} new/updated files"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error syncing knowledge base {knowledge_base.id}: {e}")
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
google_drive_scheduler = GoogleDriveSyncScheduler()
|
||||
38
backend/open_webui/utils/content_sources.py
Normal file
38
backend/open_webui/utils/content_sources.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Utility functions for content source management."""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from open_webui.content_sources.registry import content_source_registry
|
||||
from open_webui.content_sources.factory import content_source_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def refresh_provider_configuration(provider_name: str) -> bool:
|
||||
"""
|
||||
Refresh a content source provider's configuration.
|
||||
|
||||
This is typically called when configuration settings are updated
|
||||
in the admin panel.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider to refresh
|
||||
|
||||
Returns:
|
||||
True if refresh was successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get existing provider instance
|
||||
provider = content_source_registry.get_provider(provider_name)
|
||||
|
||||
if provider and hasattr(provider, 'refresh_configuration'):
|
||||
provider.refresh_configuration()
|
||||
logger.info(f"Refreshed configuration for provider: {provider_name}")
|
||||
return True
|
||||
else:
|
||||
# Provider not loaded yet or doesn't support refresh
|
||||
logger.debug(f"Provider {provider_name} not loaded or doesn't support refresh")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing provider {provider_name}: {e}")
|
||||
return False
|
||||
|
|
@ -1,5 +1,25 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import type { GoogleDriveServiceAccount, GoogleDriveSyncResponse } from '$lib/types/google-drive';
|
||||
import type { ContentSourceProvider } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Knowledge Base API
|
||||
*
|
||||
* This file contains generic content source endpoints for managing knowledge base content.
|
||||
*
|
||||
* Generic Endpoints (Provider-agnostic):
|
||||
* - getContentSourceInfo(): Get information about any content source provider
|
||||
* - syncContentSource(): Sync content from any provider to a knowledge base
|
||||
* - getContentSourceProviders(): Get list of available content source providers
|
||||
*
|
||||
* All endpoints support multiple content source providers (Google Drive, OneDrive, etc.)
|
||||
*/
|
||||
|
||||
// Import generic content source types from centralized types
|
||||
import type {
|
||||
ContentSourceServiceInfo as ContentSourceInfo,
|
||||
ContentSourceSyncConfig,
|
||||
ContentSourceSyncResults
|
||||
} from '$lib/types';
|
||||
|
||||
export const createNewKnowledge = async (
|
||||
token: string,
|
||||
|
|
@ -492,12 +512,48 @@ export const deleteKnowledgeById = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getGoogleDriveServiceAccountEmail = async (
|
||||
// Get list of available content source providers
|
||||
export const getContentSourceProviders = async (
|
||||
token: string
|
||||
): Promise<GoogleDriveServiceAccount | null> => {
|
||||
): Promise<ContentSourceProvider[] | null> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/google-drive/service-account-email`, {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/content-sources/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json.providers || [];
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// Generic content source functions
|
||||
export const getContentSourceInfo = async (
|
||||
token: string,
|
||||
provider: string
|
||||
): Promise<ContentSourceInfo | null> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/content-sources/${provider}/info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
|
@ -525,27 +581,23 @@ export const getGoogleDriveServiceAccountEmail = async (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const syncGoogleDriveFolder = async (
|
||||
|
||||
// Generic content source sync function
|
||||
export const syncContentSource = async (
|
||||
token: string,
|
||||
knowledgeId: string,
|
||||
folderId: string,
|
||||
includeNested: boolean = true,
|
||||
syncIntervalDays: number = 1
|
||||
): Promise<GoogleDriveSyncResponse | null> => {
|
||||
config: ContentSourceSyncConfig
|
||||
): Promise<ContentSourceSyncResults | null> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${knowledgeId}/google-drive/sync`, {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${knowledgeId}/sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder_id: folderId,
|
||||
include_nested: includeNested,
|
||||
sync_interval_days: syncIntervalDays
|
||||
})
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
|
||||
import AddContentMenu from './KnowledgeBase/AddContentMenu.svelte';
|
||||
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
||||
import ContentSourceStatus from './KnowledgeBase/ContentSourceStatus.svelte';
|
||||
|
||||
|
||||
import Drawer from '$lib/components/common/Drawer.svelte';
|
||||
|
|
@ -46,6 +47,8 @@
|
|||
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||
import AttachWebpageModal from '$lib/components/chat/MessageInput/AttachWebpageModal.svelte';
|
||||
import GoogleDriveSyncModal from './KnowledgeBase/GoogleDriveSyncModal.svelte';
|
||||
import ContentSourceSyncModal from './KnowledgeBase/ContentSourceSyncModal.svelte';
|
||||
import { getContentSourceProviders } from '$lib/apis/knowledge';
|
||||
|
||||
let largeScreen = true;
|
||||
|
||||
|
|
@ -65,11 +68,6 @@
|
|||
description: string;
|
||||
data: {
|
||||
file_ids: string[];
|
||||
// Google Drive sync fields
|
||||
google_drive_folder_id?: string;
|
||||
google_drive_include_nested?: boolean;
|
||||
google_drive_sync_interval_days?: number;
|
||||
google_drive_last_sync?: number;
|
||||
};
|
||||
files: any[];
|
||||
access_grants?: any[];
|
||||
|
|
@ -84,6 +82,9 @@
|
|||
let selectedFile = null;
|
||||
let selectedFileContent = '';
|
||||
let showGoogleDriveSyncModal = false;
|
||||
let showContentSourceSyncModal = false;
|
||||
let availableProviders = [];
|
||||
let selectedContentProvider = null;
|
||||
|
||||
let inputFiles = null;
|
||||
|
||||
|
|
@ -701,6 +702,16 @@
|
|||
};
|
||||
|
||||
onMount(async () => {
|
||||
// Load available content source providers
|
||||
try {
|
||||
const providers = await getContentSourceProviders(localStorage.token);
|
||||
if (providers) {
|
||||
availableProviders = providers.filter(p => p.configured);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load content source providers:', error);
|
||||
}
|
||||
|
||||
// listen to resize 1024px
|
||||
mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
|
|
@ -803,15 +814,18 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<GoogleDriveSyncModal
|
||||
bind:show={showGoogleDriveSyncModal}
|
||||
knowledgeId={id}
|
||||
knowledgeData={knowledge}
|
||||
on:sync={(e) => {
|
||||
knowledge = e.detail;
|
||||
toast.success($i18n.t('Google Drive folder synced successfully'));
|
||||
}}
|
||||
/>
|
||||
{#if selectedContentProvider}
|
||||
<ContentSourceSyncModal
|
||||
bind:show={showContentSourceSyncModal}
|
||||
knowledgeId={id}
|
||||
provider={selectedContentProvider}
|
||||
knowledgeData={knowledge}
|
||||
on:sync={(e) => {
|
||||
knowledge = e.detail;
|
||||
toast.success($i18n.t(`${selectedContentProvider.display_name} synced successfully`));
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id="files-input"
|
||||
|
|
@ -921,6 +935,12 @@
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if knowledge}
|
||||
<div class="px-1 mt-1">
|
||||
<ContentSourceStatus knowledgeData={knowledge} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@
|
|||
import ArrowPath from '$lib/components/icons/ArrowPath.svelte';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
import { config } from '$lib/stores';
|
||||
import type { ContentSourceProvider } from '$lib/types';
|
||||
import { getProviderIcon } from '$lib/utils/content-sources';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let onClose: Function = () => {};
|
||||
export let availableProviders: ContentSourceProvider[] = [];
|
||||
|
||||
export let onSync: Function = () => {};
|
||||
export let onUpload: Function = (data) => {};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { KnowledgeDataWithContentSource } from '$lib/types';
|
||||
import {
|
||||
hasContentSourceSync,
|
||||
getConfiguredProvider,
|
||||
getSyncStatus,
|
||||
formatLastSync,
|
||||
getProviderDisplayName,
|
||||
getProviderIcon
|
||||
} from '$lib/utils/content-sources';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let knowledgeData: KnowledgeDataWithContentSource;
|
||||
|
||||
$: hasSync = hasContentSourceSync(knowledgeData);
|
||||
$: provider = getConfiguredProvider(knowledgeData);
|
||||
$: syncStatus = getSyncStatus(knowledgeData);
|
||||
$: providerIcon = provider ? getProviderIcon(provider) : null;
|
||||
</script>
|
||||
{#if hasSync && provider}
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if providerIcon}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path d={providerIcon} />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>
|
||||
{getProviderDisplayName(provider)}
|
||||
{#if syncStatus.sourceId}
|
||||
• {$i18n.t('Last sync')}: {formatLastSync(syncStatus.lastSync, $i18n)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { getContentSourceInfo, syncContentSource } from '$lib/apis/knowledge';
|
||||
import { config } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import type {
|
||||
ContentSourceProvider,
|
||||
ContentSourceServiceInfo,
|
||||
KnowledgeDataWithContentSource
|
||||
} from '$lib/types';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
sync: any;
|
||||
}>();
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let knowledgeId: string;
|
||||
export let provider: ContentSourceProvider;
|
||||
export let knowledgeData: KnowledgeDataWithContentSource | null = null;
|
||||
|
||||
let loading: boolean = false;
|
||||
let providerInfo: ContentSourceServiceInfo | null = null;
|
||||
let sourceId: string = '';
|
||||
let syncOptions: Record<string, any> = {};
|
||||
let syncIntervalDays: number = 1;
|
||||
|
||||
// Provider-specific configurations
|
||||
const providerDefaults: Record<string, any> = {
|
||||
google_drive: {
|
||||
options: { include_nested: true },
|
||||
syncIntervalDays: 1
|
||||
},
|
||||
onedrive: {
|
||||
options: { include_subfolders: true },
|
||||
syncIntervalDays: 1
|
||||
},
|
||||
dropbox: {
|
||||
options: { recursive: true },
|
||||
syncIntervalDays: 2
|
||||
},
|
||||
sharepoint: {
|
||||
options: { include_subsites: false },
|
||||
syncIntervalDays: 1
|
||||
}
|
||||
};
|
||||
|
||||
// Dev mode sync interval options - applicable to all providers
|
||||
$: syncIntervalOptions = [
|
||||
...($config?.environment === 'dev' ? [
|
||||
{ value: 1/1440, label: '1 minute' }, // 1 minute = 0.000694... days
|
||||
{ value: 5/1440, label: '5 minutes' } // 5 minutes = 0.00347... days
|
||||
] : []),
|
||||
{ value: 1, label: '1 day' },
|
||||
{ value: 2, label: '2 days' },
|
||||
{ value: 3, label: '3 days' },
|
||||
{ value: 7, label: '1 week' }
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
if (show && provider) {
|
||||
await loadProviderInfo();
|
||||
loadExistingConfiguration();
|
||||
}
|
||||
});
|
||||
|
||||
const loadExistingConfiguration = (): void => {
|
||||
if (!knowledgeData?.data) return;
|
||||
|
||||
// Check for existing sync metadata from backend
|
||||
if (knowledgeData.data?.sync_metadata?.[provider.name]) {
|
||||
const syncMeta = knowledgeData.data.sync_metadata[provider.name];
|
||||
sourceId = syncMeta.source_id || '';
|
||||
const opts = syncMeta.options || {};
|
||||
// Extract sync_interval_days from options if it exists there
|
||||
syncIntervalDays = opts.sync_interval_days || providerDefaults[provider.name]?.syncIntervalDays || 1;
|
||||
// Remove sync_interval_days from options to avoid duplication
|
||||
const { sync_interval_days, ...restOptions } = opts;
|
||||
syncOptions = restOptions;
|
||||
}
|
||||
// Apply provider defaults for new configurations
|
||||
else if (providerDefaults[provider.name]) {
|
||||
syncOptions = { ...providerDefaults[provider.name].options };
|
||||
syncIntervalDays = providerDefaults[provider.name].syncIntervalDays;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for changes to show prop and reload configuration
|
||||
$: if (show && provider) {
|
||||
loadProviderInfo();
|
||||
loadExistingConfiguration();
|
||||
}
|
||||
|
||||
const loadProviderInfo = async (): Promise<void> => {
|
||||
try {
|
||||
const info = await getContentSourceInfo(localStorage.token, provider.name);
|
||||
if (info) {
|
||||
providerInfo = info;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get ${provider.display_name} info:`, error);
|
||||
toast.error($i18n.t(`Failed to get ${provider.display_name} configuration`));
|
||||
}
|
||||
};
|
||||
|
||||
const extractSourceId = (input: string, provider: string): string => {
|
||||
// Provider-specific URL extraction patterns
|
||||
const patterns: Record<string, RegExp[]> = {
|
||||
google_drive: [
|
||||
/\/folders\/([a-zA-Z0-9-_]+)/,
|
||||
/id=([a-zA-Z0-9-_]+)/,
|
||||
/^([a-zA-Z0-9-_]+)$/
|
||||
],
|
||||
onedrive: [
|
||||
/\/folder\/([a-zA-Z0-9-_]+)/,
|
||||
/folderid=([a-zA-Z0-9-_]+)/,
|
||||
/^([a-zA-Z0-9-_]+)$/
|
||||
],
|
||||
dropbox: [
|
||||
/\/home\/([^?]+)/,
|
||||
/path=([^&]+)/,
|
||||
/^\/(.+)$/
|
||||
],
|
||||
sharepoint: [
|
||||
/\/sites\/([^\/]+)/,
|
||||
/siteid=([a-zA-Z0-9-_]+)/,
|
||||
/^([a-zA-Z0-9-_]+)$/
|
||||
]
|
||||
};
|
||||
|
||||
const providerPatterns = patterns[provider] || [/^(.+)$/];
|
||||
|
||||
for (const pattern of providerPatterns) {
|
||||
const match = input.match(pattern);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return input; // Return as-is if no pattern matches
|
||||
};
|
||||
|
||||
const validateSyncInterval = (interval: number): { valid: boolean; message?: string } => {
|
||||
// Validate sync interval is within reasonable bounds
|
||||
const MIN_INTERVAL = $config?.environment === 'dev' ? 1/1440 : 0.5; // 1 minute in dev, 12 hours in prod
|
||||
const MAX_INTERVAL = 365; // 1 year
|
||||
|
||||
if (interval < MIN_INTERVAL) {
|
||||
return {
|
||||
valid: false,
|
||||
message: $config?.environment === 'dev'
|
||||
? 'Sync interval must be at least 1 minute'
|
||||
: 'Sync interval must be at least 12 hours'
|
||||
};
|
||||
}
|
||||
|
||||
if (interval > MAX_INTERVAL) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Sync interval cannot exceed 365 days'
|
||||
};
|
||||
}
|
||||
|
||||
// Warn about very frequent syncs
|
||||
if (interval < 1 && $config?.environment !== 'dev') {
|
||||
return {
|
||||
valid: true,
|
||||
message: 'Warning: Frequent syncs may impact performance and API quotas'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleSync = async (): Promise<void> => {
|
||||
if (!sourceId.trim()) {
|
||||
toast.error($i18n.t(`Please enter a ${provider.display_name} source URL or ID`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate sync interval
|
||||
const intervalValidation = validateSyncInterval(syncIntervalDays);
|
||||
if (!intervalValidation.valid) {
|
||||
toast.error($i18n.t(intervalValidation.message || 'Invalid sync interval'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show warning for aggressive sync intervals
|
||||
if (intervalValidation.message) {
|
||||
toast.warning($i18n.t(intervalValidation.message));
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const extractedSourceId = extractSourceId(sourceId.trim(), provider.name);
|
||||
|
||||
// Validate extracted source ID format
|
||||
if (!extractedSourceId || extractedSourceId.length < 3) {
|
||||
toast.error($i18n.t('Invalid source ID format'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await syncContentSource(
|
||||
localStorage.token,
|
||||
knowledgeId,
|
||||
{
|
||||
provider: provider.name,
|
||||
source_id: extractedSourceId,
|
||||
options: {
|
||||
...syncOptions,
|
||||
sync_interval_days: syncIntervalDays
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result) {
|
||||
toast.success($i18n.t(`${provider.display_name} synced successfully`));
|
||||
dispatch('sync', result);
|
||||
show = false;
|
||||
} else {
|
||||
toast.error($i18n.t(`Failed to sync ${provider.display_name}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error($i18n.t(`Error syncing ${provider.display_name}: {{error}}`, { error: errorMessage }));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string): void => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success($i18n.t(`${label} copied to clipboard`));
|
||||
};
|
||||
|
||||
// Provider-specific UI helpers
|
||||
const getSourcePlaceholder = (provider: string): string => {
|
||||
const placeholders: Record<string, string> = {
|
||||
google_drive: 'Enter Google Drive folder URL or ID',
|
||||
onedrive: 'Enter OneDrive folder URL or ID',
|
||||
dropbox: 'Enter Dropbox folder path or link',
|
||||
sharepoint: 'Enter SharePoint site URL or ID'
|
||||
};
|
||||
return placeholders[provider] || 'Enter source URL or ID';
|
||||
};
|
||||
|
||||
const getSourceLabel = (provider: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
google_drive: 'Folder URL or ID',
|
||||
onedrive: 'Folder URL or ID',
|
||||
dropbox: 'Folder path or link',
|
||||
sharepoint: 'Site URL or ID'
|
||||
};
|
||||
return labels[provider] || 'Source URL or ID';
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class=" text-lg font-medium self-center">
|
||||
{$i18n.t(`Sync ${provider.display_name}`)}
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
||||
|
||||
<div class="flex flex-col space-y-4 px-5 pt-4 pb-5">
|
||||
<!-- Provider-specific authentication info -->
|
||||
{#if providerInfo?.metadata}
|
||||
<!-- Service Account Email (Google Drive) -->
|
||||
{#if provider.name === 'google_drive' && providerInfo.metadata.service_account_email}
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
{$i18n.t('Step 1: Share folder with service account')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(
|
||||
'Copy the service account email below and share your Google Drive folder with this email address:'
|
||||
)}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
class="flex-1 text-sm bg-gray-50 dark:bg-gray-850 rounded-lg px-3 py-2"
|
||||
type="text"
|
||||
value={providerInfo.metadata.service_account_email}
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
class="px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm rounded-lg"
|
||||
on:click={() => copyToClipboard(providerInfo?.metadata?.service_account_email || '', 'Service account email')}
|
||||
>
|
||||
{$i18n.t('Copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OAuth Status (OneDrive, Dropbox) -->
|
||||
{#if ['onedrive', 'dropbox'].includes(provider.name) && providerInfo.metadata.oauth_status}
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
{$i18n.t('Authentication Status')}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if providerInfo.metadata.oauth_status === 'connected'}
|
||||
<span class="text-green-600 text-sm">✓ {$i18n.t('Connected')}</span>
|
||||
{:else}
|
||||
<span class="text-yellow-600 text-sm">⚠ {$i18n.t('Not connected')}</span>
|
||||
<button
|
||||
class="text-blue-500 hover:text-blue-600 text-sm underline"
|
||||
on:click={() => {
|
||||
// TODO: Implement OAuth flow
|
||||
toast.info($i18n.t('OAuth authentication coming soon'));
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Connect Account')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- API Key Status (custom providers) -->
|
||||
{#if providerInfo.metadata.requires_api_key && !providerInfo.metadata.api_key_configured}
|
||||
<div class="space-y-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<div class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$i18n.t('API Key Required')}
|
||||
</div>
|
||||
<div class="text-xs text-yellow-700 dark:text-yellow-300">
|
||||
{$i18n.t('Please configure the API key in the admin settings before syncing.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Source ID/URL Input -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
{providerInfo?.metadata?.service_account_email
|
||||
? $i18n.t('Step 2: Enter source')
|
||||
: $i18n.t('Enter source')}
|
||||
: {$i18n.t(getSourceLabel(provider.name))}
|
||||
</div>
|
||||
<input
|
||||
class="w-full text-sm bg-gray-50 dark:bg-gray-850 rounded-lg px-3 py-2"
|
||||
type="text"
|
||||
placeholder={$i18n.t(getSourcePlaceholder(provider.name))}
|
||||
bind:value={sourceId}
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(`You can paste the full ${provider.display_name} URL or just the ID`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider-specific sync options -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium">{$i18n.t('Sync Options')}</div>
|
||||
|
||||
<!-- Google Drive: Include Nested Folders -->
|
||||
{#if provider.name === 'google_drive'}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="includeNested"
|
||||
bind:checked={syncOptions.include_nested}
|
||||
class="rounded"
|
||||
/>
|
||||
<label for="includeNested" class="text-sm">
|
||||
{$i18n.t('Include files from nested folders')}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OneDrive: Include Subfolders -->
|
||||
{#if provider.name === 'onedrive'}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="includeSubfolders"
|
||||
bind:checked={syncOptions.include_subfolders}
|
||||
class="rounded"
|
||||
/>
|
||||
<label for="includeSubfolders" class="text-sm">
|
||||
{$i18n.t('Include files from subfolders')}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dropbox: Recursive Sync -->
|
||||
{#if provider.name === 'dropbox'}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="recursive"
|
||||
bind:checked={syncOptions.recursive}
|
||||
class="rounded"
|
||||
/>
|
||||
<label for="recursive" class="text-sm">
|
||||
{$i18n.t('Include all subdirectories')}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SharePoint: Include Subsites -->
|
||||
{#if provider.name === 'sharepoint'}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="includeSubsites"
|
||||
bind:checked={syncOptions.include_subsites}
|
||||
class="rounded"
|
||||
/>
|
||||
<label for="includeSubsites" class="text-sm">
|
||||
{$i18n.t('Include document libraries from subsites')}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Common: Sync Interval -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{$i18n.t('Automatic sync interval')}</label>
|
||||
<select
|
||||
bind:value={syncIntervalDays}
|
||||
class="w-full text-sm bg-gray-50 dark:bg-gray-850 rounded-lg px-3 py-2"
|
||||
>
|
||||
{#each syncIntervalOptions as option}
|
||||
<option value={option.value}>{$i18n.t(option.label)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Provider-specific additional options could go here -->
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2 pt-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm rounded-lg disabled:opacity-50"
|
||||
disabled={loading || !sourceId.trim()}
|
||||
on:click={handleSync}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>{$i18n.t('Syncing...')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{$i18n.t('Sync')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { getGoogleDriveServiceAccountEmail, syncGoogleDriveFolder } from '$lib/apis/knowledge';
|
||||
import { config } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import type {
|
||||
KnowledgeDataWithGoogleDrive,
|
||||
GoogleDriveSyncResponse
|
||||
} from '$lib/types/google-drive';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
sync: GoogleDriveSyncResponse;
|
||||
}>();
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let knowledgeId: string;
|
||||
export let knowledgeData: KnowledgeDataWithGoogleDrive | null = null;
|
||||
|
||||
let loading: boolean = false;
|
||||
let serviceAccountEmail: string = '';
|
||||
let folderId: string = '';
|
||||
let includeNested: boolean = true;
|
||||
let syncIntervalDays: number = 1;
|
||||
|
||||
// Note: Backend expects sync_interval_days as a float representing days
|
||||
// For dev mode, we convert minutes to fractional days (1 day = 1440 minutes)
|
||||
$: syncIntervalOptions = [
|
||||
...($config?.environment === 'dev' ? [
|
||||
{ value: 1/1440, label: '1 minute' }, // 1 minute = 0.000694... days
|
||||
{ value: 5/1440, label: '5 minutes' } // 5 minutes = 0.00347... days
|
||||
] : []),
|
||||
{ value: 1, label: '1 day' },
|
||||
{ value: 2, label: '2 days' },
|
||||
{ value: 3, label: '3 days' }
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
if (show) {
|
||||
await loadServiceAccountEmail();
|
||||
loadExistingConfiguration();
|
||||
}
|
||||
});
|
||||
|
||||
const loadExistingConfiguration = (): void => {
|
||||
if (knowledgeData && knowledgeData.data) {
|
||||
// Load existing Google Drive configuration from knowledge base data
|
||||
const data = knowledgeData.data;
|
||||
if (data.google_drive_folder_id) {
|
||||
folderId = data.google_drive_folder_id;
|
||||
}
|
||||
if (data.google_drive_include_nested !== undefined) {
|
||||
includeNested = data.google_drive_include_nested;
|
||||
}
|
||||
if (data.google_drive_sync_interval_days) {
|
||||
syncIntervalDays = data.google_drive_sync_interval_days;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for changes to show prop and reload configuration
|
||||
$: if (show) {
|
||||
loadServiceAccountEmail();
|
||||
loadExistingConfiguration();
|
||||
}
|
||||
|
||||
const loadServiceAccountEmail = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await getGoogleDriveServiceAccountEmail(localStorage.token);
|
||||
if (response?.email) {
|
||||
serviceAccountEmail = response.email;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get service account email:', error);
|
||||
toast.error($i18n.t('Failed to get Google Drive service account email'));
|
||||
}
|
||||
};
|
||||
|
||||
const extractFolderIdFromUrl = (url: string): string => {
|
||||
// Extract folder ID from Google Drive URL
|
||||
const patterns = [/\/folders\/([a-zA-Z0-9-_]+)/, /id=([a-zA-Z0-9-_]+)/, /^([a-zA-Z0-9-_]+)$/];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return url; // Return as-is if no pattern matches
|
||||
};
|
||||
|
||||
const handleSync = async (): Promise<void> => {
|
||||
if (!folderId.trim()) {
|
||||
toast.error($i18n.t('Please enter a Google Drive folder URL or ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const extractedFolderId = extractFolderIdFromUrl(folderId.trim());
|
||||
|
||||
const result = await syncGoogleDriveFolder(
|
||||
localStorage.token,
|
||||
knowledgeId,
|
||||
extractedFolderId,
|
||||
includeNested,
|
||||
syncIntervalDays
|
||||
);
|
||||
|
||||
if (result) {
|
||||
toast.success($i18n.t('Google Drive folder synced successfully'));
|
||||
dispatch('sync', result);
|
||||
show = false;
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to sync Google Drive folder'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error($i18n.t('Error syncing Google Drive folder: {{error}}', { error: errorMessage }));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyServiceAccountEmail = (): void => {
|
||||
navigator.clipboard.writeText(serviceAccountEmail);
|
||||
toast.success($i18n.t('Service account email copied to clipboard'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class=" text-lg font-medium self-center">{$i18n.t('Sync Google Drive Folder')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
// Don't reset fields - keep them for next time
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<hr class=" border-gray-100 dark:border-gray-850" />
|
||||
|
||||
<div class="flex flex-col space-y-4 px-5 pt-4 pb-5">
|
||||
<!-- Service Account Email Section -->
|
||||
{#if serviceAccountEmail}
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
{$i18n.t('Step 1: Share folder with service account')}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(
|
||||
'Copy the service account email below and share your Google Drive folder with this email address:'
|
||||
)}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
class="flex-1 text-sm bg-gray-50 dark:bg-gray-850 rounded-lg px-3 py-2"
|
||||
type="text"
|
||||
value={serviceAccountEmail}
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
class="px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm rounded-lg"
|
||||
on:click={copyServiceAccountEmail}
|
||||
>
|
||||
{$i18n.t('Copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Folder ID/URL Input -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">{$i18n.t('Step 2: Enter folder URL or ID')}</div>
|
||||
<input
|
||||
class="w-full text-sm bg-gray-50 dark:bg-gray-850 rounded-lg px-3 py-2"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Enter Google Drive folder URL or ID')}
|
||||
bind:value={folderId}
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('You can paste the full Google Drive folder URL or just the folder ID')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Options -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium">{$i18n.t('Sync Options')}</div>
|
||||
|
||||
<!-- Include Nested Folders -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="includeNested" bind:checked={includeNested} class="rounded" />
|
||||
<label for="includeNested" class="text-sm">
|
||||
{$i18n.t('Include files from nested folders')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Sync Interval -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{$i18n.t('Automatic sync interval')}</label>
|
||||
<select
|
||||
bind:value={syncIntervalDays}
|
||||
class="w-full text-sm bg-gray-50 dark:bg-gray-850 rounded-lg px-3 py-2"
|
||||
>
|
||||
{#each syncIntervalOptions as option}
|
||||
<option value={option.value}>{$i18n.t(option.label)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2 pt-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
// Don't reset fields - keep them for next time
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm rounded-lg disabled:opacity-50"
|
||||
disabled={loading || !folderId.trim()}
|
||||
on:click={handleSync}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>{$i18n.t('Syncing...')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{$i18n.t('Sync Folder')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
161
src/lib/types/content-sources.ts
Normal file
161
src/lib/types/content-sources.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// Content Source TypeScript Type Definitions
|
||||
// Generic types for provider-agnostic content sources
|
||||
// Mirrors backend Python types for consistency
|
||||
|
||||
/**
|
||||
* Content source provider information from backend
|
||||
*/
|
||||
export interface ContentSourceProvider {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
configured: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic content source sync configuration
|
||||
*/
|
||||
export interface ContentSourceSyncConfig {
|
||||
provider: string;
|
||||
source_id: string;
|
||||
options?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic content source sync results
|
||||
*/
|
||||
export interface ContentSourceSyncResults {
|
||||
added_files: string[];
|
||||
updated_files: string[];
|
||||
removed_files: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic content source file information
|
||||
*/
|
||||
export interface ContentSourceFile {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
modifiedTime: string;
|
||||
size?: string | number;
|
||||
path: string;
|
||||
webViewLink?: string;
|
||||
provider: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic content source folder information
|
||||
*/
|
||||
export interface ContentSourceFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
path?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content source service information
|
||||
*/
|
||||
export interface ContentSourceServiceInfo {
|
||||
provider: string;
|
||||
configured: boolean;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content source sync status
|
||||
*/
|
||||
export interface ContentSourceSyncStatus {
|
||||
provider: string;
|
||||
source_id: string;
|
||||
last_sync: number; // Unix timestamp
|
||||
sync_interval_days: number;
|
||||
status: 'idle' | 'syncing' | 'error';
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base data with generic content source fields
|
||||
*/
|
||||
export interface KnowledgeDataWithContentSource {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
data?: {
|
||||
file_ids?: string[];
|
||||
// Sync metadata structure
|
||||
sync_metadata?: {
|
||||
[provider: string]: {
|
||||
source_id: string;
|
||||
last_sync: number;
|
||||
options?: Record<string, any>;
|
||||
results?: {
|
||||
status: string;
|
||||
added: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
duplicates: number;
|
||||
changes: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
files?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider capabilities information
|
||||
*/
|
||||
export interface ContentSourceCapabilities {
|
||||
supports_folders: boolean;
|
||||
supports_recursive_sync: boolean;
|
||||
supports_selective_sync: boolean;
|
||||
supports_incremental_sync: boolean;
|
||||
supports_webhooks: boolean;
|
||||
max_file_size?: number;
|
||||
supported_mime_types?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Content source authentication configuration
|
||||
*/
|
||||
export interface ContentSourceAuthConfig {
|
||||
type: 'oauth2' | 'api_key' | 'service_account' | 'none';
|
||||
oauth2?: {
|
||||
client_id: string;
|
||||
scopes: string[];
|
||||
auth_url?: string;
|
||||
token_url?: string;
|
||||
};
|
||||
api_key?: {
|
||||
header_name: string;
|
||||
required: boolean;
|
||||
};
|
||||
service_account?: {
|
||||
email: string;
|
||||
status: 'active' | 'inactive';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type guards
|
||||
*/
|
||||
export function isContentSourceConfigured(source: ContentSourceProvider): boolean {
|
||||
return source.configured;
|
||||
}
|
||||
|
||||
export function hasContentSourceSync(data: KnowledgeDataWithContentSource): boolean {
|
||||
return !!data.data?.content_source?.source_id;
|
||||
}
|
||||
|
||||
export function getContentSourceProvider(data: KnowledgeDataWithContentSource): string | undefined {
|
||||
if (data.data?.content_source?.provider) {
|
||||
return data.data.content_source.provider;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
// Google Drive TypeScript Type Definitions
|
||||
// Specific types for Google Drive integration
|
||||
// Mirrors backend Python types for consistency
|
||||
|
||||
import type { KnowledgeDataWithContentSource } from './content-sources';
|
||||
|
||||
/**
|
||||
* Google Drive Specific Types
|
||||
* These types are specific to Google Drive integration
|
||||
* For generic content source types, see content-sources.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google Drive file information from Google Drive API
|
||||
*/
|
||||
|
|
@ -166,22 +175,6 @@ export interface GoogleOAuthError {
|
|||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base data with Google Drive fields
|
||||
*/
|
||||
export interface KnowledgeDataWithGoogleDrive {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
data?: Record<string, unknown>;
|
||||
// Google Drive specific fields
|
||||
google_drive_folder_id?: string;
|
||||
google_drive_include_nested?: boolean;
|
||||
google_drive_sync_interval_days?: number;
|
||||
google_drive_last_sync?: number;
|
||||
google_drive_file_ids?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* API error response structure
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,5 +14,9 @@ export enum TTS_RESPONSE_SPLIT {
|
|||
NONE = 'none'
|
||||
}
|
||||
|
||||
// Re-export Google Drive types for convenience
|
||||
// Re-export content source types (generic provider-agnostic types)
|
||||
export * from './content-sources';
|
||||
|
||||
// Re-export Google Drive specific types
|
||||
export * from './google-drive';
|
||||
|
||||
|
|
|
|||
130
src/lib/utils/content-sources.ts
Normal file
130
src/lib/utils/content-sources.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Utility functions for content source providers
|
||||
*/
|
||||
|
||||
import type { ContentSourceProvider, KnowledgeDataWithContentSource } from '$lib/types';
|
||||
|
||||
// Provider icon SVG paths - these match the paths used in AddContentMenu.svelte
|
||||
export const providerIcons: Record<string, string> = {
|
||||
google_drive: 'M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z',
|
||||
onedrive: 'M12.188 5.813q-1.325 0-2.52.544-1.195.545-2.04 1.565.446.117.85.299.405.181.792.416l4.78 2.86 2.731-1.15q.27-.117.545-.204.276-.088.58-.147-.293-.937-.855-1.705-.563-.768-1.319-1.318-.755-.551-1.658-.856-.902-.304-1.886-.304zM2.414 16.395l9.914-4.184-3.832-2.297q-.586-.351-1.23-.539-.645-.188-1.325-.188-.914 0-1.722.364-.809.363-1.412.978-.603.615-.967 1.424-.363.808-.363 1.722 0 .62.163 1.201.164.58.469 1.09.305.509.738.897.434.387.967.65.533.262 1.13.387.598.126 1.225.126h13.125q.773 0 1.453-.3.68-.299 1.19-.808.51-.51.809-1.19.299-.68.299-1.453 0-.738-.28-1.389-.282-.65-.768-1.136-.486-.486-1.143-.768-.656-.281-1.4-.281h-.047q-.023 0-.047.006-.316-1.242-1.008-2.28-.691-1.036-1.658-1.78-.967-.744-2.144-1.16-1.178-.417-2.456-.417-.949 0-1.845.229-.897.228-1.705.668-.809.439-1.5 1.066-.692.627-1.207 1.413h-.012q-.445.022-.861.082-.416.058-.85.187-.937.293-1.711.861-.774.569-1.324 1.325-.551.755-.862 1.658-.31.902-.31 1.887 0 1.242.474 2.332.475 1.09 1.29 1.904.814.815 1.903 1.29 1.09.475 2.332.475z',
|
||||
dropbox: 'M12 5L6 9l6 4-6 4-6-4 6-4L0 5l6-4zm-6 14l6-4 6 4-6 4zm6-6l6-4-6-4 6-4 6 4-6 4 6 4-6 4z',
|
||||
sharepoint: 'M24 13.5q0 1.242-.475 2.332-.474 1.09-1.289 1.904-.814.815-1.904 1.29-1.09.474-2.332.474-.762 0-1.523-.2-.106.997-.557 1.858-.451.862-1.154 1.494-.704.633-1.606.99-.902.358-1.91.358-1.09 0-2.045-.416-.955-.416-1.664-1.125-.709-.709-1.125-1.664Q6 19.84 6 18.75q0-.188.018-.375.017-.188.04-.375H.997q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.54q.14-1.277.726-2.373.586-1.096 1.488-1.904Q7.652.914 8.807.457 9.96 0 11.25 0q1.395 0 2.625.533T16.02 1.98q.914.915 1.447 2.145T18 6.75q0 .188-.012.375-.011.188-.035.375 1.242 0 2.344.469 1.101.468 1.928 1.277.826.809 1.3 1.904Q24 12.246 24 13.5zm-12.75-12q-.973 0-1.857.34-.885.34-1.577.943-.691.604-1.154 1.43Q6.2 5.039 6.06 6h4.945q.41 0 .703.293t.293.703v4.945l.21-.035q.212-.75.61-1.424.399-.673.944-1.218.545-.545 1.213-.944.668-.398 1.43-.61.093-.503.093-.96 0-1.09-.416-2.045-.416-.955-1.125-1.664'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get provider icon SVG path
|
||||
*/
|
||||
export function getProviderIcon(providerName: string): string | null {
|
||||
return providerIcons[providerName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name with fallback
|
||||
*/
|
||||
export function getProviderDisplayName(provider: ContentSourceProvider | string): string {
|
||||
if (typeof provider === 'string') {
|
||||
// Fallback display names for common providers
|
||||
const fallbackNames: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
dropbox: 'Dropbox',
|
||||
sharepoint: 'SharePoint'
|
||||
};
|
||||
return fallbackNames[provider] || provider;
|
||||
}
|
||||
return provider.display_name || provider.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a knowledge base has content source sync configured
|
||||
*/
|
||||
export function hasContentSourceSync(data: KnowledgeDataWithContentSource): boolean {
|
||||
// Check both old format (sync_metadata) and new format (content_source)
|
||||
if (data.data?.sync_metadata) {
|
||||
// Check if any provider has a source_id
|
||||
const providers = Object.keys(data.data.sync_metadata);
|
||||
return providers.some(provider =>
|
||||
data.data.sync_metadata[provider]?.source_id
|
||||
);
|
||||
}
|
||||
return !!data.data?.content_source?.source_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured content source provider name
|
||||
*/
|
||||
export function getConfiguredProvider(data: KnowledgeDataWithContentSource): string | null {
|
||||
// Check old format first (sync_metadata)
|
||||
if (data.data?.sync_metadata) {
|
||||
// Return the first provider that has a source_id
|
||||
const providers = Object.keys(data.data.sync_metadata);
|
||||
for (const provider of providers) {
|
||||
if (data.data.sync_metadata[provider]?.source_id) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check new format
|
||||
return data.data?.content_source?.provider || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status information
|
||||
*/
|
||||
export function getSyncStatus(data: KnowledgeDataWithContentSource): {
|
||||
lastSync: number | null;
|
||||
intervalDays: number;
|
||||
sourceId: string | null;
|
||||
} {
|
||||
// Check old format first (sync_metadata)
|
||||
const provider = getConfiguredProvider(data);
|
||||
if (provider && data.data?.sync_metadata?.[provider]) {
|
||||
const syncData = data.data.sync_metadata[provider];
|
||||
return {
|
||||
lastSync: syncData.last_sync || null,
|
||||
intervalDays: syncData.options?.sync_interval_days || 1,
|
||||
sourceId: syncData.source_id || null
|
||||
};
|
||||
}
|
||||
|
||||
// Check new format
|
||||
if (data.data?.content_source) {
|
||||
return {
|
||||
lastSync: data.data.content_source.last_sync || null,
|
||||
intervalDays: data.data.content_source.sync_interval_days || 1,
|
||||
sourceId: data.data.content_source.source_id || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lastSync: null,
|
||||
intervalDays: 1,
|
||||
sourceId: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format last sync time for display
|
||||
*/
|
||||
export function formatLastSync(timestamp: number | null, i18n: any): string {
|
||||
if (!timestamp) {
|
||||
return i18n.t('Never synced');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp * 1000; // Convert to milliseconds
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) {
|
||||
return i18n.t('Just now');
|
||||
} else if (minutes < 60) {
|
||||
return i18n.t('{{count}} minutes ago', { count: minutes });
|
||||
} else if (hours < 24) {
|
||||
return i18n.t('{{count}} hours ago', { count: hours });
|
||||
} else {
|
||||
return i18n.t('{{count}} days ago', { count: days });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue