From 954dde299a446c33749dd803d0b4a42a6d1f8d8c Mon Sep 17 00:00:00 2001 From: sw3205933776 <3205933776@qq.com> Date: Tue, 18 Nov 2025 14:52:04 +0800 Subject: [PATCH] refactor: remove old IntegrationList component and update imports Deleted the old IntegrationList component and updated its imports in ToolSelect and MCP pages to use the new path. This change streamlines the codebase and ensures consistency in component usage. --- src/components/AddWorker/IntegrationList.tsx | 499 ---------------- src/components/AddWorker/ToolSelect.tsx | 14 +- src/components/IntegrationList/index.tsx | 446 ++++++++++++++ src/hooks/useIntegrationManagement.ts | 294 ++++++++++ src/pages/Setting/MCP.tsx | 6 +- .../Setting/components/IntegrationList.tsx | 552 ------------------ 6 files changed, 752 insertions(+), 1059 deletions(-) delete mode 100644 src/components/AddWorker/IntegrationList.tsx create mode 100644 src/components/IntegrationList/index.tsx create mode 100644 src/hooks/useIntegrationManagement.ts delete mode 100644 src/pages/Setting/components/IntegrationList.tsx diff --git a/src/components/AddWorker/IntegrationList.tsx b/src/components/AddWorker/IntegrationList.tsx deleted file mode 100644 index f9de7cee1..000000000 --- a/src/components/AddWorker/IntegrationList.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { TooltipSimple } from "@/components/ui/tooltip"; -import { CircleAlert } from "lucide-react"; -import { proxyFetchGet, proxyFetchPost, proxyFetchPut, proxyFetchDelete, fetchDelete, fetchGet, fetchPost } from "@/api/http"; - -import React, { useState, useCallback, useEffect, useRef } from "react"; -import ellipseIcon from "@/assets/mcp/Ellipse-25.svg"; -import { capitalizeFirstLetter } from "@/lib"; -import { MCPEnvDialog } from "@/pages/Setting/components/MCPEnvDialog"; -import { useAuthStore } from "@/store/authStore"; -import { OAuth } from "@/lib/oauth"; -import { useTranslation } from "react-i18next"; -interface IntegrationItem { - key: string; - name: string; - desc: string; - env_vars: string[]; - toolkit?: string; // Add toolkit field - onInstall: () => void | Promise; -} - - - -interface IntegrationListProps { - items: IntegrationItem[]; - addOption: (mcp: any, isLocal: boolean) => void; - onShowEnvConfig?: (mcp: any) => void; - installedKeys?: string[]; - oauth?: OAuth | null; -} - -export default function IntegrationList({ - items, - addOption, - onShowEnvConfig, - installedKeys = [], - oauth, -}: IntegrationListProps) { - const [callBackUrl, setCallBackUrl] = useState(null); - const [showEnvConfig, setShowEnvConfig] = useState(false); - const [activeMcp, setActiveMcp] = useState(null); - const { email, checkAgentTool } = useAuthStore(); - const { t } = useTranslation(); - // local installed status - const [installed, setInstalled] = useState<{ [key: string]: boolean }>({}); - // configs cache - const [configs, setConfigs] = useState([]); - // 1. add useRef lock - const isLockedRef = useRef(false); - // add cache oauth event ref - const pendingOauthEventRef = useRef<{ - provider: string; - code: string; - } | null>(null); - - async function fetchInstalled(ignore: boolean = false) { - try { - const configsRes = await proxyFetchGet("/api/configs"); - console.log("configsRes", configsRes); - if (!ignore) { - console.log("configsRes", Array.isArray(configsRes), configsRes); - setConfigs(Array.isArray(configsRes) ? configsRes : []); - } - } catch (e) { - console.log("fetchInstalled error", e); - if (!ignore) setConfigs([]); - } - } - // fetch configs when mounted - useEffect(() => { - let ignore = false; - - fetchInstalled(ignore); - return () => { - ignore = true; - }; - }, []); - - // items or configs change, recalculate installed - useEffect(() => { - // For Google Calendar, check for allowed env keys - // For other integrations, check by config_group - const map: { [key: string]: boolean } = {}; - - items.forEach((item) => { - if (item.key === "Google Calendar") { - // Only mark installed when refresh token is present (auth completed) - const hasRefreshToken = configs.some( - (c: any) => - c.config_group?.toLowerCase() === "google calendar" && - c.config_name === "GOOGLE_REFRESH_TOKEN" && - c.config_value && String(c.config_value).length > 0 - ); - map[item.key] = hasRefreshToken; - } else { - // For other integrations, use config_group presence - const hasConfig = configs.some( - (c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase() - ); - map[item.key] = hasConfig; - } - }); - - setInstalled(map); - }, [items, configs]); - - // public save env/config logic - const saveEnvAndConfig = async ( - provider: string, - envVarKey: string, - value: string - ) => { - const configPayload = { - // Keep exact group name to satisfy backend whitelist - config_group: provider, - config_name: envVarKey, - config_value: value, - }; - - // Fetch latest configs to avoid stale state when deciding POST/PUT - let latestConfigs: any[] = Array.isArray(configs) ? configs : []; - try { - const fresh = await proxyFetchGet("/api/configs"); - if (Array.isArray(fresh)) latestConfigs = fresh; - } catch {} - - // Backend uniqueness is by config_name for a user - let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey); - - if (existingConfig) { - await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload); - } else { - const res = await proxyFetchPost("/api/configs", configPayload); - if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) { - try { - const again = await proxyFetchGet("/api/configs"); - const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null; - if (found) { - await proxyFetchPut(`/api/configs/${found.id}`, configPayload); - } - } catch {} - } - } - - if (window.electronAPI?.envWrite) { - await window.electronAPI.envWrite(email, { key: envVarKey, value }); - } - }; - - // useCallback to ensure processOauth can get the latest items when items change - const processOauth = useCallback( - async (data: { provider: string; code: string }) => { - if (isLockedRef.current) return; - if (!items || items.length === 0) { - // items not ready, cache event, wait for items to have value - pendingOauthEventRef.current = data; - console.warn("items is empty, cache oauth event", data); - return; - } - const provider = data.provider.toLowerCase(); - isLockedRef.current = true; - try { - const tokenResult = await proxyFetchPost( - `/api/oauth/${provider}/token`, - { code: data.code } - ); - setInstalled((prev) => ({ - ...prev, - [capitalizeFirstLetter(provider)]: true, - })); - const currentItem = items.find( - (item) => item.key.toLowerCase() === provider - ); - if (provider === "slack") { - if ( - tokenResult.access_token && - currentItem && - currentItem.env_vars && - currentItem.env_vars.length > 0 - ) { - const envVarKey = currentItem.env_vars[0]; - await saveEnvAndConfig( - provider, - envVarKey, - tokenResult.access_token - ); - await fetchInstalled(); - console.log( - "Slack authorization successful and configuration saved!" - ); - } else { - console.log( - "Slack authorization successful, but access_token not found or env configuration not found" - ); - } - } - } catch (e: any) { - console.log(`${data.provider} authorization failed: ${e.message || e}`); - } finally { - isLockedRef.current = false; - } - }, - [items, oauth] - ); - - // listen to main process oauth authorization callback, automatically mark as installed and get token - useEffect(() => { - const handler = (_event: any, data: { provider: string; code: string }) => { - if (!data.provider || !data.code) return; - processOauth(data); - }; - window.ipcRenderer?.on("oauth-authorized", handler); - return () => { - window.ipcRenderer?.off("oauth-authorized", handler); - }; - }, [items, oauth, processOauth]); - - // listen to oauth callback URL notification - useEffect(() => { - const handler = (_event: any, data: { url: string; provider: string }) => { - console.log('OAuth callback URL:', data); - if (data.url && data.provider) { - setCallBackUrl(data.url); - } - }; - window.ipcRenderer?.on("oauth-callback-url", handler); - return () => { - window.ipcRenderer?.off("oauth-callback-url", handler); - }; - }, []); - - // listen to items change, if there is a cached oauth event and items is ready, automatically process - useEffect(() => { - if (items && items.length > 0 && pendingOauthEventRef.current) { - processOauth(pendingOauthEventRef.current); - pendingOauthEventRef.current = null; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items, oauth]); - - - // install/uninstall - const handleInstall = useCallback( - async (item: IntegrationItem) => { - console.log(item); - if (item.key === "EXA Search") { - let mcp = { - name: "EXA Search", - key: "EXA Search", - install_command: { - env: {} as any, - }, - id: 13, - }; - item.env_vars.map((key) => { - mcp.install_command.env[key] = ""; - }); - onShowEnvConfig?.(mcp); - // setActiveMcp(mcp); - // setShowEnvConfig(true); - return; - } - - if (item.key === "Google Calendar") { - // Always prompt env dialog first instead of jumping to authorization - let mcp = { - name: "Google Calendar", - key: "Google Calendar", - install_command: { - env: {} as any, - }, - id: 14, - }; - item.env_vars.map((key) => { - mcp.install_command.env[key] = ""; - }); - onShowEnvConfig?.(mcp); - return; - } - if (installed[item.key]) return; - await item.onInstall(); - // refresh configs after install to update installed state indicator - await fetchInstalled(); - }, - [installed] - ); - - const onConnect = async (mcp: any) => { - console.log("[IntegrationList onConnect] Starting for", mcp.key); - - // Refresh configs first to get latest state - await fetchInstalled(); - - // Save all environment variables - await Promise.all( - Object.keys(mcp.install_command.env).map(async (key) => { - return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]); - }) - ); - - // After saving env vars, handle Google Calendar authorization flow - if (mcp.key === "Google Calendar") { - console.log("[IntegrationList onConnect] Google Calendar detected, starting auth flow"); - - // Trigger install/authorization - const calendarItem = items.find(item => item.key === "Google Calendar"); - try { - if (calendarItem && calendarItem.onInstall) { - await calendarItem.onInstall(); - } else { - await fetchPost("/install/tool/google_calendar"); - } - } catch (_) {} - - console.log("[IntegrationList onConnect] Starting OAuth status polling"); - - // Keep the dialog open and poll OAuth status until completion - const start = Date.now(); - const timeoutMs = 5 * 60 * 1000; // 5 minutes - while (Date.now() - start < timeoutMs) { - try { - const statusRes: any = await fetchGet("/oauth/status/google_calendar"); - console.log("[IntegrationList onConnect] OAuth status:", statusRes?.status); - - if (statusRes?.status === "success") { - console.log("[IntegrationList onConnect] Success! Closing dialog"); - await fetchInstalled(); - onClose(); - return; - } - if (statusRes?.status === "failed" || statusRes?.status === "cancelled") { - console.log("[IntegrationList onConnect] Failed/cancelled, keeping dialog open"); - // Stop waiting on failure/cancellation; keep dialog open for retry - return; - } - } catch (err) { - console.log("[IntegrationList onConnect] Polling error:", err); - // ignore transient polling errors - } - await new Promise((r) => setTimeout(r, 1500)); - } - // Timeout reached; return to allow user to try again - console.log("[IntegrationList onConnect] Polling timeout"); - return; - } - - console.log("[IntegrationList onConnect] Non-Google Calendar, closing immediately"); - await fetchInstalled(); - addOption(mcp, true); - onClose(); - }; - const onClose = () => { - setShowEnvConfig(false); - setActiveMcp(null); - }; - - // uninstall logic - const handleUninstall = useCallback( - async (item: IntegrationItem) => { - // find all config_group matching config, delete one by one - checkAgentTool(item.key); - const groupKey = item.key.toLowerCase(); - const toDelete = configs.filter( - (c: any) => c.config_group && c.config_group.toLowerCase() === groupKey - ); - for (const config of toDelete) { - try { - await proxyFetchDelete(`/api/configs/${config.id}`); - // delete env - if ( - item.env_vars && - item.env_vars.length > 0 && - window.electronAPI?.envRemove - ) { - await window.electronAPI.envRemove(email, item.env_vars[0]); - } - } catch (e) { - // ignore error - } - } - - // Clean up authentication tokens for Google Calendar and Notion - if (item.key === "Google Calendar") { - try { - await fetchDelete("/uninstall/tool/google_calendar"); - console.log("Cleaned up Google Calendar authentication tokens"); - } catch (e) { - console.log("Failed to clean up Google Calendar tokens:", e); - } - } else if (item.key === "Notion") { - try { - await fetchDelete("/uninstall/tool/notion"); - console.log("Cleaned up Notion authentication tokens"); - } catch (e) { - console.log("Failed to clean up Notion tokens:", e); - } - } - - // delete after refresh configs - setConfigs((prev) => - prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey) - ); - }, - [configs] - ); - - return ( -
- - {items.map((item) => { - const isInstalled = !!installed[item.key]; - - return ( -
{ - if ( - ![ - "X(Twitter)", - "WhatsApp", - "LinkedIn", - "Reddit", - "Github", - ].includes(item.name) - ) { - if (item.env_vars.length === 0 || isInstalled) { - // Ensure toolkit field is passed and normalized for known cases - const normalizedToolkit = - item.name === "Notion" ? "notion_mcp_toolkit" : item.toolkit; - addOption({ ...item, toolkit: normalizedToolkit }, true); - } else { - handleInstall(item); - } - } - }} - > -
- icon -
- {item.name} -
-
- - - -
-
- {item.env_vars.length !== 0 && ( - - )} -
- ); - })} -
- ); -} diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 5a5ae3534..dc02fab00 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -13,7 +13,7 @@ import { Textarea } from "../ui/textarea"; import { Button } from "../ui/button"; import githubIcon from "@/assets/github.svg"; import { TooltipSimple } from "../ui/tooltip"; -import IntegrationList from "./IntegrationList"; +import IntegrationList from "@/components/IntegrationList"; import { getProxyBaseURL } from "@/lib"; import { capitalizeFirstLetter } from "@/lib"; import { useAuthStore } from "@/store/authStore"; @@ -720,11 +720,13 @@ const ToolSelect = forwardRef< {isOpen && (
- + {mcpList .filter( (opt) => diff --git a/src/components/IntegrationList/index.tsx b/src/components/IntegrationList/index.tsx new file mode 100644 index 000000000..bba91d947 --- /dev/null +++ b/src/components/IntegrationList/index.tsx @@ -0,0 +1,446 @@ +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TooltipSimple } from "@/components/ui/tooltip"; +import { CircleAlert, Settings2 } from "lucide-react"; +import { fetchGet, fetchPost } from "@/api/http"; + +import React, { useState, useCallback } from "react"; +import ellipseIcon from "@/assets/mcp/Ellipse-25.svg"; +import { MCPEnvDialog } from "@/pages/Setting/components/MCPEnvDialog"; +import { OAuth } from "@/lib/oauth"; +import { useTranslation } from "react-i18next"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { + useIntegrationManagement, + type IntegrationItem, +} from "@/hooks/useIntegrationManagement"; + +type IntegrationListVariant = "select" | "manage"; + +interface IntegrationListProps { + items: IntegrationItem[]; + variant?: IntegrationListVariant; // "select" for AddWorker, "manage" for Setting + + // Select mode props (AddWorker) + addOption?: (mcp: any, isLocal: boolean) => void; + onShowEnvConfig?: (mcp: any) => void; + + // Manage mode props (Setting) + showSelect?: boolean; + selectPlaceholder?: string; + selectContent?: React.ReactNode; + onSelectChange?: (value: string, item: IntegrationItem) => void; + showConfigButton?: boolean; + showInstallButton?: boolean; + onConfigClick?: (item: IntegrationItem) => void; + showStatusDot?: boolean; + + // Common props + installedKeys?: string[]; + oauth?: OAuth | null; + translationNamespace?: "layout" | "setting"; // For translation keys +} + +export default function IntegrationList({ + items, + variant = "manage", // Default to manage mode for backward compatibility + addOption, + onShowEnvConfig, + showSelect = false, + selectPlaceholder = "Select...", + selectContent, + onSelectChange, + showConfigButton = true, + showInstallButton = true, + onConfigClick, + showStatusDot = true, + installedKeys = [], + oauth, + translationNamespace = variant === "select" ? "layout" : "setting", +}: IntegrationListProps) { + const { t } = useTranslation(); + const [showEnvConfig, setShowEnvConfig] = useState(false); + const [activeMcp, setActiveMcp] = useState(null); + const isSelectMode = variant === "select"; + + // Use shared hook for integration management + const { + installed, + fetchInstalled, + saveEnvAndConfig, + handleUninstall, + createMcpFromItem, + } = useIntegrationManagement(items); + + // Install handler - different logic for select vs manage mode + const handleInstall = useCallback( + async (item: IntegrationItem) => { + console.log(item); + const searchKey = isSelectMode ? "EXA Search" : "Search"; + + if (item.key === searchKey) { + const mcp = createMcpFromItem(item, 13); + if (isSelectMode) { + onShowEnvConfig?.(mcp); + } else { + setActiveMcp(mcp); + setShowEnvConfig(true); + } + return; + } + + if (item.key === "Google Calendar") { + const mcp = createMcpFromItem(item, 14); + if (isSelectMode) { + onShowEnvConfig?.(mcp); + } else { + setActiveMcp(mcp); + setShowEnvConfig(true); + } + return; + } + + if (installed[item.key]) return; + await item.onInstall(); + // Only refresh in select mode + if (isSelectMode) { + await fetchInstalled(); + } + }, + [ + installed, + createMcpFromItem, + isSelectMode, + onShowEnvConfig, + fetchInstalled, + ] + ); + + // onConnect handler - different logic for select vs manage mode + const onConnect = async (mcp: any) => { + console.log("[IntegrationList onConnect] Starting for", mcp.key); + + // Refresh configs first to get latest state + await fetchInstalled(); + + // Save all environment variables + await Promise.all( + Object.keys(mcp.install_command.env).map(async (key) => { + return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]); + }) + ); + + // After saving env vars, handle Google Calendar authorization flow + if (mcp.key === "Google Calendar") { + console.log( + "[IntegrationList onConnect] Google Calendar detected, starting auth flow" + ); + + // Trigger install/authorization + const calendarItem = items.find((item) => item.key === "Google Calendar"); + try { + if (calendarItem && calendarItem.onInstall) { + await calendarItem.onInstall(); + } else { + await fetchPost("/install/tool/google_calendar"); + } + } catch (_) {} + + // Select mode: poll OAuth status + if (isSelectMode) { + console.log( + "[IntegrationList onConnect] Starting OAuth status polling" + ); + + const start = Date.now(); + const timeoutMs = 5 * 60 * 1000; // 5 minutes + while (Date.now() - start < timeoutMs) { + try { + const statusRes: any = await fetchGet( + "/oauth/status/google_calendar" + ); + console.log( + "[IntegrationList onConnect] OAuth status:", + statusRes?.status + ); + + if (statusRes?.status === "success") { + console.log( + "[IntegrationList onConnect] Success! Closing dialog" + ); + await fetchInstalled(); + onClose(); + return; + } + if ( + statusRes?.status === "failed" || + statusRes?.status === "cancelled" + ) { + console.log( + "[IntegrationList onConnect] Failed/cancelled, keeping dialog open" + ); + return; + } + } catch (err) { + console.log("[IntegrationList onConnect] Polling error:", err); + } + await new Promise((r) => setTimeout(r, 1500)); + } + console.log("[IntegrationList onConnect] Polling timeout"); + return; + } + } + + // Select mode: add to tools and close + if (isSelectMode && addOption) { + console.log( + "[IntegrationList onConnect] Non-Google Calendar, closing immediately" + ); + await fetchInstalled(); + addOption(mcp, true); + onClose(); + } else { + // Manage mode: just close + await fetchInstalled(); + onClose(); + } + }; + + const onClose = () => { + setShowEnvConfig(false); + setActiveMcp(null); + }; + + const handleOpenConfig = useCallback( + (item: IntegrationItem) => { + // if external handler provided by parent, use it + if (onConfigClick) { + onConfigClick(item); + return; + } + // default behavior: if item has env vars, open built-in MCP config dialog + if (item?.env_vars && item.env_vars.length > 0) { + const mcp = createMcpFromItem(item, -1); + setActiveMcp(mcp); + setShowEnvConfig(true); + } + }, + [onConfigClick, createMcpFromItem] + ); + + const COMING_SOON_ITEMS = [ + "X(Twitter)", + "WhatsApp", + "LinkedIn", + "Reddit", + "Github", + ]; + + // Determine container and item styles based on variant + const containerClassName = isSelectMode + ? "space-y-3" + : "flex flex-col w-full items-start justify-start gap-4"; + + const itemClassName = isSelectMode + ? "cursor-pointer hover:bg-gray-100 px-3 py-2 flex justify-between" + : "w-full px-6 py-4 bg-surface-secondary rounded-2xl"; + + const titleClassName = isSelectMode + ? "text-base leading-snug font-bold text-text-action" + : "text-label-lg font-bold text-text-heading"; + + return ( +
+ + {items.map((item) => { + const isInstalled = !!installed[item.key]; + const isComingSoon = COMING_SOON_ITEMS.includes(item.name); + + return ( +
+
{ + if (!isComingSoon) { + if (item.env_vars.length === 0 || isInstalled) { + // Ensure toolkit field is passed and normalized for known cases + const normalizedToolkit = + item.name === "Notion" + ? "notion_mcp_toolkit" + : item.toolkit; + addOption?.( + { ...item, toolkit: normalizedToolkit }, + true + ); + } else { + handleInstall(item); + } + } + } + : undefined + } + > + {isSelectMode ? ( +
+ {(isSelectMode || showStatusDot) && ( + icon + )} +
{item.name}
+
+ + + +
+
+ ) : ( +
+
+ {showStatusDot && ( + icon + )} +
{item.name}
+
+ + + + + +
{item.desc}
+
+
+
+
+
+ {showConfigButton && ( + + )} + {showInstallButton && ( + + )} +
+
+ )} + {isSelectMode && item.env_vars.length !== 0 && ( + + )} +
+ + {!isSelectMode && showSelect && ( +
+
+
+ {" "} + Default {item.name} +
+
+ +
+
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/hooks/useIntegrationManagement.ts b/src/hooks/useIntegrationManagement.ts new file mode 100644 index 000000000..b3ffd0663 --- /dev/null +++ b/src/hooks/useIntegrationManagement.ts @@ -0,0 +1,294 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { + proxyFetchGet, + proxyFetchPost, + proxyFetchPut, + proxyFetchDelete, + fetchDelete, +} from "@/api/http"; +import { capitalizeFirstLetter } from "@/lib"; +import { useAuthStore } from "@/store/authStore"; + +export interface IntegrationItem { + key: string; + name: string; + desc: string | React.ReactNode; + env_vars: string[]; + toolkit?: string; + onInstall: () => void | Promise; +} + +/** + * Hook for managing integration configurations, OAuth, and installation state + */ +export function useIntegrationManagement(items: IntegrationItem[]) { + const { email, checkAgentTool } = useAuthStore(); + + // Local installed status + const [installed, setInstalled] = useState<{ [key: string]: boolean }>({}); + // Configs cache + const [configs, setConfigs] = useState([]); + // Lock to prevent concurrent OAuth processing + const isLockedRef = useRef(false); + // Cache OAuth event when items are not ready + const pendingOauthEventRef = useRef<{ + provider: string; + code: string; + } | null>(null); + const [callBackUrl, setCallBackUrl] = useState(null); + + // Fetch installed configs + const fetchInstalled = useCallback(async (ignore: boolean = false) => { + try { + const configsRes = await proxyFetchGet("/api/configs"); + if (!ignore) { + setConfigs(Array.isArray(configsRes) ? configsRes : []); + } + } catch (e) { + if (!ignore) setConfigs([]); + } + }, []); + + // Fetch configs when mounted + useEffect(() => { + let ignore = false; + fetchInstalled(ignore); + return () => { + ignore = true; + }; + }, [fetchInstalled]); + + // Recalculate installed status when items or configs change + useEffect(() => { + const map: { [key: string]: boolean } = {}; + + items.forEach((item) => { + if (item.key === "Google Calendar") { + // Only mark installed when refresh token is present (auth completed) + const hasRefreshToken = configs.some( + (c: any) => + c.config_group?.toLowerCase() === "google calendar" && + c.config_name === "GOOGLE_REFRESH_TOKEN" && + c.config_value && String(c.config_value).length > 0 + ); + map[item.key] = hasRefreshToken; + } else { + // For other integrations, use config_group presence + const hasConfig = configs.some( + (c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase() + ); + map[item.key] = hasConfig; + } + }); + + setInstalled(map); + }, [items, configs]); + + // Save environment variable and config + const saveEnvAndConfig = useCallback(async ( + provider: string, + envVarKey: string, + value: string + ) => { + const configPayload = { + // Keep exact group name to satisfy backend whitelist + config_group: provider, + config_name: envVarKey, + config_value: value, + }; + + // Fetch latest configs to avoid stale state when deciding POST/PUT + let latestConfigs: any[] = Array.isArray(configs) ? configs : []; + try { + const fresh = await proxyFetchGet("/api/configs"); + if (Array.isArray(fresh)) latestConfigs = fresh; + } catch {} + + // Backend uniqueness is by config_name for a user + let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey); + + if (existingConfig) { + await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload); + } else { + const res = await proxyFetchPost("/api/configs", configPayload); + if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) { + try { + const again = await proxyFetchGet("/api/configs"); + const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null; + if (found) { + await proxyFetchPut(`/api/configs/${found.id}`, configPayload); + } + } catch {} + } + } + + if (window.electronAPI?.envWrite) { + await window.electronAPI.envWrite(email, { key: envVarKey, value }); + } + }, [configs, email]); + + // Process OAuth callback + const processOauth = useCallback( + async (data: { provider: string; code: string }) => { + if (isLockedRef.current) return; + if (!items || items.length === 0) { + // Items not ready, cache event, wait for items to have value + pendingOauthEventRef.current = data; + console.warn("items is empty, cache oauth event", data); + return; + } + const provider = data.provider.toLowerCase(); + isLockedRef.current = true; + try { + const tokenResult = await proxyFetchPost( + `/api/oauth/${provider}/token`, + { code: data.code } + ); + setInstalled((prev) => ({ + ...prev, + [capitalizeFirstLetter(provider)]: true, + })); + const currentItem = items.find( + (item) => item.key.toLowerCase() === provider + ); + if (provider === "slack") { + if ( + tokenResult.access_token && + currentItem && + currentItem.env_vars && + currentItem.env_vars.length > 0 + ) { + const envVarKey = currentItem.env_vars[0]; + await saveEnvAndConfig( + provider, + envVarKey, + tokenResult.access_token + ); + await fetchInstalled(); + console.log( + "Slack authorization successful and configuration saved!" + ); + } else { + console.log( + "Slack authorization successful, but access_token not found or env configuration not found" + ); + } + } + } catch (e: any) { + console.log(`${data.provider} authorization failed: ${e.message || e}`); + } finally { + isLockedRef.current = false; + } + }, + [items, saveEnvAndConfig, fetchInstalled] + ); + + // Listen to main process OAuth authorization callback + useEffect(() => { + const handler = (_event: any, data: { provider: string; code: string }) => { + if (!data.provider || !data.code) return; + processOauth(data); + }; + window.ipcRenderer?.on("oauth-authorized", handler); + return () => { + window.ipcRenderer?.off("oauth-authorized", handler); + }; + }, [processOauth]); + + // Listen to OAuth callback URL notification + useEffect(() => { + const handler = (_event: any, data: { url: string; provider: string }) => { + if (data.url && data.provider) { + setCallBackUrl(data.url); + } + }; + window.ipcRenderer?.on("oauth-callback-url", handler); + return () => { + window.ipcRenderer?.off("oauth-callback-url", handler); + }; + }, []); + + // Process cached OAuth event when items are ready + useEffect(() => { + if (items && items.length > 0 && pendingOauthEventRef.current) { + processOauth(pendingOauthEventRef.current); + pendingOauthEventRef.current = null; + } + }, [items, processOauth]); + + // Uninstall integration + const handleUninstall = useCallback( + async (item: IntegrationItem) => { + checkAgentTool(item.key); + const groupKey = item.key.toLowerCase(); + const toDelete = configs.filter( + (c: any) => c.config_group && c.config_group.toLowerCase() === groupKey + ); + for (const config of toDelete) { + try { + await proxyFetchDelete(`/api/configs/${config.id}`); + // Delete env + if ( + item.env_vars && + item.env_vars.length > 0 && + window.electronAPI?.envRemove + ) { + await window.electronAPI.envRemove(email, item.env_vars[0]); + } + } catch (e) { + // Ignore error + } + } + + // Clean up authentication tokens for Google Calendar and Notion + if (item.key === "Google Calendar") { + try { + await fetchDelete("/uninstall/tool/google_calendar"); + console.log("Cleaned up Google Calendar authentication tokens"); + } catch (e) { + console.log("Failed to clean up Google Calendar tokens:", e); + } + } else if (item.key === "Notion") { + try { + await fetchDelete("/uninstall/tool/notion"); + console.log("Cleaned up Notion authentication tokens"); + } catch (e) { + console.log("Failed to clean up Notion tokens:", e); + } + } + + // Update configs after deletion + setConfigs((prev) => + prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey) + ); + }, + [configs, email, checkAgentTool] + ); + + // Helper to create MCP object from integration item + const createMcpFromItem = useCallback((item: IntegrationItem, id: number) => { + const mcp = { + name: item.name, + key: item.key, + install_command: { + env: {} as any, + }, + id, + }; + item.env_vars.forEach((key) => { + mcp.install_command.env[key] = ""; + }); + return mcp; + }, []); + + return { + installed, + configs, + callBackUrl, + fetchInstalled, + saveEnvAndConfig, + handleUninstall, + createMcpFromItem, + }; +} + diff --git a/src/pages/Setting/MCP.tsx b/src/pages/Setting/MCP.tsx index 757bc6b02..f4bf66771 100644 --- a/src/pages/Setting/MCP.tsx +++ b/src/pages/Setting/MCP.tsx @@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button"; import { ChevronDown, ChevronUp, Plus, Store, ChevronLeft } from "lucide-react"; import SearchInput from "@/components/SearchInput"; import { useNavigate } from "react-router-dom"; -import IntegrationList from "./components/IntegrationList"; +import IntegrationList from "@/components/IntegrationList"; import { getProxyBaseURL } from "@/lib"; import { useAuthStore } from "@/store/authStore"; import { useTranslation } from "react-i18next"; @@ -587,6 +587,7 @@ const [showSearchEngineConfig, setShowSearchEngineConfig] = useState(false); <>
@@ -623,7 +625,7 @@ const [showSearchEngineConfig, setShowSearchEngineConfig] = useState(false); )}
- {!collapsedMCP && } + {!collapsedMCP && }
diff --git a/src/pages/Setting/components/IntegrationList.tsx b/src/pages/Setting/components/IntegrationList.tsx deleted file mode 100644 index 962f50166..000000000 --- a/src/pages/Setting/components/IntegrationList.tsx +++ /dev/null @@ -1,552 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { CircleAlert, Settings2, Check } from "lucide-react"; -import { - proxyFetchGet, - proxyFetchPost, - proxyFetchPut, - proxyFetchDelete, - fetchDelete, -} from "@/api/http"; - -import React, { useState, useCallback, useEffect, useRef } from "react"; -import ellipseIcon from "@/assets/mcp/Ellipse-25.svg"; -import { capitalizeFirstLetter } from "@/lib"; -import { MCPEnvDialog } from "./MCPEnvDialog"; -import { useAuthStore } from "@/store/authStore"; -import { OAuth } from "@/lib/oauth"; -import { useTranslation } from "react-i18next"; -import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, - SelectItemWithButton, -} from "@/components/ui/select"; -import * as SelectPrimitive from "@radix-ui/react-select"; -interface IntegrationItem { - key: string; - name: string; - desc: string | React.ReactNode; - env_vars: string[]; - onInstall: () => void | Promise; -} - - -interface IntegrationListProps { - items: IntegrationItem[]; - installedKeys?: string[]; - oauth?: OAuth; - // optional select beside each item for importing options - showSelect?: boolean; // default hidden - selectPlaceholder?: string; // placeholder text - selectContent?: React.ReactNode; // custom content for SelectContent (e.g., list of ) - onSelectChange?: (value: string, item: IntegrationItem) => void; // callback when select changes - // button group options - showConfigButton?: boolean; // whether to show the config button (default: true) - showInstallButton?: boolean; // whether to show the install/uninstall button (default: true) - onConfigClick?: (item: IntegrationItem) => void; // optional external handler to open a popup - // status dot icon (ellipse) visibility - showStatusDot?: boolean; // default: true -} - -export default function IntegrationList({ - items, - showSelect = false, - selectPlaceholder = "Select...", - selectContent, - onSelectChange, - showConfigButton = true, - showInstallButton = true, - onConfigClick, - showStatusDot = true, -}: IntegrationListProps) { - const { t } = useTranslation(); - const [showEnvConfig, setShowEnvConfig] = useState(false); - const [activeMcp, setActiveMcp] = useState(null); - const { email, checkAgentTool } = useAuthStore(); - const [callBackUrl, setCallBackUrl] = useState(null); - - // local installed status - const [installed, setInstalled] = useState<{ [key: string]: boolean }>({}); - // configs cache - const [configs, setConfigs] = useState([]); - // 1. add useRef lock - const isLockedRef = useRef(false); - // 2. add ref to cache oauth event - const pendingOauthEventRef = useRef<{ - provider: string; - code: string; - } | null>(null); - - async function fetchInstalled(ignore: boolean = false) { - try { - const configsRes = await proxyFetchGet("/api/configs"); - if (!ignore) { - setConfigs(Array.isArray(configsRes) ? configsRes : []); - } - } catch (e) { - if (!ignore) setConfigs([]); - } - } - // 3. fetch configs when mounted - useEffect(() => { - let ignore = false; - - fetchInstalled(); - return () => { - ignore = true; - }; - }, []); - - // items or configs change, recalculate installed - useEffect(() => { - // construct installed map - const map: { [key: string]: boolean } = {}; - items.forEach((item) => { - if (item.key === "Google Calendar") { - // Only mark installed after refresh token exists - const hasRefreshToken = configs.some( - (c: any) => - c.config_group?.toLowerCase() === "google calendar" && - c.config_name === "GOOGLE_REFRESH_TOKEN" && - c.config_value && String(c.config_value).length > 0 - ); - map[item.key] = hasRefreshToken; - } else { - // For other integrations, use presence of any config in the group - const hasConfig = configs.some( - (c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase() - ); - map[item.key] = hasConfig; - } - }); - setInstalled(map); - }, [items, configs]); - - // public save env/config logic - const saveEnvAndConfig = async ( - provider: string, - envVarKey: string, - value: string - ) => { - const configPayload = { - // Use exact group name, do not transform case to avoid whitelist mismatch - config_group: provider, - config_name: envVarKey, - config_value: value, - }; - - // Fetch latest configs to avoid stale state when deciding POST/PUT - let latestConfigs: any[] = Array.isArray(configs) ? configs : []; - try { - const fresh = await proxyFetchGet("/api/configs"); - if (Array.isArray(fresh)) latestConfigs = fresh; - } catch {} - - // Check if config already exists (by name, regardless of group - backend uniqueness is by name) - let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey); - - if (existingConfig) { - await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload); - } else { - const res = await proxyFetchPost("/api/configs", configPayload); - // If backend says it already exists (race), switch to PUT - if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) { - try { - const again = await proxyFetchGet("/api/configs"); - const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null; - if (found) { - await proxyFetchPut(`/api/configs/${found.id}`, configPayload); - } - } catch {} - } - } - - if (window.electronAPI?.envWrite) { - await window.electronAPI.envWrite(email, { key: envVarKey, value }); - } - }; - - // wrap with useCallback, ensure processOauth can get the latest items and oauth when items change - const processOauth = useCallback( - async (data: { provider: string; code: string }) => { - if (isLockedRef.current) return; - console.log("items", items); - if (!items || items.length === 0) { - // items are not ready, cache event, wait for items to have value - pendingOauthEventRef.current = data; - console.warn("items are empty, cache oauth event", data); - return; - } - const provider = data.provider.toLowerCase(); - isLockedRef.current = true; - - try { - const tokenResult = await proxyFetchPost( - `/api/oauth/${provider}/token`, - { code: data.code } - ); - setInstalled((prev) => ({ - ...prev, - [capitalizeFirstLetter(provider)]: true, - })); - const currentItem = items.find( - (item) => item.key.toLowerCase() === provider - ); - console.log("provider", provider); - console.log("items", items); - if (provider === "slack") { - if ( - tokenResult.access_token && - currentItem && - currentItem.env_vars && - currentItem.env_vars.length > 0 - ) { - const envVarKey = currentItem.env_vars[0]; - await saveEnvAndConfig( - provider, - envVarKey, - tokenResult.access_token - ); - fetchInstalled(); - console.log( - "Slack authorization successful and configuration saved!" - ); - } else { - console.log( - "Slack authorization successful, but access_token not found or env configuration not found" - ); - } - } else { - // other provider authorization successful, can be extended - } - } catch (e: any) { - console.log(`${data.provider} authorization failed: ${e.message || e}`); - } finally { - isLockedRef.current = false; - } - }, - [items, callBackUrl] // add oauth to dependencies - ); - - // listen to main process oauth authorization callback, automatically mark as installed and get token - useEffect(() => { - const handler = (_event: any, data: { provider: string; code: string }) => { - if (!data.provider || !data.code) return; - processOauth(data); - }; - window.ipcRenderer?.on("oauth-authorized", handler); - return () => { - window.ipcRenderer?.off("oauth-authorized", handler); - }; - }, [processOauth]); - - // listen to oauth callback URL notification - useEffect(() => { - const handler = (_event: any, data: { url: string; provider: string }) => { - console.log("Received OAuth callback URL:", data); - - if (data.url && data.provider) { - console.log(`${data.provider} OAuth callback URL: ${data.url}`); - setCallBackUrl(data.url); - // Add user prompt or other processing logic here - } - }; - window.ipcRenderer?.on("oauth-callback-url", handler); - return () => { - window.ipcRenderer?.off("oauth-callback-url", handler); - }; - }, []); - - // as long as oauth changes and there is a cached event, process it - useEffect(() => { - if (pendingOauthEventRef.current) { - processOauth(pendingOauthEventRef.current); - pendingOauthEventRef.current = null; - } - }, [processOauth]); - - // install/uninstall - const handleInstall = useCallback( - async (item: IntegrationItem) => { - console.log(item); - if (item.key === "Search") { - let mcp = { - name: "Search", - key: "Search", - install_command: { - env: {} as any, - }, - id: 13, - }; - item.env_vars.map((key) => { - mcp.install_command.env[key] = ""; - }); - setActiveMcp(mcp); - setShowEnvConfig(true); - return; - } - - if (item.key === "Google Calendar") { - let mcp = { - name: "Google Calendar", - key: "Google Calendar", - install_command: { - env: {} as any, - }, - id: 14, - }; - item.env_vars.map((key) => { - mcp.install_command.env[key] = ""; - }); - setActiveMcp(mcp); - setShowEnvConfig(true); - return; - } - - if (installed[item.key]) return; - await item.onInstall(); - }, - [installed] - ); - - const onConnect = async (mcp: any) => { - // Refresh configs first to get latest state - await fetchInstalled(); - - // Save all environment variables - await Promise.all( - Object.keys(mcp.install_command.env).map((key) => { - return saveEnvAndConfig(mcp.key, key, mcp.install_command.env[key]); - }) - ); - - // After saving env vars, trigger installation/instantiation for Google Calendar - if (mcp.key === "Google Calendar") { - const calendarItem = items.find(item => item.key === "Google Calendar"); - if (calendarItem && calendarItem.onInstall) { - await calendarItem.onInstall(); - } - } - - await fetchInstalled(); - onClose(); - }; - const onClose = () => { - setShowEnvConfig(false); - setActiveMcp(null); - }; - - const handleOpenConfig = useCallback((item: IntegrationItem) => { - // if external handler provided by parent, use it - if (onConfigClick) { - onConfigClick(item); - return; - } - // default behavior: if item has env vars, open built-in MCP config dialog - if (item?.env_vars && item.env_vars.length > 0) { - const mcp = { - name: item.name, - key: item.key, - install_command: { - env: {} as any, - }, - id: -1, - }; - item.env_vars.forEach((key) => { - (mcp.install_command.env as any)[key] = ""; - }); - setActiveMcp(mcp); - setShowEnvConfig(true); - } - }, [onConfigClick]); - - // uninstall logic - const handleUninstall = useCallback( - async (item: IntegrationItem) => { - checkAgentTool(item.key); - // find all configs that match config_group, delete one by one - const groupKey = item.key.toLowerCase(); - const toDelete = configs.filter( - (c: any) => c.config_group && c.config_group.toLowerCase() === groupKey - ); - console.log("toDelete", toDelete); - for (const config of toDelete) { - try { - await proxyFetchDelete(`/api/configs/${config.id}`); - console.log("envRemove", email, item.env_vars[0]); - - // delete env - if ( - item.env_vars && - item.env_vars.length > 0 && - window.electronAPI?.envRemove - ) { - - await window.electronAPI.envRemove(email, item.env_vars[0]); - } - } catch (e) { - console.log("envRemove error", e); - // ignore error - } - } - - // Clean up authentication tokens for Google Calendar and Notion - if (item.key === "Google Calendar") { - try { - await fetchDelete("/uninstall/tool/google_calendar"); - console.log("Cleaned up Google Calendar authentication tokens"); - } catch (e) { - console.log("Failed to clean up Google Calendar tokens:", e); - } - } else if (item.key === "Notion") { - try { - await fetchDelete("/uninstall/tool/notion"); - console.log("Cleaned up Notion authentication tokens"); - } catch (e) { - console.log("Failed to clean up Notion tokens:", e); - } - } - - // after deletion, refresh configs - setConfigs((prev) => - prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey) - ); - }, - [configs] - ); - - return ( -
- - {items.map((item) => { - const isInstalled = !!installed[item.key]; - return ( -
-
-
- {showStatusDot && ( - icon - )} -
- {item.name} -
-
- - - - - -
{item.desc}
-
-
-
-
-
- {showConfigButton && ( - - )} - {showInstallButton && ( - - )} -
-
- - {showSelect && ( -
-
-
Default {item.name}
-
- -
-
-
- )} -
- - ); - })} -
- ); -}