import React, { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; import { Input, Button, App, Card, Space, Skeleton, Tag } from 'antd'; import { SearchOutlined, SettingOutlined, GlobalOutlined, SyncOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import InfiniteScroll from 'react-infinite-scroll-component'; import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal'; import McpServiceModal from './components/McpServiceModal'; import type { McpServiceModalRef } from './types'; import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools'; interface MarketSource { id: string; name: string; category: string; logo_url: string; url: string; description: string; api_key?: string; connected: boolean; mcp_count: number; created_at?: number; created_by?: string; } interface MarketMcp { id: string; name: string; chinese_name?: string; description: string; logo_url: string; publisher: string; categories?: string[]; tags?: string[]; view_count?: number; activated?: boolean; inDatabase?: boolean; locales?: { [lang: string]: { name: string; description: string; }; }; } interface MarketCategory { id: string; name: string; } interface MarketApiResponse { items: MarketSource[]; } const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => { const { t, i18n } = useTranslation(); const { message } = App.useApp(); const getLocaleField = (mcp: MarketMcp, field: 'name' | 'description') => { const lang = i18n.language?.startsWith('zh') ? 'zh' : 'en'; return mcp.locales?.[lang]?.[field] || mcp[field] || ''; }; const [loading, setLoading] = useState(false); const [selectedSource, setSelectedSource] = useState(null); const marketConfigModalRef = useRef(null); const mcpServiceModalRef = useRef(null); const [marketSources, setMarketSources] = useState([]); const [categories, setCategories] = useState([]); const [mcpCache, setMcpCache] = useState>({}); const [mcpTotal, setMcpTotal] = useState(0); const [searchKeyword, setSearchKeyword] = useState(''); const [configIdMap, setConfigIdMap] = useState>({}); const [hasMore, setHasMore] = useState(false); const [activatedMcps, setActivatedMcps] = useState([]); const [currentPage, setCurrentPage] = useState(1); const pageSize = 20; // 获取市场数据 useEffect(() => { const fetchMarketData = async () => { setLoading(true); try { const response = await getMarketTools({}) as MarketApiResponse; if (response?.items && Array.isArray(response.items)) { setMarketSources(response.items); // 根据 category 字段分组 const categoryMap = new Map(); response.items.forEach(item => { if (item.category && !categoryMap.has(item.category)) { categoryMap.set(item.category, { id: item.category, name: item.category }); } }); setCategories(Array.from(categoryMap.values())); } } catch (error) { console.error('获取市场数据失败:', error); message.error('获取市场数据失败'); } finally { setLoading(false); } }; fetchMarketData(); }, [message]); const fetchMcpList = async (sourceId: string, page = 1, append = false) => { setLoading(true); try { let configId = configIdMap[sourceId]; // 如果没有缓存 configId,先获取配置 if (!configId) { const config: any = await getMarketConfig(sourceId); if (config?.id) { configId = config.id; setConfigIdMap(prev => ({ ...prev, [sourceId]: configId })); } else { return; } } // 第一次加载时获取已激活列表 let activatedIds: string[] = activatedMcps; if (page === 1 && !append) { const activatedRes: any = await getMarketMCPsActivated({ mcp_market_config_id: configId }); if (activatedRes && Array.isArray(activatedRes)) { activatedIds = activatedRes.map((item: any) => item.id); setActivatedMcps(activatedIds); } } // 获取全量工具列表,用于标记已入库的 MCP const allTools: any = await getTools({ tool_type: 'mcp' }); const toolsList = Array.isArray(allTools) ? allTools : []; const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page, pagesize: pageSize }); if (res?.items && Array.isArray(res.items)) { // 标记已激活和已入库的 MCP const mcpsWithActivated = res.items.map((item: MarketMcp) => { // 检查是否已入库:market_id = sourceId, market_config_id = configId, mcp_service_id = item.id const isInDatabase = toolsList.some((tool: any) => tool.config_data?.market_id === sourceId && tool.config_data?.market_config_id === configId && tool.config_data?.mcp_service_id === item.id ); return { ...item, activated: activatedIds.includes(item.id), inDatabase: isInDatabase }; }); setMcpCache(prev => ({ ...prev, [sourceId]: append ? [...(prev[sourceId] || []), ...mcpsWithActivated] : mcpsWithActivated })); } if (res?.page) { setMcpTotal(res.page.total || 0); setHasMore(!!res.page.has_next); setCurrentPage(res.page.page || page); } } catch (error) { console.error('获取 MCP 列表失败:', error); } finally { setLoading(false); } }; const loadMore = useCallback(() => { if (!selectedSource || loading) return; fetchMcpList(selectedSource, currentPage + 1, true); }, [selectedSource, currentPage, loading]); const handleSelectSource = async (sourceId: string) => { setSelectedSource(sourceId); setSearchKeyword(''); setCurrentPage(1); setHasMore(false); setMcpTotal(0); // 如果缓存中已有数据,直接使用 if (mcpCache[sourceId]) return; await fetchMcpList(sourceId, 1); }; const handleRefresh = async (sourceId: string) => { // 清除缓存,重新从第一页加载 setMcpCache(prev => { const next = { ...prev }; delete next[sourceId]; return next; }); setCurrentPage(1); await fetchMcpList(sourceId, 1); const source = marketSources.find(s => s.id === sourceId); if (source) { message.success(`${source.name} 列表已刷新`); } }; const handleOpenConfig = async (sourceId: string) => { const source = marketSources.find(s => s.id === sourceId); if (!source) return; try { const config: any = await getMarketConfig(sourceId); marketConfigModalRef.current?.handleOpen({ ...source, connected: config?.status === 1, token: config?.token || '', configId: config?.id || '', }); } catch { marketConfigModalRef.current?.handleOpen(source); } }; const handleOpenMcpServiceModal = async (mcp: MarketMcp) => { if (!selectedSource || !configIdMap[selectedSource]) return; try { const detail: any = await getMarketMCPDetail({ mcp_market_config_id: configIdMap[selectedSource], server_id: mcp.id, }); const source = marketSources.find(s => s.id === selectedSource); const toolItem = { name: detail.name, description: detail.description, source_channel: source?.name || '', market_id: selectedSource, market_config_id: configIdMap[selectedSource], mcp_service_id: mcp.id, config_data: { server_url: detail.servers?.[0]?.url || '', connection_config: { auth_type: 'none', timeout: 30, headers: {}, }, }, }; mcpServiceModalRef.current?.handleOpen(toolItem as any); } catch (error) { console.error('获取 MCP 服务详情失败:', error); } }; const handleConnect = async (sourceId: string, configId: string) => { // 更新市场源状态,缓存 configId setMarketSources(prev => prev.map(source => { if (source.id === sourceId) { return { ...source, connected: true }; } return source; })); setConfigIdMap(prev => ({ ...prev, [sourceId]: configId })); // 用 configId 获取第一页 MCP 列表 try { const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page: 1, pagesize: pageSize }); if (res?.items && Array.isArray(res.items)) { setMcpCache(prev => ({ ...prev, [sourceId]: res.items })); } if (res?.page) { setMcpTotal(res.page.total || 0); setHasMore(!!res.page.has_next); setCurrentPage(1); } } catch (error) { console.error('获取 MCP 列表失败:', error); } }; const renderSourceDetail = () => { if (!selectedSource) { return (
🏪

选择一个 MCP 市场

从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务

); } const source = marketSources.find(s => s.id === selectedSource); if (!source) return null; const mcpList = mcpCache[selectedSource] || []; const filteredList = mcpList.filter(mcp => { const name = getLocaleField(mcp, 'name'); const desc = getLocaleField(mcp, 'description'); return name.toLowerCase().includes(searchKeyword.toLowerCase()) || desc.toLowerCase().includes(searchKeyword.toLowerCase()); }); return ( <>
{source.logo_url ? ( {source.name} { e.currentTarget.style.display = 'none'; const parent = e.currentTarget.parentElement; if (parent) { parent.innerHTML = '🏪'; parent.style.fontSize = '48px'; } }} /> ) : ( 🏪 )}

{source.name}

可用 MCP 服务 ({mcpTotal}) {/*

{source.description}

*/}
{source.connected && ( )} {mcpList.length > 0 && ( } placeholder="搜索服务..." value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)} style={{ width: 200 }} /> )}
{mcpList.length > 0 ? (
} scrollableTarget="mcpScrollableDiv" >
{filteredList.map(mcp => (
{mcp.logo_url ? ( {getLocaleField(mcp, { e.currentTarget.style.display = 'none'; const parent = e.currentTarget.parentElement; if (parent) { parent.innerHTML = '🔧'; parent.style.fontSize = '24px'; } }} /> ) : ( 🔧 )}
{mcp.categories?.[0] && ( {mcp.categories[0]} )}

{getLocaleField(mcp, 'name')}

{mcp.publisher && (
{mcp.publisher.startsWith('@') ? mcp.publisher : `@${mcp.publisher}`}
)}

{getLocaleField(mcp, 'description')}

{mcp.view_count != null && ( {mcp.view_count.toLocaleString()} )}
{mcp.activated && 已激活} {mcp.inDatabase && 已入库}
))}
) : (
{source.connected ? '📭' : '🔌'}

{source.connected ? '暂无可用的 MCP 服务' : '尚未连接此市场'}

{source.connected ? '该市场暂时没有可用的服务' : '点击右上角"配置"按钮设置连接信息'}

{!source.connected && ( )}
)}
); }; return (
{/* 左侧市场源列表 */}
{categories.map(cat => ( {cat.name}
} classNames={{ body: "rb:p-[10px]!", header: "rb:bg-[#F6F8FC]!" }} > {marketSources .filter(s => s.category === cat.id) .map(source => (
handleSelectSource(source.id)} >
{source.logo_url ? ( {source.name} { e.currentTarget.style.display = 'none'; const parent = e.currentTarget.parentElement; if (parent) { parent.innerHTML = '🏪'; parent.style.fontSize = '16px'; } }} /> ) : ( 🏪 )}
{source.name} {source.mcp_count} {source.connected && ( )}
))}
))}
{/* 右侧内容区 */}
{renderSourceDetail()}
{/* 配置弹窗 */} {}} /> ); }; export default Market;