diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 55a11f420..26a0836c8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -2,11 +2,13 @@ import { useAtomValue } from "jotai"; import { Cable, Loader2 } from "lucide-react"; -import { type FC, useMemo } from "react"; +import { type FC, useMemo, useEffect } from "react"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { useLogsSummary } from "@/hooks/use-logs"; -import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { connectorsApiService } from "@/lib/apis/connectors-api.service"; import { Dialog, DialogContent, @@ -21,16 +23,13 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; +import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view"; import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; export const ConnectorIndicator: FC = () => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { connectors, isLoading: connectorsLoading, refreshConnectors } = useSearchSourceConnectors( - false, - searchSpaceId ? Number(searchSpaceId) : undefined - ); const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); @@ -44,22 +43,6 @@ export const ConnectorIndicator: FC = () => { } ); - // Get connector IDs that are currently being indexed - const indexingConnectorIds = useMemo(() => { - if (!logsSummary?.active_tasks) return new Set(); - return new Set( - logsSummary.active_tasks - .filter((task) => task.source?.includes("connector_indexing")) - .map((task) => { - const match = task.source?.match(/connector[_-]?(\d+)/i); - return match ? parseInt(match[1], 10) : null; - }) - .filter((id): id is number => id !== null) - ); - }, [logsSummary?.active_tasks]); - - const isLoading = connectorsLoading || documentTypesLoading; - // Use the custom hook for dialog state management const { isOpen, @@ -71,6 +54,8 @@ export const ConnectorIndicator: FC = () => { indexingConnector, indexingConnectorConfig, editingConnector, + connectingConnectorType, + isCreatingConnector, startDate, endDate, isStartingIndexing, @@ -88,19 +73,81 @@ export const ConnectorIndicator: FC = () => { handleTabChange, handleScroll, handleConnectOAuth, + handleConnectNonOAuth, handleCreateWebcrawler, handleCreateYouTube, + handleSubmitConnectForm, handleStartIndexing, handleSkipIndexing, handleStartEdit, handleSaveConnector, handleDisconnectConnector, handleBackFromEdit, + handleBackFromConnect, connectorConfig, setConnectorConfig, setIndexingConnectorConfig, + setConnectorName, } = useConnectorDialog(); + // Fetch connectors using React Query with conditional refetchInterval + // This automatically refetches when mutations invalidate the cache (event-driven) + // and also polls when dialog is open to catch external changes + const { + data: connectors = [], + isLoading: connectorsLoading, + refetch: refreshConnectors, + } = useQuery({ + queryKey: cacheKeys.connectors.all(searchSpaceId || ""), + queryFn: () => + connectorsApiService.getConnectors({ + queryParams: { + search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined, + }, + }), + enabled: !!searchSpaceId, + staleTime: 5 * 60 * 1000, // 5 minutes (same as connectorsAtom) + // Poll when dialog is open to catch external changes + refetchInterval: isOpen ? 5000 : false, // 5 seconds when open, no polling when closed + }); + + const queryClient = useQueryClient(); + + // Also refresh document type counts when dialog is open + useEffect(() => { + if (!isOpen || !searchSpaceId) return; + + const POLL_INTERVAL = 5000; // 5 seconds, same as connectors + + const intervalId = setInterval(() => { + // Invalidate document type counts to refresh active document types + queryClient.invalidateQueries({ + queryKey: cacheKeys.documents.typeCounts(searchSpaceId), + }); + }, POLL_INTERVAL); + + // Cleanup interval on unmount or when dialog closes + return () => { + clearInterval(intervalId); + }; + }, [isOpen, searchSpaceId, queryClient]); + + // Get connector IDs that are currently being indexed + const indexingConnectorIds = useMemo(() => { + if (!logsSummary?.active_tasks) return new Set(); + return new Set( + logsSummary.active_tasks + .filter((task) => task.source?.includes("connector_indexing")) + .map((task) => { + const match = task.source?.match(/connector[_-]?(\d+)/i); + return match ? parseInt(match[1], 10) : null; + }) + .filter((id): id is number => id !== null) + ); + }, [logsSummary?.active_tasks]); + + const isLoading = connectorsLoading || documentTypesLoading; + // Get document types that have documents in the search space const activeDocumentTypes = documentTypeCounts ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) @@ -148,12 +195,20 @@ export const ConnectorIndicator: FC = () => { - {/* Connector Edit View - shown when editing existing connector */} - {editingConnector ? ( + {/* Connector Connect View - shown when connecting non-OAuth connectors */} + {connectingConnectorType ? ( + + ) : editingConnector ? ( { onEndDateChange={setEndDate} onPeriodicEnabledChange={setPeriodicEnabled} onFrequencyChange={setFrequencyMinutes} - onSave={() => handleSaveConnector(refreshConnectors)} - onDisconnect={() => handleDisconnectConnector(refreshConnectors)} + onSave={() => handleSaveConnector(() => refreshConnectors())} + onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())} onBack={handleBackFromEdit} onConfigChange={setConnectorConfig} + onNameChange={setConnectorName} /> ) : indexingConfig ? ( { onPeriodicEnabledChange={setPeriodicEnabled} onFrequencyChange={setFrequencyMinutes} onConfigChange={setIndexingConnectorConfig} - onStartIndexing={() => handleStartIndexing(refreshConnectors)} + onStartIndexing={() => handleStartIndexing(() => refreshConnectors())} onSkip={handleSkipIndexing} /> ) : ( @@ -207,17 +263,18 @@ export const ConnectorIndicator: FC = () => {
- + void; +} + +export const TavilyApiConfig: FC = ({ + connector, + onConfigChange, + onNameChange, +}) => { + const [apiKey, setApiKey] = useState( + (connector.config?.TAVILY_API_KEY as string) || "" + ); + const [name, setName] = useState(connector.name || ""); + + // Update API key and name when connector changes + useEffect(() => { + const key = (connector.config?.TAVILY_API_KEY as string) || ""; + setApiKey(key); + setName(connector.name || ""); + }, [connector.config, connector.name]); + + const handleApiKeyChange = (value: string) => { + setApiKey(value); + if (onConfigChange) { + onConfigChange({ + ...connector.config, + TAVILY_API_KEY: value, + }); + } + }; + + const handleNameChange = (value: string) => { + setName(value); + if (onNameChange) { + onNameChange(value); + } + }; + + return ( +
+ {/* Connector Name */} +
+
+ + handleNameChange(e.target.value)} + placeholder="My Tavily API Connector" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ A friendly name to identify this connector. +

