Merge branch 'main' of https://github.com/siiddhantt/DocsGPT into pr/1930

This commit is contained in:
Siddhant Rai
2025-09-10 20:15:20 +05:30
51 changed files with 3792 additions and 247 deletions

View File

@@ -4,6 +4,13 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../api/services/userService';
import {
getSessionToken,
setSessionToken,
removeSessionToken,
} from '../utils/providerUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { formatBytes } from '../utils/stringUtils';
import FileUpload from '../assets/file_upload.svg';
import WebsiteCollect from '../assets/website_collect.svg';
import Dropdown from '../components/Dropdown';
@@ -25,6 +32,9 @@ import {
IngestorFormSchemas,
IngestorType,
} from './types/ingestor';
import FileIcon from '../assets/file.svg';
import FolderIcon from '../assets/folder.svg';
import ConnectorAuth from '../components/ConnectorAuth';
function Upload({
receivedFile = [],
@@ -48,6 +58,23 @@ function Upload({
const [activeTab, setActiveTab] = useState<string | null>(renderTab);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
// Google Drive state
const [isGoogleDriveConnected, setIsGoogleDriveConnected] = useState(false);
const [googleDriveFiles, setGoogleDriveFiles] = useState<any[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [authError, setAuthError] = useState<string>('');
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const [folderPath, setFolderPath] = useState<
Array<{ id: string | null; name: string }>
>([{ id: null, name: 'My Drive' }]);
const [nextPageToken, setNextPageToken] = useState<string | null>(null);
const [hasMoreFiles, setHasMoreFiles] = useState<boolean>(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const renderFormFields = () => {
const schema = IngestorFormSchemas[ingestor.type];
if (!schema) return null;
@@ -204,6 +231,7 @@ function Upload({
{ label: 'Link', value: 'url' },
{ label: 'GitHub', value: 'github' },
{ label: 'Reddit', value: 'reddit' },
{ label: 'Google Drive', value: 'google_drive' },
];
const sourceDocs = useSelector(selectSourceDocs);
@@ -428,29 +456,40 @@ function Upload({
formData.append('user', 'local');
formData.append('source', ingestor.type);
const defaultConfig = IngestorDefaultConfigs[ingestor.type].config;
let configData;
const mergedConfig = { ...defaultConfig, ...ingestor.config };
const filteredConfig = Object.entries(mergedConfig).reduce(
(acc, [key, value]) => {
const field = IngestorFormSchemas[ingestor.type].find(
(f) => f.name === key,
);
// Include the field if:
// 1. It's required, or
// 2. It's optional and has a non-empty value
if (
field?.required ||
(value !== undefined && value !== null && value !== '')
) {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
if (ingestor.type === 'google_drive') {
const sessionToken = getSessionToken(ingestor.type);
formData.append('data', JSON.stringify(filteredConfig));
const selectedItems = googleDriveFiles.filter((file) =>
selectedFiles.includes(file.id),
);
const selectedFolderIds = selectedItems
.filter(
(item) =>
item.type === 'application/vnd.google-apps.folder' || item.isFolder,
)
.map((folder) => folder.id);
const selectedFileIds = selectedItems
.filter(
(item) =>
item.type !== 'application/vnd.google-apps.folder' &&
!item.isFolder,
)
.map((file) => file.id);
configData = {
file_ids: selectedFileIds,
folder_ids: selectedFolderIds,
recursive: ingestor.config.recursive,
session_token: sessionToken || null,
};
} else {
configData = { ...ingestor.config };
}
formData.append('data', JSON.stringify(configData));
const apiHost: string = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
@@ -477,6 +516,181 @@ function Upload({
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
};
useEffect(() => {
if (ingestor.type === 'google_drive') {
const sessionToken = getSessionToken(ingestor.type);
if (sessionToken) {
// Auto-authenticate if session token exists
setIsGoogleDriveConnected(true);
setAuthError('');
// Fetch user email and files using the existing session token
fetchUserEmailAndLoadFiles(sessionToken);
}
}
}, [ingestor.type]);
const fetchUserEmailAndLoadFiles = async (sessionToken: string) => {
try {
const apiHost = import.meta.env.VITE_API_HOST;
const validateResponse = await fetch(
`${apiHost}/api/connectors/validate-session`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: 'google_drive',
session_token: sessionToken,
}),
},
);
if (!validateResponse.ok) {
removeSessionToken(ingestor.type);
setIsGoogleDriveConnected(false);
setAuthError('Session expired. Please reconnect to Google Drive.');
return;
}
const validateData = await validateResponse.json();
if (validateData.success) {
setUserEmail(validateData.user_email || 'Connected User');
// reset pagination state and files
setGoogleDriveFiles([]);
setNextPageToken(null);
setHasMoreFiles(false);
loadGoogleDriveFiles(sessionToken, null, null, false);
} else {
removeSessionToken(ingestor.type);
setIsGoogleDriveConnected(false);
setAuthError(
validateData.error ||
'Session expired. Please reconnect your Google Drive account and make sure to grant offline access.',
);
}
} catch (error) {
console.error('Error validating Google Drive session:', error);
setAuthError('Failed to validate session. Please reconnect.');
setIsGoogleDriveConnected(false);
}
};
const loadGoogleDriveFiles = async (
sessionToken: string,
folderId?: string | null,
pageToken?: string | null,
append = false,
) => {
setIsLoadingFiles(true);
try {
const apiHost = import.meta.env.VITE_API_HOST;
const requestBody: any = {
session_token: sessionToken,
limit: 10,
};
if (folderId) {
requestBody.folder_id = folderId;
}
if (pageToken) {
requestBody.page_token = pageToken;
}
const filesResponse = await fetch(`${apiHost}/api/connectors/files`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ ...requestBody, provider: 'google_drive' }),
});
if (!filesResponse.ok) {
throw new Error(`Failed to load files: ${filesResponse.status}`);
}
const filesData = await filesResponse.json();
if (filesData.success && Array.isArray(filesData.files)) {
setGoogleDriveFiles((prev) =>
append ? [...prev, ...filesData.files] : filesData.files,
);
setNextPageToken(filesData.next_page_token || null);
setHasMoreFiles(Boolean(filesData.has_more));
} else {
throw new Error(filesData.error || 'Failed to load files');
}
} catch (error) {
console.error('Error loading Google Drive files:', error);
setAuthError(
error instanceof Error
? error.message
: 'Failed to load files. Please make sure your Google Drive account is properly connected and you granted offline access during authorization.',
);
} finally {
setIsLoadingFiles(false);
}
};
// Handle file selection
const handleFileSelect = (fileId: string) => {
setSelectedFiles((prev) => {
if (prev.includes(fileId)) {
return prev.filter((id) => id !== fileId);
} else {
return [...prev, fileId];
}
});
};
const handleFolderClick = (folderId: string, folderName: string) => {
const sessionToken = getSessionToken(ingestor.type);
if (sessionToken) {
setCurrentFolderId(folderId);
setFolderPath((prev) => [...prev, { id: folderId, name: folderName }]);
setGoogleDriveFiles([]);
setNextPageToken(null);
setHasMoreFiles(false);
setSelectedFiles([]);
loadGoogleDriveFiles(sessionToken, folderId, null, false);
}
};
const navigateBack = (index: number) => {
const sessionToken = getSessionToken(ingestor.type);
if (sessionToken) {
const newPath = folderPath.slice(0, index + 1);
const targetFolderId = newPath[newPath.length - 1]?.id;
setCurrentFolderId(targetFolderId as string | null);
setFolderPath(newPath);
setGoogleDriveFiles([]);
setNextPageToken(null);
setHasMoreFiles(false);
setSelectedFiles([]);
loadGoogleDriveFiles(sessionToken, targetFolderId ?? null, null, false);
}
};
const handleSelectAll = () => {
if (selectedFiles.length === googleDriveFiles.length) {
setSelectedFiles([]);
} else {
setSelectedFiles(googleDriveFiles.map((file) => file.id));
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
@@ -515,6 +729,10 @@ function Upload({
if (!remoteName?.trim()) {
return true;
}
if (ingestor.type === 'google_drive') {
return !isGoogleDriveConnected || selectedFiles.length === 0;
}
const formFields: FormField[] = IngestorFormSchemas[ingestor.type];
for (const field of formFields) {
if (field.required) {
@@ -636,7 +854,7 @@ function Upload({
{files.map((file) => (
<p
key={file.name}
className="text-gray-6000 truncate overflow-hidden text-ellipsis"
className="text-gray-6000 truncate overflow-hidden text-ellipsis dark:text-[#ececf1]"
title={file.name}
>
{file.name}
@@ -679,6 +897,253 @@ function Upload({
required={true}
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
{ingestor.type === 'google_drive' && (
<div className="space-y-4">
{authError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-600 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">
{authError}
</p>
</div>
)}
{!isGoogleDriveConnected ? (
<ConnectorAuth
provider="google_drive"
onSuccess={(data) => {
setUserEmail(data.user_email);
setIsGoogleDriveConnected(true);
setIsAuthenticating(false);
setAuthError('');
if (data.session_token) {
setSessionToken(ingestor.type, data.session_token);
loadGoogleDriveFiles(data.session_token, null);
}
}}
onError={(error) => {
setAuthError(error);
setIsAuthenticating(false);
setIsGoogleDriveConnected(false);
}}
/>
) : (
<div className="space-y-4">
{/* Connection Status */}
<div className="flex w-full items-center justify-between rounded-lg bg-green-500 px-4 py-2 text-sm text-white">
<div className="flex items-center gap-2">
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
<span>Connected as {userEmail}</span>
</div>
<button
onClick={() => {
removeSessionToken(ingestor.type);
setIsGoogleDriveConnected(false);
setGoogleDriveFiles([]);
setSelectedFiles([]);
setUserEmail('');
setAuthError('');
const apiHost = import.meta.env.VITE_API_HOST;
fetch(`${apiHost}/api/connectors/disconnect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: ingestor.type,
session_token: getSessionToken(ingestor.type),
}),
}).catch((err) =>
console.error(
'Error disconnecting from Google Drive:',
err,
),
);
}}
className="text-xs text-white underline hover:text-gray-200"
>
Disconnect
</button>
</div>
{/* File Browser */}
<div className="rounded-lg border border-gray-200 dark:border-gray-600">
<div className="rounded-t-lg border-b border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800">
{/* Breadcrumb navigation */}
<div className="mb-2 flex items-center gap-1">
{folderPath.map((path, index) => (
<div
key={path.id || 'root'}
className="flex items-center gap-1"
>
{index > 0 && (
<span className="text-gray-400">/</span>
)}
<button
onClick={() => navigateBack(index)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-400"
disabled={index === folderPath.length - 1}
>
{path.name}
</button>
</div>
))}
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Select Files from Google Drive
</h4>
{googleDriveFiles.length > 0 && (
<button
onClick={handleSelectAll}
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{selectedFiles.length === googleDriveFiles.length
? 'Deselect All'
: 'Select All'}
</button>
)}
</div>
{selectedFiles.length > 0 && (
<p className="mt-1 text-xs text-gray-500">
{selectedFiles.length} file
{selectedFiles.length !== 1 ? 's' : ''} selected
</p>
)}
</div>
<div
className="max-h-72 overflow-y-auto"
ref={scrollContainerRef}
>
{isLoadingFiles && googleDriveFiles.length === 0 ? (
<div className="p-4 text-center">
<div className="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
Loading files...
</div>
</div>
) : googleDriveFiles.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
No files found in your Google Drive
</div>
) : (
<>
<div className="divide-y divide-gray-200 dark:divide-gray-600">
{googleDriveFiles.map((file) => (
<div
key={file.id}
className={`p-3 transition-colors ${
selectedFiles.includes(file.id)
? 'bg-blue-50 dark:bg-blue-900/20'
: ''
}`}
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<input
type="checkbox"
checked={selectedFiles.includes(
file.id,
)}
onChange={() =>
handleFileSelect(file.id)
}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</div>
{file.type ===
'application/vnd.google-apps.folder' ||
file.isFolder ? (
<div
className="cursor-pointer text-lg hover:text-blue-600"
onClick={() =>
handleFolderClick(file.id, file.name)
}
>
<img
src={FolderIcon}
alt="Folder"
className="h-6 w-6"
/>
</div>
) : (
<div className="text-lg">
<img
src={FileIcon}
alt="File"
className="h-6 w-6"
/>
</div>
)}
<div className="min-w-0 flex-1">
<p
className={`truncate text-sm font-medium dark:text-[#ececf1] ${
file.type ===
'application/vnd.google-apps.folder' ||
file.isFolder
? 'cursor-pointer hover:text-blue-600'
: ''
}`}
onClick={() => {
if (
file.type ===
'application/vnd.google-apps.folder' ||
file.isFolder
) {
handleFolderClick(
file.id,
file.name,
);
}
}}
>
{file.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{file.size &&
`${formatBytes(file.size)}`}
Modified {formatDate(file.modifiedTime)}
</p>
</div>
</div>
</div>
))}
</div>
<div className="flex items-center justify-center border-t border-gray-100 p-4 dark:border-gray-800">
{isLoadingFiles && (
<div className="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
Loading more files...
</div>
)}
{!hasMoreFiles && !isLoadingFiles && (
<span className="text-sm text-gray-500 dark:text-gray-400">
All files loaded
</span>
)}
</div>
</>
)}
</div>
<div className="hidden" aria-hidden="true"></div>
</div>
</div>
)}
</div>
)}
{renderFormFields()}
{IngestorFormSchemas[ingestor.type].some(
(field) => field.advanced,
@@ -719,7 +1184,9 @@ function Upload({
: 'bg-purple-30 hover:bg-violets-are-blue cursor-pointer text-white'
}`}
>
{t('modals.uploadDoc.train')}
{ingestor.type === 'google_drive' && selectedFiles.length > 0
? `Train with ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}`
: t('modals.uploadDoc.train')}
</button>
)}
</div>
@@ -727,6 +1194,41 @@ function Upload({
);
}
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (isNearBottom && hasMoreFiles && !isLoadingFiles && nextPageToken) {
const sessionToken = getSessionToken(ingestor.type);
if (sessionToken) {
loadGoogleDriveFiles(
sessionToken,
currentFolderId,
nextPageToken,
true,
);
}
}
};
scrollContainer?.addEventListener('scroll', handleScroll);
return () => {
scrollContainer?.removeEventListener('scroll', handleScroll);
};
}, [
hasMoreFiles,
isLoadingFiles,
nextPageToken,
currentFolderId,
ingestor.type,
]);
return (
<WrapperModal
isPerformingTask={progress !== undefined && progress.percentage < 100}

View File

@@ -22,7 +22,19 @@ export interface UrlIngestorConfig extends BaseIngestorConfig {
url: string;
}
export type IngestorType = 'crawler' | 'github' | 'reddit' | 'url';
export interface GoogleDriveIngestorConfig extends BaseIngestorConfig {
folder_id?: string;
file_ids?: string;
recursive?: boolean;
token_info?: any;
}
export type IngestorType =
| 'crawler'
| 'github'
| 'reddit'
| 'url'
| 'google_drive';
export interface IngestorConfig {
type: IngestorType;
@@ -31,7 +43,8 @@ export interface IngestorConfig {
| RedditIngestorConfig
| GithubIngestorConfig
| CrawlerIngestorConfig
| UrlIngestorConfig;
| UrlIngestorConfig
| GoogleDriveIngestorConfig;
}
export type IngestorFormData = {
@@ -109,6 +122,14 @@ export const IngestorFormSchemas: Record<IngestorType, FormField[]> = {
required: true,
},
],
google_drive: [
{
name: 'recursive',
label: 'Include subfolders',
type: 'boolean',
required: false,
},
],
};
export const IngestorDefaultConfigs: Record<
@@ -143,4 +164,12 @@ export const IngestorDefaultConfigs: Record<
repo_url: '',
} as GithubIngestorConfig,
},
google_drive: {
name: '',
config: {
folder_id: '',
file_ids: '',
recursive: true,
} as GoogleDriveIngestorConfig,
},
};