mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
merge upstream/dev into feat/migrate-electric-to-zero
Resolve 8 conflicts: - Accept upstream deletion of 3 composio_*_connector.py (unified Google connectors) - Accept our deletion of ElectricProvider.tsx, use-connectors-electric.ts, use-messages-electric.ts (replaced by Zero equivalents) - Keep both new deps in package.json (@rocicorp/zero + @slate-serializers/html) - Regenerate pnpm-lock.yaml
This commit is contained in:
commit
5d8a62a4a6
207 changed files with 28023 additions and 12247 deletions
|
|
@ -234,7 +234,7 @@ const AssistantActionBar: FC = () => {
|
|||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:border md:data-floating:bg-background md:data-floating:p-1 md:data-floating:shadow-sm [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:p-1 [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<TooltipIconButton tooltip="Copy">
|
||||
|
|
@ -247,7 +247,7 @@ const AssistantActionBar: FC = () => {
|
|||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Copy>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild>
|
||||
<TooltipIconButton tooltip="Export as Markdown">
|
||||
<TooltipIconButton tooltip="Download">
|
||||
<DownloadIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import {
|
||||
|
|
@ -49,9 +49,8 @@ interface ConnectorIndicatorProps {
|
|||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
({ showTrigger = true }, ref) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
useAtomValue(currentUserAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
|
|
@ -85,9 +84,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
[statusInboxItems]
|
||||
);
|
||||
|
||||
// Check if YouTube view is active
|
||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||
|
||||
// Use the custom hook for dialog state management
|
||||
const {
|
||||
isOpen,
|
||||
|
|
@ -112,6 +108,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
isYouTubeView,
|
||||
isFromOAuth,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
|
|
@ -206,14 +204,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
if (!searchSpaceId) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && pickerOpen) return;
|
||||
handleOpenChange(open);
|
||||
}}
|
||||
modal={!pickerOpen}
|
||||
>
|
||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
{showTrigger && (
|
||||
<TooltipIconButton
|
||||
data-joyride="connector-icon"
|
||||
|
|
@ -249,9 +240,27 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
</TooltipIconButton>
|
||||
)}
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
onClick={() => {
|
||||
if (!pickerOpen) handleOpenChange(false);
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<DialogContent
|
||||
onFocusOutside={(e) => e.preventDefault()}
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
|
||||
onInteractOutside={(e) => {
|
||||
if (pickerOpen) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (pickerOpen) e.preventDefault();
|
||||
}}
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
|
|
@ -329,20 +338,27 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={
|
||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||
? () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const hasDriveItems = isDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
: true;
|
||||
if (!hasDriveItems) return undefined;
|
||||
return () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
};
|
||||
})()}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
|
|
@ -363,6 +379,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
isFromOAuth={isFromOAuth}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,9 @@
|
|||
{
|
||||
"connectorStatuses": {
|
||||
"GOOGLE_DRIVE_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
|
||||
},
|
||||
"GOOGLE_GMAIL_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
|
||||
},
|
||||
"GOOGLE_CALENDAR_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
|
||||
},
|
||||
"YOUTUBE_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Doesn't work on cloud version due to YouTube blocks. Will be fixed soon."
|
||||
"statusMessage": "Sometimes may not work due to Youtube blocks."
|
||||
},
|
||||
"WEBCRAWLER_CONNECTOR": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
|
@ -26,6 +25,7 @@ import {
|
|||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -282,10 +282,9 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
|||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormControl>
|
||||
<RadioGroup.Root
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// Clear auth fields when method changes
|
||||
if (value !== "basic") {
|
||||
form.setValue("username", "");
|
||||
form.setValue("password", "");
|
||||
|
|
@ -295,38 +294,22 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
|||
}
|
||||
}}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-2"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="api_key"
|
||||
id={authApiKeyId}
|
||||
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="api_key" id={authApiKeyId} />
|
||||
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">
|
||||
API Key
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="basic"
|
||||
id={authBasicId}
|
||||
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="basic" id={authBasicId} />
|
||||
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">
|
||||
Username & Password
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -23,13 +23,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioDriveConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
interface SelectedFolder {
|
||||
id: string;
|
||||
|
|
@ -56,14 +50,14 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
|
|||
lowerName.endsWith(".csv") ||
|
||||
lowerName.includes("spreadsheet")
|
||||
) {
|
||||
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||
return <FileSpreadsheet className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith(".pptx") ||
|
||||
lowerName.endsWith(".ppt") ||
|
||||
lowerName.includes("presentation")
|
||||
) {
|
||||
return <Presentation className={`${className} text-orange-500`} />;
|
||||
return <Presentation className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith(".docx") ||
|
||||
|
|
@ -73,7 +67,7 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
|
|||
lowerName.includes("word") ||
|
||||
lowerName.includes("text")
|
||||
) {
|
||||
return <FileText className={`${className} text-gray-500`} />;
|
||||
return <FileText className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith(".png") ||
|
||||
|
|
@ -83,15 +77,12 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
|
|||
lowerName.endsWith(".webp") ||
|
||||
lowerName.endsWith(".svg")
|
||||
) {
|
||||
return <Image className={`${className} text-purple-500`} />;
|
||||
return <Image className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
return <File className={`${className} text-gray-500`} />;
|
||||
return <File className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
export const ComposioDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
|
||||
const isIndexable = connector.config?.is_indexable as boolean;
|
||||
|
||||
const existingFolders =
|
||||
|
|
@ -103,6 +94,13 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
|||
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
|
||||
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
|
||||
const [authError, setAuthError] = useState(false);
|
||||
|
||||
const isAuthExpired = connector.config?.auth_expired === true || authError;
|
||||
|
||||
const handleAuthError = useCallback(() => {
|
||||
setAuthError(true);
|
||||
}, []);
|
||||
|
||||
const [isEditMode] = useState(() => existingFolders.length > 0 || existingFiles.length > 0);
|
||||
const [isFolderTreeOpen, setIsFolderTreeOpen] = useState(!isEditMode);
|
||||
|
|
@ -201,7 +199,7 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
|||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={folder.name}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -235,6 +233,13 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isAuthExpired && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
Your Google Drive authentication has expired. Please re-authenticate using the button
|
||||
below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isEditMode ? (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
|
|
@ -242,12 +247,12 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
|||
onClick={() => setIsFolderTreeOpen(!isFolderTreeOpen)}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Change Selection
|
||||
{isFolderTreeOpen ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
Change Selection
|
||||
</button>
|
||||
{isFolderTreeOpen && (
|
||||
<ComposioDriveFolderTree
|
||||
|
|
@ -256,6 +261,7 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
|||
onSelectFolders={handleSelectFolders}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onAuthError={handleAuthError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -266,6 +272,7 @@ export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
|||
onSelectFolders={handleSelectFolders}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onAuthError={handleAuthError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { KeyRound, Server } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface ElasticsearchConfigProps extends ConnectorConfigProps {
|
||||
|
|
@ -56,8 +56,12 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
|
|||
: ""
|
||||
);
|
||||
|
||||
// Update values when connector changes
|
||||
// Update values when the connector identity changes (e.g. switching to a different connector)
|
||||
const connectorIdRef = useRef(connector.id);
|
||||
useEffect(() => {
|
||||
if (connectorIdRef.current === connector.id) return;
|
||||
connectorIdRef.current = connector.id;
|
||||
|
||||
setName(connector.name || "");
|
||||
setEndpointUrl((connector.config?.ELASTICSEARCH_URL as string) || "");
|
||||
setAuthMethod(
|
||||
|
|
@ -82,7 +86,7 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
|
|||
? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS)
|
||||
: ""
|
||||
);
|
||||
}, [connector.config, connector.name]);
|
||||
}, [connector]);
|
||||
|
||||
const stringToArray = (str: string): string[] => {
|
||||
const items = str
|
||||
|
|
@ -192,9 +196,9 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
|
|||
|
||||
const handleMaxDocumentsChange = (value: string) => {
|
||||
setMaxDocuments(value);
|
||||
if (value && value.trim()) {
|
||||
if (value?.trim()) {
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
if (!Number.isNaN(num) && num > 0) {
|
||||
updateConfig({ ELASTICSEARCH_MAX_DOCUMENTS: num });
|
||||
}
|
||||
} else {
|
||||
|
|
@ -255,41 +259,25 @@ export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<RadioGroup.Root
|
||||
<RadioGroup
|
||||
value={authMethod}
|
||||
onValueChange={(value) => handleAuthMethodChange(value as "basic" | "api_key")}
|
||||
className="flex flex-col space-y-2"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="api_key"
|
||||
id={authApiKeyId}
|
||||
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="api_key" id={authApiKeyId} />
|
||||
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">
|
||||
API Key
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="basic"
|
||||
id={authBasicId}
|
||||
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="basic" id={authBasicId} />
|
||||
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">
|
||||
Username & Password
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</RadioGroup>
|
||||
|
||||
{authMethod === "basic" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
Image,
|
||||
Loader2,
|
||||
Presentation,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -23,6 +20,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
|
@ -52,14 +50,14 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
|
|||
lowerName.endsWith(".csv") ||
|
||||
lowerName.includes("spreadsheet")
|
||||
) {
|
||||
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||
return <FileSpreadsheet className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith(".pptx") ||
|
||||
lowerName.endsWith(".ppt") ||
|
||||
lowerName.includes("presentation")
|
||||
) {
|
||||
return <Presentation className={`${className} text-orange-500`} />;
|
||||
return <Presentation className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith(".docx") ||
|
||||
|
|
@ -69,7 +67,7 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
|
|||
lowerName.includes("word") ||
|
||||
lowerName.includes("text")
|
||||
) {
|
||||
return <FileText className={`${className} text-gray-500`} />;
|
||||
return <FileText className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith(".png") ||
|
||||
|
|
@ -79,9 +77,9 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
|
|||
lowerName.endsWith(".webp") ||
|
||||
lowerName.endsWith(".svg")
|
||||
) {
|
||||
return <Image className={`${className} text-purple-500`} />;
|
||||
return <Image className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
return <File className={`${className} text-gray-500`} />;
|
||||
return <File className={`${className} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
|
||||
|
|
@ -141,6 +139,10 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
onPicked: handlePicked,
|
||||
});
|
||||
|
||||
const isAuthExpired =
|
||||
connector.config?.auth_expired === true ||
|
||||
(!!pickerError && pickerError.toLowerCase().includes("authentication expired"));
|
||||
|
||||
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
|
||||
const newOptions = { ...indexingOptions, [key]: value };
|
||||
setIndexingOptions(newOptions);
|
||||
|
|
@ -195,7 +197,7 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={folder.name}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -233,14 +235,21 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
type="button"
|
||||
variant="outline"
|
||||
onClick={openPicker}
|
||||
disabled={pickerLoading}
|
||||
disabled={pickerLoading || isAuthExpired}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
{pickerLoading && <Loader2 className="size-3.5 mr-1.5 animate-spin" />}
|
||||
{pickerLoading && <Spinner size="xs" className="mr-1.5" />}
|
||||
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
|
||||
</Button>
|
||||
|
||||
{pickerError && <p className="text-xs text-destructive">{pickerError}</p>}
|
||||
{pickerError && !isAuthExpired && <p className="text-xs text-destructive">{pickerError}</p>}
|
||||
|
||||
{isAuthExpired && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
Your Google Drive authentication has expired. Please re-authenticate using the button
|
||||
below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indexing Options */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -127,7 +126,6 @@ export const ObsidianConfig: FC<ObsidianConfigProps> = ({
|
|||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-purple-500" />
|
||||
Vault Configuration
|
||||
</h3>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
|
|
@ -13,6 +18,17 @@ import { SummaryConfig } from "../../components/summary-config";
|
|||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
|
||||
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
|
||||
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
|
||||
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
|
||||
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
|
||||
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
|
||||
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
|
||||
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
|
||||
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
|
||||
};
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
connector: SearchSourceConnector;
|
||||
startDate: Date | undefined;
|
||||
|
|
@ -60,6 +76,41 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const searchSpaceIdAtom = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const isAuthExpired = connector.config?.auth_expired === true;
|
||||
const reauthEndpoint = REAUTH_ENDPOINTS[connector.connector_type];
|
||||
const [reauthing, setReauthing] = useState(false);
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||
if (!spaceId || !reauthEndpoint) return;
|
||||
setReauthing(true);
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const url = new URL(`${backendUrl}${reauthEndpoint}`);
|
||||
url.searchParams.set("connector_id", String(connector.id));
|
||||
url.searchParams.set("space_id", String(spaceId));
|
||||
url.searchParams.set("return_url", window.location.pathname);
|
||||
const response = await authenticatedFetch(url.toString());
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(data.detail ?? "Failed to initiate re-authentication.");
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
} else if (data.success) {
|
||||
toast.success(data.message ?? "Authentication refreshed successfully.");
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to initiate re-authentication.");
|
||||
} finally {
|
||||
setReauthing(false);
|
||||
}
|
||||
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
|
||||
|
||||
// Get connector-specific config component
|
||||
const ConnectorConfigComponent = useMemo(
|
||||
() => getConnectorConfigComponent(connector.connector_type),
|
||||
|
|
@ -169,30 +220,28 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Index Button - only show for indexable connectors, but not for Google Drive (requires folder selection) */}
|
||||
{connector.is_indexable &&
|
||||
onQuickIndex &&
|
||||
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleQuickIndex}
|
||||
disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
|
||||
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
|
||||
>
|
||||
{isQuickIndexing || isIndexing ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Syncing
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Quick Index
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Quick Index Button - hidden when auth is expired */}
|
||||
{connector.is_indexable && onQuickIndex && !isAuthExpired && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleQuickIndex}
|
||||
disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
|
||||
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
|
||||
>
|
||||
{isQuickIndexing || isIndexing ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Syncing
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Quick Index
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -350,20 +399,31 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isSaving || isDisconnecting}
|
||||
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
{isAuthExpired && reauthEndpoint ? (
|
||||
<Button
|
||||
onClick={handleReauth}
|
||||
disabled={reauthing || isDisconnecting}
|
||||
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", reauthing && "animate-spin")} />
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isSaving || isDisconnecting}
|
||||
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Info } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
|
@ -24,6 +23,7 @@ interface IndexingConfigurationViewProps {
|
|||
frequencyMinutes: string;
|
||||
enableSummary: boolean;
|
||||
isStartingIndexing: boolean;
|
||||
isFromOAuth?: boolean;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
|
|
@ -43,6 +43,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
frequencyMinutes,
|
||||
enableSummary,
|
||||
isStartingIndexing,
|
||||
isFromOAuth = false,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
|
|
@ -52,9 +53,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
onStartIndexing,
|
||||
onSkip,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const isFromOAuth = searchParams.get("view") === "configure";
|
||||
|
||||
// Get connector-specific config component
|
||||
const ConnectorConfigComponent = useMemo(
|
||||
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
|
||||
|
|
|
|||
|
|
@ -2,27 +2,30 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
|
|||
|
||||
// OAuth Connectors (Quick Connect)
|
||||
export const OAUTH_CONNECTORS = [
|
||||
// {
|
||||
// id: "google-drive-connector",
|
||||
// title: "Google Drive",
|
||||
// description: "Search your Drive files",
|
||||
// connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
// authEndpoint: "/api/v1/auth/google/drive/connector/add/",
|
||||
// },
|
||||
// {
|
||||
// id: "google-gmail-connector",
|
||||
// title: "Gmail",
|
||||
// description: "Search through your emails",
|
||||
// connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
|
||||
// authEndpoint: "/api/v1/auth/google/gmail/connector/add/",
|
||||
// },
|
||||
// {
|
||||
// id: "google-calendar-connector",
|
||||
// title: "Google Calendar",
|
||||
// description: "Search through your events",
|
||||
// connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
|
||||
// authEndpoint: "/api/v1/auth/google/calendar/connector/add/",
|
||||
// },
|
||||
{
|
||||
id: "google-drive-connector",
|
||||
title: "Google Drive",
|
||||
description: "Search your Drive files",
|
||||
connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/google/drive/connector/add/",
|
||||
selfHostedOnly: true,
|
||||
},
|
||||
{
|
||||
id: "google-gmail-connector",
|
||||
title: "Gmail",
|
||||
description: "Search through your emails",
|
||||
connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/google/gmail/connector/add/",
|
||||
selfHostedOnly: true,
|
||||
},
|
||||
{
|
||||
id: "google-calendar-connector",
|
||||
title: "Google Calendar",
|
||||
description: "Search through your events",
|
||||
connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/google/calendar/connector/add/",
|
||||
selfHostedOnly: true,
|
||||
},
|
||||
{
|
||||
id: "airtable-connector",
|
||||
title: "Airtable",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types";
|
||||
|
||||
/**
|
||||
* Schema for URL query parameters used by the connector popup
|
||||
*/
|
||||
export const connectorPopupQueryParamsSchema = z.object({
|
||||
modal: z.enum(["connectors"]).optional(),
|
||||
tab: z.enum(["all", "active"]).optional(),
|
||||
view: z
|
||||
.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list", "composio"])
|
||||
.optional(),
|
||||
connector: z.string().optional(),
|
||||
connectorId: z.string().optional(),
|
||||
connectorType: z.string().optional(),
|
||||
success: z.enum(["true", "false"]).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ConnectorPopupQueryParams = z.infer<typeof connectorPopupQueryParamsSchema>;
|
||||
|
||||
/**
|
||||
* Schema for OAuth API response (auth_url)
|
||||
*/
|
||||
|
|
@ -72,31 +54,10 @@ export const dateRangeSchema = z
|
|||
export type DateRange = z.infer<typeof dateRangeSchema>;
|
||||
|
||||
/**
|
||||
* Schema for connector ID validation (used in URL params)
|
||||
* Schema for connector ID validation
|
||||
*/
|
||||
export const connectorIdSchema = z.string().min(1, "Connector ID is required");
|
||||
|
||||
/**
|
||||
* Helper function to safely parse query params
|
||||
*/
|
||||
export function parseConnectorPopupQueryParams(
|
||||
params: URLSearchParams | Record<string, string | null>
|
||||
): ConnectorPopupQueryParams {
|
||||
const obj: Record<string, string | undefined> = {};
|
||||
|
||||
if (params instanceof URLSearchParams) {
|
||||
params.forEach((value, key) => {
|
||||
obj[key] = value || undefined;
|
||||
});
|
||||
} else {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
obj[key] = value || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return connectorPopupQueryParamsSchema.parse(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse OAuth response
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { format } from "date-fns";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
|
|
@ -37,14 +36,25 @@ import {
|
|||
import {
|
||||
dateRangeSchema,
|
||||
frequencyMinutesSchema,
|
||||
parseConnectorPopupQueryParams,
|
||||
parseOAuthAuthResponse,
|
||||
validateIndexingConfigState,
|
||||
} from "../constants/connector-popup.schemas";
|
||||
|
||||
const OAUTH_RESULT_COOKIE = "connector_oauth_result";
|
||||
|
||||
function readOAuthResultCookie(): string | null {
|
||||
const match = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith(`${OAUTH_RESULT_COOKIE}=`));
|
||||
return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null;
|
||||
}
|
||||
|
||||
function clearOAuthResultCookie(): void {
|
||||
// biome-ignore lint: only standard way to expire a cookie
|
||||
document.cookie = `${OAUTH_RESULT_COOKIE}=; path=/; max-age=0`;
|
||||
}
|
||||
|
||||
export const useConnectorDialog = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
|
||||
|
|
@ -102,6 +112,9 @@ export const useConnectorDialog = () => {
|
|||
// Track if we came from MCP list view when entering edit mode
|
||||
const [cameFromMCPList, setCameFromMCPList] = useState(false);
|
||||
|
||||
// Track if we came from MCP list view when entering connect mode
|
||||
const [connectCameFromMCPList, setConnectCameFromMCPList] = useState(false);
|
||||
|
||||
// Helper function to get frequency label
|
||||
const getFrequencyLabel = useCallback((minutes: string): string => {
|
||||
switch (minutes) {
|
||||
|
|
@ -181,352 +194,139 @@ export const useConnectorDialog = () => {
|
|||
[searchSpaceId, indexConnector, updateConnector, refetchAllConnectors]
|
||||
);
|
||||
|
||||
// When the dialog is opened externally (via setConnectorDialogOpen atom from
|
||||
// thread.tsx / DocumentsSidebar.tsx), the URL is not updated. Sync it here
|
||||
// so that other handlers that read window.location.href see modal=connectors.
|
||||
const activeTabRef = useRef(activeTab);
|
||||
activeTabRef.current = activeTab;
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const url = new URL(window.location.href);
|
||||
const modalParam = url.searchParams.get("modal");
|
||||
const tabParam = url.searchParams.get("tab");
|
||||
if (modalParam !== "connectors" || (tabParam !== "all" && tabParam !== "active")) {
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", activeTabRef.current);
|
||||
window.history.replaceState({ modal: true }, "", url.toString());
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
// YouTube view state
|
||||
const [isYouTubeView, setIsYouTubeView] = useState(false);
|
||||
|
||||
// Synchronize state with URL query params
|
||||
// Track whether the current indexing config came from an OAuth redirect
|
||||
const [isFromOAuth, setIsFromOAuth] = useState(false);
|
||||
|
||||
// Consume OAuth result from cookie (set by /connectors/callback route handler)
|
||||
useEffect(() => {
|
||||
const raw = readOAuthResultCookie();
|
||||
if (!raw || !searchSpaceId) return;
|
||||
clearOAuthResultCookie();
|
||||
|
||||
let result: {
|
||||
success: string | null;
|
||||
error: string | null;
|
||||
connector: string | null;
|
||||
connectorId: string | null;
|
||||
};
|
||||
try {
|
||||
const params = parseConnectorPopupQueryParams(searchParams);
|
||||
result = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.modal === "connectors") {
|
||||
setIsOpen(true);
|
||||
if (result.error) {
|
||||
const oauthConnector = result.connector
|
||||
? OAUTH_CONNECTORS.find((c) => c.id === result.connector)
|
||||
: null;
|
||||
const name = oauthConnector?.title || "connector";
|
||||
|
||||
if (params.tab === "active" || params.tab === "all") {
|
||||
setActiveTab(params.tab);
|
||||
}
|
||||
|
||||
// Clear indexing config if view is not "configure" anymore
|
||||
if (params.view !== "configure" && indexingConfig) {
|
||||
setIndexingConfig(null);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Clear viewing accounts type if view is not "accounts" anymore
|
||||
if (params.view !== "accounts" && viewingAccountsType) {
|
||||
setViewingAccountsType(null);
|
||||
}
|
||||
|
||||
// Clear MCP list view if view is not "mcp-list" anymore
|
||||
if (params.view !== "mcp-list" && viewingMCPList) {
|
||||
setViewingMCPList(false);
|
||||
}
|
||||
|
||||
// Handle MCP list view
|
||||
if (params.view === "mcp-list" && !viewingMCPList) {
|
||||
setViewingMCPList(true);
|
||||
}
|
||||
|
||||
// Handle connect view
|
||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||
setConnectingConnectorType(params.connectorType);
|
||||
}
|
||||
|
||||
// Handle accounts view
|
||||
if (params.view === "accounts" && params.connectorType) {
|
||||
// Update state if not set, or if connectorType has changed
|
||||
const needsUpdate =
|
||||
!viewingAccountsType || viewingAccountsType.connectorType !== params.connectorType;
|
||||
|
||||
if (needsUpdate) {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
|
||||
if (oauthConnector) {
|
||||
setViewingAccountsType({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YouTube view
|
||||
if (params.view === "youtube") {
|
||||
// YouTube view is active - no additional state needed
|
||||
}
|
||||
|
||||
// Handle configure view (for page refresh support)
|
||||
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||
if (oauthConnector) {
|
||||
let existingConnector: SearchSourceConnector | undefined;
|
||||
if (params.connectorId) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
existingConnector = allConnectors.find(
|
||||
(c: SearchSourceConnector) => c.id === connectorId
|
||||
);
|
||||
} else {
|
||||
existingConnector = allConnectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
|
||||
);
|
||||
}
|
||||
if (existingConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(existingConnector);
|
||||
if (connectorValidation.success) {
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: existingConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(existingConnector);
|
||||
setIndexingConnectorConfig(existingConnector.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edit view
|
||||
if (params.view === "edit" && params.connectorId && allConnectors && !editingConnector) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
const connector = allConnectors.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
if (connector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
setEditingConnector(connector);
|
||||
setConnectorConfig(connector.config);
|
||||
setConnectorName(connector.name);
|
||||
// Load existing periodic sync settings (disabled for non-indexable connectors)
|
||||
setPeriodicEnabled(
|
||||
!connector.is_indexable ? false : connector.periodic_indexing_enabled
|
||||
);
|
||||
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
|
||||
setEnableSummary(connector.enable_summary ?? false);
|
||||
// Reset dates - user can set new ones for re-indexing
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.error === "duplicate_account") {
|
||||
toast.error(`This ${name} account is already connected`, {
|
||||
description: "Please use a different account or manage the existing connection.",
|
||||
});
|
||||
} else {
|
||||
// Do NOT call setIsOpen(false) here. Closing the dialog is handled
|
||||
// explicitly by handleOpenChange and the individual action handlers.
|
||||
// Relying on URL params to close the dialog caused a race condition
|
||||
// where Next.js router updates from tab switches briefly produced
|
||||
// stale searchParams without the "modal" key, closing the popup.
|
||||
|
||||
// Still clean up sub-view state when the modal param is gone
|
||||
// (e.g. after browser back navigation or explicit handler URL cleanup).
|
||||
if (indexingConfig) {
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
setIndexingConnectorConfig(null);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
setEnableSummary(false);
|
||||
setIsScrolled(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
if (editingConnector) {
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
setEnableSummary(false);
|
||||
setIsScrolled(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
if (connectingConnectorType) {
|
||||
setConnectingConnectorType(null);
|
||||
}
|
||||
if (viewingAccountsType) {
|
||||
setViewingAccountsType(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,
|
||||
connectingConnectorType,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
setIsOpen,
|
||||
]);
|
||||
|
||||
// Detect OAuth success / Failure and transition to config view
|
||||
useEffect(() => {
|
||||
try {
|
||||
const params = parseConnectorPopupQueryParams(searchParams);
|
||||
|
||||
// Handle OAuth errors (e.g., duplicate account)
|
||||
if (params.error && params.modal === "connectors") {
|
||||
const oauthConnector = params.connector
|
||||
? OAUTH_CONNECTORS.find((c) => c.id === params.connector)
|
||||
: null;
|
||||
const connectorName = oauthConnector?.title || "connector";
|
||||
|
||||
if (params.error === "duplicate_account") {
|
||||
toast.error(`This ${connectorName} account is already connected`, {
|
||||
description: "Please use a different account or manage the existing connection.",
|
||||
});
|
||||
} else {
|
||||
toast.error(`Failed to connect ${connectorName}`, {
|
||||
description: params.error.replace(/_/g, " "),
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up error params from URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("error");
|
||||
url.searchParams.delete("connector");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
|
||||
// Open the popup to show the connectors
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
|
||||
// For auto-index connectors: close modal and show loading toast before refetch
|
||||
const earlyConnector = params.connector
|
||||
? OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector)
|
||||
: null;
|
||||
|
||||
if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) {
|
||||
toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" });
|
||||
setIsOpen(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("connectorId");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}
|
||||
|
||||
refetchAllConnectors().then(async (result) => {
|
||||
if (!result.data) {
|
||||
toast.dismiss("auto-index");
|
||||
return;
|
||||
}
|
||||
|
||||
let newConnector: SearchSourceConnector | undefined;
|
||||
let oauthConnector:
|
||||
| (typeof OAUTH_CONNECTORS)[number]
|
||||
| (typeof COMPOSIO_CONNECTORS)[number]
|
||||
| undefined;
|
||||
|
||||
// First, try to find connector by connectorId if provided
|
||||
if (params.connectorId) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
|
||||
// If we found the connector, find the matching OAuth/Composio connector by type
|
||||
if (newConnector) {
|
||||
const connectorType = newConnector.connector_type;
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a connector yet, try to find by connector param
|
||||
if (!newConnector && params.connector) {
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||
|
||||
if (oauthConnector) {
|
||||
const oauthConnectorType = oauthConnector.connectorType;
|
||||
newConnector = result.data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnectorType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
|
||||
if (
|
||||
newConnector.is_indexable &&
|
||||
AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType)
|
||||
) {
|
||||
await handleAutoIndex(
|
||||
newConnector,
|
||||
oauthConnector.title,
|
||||
oauthConnector.connectorType
|
||||
);
|
||||
} else {
|
||||
toast.dismiss("auto-index");
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.set("connectorId", newConnector.id.toString());
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.dismiss("auto-index");
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
} else {
|
||||
toast.dismiss("auto-index");
|
||||
}
|
||||
toast.error(`Failed to connect ${name}`, {
|
||||
description: result.error.replace(/_/g, " "),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid query params - log but don't crash
|
||||
console.warn("Invalid connector popup query params in OAuth success handler:", error);
|
||||
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
}, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen, handleAutoIndex, router]);
|
||||
|
||||
if (result.success === "true") {
|
||||
const earlyConnector = result.connector
|
||||
? OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector)
|
||||
: null;
|
||||
|
||||
if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) {
|
||||
toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" });
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
refetchAllConnectors().then(async (fetchResult) => {
|
||||
if (!fetchResult.data) {
|
||||
toast.dismiss("auto-index");
|
||||
return;
|
||||
}
|
||||
|
||||
let newConnector: SearchSourceConnector | undefined;
|
||||
let oauthConnector:
|
||||
| (typeof OAUTH_CONNECTORS)[number]
|
||||
| (typeof COMPOSIO_CONNECTORS)[number]
|
||||
| undefined;
|
||||
|
||||
if (result.connectorId) {
|
||||
const connectorId = parseInt(result.connectorId, 10);
|
||||
newConnector = fetchResult.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
if (newConnector) {
|
||||
const connectorType = newConnector.connector_type;
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!newConnector && result.connector) {
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector);
|
||||
if (oauthConnector) {
|
||||
const oauthType = oauthConnector.connectorType;
|
||||
newConnector = fetchResult.data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
|
||||
if (
|
||||
newConnector.is_indexable &&
|
||||
AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType)
|
||||
) {
|
||||
await handleAutoIndex(
|
||||
newConnector,
|
||||
oauthConnector.title,
|
||||
oauthConnector.connectorType
|
||||
);
|
||||
} else {
|
||||
toast.dismiss("auto-index");
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsFromOAuth(true);
|
||||
setIsOpen(true);
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.dismiss("auto-index");
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
} else {
|
||||
toast.dismiss("auto-index");
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchSpaceId, handleAutoIndex, refetchAllConnectors, setIsOpen]);
|
||||
|
||||
// Handle OAuth connection
|
||||
const handleConnectOAuth = useCallback(
|
||||
|
|
@ -572,12 +372,7 @@ export const useConnectorDialog = () => {
|
|||
// Handle creating YouTube crawler (not a connector, shows view in popup)
|
||||
const handleCreateYouTubeCrawler = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
// Update URL to show YouTube view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "youtube");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
setIsYouTubeView(true);
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Handle creating webcrawler connector
|
||||
|
|
@ -629,10 +424,6 @@ export const useConnectorDialog = () => {
|
|||
setIndexingConnector(connector);
|
||||
setIndexingConnectorConfig(connector.config || {});
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -648,16 +439,7 @@ export const useConnectorDialog = () => {
|
|||
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]
|
||||
);
|
||||
|
|
@ -810,26 +592,16 @@ export const useConnectorDialog = () => {
|
|||
: `${connectorTitle} connected and syncing started!`;
|
||||
toast.success(successMessage);
|
||||
|
||||
// Close dialog and clean up URL
|
||||
setIsOpen(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
// Clear indexing config state since we're not showing the view
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
setIndexingConnectorConfig(null);
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
|
||||
// Refresh connectors list
|
||||
await refetchAllConnectors();
|
||||
} else {
|
||||
// Non-indexable connector
|
||||
|
|
@ -856,15 +628,6 @@ export const useConnectorDialog = () => {
|
|||
description: "Configure the webhook URL in your Circleback settings.",
|
||||
});
|
||||
|
||||
// Transition to edit view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "edit");
|
||||
url.searchParams.set("connectorId", connector.id.toString());
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
// Refresh connectors list
|
||||
await refetchAllConnectors();
|
||||
} else {
|
||||
// Other non-indexable connectors - just show success message and close
|
||||
|
|
@ -874,19 +637,10 @@ export const useConnectorDialog = () => {
|
|||
: `${connectorTitle} connected successfully!`;
|
||||
toast.success(successMessage);
|
||||
|
||||
// Refresh connectors list before closing modal
|
||||
await refetchAllConnectors();
|
||||
|
||||
// Close dialog and clean up URL
|
||||
setIsOpen(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
// Clear indexing config state
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
setIndexingConnectorConfig(null);
|
||||
|
|
@ -911,96 +665,64 @@ export const useConnectorDialog = () => {
|
|||
refetchAllConnectors,
|
||||
updateConnector,
|
||||
indexConnector,
|
||||
router,
|
||||
setIsOpen,
|
||||
]
|
||||
);
|
||||
|
||||
// Handle going back from connect view
|
||||
const handleBackFromConnect = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
|
||||
// If we're connecting an MCP and came from list view, go back to list
|
||||
if (connectingConnectorType === "MCP_CONNECTOR" && viewingMCPList) {
|
||||
url.searchParams.set("view", "mcp-list");
|
||||
} else {
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
if (connectCameFromMCPList) {
|
||||
setViewingMCPList(true);
|
||||
setConnectCameFromMCPList(false);
|
||||
}
|
||||
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router, connectingConnectorType, viewingMCPList]);
|
||||
setConnectingConnectorType(null);
|
||||
}, [connectCameFromMCPList]);
|
||||
|
||||
// Handle going back from YouTube view
|
||||
const handleBackFromYouTube = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
setIsYouTubeView(false);
|
||||
}, []);
|
||||
|
||||
// Handle viewing accounts list for OAuth connector type
|
||||
const handleViewAccountsList = useCallback(
|
||||
(connectorType: string, _connectorTitle?: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
// Update URL to show accounts view, preserving current tab
|
||||
// The useEffect will handle setting viewingAccountsType based on URL params
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "accounts");
|
||||
url.searchParams.set("connectorType", connectorType);
|
||||
// Keep the current tab in URL so we can go back to it
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||
if (oauthConnector) {
|
||||
setViewingAccountsType({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
}
|
||||
},
|
||||
[searchSpaceId, router]
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
// Handle going back from accounts list view
|
||||
const handleBackFromAccountsList = useCallback(() => {
|
||||
setViewingAccountsType(null);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
// Keep the current tab (don't change it) - just remove view-specific params
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Handle viewing MCP list
|
||||
const handleViewMCPList = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setViewingMCPList(true);
|
||||
|
||||
// Update URL to show MCP list view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "mcp-list");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [searchSpaceId, router]);
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Handle going back from MCP list view
|
||||
const handleBackFromMCPList = useCallback(() => {
|
||||
setViewingMCPList(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Handle adding new MCP from list view
|
||||
const handleAddNewMCPFromList = useCallback(() => {
|
||||
setConnectCameFromMCPList(true);
|
||||
setViewingMCPList(false);
|
||||
setConnectingConnectorType("MCP_CONNECTOR");
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "connect");
|
||||
url.searchParams.set("connectorType", "MCP_CONNECTOR");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Handle starting indexing
|
||||
const handleStartIndexing = useCallback(
|
||||
|
|
@ -1143,15 +865,8 @@ export const useConnectorDialog = () => {
|
|||
|
||||
toast.success(`${indexingConfig.connectorTitle} indexing started`);
|
||||
|
||||
// Close dialog and clean up URL
|
||||
setIsOpen(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
setIsFromOAuth(false);
|
||||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
|
|
@ -1174,7 +889,6 @@ export const useConnectorDialog = () => {
|
|||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
router,
|
||||
indexingConnectorConfig,
|
||||
setIsOpen,
|
||||
]
|
||||
|
|
@ -1182,16 +896,9 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle skipping indexing
|
||||
const handleSkipIndexing = useCallback(() => {
|
||||
// Close dialog and clean up URL
|
||||
setIsOpen(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router, setIsOpen]);
|
||||
setIsFromOAuth(false);
|
||||
}, [setIsOpen]);
|
||||
|
||||
// Handle starting edit mode
|
||||
const handleStartEdit = useCallback(
|
||||
|
|
@ -1213,20 +920,21 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Track if we came from accounts list view
|
||||
// If viewingAccountsType matches this connector type, preserve it
|
||||
// Track if we came from accounts list view so handleBackFromEdit can restore it
|
||||
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
|
||||
setCameFromAccountsList(viewingAccountsType);
|
||||
} else {
|
||||
setCameFromAccountsList(null);
|
||||
}
|
||||
setViewingAccountsType(null);
|
||||
|
||||
// Track if we came from MCP list view
|
||||
// Track if we came from MCP list view so handleBackFromEdit can restore it
|
||||
if (viewingMCPList && connector.connector_type === "MCP_CONNECTOR") {
|
||||
setCameFromMCPList(true);
|
||||
} else {
|
||||
setCameFromMCPList(false);
|
||||
}
|
||||
setViewingMCPList(false);
|
||||
|
||||
// Track index with date range opened event
|
||||
if (connector.is_indexable) {
|
||||
|
|
@ -1239,20 +947,11 @@ export const useConnectorDialog = () => {
|
|||
|
||||
setEditingConnector(connector);
|
||||
setConnectorName(connector.name);
|
||||
// Load existing periodic sync settings (disabled for non-indexable connectors)
|
||||
setPeriodicEnabled(!connector.is_indexable ? false : connector.periodic_indexing_enabled);
|
||||
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
|
||||
setEnableSummary(connector.enable_summary ?? false);
|
||||
// Reset dates - user can set new ones for re-indexing
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
|
||||
// Update URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "edit");
|
||||
url.searchParams.set("connectorId", connector.id.toString());
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
},
|
||||
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
|
||||
);
|
||||
|
|
@ -1433,14 +1132,7 @@ export const useConnectorDialog = () => {
|
|||
: indexingDescription,
|
||||
});
|
||||
|
||||
// Close dialog and clean up URL
|
||||
setIsOpen(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
|
|
@ -1465,7 +1157,6 @@ export const useConnectorDialog = () => {
|
|||
frequencyMinutes,
|
||||
enableSummary,
|
||||
getFrequencyLabel,
|
||||
router,
|
||||
connectorConfig,
|
||||
connectorName,
|
||||
setIsOpen,
|
||||
|
|
@ -1496,23 +1187,17 @@ export const useConnectorDialog = () => {
|
|||
: `${editingConnector.name} disconnected successfully`
|
||||
);
|
||||
|
||||
// Update URL - for MCP from list view, go back to list; otherwise close modal
|
||||
const url = new URL(window.location.href);
|
||||
if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
|
||||
// Go back to MCP list view only if we came from there
|
||||
setViewingMCPList(true);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "mcp-list");
|
||||
url.searchParams.delete("connectorId");
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
} else {
|
||||
// Close dialog for all other cases
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
setIsOpen(false);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
}
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
|
|
@ -1525,7 +1210,7 @@ export const useConnectorDialog = () => {
|
|||
setIsDisconnecting(false);
|
||||
}
|
||||
},
|
||||
[editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList, setIsOpen]
|
||||
[editingConnector, searchSpaceId, deleteConnector, cameFromMCPList, setIsOpen]
|
||||
);
|
||||
|
||||
// Handle quick index (index with selected date range, or backend defaults if none selected)
|
||||
|
|
@ -1584,66 +1269,35 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle going back from edit view
|
||||
const handleBackFromEdit = useCallback(() => {
|
||||
// If editing an MCP connector and came from MCP list, go back to MCP list view
|
||||
if (editingConnector?.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
|
||||
setViewingMCPList(true);
|
||||
setCameFromMCPList(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "mcp-list");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we came from accounts list view, go back there
|
||||
if (cameFromAccountsList && editingConnector) {
|
||||
// Restore accounts list view
|
||||
setViewingAccountsType(cameFromAccountsList);
|
||||
setCameFromAccountsList(null);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "accounts");
|
||||
url.searchParams.set("connectorType", cameFromAccountsList.connectorType);
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
} else {
|
||||
// Otherwise, go back to main connector popup (preserve the tab the user was on)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", activeTab); // Use current tab instead of always "all"
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}
|
||||
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
}, [router, cameFromAccountsList, editingConnector, cameFromMCPList, activeTab]);
|
||||
}, [cameFromAccountsList, editingConnector, cameFromMCPList]);
|
||||
|
||||
// Handle dialog open/close
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
|
||||
if (open) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", activeTab);
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
} else {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("view");
|
||||
window.history.pushState({ modal: false }, "", url.toString());
|
||||
if (!open) {
|
||||
setIsScrolled(false);
|
||||
setSearchQuery("");
|
||||
setIsYouTubeView(false);
|
||||
setIsFromOAuth(false);
|
||||
if (!isStartingIndexing && !isSaving && !isDisconnecting && !isCreatingConnector) {
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
|
|
@ -1653,7 +1307,10 @@ export const useConnectorDialog = () => {
|
|||
setConnectorConfig(null);
|
||||
setConnectingConnectorType(null);
|
||||
setViewingAccountsType(null);
|
||||
setViewingMCPList(false);
|
||||
setCameFromAccountsList(null);
|
||||
setCameFromMCPList(false);
|
||||
setConnectCameFromMCPList(false);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
|
|
@ -1662,14 +1319,9 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
}
|
||||
},
|
||||
[activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
|
||||
[isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector, setIsOpen]
|
||||
);
|
||||
|
||||
// Handle tab change — only update React state.
|
||||
// Avoid window.history.replaceState here: Next.js intercepts it, triggers a
|
||||
// searchParams update/transition, and the resulting concurrent re-render can
|
||||
// cause Radix Dialog's DismissableLayer to detect a transient focus-outside
|
||||
// event, which fires onOpenChange(false) and closes the dialog.
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value);
|
||||
}, []);
|
||||
|
|
@ -1704,6 +1356,8 @@ export const useConnectorDialog = () => {
|
|||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
isYouTubeView,
|
||||
isFromOAuth,
|
||||
|
||||
// Setters
|
||||
setSearchQuery,
|
||||
|
|
|
|||
|
|
@ -12,19 +12,16 @@ export type { IndexingConfigState } from "./constants/connector-constants";
|
|||
// Constants and types
|
||||
export { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./constants/connector-constants";
|
||||
export type {
|
||||
ConnectorPopupQueryParams,
|
||||
DateRange,
|
||||
FrequencyMinutes,
|
||||
OAuthAuthResponse,
|
||||
} from "./constants/connector-popup.schemas";
|
||||
// Schemas and validation
|
||||
export {
|
||||
connectorPopupQueryParamsSchema,
|
||||
dateRangeSchema,
|
||||
frequencyMinutesSchema,
|
||||
indexingConfigStateSchema,
|
||||
oauthAuthResponseSchema,
|
||||
parseConnectorPopupQueryParams,
|
||||
parseOAuthAuthResponse,
|
||||
validateIndexingConfigState,
|
||||
} from "./constants/connector-popup.schemas";
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
|||
// Special mappings (connector type differs from document type)
|
||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||
// Composio connectors map to their own document types
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
COMPOSIO_GMAIL_CONNECTOR: "COMPOSIO_GMAIL_CONNECTOR",
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
// Composio connectors map to unified Google document types
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||
COMPOSIO_GMAIL_CONNECTOR: "GOOGLE_GMAIL_CONNECTOR",
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "GOOGLE_CALENDAR_CONNECTOR",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import { z } from "zod";
|
|||
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === "development";
|
||||
|
||||
/**
|
||||
* Zod schema for MCP server configuration
|
||||
* Supports both stdio (local process) and HTTP (remote server) transports
|
||||
|
|
@ -102,11 +104,11 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
|
|||
// Check cache first
|
||||
const cached = configCache.get(configJson);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
console.log("[MCP Validator] ✅ Using cached config");
|
||||
if (IS_DEV) console.log("[MCP Validator] ✅ Using cached config");
|
||||
return { config: cached.config, error: null };
|
||||
}
|
||||
|
||||
console.log("[MCP Validator] 🔍 Parsing new config...");
|
||||
if (IS_DEV) console.log("[MCP Validator] 🔍 Parsing new config...");
|
||||
|
||||
// Clean up expired cache entries periodically
|
||||
if (configCache.size > 100) {
|
||||
|
|
@ -176,7 +178,7 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
|
|||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
console.log("[MCP Validator] ✅ Config parsed successfully:", config);
|
||||
if (IS_DEV) console.log("[MCP Validator] ✅ Config parsed successfully:", config);
|
||||
|
||||
return {
|
||||
config,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus, Server } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||
|
||||
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
|
||||
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
|
||||
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
|
||||
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
|
||||
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
|
||||
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
|
||||
[EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
|
||||
[EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
|
||||
[EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth",
|
||||
[EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth",
|
||||
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth",
|
||||
};
|
||||
|
||||
interface ConnectorAccountsListViewProps {
|
||||
connectorType: string;
|
||||
connectorTitle: string;
|
||||
|
|
@ -43,12 +60,49 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
isConnecting = false,
|
||||
addButtonText,
|
||||
}) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const [reauthingId, setReauthingId] = useState<number | null>(null);
|
||||
|
||||
// Get connector status
|
||||
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
|
||||
|
||||
const isEnabled = isConnectorEnabled(connectorType);
|
||||
const statusMessage = getConnectorStatusMessage(connectorType);
|
||||
|
||||
const reauthEndpoint = REAUTH_ENDPOINTS[connectorType];
|
||||
|
||||
const handleReauth = useCallback(
|
||||
async (connectorId: number) => {
|
||||
if (!searchSpaceId || !reauthEndpoint) return;
|
||||
setReauthingId(connectorId);
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const url = new URL(`${backendUrl}${reauthEndpoint}`);
|
||||
url.searchParams.set("connector_id", String(connectorId));
|
||||
url.searchParams.set("space_id", String(searchSpaceId));
|
||||
url.searchParams.set("return_url", window.location.pathname);
|
||||
const response = await authenticatedFetch(url.toString());
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(data.detail ?? "Failed to initiate re-authentication.");
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
} else if (data.success) {
|
||||
toast.success(data.message ?? "Authentication refreshed successfully.");
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to initiate re-authentication.");
|
||||
} finally {
|
||||
setReauthingId(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId, reauthEndpoint]
|
||||
);
|
||||
|
||||
// Filter connectors to only show those of this type
|
||||
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
|
||||
|
||||
|
|
@ -149,6 +203,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{typeConnectors.map((connector) => {
|
||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||
const isAuthExpired = !!reauthEndpoint && connector.config?.auth_expired === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -189,14 +244,28 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
|
||||
onClick={() => onManage(connector)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
{isAuthExpired ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
|
||||
onClick={() => handleReauth(connector.id)}
|
||||
disabled={reauthingId === connector.id}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
|
||||
/>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
|
||||
onClick={() => onManage(connector)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,11 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
[isThreadRunning]
|
||||
);
|
||||
|
||||
// Calculate summary info
|
||||
const completedSteps = steps.filter((s) => getEffectiveStatus(s) === "completed").length;
|
||||
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
|
||||
const allCompleted = completedSteps === steps.length && steps.length > 0 && !isThreadRunning;
|
||||
const allCompleted =
|
||||
steps.length > 0 &&
|
||||
!isThreadRunning &&
|
||||
steps.every((s) => getEffectiveStatus(s) === "completed");
|
||||
const isProcessing = isThreadRunning && !allCompleted;
|
||||
|
||||
// Auto-collapse when all tasks are completed
|
||||
|
|
@ -45,18 +46,17 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
// Generate header text
|
||||
const getHeaderText = () => {
|
||||
if (allCompleted) {
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
return "Reviewed";
|
||||
}
|
||||
if (inProgressStep) {
|
||||
return inProgressStep.title;
|
||||
}
|
||||
if (isProcessing) {
|
||||
return `Processing ${completedSteps}/${steps.length} steps`;
|
||||
return "Processing";
|
||||
}
|
||||
return `Reviewed ${completedSteps} ${completedSteps === 1 ? "step" : "steps"}`;
|
||||
return "Reviewed";
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -129,11 +129,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
effectiveStatus === "pending" && "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<TextShimmerLoader text={step.title} size="sm" />
|
||||
) : (
|
||||
step.title
|
||||
)}
|
||||
{step.title}
|
||||
</div>
|
||||
|
||||
{/* Step items (sub-content) */}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ import {
|
|||
SquareIcon,
|
||||
Unplug,
|
||||
Upload,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
|
@ -88,7 +90,11 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import {
|
||||
CONNECTOR_ICON_TO_TYPES,
|
||||
CONNECTOR_TOOL_ICON_PATHS,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
|
|
@ -97,12 +103,12 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
/** Placeholder texts that cycle in new chats when input is empty */
|
||||
const CYCLING_PLACEHOLDERS = [
|
||||
"Ask SurfSense anything or @mention docs.",
|
||||
"Generate a podcast from my vacation ideas in Notion.",
|
||||
"Sum up last week's meeting notes from Drive in a bulleted list.",
|
||||
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
|
||||
"Ask SurfSense anything or @mention docs",
|
||||
"Generate a podcast from my vacation ideas in Notion",
|
||||
"Sum up last week's meeting notes from Drive in a bulleted list",
|
||||
"Give me a brief overview of the most urgent tickets in Jira and Linear",
|
||||
"Briefly, what are today's top ten important emails and calendar events?",
|
||||
"Check if this week's Slack messages reference any GitHub issues.",
|
||||
"Check if this week's Slack messages reference any GitHub issues",
|
||||
];
|
||||
|
||||
interface ThreadProps {
|
||||
|
|
@ -256,7 +262,7 @@ const BANNER_CONNECTORS = [
|
|||
|
||||
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
|
||||
|
||||
const ConnectToolsBanner: FC = () => {
|
||||
const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) => {
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
|
|
@ -266,7 +272,7 @@ const ConnectToolsBanner: FC = () => {
|
|||
|
||||
const hasConnectors = (connectors?.length ?? 0) > 0;
|
||||
|
||||
if (dismissed || hasConnectors) return null;
|
||||
if (dismissed || hasConnectors || !isThreadEmpty) return null;
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -560,7 +566,7 @@ const Composer: FC = () => {
|
|||
)}
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
<ConnectorIndicator showTrigger={false} />
|
||||
<ConnectToolsBanner />
|
||||
<ConnectToolsBanner isThreadEmpty={isThreadEmpty} />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
|
@ -598,29 +604,49 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
const { data: agentTools } = useAtomValue(agentToolsAtom);
|
||||
const disabledTools = useAtomValue(disabledToolsAtom);
|
||||
const toggleTool = useSetAtom(toggleToolAtom);
|
||||
const setDisabledTools = useSetAtom(disabledToolsAtom);
|
||||
const hydrateDisabled = useSetAtom(hydrateDisabledToolsAtom);
|
||||
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const connectedTypes = useMemo(
|
||||
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
|
||||
[connectors]
|
||||
);
|
||||
|
||||
const toggleToolGroup = useCallback(
|
||||
(toolNames: string[]) => {
|
||||
const allDisabled = toolNames.every((name) => disabledTools.includes(name));
|
||||
if (allDisabled) {
|
||||
setDisabledTools((prev) => prev.filter((t) => !toolNames.includes(t)));
|
||||
} else {
|
||||
setDisabledTools((prev) => [...new Set([...prev, ...toolNames])]);
|
||||
}
|
||||
},
|
||||
[disabledTools, setDisabledTools]
|
||||
);
|
||||
|
||||
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
|
||||
const isWebSearchEnabled = hasWebSearchTool && !disabledTools.includes("web_search");
|
||||
const filteredTools = useMemo(
|
||||
() => agentTools?.filter((t) => t.name !== "web_search"),
|
||||
[agentTools]
|
||||
);
|
||||
const filteredEnabledCount = useMemo(() => {
|
||||
if (!filteredTools) return 0;
|
||||
return (
|
||||
filteredTools.length -
|
||||
disabledTools.filter((d) => filteredTools.some((t) => t.name === d)).length
|
||||
);
|
||||
}, [filteredTools, disabledTools]);
|
||||
|
||||
const groupedTools = useMemo(() => {
|
||||
if (!filteredTools) return [];
|
||||
const toolsByName = new Map(filteredTools.map((t) => [t.name, t]));
|
||||
const result: { label: string; tools: typeof filteredTools }[] = [];
|
||||
const result: { label: string; tools: typeof filteredTools; connectorIcon?: string }[] = [];
|
||||
const placed = new Set<string>();
|
||||
|
||||
for (const group of TOOL_GROUPS) {
|
||||
if (group.connectorIcon) {
|
||||
const requiredTypes = CONNECTOR_ICON_TO_TYPES[group.connectorIcon];
|
||||
const isConnected = requiredTypes?.some((t) => connectedTypes.has(t));
|
||||
if (!isConnected) {
|
||||
for (const name of group.tools) placed.add(name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const matched = group.tools.flatMap((name) => {
|
||||
const tool = toolsByName.get(name);
|
||||
if (!tool) return [];
|
||||
|
|
@ -628,7 +654,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
return [tool];
|
||||
});
|
||||
if (matched.length > 0) {
|
||||
result.push({ label: group.label, tools: matched });
|
||||
result.push({ label: group.label, tools: matched, connectorIcon: group.connectorIcon });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -638,7 +664,25 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
}
|
||||
|
||||
return result;
|
||||
}, [filteredTools]);
|
||||
}, [filteredTools, connectedTypes]);
|
||||
|
||||
const { visibleTotal, visibleEnabled } = useMemo(() => {
|
||||
let total = 0;
|
||||
let enabled = 0;
|
||||
for (const group of groupedTools) {
|
||||
if (group.connectorIcon) {
|
||||
total += 1;
|
||||
const allDisabled = group.tools.every((t) => disabledTools.includes(t.name));
|
||||
if (!allDisabled) enabled += 1;
|
||||
} else {
|
||||
for (const tool of group.tools) {
|
||||
total += 1;
|
||||
if (!disabledTools.includes(tool.name)) enabled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { visibleTotal: total, visibleEnabled: enabled };
|
||||
}, [groupedTools, disabledTools]);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateDisabled();
|
||||
|
|
@ -691,37 +735,81 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<DrawerTitle className="text-sm font-medium">Agent Tools</DrawerTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
|
||||
{visibleEnabled}/{visibleTotal} enabled
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto pb-6" onScroll={handleToolsScroll}>
|
||||
{groupedTools.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||
{group.label}
|
||||
{groupedTools
|
||||
.filter((g) => !g.connectorIcon)
|
||||
.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{groupedTools.some((g) => g.connectorIcon) && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||
Connector Actions
|
||||
</div>
|
||||
{groupedTools
|
||||
.filter((g) => g.connectorIcon)
|
||||
.map((group) => {
|
||||
const iconKey = group.connectorIcon ?? "";
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
|
||||
return (
|
||||
<div
|
||||
key={group.label}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
{iconInfo ? (
|
||||
<Image
|
||||
src={iconInfo.src}
|
||||
alt={iconInfo.alt}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-[18px] shrink-0 select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
||||
{group.label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!allDisabled}
|
||||
onCheckedChange={() => toggleToolGroup(toolNames)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{!filteredTools?.length && (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Loading tools...
|
||||
|
|
@ -766,7 +854,7 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
<div className="flex items-center justify-between px-2.5 py-2 sm:px-3 sm:py-2.5 border-b">
|
||||
<span className="text-xs sm:text-sm font-medium">Agent Tools</span>
|
||||
<span className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
{filteredEnabledCount}/{filteredTools?.length ?? 0} enabled
|
||||
{visibleEnabled}/{visibleTotal} enabled
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -777,38 +865,89 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
|||
WebkitMaskImage: `linear-gradient(to bottom, ${toolsScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${toolsScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{groupedTools.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
|
||||
{group.label}
|
||||
{groupedTools
|
||||
.filter((g) => !g.connectorIcon)
|
||||
.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
const row = (
|
||||
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
||||
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0 scale-[0.6] sm:scale-75"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip key={tool.name}>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-64 text-xs">
|
||||
{tool.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledTools.includes(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
const row = (
|
||||
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
||||
<ToolIcon className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0 scale-[0.6] sm:scale-75"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip key={tool.name}>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-64 text-xs">
|
||||
{tool.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{groupedTools.some((g) => g.connectorIcon) && (
|
||||
<div>
|
||||
<div className="px-2.5 sm:px-3 pt-2 pb-0.5 text-[10px] sm:text-xs text-muted-foreground/80 font-normal select-none">
|
||||
Connector Actions
|
||||
</div>
|
||||
{groupedTools
|
||||
.filter((g) => g.connectorIcon)
|
||||
.map((group) => {
|
||||
const iconKey = group.connectorIcon ?? "";
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledTools.includes(n));
|
||||
const groupDef = TOOL_GROUPS.find((g) => g.label === group.label);
|
||||
const row = (
|
||||
<div className="flex w-full items-center gap-2 sm:gap-3 px-2.5 sm:px-3 py-1 sm:py-1.5 hover:bg-muted-foreground/10 transition-colors">
|
||||
{iconInfo ? (
|
||||
<Image
|
||||
src={iconInfo.src}
|
||||
alt={iconInfo.alt}
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-3.5 sm:size-4 shrink-0 select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="size-3.5 sm:size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 min-w-0 text-xs sm:text-sm font-medium truncate">
|
||||
{group.label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!allDisabled}
|
||||
onCheckedChange={() => toggleToolGroup(toolNames)}
|
||||
className="shrink-0 scale-[0.6] sm:scale-75"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip key={group.label}>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72 text-xs">
|
||||
{groupDef?.tooltip ??
|
||||
group.tools.map((t) => t.description).join(" · ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{!filteredTools?.length && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
Loading tools...
|
||||
|
|
@ -931,7 +1070,14 @@ function formatToolName(name: string): string {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
||||
interface ToolGroup {
|
||||
label: string;
|
||||
tools: string[];
|
||||
connectorIcon?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const TOOL_GROUPS: ToolGroup[] = [
|
||||
{
|
||||
label: "Research",
|
||||
tools: ["search_knowledge_base", "search_surfsense_docs", "scrape_webpage", "link_preview"],
|
||||
|
|
@ -950,6 +1096,48 @@ const TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
|||
label: "Memory",
|
||||
tools: ["save_memory", "recall_memory"],
|
||||
},
|
||||
{
|
||||
label: "Gmail",
|
||||
tools: ["create_gmail_draft", "update_gmail_draft", "send_gmail_email", "trash_gmail_email"],
|
||||
connectorIcon: "gmail",
|
||||
tooltip: "Create drafts, update drafts, send emails, and trash emails in Gmail.",
|
||||
},
|
||||
{
|
||||
label: "Google Calendar",
|
||||
tools: ["create_calendar_event", "update_calendar_event", "delete_calendar_event"],
|
||||
connectorIcon: "google_calendar",
|
||||
tooltip: "Create, update, and delete events in Google Calendar.",
|
||||
},
|
||||
{
|
||||
label: "Google Drive",
|
||||
tools: ["create_google_drive_file", "delete_google_drive_file"],
|
||||
connectorIcon: "google_drive",
|
||||
tooltip: "Create and delete files in Google Drive.",
|
||||
},
|
||||
{
|
||||
label: "Notion",
|
||||
tools: ["create_notion_page", "update_notion_page", "delete_notion_page"],
|
||||
connectorIcon: "notion",
|
||||
tooltip: "Create, update, and delete pages in Notion.",
|
||||
},
|
||||
{
|
||||
label: "Linear",
|
||||
tools: ["create_linear_issue", "update_linear_issue", "delete_linear_issue"],
|
||||
connectorIcon: "linear",
|
||||
tooltip: "Create, update, and delete issues in Linear.",
|
||||
},
|
||||
{
|
||||
label: "Jira",
|
||||
tools: ["create_jira_issue", "update_jira_issue", "delete_jira_issue"],
|
||||
connectorIcon: "jira",
|
||||
tooltip: "Create, update, and delete issues in Jira.",
|
||||
},
|
||||
{
|
||||
label: "Confluence",
|
||||
tools: ["create_confluence_page", "update_confluence_page", "delete_confluence_page"],
|
||||
connectorIcon: "confluence",
|
||||
tooltip: "Create, update, and delete pages in Confluence.",
|
||||
},
|
||||
];
|
||||
|
||||
const MessageError: FC = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue