Merge branch 'main' into feat/remote-mcp

This commit is contained in:
Siddhant Rai
2025-09-15 09:53:58 +05:30
53 changed files with 4159 additions and 344 deletions

View File

@@ -29,7 +29,7 @@ export default function Hero({
</div>
{/* Demo Buttons Section */}
<div className="mb-8 w-full max-w-full md:mb-16">
<div className="mb-3 w-full max-w-full md:mb-3">
<div className="grid grid-cols-1 gap-3 text-xs md:grid-cols-1 md:gap-4 lg:grid-cols-2">
{demos?.map(
(demo: { header: string; query: string }, key: number) =>

View File

@@ -45,6 +45,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
description: '',
image: '',
source: '',
sources: [],
chunks: '',
retriever: '',
prompt_id: 'default',
@@ -150,7 +151,41 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const formData = new FormData();
formData.append('name', agent.name);
formData.append('description', agent.description);
formData.append('source', agent.source);
if (selectedSourceIds.size > 1) {
const sourcesArray = Array.from(selectedSourceIds)
.map((id) => {
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
);
if (sourceDoc?.name === 'Default' && !sourceDoc?.id) {
return 'default';
}
return sourceDoc?.id || id;
})
.filter(Boolean);
formData.append('sources', JSON.stringify(sourcesArray));
formData.append('source', '');
} else if (selectedSourceIds.size === 1) {
const singleSourceId = Array.from(selectedSourceIds)[0];
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === singleSourceId ||
source.retriever === singleSourceId ||
source.name === singleSourceId,
);
let finalSourceId;
if (sourceDoc?.name === 'Default' && !sourceDoc?.id)
finalSourceId = 'default';
else finalSourceId = sourceDoc?.id || singleSourceId;
formData.append('source', String(finalSourceId));
formData.append('sources', JSON.stringify([]));
} else {
formData.append('source', '');
formData.append('sources', JSON.stringify([]));
}
formData.append('chunks', agent.chunks);
formData.append('retriever', agent.retriever);
formData.append('prompt_id', agent.prompt_id);
@@ -196,7 +231,41 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const formData = new FormData();
formData.append('name', agent.name);
formData.append('description', agent.description);
formData.append('source', agent.source);
if (selectedSourceIds.size > 1) {
const sourcesArray = Array.from(selectedSourceIds)
.map((id) => {
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
);
if (sourceDoc?.name === 'Default' && !sourceDoc?.id) {
return 'default';
}
return sourceDoc?.id || id;
})
.filter(Boolean);
formData.append('sources', JSON.stringify(sourcesArray));
formData.append('source', '');
} else if (selectedSourceIds.size === 1) {
const singleSourceId = Array.from(selectedSourceIds)[0];
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === singleSourceId ||
source.retriever === singleSourceId ||
source.name === singleSourceId,
);
let finalSourceId;
if (sourceDoc?.name === 'Default' && !sourceDoc?.id)
finalSourceId = 'default';
else finalSourceId = sourceDoc?.id || singleSourceId;
formData.append('source', String(finalSourceId));
formData.append('sources', JSON.stringify([]));
} else {
formData.append('source', '');
formData.append('sources', JSON.stringify([]));
}
formData.append('chunks', agent.chunks);
formData.append('retriever', agent.retriever);
formData.append('prompt_id', agent.prompt_id);
@@ -293,9 +362,33 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
throw new Error('Failed to fetch agent');
}
const data = await response.json();
if (data.source) setSelectedSourceIds(new Set([data.source]));
else if (data.retriever)
if (data.sources && data.sources.length > 0) {
const mappedSources = data.sources.map((sourceId: string) => {
if (sourceId === 'default') {
const defaultSource = sourceDocs?.find(
(source) => source.name === 'Default',
);
return defaultSource?.retriever || 'classic';
}
return sourceId;
});
setSelectedSourceIds(new Set(mappedSources));
} else if (data.source) {
if (data.source === 'default') {
const defaultSource = sourceDocs?.find(
(source) => source.name === 'Default',
);
setSelectedSourceIds(
new Set([defaultSource?.retriever || 'classic']),
);
} else {
setSelectedSourceIds(new Set([data.source]));
}
} else if (data.retriever) {
setSelectedSourceIds(new Set([data.retriever]));
}
if (data.tools) setSelectedToolIds(new Set(data.tools));
if (data.status === 'draft') setEffectiveMode('draft');
if (data.json_schema) {
@@ -311,25 +404,57 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}, [agentId, mode, token]);
useEffect(() => {
const selectedSource = Array.from(selectedSourceIds).map((id) =>
sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
),
);
if (selectedSource[0]?.model === embeddingsName) {
if (selectedSource[0] && 'id' in selectedSource[0]) {
const selectedSources = Array.from(selectedSourceIds)
.map((id) =>
sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
),
)
.filter(Boolean);
if (selectedSources.length > 0) {
// Handle multiple sources
if (selectedSources.length > 1) {
// Multiple sources selected - store in sources array
const sourceIds = selectedSources
.map((source) => source?.id)
.filter((id): id is string => Boolean(id));
setAgent((prev) => ({
...prev,
source: selectedSource[0]?.id || 'default',
sources: sourceIds,
source: '', // Clear single source for multiple sources
retriever: '',
}));
} else
setAgent((prev) => ({
...prev,
source: '',
retriever: selectedSource[0]?.retriever || 'classic',
}));
} else {
// Single source selected - maintain backward compatibility
const selectedSource = selectedSources[0];
if (selectedSource?.model === embeddingsName) {
if (selectedSource && 'id' in selectedSource) {
setAgent((prev) => ({
...prev,
source: selectedSource?.id || 'default',
sources: [], // Clear sources array for single source
retriever: '',
}));
} else {
setAgent((prev) => ({
...prev,
source: '',
sources: [], // Clear sources array
retriever: selectedSource?.retriever || 'classic',
}));
}
}
}
} else {
// No sources selected
setAgent((prev) => ({
...prev,
source: '',
sources: [],
retriever: '',
}));
}
}, [selectedSourceIds]);
@@ -461,7 +586,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 h-32 w-full rounded-3xl border bg-white px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 h-32 w-full rounded-xl border bg-white px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
placeholder="Describe your agent"
value={agent.description}
onChange={(e) =>
@@ -510,7 +635,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)
.filter(Boolean)
.join(', ')
: 'Select source'}
: 'Select sources'}
</button>
<MultiSelectPopup
isOpen={isSourcePopupOpen}
@@ -526,12 +651,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
selectedIds={selectedSourceIds}
onSelectionChange={(newSelectedIds: Set<string | number>) => {
setSelectedSourceIds(newSelectedIds);
setIsSourcePopupOpen(false);
}}
title="Select Source"
title="Select Sources"
searchPlaceholder="Search sources..."
noOptionsMessage="No source available"
singleSelect={true}
noOptionsMessage="No sources available"
/>
</div>
<div className="mt-3">

View File

