From b9340ba02d347a97ed4e41a8b04747eee7892c26 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 11 Mar 2026 15:16:02 +0800 Subject: [PATCH 1/5] feat(web): model api key add request abort --- web/src/api/models.ts | 16 +++++++------- .../components/CustomModelModal.tsx | 19 ++++++++++------- .../components/KeyConfigModal.tsx | 21 ++++++++++++------- .../components/MultiKeyConfigModal.tsx | 12 +++++++---- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/web/src/api/models.ts b/web/src/api/models.ts index 2d590287..7af412f8 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -41,12 +41,12 @@ export const deleteCompositeModel = (model_id: string) => { return request.delete(`/models/composite/${model_id}`) } // Create API keys for all matching models by provider -export const updateProviderApiKeys = (data: KeyConfigModalForm) => { - return request.post('/models/provider/apikeys', data) +export const updateProviderApiKeys = (data: KeyConfigModalForm, signal?: AbortSignal) => { + return request.post('/models/provider/apikeys', data, { signal }) } // Create model API key -export const addModelApiKey = (model_id: string, data: MultiKeyForm) => { - return request.post(`/models/${model_id}/apikeys`, data) +export const addModelApiKey = (model_id: string, data: MultiKeyForm, signal?: AbortSignal) => { + return request.post(`/models/${model_id}/apikeys`, data, { signal }) } // Delete model API key export const deleteModelApiKey = (api_key_id: string) => { @@ -65,10 +65,10 @@ export const addModelPlaza = (model_base_id: string) => { return request.post(`/models/model_plaza/${model_base_id}/add`) } // Create custom model -export const addCustomModel = (data: CustomModelForm) => { - return request.post('/models', data) +export const addCustomModel = (data: CustomModelForm, signal?: AbortSignal) => { + return request.post('/models', data, { signal }) } // Update custom model -export const updateCustomModel = (model_base_id: string, data: CustomModelForm) => { - return request.put(`/models/${model_base_id}`, data) +export const updateCustomModel = (model_base_id: string, data: CustomModelForm, signal?: AbortSignal) => { + return request.put(`/models/${model_base_id}`, data, { signal }) } \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index d47fc996..25c7385c 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:49:28 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-04 11:31:43 + * @Last Modified time: 2026-03-11 15:08:24 */ /** * Custom Model Modal @@ -11,7 +11,7 @@ */ import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; -import { Form, Input, App, Checkbox } from 'antd'; +import { Form, Input, App, Checkbox, Button } from 'antd'; import { useTranslation } from 'react-i18next'; import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types'; @@ -35,6 +35,7 @@ const CustomModelModal = forwardRef( const [isEdit, setIsEdit] = useState(false); const [form] = Form.useForm(); const [loading, setLoading] = useState(false) + const [abortController, setAbortController] = useState(null) const modelType = Form.useWatch(['type'], form); const isOmni = Form.useWatch(['is_omni'], form); @@ -46,6 +47,8 @@ const CustomModelModal = forwardRef( /** Close modal and reset state */ const handleClose = () => { + abortController?.abort() + setAbortController(null) setModel({} as ModelListItem); form.resetFields(); setLoading(false) @@ -73,8 +76,10 @@ const CustomModelModal = forwardRef( /** Update or create custom model */ const handleUpdate = (data: CustomModelForm) => { setLoading(true) + const controller = new AbortController() + setAbortController(controller) const { type, provider, ...rest} = data - const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data) + const res = isEdit ? updateCustomModel(model.id, rest, controller.signal) : addCustomModel(data, controller.signal) res.then(() => { refresh?.(isEdit) @@ -124,15 +129,15 @@ const CustomModelModal = forwardRef( useImperativeHandle(ref, () => ({ handleOpen, })); - console.log('modelType', modelType) return ( {t('common.cancel')}, + , + ]} >
(({ const [model, setModel] = useState({} as ProviderModelItem); const [form] = Form.useForm(); const [loading, setLoading] = useState(false) + const [abortController, setAbortController] = useState(null) /** Close modal and reset state */ const handleClose = () => { + abortController?.abort() + setAbortController(null) setModel({} as ProviderModelItem); form.resetFields(); setLoading(false) @@ -51,10 +54,13 @@ const KeyConfigModal = forwardRef(({ .then((values) => { setLoading(true) + const controller = new AbortController() + setAbortController(controller) + updateProviderApiKeys({ ...values, provider: model.provider - }).then((res) => { + }, controller.signal).then((res) => { if (refresh) { refresh(); } @@ -81,9 +87,10 @@ const KeyConfigModal = forwardRef(({ title={`${model.provider} - ${t('modelNew.keyConfig')}`} open={visible} onCancel={handleClose} - okText={t(`common.save`)} - onOk={handleSave} - confirmLoading={loading} + footer={[ + , + , + ]} > ({} as ModelListItem); const [form] = Form.useForm(); const [loading, setLoading] = useState(false) + const [abortController, setAbortController] = useState(null) /** Close modal and refresh parent */ const handleClose = () => { + abortController?.abort() + setAbortController(null) setModel({} as ModelListItem); refresh?.() @@ -60,12 +63,14 @@ const MultiKeyConfigModal = forwardRef { setLoading(true) + const controller = new AbortController() + setAbortController(controller) addModelApiKey(model.id, { ...values, model_config_id: model.id, model_name: model.name, provider: model.provider, - }).then(() => { + }, controller.signal).then(() => { message.success(t('common.saveSuccess')) form.resetFields(); getData(model) @@ -98,7 +103,6 @@ const MultiKeyConfigModal = forwardRef {model.api_keys && model.api_keys.length > 0 && (
From 168cce1678395d926347174c1a217dacc2403729 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Wed, 11 Mar 2026 17:11:16 +0800 Subject: [PATCH 2/5] feat(web): improve MCP market UI responsiveness and add refresh after service addition - Change getMarketTools parameter type from Query to optional Record for flexibility - Rename marketConfig i18n key to marketConfigBtn for clarity and consistency - Add handleRefreshAfterAdd function to refresh MCP list after successful service addition - Update grid layout to use auto-fill responsive columns instead of fixed 3-column layout - Disable Add button for services already in database to prevent duplicate additions - Connect McpServiceModal refresh callback to handleRefreshAfterAdd for cache invalidation - Improves user experience by automatically updating market list after adding services --- web/src/api/tools.ts | 2 +- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- web/src/views/ToolManagement/Market.tsx | 28 +++++++++++++++++++++---- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index 2aed3f80..7c7a0e3d 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -36,7 +36,7 @@ export const getToolMethods = (tool_id: string) => { } // MCP市场列表 -export const getMarketTools = (data: Query) => { +export const getMarketTools = (data?: Record) => { return request.get('/mcp_markets/mcp_markets', data) } // 市场配置创建 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 11be15b4..80d51554 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1818,7 +1818,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re marketInDatabase: 'In Database', marketAdd: 'Add', marketRefresh: 'Refresh', - marketConfig: 'Configure', + marketConfigBtn: 'Configure', marketConfigConnection: 'Configure Connection', marketNoServices: 'No MCP Services Available', marketNotConnected: 'Not Connected to This Market', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 94c145eb..d2a39ad4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1814,7 +1814,7 @@ export const zh = { marketInDatabase: '已入库', marketAdd: '添加', marketRefresh: '刷新', - marketConfig: '配置', + marketConfigBtn: '配置', marketConfigConnection: '配置连接', marketNoServices: '暂无可用的 MCP 服务', marketNotConnected: '尚未连接此市场', diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index 5297903e..9bcf5f67 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -279,6 +279,20 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => } }; + const handleRefreshAfterAdd = async () => { + // 添加成功后,刷新当前选中的市场源的 MCP 列表 + if (!selectedSource) return; + + // 清除缓存并重新加载,这样会重新获取工具列表并更新 inDatabase 标记 + setMcpCache(prev => { + const next = { ...prev }; + delete next[selectedSource]; + return next; + }); + setCurrentPage(1); + await fetchMcpList(selectedSource, 1); + }; + const renderSourceDetail = () => { if (!selectedSource) { return ( @@ -355,7 +369,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => )}
@@ -523,7 +543,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => /> {}} + refresh={handleRefreshAfterAdd} /> ); From a3e6f67ff707c169b04a22cd3c41d826197d56aa Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 11 Mar 2026 17:19:07 +0800 Subject: [PATCH 3/5] fix(tool): The MCP tool checks for duplicate additions from the main screen and performs a test before adding. --- api/app/controllers/tool_controller.py | 5 +- api/app/services/tool_service.py | 81 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/api/app/controllers/tool_controller.py b/api/app/controllers/tool_controller.py index ce5b15c0..10ca83af 100644 --- a/api/app/controllers/tool_controller.py +++ b/api/app/controllers/tool_controller.py @@ -14,6 +14,7 @@ from app.models import User from app.models.tool_model import ToolType, ToolStatus, AuthType from app.services.tool_service import ToolService from app.schemas.response_schema import ApiResponse +from app.core.exceptions import BusinessException router = APIRouter(prefix="/tools", tags=["Tool System"]) @@ -103,7 +104,7 @@ async def create_tool( val = getattr(request, key, None) if val is not None: request.config[key] = val - tool_id = service.create_tool( + tool_id = await service.create_tool( name=request.name, tool_type=request.tool_type, tenant_id=current_user.tenant_id, @@ -113,6 +114,8 @@ async def create_tool( tags=request.tags ) return success(data={"tool_id": tool_id}, msg="工具创建成功") + except BusinessException as e: + raise HTTPException(status_code=400, detail=e.message) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: diff --git a/api/app/services/tool_service.py b/api/app/services/tool_service.py index 4fe1e9e6..23def7f8 100644 --- a/api/app/services/tool_service.py +++ b/api/app/services/tool_service.py @@ -93,7 +93,44 @@ class ToolService: if query.first(): raise BusinessException(f"工具名称 '{name}' 已存在", BizCode.DUPLICATE_NAME) - def create_tool( + def _check_mcp_duplicate(self, name: str, tool_type: ToolType, tenant_id: uuid.UUID, config: Dict[str, Any]): + """检查MCP工具是否重复:市场来源按market_id+market_config_id+mcp_service_id判断(名称无关),自建按name+tool_type判断""" + from app.models.tool_model import MCPSourceChannel + source_channel = config.get("source_channel") + is_market_source = ( + source_channel is not None + and source_channel != MCPSourceChannel.SELF_HOSTED + ) + if is_market_source: + exists = ( + self.db.query(ToolConfig) + .join(MCPToolConfig, MCPToolConfig.id == ToolConfig.id) + .filter( + ToolConfig.tenant_id == tenant_id, + ToolConfig.tool_type == tool_type, + MCPToolConfig.source_channel == source_channel, + MCPToolConfig.market_id == config.get("market_id"), + MCPToolConfig.market_config_id == config.get("market_config_id"), + MCPToolConfig.mcp_service_id == config.get("mcp_service_id"), + ) + .first() + ) + if exists: + raise BusinessException(f"该MCP服务已添加", BizCode.DUPLICATE_NAME) + else: + exists = ( + self.db.query(ToolConfig) + .filter( + ToolConfig.name == name, + ToolConfig.tool_type == tool_type, + ToolConfig.tenant_id == tenant_id, + ) + .first() + ) + if exists: + raise BusinessException(f"工具 '{name}' 已存在", BizCode.DUPLICATE_NAME) + + async def create_tool( self, name: str, tool_type: ToolType, @@ -106,7 +143,19 @@ class ToolService: """创建工具""" if tool_type == ToolType.BUILTIN: raise ValueError("内置工具不允许创建") - self._check_name_duplicate(name, tool_type, tenant_id) + + cfg = config or {} + if tool_type == ToolType.MCP: + self._check_mcp_duplicate(name, tool_type, tenant_id, cfg) + # 创建前测试连接 + test_result = await self._test_mcp_connection_by_config(cfg) + if not test_result["success"]: + raise BusinessException(f"MCP连接测试失败: {test_result['message']}", BizCode.INVALID_PARAMETER) + # 将发现的工具列表写回 config + if "available_tools" in test_result: + cfg["available_tools"] = test_result["available_tools"] + else: + self._check_name_duplicate(name, tool_type, tenant_id) try: # 创建基础配置 @@ -117,19 +166,22 @@ class ToolService: tool_type=tool_type.value, tenant_id=tenant_id, status=ToolStatus.AVAILABLE.value, - config_data=config or {}, + config_data=cfg, tags=tags ) self.db.add(tool_config) self.db.flush() # 创建类型特定配置 - self._create_type_config(tool_config, config or {}) + self._create_type_config(tool_config, cfg) self.db.commit() logger.info(f"工具创建成功: {tool_config.id}") return str(tool_config.id) + except BusinessException: + self.db.rollback() + raise except Exception as e: self.db.rollback() logger.error(f"创建工具失败: {e}") @@ -1165,6 +1217,27 @@ class ToolService: logger.error(f"加载内置工具配置失败: {e}") return {} + async def _test_mcp_connection_by_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """根据配置参数直接测试MCP连接(创建前调用,无需已存在的工具记录)""" + server_url = config.get("server_url") + if not server_url: + return {"success": False, "message": "server_url不能为空"} + connection_config = config.get("connection_config") or {} + try: + test_result = await self.mcp_tool_manager.test_tool_connection(server_url, connection_config) + if not test_result["success"]: + return test_result + success_flag, tools, error = await self.mcp_tool_manager.discover_tools(server_url, connection_config) + if not success_flag: + return {"success": False, "message": f"获取工具列表失败: {error}"} + tool_list = [ + {tool["name"]: {"description": tool.get("description", ""), "inputSchema": tool.get("inputSchema", {})}} + for tool in tools if tool.get("name") + ] + return {"success": True, "message": "MCP连接测试成功", "available_tools": tool_list} + except Exception as e: + return {"success": False, "message": f"连接测试异常: {str(e)}"} + async def _test_mcp_connection(self, config: ToolConfig) -> Dict[str, Any]: """测试MCP连接并自动同步工具列表""" try: From 59618457df158fadb7fbcceb3ad483b2fc1605a7 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Wed, 11 Mar 2026 18:24:46 +0800 Subject: [PATCH 4/5] feat(web): add search functionality and empty states to MCP market - Add search input with debouncing (500ms) to filter MCP services by keywords - Implement server-side search via keywords parameter in getMarketMCPs API call - Add new i18n strings for empty states: marketNoData, marketNoDataDesc, marketNoSearchResult, marketNoSearchResultDesc - Replace client-side filtering with server-side search for better performance - Update Empty component display to show different messages for no data vs no search results - Remove BodyWrapper component and implement custom empty state handling - Add searchTimerRef to manage debounce timer lifecycle - Update loadMore callback to include searchKeyword parameter for pagination consistency - Add allowClear prop to search input for better UX - Remove conditional rendering of search input to keep it always visible --- web/src/i18n/en.ts | 4 + web/src/i18n/zh.ts | 4 + web/src/views/ToolManagement/Market.tsx | 87 ++++++++++++++----- .../components/McpServiceModal.tsx | 14 +-- web/src/views/ToolManagement/types.ts | 1 + 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 6d1e7f6d..62f404aa 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1820,6 +1820,10 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re marketRefresh: 'Refresh', marketConfigBtn: 'Configure', marketConfigConnection: 'Configure Connection', + marketNoData: 'No Data', + marketNoDataDesc: 'This market currently has no available services', + marketNoSearchResult: 'No Search Results', + marketNoSearchResultDesc: 'No matching services found, please try other keywords', marketNoServices: 'No MCP Services Available', marketNotConnected: 'Not Connected to This Market', marketNoServicesDesc: 'This market currently has no available services', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index e2308aee..387c67c3 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1816,6 +1816,10 @@ export const zh = { marketRefresh: '刷新', marketConfigBtn: '配置', marketConfigConnection: '配置连接', + marketNoData: '暂无数据', + marketNoDataDesc: '该市场暂时没有可用的服务', + marketNoSearchResult: '无搜索结果', + marketNoSearchResultDesc: '未找到匹配的服务,请尝试其他关键词', marketNoServices: '暂无可用的 MCP 服务', marketNotConnected: '尚未连接此市场', marketNoServicesDesc: '该市场暂时没有可用的服务', diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index 9bcf5f67..351ae8b7 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -9,7 +9,6 @@ import type { McpServiceModalRef } from './types'; import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png' import Empty from '@/components/Empty/index' import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools'; -import BodyWrapper from '@/components/Empty/BodyWrapper'; interface MarketSource { id: string; name: string; @@ -75,6 +74,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => const [activatedMcps, setActivatedMcps] = useState([]); const [currentPage, setCurrentPage] = useState(1); const pageSize = 20; + const searchTimerRef = useRef(null); // 获取市场数据 useEffect(() => { @@ -109,7 +109,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => fetchMarketData(); }, [message]); - const fetchMcpList = async (sourceId: string, page = 1, append = false) => { + const fetchMcpList = async (sourceId: string, page = 1, append = false, keywords = '') => { setLoading(true); try { let configId = configIdMap[sourceId]; @@ -139,7 +139,12 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => 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 }); + const res: any = await getMarketMCPs({ + mcp_market_config_id: configId, + page, + pagesize: pageSize, + ...(keywords ? { keywords } : {}) + }); if (res?.items && Array.isArray(res.items)) { // 标记已激活和已入库的 MCP const mcpsWithActivated = res.items.map((item: MarketMcp) => { @@ -176,8 +181,46 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => const loadMore = useCallback(() => { if (!selectedSource || loading) return; - fetchMcpList(selectedSource, currentPage + 1, true); - }, [selectedSource, currentPage, loading]); + fetchMcpList(selectedSource, currentPage + 1, true, searchKeyword); + }, [selectedSource, currentPage, loading, searchKeyword]); + + const handleSearchChange = (value: string) => { + setSearchKeyword(value); + + // 清除之前的定时器 + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + // 如果清空搜索框,恢复原始列表 + if (!value.trim()) { + if (selectedSource) { + // 清除缓存,重新加载原始列表 + setMcpCache(prev => { + const next = { ...prev }; + delete next[selectedSource]; + return next; + }); + setCurrentPage(1); + fetchMcpList(selectedSource, 1, false, ''); + } + return; + } + + // 设置新的定时器,500ms 后执行搜索 + searchTimerRef.current = setTimeout(() => { + if (selectedSource) { + // 清除缓存,重新搜索 + setMcpCache(prev => { + const next = { ...prev }; + delete next[selectedSource]; + return next; + }); + setCurrentPage(1); + fetchMcpList(selectedSource, 1, false, value); + } + }, 500); + }; const handleSelectSource = async (sourceId: string) => { setSelectedSource(sourceId); @@ -313,12 +356,6 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => 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 ( <> @@ -358,15 +395,17 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => {t('tool.marketRefresh')} )} - {mcpList.length > 0 && ( + } placeholder={t('tool.marketSearchPlaceholder')} value={searchKeyword} - onChange={(e) => setSearchKeyword(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} + allowClear style={{ width: 200 }} + /> - )} +