+
+
+ + {/* Configuration */} +
+
+

Configuration

+
+ +
+ + handleApiKeyChange(e.target.value)} + placeholder="Enter your Tavily API key" + className="border-slate-400/20 focus-visible:border-slate-400/40" + /> +

+ Update the Tavily API Key if needed. +

+
+
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/connect-forms/index.tsx new file mode 100644 index 000000000..fd0c4eced --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/connect-forms/index.tsx @@ -0,0 +1,36 @@ +import type { FC } from "react"; +import { TavilyApiConnectForm } from "./tavily-api-connect-form"; + +export interface ConnectFormProps { + onSubmit: (data: { + name: string; + connector_type: string; + config: Record; + is_indexable: boolean; + last_indexed_at: null; + periodic_indexing_enabled: boolean; + indexing_frequency_minutes: null; + next_scheduled_at: null; + }) => Promise; + onBack: () => void; + isSubmitting: boolean; + onFormSubmit?: () => void; +} + +export type ConnectFormComponent = FC; + +/** + * Factory function to get the appropriate connect form component for a connector type + */ +export function getConnectFormComponent( + connectorType: string +): ConnectFormComponent | null { + switch (connectorType) { + case "TAVILY_API": + return TavilyApiConnectForm; + // Add other connector types here as needed + default: + return null; + } +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/connect-forms/tavily-api-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/connect-forms/tavily-api-connect-form.tsx new file mode 100644 index 000000000..ddccbf8c8 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/connect-forms/tavily-api-connect-form.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Info } from "lucide-react"; +import type { FC } from "react"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import type { ConnectFormProps } from "./index"; + +const tavilyApiFormSchema = z.object({ + name: z.string().min(3, { + message: "Connector name must be at least 3 characters.", + }), + api_key: z.string().min(10, { + message: "API key is required and must be valid.", + }), +}); + +type TavilyApiFormValues = z.infer; + +export const TavilyApiConnectForm: FC = ({ + onSubmit, + isSubmitting, +}) => { + const isSubmittingRef = useRef(false); + const form = useForm({ + resolver: zodResolver(tavilyApiFormSchema), + defaultValues: { + name: "Tavily API Connector", + api_key: "", + }, + }); + + const handleSubmit = async (values: TavilyApiFormValues) => { + // Prevent multiple submissions + if (isSubmittingRef.current || isSubmitting) { + return; + } + + isSubmittingRef.current = true; + try { + await onSubmit({ + name: values.name, + connector_type: EnumConnectorName.TAVILY_API, + config: { + TAVILY_API_KEY: values.api_key, + }, + is_indexable: false, + last_indexed_at: null, + periodic_indexing_enabled: false, + indexing_frequency_minutes: null, + next_scheduled_at: null, + }); + } finally { + isSubmittingRef.current = false; + } + }; + + return ( +
+ + +
+ API Key Required + + You'll need a Tavily API key to use this connector. You can get one by signing up at{" "} + + tavily.com + + +
+
+ +
+
+ + ( + + Connector Name + + + + + A friendly name to identify this connector. + + + + )} + /> + + ( + + Tavily API Key + + + + + Your API key will be encrypted and stored securely. + + + + )} + /> + + +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx index 4b6f56523..78efb60a9 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -3,12 +3,14 @@ import type { FC } from "react"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { GoogleDriveConfig } from "./components/google-drive-config"; +import { TavilyApiConfig } from "./components/tavily-api-config"; import { WebcrawlerConfig } from "./components/webcrawler-config"; import { YouTubeConfig } from "./components/youtube-config"; export interface ConnectorConfigProps { connector: SearchSourceConnector; onConfigChange?: (config: Record) => void; + onNameChange?: (name: string) => void; } export type ConnectorConfigComponent = FC; @@ -22,6 +24,8 @@ export function getConnectorConfigComponent( switch (connectorType) { case "GOOGLE_DRIVE_CONNECTOR": return GoogleDriveConfig; + case "TAVILY_API": + return TavilyApiConfig; case "WEBCRAWLER_CONNECTOR": return WebcrawlerConfig; case "YOUTUBE_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx new file mode 100644 index 000000000..9499606c9 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { ArrowLeft, Loader2 } from "lucide-react"; +import { type FC, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; +import { getConnectFormComponent } from "../connect-forms"; + +interface ConnectorConnectViewProps { + connectorType: string; + onSubmit: (data: { + name: string; + connector_type: string; + config: Record; + is_indexable: boolean; + last_indexed_at: null; + periodic_indexing_enabled: boolean; + indexing_frequency_minutes: null; + next_scheduled_at: null; + }) => Promise; + onBack: () => void; + isSubmitting: boolean; +} + +export const ConnectorConnectView: FC = ({ + connectorType, + onSubmit, + onBack, + isSubmitting, +}) => { + // Get connector-specific form component + const ConnectFormComponent = useMemo( + () => getConnectFormComponent(connectorType), + [connectorType] + ); + + const handleFormSubmit = () => { + // Prevent multiple submissions + if (isSubmitting) { + return; + } + const form = document.getElementById("tavily-connect-form") as HTMLFormElement; + if (form) { + form.requestSubmit(); + } + }; + + if (!ConnectFormComponent) { + return ( +
+

+ Connector form not found for type: {connectorType} +

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ + +
+
+ {getConnectorIcon(connectorType as EnumConnectorName, "h-7 w-7")} +
+
+

+ Connect {connectorType === "TAVILY_API" ? "Tavily API" : connectorType} +

+

+ Enter your connection details +

+
+
+
+ + {/* Form Content - Scrollable */} +
+ +
+ + {/* Fixed Footer - Action buttons */} +
+ + +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 9412b9633..290b08825 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -26,6 +26,7 @@ interface ConnectorEditViewProps { onDisconnect: () => void; onBack: () => void; onConfigChange?: (config: Record) => void; + onNameChange?: (name: string) => void; } export const ConnectorEditView: FC = ({ @@ -44,6 +45,7 @@ export const ConnectorEditView: FC = ({ onDisconnect, onBack, onConfigChange, + onNameChange, }) => { // Get connector-specific config component const ConnectorConfigComponent = useMemo( @@ -146,25 +148,31 @@ export const ConnectorEditView: FC = ({ )} - {/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */} - {connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && connector.connector_type !== "WEBCRAWLER_CONNECTOR" && connector.connector_type !== "YOUTUBE_CONNECTOR" && ( - - )} + {/* Date range selector and periodic sync - only shown for indexable connectors */} + {connector.is_indexable && ( + <> + {/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */} + {connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && connector.connector_type !== "WEBCRAWLER_CONNECTOR" && connector.connector_type !== "YOUTUBE_CONNECTOR" && ( + + )} - + + + )} {/* Info box */}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index 3b17d9bee..1b49b2046 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -3,7 +3,6 @@ import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { cn } from "@/lib/utils"; import type { IndexingConfigState } from "../../constants/connector-constants"; @@ -134,22 +133,27 @@ export const IndexingConfigurationView: FC = ({ /> )} - {/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */} - {config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && config.connectorType !== "WEBCRAWLER_CONNECTOR" && config.connectorType !== "YOUTUBE_CONNECTOR" && ( - - )} + {/* Date range selector and periodic sync - only shown for indexable connectors */} + {connector?.is_indexable && ( + <> + {/* Date range selector - not shown for Google Drive (uses folder selection), Webcrawler (uses config), or YouTube (uses URL selection) */} + {config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && config.connectorType !== "WEBCRAWLER_CONNECTOR" && config.connectorType !== "YOUTUBE_CONNECTOR" && ( + + )} - + + + )} {/* Info box */}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts index ff2fc001d..cafce6bba 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts @@ -7,9 +7,10 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types export const connectorPopupQueryParamsSchema = z.object({ modal: z.enum(["connectors"]).optional(), tab: z.enum(["all", "active"]).optional(), - view: z.enum(["configure", "edit"]).optional(), + view: z.enum(["configure", "edit", "connect"]).optional(), connector: z.string().optional(), connectorId: z.string().optional(), + connectorType: z.string().optional(), success: z.enum(["true", "false"]).optional(), }); diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 633afd75b..c3dbf1b4c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "jotai"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { createConnectorMutationAtom, deleteConnectorMutationAtom, indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; @@ -51,6 +51,12 @@ export const useConnectorDialog = () => { const [isSaving, setIsSaving] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false); const [connectorConfig, setConnectorConfig] = useState | null>(null); + const [connectorName, setConnectorName] = useState(null); + + // Connect mode state (for non-OAuth connectors) + const [connectingConnectorType, setConnectingConnectorType] = useState(null); + const [isCreatingConnector, setIsCreatingConnector] = useState(false); + const isCreatingConnectorRef = useRef(false); // Helper function to get frequency label const getFrequencyLabel = useCallback((minutes: string): string => { @@ -85,6 +91,17 @@ export const useConnectorDialog = () => { // Clear editing connector if view is not "edit" anymore if (params.view !== "edit" && editingConnector) { setEditingConnector(null); + setConnectorName(null); + } + + // Clear connecting connector type if view is not "connect" anymore + if (params.view !== "connect" && connectingConnectorType) { + setConnectingConnectorType(null); + } + + // Handle connect view + if (params.view === "connect" && params.connectorType && !connectingConnectorType) { + setConnectingConnectorType(params.connectorType); } if (params.view === "configure" && params.connector && !indexingConfig) { @@ -147,6 +164,7 @@ export const useConnectorDialog = () => { // Clear editing connector when modal is closed if (editingConnector) { setEditingConnector(null); + setConnectorName(null); setConnectorConfig(null); setStartDate(undefined); setEndDate(undefined); @@ -155,13 +173,17 @@ export const useConnectorDialog = () => { setIsScrolled(false); setSearchQuery(""); } + // Clear connecting connector type when modal is closed + if (connectingConnectorType) { + setConnectingConnectorType(null); + } } } catch (error) { // Invalid query params - log but don't crash console.warn("Invalid connector popup query params:", error); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, allConnectors, editingConnector, indexingConfig]); + }, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]); // Detect OAuth success and transition to config view useEffect(() => { @@ -358,6 +380,106 @@ export const useConnectorDialog = () => { } }, [searchSpaceId, createConnector, refetchAllConnectors]); + // Handle connecting non-OAuth connectors (like Tavily API) + const handleConnectNonOAuth = useCallback((connectorType: string) => { + if (!searchSpaceId) return; + + // Set connecting state + setConnectingConnectorType(connectorType); + + // Update URL to show connect view + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("view", "connect"); + url.searchParams.set("connectorType", connectorType); + window.history.pushState({ modal: true }, "", url.toString()); + }, [searchSpaceId]); + + // Handle submitting connect form + const handleSubmitConnectForm = useCallback(async ( + formData: { + name: string; + connector_type: string; + config: Record; + is_indexable: boolean; + last_indexed_at: null; + periodic_indexing_enabled: boolean; + indexing_frequency_minutes: null; + next_scheduled_at: null; + } + ) => { + if (!searchSpaceId || !connectingConnectorType) return; + + // Prevent multiple submissions using ref for immediate check + if (isCreatingConnectorRef.current) return; + isCreatingConnectorRef.current = true; + + setIsCreatingConnector(true); + try { + // Create connector + const newConnector = await createConnector({ + data: formData, + queryParams: { + search_space_id: searchSpaceId, + }, + }); + + // Refetch connectors to get the new one + const result = await refetchAllConnectors(); + if (result.data) { + const connector = result.data.find( + (c: SearchSourceConnector) => c.id === newConnector.id + ); + if (connector) { + // Validate connector data + const connectorValidation = searchSourceConnector.safeParse(connector); + if (connectorValidation.success) { + // Find connector title from constants + const connectorInfo = OTHER_CONNECTORS.find( + c => c.connectorType === connectingConnectorType + ); + const connectorTitle = connectorInfo?.title || connector.name; + + // Set up indexing config + const config = validateIndexingConfigState({ + connectorType: connectingConnectorType as EnumConnectorName, + connectorId: connector.id, + connectorTitle, + }); + setIndexingConfig(config); + setIndexingConnector(connector); + setIndexingConnectorConfig(connector.config || {}); + + // Transition to configure view + const url = new URL(window.location.href); + url.searchParams.set("view", "configure"); + url.searchParams.delete("connectorType"); + window.history.replaceState({}, "", url.toString()); + + toast.success(`${connectorTitle} connected successfully!`); + } + } + } + } catch (error) { + console.error("Error creating connector:", error); + toast.error(error instanceof Error ? error.message : "Failed to create connector"); + } finally { + isCreatingConnectorRef.current = false; + setIsCreatingConnector(false); + setConnectingConnectorType(null); + } + }, [connectingConnectorType, searchSpaceId, createConnector, refetchAllConnectors]); + + // Handle going back from connect view + const handleBackFromConnect = useCallback(() => { + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", "all"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); + // Handle starting indexing const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { if (!indexingConfig || !searchSpaceId) return; @@ -494,11 +616,12 @@ export const useConnectorDialog = () => { (oauthConnector) => oauthConnector.connectorType === connector.connector_type ); - // Check if this is webcrawler (can be managed in popup) + // Check if this is webcrawler or Tavily API (can be managed in popup) const isWebcrawler = connector.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR; + const isTavilyApi = connector.connector_type === EnumConnectorName.TAVILY_API; - // If not OAuth and not webcrawler, redirect to old connector edit page - if (!isOAuthConnector && !isWebcrawler) { + // If not OAuth, not webcrawler, and not Tavily API, redirect to old connector edit page + if (!isOAuthConnector && !isWebcrawler && !isTavilyApi) { router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`); return; } @@ -511,6 +634,7 @@ export const useConnectorDialog = () => { } setEditingConnector(connector); + setConnectorName(connector.name); // Load existing periodic sync settings setPeriodicEnabled(connector.periodic_indexing_enabled); setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440"); @@ -530,8 +654,8 @@ export const useConnectorDialog = () => { const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => { if (!editingConnector || !searchSpaceId) return; - // Validate date range (skip for Google Drive which uses folder selection, and Webcrawler which uses config) - if (editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR") { + // Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors) + if (editingConnector.is_indexable && editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR") { const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); if (!dateRangeValidation.success) { toast.error(dateRangeValidation.error.issues[0]?.message || "Invalid date range"); @@ -539,8 +663,14 @@ export const useConnectorDialog = () => { } } - // Validate frequency minutes if periodic is enabled - if (periodicEnabled) { + // Prevent periodic indexing for non-indexable connectors + if (periodicEnabled && !editingConnector.is_indexable) { + toast.error("Periodic indexing is not available for this connector type"); + return; + } + + // Validate frequency minutes if periodic is enabled (only for indexable connectors) + if (periodicEnabled && editingConnector.is_indexable) { const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); if (!frequencyValidation.success) { toast.error("Invalid frequency value"); @@ -553,20 +683,24 @@ export const useConnectorDialog = () => { const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; - // Update connector with periodic sync settings and config changes + // Update connector with periodic sync settings, config changes, and name const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : null; await updateConnector({ id: editingConnector.id, data: { + name: connectorName || editingConnector.name, periodic_indexing_enabled: periodicEnabled, indexing_frequency_minutes: frequency, config: connectorConfig || editingConnector.config, }, }); - // Re-index based on connector type + // Re-index based on connector type (only for indexable connectors) let indexingDescription = "Settings saved."; - if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") { + if (!editingConnector.is_indexable) { + // Non-indexable connectors (like Tavily API) don't need re-indexing + indexingDescription = "Settings saved."; + } else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") { // Google Drive uses folder selection from config, not date ranges const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as Array<{ id: string; name: string }> | undefined; if (selectedFolders && selectedFolders.length > 0) { @@ -628,7 +762,7 @@ export const useConnectorDialog = () => { } finally { setIsSaving(false); } - }, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig]); + }, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig, connectorName]); // Handle disconnecting connector const handleDisconnectConnector = useCallback(async (refreshConnectors: () => void) => { @@ -692,12 +826,14 @@ export const useConnectorDialog = () => { window.history.pushState({ modal: false }, "", url.toString()); setIsScrolled(false); setSearchQuery(""); - if (!isStartingIndexing && !isSaving && !isDisconnecting) { + if (!isStartingIndexing && !isSaving && !isDisconnecting && !isCreatingConnector) { setIndexingConfig(null); setIndexingConnector(null); setIndexingConnectorConfig(null); setEditingConnector(null); + setConnectorName(null); setConnectorConfig(null); + setConnectingConnectorType(null); setStartDate(undefined); setEndDate(undefined); setPeriodicEnabled(false); @@ -705,7 +841,7 @@ export const useConnectorDialog = () => { } } }, - [activeTab, isStartingIndexing, isDisconnecting, isSaving] + [activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector] ); // Handle tab change @@ -735,6 +871,8 @@ export const useConnectorDialog = () => { indexingConnector, indexingConnectorConfig, editingConnector, + connectingConnectorType, + isCreatingConnector, startDate, endDate, isStartingIndexing, @@ -751,20 +889,24 @@ export const useConnectorDialog = () => { setEndDate, setPeriodicEnabled, setFrequencyMinutes, + setConnectorName, // Handlers handleOpenChange, handleTabChange, handleScroll, handleConnectOAuth, + handleConnectNonOAuth, handleCreateWebcrawler, handleCreateYouTube, + handleSubmitConnectForm, handleStartIndexing, handleSkipIndexing, handleStartEdit, handleSaveConnector, handleDisconnectConnector, handleBackFromEdit, + handleBackFromConnect, connectorConfig, setConnectorConfig, setIndexingConnectorConfig, diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 67670a986..76380e74b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -13,6 +13,7 @@ interface AllConnectorsTabProps { connectingId: string | null; allConnectors: SearchSourceConnector[] | undefined; onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[0]) => void; + onConnectNonOAuth?: (connectorType: string) => void; onCreateWebcrawler?: () => void; onCreateYouTube?: () => void; onManage?: (connector: SearchSourceConnector) => void; @@ -25,6 +26,7 @@ export const AllConnectorsTab: FC = ({ connectingId, allConnectors, onConnectOAuth, + onConnectNonOAuth, onCreateWebcrawler, onCreateYouTube, onManage, @@ -98,13 +100,16 @@ export const AllConnectorsTab: FC = ({ ? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType) : undefined; - // Special handling for webcrawler and YouTube - create in popup + // Special handling for connectors that can be created in popup const isWebcrawler = connector.id === "webcrawler-connector"; const isYouTube = connector.id === "youtube-connector"; + const isTavily = connector.id === "tavily-api"; const handleConnect = isWebcrawler && onCreateWebcrawler ? onCreateWebcrawler : isYouTube && onCreateYouTube ? onCreateYouTube + : isTavily && onConnectNonOAuth + ? () => onConnectNonOAuth(connector.connectorType) : () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`); return (