From 513dd2f1069478075eeae025b9486a0a39dfb8a7 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sat, 18 Jul 2026 00:06:12 +0800 Subject: [PATCH 1/5] feat: add Connector Gateway UI and MCP wiring --- backend/app/agent/factory/browser.py | 6 +- backend/app/agent/factory/developer.py | 6 +- backend/app/agent/factory/document.py | 6 +- backend/app/agent/factory/mcp.py | 4 +- backend/app/agent/factory/multi_modal.py | 6 +- backend/app/agent/factory/question_confirm.py | 9 +- backend/app/agent/factory/single_agent.py | 6 +- backend/app/agent/factory/social_media.py | 6 +- backend/app/agent/prompt.py | 23 + backend/app/service/chat_service.py | 8 +- src/api/connectors.ts | 221 +++++ src/pages/Connectors/ConnectorGateway.tsx | 904 ++++++++++++++++++ src/pages/Connectors/index.tsx | 2 + src/store/chatStore.ts | 91 +- src/store/serverCapabilityStore.ts | 153 +++ 15 files changed, 1438 insertions(+), 13 deletions(-) create mode 100644 src/api/connectors.ts create mode 100644 src/pages/Connectors/ConnectorGateway.tsx create mode 100644 src/store/serverCapabilityStore.ts diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 4aa9f9a00..c825378f3 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -25,7 +25,10 @@ attach_remote_sub_agent_if_enabled, ) from app.agent.listen_chat_agent import logger -from app.agent.prompt import BROWSER_SYS_PROMPT +from app.agent.prompt import ( + BROWSER_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.hybrid_browser_toolkit import HybridBrowserToolkit @@ -382,6 +385,7 @@ def browser_agent( now_str=NOW_STR, external_browser_notice=external_browser_notice, ) + system_message = append_connected_app_mcp_notice(system_message) system_message = attach_remote_sub_agent_if_enabled( options=options, agent_name=Agents.browser_agent, diff --git a/backend/app/agent/factory/developer.py b/backend/app/agent/factory/developer.py index 6cd1afbe2..53b6552e0 100644 --- a/backend/app/agent/factory/developer.py +++ b/backend/app/agent/factory/developer.py @@ -22,7 +22,10 @@ attach_remote_sub_agent_if_enabled, ) from app.agent.listen_chat_agent import logger -from app.agent.prompt import DEVELOPER_SYS_PROMPT +from app.agent.prompt import ( + DEVELOPER_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.toolkit.human_toolkit import HumanToolkit # TODO: Remove NoteTakingToolkit and use TerminalToolkit instead @@ -128,6 +131,7 @@ async def developer_agent( working_directory=working_directory, now_str=NOW_STR, ) + system_message = append_connected_app_mcp_notice(system_message) system_message = attach_remote_sub_agent_if_enabled( options=options, agent_name=Agents.developer_agent, diff --git a/backend/app/agent/factory/document.py b/backend/app/agent/factory/document.py index 709e2f35d..83b973530 100644 --- a/backend/app/agent/factory/document.py +++ b/backend/app/agent/factory/document.py @@ -21,7 +21,10 @@ attach_remote_sub_agent_if_enabled, ) from app.agent.listen_chat_agent import logger -from app.agent.prompt import DOCUMENT_SYS_PROMPT +from app.agent.prompt import ( + DOCUMENT_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.toolkit.excel_toolkit import ExcelToolkit from app.agent.toolkit.file_write_toolkit import FileToolkit from app.agent.toolkit.google_drive_mcp_toolkit import GoogleDriveMCPToolkit @@ -154,6 +157,7 @@ async def document_agent( working_directory=working_directory, now_str=NOW_STR, ) + system_message = append_connected_app_mcp_notice(system_message) system_message = attach_remote_sub_agent_if_enabled( options=options, agent_name=Agents.document_agent, diff --git a/backend/app/agent/factory/mcp.py b/backend/app/agent/factory/mcp.py index d066b1586..c5832c5bb 100644 --- a/backend/app/agent/factory/mcp.py +++ b/backend/app/agent/factory/mcp.py @@ -19,7 +19,7 @@ remote_sub_agent_enabled, ) from app.agent.listen_chat_agent import logger -from app.agent.prompt import MCP_SYS_PROMPT +from app.agent.prompt import MCP_SYS_PROMPT, append_connected_app_mcp_notice from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.mcp_search_toolkit import McpSearchToolkit from app.agent.tools import get_mcp_tools @@ -73,7 +73,7 @@ async def mcp_agent(options: Chat): working_directory=working_directory, tools=tools, tool_names=tool_names, - system_message=MCP_SYS_PROMPT, + system_message=append_connected_app_mcp_notice(MCP_SYS_PROMPT), local_tool_description="local MCP or search tools", message_integration=message_integration, ) diff --git a/backend/app/agent/factory/multi_modal.py b/backend/app/agent/factory/multi_modal.py index 9b13c8432..56d244460 100644 --- a/backend/app/agent/factory/multi_modal.py +++ b/backend/app/agent/factory/multi_modal.py @@ -23,7 +23,10 @@ attach_remote_sub_agent_if_enabled, ) from app.agent.listen_chat_agent import logger -from app.agent.prompt import MULTI_MODAL_SYS_PROMPT +from app.agent.prompt import ( + MULTI_MODAL_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.toolkit.audio_analysis_toolkit import AudioAnalysisToolkit from app.agent.toolkit.human_toolkit import HumanToolkit @@ -202,6 +205,7 @@ def multi_modal_agent( working_directory=working_directory, now_str=NOW_STR, ) + system_message = append_connected_app_mcp_notice(system_message) system_message = attach_remote_sub_agent_if_enabled( options=options, agent_name=Agents.multi_modal_agent, diff --git a/backend/app/agent/factory/question_confirm.py b/backend/app/agent/factory/question_confirm.py index 3461f5238..dba387810 100644 --- a/backend/app/agent/factory/question_confirm.py +++ b/backend/app/agent/factory/question_confirm.py @@ -13,7 +13,10 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= from app.agent.agent_model import agent_model -from app.agent.prompt import QUESTION_CONFIRM_SYS_PROMPT +from app.agent.prompt import ( + QUESTION_CONFIRM_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.utils import NOW_STR from app.model.chat import Chat @@ -21,6 +24,8 @@ def question_confirm_agent(options: Chat): return agent_model( "question_confirm_agent", - QUESTION_CONFIRM_SYS_PROMPT.format(now_str=NOW_STR), + append_connected_app_mcp_notice( + QUESTION_CONFIRM_SYS_PROMPT.format(now_str=NOW_STR) + ), options, ) diff --git a/backend/app/agent/factory/single_agent.py b/backend/app/agent/factory/single_agent.py index ac5e3a747..229c50f4c 100644 --- a/backend/app/agent/factory/single_agent.py +++ b/backend/app/agent/factory/single_agent.py @@ -21,7 +21,10 @@ from app.agent.agent_model import agent_model from app.agent.factory.toolkit_assembler import assemble_single_agent_toolkits -from app.agent.prompt import SINGLE_AGENT_SYS_PROMPT +from app.agent.prompt import ( + SINGLE_AGENT_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.utils import NOW_STR from app.hands.interface import IHands from app.model.chat import Chat @@ -70,6 +73,7 @@ async def single_agent( working_directory=working_directory, now_str=NOW_STR, ) + system_message = append_connected_app_mcp_notice(system_message) agent = agent_model( Agents.single_agent, diff --git a/backend/app/agent/factory/social_media.py b/backend/app/agent/factory/social_media.py index d1cb32349..6b42ea031 100644 --- a/backend/app/agent/factory/social_media.py +++ b/backend/app/agent/factory/social_media.py @@ -19,7 +19,10 @@ attach_remote_sub_agent_if_enabled, ) from app.agent.listen_chat_agent import logger -from app.agent.prompt import SOCIAL_MEDIA_SYS_PROMPT +from app.agent.prompt import ( + SOCIAL_MEDIA_SYS_PROMPT, + append_connected_app_mcp_notice, +) from app.agent.toolkit.google_calendar_toolkit import GoogleCalendarToolkit from app.agent.toolkit.google_gmail_mcp_toolkit import GoogleGmailMCPToolkit from app.agent.toolkit.human_toolkit import HumanToolkit @@ -110,6 +113,7 @@ async def social_media_agent(options: Chat): system_message = SOCIAL_MEDIA_SYS_PROMPT.format( working_directory=working_directory, now_str=NOW_STR ) + system_message = append_connected_app_mcp_notice(system_message) system_message = attach_remote_sub_agent_if_enabled( options=options, agent_name=Agents.social_media_agent, diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 7ee1f905b..127551f0f 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -109,6 +109,29 @@ def build_remote_sub_agent_planning_notice() -> str: return REMOTE_SUB_AGENT_PLANNING_NOTICE +CONNECTED_APP_MCP_NOTICE = """\ + +When the user asks to query or operate a third-party app that may already be +connected, use available MCP or connector tools before browser/manual-login +flows. Examples include Clerk, Slack, Gmail, Notion, GitHub, Google Calendar, +Google Drive, Jira, Linear, Lark, Airtable, HubSpot, and similar SaaS apps. + +- Search or list the app's available actions first. +- Execute read/list/search/count actions directly when the user's intent is + clear and all required input is available or optional. +- Execute write/send/update/delete/admin actions only when the user explicitly + asked for that change and the target details are clear. +- Ask for dashboard links, API keys, or manual browser login only after the + connector tools show the app/account/action is unavailable or required input + is missing. + +""" + + +def append_connected_app_mcp_notice(system_message: str) -> str: + return f"{system_message.rstrip()}\n\n{CONNECTED_APP_MCP_NOTICE}" + + SOCIAL_MEDIA_SYS_PROMPT = """\ You are a Social Media Management Assistant with comprehensive capabilities across multiple platforms. You MUST use the `send_message_to_user` tool to diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 2b977df8c..1ebc1f08b 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -49,7 +49,10 @@ remote_sub_agent_enabled, ) from app.agent.listen_chat_agent import ListenChatAgent -from app.agent.prompt import build_remote_sub_agent_planning_notice +from app.agent.prompt import ( + append_connected_app_mcp_notice, + build_remote_sub_agent_planning_notice, +) from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.skill_toolkit import SkillToolkit @@ -2867,6 +2870,9 @@ async def new_agent_model( For any date-related tasks, you MUST use this as \ the current date. """ + enhanced_description = append_connected_app_mcp_notice( + enhanced_description + ) message_integration = ToolkitMessageIntegration( message_handler=HumanToolkit( options.project_id, data.name diff --git a/src/api/connectors.ts b/src/api/connectors.ts new file mode 100644 index 000000000..667bbdc3e --- /dev/null +++ b/src/api/connectors.ts @@ -0,0 +1,221 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + proxyFetchDelete, + proxyFetchGet, + proxyFetchPost, + proxyFetchPut, +} from '@/api/http'; + +export type ConnectorAuthType = + | 'no_auth' + | 'api_key' + | 'custom_credential' + | 'oauth2' + | string; + +export interface ConnectorCredentialField { + key: string; + label: string; + inputType: 'text' | 'password' | 'textarea' | 'json' | string; + required: boolean; + secret: boolean; + placeholder?: string | null; + description?: string | null; +} + +export interface ConnectorAuthDefinition { + type: ConnectorAuthType; + label?: string | null; + placeholder?: string | null; + description?: string | null; + extraFields?: ConnectorCredentialField[]; + fields?: ConnectorCredentialField[]; + scopes?: string[]; + clientConfigFields?: ConnectorCredentialField[]; +} + +export interface ConnectorAction { + id?: string; + name?: string; + description?: string; +} + +export interface ConnectorConnection { + id?: string | null; + service: string; + connectionName: string; + authType: ConnectorAuthType; + configured: boolean; + virtual: boolean; + default: boolean; + profile?: { + displayName?: string | null; + grantedScopes?: string[]; + } | null; +} + +export interface ConnectorProvider { + service: string; + displayName?: string; + description?: string | null; + iconUrl?: string | null; + homepageUrl?: string | null; + categories?: string[]; + authTypes?: ConnectorAuthType[]; + auth?: ConnectorAuthDefinition[]; + action_count?: number; + locally_executable_action_count?: number; + catalog_only_action_count?: number; + recommended?: boolean; + sort_rank?: number | null; + connection?: ConnectorConnection | null; + actions?: ConnectorAction[]; +} + +export interface ConnectorProvidersResponse { + enabled: boolean; + source: 'connector_gateway'; + provider_count: number; + filtered_count: number; + connected_count: number; + page: number; + page_size: number; + total_pages: number; + providers: ConnectorProvider[]; +} + +export interface ConnectorProviderResponse { + enabled: boolean; + source: 'connector_gateway'; + provider: ConnectorProvider; +} + +export interface ConnectProviderRequest { + auth_type: ConnectorAuthType; + values?: Record; + connection_name?: string; +} + +export interface ConnectorOAuthAuthorization { + service: string; + authorizationUrl: string; + state?: string; +} + +export interface FetchConnectorProvidersOptions { + page?: number; + pageSize?: number; + query?: string; +} + +export async function fetchConnectorProviders( + options: FetchConnectorProvidersOptions = {} +): Promise { + const params: Record = { + page: options.page || 1, + page_size: options.pageSize || 24, + }; + const query = options.query?.trim(); + if (query) { + params.q = query; + } + const response = await proxyFetchGet('/api/v1/connectors/providers', params); + const providers = Array.isArray(response?.providers) + ? response.providers + : []; + const providerCount = + typeof response?.provider_count === 'number' + ? response.provider_count + : providers.length; + const filteredCount = + typeof response?.filtered_count === 'number' + ? response.filtered_count + : providerCount; + const pageSize = + typeof response?.page_size === 'number' + ? response.page_size + : options.pageSize || 24; + return { + enabled: response?.enabled === true, + source: 'connector_gateway', + provider_count: providerCount, + filtered_count: filteredCount, + connected_count: + typeof response?.connected_count === 'number' + ? response.connected_count + : 0, + page: + typeof response?.page === 'number' ? response.page : options.page || 1, + page_size: pageSize, + total_pages: + typeof response?.total_pages === 'number' + ? response.total_pages + : Math.max(1, Math.ceil(filteredCount / pageSize)), + providers, + }; +} + +export async function fetchConnectorProvider( + service: string +): Promise { + const response = await proxyFetchGet( + `/api/v1/connectors/providers/${encodeURIComponent(service)}` + ); + return { + enabled: response?.enabled === true, + source: 'connector_gateway', + provider: response?.provider, + }; +} + +export async function connectProvider( + service: string, + request: ConnectProviderRequest +) { + return proxyFetchPut( + `/api/v1/connectors/connections/${encodeURIComponent(service)}`, + request + ); +} + +export async function disconnectProvider( + service: string, + connectionName?: string +) { + return proxyFetchDelete( + `/api/v1/connectors/connections/${encodeURIComponent(service)}`, + connectionName ? { connection_name: connectionName } : undefined + ); +} + +export async function createConnectorOAuthAuthorization( + service: string, + connectionName?: string +): Promise { + const response = await proxyFetchPost( + '/api/v1/connectors/oauth/authorizations', + { + service, + connection_name: connectionName, + } + ); + const authorization = response?.authorization; + return { + service: authorization?.service || service, + authorizationUrl: authorization?.authorizationUrl || '', + state: authorization?.state, + }; +} diff --git a/src/pages/Connectors/ConnectorGateway.tsx b/src/pages/Connectors/ConnectorGateway.tsx new file mode 100644 index 000000000..b452b529f --- /dev/null +++ b/src/pages/Connectors/ConnectorGateway.tsx @@ -0,0 +1,904 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + connectProvider, + createConnectorOAuthAuthorization, + disconnectProvider, + fetchConnectorProvider, + fetchConnectorProviders, + type ConnectorAction, + type ConnectorAuthDefinition, + type ConnectorCredentialField, + type ConnectorProvider, +} from '@/api/connectors'; +import SearchInput from '@/components/Dashboard/SearchInput'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Textarea } from '@/components/ui/textarea'; +import { TooltipSimple } from '@/components/ui/tooltip'; +import { useServerCapabilityStore } from '@/store/serverCapabilityStore'; +import { + CheckCircle2, + ChevronLeft, + ChevronRight, + ExternalLink, + KeyRound, + ListChecks, + Loader2, + PlugZap, + RefreshCw, + ShieldCheck, + Sparkles, + Trash2, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +const CONNECTOR_PAGE_SIZE = 24; + +function providerLabel(provider: ConnectorProvider): string { + return provider.displayName || provider.service; +} + +function providerActionCount(provider: ConnectorProvider): number { + return typeof provider.action_count === 'number' + ? provider.action_count + : Array.isArray(provider.actions) + ? provider.actions.length + : 0; +} + +function providerActionCountOrZero( + provider: ConnectorProvider | null | undefined +): number { + return provider ? providerActionCount(provider) : 0; +} + +function actionLabel(action: ConnectorAction): string { + return action.name || action.id || 'Unnamed action'; +} + +function isConnected(provider: ConnectorProvider | null | undefined): boolean { + const connection = provider?.connection; + return ( + connection?.configured === true && + connection.virtual !== true && + connection.authType !== 'no_auth' + ); +} + +function authLabel(authType: string): string { + if (authType === 'api_key') return 'API key'; + if (authType === 'custom_credential') return 'Credential'; + if (authType === 'oauth2') return 'OAuth'; + if (authType === 'no_auth') return 'No auth'; + return authType; +} + +function authPriority(authType: string): number { + if (authType === 'api_key') return 0; + if (authType === 'custom_credential') return 1; + if (authType === 'no_auth') return 2; + if (authType === 'oauth2') return 3; + return 10; +} + +function credentialFieldsFor( + auth: ConnectorAuthDefinition | undefined +): ConnectorCredentialField[] { + if (!auth) return []; + if (auth.type === 'api_key') { + return [ + { + key: 'apiKey', + label: auth.label || 'API key', + inputType: 'password', + required: true, + secret: true, + placeholder: auth.placeholder, + description: auth.description, + }, + ...(auth.extraFields || []), + ]; + } + if (auth.type === 'custom_credential') { + return auth.fields || []; + } + return []; +} + +function authDefinitions( + provider: ConnectorProvider | null +): ConnectorAuthDefinition[] { + if (!provider) return []; + if (provider.auth?.length) { + return [...provider.auth].sort( + (left, right) => authPriority(left.type) - authPriority(right.type) + ); + } + return (provider.authTypes || []).map((type) => ({ type })); +} + +function preferredAuthType(provider: ConnectorProvider | null): string | null { + const definitions = authDefinitions(provider); + if (!definitions.length) return null; + const connectedAuth = provider?.connection?.authType; + if ( + connectedAuth && + definitions.some((auth) => auth.type === connectedAuth) + ) { + return connectedAuth; + } + return definitions[0].type; +} + +function updateProviderInList( + providers: ConnectorProvider[], + updated: ConnectorProvider +): ConnectorProvider[] { + return providers.map((provider) => + provider.service === updated.service + ? { ...provider, ...updated } + : provider + ); +} + +function ProviderIcon({ + provider, + size = 'sm', +}: { + provider: ConnectorProvider | null | undefined; + size?: 'sm' | 'lg'; +}) { + const iconUrl = provider?.iconUrl || ''; + const [iconFailed, setIconFailed] = useState(false); + + useEffect(() => { + setIconFailed(false); + }, [iconUrl]); + + const shellClass = + size === 'lg' ? 'h-12 w-12 rounded-xl' : 'h-11 w-11 rounded-xl'; + const imageClass = size === 'lg' ? 'h-7 w-7' : 'h-7 w-7'; + const fallbackClass = size === 'lg' ? 'h-6 w-6' : 'h-5 w-5'; + + return ( +
+ {iconUrl && !iconFailed ? ( + setIconFailed(true)} + /> + ) : ( + + )} +
+ ); +} + +export default function ConnectorGateway() { + const { t } = useTranslation(); + const capabilities = useServerCapabilityStore((state) => state.capabilities); + const capabilityStatus = useServerCapabilityStore((state) => state.status); + const fetchCapabilities = useServerCapabilityStore( + (state) => state.fetchCapabilities + ); + const [providers, setProviders] = useState([]); + const [providerCount, setProviderCount] = useState(0); + const [filteredCount, setFilteredCount] = useState(0); + const [connectedCount, setConnectedCount] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(CONNECTOR_PAGE_SIZE); + const [totalPages, setTotalPages] = useState(1); + const [loadingProviders, setLoadingProviders] = useState(false); + const [providerError, setProviderError] = useState(null); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [selectedProvider, setSelectedProvider] = + useState(null); + const [selectedDetail, setSelectedDetail] = + useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + const [selectedAuthType, setSelectedAuthType] = useState(null); + const [values, setValues] = useState>({}); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + + const connectorGatewayEnabled = + capabilities.features.connector_gateway.enabled === true; + + useEffect(() => { + void fetchCapabilities(); + }, [fetchCapabilities]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedQuery(query.trim()); + }, 250); + return () => window.clearTimeout(timer); + }, [query]); + + const refreshProviders = useCallback( + async (targetPage: number = page) => { + if (!connectorGatewayEnabled) return; + setLoadingProviders(true); + setProviderError(null); + try { + const response = await fetchConnectorProviders({ + page: targetPage, + pageSize, + query: debouncedQuery, + }); + setProviders(response.providers); + setProviderCount(response.provider_count); + setFilteredCount(response.filtered_count); + setConnectedCount(response.connected_count); + setPageSize(response.page_size); + setTotalPages(response.total_pages); + if (response.page !== targetPage) { + setPage(response.page); + } + } catch (error: any) { + setProviderError( + error?.message || + t('setting.connector-gateway-load-failed', { + defaultValue: 'Failed to load Connector Gateway providers', + }) + ); + setProviders([]); + setProviderCount(0); + setFilteredCount(0); + setConnectedCount(0); + setTotalPages(1); + } finally { + setLoadingProviders(false); + } + }, + [connectorGatewayEnabled, debouncedQuery, page, pageSize, t] + ); + + useEffect(() => { + if (connectorGatewayEnabled) { + void refreshProviders(page); + } + }, [connectorGatewayEnabled, debouncedQuery, page, refreshProviders]); + + const selected = selectedDetail || selectedProvider; + const selectedAuth = useMemo( + () => + authDefinitions(selected).find((auth) => auth.type === selectedAuthType), + [selected, selectedAuthType] + ); + const selectedFields = useMemo( + () => credentialFieldsFor(selectedAuth), + [selectedAuth] + ); + const selectedConnected = isConnected(selected); + const authOptions = authDefinitions(selected); + const selectedActions = selected?.actions || []; + const canSave = + Boolean(selected && selectedAuth) && + (selectedAuth?.type === 'oauth2' || + selectedAuth?.type === 'no_auth' || + selectedFields.every((field) => !field.required || values[field.key])); + const pageStart = filteredCount === 0 ? 0 : (page - 1) * pageSize + 1; + const pageEnd = Math.min(page * pageSize, filteredCount); + const showPagination = filteredCount > pageSize; + const canGoPrevious = page > 1 && !loadingProviders; + const canGoNext = page < totalPages && !loadingProviders; + const initialProvidersLoading = + (capabilityStatus === 'loading' || loadingProviders) && + providers.length === 0; + const listRefreshing = loadingProviders && providers.length > 0; + const pagedGridMinHeightClass = showPagination + ? 'min-h-[1872px] md:min-h-[936px] xl:min-h-[624px]' + : ''; + const skeletonItems = Array.from({ length: pageSize || CONNECTOR_PAGE_SIZE }); + + const openProvider = useCallback((provider: ConnectorProvider) => { + setSelectedProvider(provider); + setSelectedDetail(null); + setSelectedAuthType(preferredAuthType(provider)); + setValues({}); + setFormError(null); + }, []); + + const closeProvider = useCallback(() => { + setSelectedProvider(null); + setSelectedDetail(null); + setSelectedAuthType(null); + setValues({}); + setFormError(null); + }, []); + + useEffect(() => { + if (!selectedProvider) return; + let cancelled = false; + setLoadingDetail(true); + void fetchConnectorProvider(selectedProvider.service) + .then((response) => { + if (cancelled) return; + setSelectedDetail(response.provider); + setSelectedAuthType(preferredAuthType(response.provider)); + setProviders((current) => + updateProviderInList(current, response.provider) + ); + }) + .catch((error: any) => { + if (cancelled) return; + setFormError(error?.message || 'Failed to load connector details'); + }) + .finally(() => { + if (!cancelled) setLoadingDetail(false); + }); + return () => { + cancelled = true; + }; + }, [selectedProvider]); + + const refreshSelectedProvider = useCallback(async () => { + if (!selected) return; + const response = await fetchConnectorProvider(selected.service); + setSelectedDetail(response.provider); + setProviders((current) => updateProviderInList(current, response.provider)); + }, [selected]); + + const saveConnection = useCallback(async () => { + if (!selected || !selectedAuth) return; + if (selectedAuth.type === 'oauth2') { + setSaving(true); + setFormError(null); + try { + const authorization = await createConnectorOAuthAuthorization( + selected.service, + selected.connection?.connectionName + ); + if (!authorization.authorizationUrl) { + throw new Error( + 'Connector Gateway did not return an authorization URL' + ); + } + window.open( + authorization.authorizationUrl, + 'eigent_connector_oauth', + 'popup=yes,width=720,height=760,menubar=no,toolbar=no,location=yes,status=no' + ); + toast.success( + t('setting.connector-gateway-oauth-started', { + defaultValue: 'OAuth authorization started', + }) + ); + } catch (error: any) { + setFormError(error?.message || 'Failed to start OAuth authorization'); + } finally { + setSaving(false); + } + return; + } + + setSaving(true); + setFormError(null); + try { + await connectProvider(selected.service, { + auth_type: selectedAuth.type, + values: + selectedAuth.type === 'no_auth' + ? {} + : selectedFields.reduce>((acc, field) => { + acc[field.key] = values[field.key] || ''; + return acc; + }, {}), + }); + await refreshSelectedProvider(); + setPage(1); + await refreshProviders(1); + toast.success( + t('setting.connector-gateway-saved', { + defaultValue: 'Connector saved', + }) + ); + } catch (error: any) { + setFormError(error?.message || 'Failed to save connector'); + } finally { + setSaving(false); + } + }, [ + refreshProviders, + refreshSelectedProvider, + selected, + selectedAuth, + selectedFields, + t, + values, + ]); + + const removeConnection = useCallback(async () => { + if (!selected?.connection) return; + setSaving(true); + setFormError(null); + try { + await disconnectProvider( + selected.service, + selected.connection.connectionName + ); + await refreshSelectedProvider(); + setPage(1); + await refreshProviders(1); + toast.success( + t('setting.connector-gateway-disconnected', { + defaultValue: 'Connector disconnected', + }) + ); + } catch (error: any) { + setFormError(error?.message || 'Failed to disconnect connector'); + } finally { + setSaving(false); + } + }, [refreshProviders, refreshSelectedProvider, selected, t]); + + if (!connectorGatewayEnabled) { + return null; + } + + return ( + <> +
+
+
+
+
+ +
+
+
+ Connector Gateway +
+
+ {providerCount || providers.length} connectors + {connectedCount > 0 ? ` | ${connectedCount} connected` : ''} +
+
+
+
+ { + setQuery(event.target.value); + setPage(1); + }} + placeholder={t('setting.search-mcp')} + /> + + + +
+
+ + {initialProvidersLoading ? ( +
+ {skeletonItems.map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : providerError ? ( +
+ {providerError} +
+ ) : providers.length === 0 ? ( +
+ {t('setting.no-connectors-found', { + defaultValue: 'No connectors found', + })} +
+ ) : ( + <> +
+
+ {providers.map((provider) => { + const connected = isConnected(provider); + return ( + + ); + })} +
+ {listRefreshing ? ( +
+
+ + {t('setting.loading')} +
+
+ ) : null} +
+ {showPagination ? ( +
+
+ Showing {pageStart}-{pageEnd} of {filteredCount} + {debouncedQuery ? ' results' : ' connectors'} +
+
+ +
+ {page} / {totalPages} +
+ +
+
+ ) : null} + + )} +
+
+ + { + if (!open) closeProvider(); + }} + > + + +
+ +
+ + {selected ? providerLabel(selected) : 'Connector'} + + + {selected?.description || + `${providerActionCountOrZero(selected)} actions`} + +
+
+
+ +
+ {loadingDetail ? ( +
+
+
+
+
+ ) : selected ? ( + <> +
+ {selectedConnected ? ( + + + Connected + + ) : null} + {selected.recommended ? ( + + + Popular + + ) : null} + + {providerActionCount(selected)} actions + + {selected.homepageUrl ? ( + + Website + + + ) : null} +
+ + {selectedActions.length > 0 ? ( +
+
+
+ + Actions +
+ + {selectedActions.length} + +
+
+ {selectedActions.map((action, index) => ( +
+
+ {actionLabel(action)} +
+ {action.id && action.id !== action.name ? ( +
+ {action.id} +
+ ) : null} + {action.description ? ( +
+ {action.description} +
+ ) : null} +
+ ))} +
+
+ ) : null} + + {authOptions.length > 1 ? ( +
+
+ Authentication +
+
+ {authOptions.map((auth) => ( + + ))} +
+
+ ) : null} + + {selectedAuth?.type === 'oauth2' ? ( +
+
+ Connect with OAuth +
+ Eigent will open the provider authorization page through + Connector Gateway. After authorization completes, refresh + this connector to see the connected account. + {selectedAuth.scopes?.length ? ( +
+ Scopes: {selectedAuth.scopes.join(', ')} +
+ ) : null} +
+ ) : selectedAuth?.type === 'no_auth' ? ( +
+ + This connector does not require credentials. +
+ ) : selectedFields.length > 0 ? ( +
+ {selectedFields.map((field) => + field.inputType === 'textarea' || + field.inputType === 'json' ? ( +