diff --git a/frontend/src/components/FilePicker.tsx b/frontend/src/components/FilePicker.tsx index f9570bc0..1e0d1ac1 100644 --- a/frontend/src/components/FilePicker.tsx +++ b/frontend/src/components/FilePicker.tsx @@ -75,6 +75,8 @@ interface CloudFilePickerProps { selectedFolderIds?: string[], ) => void; onDisconnect?: () => void; + /** Called with the first item's name when the selection goes from empty to one. */ + onFirstPickName?: (name: string) => void; provider: string; token: string | null; initialSelectedFiles?: string[]; @@ -84,6 +86,7 @@ interface CloudFilePickerProps { export const FilePicker: React.FC = ({ onSelectionChange, onDisconnect, + onFirstPickName, provider, token, initialSelectedFiles = [], @@ -404,6 +407,14 @@ export const FilePicker: React.FC = ({ }; const handleFileSelect = (fileId: string, isFolder: boolean) => { + if ( + selectedFiles.length === 0 && + selectedFolders.length === 0 && + onFirstPickName + ) { + const picked = files.find((file) => file.id === fileId); + if (picked) onFirstPickName(picked.name); + } if (isFolder) { const newSelectedFolders = selectedFolders.includes(fileId) ? selectedFolders.filter((id) => id !== fileId) diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index e172040f..99f57abc 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -33,11 +33,14 @@ interface PickerFile { interface GoogleDrivePickerProps { token: string | null; onSelectionChange: (fileIds: string[], folderIds?: string[]) => void; + /** Called with the first item's name when the selection goes from empty to one. */ + onFirstPickName?: (name: string) => void; } const GoogleDrivePicker: React.FC = ({ token, onSelectionChange, + onFirstPickName, }) => { const { t } = useTranslation(); const [selectedFiles, setSelectedFiles] = useState([]); @@ -191,6 +194,13 @@ const GoogleDrivePicker: React.FC = ({ ); return [...prevFolders, ...uniqueNewFolders]; }); + if ( + selectedFiles.length === 0 && + selectedFolders.length === 0 && + docs.length > 0 + ) { + onFirstPickName?.(docs[0].name); + } onSelectionChange( [...selectedFiles, ...newFiles].map((file) => file.id), [...selectedFolders, ...newFolders].map((folder) => folder.id), diff --git a/frontend/src/modals/AddToolModal.tsx b/frontend/src/modals/AddToolModal.tsx index 81ae3771..421831fe 100644 --- a/frontend/src/modals/AddToolModal.tsx +++ b/frontend/src/modals/AddToolModal.tsx @@ -1,18 +1,21 @@ +import { ArrowRight, Plus } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; import userService from '../api/services/userService'; import SkeletonLoader from '../components/SkeletonLoader'; import ToolIcon from '../components/ToolIcon'; +import { Button } from '../components/ui/button'; import { Card, CardDescription, CardTitle } from '../components/ui/card'; import { Modal } from '../components/ui/modal'; +import { SectionHeader } from '../components/ui/section-header'; import { useLoaderState } from '../hooks'; import PairDeviceModal from '../settings/PairDeviceModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import ConfigToolModal from './ConfigToolModal'; -import MCPServerModal from './MCPServerModal'; import { AvailableToolType } from './types'; export default function AddToolModal({ @@ -39,8 +42,6 @@ export default function AddToolModal({ React.useState(null); const [configModalState, setConfigModalState] = React.useState('INACTIVE'); - const [mcpModalState, setMcpModalState] = - React.useState('INACTIVE'); const [pairModalState, setPairModalState] = React.useState('INACTIVE'); const [loading, setLoading] = useLoaderState(false); @@ -53,7 +54,11 @@ export default function AddToolModal({ return res.json(); }) .then((data) => { - setAvailableTools(data.data); + setAvailableTools( + (data.data as AvailableToolType[]).filter( + (tool) => tool.group !== 'custom', + ), + ); setLoading(false); }); }; @@ -96,9 +101,6 @@ export default function AddToolModal({ .catch((error) => { console.error('Failed to create tool:', error); }); - } else if (tool.name === 'mcp_tool') { - setModalState('INACTIVE'); - setMcpModalState('ACTIVE'); } else { setModalState('INACTIVE'); setConfigModalState('ACTIVE'); @@ -109,11 +111,6 @@ export default function AddToolModal({ if (modalState === 'ACTIVE') getAvailableTools(); }, [modalState]); - const handleMcpServerAdded = () => { - getUserTools(); - setMcpModalState('INACTIVE'); - }; - return ( <> -
-
-
- {loading ? ( -
- -
- ) : ( -
- {availableTools.map((tool, index) => ( - - - - ))} -
- )} +
+ {loading ? ( +
+
+ ) : ( + (['built_in', 'service'] as const).map((group) => { + const tools = availableTools.filter( + (tool) => (tool.group ?? 'built_in') === group, + ); + if (tools.length === 0) return null; + return ( +
+ +
+ {tools.map((tool) => ( + + + + ))} +
+
+ ); + }) + )} +
+ +
@@ -187,11 +210,6 @@ export default function AddToolModal({ tool={selectedTool} getUserTools={getUserTools} /> - ({ useTranslation: () => ({ t: (key: string) => key }), })); +const connectorsState = vi.hoisted(() => ({ + catalog: [] as Record[], + connections: [] as Record[], +})); + vi.mock('react-redux', () => ({ - useSelector: () => null, + useSelector: (selector: (state: unknown) => unknown) => { + const state = { + preference: { token: null, selectedDocs: [] }, + connectors: { + catalog: connectorsState.catalog, + connections: connectorsState.connections, + loaded: true, + loading: false, + failed: false, + }, + }; + try { + return selector(state); + } catch { + return null; + } + }, useDispatch: () => vi.fn(), useStore: () => ({ getState: () => ({}) }), })); @@ -84,6 +105,70 @@ describe('Upload source-type tiles', () => { ); }); + it('groups upload and web apart from connections', async () => { + await render(); + expect(document.body.textContent).toContain( + 'modals.uploadDoc.groupUploadWeb', + ); + expect(document.body.textContent).toContain( + 'modals.uploadDoc.groupConnection', + ); + }); + + it('shows connector tiles even when the server is not set up', async () => { + connectorsState.catalog = [ + { + key: 'google_drive', + icon: 'drive', + sync_ingestor: 'google_drive', + available: false, + missing_settings: ['GOOGLE_CLIENT_ID'], + }, + ]; + await render(); + const drive = tiles().find((tile) => + tile.textContent?.includes( + 'modals.uploadDoc.ingestors.google_drive.label', + ), + ); + expect(drive).toBeDefined(); + expect(drive!.textContent).toContain( + 'settings.connectors.status.needsAdminSetup', + ); + await act(async () => drive!.click()); + expect(document.body.textContent).toContain('GOOGLE_CLIENT_ID'); + connectorsState.catalog = []; + }); + + it('names the connected account on a connection tile', async () => { + connectorsState.catalog = [ + { + key: 'confluence', + icon: 'confluence', + sync_ingestor: 'confluence', + available: true, + missing_settings: [], + }, + ]; + connectorsState.connections = [ + { + id: 'c1', + connector_key: 'confluence', + status: 'connected', + account_label: 'alex@example.com', + }, + ]; + await render(); + const confluence = tiles().find((tile) => + tile.textContent?.includes('modals.uploadDoc.ingestors.confluence.label'), + ); + expect(confluence!.textContent).toContain( + 'modals.uploadDoc.tileConnectedAs', + ); + connectorsState.catalog = []; + connectorsState.connections = []; + }); + it('leaves the disabled Train button on the default variant', async () => { await render(); const crawler = tiles().find((tile) => diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 257032c5..9c02bacb 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, CircleAlert } from 'lucide-react'; import { envVar } from '@/env'; import { cn } from '@/lib/utils'; import { useCallback, useEffect, useState } from 'react'; @@ -28,6 +28,16 @@ import { Textarea } from '../components/ui/textarea'; import { Modal } from '../components/ui/modal'; import { Separator } from '../components/ui/separator'; import { OptionCard } from '../components/ui/option-card'; +import { SectionHeader } from '../components/ui/section-header'; +import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + loadConnectors, + selectConnections, + selectConnectorCatalog, + selectConnectorsLoaded, +} from '../connectors/connectorsSlice'; +import type { AppDispatch } from '../store'; import { ActiveState, Doc } from '../models/misc'; import { getDocs } from '../preferences/preferenceApi'; @@ -38,10 +48,12 @@ import { setSourceDocs, } from '../preferences/preferenceSlice'; import { + CONNECTION_INGESTORS, IngestorDefaultConfigs, IngestorFormSchemas, getIngestorSchema, IngestorOption, + UPLOAD_AND_WEB_INGESTORS, } from '../upload/types/ingestor'; import { addUploadTask, updateUploadTask } from './uploadSlice'; @@ -65,6 +77,7 @@ function Upload({ close, onSuccessfulUpload = () => undefined, selectUploadedDoc = true, + initialIngestor, }: { receivedFile: File[]; setModalState: (state: ActiveState) => void; @@ -79,9 +92,14 @@ function Upload({ * uploading never repoints the conversation the user left open. */ selectUploadedDoc?: boolean; + /** Open straight on this source type's form (Connect from a connector card). */ + initialIngestor?: IngestorType; }) { const token = useSelector(selectToken); const selectedDocs = useSelector(selectSelectedDocs); + const connectorCatalog = useSelector(selectConnectorCatalog); + const connections = useSelector(selectConnections); + const connectorsLoaded = useSelector(selectConnectorsLoaded); const [files, setfiles] = useState(receivedFile); const [activeTab, setActiveTab] = useState(true); @@ -334,6 +352,7 @@ function Upload({ setSelectedFiles(selectedFileIds); setSelectedFolders(selectedFolderIds); }} + onFirstPickName={prefillName} provider={ingestor.type as unknown as string} token={token} initialSelectedFiles={selectedFiles} @@ -341,7 +360,9 @@ function Upload({ /> ); case 'google_drive_picker': - return ( + // Google's own picker needs the client id in the browser; without it + // the server-side file browser lists the same Drive. + return envVar('VITE_GOOGLE_CLIENT_ID') ? ( + ) : ( + { + setSelectedFiles(selectedFileIds); + setSelectedFolders(selectedFolderIds); + }} + onFirstPickName={prefillName} + provider="google_drive" + token={token} + initialSelectedFiles={selectedFiles} + initialSelectedFolders={selectedFolders} + /> ); case 'share_point_picker': return ( @@ -365,6 +403,7 @@ function Upload({ setSelectedFiles(selectedFileIds); setSelectedFolders(selectedFolderIds); }} + onFirstPickName={prefillName} provider="share_point" token={token} initialSelectedFiles={selectedFiles} @@ -382,6 +421,7 @@ function Upload({ setSelectedFiles(selectedFileIds); setSelectedFolders(selectedFolderIds); }} + onFirstPickName={prefillName} provider="confluence" token={token} initialSelectedFiles={selectedFiles} @@ -402,17 +442,39 @@ function Upload({ const [nameTouched, setNameTouched] = useState(false); const { t } = useTranslation(); - const dispatch = useDispatch(); + const dispatch = useDispatch(); const store = useStore(); - const ingestorOptions: IngestorOption[] = IngestorFormSchemas.filter( - (schema) => (schema.validate ? schema.validate() : true), - ).map((schema) => ({ - label: schema.label, - value: schema.key, - icon: schema.icon, - heading: schema.heading, - })); + useEffect(() => { + if (!connectorsLoaded) dispatch(loadConnectors({ token })); + }, [connectorsLoaded, dispatch, token]); + + useEffect(() => { + if (initialIngestor) handleIngestorTypeChange(initialIngestor); + // Only the type the modal opened with; later picks are the user's. + }, []); + + /** The name field follows the first picked item until the user edits it. */ + const prefillName = (name: string) => { + if (nameTouched || !name) return; + setIngestor((prev) => (prev.name ? prev : { ...prev, name })); + }; + + const connectorFor = (type: IngestorType | null) => + type && CONNECTION_INGESTORS.includes(type) + ? connectorCatalog.find((c) => c.sync_ingestor === type) + : undefined; + const selectedConnector = connectorFor(ingestor.type); + const needsSetup = !!selectedConnector && !selectedConnector.available; + + const ingestorOptions: IngestorOption[] = IngestorFormSchemas.map( + (schema) => ({ + label: schema.label, + value: schema.key, + icon: schema.icon, + heading: schema.heading, + }), + ); const resetUploaderState = useCallback(() => { setIngestor({ type: null, name: '', config: {} }); @@ -923,6 +985,7 @@ function Upload({ if (!isPrescreenConfigValid(retrievalOptions)) return true; if (!ingestor.type) return true; + if (needsSetup) return true; const ingestorSchemaForValidation = getIngestorSchema( ingestor.type as IngestorType, ); @@ -1026,28 +1089,109 @@ function Upload({ } }; + const connectionTileState = (type: IngestorType) => { + const connector = connectorFor(type); + if (!connector) return undefined; + if (!connector.available) + return t('settings.connectors.status.needsAdminSetup'); + const accounts = connections.filter( + (c) => c.connector_key === connector.key && c.status === 'connected', + ); + if (accounts.length === 1) + return t('modals.uploadDoc.tileConnectedAs', { + account: accounts[0].account_label, + interpolation: { escapeValue: false }, + }); + if (accounts.length > 1) + return t('settings.connectors.status.connectedCount', { + count: accounts.length, + }); + return t('settings.connectors.status.connect'); + }; + const renderIngestorSelection = () => { + const optionsFor = (types: IngestorType[]) => + types + .map((type) => ingestorOptions.find((o) => o.value === type)) + .filter((option): option is IngestorOption => !!option); return ( -
- {ingestorOptions.map((option) => ( - - } - title={t(`modals.uploadDoc.ingestors.${option.value}.label`)} - onClick={() => - handleIngestorTypeChange(option.value as IngestorType) - } +
+
+ - ))} +
+ {optionsFor(UPLOAD_AND_WEB_INGESTORS).map((option) => ( + + } + title={t(`modals.uploadDoc.ingestors.${option.value}.label`)} + onClick={() => handleIngestorTypeChange(option.value)} + /> + ))} +
+
+
+ +
+ {optionsFor(CONNECTION_INGESTORS).map((option) => { + const connector = connectorFor(option.value); + return ( + + } + title={t(`modals.uploadDoc.ingestors.${option.value}.label`)} + description={connectionTileState(option.value)} + onClick={() => handleIngestorTypeChange(option.value)} + /> + ); + })} +
+
); }; + + const renderSetupNotice = () => + selectedConnector && needsSetup ? ( + + + + {t('settings.connectors.status.needsAdminSetup')} + + +
+ {selectedConnector.missing_settings.length > 0 ? ( + <> + {t('settings.connectors.setupSettings')} + + {selectedConnector.missing_settings.join(', ')} + + + ) : ( + {t('settings.connectors.askAdmin')} + )} +
+
+
+ ) : null; return ( - {renderFormFields()} - {ingestor.type !== 'wiki' && ( + {needsSetup ? renderSetupNotice() : renderFormFields()} + {ingestor.type !== 'wiki' && !needsSetup && ( boolean; fields: FormField[]; } +/** Add Source groups: what needs no account, and what comes from a connection. */ +export const UPLOAD_AND_WEB_INGESTORS: IngestorType[] = [ + 'local_file', + 'url', + 'crawler', + 'github', + 'wiki', +]; +export const CONNECTION_INGESTORS: IngestorType[] = [ + 'google_drive', + 'share_point', + 'confluence', + 's3', + 'reddit', +]; + export const IngestorFormSchemas: IngestorSchema[] = [ { key: 'local_file', @@ -175,10 +189,6 @@ export const IngestorFormSchemas: IngestorSchema[] = [ label: 'Google Drive', icon: DriveIcon, heading: 'Upload from Google Drive', - validate: () => { - const googleClientId = envVar('VITE_GOOGLE_CLIENT_ID'); - return !!googleClientId; - }, fields: [ { name: 'files', @@ -243,10 +253,6 @@ export const IngestorFormSchemas: IngestorSchema[] = [ label: 'Share Point', icon: SharePoint, heading: 'Upload from Share Point', - validate: () => { - const sharePointClientId = envVar('VITE_SHARE_POINT_CLIENT_ID'); - return !!sharePointClientId; - }, fields: [ { name: 'files', @@ -261,10 +267,6 @@ export const IngestorFormSchemas: IngestorSchema[] = [ label: 'Confluence', icon: ConfluenceIcon, heading: 'Upload from Confluence', - validate: () => { - const confluenceClientId = envVar('VITE_CONFLUENCE_CLIENT_ID'); - return !!confluenceClientId; - }, fields: [ { name: 'files',