Merge pull request #867 from CREDO23/improve-ux-connectors

[Improvement] UX for connectors: Google drive Picker, auto-index with default configs
This commit is contained in:
Rohan Verma 2026-03-10 15:51:54 -07:00 committed by GitHub
commit c131912a08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 630 additions and 107 deletions

View file

@ -50,6 +50,7 @@ REGISTRATION_ENABLED=TRUE or FALSE
# For Google Auth Only
GOOGLE_OAUTH_CLIENT_ID=924507538m
GOOGLE_OAUTH_CLIENT_SECRET=GOCSV
GOOGLE_PICKER_API_KEY=your-google-picker-api-key
# Google Connector Specific Configurations
GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback

View file

@ -235,6 +235,7 @@ class Config:
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
GOOGLE_PICKER_API_KEY = os.getenv("GOOGLE_PICKER_API_KEY")
# Google Calendar redirect URI
GOOGLE_CALENDAR_REDIRECT_URI = os.getenv("GOOGLE_CALENDAR_REDIRECT_URI")

View file

@ -10,6 +10,9 @@ from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from bs4 import BeautifulSoup
from markdownify import markdownify as md
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
@ -130,6 +133,16 @@ class ComposioGmailConnector(ComposioConnector):
message_id=message_id,
)
@staticmethod
def _html_to_markdown(html: str) -> str:
"""Convert HTML (especially email layouts with nested tables) to clean markdown."""
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all(["style", "script", "img"]):
tag.decompose()
for tag in soup.find_all(["table", "thead", "tbody", "tfoot", "tr", "td", "th"]):
tag.unwrap()
return md(str(soup)).strip()
def format_gmail_message_to_markdown(self, message: dict[str, Any]) -> str:
"""
Format a Gmail message to markdown.
@ -178,9 +191,10 @@ class ComposioGmailConnector(ComposioConnector):
markdown_content += "\n---\n\n"
# Composio provides full message text in 'messageText'
# Composio provides full message text in 'messageText' which is often raw HTML
message_text = message.get("messageText", "")
if message_text:
message_text = self._html_to_markdown(message_text)
markdown_content += f"## Content\n\n{message_text}\n\n"
else:
# Fallback to snippet if no messageText

View file

@ -7,9 +7,11 @@ Allows fetching emails from Gmail mailbox using Google OAuth credentials.
import base64
import json
import logging
import re
from typing import Any
from bs4 import BeautifulSoup
from markdownify import markdownify as md
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
@ -348,6 +350,16 @@ class GoogleGmailConnector:
except Exception as e:
return [], f"Error fetching recent messages: {e!s}"
@staticmethod
def _html_to_markdown(html: str) -> str:
"""Convert HTML (especially email layouts with nested tables) to clean markdown."""
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all(["style", "script", "img"]):
tag.decompose()
for tag in soup.find_all(["table", "thead", "tbody", "tfoot", "tr", "td", "th"]):
tag.unwrap()
return md(str(soup)).strip()
def extract_message_text(self, message: dict[str, Any]) -> str:
"""
Extract text content from a Gmail message.
@ -387,13 +399,10 @@ class GoogleGmailConnector:
)
text_content += decoded_data + "\n"
elif mime_type == "text/html" and data and not text_content:
# Use HTML as fallback if no plain text
decoded_data = base64.urlsafe_b64decode(data + "===").decode(
"utf-8", errors="ignore"
)
# Basic HTML tag removal (you might want to use a proper HTML parser)
text_content = re.sub(r"<[^>]+>", "", decoded_data)
text_content = self._html_to_markdown(decoded_data)
return text_content.strip()

View file

