Browse Source

Merge pull request #7900 from taylorwilsdon/add_google_drive_integration

feat: Add Google Drive integration for Open-Webui
Timothy Jaeryang Baek 4 months ago
parent
commit
5871df02ac

+ 19 - 0
backend/open_webui/config.py

@@ -307,6 +307,18 @@ GOOGLE_CLIENT_SECRET = PersistentConfig(
     os.environ.get("GOOGLE_CLIENT_SECRET", ""),
 )
 
+GOOGLE_DRIVE_CLIENT_ID = PersistentConfig(
+    "GOOGLE_DRIVE_CLIENT_ID",
+    "google_drive.client_id",
+    os.environ.get("GOOGLE_DRIVE_CLIENT_ID", ""),
+)
+
+GOOGLE_DRIVE_API_KEY = PersistentConfig(
+    "GOOGLE_DRIVE_API_KEY",
+    "google_drive.api_key",
+    os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
+)
+
 GOOGLE_OAUTH_SCOPE = PersistentConfig(
     "GOOGLE_OAUTH_SCOPE",
     "oauth.google.scope",
@@ -1426,6 +1438,13 @@ RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
     ],
 )
 
+# If configured, Google Drive will be available as an upload option.
+ENABLE_GOOGLE_DRIVE = PersistentConfig(
+    "ENABLE_GOOGLE_DRIVE",
+    "rag.drive.enable",
+    os.getenv("ENABLE_GOOGLE_DRIVE", "False").lower() == "true",
+)
+
 SEARXNG_QUERY_URL = PersistentConfig(
     "SEARXNG_QUERY_URL",
     "rag.web.search.searxng_query_url",

+ 9 - 0
backend/open_webui/main.py

@@ -177,10 +177,13 @@ from open_webui.config import (
     MOJEEK_SEARCH_API_KEY,
     GOOGLE_PSE_API_KEY,
     GOOGLE_PSE_ENGINE_ID,
+    GOOGLE_DRIVE_CLIENT_ID,
+    GOOGLE_DRIVE_API_KEY,
     ENABLE_RAG_HYBRID_SEARCH,
     ENABLE_RAG_LOCAL_WEB_FETCH,
     ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
     ENABLE_RAG_WEB_SEARCH,
+    ENABLE_GOOGLE_DRIVE,
     UPLOAD_DIR,
     # WebUI
     WEBUI_AUTH,
@@ -483,6 +486,7 @@ app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH
 app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE
 app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST
 
+app.state.config.ENABLE_GOOGLE_DRIVE = ENABLE_GOOGLE_DRIVE
 app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
 app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
 app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
@@ -935,6 +939,7 @@ async def get_app_config(request: Request):
             **(
                 {
                     "enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
+                    "enable_google_drive": app.state.config.ENABLE_GOOGLE_DRIVE,
                     "enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
                     "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
                     "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
@@ -945,6 +950,10 @@ async def get_app_config(request: Request):
                 else {}
             ),
         },
+        "google_drive": {
+            "client_id": GOOGLE_DRIVE_CLIENT_ID.value,
+            "api_key": GOOGLE_DRIVE_API_KEY.value,
+        },
         **(
             {
                 "default_models": app.state.config.DEFAULT_MODELS,

+ 19 - 16
backend/open_webui/routers/knowledge.py

@@ -11,7 +11,12 @@ from open_webui.models.knowledge import (
 )
 from open_webui.models.files import Files, FileModel
 from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
-from open_webui.routers.retrieval import process_file, ProcessFileForm, process_files_batch, BatchProcessFilesForm
+from open_webui.routers.retrieval import (
+    process_file,
+    ProcessFileForm,
+    process_files_batch,
+    BatchProcessFilesForm,
+)
 
 
 from open_webui.constants import ERROR_MESSAGES
@@ -519,6 +524,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
 # AddFilesToKnowledge
 ############################
 
+
 @router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse])
 def add_files_to_knowledge_batch(
     id: str,
@@ -555,27 +561,25 @@ def add_files_to_knowledge_batch(
 
     # Process files
     try:
-        result = process_files_batch(BatchProcessFilesForm(
-            files=files,
-            collection_name=id
-        ))
+        result = process_files_batch(
+            BatchProcessFilesForm(files=files, collection_name=id)
+        )
     except Exception as e:
-        log.error(f"add_files_to_knowledge_batch: Exception occurred: {e}", exc_info=True)
-        raise HTTPException(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            detail=str(e)
+        log.error(
+            f"add_files_to_knowledge_batch: Exception occurred: {e}", exc_info=True
         )
-    
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
     # Add successful files to knowledge base
     data = knowledge.data or {}
     existing_file_ids = data.get("file_ids", [])
-    
+
     # Only add files that were successfully processed
     successful_file_ids = [r.file_id for r in result.results if r.status == "completed"]
     for file_id in successful_file_ids:
         if file_id not in existing_file_ids:
             existing_file_ids.append(file_id)
-    
+
     data["file_ids"] = existing_file_ids
     knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
 
@@ -587,11 +591,10 @@ def add_files_to_knowledge_batch(
             files=Files.get_files_by_ids(existing_file_ids),
             warnings={
                 "message": "Some files failed to process",
-                "errors": error_details
-            }
+                "errors": error_details,
+            },
         )
 
     return KnowledgeFilesResponse(
-        **knowledge.model_dump(),
-        files=Files.get_files_by_ids(existing_file_ids)
+        **knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids)
     )

+ 9 - 0
backend/open_webui/routers/retrieval.py

@@ -347,6 +347,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
     return {
         "status": True,
         "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
+        "enable_google_drive": request.app.state.config.ENABLE_GOOGLE_DRIVE,
         "content_extraction": {
             "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
             "tika_server_url": request.app.state.config.TIKA_SERVER_URL,
@@ -369,6 +370,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
             "web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
             "search": {
                 "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
+                "drive": request.app.state.config.ENABLE_GOOGLE_DRIVE,
                 "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
                 "searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL,
                 "google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY,
@@ -445,6 +447,7 @@ class WebConfig(BaseModel):
 
 class ConfigUpdateForm(BaseModel):
     pdf_extract_images: Optional[bool] = None
+    enable_google_drive: Optional[bool] = None
     file: Optional[FileConfig] = None
     content_extraction: Optional[ContentExtractionConfig] = None
     chunk: Optional[ChunkParamUpdateForm] = None
@@ -462,6 +465,12 @@ async def update_rag_config(
         else request.app.state.config.PDF_EXTRACT_IMAGES
     )
 
+    request.app.state.config.ENABLE_GOOGLE_DRIVE = (
+        form_data.enable_google_drive
+        if form_data.enable_google_drive is not None
+        else request.app.state.config.ENABLE_GOOGLE_DRIVE
+    )
+
     if form_data.file is not None:
         request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size
         request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count

+ 28 - 34
backend/open_webui/static/swagger-ui/swagger-ui-bundle.js

@@ -37007,16 +37007,14 @@
 								Pe.createElement('span', { className: 'brace-close' }, '}')
 							),
 							pe.size
-								? pe
-										.entrySeq()
-										.map(([s, o]) =>
-											Pe.createElement(xe, {
-												key: `${s}-${o}`,
-												propKey: s,
-												propVal: o,
-												propClass: 'property'
-											})
-										)
+								? pe.entrySeq().map(([s, o]) =>
+										Pe.createElement(xe, {
+											key: `${s}-${o}`,
+											propKey: s,
+											propVal: o,
+											propClass: 'property'
+										})
+									)
 								: null
 						);
 					}
@@ -37167,16 +37165,14 @@
 											)
 										: null,
 									C && z.size
-										? z
-												.entrySeq()
-												.map(([s, o]) =>
-													Pe.createElement(le, {
-														key: `${s}-${o}`,
-														propKey: s,
-														propVal: o,
-														propClass: rs
-													})
-												)
+										? z.entrySeq().map(([s, o]) =>
+												Pe.createElement(le, {
+													key: `${s}-${o}`,
+													propKey: s,
+													propVal: o,
+													propClass: rs
+												})
+											)
 										: null,
 									U ? Pe.createElement(ie, { source: U }) : null,
 									Z &&
@@ -57290,20 +57286,18 @@
 										Pe.createElement(
 											'div',
 											{ className: 'modal-ux-content' },
-											x
-												.valueSeq()
-												.map((x, j) =>
-													Pe.createElement(C, {
-														key: j,
-														AST: w,
-														definitions: x,
-														getComponent: i,
-														errSelectors: u,
-														authSelectors: s,
-														authActions: o,
-														specSelectors: _
-													})
-												)
+											x.valueSeq().map((x, j) =>
+												Pe.createElement(C, {
+													key: j,
+													AST: w,
+													definitions: x,
+													getComponent: i,
+													errSelectors: u,
+													authSelectors: s,
+													authActions: o,
+													specSelectors: _
+												})
+											)
 										)
 									)
 								)

