mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-04 05:50:00 +00:00
feat: add Connector Gateway UI and MCP wiring
This commit is contained in:
parent
93d645fc6b
commit
513dd2f106
15 changed files with 1438 additions and 13 deletions
|
|
@ -25,7 +25,10 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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 @@ from app.model.chat import Chat
|
|||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ from camel.messages import BaseMessage
|
|||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,29 @@ def build_remote_sub_agent_planning_notice() -> str:
|
|||
return REMOTE_SUB_AGENT_PLANNING_NOTICE
|
||||
|
||||
|
||||
CONNECTED_APP_MCP_NOTICE = """\
|
||||
<connected_app_tools>
|
||||
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.
|
||||
</connected_app_tools>
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ from app.agent.factory.remote_sub_agent import (
|
|||
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 @@ The current date is {datetime.date.today()}. \
|
|||
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
|
||||
|
|
|
|||
221
src/api/connectors.ts
Normal file
221
src/api/connectors.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<ConnectorProvidersResponse> {
|
||||
const params: Record<string, string | number> = {
|
||||
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<ConnectorProviderResponse> {
|
||||
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<ConnectorOAuthAuthorization> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
904
src/pages/Connectors/ConnectorGateway.tsx
Normal file
904
src/pages/Connectors/ConnectorGateway.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={`flex shrink-0 items-center justify-center border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default ${shellClass}`}
|
||||
>
|
||||
{iconUrl && !iconFailed ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
className={`${imageClass} object-contain`}
|
||||
onError={() => setIconFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<PlugZap
|
||||
className={`${fallbackClass} text-ds-icon-neutral-muted-default`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ConnectorProvider[]>([]);
|
||||
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<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<ConnectorProvider | null>(null);
|
||||
const [selectedDetail, setSelectedDetail] =
|
||||
useState<ConnectorProvider | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [selectedAuthType, setSelectedAuthType] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(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<Record<string, string>>((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 (
|
||||
<>
|
||||
<div className="mx-auto flex w-full flex-col px-6 pt-6">
|
||||
<div className="rounded-2xl bg-ds-bg-neutral-default-default px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-x-0 border-b-[0.5px] border-t-0 border-solid border-ds-border-neutral-default-default px-3 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-ds-bg-neutral-subtle-default">
|
||||
<PlugZap className="h-5 w-5 text-ds-icon-neutral-default-default" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-body-base truncate font-bold text-ds-text-neutral-default-default">
|
||||
Connector Gateway
|
||||
</div>
|
||||
<div className="text-body-xs text-ds-text-neutral-muted-default">
|
||||
{providerCount || providers.length} connectors
|
||||
{connectedCount > 0 ? ` | ${connectedCount} connected` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput
|
||||
variant="icon"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t('setting.search-mcp')}
|
||||
/>
|
||||
<TooltipSimple content={t('setting.refresh')}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
disabled={loadingProviders}
|
||||
aria-label={t('setting.refresh')}
|
||||
onClick={() => void refreshProviders(page)}
|
||||
>
|
||||
{loadingProviders ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initialProvidersLoading ? (
|
||||
<div className="mx-3 my-4 grid grid-cols-1 overflow-hidden rounded-xl border border-solid border-ds-border-neutral-default-default md:grid-cols-2 xl:grid-cols-3">
|
||||
{skeletonItems.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-h-[78px] items-center gap-3 border-x-0 border-b border-r border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default px-4 py-3"
|
||||
>
|
||||
<div className="h-11 w-11 shrink-0 rounded-xl bg-ds-bg-neutral-strong-default" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-3.5 w-32 rounded bg-ds-bg-neutral-strong-default" />
|
||||
<div className="h-2.5 w-20 rounded bg-ds-bg-neutral-strong-default" />
|
||||
</div>
|
||||
<div className="h-3 w-14 rounded bg-ds-bg-neutral-strong-default" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : providerError ? (
|
||||
<div className="px-3 py-4 text-body-sm text-ds-text-error-default-default">
|
||||
{providerError}
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="px-3 py-4 text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('setting.no-connectors-found', {
|
||||
defaultValue: 'No connectors found',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative mx-3 my-4">
|
||||
<div
|
||||
className={`grid grid-cols-1 overflow-hidden rounded-xl border border-solid border-ds-border-neutral-default-default transition-opacity md:grid-cols-2 xl:grid-cols-3 ${
|
||||
listRefreshing ? 'opacity-60' : 'opacity-100'
|
||||
} ${pagedGridMinHeightClass}`}
|
||||
>
|
||||
{providers.map((provider) => {
|
||||
const connected = isConnected(provider);
|
||||
return (
|
||||
<button
|
||||
key={provider.service}
|
||||
type="button"
|
||||
onClick={() => openProvider(provider)}
|
||||
disabled={loadingProviders}
|
||||
className="group flex min-h-[78px] w-full items-center gap-3 border-x-0 border-b border-r border-t-0 border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default px-4 py-3 text-left transition-colors hover:bg-ds-bg-neutral-default-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus"
|
||||
>
|
||||
<ProviderIcon provider={provider} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{providerLabel(provider)}
|
||||
</span>
|
||||
{connected ? (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-ds-text-success-default-default" />
|
||||
) : provider.recommended ? (
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-ds-icon-brand-default-default" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-label-sm font-bold text-ds-text-neutral-muted-default">
|
||||
{connected
|
||||
? t('setting.connected', {
|
||||
defaultValue: 'Connected',
|
||||
})
|
||||
: t('setting.connect', {
|
||||
defaultValue: 'Connect',
|
||||
})}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-ds-icon-neutral-muted-default transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{listRefreshing ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-start justify-end p-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-2 text-body-xs font-bold text-ds-text-neutral-muted-default shadow-sm">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('setting.loading')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showPagination ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-x-0 border-b-0 border-t-[0.5px] border-solid border-ds-border-neutral-default-default px-3 py-3">
|
||||
<div className="text-body-xs text-ds-text-neutral-muted-default">
|
||||
Showing {pageStart}-{pageEnd} of {filteredCount}
|
||||
{debouncedQuery ? ' results' : ' connectors'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canGoPrevious}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.max(1, current - 1))
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<div className="min-w-[88px] text-center text-body-xs text-ds-text-neutral-muted-default">
|
||||
{page} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canGoNext}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={Boolean(selectedProvider)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeProvider();
|
||||
}}
|
||||
>
|
||||
<SheetContent className="flex !w-[520px] !max-w-[calc(100vw-24px)] flex-col gap-0 p-0 sm:!max-w-[520px]">
|
||||
<SheetHeader className="border-x-0 border-b border-t-0 border-solid border-ds-border-neutral-default-default p-6 pr-12 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<ProviderIcon provider={selected} size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="text-heading-xs truncate">
|
||||
{selected ? providerLabel(selected) : 'Connector'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-1 line-clamp-2">
|
||||
{selected?.description ||
|
||||
`${providerActionCountOrZero(selected)} actions`}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto p-6">
|
||||
{loadingDetail ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-10 rounded-xl bg-ds-bg-neutral-strong-default" />
|
||||
<div className="h-28 rounded-xl bg-ds-bg-neutral-strong-default" />
|
||||
<div className="h-10 rounded-xl bg-ds-bg-neutral-strong-default" />
|
||||
</div>
|
||||
) : selected ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedConnected ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
tone="success"
|
||||
emphasis="subtle"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : null}
|
||||
{selected.recommended ? (
|
||||
<Badge size="sm" variant="secondary" emphasis="subtle">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Popular
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge size="sm" variant="outline">
|
||||
{providerActionCount(selected)} actions
|
||||
</Badge>
|
||||
{selected.homepageUrl ? (
|
||||
<a
|
||||
href={selected.homepageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-label-sm text-ds-text-neutral-muted-default underline-offset-2 hover:underline"
|
||||
>
|
||||
Website
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedActions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
<ListChecks className="h-4 w-4 shrink-0 text-ds-icon-neutral-default-default" />
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-body-xs text-ds-text-neutral-muted-default">
|
||||
{selectedActions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[280px] overflow-y-auto rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default">
|
||||
{selectedActions.map((action, index) => (
|
||||
<div
|
||||
key={action.id || action.name || index}
|
||||
className={`px-4 py-3 ${
|
||||
index < selectedActions.length - 1
|
||||
? 'border-x-0 border-b border-t-0 border-solid border-ds-border-neutral-default-default'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
{actionLabel(action)}
|
||||
</div>
|
||||
{action.id && action.id !== action.name ? (
|
||||
<div className="mt-0.5 truncate text-body-xs text-ds-text-neutral-muted-default">
|
||||
{action.id}
|
||||
</div>
|
||||
) : null}
|
||||
{action.description ? (
|
||||
<div className="mt-1 text-body-xs leading-5 text-ds-text-neutral-muted-default">
|
||||
{action.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authOptions.length > 1 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
|
||||
Authentication
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{authOptions.map((auth) => (
|
||||
<button
|
||||
key={auth.type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedAuthType(auth.type);
|
||||
setValues({});
|
||||
setFormError(null);
|
||||
}}
|
||||
className={`rounded-lg border border-solid px-3 py-1.5 text-label-sm transition-colors ${
|
||||
selectedAuthType === auth.type
|
||||
? 'border-ds-border-brand-default-default bg-ds-bg-brand-default-default text-ds-text-brand-inverse-default'
|
||||
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-subtle-default'
|
||||
}`}
|
||||
>
|
||||
{authLabel(auth.type)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAuth?.type === 'oauth2' ? (
|
||||
<div className="rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-4 text-body-sm text-ds-text-neutral-muted-default">
|
||||
<div className="mb-2 font-bold text-ds-text-neutral-default-default">
|
||||
Connect with OAuth
|
||||
</div>
|
||||
Eigent will open the provider authorization page through
|
||||
Connector Gateway. After authorization completes, refresh
|
||||
this connector to see the connected account.
|
||||
{selectedAuth.scopes?.length ? (
|
||||
<div className="mt-3 line-clamp-3 text-body-xs">
|
||||
Scopes: {selectedAuth.scopes.join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : selectedAuth?.type === 'no_auth' ? (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-4 text-body-sm text-ds-text-neutral-muted-default">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-ds-icon-neutral-default-default" />
|
||||
This connector does not require credentials.
|
||||
</div>
|
||||
) : selectedFields.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{selectedFields.map((field) =>
|
||||
field.inputType === 'textarea' ||
|
||||
field.inputType === 'json' ? (
|
||||
<Textarea
|
||||
key={field.key}
|
||||
variant="enhanced"
|
||||
title={field.label}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder || undefined}
|
||||
note={field.description || undefined}
|
||||
value={values[field.key] || ''}
|
||||
onChange={(event) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[92px]"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
key={field.key}
|
||||
title={field.label}
|
||||
required={field.required}
|
||||
type={field.secret ? 'password' : 'text'}
|
||||
placeholder={field.placeholder || undefined}
|
||||
note={field.description || undefined}
|
||||
leadingIcon={<KeyRound className="h-4 w-4" />}
|
||||
value={values[field.key] || ''}
|
||||
onChange={(event) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-4 text-body-sm text-ds-text-neutral-muted-default">
|
||||
No credential fields are declared for this authentication
|
||||
type.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError ? (
|
||||
<div className="rounded-xl bg-ds-bg-error-subtle-default p-3 text-body-sm text-ds-text-error-strong-default">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-x-0 border-b-0 border-t border-solid border-ds-border-neutral-default-default p-4">
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{selectedConnected ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
tone="error"
|
||||
disabled={saving}
|
||||
onClick={() => void removeConnection()}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={saving}
|
||||
onClick={closeProvider}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canSave || saving}
|
||||
onClick={() => void saveConnection()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : selectedAuth?.type === 'oauth2' ? (
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
{selectedAuth?.type === 'oauth2'
|
||||
? selectedConnected
|
||||
? 'Reconnect OAuth'
|
||||
: 'Connect OAuth'
|
||||
: selectedConnected
|
||||
? 'Save'
|
||||
: 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,11 +12,13 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import ConnectorGateway from './ConnectorGateway';
|
||||
import MCP from './MCP';
|
||||
|
||||
export default function Connectors() {
|
||||
return (
|
||||
<div className="flex h-auto w-full flex-1 flex-col">
|
||||
<ConnectorGateway />
|
||||
<MCP />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -57,9 +57,11 @@ import { getAuthStore, getWorkerList } from './authStore';
|
|||
import { getCloudModelStore } from './cloudModelStore';
|
||||
import { usePageTabStore } from './pageTabStore';
|
||||
import { useProjectStore } from './projectStore';
|
||||
import { getServerCapabilityStore } from './serverCapabilityStore';
|
||||
import { legacySpaceIdForUser, useSpaceStore } from './spaceStore';
|
||||
|
||||
const API_CODE_TRIAL_LIMIT = '22';
|
||||
const CONNECTOR_GATEWAY_MCP_NAME = 'connector_gateway';
|
||||
const PROJECT_CONTEXT_MAX_CHARS = 24_000;
|
||||
const PROJECT_CONTEXT_MAX_RUNS = 8;
|
||||
// chat_history.summary is a bounded database column; an over-long value
|
||||
|
|
@ -196,6 +198,84 @@ function getDirectServerApiBaseUrl(): string | undefined {
|
|||
return normalizeServerApiBaseUrl(import.meta.env.VITE_BASE_URL);
|
||||
}
|
||||
|
||||
function hasMcpServers(config: any): boolean {
|
||||
return Boolean(
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
config.mcpServers &&
|
||||
typeof config.mcpServers === 'object' &&
|
||||
Object.keys(config.mcpServers).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMcpConfigs(...configs: any[]): {
|
||||
mcpServers: Record<string, any>;
|
||||
} {
|
||||
const mcpServers: Record<string, any> = {};
|
||||
configs.forEach((config) => {
|
||||
if (!hasMcpServers(config)) {
|
||||
return;
|
||||
}
|
||||
Object.assign(mcpServers, config.mcpServers);
|
||||
});
|
||||
return { mcpServers };
|
||||
}
|
||||
|
||||
async function buildConnectorGatewayMcpConfig(
|
||||
token?: string | null
|
||||
): Promise<{ mcpServers: Record<string, any> } | null> {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let connectorGatewayEnabled = false;
|
||||
|
||||
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
|
||||
// The Connectors page can stay visible in local debug mode, but chat
|
||||
// should only mount the MCP server when eigent_server confirms it is
|
||||
// actually configured. Otherwise CAMEL tries to connect to a disabled
|
||||
// endpoint and connector tools silently disappear.
|
||||
const capabilities = await proxyFetchGet('/api/v1/server/capabilities');
|
||||
connectorGatewayEnabled =
|
||||
capabilities?.features?.connector_gateway?.enabled === true;
|
||||
} else {
|
||||
const capabilities =
|
||||
await getServerCapabilityStore().fetchCapabilities(false);
|
||||
connectorGatewayEnabled =
|
||||
capabilities.features.connector_gateway.enabled === true;
|
||||
}
|
||||
|
||||
if (!connectorGatewayEnabled) {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to resolve Connector Gateway capability for MCP:',
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverApiBaseUrl = getDirectServerApiBaseUrl();
|
||||
if (!serverApiBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mcpServers: {
|
||||
[CONNECTOR_GATEWAY_MCP_NAME]: {
|
||||
type: 'streamable_http',
|
||||
url: `${serverApiBaseUrl}/connectors/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
timeout: 180,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getHostElectronAPI() {
|
||||
return _host?.electronAPI ?? null;
|
||||
}
|
||||
|
|
@ -1831,6 +1911,10 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
}
|
||||
}
|
||||
|
||||
const connectorGatewayMcpConfig = !type
|
||||
? await buildConnectorGatewayMcpConfig(token)
|
||||
: null;
|
||||
|
||||
const addWorkers = workerList.map((worker) => {
|
||||
const providerId = worker.workerInfo?.model_provider_id;
|
||||
const provider = Number.isInteger(providerId)
|
||||
|
|
@ -1840,7 +1924,10 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
name: worker.workerInfo?.name,
|
||||
description: worker.workerInfo?.description,
|
||||
tools: worker.workerInfo?.tools,
|
||||
mcp_tools: worker.workerInfo?.mcp_tools,
|
||||
mcp_tools: mergeMcpConfigs(
|
||||
worker.workerInfo?.mcp_tools,
|
||||
connectorGatewayMcpConfig
|
||||
),
|
||||
custom_model_config: provider
|
||||
? buildAgentModelConfigFromProvider(provider)
|
||||
: undefined,
|
||||
|
|
@ -2000,7 +2087,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
model_config_dict: apiModel.model_config_dict,
|
||||
extra_params: apiModel.extra_params,
|
||||
auth_source: apiModel.auth_source,
|
||||
installed_mcp: { mcpServers: {} },
|
||||
installed_mcp: connectorGatewayMcpConfig || { mcpServers: {} },
|
||||
language: systemLanguage,
|
||||
allow_local_system: true,
|
||||
attaches: (
|
||||
|
|
|
|||
153
src/store/serverCapabilityStore.ts
Normal file
153
src/store/serverCapabilityStore.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// ========= 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 { proxyFetchGet } from '@/api/http';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const CAPABILITY_CACHE_TTL_MS = 60 * 1000;
|
||||
let capabilityRefreshPromise: Promise<ServerCapabilities> | null = null;
|
||||
|
||||
export interface FeatureCapability {
|
||||
enabled: boolean;
|
||||
provider?: string | null;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface ServerCapabilities {
|
||||
features: {
|
||||
connector_gateway: FeatureCapability;
|
||||
};
|
||||
}
|
||||
|
||||
type CapabilityStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
interface ServerCapabilityState {
|
||||
capabilities: ServerCapabilities;
|
||||
status: CapabilityStatus;
|
||||
lastFetchedAt: number;
|
||||
error: string | null;
|
||||
fetchCapabilities: (force?: boolean) => Promise<ServerCapabilities>;
|
||||
isConnectorGatewayEnabled: () => boolean;
|
||||
}
|
||||
|
||||
const disabledCapabilities = (reason: string): ServerCapabilities => ({
|
||||
features: {
|
||||
connector_gateway: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeCapabilities(raw: unknown): ServerCapabilities {
|
||||
const input = raw as Partial<ServerCapabilities> | undefined;
|
||||
const connectorGateway = input?.features?.connector_gateway;
|
||||
return {
|
||||
features: {
|
||||
connector_gateway: {
|
||||
enabled: connectorGateway?.enabled === true,
|
||||
provider:
|
||||
typeof connectorGateway?.provider === 'string'
|
||||
? connectorGateway.provider
|
||||
: null,
|
||||
reason:
|
||||
typeof connectorGateway?.reason === 'string'
|
||||
? connectorGateway.reason
|
||||
: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useServerCapabilityStore = create<ServerCapabilityState>()(
|
||||
(set, get) => ({
|
||||
capabilities: disabledCapabilities('not_loaded'),
|
||||
status: 'idle',
|
||||
lastFetchedAt: 0,
|
||||
error: null,
|
||||
|
||||
fetchCapabilities: async (force = false) => {
|
||||
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
|
||||
// Local debug: keep Connector Gateway visible while using local auto-login.
|
||||
// Before release, switch this branch back to disabledCapabilities('local_proxy')
|
||||
// so fully-local self-hosted users do not request Eigent server connectors.
|
||||
const localCapabilities: ServerCapabilities = {
|
||||
features: {
|
||||
connector_gateway: {
|
||||
enabled: true,
|
||||
provider: 'connector_gateway',
|
||||
reason: 'local_debug',
|
||||
},
|
||||
},
|
||||
};
|
||||
set({
|
||||
capabilities: localCapabilities,
|
||||
status: 'ready',
|
||||
lastFetchedAt: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
return localCapabilities;
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
state.status === 'ready' &&
|
||||
now - state.lastFetchedAt < CAPABILITY_CACHE_TTL_MS
|
||||
) {
|
||||
return state.capabilities;
|
||||
}
|
||||
if (capabilityRefreshPromise) {
|
||||
return capabilityRefreshPromise;
|
||||
}
|
||||
|
||||
capabilityRefreshPromise = (async () => {
|
||||
set({ status: 'loading', error: null });
|
||||
try {
|
||||
const capabilities = normalizeCapabilities(
|
||||
await proxyFetchGet('/api/v1/server/capabilities')
|
||||
);
|
||||
set({
|
||||
capabilities,
|
||||
status: 'ready',
|
||||
lastFetchedAt: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
return capabilities;
|
||||
} catch (error: any) {
|
||||
const fallback = disabledCapabilities('capability_request_failed');
|
||||
set({
|
||||
capabilities: fallback,
|
||||
status: 'error',
|
||||
lastFetchedAt: Date.now(),
|
||||
error: error?.message || 'Failed to load server capabilities',
|
||||
});
|
||||
return fallback;
|
||||
} finally {
|
||||
capabilityRefreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return capabilityRefreshPromise;
|
||||
},
|
||||
|
||||
isConnectorGatewayEnabled: () =>
|
||||
get().capabilities.features.connector_gateway.enabled === true,
|
||||
})
|
||||
);
|
||||
|
||||
export const getServerCapabilityStore = () =>
|
||||
useServerCapabilityStore.getState();
|
||||
Loading…
Add table
Add a link
Reference in a new issue