@ -52,7 +52,9 @@ from app.schemas import (
SearchSourceConnectorRead,
SearchSourceConnectorUpdate,
)
from app.services.composio_service import ComposioService
import asyncio
from app.services.composio_service import ComposioService, get_composio_service
from app.services.notification_service import NotificationService
from app.tasks.connector_indexers import (
index_airtable_records,
@ -3054,3 +3056,86 @@ async def test_mcp_server_connection(
"message": f"Failed to test connection: {e!s}",
"tools": [],
}
# ---------------------------------------------------------------------------
# Google Picker token endpoint (unified for native & Composio Drive)
# ---------------------------------------------------------------------------
DRIVE_CONNECTOR_TYPES = {
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
}
@router.get("/connectors/{connector_id}/drive-picker-token")
async def get_drive_picker_token(
connector_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Return an OAuth access token + client ID for the Google Picker API."""
result = await session.execute(
select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(status_code=404, detail="Connector not found")
await check_permission(
session,
user,
connector.search_space_id,
Permission.CONNECTORS_READ.value,
"You don't have permission to access this connector",
)
if connector.connector_type not in DRIVE_CONNECTOR_TYPES:
raise HTTPException(
status_code=400,
detail="This endpoint is only for Google Drive connectors",
)
picker_api_key = config.GOOGLE_PICKER_API_KEY
if not picker_api_key:
raise HTTPException(
status_code=500,
detail="GOOGLE_PICKER_API_KEY is not configured on the server",
)
try:
if connector.connector_type == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR:
from app.connectors.google_drive.credentials import get_valid_credentials
credentials = await get_valid_credentials(session, connector_id)
return {
"access_token": credentials.token,
"client_id": config.GOOGLE_OAUTH_CLIENT_ID,
"picker_api_key": picker_api_key,
}
# Composio path
composio_account_id = (connector.config or {}).get(
"composio_connected_account_id"
)
if not composio_account_id:
raise HTTPException(
status_code=400,
detail="Composio connected account not found. Please reconnect.",
)
service = get_composio_service()
access_token = await asyncio.to_thread(service.get_access_token, composio_account_id)
return {
"access_token": access_token,
"client_id": config.GOOGLE_OAUTH_CLIENT_ID,
"picker_api_key": picker_api_key,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get Drive picker token: {e!s}", exc_info=True)
raise HTTPException(
status_code=500,
detail="Failed to retrieve access token. Check server logs for details.",
) from e

View file

@ -247,6 +247,19 @@ class ComposioService:
)
return False
def get_access_token(self, connected_account_id: str) -> str:
"""Retrieve the raw OAuth access token for a Composio connected account."""
account = self.client.connected_accounts.get(nanoid=connected_account_id)
token = getattr(getattr(account, "state", None), "val", None)
if token is None:
raise ValueError(
f"No state.val on connected account {connected_account_id}"
)
access_token = getattr(token, "access_token", None)
if not access_token:
raise ValueError(f"No access_token in state.val for {connected_account_id}")
return access_token
async def execute_tool(
self,
connected_account_id: str,

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { type FC, forwardRef, useImperativeHandle, useMemo } from "react";
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import {
@ -21,6 +21,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
@ -143,6 +144,18 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
setConnectorName,
} = useConnectorDialog();
const [pickerOpen, setPickerOpen] = useState(false);
useEffect(() => {
const onOpen = () => setPickerOpen(true);
const onClose = () => setPickerOpen(false);
window.addEventListener(PICKER_OPEN_EVENT, onOpen);
window.addEventListener(PICKER_CLOSE_EVENT, onClose);
return () => {
window.removeEventListener(PICKER_OPEN_EVENT, onOpen);
window.removeEventListener(PICKER_CLOSE_EVENT, onClose);
};
}, []);
// Fetch connectors using Electric SQL + PGlite for real-time updates
// This provides instant updates when connectors change, without polling
const {
@ -202,7 +215,14 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
if (!searchSpaceId) return null;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && pickerOpen) return;
handleOpenChange(open);
}}
modal={!pickerOpen}
>
{showTrigger && (
<TooltipIconButton
data-joyride="connector-icon"

View file

@ -48,10 +48,8 @@ const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
include_subfolders: true,
};
// Helper to get appropriate icon for file type based on file name
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
const lowerName = fileName.toLowerCase();
// Spreadsheets
if (
lowerName.endsWith(".xlsx") ||
lowerName.endsWith(".xls") ||
@ -60,7 +58,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <FileSpreadsheet className={`${className} text-green-500`} />;
}
// Presentations
if (
lowerName.endsWith(".pptx") ||
lowerName.endsWith(".ppt") ||
@ -68,7 +65,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <Presentation className={`${className} text-orange-500`} />;
}
// Documents (word, text only - not PDF)
if (
lowerName.endsWith(".docx") ||
lowerName.endsWith(".doc") ||
@ -79,7 +75,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <FileText className={`${className} text-gray-500`} />;
}
// Images
if (
lowerName.endsWith(".png") ||
lowerName.endsWith(".jpg") ||
@ -90,7 +85,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <Image className={`${className} text-purple-500`} />;
}
// Default (including PDF)
return <File className={`${className} text-gray-500`} />;
}
@ -100,7 +94,6 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
}) => {
const isIndexable = connector.config?.is_indexable as boolean;
// Initialize with existing selected folders and files from connector config
const existingFolders =
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
@ -114,7 +107,6 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
// Update selected folders and files when connector config changes
useEffect(() => {
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
@ -171,7 +163,6 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
const totalSelected = selectedFolders.length + selectedFiles.length;
// Only show configuration if the connector is indexable
if (!isIndexable) {
return <div className="space-y-6" />;
}

View file

@ -8,12 +8,13 @@ import {
FileText,
FolderClosed,
Image,
Loader2,
Presentation,
X,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
@ -23,9 +24,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker";
import type { ConnectorConfigProps } from "../index";
interface SelectedFolder {
interface SelectedItem {
id: string;
name: string;
}
@ -42,10 +44,8 @@ const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
include_subfolders: true,
};
// Helper to get appropriate icon for file type based on file name
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
const lowerName = fileName.toLowerCase();
// Spreadsheets
if (
lowerName.endsWith(".xlsx") ||
lowerName.endsWith(".xls") ||
@ -54,7 +54,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <FileSpreadsheet className={`${className} text-green-500`} />;
}
// Presentations
if (
lowerName.endsWith(".pptx") ||
lowerName.endsWith(".ppt") ||
@ -62,7 +61,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <Presentation className={`${className} text-orange-500`} />;
}
// Documents (word, text only - not PDF)
if (
lowerName.endsWith(".docx") ||
lowerName.endsWith(".doc") ||
@ -73,7 +71,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <FileText className={`${className} text-gray-500`} />;
}
// Images
if (
lowerName.endsWith(".png") ||
lowerName.endsWith(".jpg") ||
@ -84,29 +81,22 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
) {
return <Image className={`${className} text-purple-500`} />;
}
// Default (including PDF)
return <File className={`${className} text-gray-500`} />;
}
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
// Initialize with existing selected folders and files from connector config
const existingFolders =
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || [];
const existingIndexingOptions =
(connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
const [selectedFolders, setSelectedFolders] = useState<SelectedItem[]>(existingFolders);
const [selectedFiles, setSelectedFiles] = useState<SelectedItem[]>(existingFiles);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
// Update selected folders and files when connector config changes
useEffect(() => {
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
const files = (connector.config?.selected_files as SelectedItem[] | undefined) || [];
const options =
(connector.config?.indexing_options as IndexingOptions | undefined) ||
DEFAULT_INDEXING_OPTIONS;
@ -116,8 +106,8 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
}, [connector.config]);
const updateConfig = (
folders: SelectedFolder[],
files: SelectedFolder[],
folders: SelectedItem[],
files: SelectedItem[],
options: IndexingOptions
) => {
if (onConfigChange) {
@ -130,15 +120,26 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
}
};
const handleSelectFolders = (folders: SelectedFolder[]) => {
setSelectedFolders(folders);
updateConfig(folders, selectedFiles, indexingOptions);
};
const handlePicked = useCallback(
(result: PickerResult) => {
const folders = result.folders.map((f) => ({ id: f.id, name: f.name }));
const files = result.files.map((f) => ({ id: f.id, name: f.name }));
setSelectedFolders(folders);
setSelectedFiles(files);
updateConfig(folders, files, indexingOptions);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[indexingOptions, connector.config]
);
const handleSelectFiles = (files: SelectedFolder[]) => {
setSelectedFiles(files);
updateConfig(selectedFolders, files, indexingOptions);
};
const {
openPicker,
loading: pickerLoading,
error: pickerError,
} = useGooglePicker({
connectorId: connector.id,
onPicked: handlePicked,
});
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
const newOptions = { ...indexingOptions, [key]: value };
@ -147,13 +148,13 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
};
const handleRemoveFolder = (folderId: string) => {
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
const newFolders = selectedFolders.filter((f) => f.id !== folderId);
setSelectedFolders(newFolders);
updateConfig(newFolders, selectedFiles, indexingOptions);
};
const handleRemoveFile = (fileId: string) => {
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
const newFiles = selectedFiles.filter((f) => f.id !== fileId);
setSelectedFiles(newFiles);
updateConfig(selectedFolders, newFiles, indexingOptions);
};
@ -228,39 +229,18 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
</div>
)}
{isEditMode ? (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
{isFolderTreeOpen ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
Change Selection
</button>
{isFolderTreeOpen && (
<GoogleDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
/>
)}
</div>
) : (
<GoogleDriveFolderTree
connectorId={connector.id}
selectedFolders={selectedFolders}
onSelectFolders={handleSelectFolders}
selectedFiles={selectedFiles}
onSelectFiles={handleSelectFiles}
/>
)}
<Button
type="button"
variant="outline"
onClick={openPicker}
disabled={pickerLoading}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{pickerLoading && <Loader2 className="size-3.5 mr-1.5 animate-spin" />}
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button>
{pickerError && <p className="text-xs text-destructive">{pickerError}</p>}
</div>
{/* Indexing Options */}

View file

@ -2,7 +2,6 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
// OAuth Connectors (Quick Connect)
export const OAUTH_CONNECTORS = [
// // Uncomment for managed Google Connections
// {
// id: "google-drive-connector",
// title: "Google Drive",
@ -241,5 +240,95 @@ export const COMPOSIO_TOOLKITS = [
},
] as const;
export interface AutoIndexConfig {
daysBack: number;
daysForward: number;
frequencyMinutes: number;
syncDescription: string;
}
export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of emails.",
},
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of emails.",
},
[EnumConnectorName.SLACK_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of messages.",
},
[EnumConnectorName.DISCORD_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of messages.",
},
[EnumConnectorName.TEAMS_CONNECTOR]: {
daysBack: 30,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 30 days of messages.",
},
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: {
daysBack: 90,
daysForward: 90,
frequencyMinutes: 1440,
syncDescription: "Syncing 90 days of past and upcoming events.",
},
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: {
daysBack: 90,
daysForward: 90,
frequencyMinutes: 1440,
syncDescription: "Syncing 90 days of past and upcoming events.",
},
[EnumConnectorName.LINEAR_CONNECTOR]: {
daysBack: 90,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 90 days of issues.",
},
[EnumConnectorName.JIRA_CONNECTOR]: {
daysBack: 90,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 90 days of issues.",
},
[EnumConnectorName.CLICKUP_CONNECTOR]: {
daysBack: 90,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your last 90 days of tasks.",
},
[EnumConnectorName.NOTION_CONNECTOR]: {
daysBack: 365,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your pages.",
},
[EnumConnectorName.CONFLUENCE_CONNECTOR]: {
daysBack: 365,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your documentation.",
},
[EnumConnectorName.AIRTABLE_CONNECTOR]: {
daysBack: 365,
daysForward: 0,
frequencyMinutes: 1440,
syncDescription: "Syncing your bases.",
},
};
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS));
// Re-export IndexingConfigState from schemas for backward compatibility
export type { IndexingConfigState } from "./connector-popup.schemas";

View file

@ -28,6 +28,8 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import type { IndexingConfigState } from "../constants/connector-constants";
import {
AUTO_INDEX_CONNECTOR_TYPES,
AUTO_INDEX_DEFAULTS,
COMPOSIO_CONNECTORS,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
@ -80,6 +82,7 @@ export const useConnectorDialog = () => {
const [connectingConnectorType, setConnectingConnectorType] = useState<string | null>(null);
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const isCreatingConnectorRef = useRef(false);
const isAutoIndexingRef = useRef(false);
// Accounts list view state (for OAuth connectors with multiple accounts)
const [viewingAccountsType, setViewingAccountsType] = useState<{
@ -119,6 +122,71 @@ export const useConnectorDialog = () => {
}
}, []);
const handleAutoIndex = useCallback(
async (
connector: SearchSourceConnector,
connectorTitle: string,
connectorType: string
) => {
if (!searchSpaceId || isAutoIndexingRef.current) return;
isAutoIndexingRef.current = true;
const defaults = AUTO_INDEX_DEFAULTS[connectorType];
const now = new Date();
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - (defaults?.daysBack ?? 365));
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + (defaults?.daysForward ?? 0));
const toastId = "auto-index";
toast.loading(`Setting up ${connectorTitle}...`, { id: toastId });
try {
await updateConnector({
id: connector.id,
data: {
periodic_indexing_enabled: true,
indexing_frequency_minutes: defaults?.frequencyMinutes ?? 1440,
},
});
await indexConnector({
connector_id: connector.id,
queryParams: {
search_space_id: searchSpaceId,
start_date: format(startDate, "yyyy-MM-dd"),
end_date: format(endDate, "yyyy-MM-dd"),
},
});
trackIndexWithDateRangeStarted(
Number(searchSpaceId),
connectorType,
connector.id,
{ hasStartDate: true, hasEndDate: true }
);
toast.success(`${connectorTitle} connected!`, {
id: toastId,
description: defaults?.syncDescription ?? "Syncing started.",
});
} catch (error) {
console.error("Auto-index failed:", error);
toast.error(`${connectorTitle} connected, but sync failed`, {
id: toastId,
description: "You can start syncing from settings.",
});
} finally {
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
await refetchAllConnectors();
isAutoIndexingRef.current = false;
}
},
[searchSpaceId, indexConnector, updateConnector, refetchAllConnectors]
);
// Synchronize state with URL query params
useEffect(() => {
try {
@ -336,8 +404,29 @@ export const useConnectorDialog = () => {
}
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
refetchAllConnectors().then((result) => {
if (!result.data) return;
// For auto-index connectors: close modal and show loading toast before refetch
const earlyConnector = params.connector
? OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector)
: null;
if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) {
toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" });
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.delete("connector");
url.searchParams.delete("connectorId");
url.searchParams.delete("view");
url.searchParams.delete("modal");
url.searchParams.delete("tab");
router.replace(url.pathname + url.search, { scroll: false });
}
refetchAllConnectors().then(async (result) => {
if (!result.data) {
toast.dismiss("auto-index");
return;
}
let newConnector: SearchSourceConnector | undefined;
let oauthConnector:
@ -376,31 +465,45 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
// Track connector connected event for OAuth/Composio connectors
trackConnectorConnected(
Number(searchSpaceId),
oauthConnector.connectorType,
newConnector.id
);
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config);
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
if (
newConnector.is_indexable &&
AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType)
) {
await handleAutoIndex(
newConnector,
oauthConnector.title,
oauthConnector.connectorType
);
} else {
toast.dismiss("auto-index");
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
connectorTitle: oauthConnector.title,
});
setIndexingConfig(config);
setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config);
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
}
} else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.dismiss("auto-index");
toast.error("Failed to validate connector data");
}
} else {
toast.dismiss("auto-index");
}
});
}
@ -408,7 +511,7 @@ export const useConnectorDialog = () => {
// Invalid query params - log but don't crash
console.warn("Invalid connector popup query params in OAuth success handler:", error);
}
}, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen]);
}, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen, handleAutoIndex, router]);
// Handle OAuth connection
const handleConnectOAuth = useCallback(
@ -479,6 +582,7 @@ export const useConnectorDialog = () => {
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
enable_summary: false,
},
queryParams: {
search_space_id: searchSpaceId,
@ -583,6 +687,7 @@ export const useConnectorDialog = () => {
connector_type: connectorData.connector_type as EnumConnectorName,
is_active: true,
next_scheduled_at: connectorData.next_scheduled_at as string | null,
enable_summary: false,
},
queryParams: {
search_space_id: searchSpaceId,
@ -1592,6 +1697,7 @@ export const useConnectorDialog = () => {
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleAutoIndex,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,

View file

@ -1,6 +1,5 @@
import { createCodePlugin } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import Image from "next/image";
import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css";
import { cn } from "@/lib/utils";
@ -126,12 +125,12 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
),
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
img: ({ src, alt, width: _w, height: _h, ...props }) => (
<Image
// eslint-disable-next-line @next/next/no-img-element
<img
className="max-w-full h-auto my-4 rounded"
alt={alt || "markdown image"}
height={100}
width={100}
src={typeof src === "string" ? src : ""}
loading="lazy"
{...props}
/>
),

View file

@ -0,0 +1,186 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
export interface PickerItem {
id: string;
name: string;
mimeType: string;
}
export interface PickerResult {
folders: PickerItem[];
files: PickerItem[];
}
interface UseGooglePickerOptions {
connectorId: number;
onPicked: (result: PickerResult) => void;
}
const PICKER_SCRIPT_URL = "https://apis.google.com/js/api.js";
const FOLDER_MIME = "application/vnd.google-apps.folder";
export const PICKER_OPEN_EVENT = "google-picker-open";
export const PICKER_CLOSE_EVENT = "google-picker-close";
let scriptLoadPromise: Promise<void> | null = null;
let pickerApiPromise: Promise<void> | null = null;
function loadPickerScript(): Promise<void> {
if (scriptLoadPromise) return scriptLoadPromise;
if (typeof window !== "undefined" && window.gapi) {
scriptLoadPromise = Promise.resolve();
return scriptLoadPromise;
}
scriptLoadPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = PICKER_SCRIPT_URL;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => {
scriptLoadPromise = null;
reject(new Error("Failed to load Google Picker script"));
};
document.head.appendChild(script);
});
return scriptLoadPromise;
}
function loadPickerApi(): Promise<void> {
if (pickerApiPromise) return pickerApiPromise;
pickerApiPromise = new Promise<void>((resolve, reject) => {
gapi.load("picker", {
callback: () => resolve(),
onerror: () => {
pickerApiPromise = null;
reject(new Error("Failed to load Google Picker API"));
},
});
});
return pickerApiPromise;
}
export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOptions) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onPickedRef = useRef(onPicked);
onPickedRef.current = onPicked;
const openingRef = useRef(false);
const pickerRef = useRef<google.picker.Picker | null>(null);
const closePicker = useCallback(() => {
if (!pickerRef.current) return;
window.dispatchEvent(new Event(PICKER_CLOSE_EVENT));
pickerRef.current.dispose();
pickerRef.current = null;
openingRef.current = false;
}, []);
useEffect(() => {
const onEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && pickerRef.current) {
closePicker();
}
};
window.addEventListener("keydown", onEscape);
return () => {
window.removeEventListener("keydown", onEscape);
if (pickerRef.current) {
pickerRef.current.dispose();
pickerRef.current = null;
}
openingRef.current = false;
};
}, [closePicker]);
const openPicker = useCallback(async () => {
if (openingRef.current) return;
openingRef.current = true;
setLoading(true);
setError(null);
try {
const [tokenData] = await Promise.all([
connectorsApiService.getDrivePickerToken(connectorId),
loadPickerScript().then(() => loadPickerApi()),
]);
const { access_token, picker_api_key } = tokenData;
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setIncludeFolders(true)
.setSelectFolderEnabled(true);
const builder = new google.picker.PickerBuilder()
.addView(docsView)
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
.setOAuthToken(access_token)
.setOrigin(window.location.protocol + "//" + window.location.host)
.setTitle("Select files and folders to index");
if (picker_api_key) {
builder.setDeveloperKey(picker_api_key);
}
const picker = builder
.setCallback((data: google.picker.ResponseObject) => {
const action = data[google.picker.Response.ACTION];
if (action === google.picker.Action.PICKED) {
const docs = data[google.picker.Response.DOCUMENTS];
if (docs) {
const folders: PickerItem[] = [];
const files: PickerItem[] = [];
for (const doc of docs) {
const mimeType = doc[google.picker.Document.MIME_TYPE] ?? "";
const item: PickerItem = {
id: doc[google.picker.Document.ID],
name: doc[google.picker.Document.NAME] ?? "Untitled",
mimeType,
};
if (mimeType === FOLDER_MIME) {
folders.push(item);
} else {
files.push(item);
}
}
onPickedRef.current({ folders, files });
}
}
if (action === google.picker.Action.ERROR) {
setError("Google Drive encountered an error. Please try again.");
}
if (
action === google.picker.Action.PICKED ||
action === google.picker.Action.CANCEL ||
action === google.picker.Action.ERROR
) {
closePicker();
}
})
.build();
pickerRef.current = picker;
window.dispatchEvent(new Event(PICKER_OPEN_EVENT));
picker.setVisible(true);
} catch (err) {
window.dispatchEvent(new Event(PICKER_CLOSE_EVENT));
openingRef.current = false;
const msg = err instanceof Error ? err.message : "Failed to open Google Picker";
setError(msg);
console.error("Google Picker error:", err);
} finally {
setLoading(false);
}
}, [connectorId, closePicker]);
return { openPicker, closePicker, loading, error };
}

View file

@ -266,6 +266,17 @@ class ConnectorsApiService {
);
};
/**
* Get Google Picker token (access_token + client_id + picker_api_key) for a Drive connector
*/
getDrivePickerToken = async (connectorId: number) => {
return baseApiService.get<{
access_token: string;
client_id: string;
picker_api_key: string | null;
}>(`/api/v1/connectors/${connectorId}/drive-picker-token`);
};
// =============================================================================
// MCP Connector Methods
// =============================================================================

View file

@ -144,6 +144,8 @@
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/canvas-confetti": "^1.9.0",
"@types/gapi": "^0.0.47",
"@types/google.picker": "^0.0.52",
"@types/node": "^20.19.9",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.8",

View file

@ -372,6 +372,12 @@ importers:
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/gapi':
specifier: ^0.0.47
version: 0.0.47
'@types/google.picker':
specifier: ^0.0.52
version: 0.0.52
'@types/node':
specifier: ^20.19.9
version: 20.19.33
@ -3807,6 +3813,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/gapi@0.0.47':
resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==}
'@types/google.picker@0.0.52':
resolution: {integrity: sha512-k0HyW8HxJePomM2r0JWq9nE9XG6qY93lVpoVnaV4WjQggDHrGwDKq3G8CGpcBWhQlJBTxX9jDIrI7RQnqjM63w==}
'@types/hast@2.3.10':
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
@ -10486,6 +10498,10 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/gapi@0.0.47': {}
'@types/google.picker@0.0.52': {}
'@types/hast@2.3.10':
dependencies:
'@types/unist': 2.0.11