+ 5 - 0
backend/requirements.txt

@@ -90,6 +90,11 @@ extract_msg
 pydub
 duckduckgo-search~=6.3.5
 
+## Google Drive
+google-api-python-client
+google-auth-httplib2
+google-auth-oauthlib
+
 ## Tests
 docker~=7.1.0
 pytest~=8.3.2

+ 11 - 9
package-lock.json

@@ -2260,9 +2260,9 @@
 			}
 		},
 		"node_modules/@sveltejs/kit": {
-			"version": "2.9.0",
-			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.9.0.tgz",
-			"integrity": "sha512-W3E7ed3ChB6kPqRs2H7tcHp+Z7oiTFC6m+lLyAQQuyXeqw6LdNuuwEUla+5VM0OGgqQD+cYD6+7Xq80vVm17Vg==",
+			"version": "2.12.1",
+			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.12.1.tgz",
+			"integrity": "sha512-M3rPijGImeOkI0DBJSwjqz+YFX2DyOf6NzWgHVk3mqpT06dlYCpcv5xh1q4rYEqB58yQlk4QA1Y35PUqnUiFKw==",
 			"hasInstallScript": true,
 			"license": "MIT",
 			"dependencies": {
@@ -8267,15 +8267,16 @@
 			}
 		},
 		"node_modules/nanoid": {
-			"version": "5.0.6",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
-			"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==",
+			"version": "5.0.9",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",
+			"integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==",
 			"funding": [
 				{
 					"type": "github",
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"bin": {
 				"nanoid": "bin/nanoid.js"
 			},
@@ -8976,15 +8977,16 @@
 			"dev": true
 		},
 		"node_modules/postcss/node_modules/nanoid": {
-			"version": "3.3.7",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
-			"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+			"version": "3.3.8",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+			"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
 			"funding": [
 				{
 					"type": "github",
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"bin": {
 				"nanoid": "bin/nanoid.cjs"
 			},

+ 1 - 0
src/lib/apis/retrieval/index.ts

@@ -45,6 +45,7 @@ type YoutubeConfigForm = {
 
 type RAGConfigForm = {
 	pdf_extract_images?: boolean;
+	enable_google_drive?: boolean;
 	chunk?: ChunkConfigForm;
 	content_extraction?: ContentExtractConfigForm;
 	web_loader_ssl_verification?: boolean;

+ 219 - 195
src/lib/components/admin/Settings/Documents.svelte

@@ -56,6 +56,8 @@
 	let chunkOverlap = 0;
 	let pdfExtractImages = true;
 
+	let enableGoogleDrive = false;
+
 	let OpenAIUrl = '';
 	let OpenAIKey = '';
 
@@ -175,6 +177,7 @@
 		}
 		const res = await updateRAGConfig(localStorage.token, {
 			pdf_extract_images: pdfExtractImages,
+			enable_google_drive: enableGoogleDrive,
 			file: {
 				max_size: fileMaxSize === '' ? null : fileMaxSize,
 				max_count: fileMaxCount === '' ? null : fileMaxCount
@@ -245,6 +248,8 @@
 
 			fileMaxSize = res?.file.max_size ?? '';
 			fileMaxCount = res?.file.max_count ?? '';
+
+			enableGoogleDrive = res.enable_google_drive;
 		}
 	});
 </script>
@@ -571,6 +576,8 @@
 				</div>
 			</div>
 
+			<hr class=" dark:border-gray-850" />
+
 			{#if showTikaServerUrl}
 				<div class="flex w-full mt-1">
 					<div class="flex-1 mr-2">
@@ -584,251 +591,268 @@
 			{/if}
 		</div>
 
-		<hr class=" dark:border-gray-850" />
-
-		<div class=" ">
-			<div class=" text-sm font-medium mb-1">{$i18n.t('Query Params')}</div>
-
-			<div class=" flex gap-1.5">
-				<div class="flex flex-col w-full gap-1">
-					<div class=" text-xs font-medium w-full">{$i18n.t('Top K')}</div>
+		<div class="">
+			<div class="text-sm font-medium mb-1">{$i18n.t('Google Drive')}</div>
 
-					<div class="w-full">
-						<input
-							class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
-							type="number"
-							placeholder={$i18n.t('Enter Top K')}
-							bind:value={querySettings.k}
-							autocomplete="off"
-							min="0"
-						/>
+			<div class="my-2">
+				<div class="flex justify-between items-center text-xs">
+					<div class="text-xs font-medium">{$i18n.t('Enable Google Drive')}</div>
+					<div>
+						<Switch bind:state={enableGoogleDrive} />
 					</div>
 				</div>
+			</div>
 
-				{#if querySettings.hybrid === true}
-					<div class=" flex flex-col w-full gap-1">
-						<div class="text-xs font-medium w-full">
-							{$i18n.t('Minimum Score')}
-						</div>
+			<hr class=" dark:border-gray-850" />
+
+			<div class=" ">
+				<div class=" text-sm font-medium mb-1">{$i18n.t('Query Params')}</div>
+
+				<div class=" flex gap-1.5">
+					<div class="flex flex-col w-full gap-1">
+						<div class=" text-xs font-medium w-full">{$i18n.t('Top K')}</div>
 
 						<div class="w-full">
 							<input
 								class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
 								type="number"
-								step="0.01"
-								placeholder={$i18n.t('Enter Score')}
-								bind:value={querySettings.r}
+								placeholder={$i18n.t('Enter Top K')}
+								bind:value={querySettings.k}
 								autocomplete="off"
-								min="0.0"
-								title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
+								min="0"
 							/>
 						</div>
 					</div>
-				{/if}
-			</div>
 
-			{#if querySettings.hybrid === true}
-				<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
-					{$i18n.t(
-						'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
-					)}
+					{#if querySettings.hybrid === true}
+						<div class=" flex flex-col w-full gap-1">
+							<div class="text-xs font-medium w-full">
+								{$i18n.t('Minimum Score')}
+							</div>
+
+							<div class="w-full">
+								<input
+									class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type="number"
+									step="0.01"
+									placeholder={$i18n.t('Enter Score')}
+									bind:value={querySettings.r}
+									autocomplete="off"
+									min="0.0"
+									title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
+								/>
+							</div>
+						</div>
+					{/if}
 				</div>
-			{/if}
-
-			<div class="mt-2">
-				<div class=" mb-1 text-xs font-medium">{$i18n.t('RAG Template')}</div>
-				<Tooltip
-					content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
-					placement="top-start"
-				>
-					<Textarea
-						bind:value={querySettings.template}
-						placeholder={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
-					/>
-				</Tooltip>
-			</div>
-		</div>
-
-		<hr class=" dark:border-gray-850" />
 
-		<div class=" ">
-			<div class="mb-1 text-sm font-medium">{$i18n.t('Chunk Params')}</div>
+				{#if querySettings.hybrid === true}
+					<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
+						{$i18n.t(
+							'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.'
+						)}
+					</div>
+				{/if}
 
-			<div class="flex w-full justify-between mb-1.5">
-				<div class="self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div>
-				<div class="flex items-center relative">
-					<select
-						class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
-						bind:value={textSplitter}
+				<div class="mt-2">
+					<div class=" mb-1 text-xs font-medium">{$i18n.t('RAG Template')}</div>
+					<Tooltip
+						content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
+						placement="top-start"
 					>
-						<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
-						<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
-					</select>
+						<Textarea
+							bind:value={querySettings.template}
+							placeholder={$i18n.t(
+								'Leave empty to use the default prompt, or enter a custom prompt'
+							)}
+						/>
+					</Tooltip>
 				</div>
 			</div>
 
-			<div class=" flex gap-1.5">
-				<div class="  w-full justify-between">
-					<div class="self-center text-xs font-medium min-w-fit mb-1">{$i18n.t('Chunk Size')}</div>
-					<div class="self-center">
-						<input
-							class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
-							type="number"
-							placeholder={$i18n.t('Enter Chunk Size')}
-							bind:value={chunkSize}
-							autocomplete="off"
-							min="0"
-						/>
+			<hr class=" dark:border-gray-850" />
+
+			<div class=" ">
+				<div class="mb-1 text-sm font-medium">{$i18n.t('Chunk Params')}</div>
+
+				<div class="flex w-full justify-between mb-1.5">
+					<div class="self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div>
+					<div class="flex items-center relative">
+						<select
+							class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
+							bind:value={textSplitter}
+						>
+							<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
+							<option value="token">{$i18n.t('Token')} ({$i18n.t('Tiktoken')})</option>
+						</select>
 					</div>
 				</div>
 
-				<div class="w-full">
-					<div class=" self-center text-xs font-medium min-w-fit mb-1">
-						{$i18n.t('Chunk Overlap')}
+				<div class=" flex gap-1.5">
+					<div class="  w-full justify-between">
+						<div class="self-center text-xs font-medium min-w-fit mb-1">
+							{$i18n.t('Chunk Size')}
+						</div>
+						<div class="self-center">
+							<input
+								class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
+								type="number"
+								placeholder={$i18n.t('Enter Chunk Size')}
+								bind:value={chunkSize}
+								autocomplete="off"
+								min="0"
+							/>
+						</div>
 					</div>
 
-					<div class="self-center">
-						<input
-							class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
-							type="number"
-							placeholder={$i18n.t('Enter Chunk Overlap')}
-							bind:value={chunkOverlap}
-							autocomplete="off"
-							min="0"
-						/>
+					<div class="w-full">
+						<div class=" self-center text-xs font-medium min-w-fit mb-1">
+							{$i18n.t('Chunk Overlap')}
+						</div>
+
+						<div class="self-center">
+							<input
+								class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
+								type="number"
+								placeholder={$i18n.t('Enter Chunk Overlap')}
+								bind:value={chunkOverlap}
+								autocomplete="off"
+								min="0"
+							/>
+						</div>
 					</div>
 				</div>
-			</div>
 
-			<div class="my-2">
-				<div class="flex justify-between items-center text-xs">
-					<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
+				<div class="my-2">
+					<div class="flex justify-between items-center text-xs">
+						<div class=" text-xs font-medium">{$i18n.t('PDF Extract Images (OCR)')}</div>
 
-					<div>
-						<Switch bind:state={pdfExtractImages} />
+						<div>
+							<Switch bind:state={pdfExtractImages} />
+						</div>
 					</div>
 				</div>
 			</div>
-		</div>
 
-		<hr class=" dark:border-gray-850" />
+			<hr class=" dark:border-gray-850" />
 
-		<div class="">
-			<div class="text-sm font-medium mb-1">{$i18n.t('Files')}</div>
+			<div class="">
+				<div class="text-sm font-medium mb-1">{$i18n.t('Files')}</div>
+
+				<div class=" flex gap-1.5">
+					<div class="w-full">
+						<div class=" self-center text-xs font-medium min-w-fit mb-1">
+							{$i18n.t('Max Upload Size')}
+						</div>
 
-			<div class=" flex gap-1.5">
-				<div class="w-full">
-					<div class=" self-center text-xs font-medium min-w-fit mb-1">
-						{$i18n.t('Max Upload Size')}
+						<div class="self-center">
+							<Tooltip
+								content={$i18n.t(
+									'The maximum file size in MB. If the file size exceeds this limit, the file will not be uploaded.'
+								)}
+								placement="top-start"
+							>
+								<input
+									class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type="number"
+									placeholder={$i18n.t('Leave empty for unlimited')}
+									bind:value={fileMaxSize}
+									autocomplete="off"
+									min="0"
+								/>
+							</Tooltip>
+						</div>
 					</div>
 
-					<div class="self-center">
-						<Tooltip
-							content={$i18n.t(
-								'The maximum file size in MB. If the file size exceeds this limit, the file will not be uploaded.'
-							)}
-							placement="top-start"
-						>
-							<input
-								class="w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
-								type="number"
-								placeholder={$i18n.t('Leave empty for unlimited')}
-								bind:value={fileMaxSize}
-								autocomplete="off"
-								min="0"
-							/>
-						</Tooltip>
+					<div class="  w-full">
+						<div class="self-center text-xs font-medium min-w-fit mb-1">
+							{$i18n.t('Max Upload Count')}
+						</div>
+						<div class="self-center">
+							<Tooltip
+								content={$i18n.t(
+									'The maximum number of files that can be used at once in chat. If the number of files exceeds this limit, the files will not be uploaded.'
+								)}
+								placement="top-start"
+							>
+								<input
+									class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
+									type="number"
+									placeholder={$i18n.t('Leave empty for unlimited')}
+									bind:value={fileMaxCount}
+									autocomplete="off"
+									min="0"
+								/>
+							</Tooltip>
+						</div>
 					</div>
 				</div>
+			</div>
+
+			<hr class=" dark:border-gray-850" />
 
-				<div class="  w-full">
-					<div class="self-center text-xs font-medium min-w-fit mb-1">
-						{$i18n.t('Max Upload Count')}
+			<div>
+				<button
+					class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					on:click={() => {
+						showResetUploadDirConfirm = true;
+					}}
+					type="button"
+				>
+					<div class=" self-center mr-3">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 24 24"
+							fill="currentColor"
+							class="size-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z"
+								clip-rule="evenodd"
+							/>
+							<path
+								d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
+							/>
+						</svg>
 					</div>
-					<div class="self-center">
-						<Tooltip
-							content={$i18n.t(
-								'The maximum number of files that can be used at once in chat. If the number of files exceeds this limit, the files will not be uploaded.'
-							)}
-							placement="top-start"
+					<div class=" self-center text-sm font-medium">{$i18n.t('Reset Upload Directory')}</div>
+				</button>
+
+				<button
+					class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					on:click={() => {
+						showResetConfirm = true;
+					}}
+					type="button"
+				>
+					<div class=" self-center mr-3">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 16 16"
+							fill="currentColor"
+							class="w-4 h-4"
 						>
-							<input
-								class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
-								type="number"
-								placeholder={$i18n.t('Leave empty for unlimited')}
-								bind:value={fileMaxCount}
-								autocomplete="off"
-								min="0"
+							<path
+								fill-rule="evenodd"
+								d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
+								clip-rule="evenodd"
 							/>
-						</Tooltip>
+						</svg>
 					</div>
-				</div>
+					<div class=" self-center text-sm font-medium">
+						{$i18n.t('Reset Vector Storage/Knowledge')}
+					</div>
+				</button>
 			</div>
 		</div>
-
-		<hr class=" dark:border-gray-850" />
-
-		<div>
-			<button
-				class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
-				on:click={() => {
-					showResetUploadDirConfirm = true;
-				}}
-				type="button"
-			>
-				<div class=" self-center mr-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 24 24"
-						fill="currentColor"
-						class="size-4"
-					>
-						<path
-							fill-rule="evenodd"
-							d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z"
-							clip-rule="evenodd"
-						/>
-						<path
-							d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
-						/>
-					</svg>
-				</div>
-				<div class=" self-center text-sm font-medium">{$i18n.t('Reset Upload Directory')}</div>
-			</button>
-
+		<div class="flex justify-end pt-3 text-sm font-medium">
 			<button
-				class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
-				on:click={() => {
-					showResetConfirm = true;
-				}}
-				type="button"
+				class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
+				type="submit"
 			>
-				<div class=" self-center mr-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 16 16"
-						fill="currentColor"
-						class="w-4 h-4"
-					>
-						<path
-							fill-rule="evenodd"
-							d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
-							clip-rule="evenodd"
-						/>
-					</svg>
-				</div>
-				<div class=" self-center text-sm font-medium">
-					{$i18n.t('Reset Vector Storage/Knowledge')}
-				</div>
+				{$i18n.t('Save')}
 			</button>
 		</div>
 	</div>
-	<div class="flex justify-end pt-3 text-sm font-medium">
-		<button
-			class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
-			type="submit"
-		>
-			{$i18n.t('Save')}
-		</button>
-	</div>
 </form>

+ 127 - 0
src/lib/components/chat/Chat.svelte

@@ -384,6 +384,131 @@
 
 	// File upload functions
 
+	const uploadGoogleDriveFile = async (fileData) => {
+		console.log('Starting uploadGoogleDriveFile with:', {
+			id: fileData.id,
+			name: fileData.name,
+			url: fileData.url,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		});
+
+		// Validate input
+		if (!fileData?.id || !fileData?.name || !fileData?.url || !fileData?.headers?.Authorization) {
+			throw new Error('Invalid file data provided');
+		}
+
+		const tempItemId = uuidv4();
+		const fileItem = {
+			type: 'file',
+			file: '',
+			id: null,
+			url: fileData.url,
+			name: fileData.name,
+			collection_name: '',
+			status: 'uploading',
+			error: '',
+			itemId: tempItemId,
+			size: 0
+		};
+
+		try {
+			files = [...files, fileItem];
+			console.log('Processing web file with URL:', fileData.url);
+
+			// Configure fetch options with proper headers
+			const fetchOptions = {
+				headers: {
+					Authorization: fileData.headers.Authorization,
+					Accept: '*/*'
+				},
+				method: 'GET'
+			};
+
+			// Attempt to fetch the file
+			console.log('Fetching file content from Google Drive...');
+			const fileResponse = await fetch(fileData.url, fetchOptions);
+
+			if (!fileResponse.ok) {
+				const errorText = await fileResponse.text();
+				throw new Error(`Failed to fetch file (${fileResponse.status}): ${errorText}`);
+			}
+
+			// Get content type from response
+			const contentType = fileResponse.headers.get('content-type') || 'application/octet-stream';
+			console.log('Response received with content-type:', contentType);
+
+			// Convert response to blob
+			console.log('Converting response to blob...');
+			const fileBlob = await fileResponse.blob();
+
+			if (fileBlob.size === 0) {
+				throw new Error('Retrieved file is empty');
+			}
+
+			console.log('Blob created:', {
+				size: fileBlob.size,
+				type: fileBlob.type || contentType
+			});
+
+			// Create File object with proper MIME type
+			const file = new File([fileBlob], fileData.name, {
+				type: fileBlob.type || contentType
+			});
+
+			console.log('File object created:', {
+				name: file.name,
+				size: file.size,
+				type: file.type
+			});
+
+			if (file.size === 0) {
+				throw new Error('Created file is empty');
+			}
+
+			// Upload file to server
+			console.log('Uploading file to server...');
+			const uploadedFile = await uploadFile(localStorage.token, file);
+
+			if (!uploadedFile) {
+				throw new Error('Server returned null response for file upload');
+			}
+
+			console.log('File uploaded successfully:', uploadedFile);
+
+			// Update file item with upload results
+			fileItem.status = 'uploaded';
+			fileItem.file = uploadedFile;
+			fileItem.id = uploadedFile.id;
+			fileItem.size = file.size;
+			fileItem.collection_name = uploadedFile?.meta?.collection_name;
+			fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
+
+			files = files;
+			toast.success($i18n.t('File uploaded successfully'));
+		} catch (e) {
+			console.error('Error uploading file:', e);
+			files = files.filter((f) => f.itemId !== tempItemId);
+			toast.error(
+				$i18n.t('Error uploading file: {{error}}', {
+					error: e.message || 'Unknown error'
+				})
+			);
+		}
+	};
+
+	const handleGoogleDrivePicker = async () => {
+		try {
+			const fileData = await createPicker();
+			if (fileData) {
+				await uploadGoogleDriveFile(fileData);
+			}
+		} catch (error) {
+			toast.error('Error accessing Google Drive: ' + error.message);
+		}
+	};
+
 	const uploadWeb = async (url) => {
 		console.log(url);
 
@@ -1901,6 +2026,8 @@
 										await uploadWeb(data);
 									} else if (type === 'youtube') {
 										await uploadYoutubeTranscription(data);
+									} else if (type === 'google-drive') {
+										await uploadGoogleDriveFile(data);
 									}
 								}}
 								on:submit={async (e) => {

+ 41 - 4
src/lib/components/chat/MessageInput.svelte

@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
 	import { v4 as uuidv4 } from 'uuid';
+	import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
 
 	import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
 	const dispatch = createEventDispatcher();
@@ -132,8 +133,6 @@
 			return null;
 		}
 
-		console.log(file);
-
 		const tempItemId = uuidv4();
 		const fileItem = {
 			type: 'file',
@@ -177,14 +176,22 @@
 			const uploadedFile = await uploadFile(localStorage.token, file);
 
 			if (uploadedFile) {
+				console.log('File upload completed:', {
+					id: uploadedFile.id,
+					name: fileItem.name,
+					collection: uploadedFile?.meta?.collection_name
+				});
+
 				if (uploadedFile.error) {
+					console.warn('File upload warning:', uploadedFile.error);
 					toast.warning(uploadedFile.error);
 				}
 
 				fileItem.status = 'uploaded';
 				fileItem.file = uploadedFile;
 				fileItem.id = uploadedFile.id;
-				fileItem.collection_name = uploadedFile?.meta?.collection_name;
+				fileItem.collection_name =
+					uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
 				fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
 
 				files = files;
@@ -198,13 +205,23 @@
 	};
 
 	const inputFilesHandler = async (inputFiles) => {
+		console.log('Input files handler called with:', inputFiles);
 		inputFiles.forEach((file) => {
-			console.log(file, file.name.split('.').at(-1));
+			console.log('Processing file:', {
+				name: file.name,
+				type: file.type,
+				size: file.size,
+				extension: file.name.split('.').at(-1)
+			});
 
 			if (
 				($config?.file?.max_size ?? null) !== null &&
 				file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
 			) {
+				console.log('File exceeds max size limit:', {
+					fileSize: file.size,
+					maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
+				});
 				toast.error(
 					$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
 						maxSize: $config?.file?.max_size
@@ -593,6 +610,26 @@
 											uploadFilesHandler={() => {
 												filesInputElement.click();
 											}}
+											uploadGoogleDriveHandler={async () => {
+												try {
+													const fileData = await createPicker();
+													if (fileData) {
+														const file = new File([fileData.blob], fileData.name, {
+															type: fileData.blob.type
+														});
+														await uploadFileHandler(file);
+													} else {
+														console.log('No file was selected from Google Drive');
+													}
+												} catch (error) {
+													console.error('Google Drive Error:', error);
+													toast.error(
+														$i18n.t('Error accessing Google Drive: {{error}}', {
+															error: error.message
+														})
+													);
+												}
+											}}
 											onClose={async () => {
 												await tick();
 

+ 42 - 2
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -4,6 +4,7 @@
 	import { getContext, onMount, tick } from 'svelte';
 
 	import { config, user, tools as _tools, mobile } from '$lib/stores';
+	import { createPicker } from '$lib/utils/google-drive-picker';
 	import { getTools } from '$lib/apis/tools';
 
 	import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -18,6 +19,8 @@
 
 	export let screenCaptureHandler: Function;
 	export let uploadFilesHandler: Function;
+	export let uploadGoogleDriveHandler: Function;
+
 	export let selectedToolIds: string[] = [];
 
 	export let webSearchEnabled: boolean;
@@ -142,14 +145,51 @@
 			{/if}
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-xl"
+				class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
 				on:click={() => {
 					uploadFilesHandler();
 				}}
 			>
 				<DocumentArrowUpSolid />
-				<div class=" line-clamp-1">{$i18n.t('Upload Files')}</div>
+				<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
 			</DropdownMenu.Item>
+
+			{#if $config?.features?.enable_google_drive}
+				<DropdownMenu.Item
+					class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
+					on:click={() => {
+						uploadGoogleDriveHandler();
+					}}
+				>
+					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-5 h-5">
+						<path
+							d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
+							fill="#0066da"
+						/>
+						<path
+							d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z"
+							fill="#00ac47"
+						/>
+						<path
+							d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
+							fill="#ea4335"
+						/>
+						<path
+							d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z"
+							fill="#00832d"
+						/>
+						<path
+							d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
+							fill="#2684fc"
+						/>
+						<path
+							d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
+							fill="#ffba00"
+						/>
+					</svg>
+					<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
+				</DropdownMenu.Item>
+			{/if}
 		</DropdownMenu.Content>
 	</div>
 </Dropdown>

+ 1 - 0
src/lib/stores/index.ts

@@ -176,6 +176,7 @@ type Config = {
 		enable_signup: boolean;
 		enable_login_form: boolean;
 		enable_web_search?: boolean;
+		enable_google_drive: boolean;
 		enable_image_generation: boolean;
 		enable_admin_export: boolean;
 		enable_admin_chat_access: boolean;

+ 212 - 0
src/lib/utils/google-drive-picker.ts

@@ -0,0 +1,212 @@
+// Google Drive Picker API configuration
+let API_KEY = '';
+let CLIENT_ID = '';
+
+// Function to fetch credentials from backend config
+async function getCredentials() {
+	const response = await fetch('/api/config');
+	if (!response.ok) {
+		throw new Error('Failed to fetch Google Drive credentials');
+	}
+	const config = await response.json();
+	API_KEY = config.google_drive?.api_key;
+	CLIENT_ID = config.google_drive?.client_id;
+
+	if (!API_KEY || !CLIENT_ID) {
+		throw new Error('Google Drive API credentials not configured');
+	}
+}
+const SCOPE = [
+	'https://www.googleapis.com/auth/drive.readonly',
+	'https://www.googleapis.com/auth/drive.file'
+];
+
+// Validate required credentials
+const validateCredentials = () => {
+	if (!API_KEY || !CLIENT_ID) {
+		throw new Error('Google Drive API credentials not configured');
+	}
+	if (API_KEY === '' || CLIENT_ID === '') {
+		throw new Error('Please configure valid Google Drive API credentials');
+	}
+};
+
+let pickerApiLoaded = false;
+let oauthToken: string | null = null;
+let initialized = false;
+
+export const loadGoogleDriveApi = () => {
+	return new Promise((resolve, reject) => {
+		if (typeof gapi === 'undefined') {
+			const script = document.createElement('script');
+			script.src = 'https://apis.google.com/js/api.js';
+			script.onload = () => {
+				gapi.load('picker', () => {
+					pickerApiLoaded = true;
+					resolve(true);
+				});
+			};
+			script.onerror = reject;
+			document.body.appendChild(script);
+		} else {
+			gapi.load('picker', () => {
+				pickerApiLoaded = true;
+				resolve(true);
+			});
+		}
+	});
+};
+
+export const loadGoogleAuthApi = () => {
+	return new Promise((resolve, reject) => {
+		if (typeof google === 'undefined') {
+			const script = document.createElement('script');
+			script.src = 'https://accounts.google.com/gsi/client';
+			script.onload = resolve;
+			script.onerror = reject;
+			document.body.appendChild(script);
+		} else {
+			resolve(true);
+		}
+	});
+};
+
+export const getAuthToken = async () => {
+	if (!oauthToken) {
+		return new Promise((resolve, reject) => {
+			const tokenClient = google.accounts.oauth2.initTokenClient({
+				client_id: CLIENT_ID,
+				scope: SCOPE.join(' '),
+				callback: (response: any) => {
+					if (response.access_token) {
+						oauthToken = response.access_token;
+						resolve(oauthToken);
+					} else {
+						reject(new Error('Failed to get access token'));
+					}
+				},
+				error_callback: (error: any) => {
+					reject(new Error(error.message || 'OAuth error occurred'));
+				}
+			});
+			tokenClient.requestAccessToken();
+		});
+	}
+	return oauthToken;
+};
+
+const initialize = async () => {
+	if (!initialized) {
+		await getCredentials();
+		validateCredentials();
+		await Promise.all([loadGoogleDriveApi(), loadGoogleAuthApi()]);
+		initialized = true;
+	}
+};
+
+export const createPicker = () => {
+	return new Promise(async (resolve, reject) => {
+		try {
+			console.log('Initializing Google Drive Picker...');
+			await initialize();
+			console.log('Getting auth token...');
+			const token = await getAuthToken();
+			if (!token) {
+				console.error('Failed to get OAuth token');
+				throw new Error('Unable to get OAuth token');
+			}
+			console.log('Auth token obtained successfully');
+
+			const picker = new google.picker.PickerBuilder()
+				.enableFeature(google.picker.Feature.NAV_HIDDEN)
+				.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
+				.addView(
+					new google.picker.DocsView()
+						.setIncludeFolders(false)
+						.setSelectFolderEnabled(false)
+						.setMimeTypes(
+							'application/pdf,text/plain,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.google-apps.document,application/vnd.google-apps.spreadsheet,application/vnd.google-apps.presentation'
+						)
+				)
+				.setOAuthToken(token)
+				.setDeveloperKey(API_KEY)
+				// Remove app ID setting as it's not needed and can cause 404 errors
+				.setCallback(async (data: any) => {
+					if (data[google.picker.Response.ACTION] === google.picker.Action.PICKED) {
+						try {
+							const doc = data[google.picker.Response.DOCUMENTS][0];
+							const fileId = doc[google.picker.Document.ID];
+							const fileName = doc[google.picker.Document.NAME];
+							const fileUrl = doc[google.picker.Document.URL];
+
+							if (!fileId || !fileName) {
+								throw new Error('Required file details missing');
+							}
+
+							// Construct download URL based on MIME type
+							const mimeType = doc[google.picker.Document.MIME_TYPE];
+
+							let downloadUrl;
+							let exportFormat;
+
+							if (mimeType.includes('google-apps')) {
+								// Handle Google Workspace files
+								if (mimeType.includes('document')) {
+									exportFormat = 'text/plain';
+								} else if (mimeType.includes('spreadsheet')) {
+									exportFormat = 'text/csv';
+								} else if (mimeType.includes('presentation')) {
+									exportFormat = 'text/plain';
+								} else {
+									exportFormat = 'application/pdf';
+								}
+								downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=${encodeURIComponent(exportFormat)}`;
+							} else {
+								// Regular files use direct download URL
+								downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`;
+							}
+							// Create a Blob from the file download
+							const response = await fetch(downloadUrl, {
+								headers: {
+									Authorization: `Bearer ${token}`,
+									Accept: '*/*'
+								}
+							});
+
+							if (!response.ok) {
+								const errorText = await response.text();
+								console.error('Download failed:', {
+									status: response.status,
+									statusText: response.statusText,
+									error: errorText
+								});
+								throw new Error(`Failed to download file (${response.status}): ${errorText}`);
+							}
+
+							const blob = await response.blob();
+							const result = {
+								id: fileId,
+								name: fileName,
+								url: downloadUrl,
+								blob: blob,
+								headers: {
+									Authorization: `Bearer ${token}`,
+									Accept: '*/*'
+								}
+							};
+							resolve(result);
+						} catch (error) {
+							reject(error);
+						}
+					} else if (data[google.picker.Response.ACTION] === google.picker.Action.CANCEL) {
+						resolve(null);
+					}
+				})
+				.build();
+			picker.setVisible(true);
+		} catch (error) {
+			console.error('Google Drive Picker error:', error);
+			reject(error);
+		}
+	});
+};

+ 27 - 25
src/lib/utils/index.ts

@@ -552,31 +552,33 @@ export const removeEmojis = (str: string) => {
 };
 
 export const removeFormattings = (str: string) => {
-	return str
-        // Block elements (remove completely)
-        .replace(/(```[\s\S]*?```)/g, '')                    // Code blocks
-        .replace(/^\|.*\|$/gm, '')                          // Tables
-	// Inline elements (preserve content)
-        .replace(/(?:\*\*|__)(.*?)(?:\*\*|__)/g, '$1')      // Bold
-        .replace(/(?:[*_])(.*?)(?:[*_])/g, '$1')            // Italic
-        .replace(/~~(.*?)~~/g, '$1')                        // Strikethrough
-        .replace(/`([^`]+)`/g, '$1')                        // Inline code
-        
-        // Links and images
-        .replace(/!?\[([^\]]*)\](?:\([^)]+\)|\[[^\]]*\])/g, '$1') // Links & images
-        .replace(/^\[[^\]]+\]:\s*.*$/gm, '')               // Reference definitions
-        
-        // Block formatting
-        .replace(/^#{1,6}\s+/gm, '')                       // Headers
-        .replace(/^\s*[-*+]\s+/gm, '')                     // Lists
-        .replace(/^\s*(?:\d+\.)\s+/gm, '')                 // Numbered lists
-        .replace(/^\s*>[> ]*/gm, '')                       // Blockquotes
-        .replace(/^\s*:\s+/gm, '')                         // Definition lists
-        
-        // Cleanup
-        .replace(/\[\^[^\]]*\]/g, '')                      // Footnotes
-        .replace(/[-*_~]/g, '')                            // Remaining markers
-        .replace(/\n{2,}/g, '\n')                          // Multiple newlines
+	return (
+		str
+			// Block elements (remove completely)
+			.replace(/(```[\s\S]*?```)/g, '') // Code blocks
+			.replace(/^\|.*\|$/gm, '') // Tables
+			// Inline elements (preserve content)
+			.replace(/(?:\*\*|__)(.*?)(?:\*\*|__)/g, '$1') // Bold
+			.replace(/(?:[*_])(.*?)(?:[*_])/g, '$1') // Italic
+			.replace(/~~(.*?)~~/g, '$1') // Strikethrough
+			.replace(/`([^`]+)`/g, '$1') // Inline code
+
+			// Links and images
+			.replace(/!?\[([^\]]*)\](?:\([^)]+\)|\[[^\]]*\])/g, '$1') // Links & images
+			.replace(/^\[[^\]]+\]:\s*.*$/gm, '') // Reference definitions
+
+			// Block formatting
+			.replace(/^#{1,6}\s+/gm, '') // Headers
+			.replace(/^\s*[-*+]\s+/gm, '') // Lists
+			.replace(/^\s*(?:\d+\.)\s+/gm, '') // Numbered lists
+			.replace(/^\s*>[> ]*/gm, '') // Blockquotes
+			.replace(/^\s*:\s+/gm, '') // Definition lists
+
+			// Cleanup
+			.replace(/\[\^[^\]]*\]/g, '') // Footnotes
+			.replace(/[-*_~]/g, '') // Remaining markers
+			.replace(/\n{2,}/g, '\n')
+	); // Multiple newlines
 };
 
 export const cleanText = (content: string) => {