diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index b14905f8..2aed3f80 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -1,5 +1,5 @@ import { request } from '@/utils/request' -import type { Query, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types' +import type { Query, MarketQuery, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types' // 工具列表 export const getTools = (data: Query) => { @@ -33,4 +33,44 @@ export const getToolDetail = (tool_id: string) => { } export const getToolMethods = (tool_id: string) => { return request.get(`/tools/${tool_id}/methods`) +} + +// MCP市场列表 +export const getMarketTools = (data: Query) => { + return request.get('/mcp_markets/mcp_markets', data) +} +// 市场配置创建 +export const createMarketConfig = (values: { + mcp_market_id: string; + token: string; + status: number; +}) => { + return request.post('/mcp_market_configs/mcp_market_config', values) +} +// 市场配置更新 +export const updateMarketConfig = (values: { + mcp_market_config_id: string; + token: string; + status: number; +}) => { + return request.put(`/mcp_market_configs/${values.mcp_market_config_id}`, values) +} +// 市场根据id获取配置 +export const getMarketConfig = (mcp_market_id: string) => { + return request.get(`/mcp_market_configs/mcp_market_id/${mcp_market_id}`) +} +// 市场MCP列表 +export const getMarketMCPs = (data: MarketQuery) => { + return request.get('/mcp_market_configs/mcp_servers', data) +} +// 根据配置ID serverId 获取MCP服务详情 +export const getMarketMCPDetail = (data:{ + mcp_market_config_id: string; + server_id: string; +}) => { + return request.get(`/mcp_market_configs/mcp_server`,data) +} +// 市场已激活MCP列表 +export const getMarketMCPsActivated = (data: MarketQuery) => { + return request.get('/mcp_market_configs/operational_mcp_servers', data) } \ No newline at end of file diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b9ab09b0..5b9504b4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1948,7 +1948,20 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re path: 'Path', viewDetail: 'View Details', textLink: 'Test Connection', - noResult: 'Processing results will be displayed here' + noResult: 'Processing results will be displayed here', + + marketConfig: 'Configure {{name}}', + marketSaveAndConnect: 'Save & Connect', + marketUrl: 'Market URL', + marketUrlPlaceholder: 'Market URL', + marketCopy: 'Copy', + marketApiKeyOptional: 'Optional', + marketApiKeyExtra: 'Some markets require an API Key to access the full service list', + marketApiKeyPlaceholder: 'Enter API Key to access more services', + marketConnectionStatus: 'Connection Status', + marketConnected: '● Connected', + marketDisconnected: '○ Disconnected', + marketConnecting: 'Connecting to {{name}}...', }, workflow: { coreNode: 'Core Nodes', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 71a20207..6d836ce9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1945,7 +1945,20 @@ export const zh = { path: '路径', viewDetail: '查看详情', textLink: '测试连接', - noResult: '处理结果将显示在这里' + noResult: '处理结果将显示在这里', + + marketConfig: '配置 {{name}}', + marketSaveAndConnect: '保存并连接', + marketUrl: '市场地址', + marketUrlPlaceholder: '市场地址', + marketCopy: '复制', + marketApiKeyOptional: '可选', + marketApiKeyExtra: '部分市场需要 API Key 才能获取完整的服务列表', + marketApiKeyPlaceholder: '输入 API Key 以获取更多服务', + marketConnectionStatus: '连接状态', + marketConnected: '● 已连接', + marketDisconnected: '○ 未连接', + marketConnecting: '正在连接 {{name}}...', }, workflow: { coreNode: '核心节点', diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index 59fbddcc..7a2df6df 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -1,122 +1,259 @@ -import React, { useState, useRef, type ReactNode } from 'react'; -import { Input, Button, Spin, App } from 'antd'; +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 } from '@/api/tools'; interface MarketSource { id: string; name: string; category: string; - icon: string; + logo_url: string; url: string; - desc: string; - apiKey: string; + description: string; + api_key?: string; connected: boolean; - mcpCount: number; + mcp_count: number; + created_at?: number; + created_by?: string; } interface MarketMcp { id: string; name: string; - provider: string; - type: string; - desc: string; - downloads?: string; - stars?: string; - icon: string; - configTemplate: any; + chinese_name?: string; + description: string; + logo_url: string; + publisher: string; + categories?: string[]; + tags?: string[]; + view_count?: number; + activated?: boolean; + locales?: { + [lang: string]: { + name: string; + description: string; + }; + }; } interface MarketCategory { id: string; name: string; - icon: string; +} + +interface MarketApiResponse { + items: MarketSource[]; } const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => { - const { t } = useTranslation(); + 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 [marketSources, setMarketSources] = useState([ - { id: 'smithery', name: 'Smithery', category: 'official', icon: '🔧', url: 'https://mcp.smithery.ai', desc: '官方 MCP 服务市场,提供丰富的 MCP 服务', apiKey: '', connected: false, mcpCount: 2847 }, - { id: 'mcpmarket', name: 'MCP Market', category: 'official', icon: '🏪', url: 'https://mcpmarket.com', desc: '综合性 MCP 市场平台', apiKey: '', connected: false, mcpCount: 1523 }, - { id: 'glama', name: 'Glama.ai MCP', category: 'official', icon: '✨', url: 'https://glama.ai/mcp', desc: 'Glama AI 提供的 MCP 服务集合', apiKey: '', connected: false, mcpCount: 892 }, - { id: 'github-mcp', name: 'modelcontextprotocol/servers', category: 'official', icon: '🐙', url: 'https://github.com/modelcontextprotocol/servers', desc: 'GitHub 官方 MCP 服务器仓库', apiKey: '', connected: true, mcpCount: 156 }, - { id: 'aliyun-bailian', name: '阿里云百炼 MCP', category: 'china-cloud', icon: '☁️', url: 'https://bailian.console.aliyun.com/mcp', desc: '阿里云百炼平台 MCP 市场', apiKey: '', connected: false, mcpCount: 423 }, - { id: 'modelscope', name: '魔搭社区 MCP', category: 'china-cloud', icon: '🎭', url: 'https://modelscope.cn/mcp', desc: '阿里达摩院魔搭社区 MCP 市场', apiKey: '', connected: false, mcpCount: 312 }, - ]); - - const [categories] = useState([ - { id: 'official', name: '官方/综合', icon: '🌐' }, - { id: 'china-cloud', name: '国内云', icon: '☁️' }, - { id: 'community', name: '社区/垂直', icon: '👥' } - ]); - - const [mcpCache, setMcpCache] = useState>({ - 'github-mcp': [ - { id: 'gh-1', name: 'Fetch', provider: 'modelcontextprotocol', type: 'Hosted', desc: '使用浏览器模拟大型语言模型检索和处理网页内容', downloads: '203.7m', stars: '308.2k', icon: '🌐', configTemplate: {} }, - { id: 'gh-2', name: 'Filesystem', provider: 'modelcontextprotocol', type: 'Local', desc: '安全的文件系统操作,支持读写文件和目录管理', downloads: '156.2m', stars: '245.1k', icon: '📁', configTemplate: {} }, - { id: 'gh-3', name: 'GitHub', provider: 'modelcontextprotocol', type: 'Hosted', desc: 'GitHub API 集成,支持仓库、Issue、PR 等操作', downloads: '89.4m', stars: '178.3k', icon: '🐙', configTemplate: {} }, - ] - }); - + 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; - const handleSelectSource = (sourceId: string) => { - setSelectedSource(sourceId); - }; - - const handleRefresh = (sourceId: string) => { - setLoading(true); - setTimeout(() => { - // 模拟刷新数据 - const source = marketSources.find(s => s.id === sourceId); - if (source) { - message.success(`${source.name} 列表已刷新`); + // 获取市场数据 + 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); + } + } + + 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) => ({ + ...item, + activated: activatedIds.includes(item.id) + })); + + 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); - }, 600); + } }; - const handleOpenConfig = (sourceId: string) => { + 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 handleConnect = (sourceId: string, apiKey: string) => { - // 更新市场源状态 + 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 toolItem = { + name: detail.name, + description: detail.description, + 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, - apiKey, - connected: true - }; + return { ...source, connected: true }; } return source; })); + setConfigIdMap(prev => ({ ...prev, [sourceId]: configId })); - // 模拟获取MCP列表 - setTimeout(() => { - const source = marketSources.find(s => s.id === sourceId); - if (source && !mcpCache[sourceId]) { - // 生成模拟数据 - const mockData: MarketMcp[] = [ - { id: `${sourceId}-1`, name: `${source.name} 服务 1`, provider: source.name, type: 'Hosted', desc: `来自 ${source.name} 的 MCP 服务`, downloads: '10.2m', stars: '23.4k', icon: '🔧', configTemplate: {} }, - { id: `${sourceId}-2`, name: `${source.name} 服务 2`, provider: source.name, type: 'Local', desc: `来自 ${source.name} 的本地 MCP 服务`, downloads: '8.5m', stars: '18.7k', icon: '⚙️', configTemplate: {} } - ]; - setMcpCache(prev => ({ - ...prev, - [sourceId]: mockData - })); + // 用 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 })); } - message.success(`已连接 ${source?.name}`); - }, 800); + if (res?.page) { + setMcpTotal(res.page.total || 0); + setHasMore(!!res.page.has_next); + setCurrentPage(1); + } + } catch (error) { + console.error('获取 MCP 列表失败:', error); + } }; const renderSourceDetail = () => { @@ -134,38 +271,45 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => if (!source) return null; const mcpList = mcpCache[selectedSource] || []; - const filteredList = mcpList.filter(mcp => - mcp.name.toLowerCase().includes(searchKeyword.toLowerCase()) || - mcp.desc.toLowerCase().includes(searchKeyword.toLowerCase()) - ); + 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.icon} +
+
+
+ {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}

-

{source.desc}

+
+

{source.name}

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

{source.description}

*/}
-
- - -
-
-
-
-