@@ -10,6 +10,7 @@ export type Agent = {
description: string;
image: string;
source: string;
sources?: string[];
chunks: string;
retriever: string;
prompt_id: string;

View File

@@ -38,6 +38,7 @@ const endpoints = {
UPDATE_TOOL_STATUS: '/api/update_tool_status',
UPDATE_TOOL: '/api/update_tool',
DELETE_TOOL: '/api/delete_tool',
SYNC_CONNECTOR: '/api/connectors/sync',
GET_CHUNKS: (
docId: string,
page: number,

View File

@@ -1,5 +1,6 @@
import apiClient from '../client';
import endpoints from '../endpoints';
import { getSessionToken } from '../../utils/providerUtils';
const userService = {
getConfig: (): Promise<any> => apiClient.get(endpoints.USER.CONFIG, null),
@@ -111,6 +112,22 @@ const userService = {
apiClient.post(endpoints.USER.MCP_TEST_CONNECTION, data, token),
saveMCPServer: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.MCP_SAVE_SERVER, data, token),
syncConnector: (
docId: string,
provider: string,
token: string | null,
): Promise<any> => {
const sessionToken = getSessionToken(provider);
return apiClient.post(
endpoints.USER.SYNC_CONNECTOR,
{
source_id: docId,
session_token: sessionToken,
provider: provider,
},
token,
);
},
};
export default userService;

View File

@@ -1 +1 @@
<svg width="16px" height="16px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#11ee1c" stroke="#11ee1c" stroke-width="83.96799999999999"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M866.133333 258.133333L362.666667 761.6l-204.8-204.8L98.133333 618.666667 362.666667 881.066667l563.2-563.2z" fill="#11ee1c"></path></g></svg>
<svg width="16px" height="16px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg" fill="#11ee1c" stroke="#11ee1c" stroke-width="83.96799999999999"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M866.133333 258.133333L362.666667 761.6l-204.8-204.8L98.133333 618.666667 362.666667 881.066667l563.2-563.2z" fill="#0C9D35"></path></g></svg>

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="black" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>

Before

Width:  |  Height:  |  Size: 454 B

After

Width:  |  Height:  |  Size: 454 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="black" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>

Before

Width:  |  Height:  |  Size: 454 B

After

Width:  |  Height:  |  Size: 454 B

View File

@@ -0,0 +1,130 @@
import React, { useRef } from 'react';
import { useSelector } from 'react-redux';
import { selectToken } from '../preferences/preferenceSlice';
interface ConnectorAuthProps {
provider: string;
onSuccess: (data: { session_token: string; user_email: string }) => void;
onError: (error: string) => void;
label?: string;
}
const providerLabel = (provider: string) => {
const map: Record<string, string> = {
google_drive: 'Google Drive',
};
return map[provider] || provider.replace(/_/g, ' ');
};
const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
provider,
onSuccess,
onError,
label,
}) => {
const token = useSelector(selectToken);
const completedRef = useRef(false);
const intervalRef = useRef<number | null>(null);
const cleanup = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
window.removeEventListener('message', handleAuthMessage as any);
};
const handleAuthMessage = (event: MessageEvent) => {
const successGeneric = event.data?.type === 'connector_auth_success';
const successProvider =
event.data?.type === `${provider}_auth_success` ||
event.data?.type === 'google_drive_auth_success';
const errorProvider =
event.data?.type === `${provider}_auth_error` ||
event.data?.type === 'google_drive_auth_error';
if (successGeneric || successProvider) {
completedRef.current = true;
cleanup();
onSuccess({
session_token: event.data.session_token,
user_email: event.data.user_email || 'Connected User',
});
} else if (errorProvider) {
completedRef.current = true;
cleanup();
onError(event.data.error || 'Authentication failed');
}
};
const handleAuth = async () => {
try {
completedRef.current = false;
cleanup();
const apiHost = import.meta.env.VITE_API_HOST;
const authResponse = await fetch(
`${apiHost}/api/connectors/auth?provider=${provider}`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
if (!authResponse.ok) {
throw new Error(
`Failed to get authorization URL: ${authResponse.status}`,
);
}
const authData = await authResponse.json();
if (!authData.success || !authData.authorization_url) {
throw new Error(authData.error || 'Failed to get authorization URL');
}
const authWindow = window.open(
authData.authorization_url,
`${provider}-auth`,
'width=500,height=600,scrollbars=yes,resizable=yes',
);
if (!authWindow) {
throw new Error(
'Failed to open authentication window. Please allow popups.',
);
}
window.addEventListener('message', handleAuthMessage as any);
const checkClosed = window.setInterval(() => {
if (authWindow.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', handleAuthMessage as any);
if (!completedRef.current) {
onError('Authentication was cancelled');
}
}
}, 1000);
intervalRef.current = checkClosed;
} catch (error) {
onError(error instanceof Error ? error.message : 'Authentication failed');
}
};
const buttonLabel = label || `Connect ${providerLabel(provider)}`;
return (
<button
onClick={handleAuth}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3 text-white transition-colors hover:bg-blue-600"
>
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M6.28 3l5.72 10H24l-5.72-10H6.28zm11.44 0L12 13l5.72 10H24L18.28 3h-.56zM0 13l5.72 10h5.72L5.72 13H0z"
/>
</svg>
{buttonLabel}
</button>
);
};
export default ConnectorAuth;

View File

@@ -0,0 +1,731 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { formatBytes } from '../utils/stringUtils';
import { selectToken } from '../preferences/preferenceSlice';
import Chunks from './Chunks';
import ContextMenu, { MenuOption } from './ContextMenu';
import userService from '../api/services/userService';
import FileIcon from '../assets/file.svg';
import FolderIcon from '../assets/folder.svg';
import ArrowLeft from '../assets/arrow-left.svg';
import ThreeDots from '../assets/three-dots.svg';
import EyeView from '../assets/eye-view.svg';
import SyncIcon from '../assets/sync.svg';
import { useOutsideAlerter } from '../hooks';
interface FileNode {
type?: string;
token_count?: number;
size_bytes?: number;
[key: string]: any;
}
interface DirectoryStructure {
[key: string]: FileNode;
}
interface ConnectorTreeComponentProps {
docId: string;
sourceName: string;
onBackToDocuments: () => void;
}
interface SearchResult {
name: string;
path: string;
isFile: boolean;
}
const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
docId,
sourceName,
onBackToDocuments,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [directoryStructure, setDirectoryStructure] =
useState<DirectoryStructure | null>(null);
const [currentPath, setCurrentPath] = useState<string[]>([]);
const token = useSelector(selectToken);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const menuRefs = useRef<{
[key: string]: React.RefObject<HTMLDivElement | null>;
}>({});
const [selectedFile, setSelectedFile] = useState<{
id: string;
name: string;
} | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const searchDropdownRef = useRef<HTMLDivElement>(null);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [syncProgress, setSyncProgress] = useState<number>(0);
const [sourceProvider, setSourceProvider] = useState<string>('');
const [syncDone, setSyncDone] = useState<boolean>(false);
useOutsideAlerter(
searchDropdownRef,
() => {
setSearchQuery('');
setSearchResults([]);
},
[],
false,
);
const handleFileClick = (fileName: string) => {
const fullPath = [...currentPath, fileName].join('/');
setSelectedFile({
id: fullPath,
name: fileName,
});
};
const handleSync = async () => {
if (isSyncing) return;
const provider = sourceProvider;
setIsSyncing(true);
setSyncProgress(0);
try {
const response = await userService.syncConnector(docId, provider, token);
const data = await response.json();
if (data.success) {
console.log('Sync started successfully:', data.task_id);
setSyncProgress(10);
// Poll task status using userService
const maxAttempts = 30;
const pollInterval = 2000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const statusResponse = await userService.getTaskStatus(
data.task_id,
token,
);
const statusData = await statusResponse.json();
console.log(
`Task status (attempt ${attempt + 1}):`,
statusData.status,
);
if (statusData.status === 'SUCCESS') {
setSyncProgress(100);
console.log('Sync completed successfully');
// Refresh directory structure
try {
const refreshResponse = await userService.getDirectoryStructure(
docId,
token,
);
const refreshData = await refreshResponse.json();
if (refreshData && refreshData.directory_structure) {
setDirectoryStructure(refreshData.directory_structure);
setCurrentPath([]);
}
if (refreshData && refreshData.provider) {
setSourceProvider(refreshData.provider);
}
setSyncDone(true);
setTimeout(() => setSyncDone(false), 5000);
} catch (err) {
console.error('Error refreshing directory structure:', err);
}
break;
} else if (statusData.status === 'FAILURE') {
console.error('Sync task failed:', statusData.result);
break;
} else if (statusData.status === 'PROGRESS') {
const progress = Number(
statusData.result && statusData.result.current != null
? statusData.result.current
: statusData.meta && statusData.meta.current != null
? statusData.meta.current
: 0,
);
setSyncProgress(Math.max(10, progress));
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
} catch (error) {
console.error('Error polling task status:', error);
break;
}
}
} else {
console.error('Sync failed:', data.error);
}
} catch (err) {
console.error('Error syncing connector:', err);
} finally {
setIsSyncing(false);
setSyncProgress(0);
}
};
useEffect(() => {
const fetchDirectoryStructure = async () => {
try {
setLoading(true);
const directoryResponse = await userService.getDirectoryStructure(
docId,
token,
);
const directoryData = await directoryResponse.json();
if (directoryData && directoryData.directory_structure) {
setDirectoryStructure(directoryData.directory_structure);
} else {
setError('Invalid response format');
}
if (directoryData && directoryData.provider) {
setSourceProvider(directoryData.provider);
}
} catch (err) {
setError('Failed to load source information');
console.error(err);
} finally {
setLoading(false);
}
};
if (docId) {
fetchDirectoryStructure();
}
}, [docId, token]);
const navigateToDirectory = (dirName: string) => {
setCurrentPath([...currentPath, dirName]);
};
const navigateUp = () => {
setCurrentPath(currentPath.slice(0, -1));
};
const getCurrentDirectory = (): DirectoryStructure => {
if (!directoryStructure) return {};
let current = directoryStructure;
for (const dir of currentPath) {
if (current[dir] && !current[dir].type) {
current = current[dir] as DirectoryStructure;
} else {
return {};
}
}
return current;
};
const getMenuRef = (id: string) => {
if (!menuRefs.current[id]) {
menuRefs.current[id] = React.createRef();
}
return menuRefs.current[id];
};
const handleMenuClick = (
e: React.MouseEvent<HTMLButtonElement>,
id: string,
) => {
e.stopPropagation();
setActiveMenuId(activeMenuId === id ? null : id);
};
const getActionOptions = (
name: string,
isFile: boolean,
_itemId: string,
): MenuOption[] => {
const options: MenuOption[] = [];
options.push({
icon: EyeView,
label: t('settings.sources.view'),
onClick: (event: React.SyntheticEvent) => {
event.stopPropagation();
if (isFile) {
handleFileClick(name);
} else {
navigateToDirectory(name);
}
},
iconWidth: 18,
iconHeight: 18,
variant: 'primary',
});
return options;
};
const calculateDirectoryStats = (
structure: DirectoryStructure,
): { totalSize: number; totalTokens: number } => {
let totalSize = 0;
let totalTokens = 0;
Object.entries(structure).forEach(([_, node]) => {
if (node.type) {
// It's a file
totalSize += node.size_bytes || 0;
totalTokens += node.token_count || 0;
} else {
// It's a directory, recurse
const stats = calculateDirectoryStats(node);
totalSize += stats.totalSize;
totalTokens += stats.totalTokens;
}
});
return { totalSize, totalTokens };
};
const handleBackNavigation = () => {
if (selectedFile) {
setSelectedFile(null);
} else if (currentPath.length === 0) {
if (onBackToDocuments) {
onBackToDocuments();
}
} else {
navigateUp();
}
};
const renderPathNavigation = () => {
return (
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
{/* Left side with path navigation */}
<div className="flex w-full items-center sm:w-auto">
<button
className="mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm font-medium text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={handleBackNavigation}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<div className="flex flex-wrap items-center">
<span className="font-semibold break-words text-[#7D54D1]">
{sourceName}
</span>
{currentPath.length > 0 && (
<>
<span className="mx-1 flex-shrink-0 text-gray-500">/</span>
{currentPath.map((dir, index) => (
<React.Fragment key={index}>
<span className="break-words text-gray-700 dark:text-[#E0E0E0]">
{dir}
</span>
{index < currentPath.length - 1 && (
<span className="mx-1 flex-shrink-0 text-gray-500">
/
</span>
)}
</React.Fragment>
))}
</>
)}
</div>
</div>
<div className="relative mt-2 flex w-full flex-row flex-nowrap items-center justify-end gap-2 sm:mt-0 sm:w-auto">
{renderFileSearch()}
{/* Sync button */}
<button
onClick={handleSync}
disabled={isSyncing}
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-nowrap transition-colors ${
isSyncing
? 'cursor-not-allowed bg-gray-300 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
: 'bg-purple-30 hover:bg-violets-are-blue text-white'
}`}
title={
isSyncing
? `${t('settings.sources.syncing')} ${syncProgress}%`
: syncDone
? 'Done'
: t('settings.sources.sync')
}
>
<img
src={SyncIcon}
alt={t('settings.sources.sync')}
className={`mr-2 h-4 w-4 brightness-0 invert filter ${isSyncing ? 'animate-spin' : ''}`}
/>
{isSyncing
? `${syncProgress}%`
: syncDone
? 'Done'
: t('settings.sources.sync')}
</button>
</div>
</div>
);
};
const renderFileTree = (directory: DirectoryStructure) => {
if (!directory) return [];
// Create parent directory row
const parentRow =
currentPath.length > 0
? [
<tr
key="parent-dir"
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={navigateUp}
>
<td className="px-2 py-2 lg:px-4">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate text-sm dark:text-[#E0E0E0]">
..
</span>
</div>
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
-
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
-
</td>
<td className="w-10 px-2 py-2 text-sm lg:px-4"></td>
</tr>,
]
: [];
// Sort entries: directories first, then files, both alphabetically
const sortedEntries = Object.entries(directory).sort(
([nameA, nodeA], [nameB, nodeB]) => {
const isFileA = !!nodeA.type;
const isFileB = !!nodeB.type;
if (isFileA !== isFileB) {
return isFileA ? 1 : -1; // Directories first
}
return nameA.localeCompare(nameB); // Alphabetical within each group
},
);
// Process directories
const directoryRows = sortedEntries
.filter(([_, node]) => !node.type)
.map(([name, node]) => {
const itemId = `dir-${name}`;
const menuRef = getMenuRef(itemId);
// Calculate directory stats
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
return (
<tr
key={itemId}
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={() => navigateToDirectory(name)}
>
<td className="px-2 py-2 lg:px-4">
<div className="flex min-w-0 items-center">
<img
src={FolderIcon}
alt={t('settings.sources.folderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate text-sm dark:text-[#E0E0E0]">
{name}
</span>
</div>
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
{dirStats.totalTokens > 0
? dirStats.totalTokens.toLocaleString()
: '-'}
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
{dirStats.totalSize > 0 ? formatBytes(dirStats.totalSize) : '-'}
</td>
<td className="w-10 px-2 py-2 text-sm lg:px-4">
<div ref={menuRef} className="relative">
<button
onClick={(e) => handleMenuClick(e, itemId)}
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E]"
aria-label={t('settings.sources.menuAlt')}
>
<img
src={ThreeDots}
alt={t('settings.sources.menuAlt')}
className="opacity-60 hover:opacity-100"
/>
</button>
<ContextMenu
isOpen={activeMenuId === itemId}
setIsOpen={(isOpen) =>
setActiveMenuId(isOpen ? itemId : null)
}
options={getActionOptions(name, false, itemId)}
anchorRef={menuRef}
position="bottom-left"
offset={{ x: -4, y: 4 }}
/>
</div>
</td>
</tr>
);
});
// Process files
const fileRows = sortedEntries
.filter(([_, node]) => !!node.type)
.map(([name, node]) => {
const itemId = `file-${name}`;
const menuRef = getMenuRef(itemId);
return (
<tr
key={itemId}
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={() => handleFileClick(name)}
>
<td className="px-2 py-2 lg:px-4">
<div className="flex min-w-0 items-center">
<img
src={FileIcon}
alt={t('settings.sources.fileAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate text-sm dark:text-[#E0E0E0]">
{name}
</span>
</div>
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
{node.token_count?.toLocaleString() || '-'}
</td>
<td className="px-2 py-2 text-sm md:px-4 dark:text-[#E0E0E0]">
{node.size_bytes ? formatBytes(node.size_bytes) : '-'}
</td>
<td className="w-10 px-2 py-2 text-sm lg:px-4">
<div ref={menuRef} className="relative">
<button
onClick={(e) => handleMenuClick(e, itemId)}
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E]"
aria-label={t('settings.sources.menuAlt')}
>
<img
src={ThreeDots}
alt={t('settings.sources.menuAlt')}
className="opacity-60 hover:opacity-100"
/>
</button>
<ContextMenu
isOpen={activeMenuId === itemId}
setIsOpen={(isOpen) =>
setActiveMenuId(isOpen ? itemId : null)
}
options={getActionOptions(name, true, itemId)}
anchorRef={menuRef}
position="bottom-left"
offset={{ x: -4, y: 4 }}
/>
</div>
</td>
</tr>
);
});
return [...parentRow, ...directoryRows, ...fileRows];
};
const searchFiles = (
query: string,
structure: DirectoryStructure,
currentPath: string[] = [],
): SearchResult[] => {
let results: SearchResult[] = [];
Object.entries(structure).forEach(([name, node]) => {
const fullPath = [...currentPath, name].join('/');
if (name.toLowerCase().includes(query.toLowerCase())) {
results.push({
name,
path: fullPath,
isFile: !!node.type,
});
}
if (!node.type) {
// If it's a directory, search recursively
results = [
...results,
...searchFiles(query, node as DirectoryStructure, [
...currentPath,
name,
]),
];
}
});
return results;
};
const handleSearchSelect = (result: SearchResult) => {
if (result.isFile) {
const pathParts = result.path.split('/');
const fileName = pathParts.pop() || '';
setCurrentPath(pathParts);
setSelectedFile({
id: result.path,
name: fileName,
});
} else {
setCurrentPath(result.path.split('/'));
setSelectedFile(null);
}
setSearchQuery('');
setSearchResults([]);
};
const renderFileSearch = () => {
return (
<div className="relative w-52" ref={searchDropdownRef}>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
if (directoryStructure) {
setSearchResults(searchFiles(e.target.value, directoryStructure));
}
}}
placeholder={t('settings.sources.searchFiles')}
className={`h-[38px] w-full border border-[#D1D9E0] px-4 py-2 dark:border-[#6A6A6A] ${searchQuery ? 'rounded-t-[24px]' : 'rounded-[24px]'} bg-transparent focus:outline-none dark:text-[#E0E0E0]`}
/>
{searchQuery && (
<div className="absolute top-full right-0 left-0 z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-[12px] border border-t-0 border-[#D1D9E0] bg-white shadow-lg transition-all duration-200 dark:border-[#6A6A6A] dark:bg-[#1F2023]">
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto overscroll-contain">
{searchResults.length === 0 ? (
<div className="py-2 text-center text-sm text-gray-500 dark:text-gray-400">
{t('settings.sources.noResults')}
</div>
) : (
searchResults.map((result, index) => (
<div
key={index}
onClick={() => handleSearchSelect(result)}
title={result.path}
className={`flex min-w-0 cursor-pointer items-center px-3 py-2 hover:bg-[#ECEEEF] dark:hover:bg-[#27282D] ${
index !== searchResults.length - 1
? 'border-b border-[#D1D9E0] dark:border-[#6A6A6A]'
: ''
}`}
>
<img
src={result.isFile ? FileIcon : FolderIcon}
alt={
result.isFile
? t('settings.sources.fileAlt')
: t('settings.sources.folderAlt')
}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="flex-1 truncate text-sm dark:text-[#E0E0E0]">
{result.path.split('/').pop() || result.path}
</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
};
const handleFileSearch = (searchQuery: string) => {
if (directoryStructure) {
return searchFiles(searchQuery, directoryStructure);
}
return [];
};
const handleFileSelect = (path: string) => {
const pathParts = path.split('/');
const fileName = pathParts.pop() || '';
setCurrentPath(pathParts);
setSelectedFile({
id: path,
name: fileName,
});
};
const currentDirectory = getCurrentDirectory();
const navigateToPath = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
};
return (
<div>
{selectedFile ? (
<div className="flex">
<div className="flex-1">
<Chunks
documentId={docId}
documentName={sourceName}
handleGoBack={() => setSelectedFile(null)}
path={selectedFile.id}
onFileSearch={handleFileSearch}
onFileSelect={handleFileSelect}
/>
</div>
</div>
) : (
<div className="flex w-full max-w-full flex-col overflow-hidden">
<div className="mb-2">{renderPathNavigation()}</div>
<div className="w-full">
<div className="overflow-x-auto rounded-[6px] border border-[#D1D9E0] dark:border-[#6A6A6A]">
<table className="w-full min-w-[600px] table-auto bg-transparent">
<thead className="bg-gray-100 dark:bg-[#27282D]">
<tr className="border-b border-[#D1D9E0] dark:border-[#6A6A6A]">
<th className="min-w-[200px] px-2 py-3 text-left text-sm font-medium text-gray-700 lg:px-4 dark:text-[#59636E]">
{t('settings.sources.fileName')}
</th>
<th className="min-w-[80px] px-2 py-3 text-left text-sm font-medium text-gray-700 lg:px-4 dark:text-[#59636E]">
{t('settings.sources.tokens')}
</th>
<th className="min-w-[80px] px-2 py-3 text-left text-sm font-medium text-gray-700 lg:px-4 dark:text-[#59636E]">
{t('settings.sources.size')}
</th>
<th className="w-10 px-2 py-3 text-left text-sm font-medium text-gray-700 lg:px-4 dark:text-[#59636E]"></th>
</tr>
</thead>
<tbody>{renderFileTree(getCurrentDirectory())}</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
};
export default ConnectorTreeComponent;

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { selectToken } from '../preferences/preferenceSlice';
import { formatBytes } from '../utils/stringUtils';
import Chunks from './Chunks';
import ContextMenu, { MenuOption } from './ContextMenu';
import userService from '../api/services/userService';
@@ -10,9 +11,7 @@ import FolderIcon from '../assets/folder.svg';
import ArrowLeft from '../assets/arrow-left.svg';
import ThreeDots from '../assets/three-dots.svg';
import EyeView from '../assets/eye-view.svg';
import OutlineSource from '../assets/outline-source.svg';
import Trash from '../assets/red-trash.svg';
import SearchIcon from '../assets/search.svg';
import { useOutsideAlerter } from '../hooks';
import ConfirmationModal from '../modals/ConfirmationModal';
@@ -128,14 +127,6 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
}
}, [docId, token]);
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const navigateToDirectory = (dirName: string) => {
setCurrentPath((prev) => [...prev, dirName]);
};
@@ -443,18 +434,18 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
const renderPathNavigation = () => {
return (
<div className="mb-0 min-h-[38px] flex flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
{/* Left side with path navigation */}
<div className="flex w-full items-center sm:w-auto">
<button
className="mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34] font-medium"
className="mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm font-medium text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={handleBackNavigation}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<div className="flex flex-wrap items-center">
<span className="text-[#7D54D1] font-semibold break-words">
<span className="font-semibold break-words text-[#7D54D1]">
{sourceName}
</span>
{currentPath.length > 0 && (
@@ -485,8 +476,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
</div>
</div>
<div className="flex relative flex-row flex-nowrap items-center gap-2 w-full sm:w-auto justify-end mt-2 sm:mt-0">
<div className="relative mt-2 flex w-full flex-row flex-nowrap items-center justify-end gap-2 sm:mt-0 sm:w-auto">
{processingRef.current && (
<div className="text-sm text-gray-500">
{currentOpRef.current === 'add'
@@ -495,13 +485,13 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
</div>
)}
{renderFileSearch()}
{renderFileSearch()}
{/* Add file button */}
{!processingRef.current && (
<button
onClick={handleAddFile}
className="bg-purple-30 hover:bg-violets-are-blue flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] whitespace-nowrap text-white font-medium"
className="bg-purple-30 hover:bg-violets-are-blue flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-nowrap text-white"
title={t('settings.sources.addFile')}
>
{t('settings.sources.addFile')}
@@ -543,32 +533,32 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
const parentRow =
currentPath.length > 0
? [
<tr
key="parent-dir"
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={navigateUp}
>
<td className="px-2 py-2 lg:px-4">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate text-sm dark:text-[#E0E0E0]">
..
</span>
</div>
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
-
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
-
</td>
<td className="w-10 px-2 py-2 text-sm lg:px-4"></td>
</tr>,
]
<tr
key="parent-dir"
className="cursor-pointer border-b border-[#D1D9E0] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]"
onClick={navigateUp}
>
<td className="px-2 py-2 lg:px-4">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate text-sm dark:text-[#E0E0E0]">
..
</span>
</div>
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
-
</td>
<td className="px-2 py-2 text-sm lg:px-4 dark:text-[#E0E0E0]">
-
</td>
<td className="w-10 px-2 py-2 text-sm lg:px-4"></td>
</tr>,
]
: [];
// Render directories first, then files
@@ -609,7 +599,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
<div ref={menuRef} className="relative">
<button
onClick={(e) => handleMenuClick(e, itemId)}
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E] font-medium"
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E]"
aria-label={t('settings.sources.menuAlt')}
>
<img
@@ -665,7 +655,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
<div ref={menuRef} className="relative">
<button
onClick={(e) => handleMenuClick(e, itemId)}
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E] font-medium"
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E]"
aria-label={t('settings.sources.menuAlt')}
>
<img
@@ -757,14 +747,12 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
}
}}
placeholder={t('settings.sources.searchFiles')}
className={`w-full h-[38px] border border-[#D1D9E0] px-4 py-2 dark:border-[#6A6A6A]
${searchQuery ? 'rounded-t-[24px]' : 'rounded-[24px]'}
bg-transparent focus:outline-none dark:text-[#E0E0E0]`}
className={`h-[38px] w-full border border-[#D1D9E0] px-4 py-2 dark:border-[#6A6A6A] ${searchQuery ? 'rounded-t-[24px]' : 'rounded-[24px]'} bg-transparent focus:outline-none dark:text-[#E0E0E0]`}
/>
{searchQuery && (
<div className="absolute top-full left-0 right-0 z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-[12px] border border-t-0 border-[#D1D9E0] bg-white shadow-lg dark:border-[#6A6A6A] dark:bg-[#1F2023] transition-all duration-200">
<div className="max-h-[calc(100vh-200px)] overflow-y-auto overflow-x-hidden overscroll-contain">
<div className="absolute top-full right-0 left-0 z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-[12px] border border-t-0 border-[#D1D9E0] bg-white shadow-lg transition-all duration-200 dark:border-[#6A6A6A] dark:bg-[#1F2023]">
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto overscroll-contain">
{searchResults.length === 0 ? (
<div className="py-2 text-center text-sm text-gray-500 dark:text-gray-400">
{t('settings.sources.noResults')}
@@ -775,10 +763,11 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
key={index}
onClick={() => handleSearchSelect(result)}
title={result.path}
className={`flex min-w-0 cursor-pointer items-center px-3 py-2 hover:bg-[#ECEEEF] dark:hover:bg-[#27282D] ${index !== searchResults.length - 1
className={`flex min-w-0 cursor-pointer items-center px-3 py-2 hover:bg-[#ECEEEF] dark:hover:bg-[#27282D] ${
index !== searchResults.length - 1
? 'border-b border-[#D1D9E0] dark:border-[#6A6A6A]'
: ''
}`}
}`}
>
<img
src={result.isFile ? FileIcon : FolderIcon}
@@ -789,7 +778,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="text-sm dark:text-[#E0E0E0] truncate flex-1">
<span className="flex-1 truncate text-sm dark:text-[#E0E0E0]">
{result.path.split('/').pop() || result.path}
</span>
</div>
@@ -871,7 +860,9 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
message={
itemToDelete?.isFile
? t('settings.sources.confirmDelete')
: t('settings.sources.deleteDirectoryWarning', { name: itemToDelete?.name })
: t('settings.sources.deleteDirectoryWarning', {
name: itemToDelete?.name,
})
}
modalState={deleteModalState}
setModalState={setDeleteModalState}

View File

@@ -259,7 +259,7 @@ export default function MessageInput({
return (
<div className="mx-2 flex w-full flex-col">
<div className="border-dark-gray bg-lotion dark:border-grey relative flex w-full flex-col rounded-[23px] border dark:bg-transparent">
<div className="flex flex-wrap gap-1.5 px-4 pt-3 pb-0 sm:gap-2 sm:px-6">
<div className="flex flex-wrap gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
{attachments.map((attachment, index) => (
<div
key={index}
@@ -353,14 +353,14 @@ export default function MessageInput({
onChange={handleChange}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style no-scrollbar bg-lotion dark:text-bright-gray dark:placeholder:text-bright-gray/50 w-full overflow-x-hidden overflow-y-auto rounded-t-[23px] px-4 py-3 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-6 sm:py-5 dark:bg-transparent"
className="inputbox-style no-scrollbar bg-lotion dark:text-bright-gray dark:placeholder:text-bright-gray/50 w-full overflow-x-hidden overflow-y-auto rounded-t-[23px] px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3 dark:bg-transparent"
onInput={handleInput}
onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')}
/>
</div>
<div className="flex items-center px-3 py-1.5 sm:px-4 sm:py-2">
<div className="flex items-center px-2 pb-1.5 sm:px-3 sm:pb-2">
<div className="flex grow flex-wrap gap-1 sm:gap-2">
{showSourceButton && (
<button
@@ -368,8 +368,8 @@ export default function MessageInput({
className="xs:px-3 xs:py-1.5 dark:border-purple-taupe flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 sm:max-w-[150px] dark:hover:bg-[#2C2E3C]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={
selectedDocs
? selectedDocs.name
selectedDocs && selectedDocs.length > 0
? selectedDocs.map((doc) => doc.name).join(', ')
: t('conversation.sources.title')
}
>
@@ -379,8 +379,10 @@ export default function MessageInput({
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4"
/>
<span className="xs:text-[12px] dark:text-bright-gray truncate overflow-hidden text-[10px] font-medium text-[#5D5D5D] sm:text-[14px]">
{selectedDocs
? selectedDocs.name
{selectedDocs && selectedDocs.length > 0
? selectedDocs.length === 1
? selectedDocs[0].name
: `${selectedDocs.length} sources selected`
: t('conversation.sources.title')}
</span>
{!isTouch && (
@@ -428,18 +430,18 @@ export default function MessageInput({
<button
onClick={loading ? undefined : handleSubmit}
aria-label={loading ? t('loading') : t('send')}
className={`flex items-center justify-center rounded-full p-2 sm:p-2.5 ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto shrink-0`}
className={`flex h-7 w-7 items-center justify-center rounded-full sm:h-9 sm:w-9 ${loading || !value.trim() ? 'bg-black opacity-60 dark:bg-[#F0F3F4] dark:opacity-80' : 'bg-black opacity-100 dark:bg-[#F0F3F4]'} ml-auto shrink-0`}
disabled={loading}
>
{loading ? (
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4"
className="mx-auto my-auto block h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4"
alt={t('loading')}
/>
) : (
<img
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 ${isDarkTheme ? 'invert filter' : ''}`}
className={`mx-auto my-auto block h-3.5 w-3.5 translate-x-[-0.9px] translate-y-[1.1px] sm:h-4 sm:w-4 ${isDarkTheme ? 'invert filter' : ''}`}
src={PaperPlane}
alt={t('send')}
/>

View File

@@ -248,7 +248,7 @@ export default function MultiSelectPopup({
</div>
<div className="shrink-0">
<div
className={`dark:bg-charleston-green-2 flex h-4 w-4 items-center justify-center rounded-xs border border-[#C6C6C6] bg-white dark:border-[#757783]`}
className={`dark:bg-charleston-green-2 flex h-4 w-4 items-center justify-center rounded-xs border-2 border-[#C6C6C6] bg-white dark:border-[#757783]`}
aria-hidden="true"
>
{isSelected && (

View File

@@ -17,7 +17,7 @@ type SourcesPopupProps = {
isOpen: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
handlePostDocumentSelect: (doc: Doc | null) => void;
handlePostDocumentSelect: (doc: Doc[] | null) => void;
setUploadModalState: React.Dispatch<React.SetStateAction<ActiveState>>;
};
@@ -149,9 +149,13 @@ export default function SourcesPopup({
if (option.model === embeddingsName) {
const isSelected =
selectedDocs &&
(option.id
? selectedDocs.id === option.id
: selectedDocs.date === option.date);
Array.isArray(selectedDocs) &&
selectedDocs.length > 0 &&
selectedDocs.some((doc) =>
option.id
? doc.id === option.id
: doc.date === option.date,
);
return (
<div
@@ -159,11 +163,29 @@ export default function SourcesPopup({
className="border-opacity-80 dark:border-dim-gray flex cursor-pointer items-center border-b border-[#D9D9D9] p-3 transition-colors hover:bg-gray-100 dark:text-[14px] dark:hover:bg-[#2C2E3C]"
onClick={() => {
if (isSelected) {
dispatch(setSelectedDocs(null));
handlePostDocumentSelect(null);
const updatedDocs =
selectedDocs && Array.isArray(selectedDocs)
? selectedDocs.filter((doc) =>
option.id
? doc.id !== option.id
: doc.date !== option.date,
)
: [];
dispatch(
setSelectedDocs(
updatedDocs.length > 0 ? updatedDocs : null,
),
);
handlePostDocumentSelect(
updatedDocs.length > 0 ? updatedDocs : null,
);
} else {
dispatch(setSelectedDocs(option));
handlePostDocumentSelect(option);
const updatedDocs =
selectedDocs && Array.isArray(selectedDocs)
? [...selectedDocs, option]
: [option];
dispatch(setSelectedDocs(updatedDocs));
handlePostDocumentSelect(updatedDocs);
}
}}
>
@@ -178,7 +200,7 @@ export default function SourcesPopup({
{option.name}
</span>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center border border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border-2 border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`}
>
{isSelected && (
<img

View File

@@ -46,7 +46,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
return (
<label
className={`flex cursor-pointer select-none flex-row items-center ${
className={`flex cursor-pointer flex-row items-center select-none ${
labelPosition === 'right' ? 'flex-row-reverse' : ''
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${className}`}
>
@@ -75,7 +75,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
}`}
></div>
<div
className={`absolute ${toggle} flex items-center justify-center rounded-full bg-white opacity-80 transition ${
className={`absolute ${toggle} flex items-center justify-center rounded-full bg-white transition ${
checked ? `${translate} bg-silver` : ''
}`}
></div>

View File

@@ -207,7 +207,7 @@ export default function ToolsPopup({
</div>
<div className="flex shrink-0 items-center">
<div
className={`flex h-4 w-4 items-center justify-center border border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`}
className={`flex h-4 w-4 items-center justify-center rounded-xs border-2 border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`}
>
{tool.status && (
<img

View File

@@ -7,7 +7,7 @@ export function handleFetchAnswer(
question: string,
signal: AbortSignal,
token: string | null,
selectedDocs: Doc | null,
selectedDocs: Doc[] | null,
conversationId: string | null,
promptId: string | null,
chunks: string,
@@ -52,10 +52,17 @@ export function handleFetchAnswer(
payload.attachments = attachments;
}
if (selectedDocs && 'id' in selectedDocs) {
payload.active_docs = selectedDocs.id as string;
if (selectedDocs && Array.isArray(selectedDocs)) {
if (selectedDocs.length > 1) {
// Handle multiple documents
payload.active_docs = selectedDocs.map((doc) => doc.id!);
payload.retriever = selectedDocs[0]?.retriever as string;
} else if (selectedDocs.length === 1 && 'id' in selectedDocs[0]) {
// Handle single document (backward compatibility)
payload.active_docs = selectedDocs[0].id as string;
payload.retriever = selectedDocs[0].retriever as string;
}
}
payload.retriever = selectedDocs?.retriever as string;
return conversationService
.answer(payload, token, signal)
.then((response) => {
@@ -84,7 +91,7 @@ export function handleFetchAnswerSteaming(
question: string,
signal: AbortSignal,
token: string | null,
selectedDocs: Doc | null,
selectedDocs: Doc[] | null,
conversationId: string | null,
promptId: string | null,
chunks: string,
@@ -112,10 +119,17 @@ export function handleFetchAnswerSteaming(
payload.attachments = attachments;
}
if (selectedDocs && 'id' in selectedDocs) {
payload.active_docs = selectedDocs.id as string;
if (selectedDocs && Array.isArray(selectedDocs)) {
if (selectedDocs.length > 1) {
// Handle multiple documents
payload.active_docs = selectedDocs.map((doc) => doc.id!);
payload.retriever = selectedDocs[0]?.retriever as string;
} else if (selectedDocs.length === 1 && 'id' in selectedDocs[0]) {
// Handle single document (backward compatibility)
payload.active_docs = selectedDocs[0].id as string;
payload.retriever = selectedDocs[0].retriever as string;
}
}
payload.retriever = selectedDocs?.retriever as string;
return new Promise<Answer>((resolve, reject) => {
conversationService
@@ -171,7 +185,7 @@ export function handleFetchAnswerSteaming(
export function handleSearch(
question: string,
token: string | null,
selectedDocs: Doc | null,
selectedDocs: Doc[] | null,
conversation_id: string | null,
chunks: string,
token_limit: number,
@@ -183,9 +197,17 @@ export function handleSearch(
token_limit: token_limit,
isNoneDoc: selectedDocs === null,
};
if (selectedDocs && 'id' in selectedDocs)
payload.active_docs = selectedDocs.id as string;
payload.retriever = selectedDocs?.retriever as string;
if (selectedDocs && Array.isArray(selectedDocs)) {
if (selectedDocs.length > 1) {
// Handle multiple documents
payload.active_docs = selectedDocs.map((doc) => doc.id!);
payload.retriever = selectedDocs[0]?.retriever as string;
} else if (selectedDocs.length === 1 && 'id' in selectedDocs[0]) {
// Handle single document (backward compatibility)
payload.active_docs = selectedDocs[0].id as string;
payload.retriever = selectedDocs[0].retriever as string;
}
}
return conversationService
.search(payload, token)
.then((response) => response.json())

View File

@@ -54,7 +54,7 @@ export interface Query {
export interface RetrievalPayload {
question: string;
active_docs?: string;
active_docs?: string | string[];
retriever?: string;
conversation_id: string | null;
prompt_id?: string | null;

View File

@@ -18,11 +18,14 @@ export default function useDefaultDocument() {
const fetchDocs = () => {
getDocs(token).then((data) => {
dispatch(setSourceDocs(data));
if (!selectedDoc)
if (
!selectedDoc ||
(Array.isArray(selectedDoc) && selectedDoc.length === 0)
)
Array.isArray(data) &&
data?.forEach((doc: Doc) => {
if (doc.model && doc.name === 'default') {
dispatch(setSelectedDocs(doc));
dispatch(setSelectedDocs([doc]));
}
});
});

View File

@@ -67,6 +67,7 @@
"preLoaded": "Pre-loaded",
"private": "Private",
"sync": "Sync",
"syncing": "Syncing...",
"syncFrequency": {
"never": "Never",
"daily": "Daily",

View File

@@ -67,6 +67,7 @@
"preLoaded": "Precargado",
"private": "Privado",
"sync": "Sincronizar",
"syncing": "Sincronizando...",
"syncFrequency": {
"never": "Nunca",
"daily": "Diario",

View File

@@ -67,6 +67,7 @@
"preLoaded": "プリロード済み",
"private": "プライベート",
"sync": "同期",
"syncing": "同期中...",
"syncFrequency": {
"never": "なし",
"daily": "毎日",

View File

@@ -67,6 +67,7 @@
"preLoaded": "Предзагруженный",
"private": "Частный",
"sync": "Синхронизация",
"syncing": "Синхронизация...",
"syncFrequency": {
"never": "Никогда",
"daily": "Ежедневно",

View File

@@ -67,6 +67,7 @@
"preLoaded": "預載入",
"private": "私人",
"sync": "同步",
"syncing": "同步中...",
"syncFrequency": {
"never": "從不",
"daily": "每天",

View File

@@ -67,6 +67,7 @@
"preLoaded": "预加载",
"private": "私有",
"sync": "同步",
"syncing": "同步中...",
"syncFrequency": {
"never": "从不",
"daily": "每天",

View File

@@ -60,7 +60,7 @@ export const ShareConversationModal = ({
const [sourcePath, setSourcePath] = useState<{
label: string;
value: string;
} | null>(preSelectedDoc ? extractDocPaths([preSelectedDoc])[0] : null);
} | null>(preSelectedDoc ? extractDocPaths(preSelectedDoc)[0] : null);
const handleCopyKey = (url: string) => {
navigator.clipboard.writeText(url);
@@ -105,14 +105,14 @@ export const ShareConversationModal = ({
return (
<WrapperModal close={close}>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-medium text-eerie-black dark:text-chinese-white">
<h2 className="text-eerie-black dark:text-chinese-white text-xl font-medium">
{t('modals.shareConv.label')}
</h2>
<p className="text-sm text-eerie-black dark:text-silver/60">
<p className="text-eerie-black dark:text-silver/60 text-sm">
{t('modals.shareConv.note')}
</p>
<div className="flex items-center justify-between">
<span className="text-lg text-eerie-black dark:text-white">
<span className="text-eerie-black text-lg dark:text-white">
{t('modals.shareConv.option')}
</span>
<ToggleSwitch
@@ -136,19 +136,19 @@ export const ShareConversationModal = ({
</div>
)}
<div className="flex items-baseline justify-between gap-2">
<span className="no-scrollbar w-full overflow-x-auto whitespace-nowrap rounded-full border-2 border-silver px-4 py-3 text-eerie-black dark:border-silver/40 dark:text-white">
<span className="no-scrollbar border-silver text-eerie-black dark:border-silver/40 w-full overflow-x-auto rounded-full border-2 px-4 py-3 whitespace-nowrap dark:text-white">
{`${domain}/share/${identifier ?? '....'}`}
</span>
{status === 'fetched' ? (
<button
className="my-1 h-10 w-28 rounded-full bg-purple-30 p-2 text-sm text-white hover:bg-violets-are-blue"
className="bg-purple-30 hover:bg-violets-are-blue my-1 h-10 w-28 rounded-full p-2 text-sm text-white"
onClick={() => handleCopyKey(`${domain}/share/${identifier}`)}
>
{isCopied ? t('modals.saveKey.copied') : t('modals.saveKey.copy')}
</button>
) : (
<button
className="my-1 flex h-10 w-28 items-center justify-evenly rounded-full bg-purple-30 p-2 text-center text-sm font-normal text-white hover:bg-violets-are-blue"
className="bg-purple-30 hover:bg-violets-are-blue my-1 flex h-10 w-28 items-center justify-evenly rounded-full p-2 text-center text-sm font-normal text-white"
onClick={() => {
shareCoversationPublicly(allowPrompt);
}}

View File

@@ -42,10 +42,10 @@ export default function WrapperModal({
}, [close, isPerformingTask]);
const modalContent = (
<div className="bg-gray-alpha bg-opacity-50 fixed top-0 left-0 z-30 flex h-screen w-screen items-center justify-center">
<div className="fixed top-0 left-0 z-30 flex h-screen w-screen items-center justify-center">
<div
ref={modalRef}
className={`relative w-11/12 rounded-2xl bg-white p-8 sm:w-[512px] dark:bg-[#26272E] ${className}`}
className={`relative w-11/12 rounded-2xl bg-white p-8 shadow-2xl sm:w-[512px] dark:bg-[#26272E] ${className}`}
>
{!isPerformingTask && (
<button

View File

@@ -90,9 +90,9 @@ export function getLocalApiKey(): string | null {
return key;
}
export function getLocalRecentDocs(): string | null {
const doc = localStorage.getItem('DocsGPTRecentDocs');
return doc;
export function getLocalRecentDocs(): Doc[] | null {
const docs = localStorage.getItem('DocsGPTRecentDocs');
return docs ? (JSON.parse(docs) as Doc[]) : null;
}
export function getLocalPrompt(): string | null {
@@ -108,19 +108,20 @@ export function setLocalPrompt(prompt: string): void {
localStorage.setItem('DocsGPTPrompt', prompt);
}
export function setLocalRecentDocs(doc: Doc | null): void {
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(doc));
export function setLocalRecentDocs(docs: Doc[] | null): void {
if (docs && docs.length > 0) {
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(docs));
let docPath = 'default';
if (doc?.type === 'local') {
docPath = 'local' + '/' + doc.name + '/';
docs.forEach((doc) => {
let docPath = 'default';
if (doc.type === 'local') {
docPath = 'local' + '/' + doc.name + '/';
}
userService
.checkDocs({ docs: docPath }, null)
.then((response) => response.json());
});
} else {
localStorage.removeItem('DocsGPTRecentDocs');
}
userService
.checkDocs(
{
docs: docPath,
},
null,
)
.then((response) => response.json());
}

View File

@@ -15,7 +15,7 @@ export interface Preference {
prompt: { name: string; id: string; type: string };
chunks: string;
token_limit: number;
selectedDocs: Doc | null;
selectedDocs: Doc[] | null;
sourceDocs: Doc[] | null;
conversations: {
data: { name: string; id: string }[] | null;
@@ -34,15 +34,16 @@ const initialState: Preference = {
prompt: { name: 'default', id: 'default', type: 'public' },
chunks: '2',
token_limit: 2000,
selectedDocs: {
id: 'default',
name: 'default',
type: 'remote',
date: 'default',
docLink: 'default',
model: 'openai_text-embedding-ada-002',
retriever: 'classic',
} as Doc,
selectedDocs: [
{
id: 'default',
name: 'default',
type: 'remote',
date: 'default',
model: 'openai_text-embedding-ada-002',
retriever: 'classic',
},
] as Doc[],
sourceDocs: null,
conversations: {
data: null,

View File

@@ -1,4 +1,3 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -29,6 +28,7 @@ import {
import Upload from '../upload/Upload';
import { formatDate } from '../utils/dateTimeUtils';
import FileTreeComponent from '../components/FileTreeComponent';
import ConnectorTreeComponent from '../components/ConnectorTreeComponent';
import Chunks from '../components/Chunks';
const formatTokens = (tokens: number): string => {
@@ -272,11 +272,19 @@ export default function Sources({
return documentToView ? (
<div className="mt-8 flex flex-col">
{documentToView.isNested ? (
<FileTreeComponent
docId={documentToView.id || ''}
sourceName={documentToView.name}
onBackToDocuments={() => setDocumentToView(undefined)}
/>
documentToView.type === 'connector' ? (
<ConnectorTreeComponent
docId={documentToView.id || ''}
sourceName={documentToView.name}
onBackToDocuments={() => setDocumentToView(undefined)}
/>
) : (
<FileTreeComponent
docId={documentToView.id || ''}
sourceName={documentToView.name}
onBackToDocuments={() => setDocumentToView(undefined)}
/>
)
) : (
<Chunks
documentId={documentToView.id || ''}
@@ -310,7 +318,7 @@ export default function Sources({
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-full h-[32px] rounded-full border border-silver dark:border-silver/40 bg-transparent px-3 text-sm text-jet dark:text-bright-gray placeholder:text-gray-400 dark:placeholder:text-gray-500 outline-none focus:border-silver dark:focus:border-silver/60"
className="border-silver dark:border-silver/40 text-jet dark:text-bright-gray focus:border-silver dark:focus:border-silver/60 h-[32px] w-full rounded-full border bg-transparent px-3 text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
</div>
@@ -327,7 +335,7 @@ export default function Sources({
</div>
<div className="relative w-full">
{loading ? (
<div className="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 px-2 py-4">
<div className="grid w-full grid-cols-1 gap-6 px-2 py-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<SkeletonLoader component="sourceCards" count={rowsPerPage} />
</div>
) : !currentDocuments?.length ? (
@@ -342,19 +350,19 @@ export default function Sources({
</p>
</div>
) : (
<div className="w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 px-2 py-4">
{currentDocuments.map((document, index) => {
const docId = document.id ? document.id.toString() : '';
<div className="grid w-full grid-cols-1 gap-6 px-2 py-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{currentDocuments.map((document, index) => {
const docId = document.id ? document.id.toString() : '';
return (
<div key={docId} className="relative">
<div
className={`flex h-[130px] w-full flex-col rounded-2xl bg-[#F9F9F9] p-3 transition-all duration-200 dark:bg-[#383838] ${
activeMenuId === docId || syncMenuState.docId === docId
? 'scale-[1.05]'
: 'hover:scale-[1.05]'
}`}
>
return (
<div key={docId} className="relative">
<div
className={`flex h-[130px] w-full flex-col rounded-2xl bg-[#F9F9F9] p-3 transition-all duration-200 dark:bg-[#383838] ${
activeMenuId === docId || syncMenuState.docId === docId
? 'scale-[1.05]'
: 'hover:scale-[1.05]'
}`}
>
<div className="w-full flex-1">
<div className="flex w-full items-center justify-between gap-2">
<h3
@@ -418,7 +426,7 @@ export default function Sources({
<img
src={CalendarIcon}
alt=""
className="w-[14px] h-[14px]"
className="h-[14px] w-[14px]"
/>
<span className="font-inter text-[12px] leading-[18px] font-[500] text-[#848484] dark:text-[#848484]">
{document.date ? formatDate(document.date) : ''}
@@ -428,7 +436,7 @@ export default function Sources({
<img
src={DiscIcon}
alt=""
className="w-[14px] h-[14px]"
className="h-[14px] w-[14px]"
/>
<span className="font-inter text-[12px] leading-[18px] font-[500] text-[#848484] dark:text-[#848484]">
{document.tokens

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,
},
};

View File

@@ -0,0 +1,16 @@
/**
* Utility functions for managing session tokens for different cloud service providers.
* Follows the convention: {provider}_session_token
*/
export const getSessionToken = (provider: string): string | null => {
return localStorage.getItem(`${provider}_session_token`);
};
export const setSessionToken = (provider: string, token: string): void => {
localStorage.setItem(`${provider}_session_token`, token);
};
export const removeSessionToken = (provider: string): void => {
localStorage.removeItem(`${provider}_session_token`);
};

View File

@@ -2,3 +2,12 @@ export function truncate(str: string, n: number) {
// slices long strings and ends with ...
return str.length > n ? str.slice(0, n - 1) + '...' : str;
}
export function formatBytes(bytes: number | null): string {
if (!bytes || bytes <= 0) return '';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}