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 5e11bc7fa..81949941a 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"; @@ -654,6 +654,7 @@ export default function SettingMCP() { <>
)} - - setShowAdd(false)} - onInstall={handleInstall} - /> - setDeleteTarget(null)} - onConfirm={handleDelete} - loading={deleting} - /> - setShowSearchEngineConfig(false)} - />
+ + setShowAdd(false)} + onInstall={handleInstall} + /> + setDeleteTarget(null)} + onConfirm={handleDelete} + loading={deleting} + /> + setShowSearchEngineConfig(false)} + /> )}
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}
-
- -
-
-
- )} -
- - ); - })} -
- ); -}