- 可用 MCP 服务 ({mcpList.length}) -

+
{source.connected && (
+ +
+
+
{mcpList.length > 0 ? ( - -
- {filteredList.map(mcp => ( +
+ } + scrollableTarget="mcpScrollableDiv" + > +
+ {filteredList.map(mcp => (
-
- {mcp.icon} +
+ {mcp.logo_url ? ( + {getLocaleField(mcp, { + e.currentTarget.style.display = 'none'; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = '🔧'; + parent.style.fontSize = '24px'; + } + }} + /> + ) : ( + 🔧 + )}
- - {mcp.type} - + {mcp.categories?.[0] && ( + + {mcp.categories[0]} + + )}
-

{mcp.name}

- {mcp.provider && ( +

{getLocaleField(mcp, 'name')}

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

{mcp.desc}

+

{getLocaleField(mcp, 'description')}

- {mcp.downloads && ( + {mcp.view_count != null && ( - {mcp.downloads} - - )} - {mcp.stars && ( - - ⭐ {mcp.stars} + {mcp.view_count.toLocaleString()} )}
-
-
))} -
- +
+
+
) : (
{source.connected ? '📭' : '🔌'}
@@ -254,50 +425,76 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => }; return ( -
+
{/* 左侧市场源列表 */} -
-
- MCP 市场 -
- {categories.map(cat => ( -
-
- {cat.icon} - {cat.name} -
-
- {marketSources - .filter(s => s.category === cat.id) - .map(source => ( -
handleSelectSource(source.id)} - > - {source.icon} - - {source.name} - - - {source.mcpCount} - - {source.connected && ( - - )} -
- ))} -
-
- ))} +
+ + {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()}
@@ -308,6 +505,10 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => ref={marketConfigModalRef} onConnect={handleConnect} /> + {}} + />
); }; diff --git a/web/src/views/ToolManagement/components/MarketConfigModal.tsx b/web/src/views/ToolManagement/components/MarketConfigModal.tsx index d1d87563..2b4496fa 100644 --- a/web/src/views/ToolManagement/components/MarketConfigModal.tsx +++ b/web/src/views/ToolManagement/components/MarketConfigModal.tsx @@ -2,6 +2,7 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; import { Form, Input, Button, App, Space } from 'antd'; import { useTranslation } from 'react-i18next'; import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; +import { createMarketConfig,updateMarketConfig } from '@/api/tools'; import RbModal from '@/components/RbModal'; const FormItem = Form.Item; @@ -9,15 +10,16 @@ const FormItem = Form.Item; interface MarketSource { id: string; name: string; - icon: string; + logo_url: string; url: string; - desc: string; - apiKey: string; + description: string; + token?: string; connected: boolean; + configId?: string; } interface MarketConfigModalProps { - onConnect: (sourceId: string, apiKey: string) => void; + onConnect: (sourceId: string, configId: string) => void; } export interface MarketConfigModalRef { @@ -47,8 +49,7 @@ const MarketConfigModal = forwardRef { setCurrentSource(source); form.setFieldsValue({ - url: source.url, - apiKey: source.apiKey, + token: source.token || '', }); setVisible(true); }; @@ -56,18 +57,36 @@ const MarketConfigModal = forwardRef { form .validateFields() - .then((values) => { + .then(async (values) => { if (!currentSource) return; setLoading(true); - - // 模拟连接延迟 - setTimeout(() => { - onConnect(currentSource.id, values.apiKey || ''); - message.success(`正在连接 ${currentSource.name}...`); - setLoading(false); + try { + let res: any; + if (currentSource.configId) { + // 更新配置 + res = await updateMarketConfig({ + mcp_market_config_id: currentSource.configId, + token: values.token || '', + status: 1, + }); + message.success(t('tool.marketConfigUpdated', { name: currentSource.name })); + } else { + // 创建配置 + res = await createMarketConfig({ + mcp_market_id: currentSource.id || '', + token: values.token || '', + status: 1, + }); + message.success(t('tool.marketConnecting', { name: currentSource.name })); + } + onConnect(currentSource.id, res.id || currentSource.configId); handleClose(); - }, 500); + } catch (error) { + console.error('保存配置失败:', error); + } finally { + setLoading(false); + } }) .catch((err) => { console.log('表单验证失败:', err); @@ -91,10 +110,10 @@ const MarketConfigModal = forwardRef {/* 市场源信息头部 */}
-
- {currentSource.icon} +
+ {currentSource.logo_url ? ( + {currentSource.name} { + e.currentTarget.style.display = 'none'; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = '🏪'; + parent.style.fontSize = '32px'; + } + }} + /> + ) : ( + 🏪 + )}

{currentSource.name}

-

{currentSource.desc}

+

{currentSource.description}

@@ -115,39 +150,34 @@ const MarketConfigModal = forwardRef - {/* 市场地址 */} - + - {/* API Key */} - API Key (可选) + API Key ({t('tool.marketApiKeyOptional')}) } - extra="部分市场需要 API Key 才能获取完整的服务列表" + extra={{t('tool.marketApiKeyExtra')}} >
); }; diff --git a/web/src/views/ToolManagement/types.ts b/web/src/views/ToolManagement/types.ts index aa97db66..98976e28 100644 --- a/web/src/views/ToolManagement/types.ts +++ b/web/src/views/ToolManagement/types.ts @@ -136,4 +136,10 @@ export interface ExecuteData { export interface CustomToolModalRef { handleOpen: (data?: ToolItem) => void; handleClose: () => void; +} + +export interface MarketQuery { + mcp_market_config_id?: string; + page?: number; + pagesize?: number; } \ No newline at end of file