diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index f3dc9ad3fc..4e1504cf2c 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -19,6 +19,7 @@ import { AppearanceConfig, ArchivedSessionsConfig, BasicsConfig, + ConnectorsConfig, EditorConfig, ExternalSourcesConfig, HooksConfig, @@ -70,6 +71,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { case 'external-sources': return ExternalSourcesConfig; case 'hooks': return HooksConfig; case 'acp-agents': return AcpAgentsConfig; + case 'connectors': return ConnectorsConfig; case 'editor': return EditorConfig; case 'keyboard': return KeyboardShortcutsTab; default: return null; diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index 88cd3c6a3a..2953cf30ac 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -21,6 +21,7 @@ export type ConfigTab = | 'external-sources' | 'hooks' | 'acp-agents' + | 'connectors' // | 'lsp' // temporarily hidden from config center | 'editor' | 'keyboard'; @@ -286,6 +287,24 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'stdio', ], }, + { + id: 'connectors', + labelKey: 'configCenter.tabs.connectors', + descriptionKey: 'configCenter.tabDescriptions.connectors', + beta: true, + keywords: [ + 'connector', + 'connectors', + 'integration', + 'dingtalk', + 'dingding', + 'xiaoshouyi', + 'neocrm', + 'saas', + 'api', + 'workbuddy', + ], + }, ], }, { diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index 3882c074e0..6b7dd19e11 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -18,6 +18,7 @@ const loadArchivedSessionsConfig = () => import('./components/ArchivedSessionsCo const loadWorktreesConfig = () => import('../../../infrastructure/config/components/WorktreesConfig'); const loadKeyboardShortcutsTab = () => import('./components/KeyboardShortcutsTab'); const loadSessionConfig = () => import('../../../infrastructure/config/components/SessionConfig'); +const loadConnectorsConfig = () => import('../../../infrastructure/config/components/ConnectorsConfig'); export const AIModelConfig = lazy(loadAIModelConfig); export const McpToolsConfig = lazy(loadMcpToolsConfig); @@ -34,6 +35,7 @@ export const VoiceInputConfig = lazy(loadVoiceInputConfig); export const ArchivedSessionsConfig = lazy(loadArchivedSessionsConfig); export const WorktreesConfig = lazy(loadWorktreesConfig); export const KeyboardShortcutsTab = lazy(loadKeyboardShortcutsTab); +export const ConnectorsConfig = lazy(loadConnectorsConfig); export const SessionPersonalizationConfig = lazy(() => loadSessionConfig().then((module) => ({ default: module.SessionPersonalizationConfig, @@ -61,6 +63,7 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise 'external-sources': loadExternalSourcesConfig, hooks: loadHooksConfig, 'acp-agents': loadAcpAgentsConfig, + connectors: loadConnectorsConfig, editor: loadEditorConfig, keyboard: loadKeyboardShortcutsTab, }; diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts index 59cf070804..c068d06a1c 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts @@ -36,6 +36,7 @@ export const SETTINGS_TAB_I18N_NAMESPACES: Record; + lastSyncedAt?: string; + errorMessage?: string; +} + +export interface ConnectorListResponse { + connectors: ConnectorInstance[]; +} + +export interface ConnectorTestResult { + success: boolean; + message: string; +} + +export const CONNECTOR_PROVIDERS: readonly ConnectorProviderDefinition[] = [ + { + type: 'dingtalk', + displayName: 'DingTalk', + description: 'Connect to DingTalk (钉钉) for messages, contacts, and approval data.', + iconKey: 'dingtalk', + docsUrl: 'https://open.dingtalk.com', + supportedCapabilities: ['messages', 'contacts', 'approvals', 'calendar'], + }, + { + type: 'xiaoshouyi', + displayName: 'XiaoShouYi (Neocrm)', + description: 'Connect to XiaoShouYi (销售易) for CRM data, leads, and customer insights.', + iconKey: 'xiaoshouyi', + docsUrl: 'https://www.xiaoshouyi.com', + supportedCapabilities: ['leads', 'customers', 'opportunities', 'reports'], + }, +]; + +export const CONNECTOR_PROVIDER_CONFIGS: Record = { + dingtalk: { + type: 'dingtalk', + fields: [ + { + key: 'appKey', + label: 'App Key', + type: 'string', + required: true, + placeholder: 'dingxxxxx', + helpText: 'DingTalk application App Key from the open platform.', + }, + { + key: 'appSecret', + label: 'App Secret', + type: 'secret', + required: true, + placeholder: 'Enter App Secret', + helpText: 'DingTalk application App Secret.', + }, + { + key: 'baseUrl', + label: 'API Base URL', + type: 'url', + required: false, + placeholder: 'https://oapi.dingtalk.com', + helpText: 'Override the default DingTalk API endpoint.', + }, + ], + }, + xiaoshouyi: { + type: 'xiaoshouyi', + fields: [ + { + key: 'clientId', + label: 'Client ID', + type: 'string', + required: true, + placeholder: 'Enter Client ID', + helpText: 'XiaoShouYi API Client ID.', + }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'secret', + required: true, + placeholder: 'Enter Client Secret', + helpText: 'XiaoShouYi API Client Secret.', + }, + { + key: 'baseUrl', + label: 'API Base URL', + type: 'url', + required: false, + placeholder: 'https://api.xiaoshouyi.com', + helpText: 'Override the default XiaoShouYi API endpoint.', + }, + ], + }, + custom: { + type: 'custom', + fields: [ + { + key: 'baseUrl', + label: 'API Base URL', + type: 'url', + required: true, + placeholder: 'https://api.example.com', + helpText: 'Custom connector API base URL.', + }, + { + key: 'apiKey', + label: 'API Key', + type: 'secret', + required: true, + placeholder: 'Enter API Key', + helpText: 'Authentication key for the custom connector.', + }, + ], + }, +}; + +export const connectorsAPI = { + async listConnectors(): Promise { + return api.invoke('connectors_list', { request: {} }); + }, + + async createConnector( + provider: ConnectorProviderType, + name: string, + config: Record, + ): Promise { + return api.invoke('connectors_create', { + request: { provider, name, config }, + }); + }, + + async updateConnector( + id: string, + config: Record, + ): Promise { + return api.invoke('connectors_update', { + request: { id, config }, + }); + }, + + async deleteConnector(id: string): Promise { + await api.invoke('connectors_delete', { request: { id } }); + }, + + async testConnector(id: string): Promise { + return api.invoke('connectors_test', { + request: { id }, + }); + }, + + async syncConnector(id: string): Promise { + return api.invoke('connectors_sync', { + request: { id }, + }); + }, +}; diff --git a/src/web-ui/src/infrastructure/config/components/ConnectorsConfig.tsx b/src/web-ui/src/infrastructure/config/components/ConnectorsConfig.tsx new file mode 100644 index 0000000000..b6bd75370b --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ConnectorsConfig.tsx @@ -0,0 +1,431 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CheckCircle2, + CircleDashed, + AlertTriangle, + Plug, + Plus, + Trash2, + RefreshCw, + TestTube2, + Loader2, +} from 'lucide-react'; +import { Button, ConfigPageLoading } from '@/component-library'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageRow, + ConfigPageSection, +} from './common'; +import { + connectorsAPI, + CONNECTOR_PROVIDERS, + CONNECTOR_PROVIDER_CONFIGS, + type ConnectorInstance, + type ConnectorProviderType, + type ConnectorConnectionStatus, +} from '@/infrastructure/api/service-api/ConnectorsAPI'; +import { createLogger } from '@/shared/utils/logger'; + +const logger = createLogger('ConnectorsConfig'); + +function statusIcon(status: ConnectorConnectionStatus): React.ReactNode { + switch (status) { + case 'connected': + return ; + case 'disconnected': + return ; + case 'error': + return ; + case 'pending': + return ; + default: + return null; + } +} + +function statusLabelKey(status: ConnectorConnectionStatus): string { + switch (status) { + case 'connected': + return 'connectors.status.connected'; + case 'disconnected': + return 'connectors.status.disconnected'; + case 'error': + return 'connectors.status.error'; + case 'pending': + return 'connectors.status.pending'; + default: + return 'connectors.status.disconnected'; + } +} + +const AddConnectorDialog: React.FC<{ + open: boolean; + onClose: () => void; + onAdd: (provider: ConnectorProviderType, name: string, config: Record) => void; + adding: boolean; +}> = ({ open, onClose, onAdd, adding }) => { + const { t } = useTranslation('settings/connectors'); + const [selectedProvider, setSelectedProvider] = useState('dingtalk'); + const [name, setName] = useState(''); + const [configValues, setConfigValues] = useState>({}); + + const providerConfig = CONNECTOR_PROVIDER_CONFIGS[selectedProvider]; + + const handleSubmit = useCallback(() => { + onAdd(selectedProvider, name || providerConfig.fields[0]?.label || 'Connector', configValues); + setName(''); + setConfigValues({}); + setSelectedProvider('dingtalk'); + }, [selectedProvider, name, configValues, onAdd, providerConfig]); + + const handleCancel = useCallback(() => { + setName(''); + setConfigValues({}); + setSelectedProvider('dingtalk'); + onClose(); + }, [onClose]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +

{t('connectors.addDialog.title')}

+ +
+ + +
+ +
+ + setName(e.target.value)} + /> +
+ + {providerConfig.fields.map((field) => ( +
+ + + setConfigValues((prev) => ({ ...prev, [field.key]: e.target.value })) + } + /> + {field.helpText && ( + {field.helpText} + )} +
+ ))} + +
+ + +
+
+
+ ); +}; + +const ConnectorCard: React.FC<{ + connector: ConnectorInstance; + onDelete: (id: string) => void; + onTest: (id: string) => void; + onSync: (id: string) => void; + deleting: string | null; + testing: string | null; + syncing: string | null; +}> = ({ connector, onDelete, onTest, onSync, deleting, testing, syncing }) => { + const { t } = useTranslation('settings/connectors'); + const providerDef = CONNECTOR_PROVIDERS.find((p) => p.type === connector.provider); + const isBusy = deleting === connector.id || testing === connector.id || syncing === connector.id; + + return ( +
+
+
+ +
+ {connector.name} + + {providerDef?.displayName ?? connector.provider} + +
+
+
+ {statusIcon(connector.status)} + + {t(statusLabelKey(connector.status))} + +
+
+ + {connector.errorMessage && ( +
+ + {connector.errorMessage} +
+ )} + + {connector.lastSyncedAt && ( +
+ {t('connectors.card.lastSynced')}: {connector.lastSyncedAt} +
+ )} + +
+ + + +
+
+ ); +}; + +const ConnectorsConfig: React.FC = () => { + const { t, ready } = useTranslation('settings/connectors'); + const [connectors, setConnectors] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddDialog, setShowAddDialog] = useState(false); + const [adding, setAdding] = useState(false); + const [deleting, setDeleting] = useState(null); + const [testing, setTesting] = useState(null); + const [syncing, setSyncing] = useState(null); + + const loadConnectors = useCallback(async () => { + setLoading(true); + try { + const response = await connectorsAPI.listConnectors(); + setConnectors(response.connectors); + } catch (err) { + logger.warn('Failed to load connectors', { error: String(err) }); + setConnectors([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadConnectors(); + }, [loadConnectors]); + + const handleAdd = useCallback( + async ( + provider: ConnectorProviderType, + name: string, + config: Record, + ) => { + setAdding(true); + try { + const instance = await connectorsAPI.createConnector(provider, name, config); + setConnectors((prev) => [...prev, instance]); + setShowAddDialog(false); + } catch (err) { + logger.error('Failed to create connector', { error: String(err) }); + } finally { + setAdding(false); + } + }, + [], + ); + + const handleDelete = useCallback(async (id: string) => { + setDeleting(id); + try { + await connectorsAPI.deleteConnector(id); + setConnectors((prev) => prev.filter((c) => c.id !== id)); + } catch (err) { + logger.error('Failed to delete connector', { error: String(err), id }); + } finally { + setDeleting(null); + } + }, []); + + const handleTest = useCallback(async (id: string) => { + setTesting(id); + try { + await connectorsAPI.testConnector(id); + } catch (err) { + logger.error('Connector test failed', { error: String(err), id }); + } finally { + setTesting(null); + } + }, []); + + const handleSync = useCallback(async (id: string) => { + setSyncing(id); + try { + const updated = await connectorsAPI.syncConnector(id); + setConnectors((prev) => prev.map((c) => (c.id === id ? updated : c))); + } catch (err) { + logger.error('Connector sync failed', { error: String(err), id }); + } finally { + setSyncing(null); + } + }, []); + + if (loading || !ready) { + return ; + } + + return ( + + + + + +
+

+ {t('connectors.section.configuredConnectors')} +

+ +
+
+ + {connectors.length === 0 ? ( + +
+ +

+ {t('connectors.emptyState.description')} +

+
+
+ ) : ( + +
+ {connectors.map((connector) => ( + + ))} +
+
+ )} +
+ + + +

+ {t('connectors.section.availableProviders')} +

+
+ +
+ {CONNECTOR_PROVIDERS.map((provider) => ( +
+
+ + {provider.displayName} + +
+

+ {provider.description} +

+ {provider.supportedCapabilities.length > 0 && ( +
+ {provider.supportedCapabilities.map((cap) => ( + + {cap} + + ))} +
+ )} +
+ ))} +
+
+
+ + setShowAddDialog(false)} + onAdd={handleAdd} + adding={adding} + /> +
+
+ ); +}; + +export default ConnectorsConfig; diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 80009a077d..270b22c099 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -29,6 +29,7 @@ "externalSources": "Load compatible commands and extensions from other AI applications.", "hooks": "Run your own commands at Agent lifecycle points. Codex-compatible.", "acpAgents": "External ACP agents such as opencode, Claude Code, and Codex.", + "connectors": "Connect to DingTalk, XiaoShouYi, and other SaaS platforms.", "editor": "Editor font, display, and formatting.", "lsp": "Language servers and code intelligence.", "keyboard": "Shortcuts and key bindings.", @@ -55,6 +56,7 @@ "externalSources": "External AI Apps", "hooks": "Agent Hooks", "acpAgents": "ACP Agents", + "connectors": "Connectors", "agents": "Agents", "editor": "Editor", "lsp": "LSP", diff --git a/src/web-ui/src/locales/en-US/settings/connectors.json b/src/web-ui/src/locales/en-US/settings/connectors.json new file mode 100644 index 0000000000..c84c51a8b1 --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/connectors.json @@ -0,0 +1,51 @@ +{ + "connectors": { + "title": "Connectors", + "description": "Connect to DingTalk, XiaoShouYi, and other SaaS platforms to let your AI agent fetch and analyze data.", + "section": { + "configuredConnectors": "Configured Connectors", + "availableProviders": "Available Providers" + }, + "actions": { + "add": "Add Connector", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "test": "Test", + "sync": "Sync" + }, + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "error": "Error", + "pending": "Pending" + }, + "card": { + "lastSynced": "Last synced", + "testing": "Testing…", + "syncing": "Syncing…", + "deleting": "Deleting…", + "test": "Test", + "sync": "Sync", + "delete": "Delete" + }, + "addDialog": { + "title": "Add Connector", + "provider": "Provider", + "name": "Connector Name", + "namePlaceholder": "e.g. Production DingTalk", + "add": "Add", + "adding": "Adding…", + "cancel": "Cancel" + }, + "emptyState": { + "title": "No connectors configured", + "description": "Add a connector to let your AI agent access data from DingTalk, XiaoShouYi, and other platforms." + }, + "provider": { + "dingtalk": "DingTalk", + "xiaoshouyi": "XiaoShouYi (Neocrm)", + "custom": "Custom" + } + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index c457d350f7..061ea9a633 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -50,6 +50,7 @@ "externalSources": "加载其他 AI 应用中兼容的命令与扩展。", "hooks": "在 Agent 生命周期节点运行你自己的命令,与 Codex Hooks 兼容。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", + "connectors": "连接钉钉、销售易等 SaaS 平台。", "editor": "编辑器字体、显示与格式化。", "lsp": "语言服务与代码智能。", "keyboard": "快捷键与键位。", @@ -76,6 +77,7 @@ "externalSources": "外部 AI 应用", "hooks": "Agent Hooks", "acpAgents": "ACP Agent", + "connectors": "连接器", "agents": "智能体", "editor": "编辑器", "lsp": "语言服务", diff --git a/src/web-ui/src/locales/zh-CN/settings/connectors.json b/src/web-ui/src/locales/zh-CN/settings/connectors.json new file mode 100644 index 0000000000..918985d74f --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/connectors.json @@ -0,0 +1,51 @@ +{ + "connectors": { + "title": "连接器", + "description": "连接钉钉、销售易等 SaaS 平台,让 AI 助手获取并分析数据。", + "section": { + "configuredConnectors": "已配置连接器", + "availableProviders": "可用服务商" + }, + "actions": { + "add": "添加连接器", + "save": "保存", + "cancel": "取消", + "delete": "删除", + "test": "测试", + "sync": "同步" + }, + "status": { + "connected": "已连接", + "disconnected": "未连接", + "error": "错误", + "pending": "处理中" + }, + "card": { + "lastSynced": "上次同步", + "testing": "测试中…", + "syncing": "同步中…", + "deleting": "删除中…", + "test": "测试", + "sync": "同步", + "delete": "删除" + }, + "addDialog": { + "title": "添加连接器", + "provider": "服务商", + "name": "连接器名称", + "namePlaceholder": "例如:生产环境钉钉", + "add": "添加", + "adding": "添加中…", + "cancel": "取消" + }, + "emptyState": { + "title": "尚未配置连接器", + "description": "添加连接器,让 AI 助手访问钉钉、销售易等平台的数据。" + }, + "provider": { + "dingtalk": "钉钉", + "xiaoshouyi": "销售易 (Neocrm)", + "custom": "自定义" + } + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 272c1c7c50..d92501a176 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -48,6 +48,7 @@ "externalSources": "載入其他 AI 應用中相容的命令與擴充。", "hooks": "在 Agent 生命週期節點執行你自己的命令,與 Codex Hooks 相容。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", + "connectors": "連接釘釘、銷售易等 SaaS 平台。", "editor": "編輯器字體、顯示與格式化。", "lsp": "語言服務與代碼智能。", "keyboard": "快捷鍵與鍵位。", @@ -74,6 +75,7 @@ "externalSources": "外部 AI 應用", "hooks": "Agent Hooks", "acpAgents": "ACP Agent", + "connectors": "連接器", "agents": "智能體", "editor": "編輯器", "lsp": "語言服務", diff --git a/src/web-ui/src/locales/zh-TW/settings/connectors.json b/src/web-ui/src/locales/zh-TW/settings/connectors.json new file mode 100644 index 0000000000..2f7a1b62d1 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/connectors.json @@ -0,0 +1,51 @@ +{ + "connectors": { + "title": "連接器", + "description": "連接釘釘、銷售易等 SaaS 平台,讓 AI 助手取得並分析資料。", + "section": { + "configuredConnectors": "已設定連接器", + "availableProviders": "可用服務商" + }, + "actions": { + "add": "新增連接器", + "save": "儲存", + "cancel": "取消", + "delete": "刪除", + "test": "測試", + "sync": "同步" + }, + "status": { + "connected": "已連接", + "disconnected": "未連接", + "error": "錯誤", + "pending": "處理中" + }, + "card": { + "lastSynced": "上次同步", + "testing": "測試中…", + "syncing": "同步中…", + "deleting": "刪除中…", + "test": "測試", + "sync": "同步", + "delete": "刪除" + }, + "addDialog": { + "title": "新增連接器", + "provider": "服務商", + "name": "連接器名稱", + "namePlaceholder": "例如:正式環境釘釘", + "add": "新增", + "adding": "新增中…", + "cancel": "取消" + }, + "emptyState": { + "title": "尚未設定連接器", + "description": "新增連接器,讓 AI 助手存取釘釘、銷售易等平台的資料。" + }, + "provider": { + "dingtalk": "釘釘", + "xiaoshouyi": "銷售易 (Neocrm)", + "custom": "自訂" + } + } +}