Merge pull request #538 from SuanmoSuanyangTechnology/release/v0.2.7
Release/v0.2.7
This commit is contained in:
@@ -65,7 +65,7 @@ celery_app.conf.update(
|
|||||||
|
|
||||||
# 时区
|
# 时区
|
||||||
timezone='Asia/Shanghai',
|
timezone='Asia/Shanghai',
|
||||||
enable_utc=True,
|
enable_utc=False,
|
||||||
|
|
||||||
# 任务追踪
|
# 任务追踪
|
||||||
task_track_started=True,
|
task_track_started=True,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models import User
|
|||||||
from app.models.tool_model import ToolType, ToolStatus, AuthType
|
from app.models.tool_model import ToolType, ToolStatus, AuthType
|
||||||
from app.services.tool_service import ToolService
|
from app.services.tool_service import ToolService
|
||||||
from app.schemas.response_schema import ApiResponse
|
from app.schemas.response_schema import ApiResponse
|
||||||
|
from app.core.exceptions import BusinessException
|
||||||
|
|
||||||
router = APIRouter(prefix="/tools", tags=["Tool System"])
|
router = APIRouter(prefix="/tools", tags=["Tool System"])
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ async def create_tool(
|
|||||||
val = getattr(request, key, None)
|
val = getattr(request, key, None)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
request.config[key] = val
|
request.config[key] = val
|
||||||
tool_id = service.create_tool(
|
tool_id = await service.create_tool(
|
||||||
name=request.name,
|
name=request.name,
|
||||||
tool_type=request.tool_type,
|
tool_type=request.tool_type,
|
||||||
tenant_id=current_user.tenant_id,
|
tenant_id=current_user.tenant_id,
|
||||||
@@ -113,6 +114,8 @@ async def create_tool(
|
|||||||
tags=request.tags
|
tags=request.tags
|
||||||
)
|
)
|
||||||
return success(data={"tool_id": tool_id}, msg="工具创建成功")
|
return success(data={"tool_id": tool_id}, msg="工具创建成功")
|
||||||
|
except BusinessException as e:
|
||||||
|
raise HTTPException(status_code=400, detail=e.message)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -93,7 +93,44 @@ class ToolService:
|
|||||||
if query.first():
|
if query.first():
|
||||||
raise BusinessException(f"工具名称 '{name}' 已存在", BizCode.DUPLICATE_NAME)
|
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,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
tool_type: ToolType,
|
tool_type: ToolType,
|
||||||
@@ -106,7 +143,19 @@ class ToolService:
|
|||||||
"""创建工具"""
|
"""创建工具"""
|
||||||
if tool_type == ToolType.BUILTIN:
|
if tool_type == ToolType.BUILTIN:
|
||||||
raise ValueError("内置工具不允许创建")
|
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:
|
try:
|
||||||
# 创建基础配置
|
# 创建基础配置
|
||||||
@@ -117,19 +166,22 @@ class ToolService:
|
|||||||
tool_type=tool_type.value,
|
tool_type=tool_type.value,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
status=ToolStatus.AVAILABLE.value,
|
status=ToolStatus.AVAILABLE.value,
|
||||||
config_data=config or {},
|
config_data=cfg,
|
||||||
tags=tags
|
tags=tags
|
||||||
)
|
)
|
||||||
self.db.add(tool_config)
|
self.db.add(tool_config)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
# 创建类型特定配置
|
# 创建类型特定配置
|
||||||
self._create_type_config(tool_config, config or {})
|
self._create_type_config(tool_config, cfg)
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
logger.info(f"工具创建成功: {tool_config.id}")
|
logger.info(f"工具创建成功: {tool_config.id}")
|
||||||
return str(tool_config.id)
|
return str(tool_config.id)
|
||||||
|
|
||||||
|
except BusinessException:
|
||||||
|
self.db.rollback()
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
logger.error(f"创建工具失败: {e}")
|
logger.error(f"创建工具失败: {e}")
|
||||||
@@ -1165,6 +1217,27 @@ class ToolService:
|
|||||||
logger.error(f"加载内置工具配置失败: {e}")
|
logger.error(f"加载内置工具配置失败: {e}")
|
||||||
return {}
|
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]:
|
async def _test_mcp_connection(self, config: ToolConfig) -> Dict[str, Any]:
|
||||||
"""测试MCP连接并自动同步工具列表"""
|
"""测试MCP连接并自动同步工具列表"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export const deleteCompositeModel = (model_id: string) => {
|
|||||||
return request.delete(`/models/composite/${model_id}`)
|
return request.delete(`/models/composite/${model_id}`)
|
||||||
}
|
}
|
||||||
// Create API keys for all matching models by provider
|
// Create API keys for all matching models by provider
|
||||||
export const updateProviderApiKeys = (data: KeyConfigModalForm) => {
|
export const updateProviderApiKeys = (data: KeyConfigModalForm, signal?: AbortSignal) => {
|
||||||
return request.post('/models/provider/apikeys', data)
|
return request.post('/models/provider/apikeys', data, { signal })
|
||||||
}
|
}
|
||||||
// Create model API key
|
// Create model API key
|
||||||
export const addModelApiKey = (model_id: string, data: MultiKeyForm) => {
|
export const addModelApiKey = (model_id: string, data: MultiKeyForm, signal?: AbortSignal) => {
|
||||||
return request.post(`/models/${model_id}/apikeys`, data)
|
return request.post(`/models/${model_id}/apikeys`, data, { signal })
|
||||||
}
|
}
|
||||||
// Delete model API key
|
// Delete model API key
|
||||||
export const deleteModelApiKey = (api_key_id: string) => {
|
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`)
|
return request.post(`/models/model_plaza/${model_base_id}/add`)
|
||||||
}
|
}
|
||||||
// Create custom model
|
// Create custom model
|
||||||
export const addCustomModel = (data: CustomModelForm) => {
|
export const addCustomModel = (data: CustomModelForm, signal?: AbortSignal) => {
|
||||||
return request.post('/models', data)
|
return request.post('/models', data, { signal })
|
||||||
}
|
}
|
||||||
// Update custom model
|
// Update custom model
|
||||||
export const updateCustomModel = (model_base_id: string, data: CustomModelForm) => {
|
export const updateCustomModel = (model_base_id: string, data: CustomModelForm, signal?: AbortSignal) => {
|
||||||
return request.put(`/models/${model_base_id}`, data)
|
return request.put(`/models/${model_base_id}`, data, { signal })
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ export const getToolMethods = (tool_id: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MCP市场列表
|
// MCP市场列表
|
||||||
export const getMarketTools = (data: Query) => {
|
export const getMarketTools = (data?: Record<string, any>) => {
|
||||||
return request.get('/mcp_markets/mcp_markets', data)
|
return request.get('/mcp_markets/mcp_markets', data)
|
||||||
}
|
}
|
||||||
// 市场配置创建
|
// 市场配置创建
|
||||||
|
|||||||
@@ -1818,8 +1818,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
marketInDatabase: 'In Database',
|
marketInDatabase: 'In Database',
|
||||||
marketAdd: 'Add',
|
marketAdd: 'Add',
|
||||||
marketRefresh: 'Refresh',
|
marketRefresh: 'Refresh',
|
||||||
marketConfig: 'Configure',
|
marketConfigBtn: 'Configure',
|
||||||
marketConfigConnection: 'Configure Connection',
|
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',
|
marketNoServices: 'No MCP Services Available',
|
||||||
marketNotConnected: 'Not Connected to This Market',
|
marketNotConnected: 'Not Connected to This Market',
|
||||||
marketNoServicesDesc: 'This market currently has no available services',
|
marketNoServicesDesc: 'This market currently has no available services',
|
||||||
|
|||||||
@@ -1814,8 +1814,12 @@ export const zh = {
|
|||||||
marketInDatabase: '已入库',
|
marketInDatabase: '已入库',
|
||||||
marketAdd: '添加',
|
marketAdd: '添加',
|
||||||
marketRefresh: '刷新',
|
marketRefresh: '刷新',
|
||||||
marketConfig: '配置',
|
marketConfigBtn: '配置',
|
||||||
marketConfigConnection: '配置连接',
|
marketConfigConnection: '配置连接',
|
||||||
|
marketNoData: '暂无数据',
|
||||||
|
marketNoDataDesc: '该市场暂时没有可用的服务',
|
||||||
|
marketNoSearchResult: '无搜索结果',
|
||||||
|
marketNoSearchResultDesc: '未找到匹配的服务,请尝试其他关键词',
|
||||||
marketNoServices: '暂无可用的 MCP 服务',
|
marketNoServices: '暂无可用的 MCP 服务',
|
||||||
marketNotConnected: '尚未连接此市场',
|
marketNotConnected: '尚未连接此市场',
|
||||||
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:49:28
|
* @Date: 2026-02-03 16:49:28
|
||||||
* @Last Modified by: ZhaoYing
|
* @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
|
* Custom Model Modal
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types';
|
import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types';
|
||||||
@@ -35,6 +35,7 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
|||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [form] = Form.useForm<CustomModelForm>();
|
const [form] = Form.useForm<CustomModelForm>();
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||||
const modelType = Form.useWatch(['type'], form);
|
const modelType = Form.useWatch(['type'], form);
|
||||||
const isOmni = Form.useWatch(['is_omni'], form);
|
const isOmni = Form.useWatch(['is_omni'], form);
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
|||||||
|
|
||||||
/** Close modal and reset state */
|
/** Close modal and reset state */
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
abortController?.abort()
|
||||||
|
setAbortController(null)
|
||||||
setModel({} as ModelListItem);
|
setModel({} as ModelListItem);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -73,8 +76,10 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
|||||||
/** Update or create custom model */
|
/** Update or create custom model */
|
||||||
const handleUpdate = (data: CustomModelForm) => {
|
const handleUpdate = (data: CustomModelForm) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
const controller = new AbortController()
|
||||||
|
setAbortController(controller)
|
||||||
const { type, provider, ...rest} = data
|
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(() => {
|
res.then(() => {
|
||||||
refresh?.(isEdit)
|
refresh?.(isEdit)
|
||||||
@@ -124,15 +129,15 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
handleOpen,
|
handleOpen,
|
||||||
}));
|
}));
|
||||||
console.log('modelType', modelType)
|
|
||||||
return (
|
return (
|
||||||
<RbModal
|
<RbModal
|
||||||
title={isEdit ? `${model.name} - ${t('modelNew.modelConfiguration')}` : t('modelNew.createCustomModel')}
|
title={isEdit ? `${model.name} - ${t('modelNew.modelConfiguration')}` : t('modelNew.createCustomModel')}
|
||||||
open={visible}
|
open={visible}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
okText={t(`common.${isEdit ? 'save' : 'create'}`)}
|
footer={[
|
||||||
onOk={handleSave}
|
<Button key="cancel" onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||||
confirmLoading={loading}
|
<Button key="confirm" type="primary" loading={loading} onClick={handleSave}>{t(`common.${isEdit ? 'save' : 'create'}`)}</Button>,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:49:40
|
* @Date: 2026-02-03 16:49:40
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-02-03 16:49:40
|
* @Last Modified time: 2026-03-11 15:12:17
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Key Configuration Modal
|
* Key Configuration Modal
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
import { Form, Input, App } from 'antd';
|
import { Form, Input, App, Button } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { KeyConfigModalForm, ProviderModelItem, KeyConfigModalRef, KeyConfigModalProps } from '../types';
|
import type { KeyConfigModalForm, ProviderModelItem, KeyConfigModalRef, KeyConfigModalProps } from '../types';
|
||||||
@@ -30,9 +30,12 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
|||||||
const [model, setModel] = useState<ProviderModelItem>({} as ProviderModelItem);
|
const [model, setModel] = useState<ProviderModelItem>({} as ProviderModelItem);
|
||||||
const [form] = Form.useForm<KeyConfigModalForm>();
|
const [form] = Form.useForm<KeyConfigModalForm>();
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||||
|
|
||||||
/** Close modal and reset state */
|
/** Close modal and reset state */
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
abortController?.abort()
|
||||||
|
setAbortController(null)
|
||||||
setModel({} as ProviderModelItem);
|
setModel({} as ProviderModelItem);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -51,10 +54,13 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
|||||||
.then((values) => {
|
.then((values) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setAbortController(controller)
|
||||||
|
|
||||||
updateProviderApiKeys({
|
updateProviderApiKeys({
|
||||||
...values,
|
...values,
|
||||||
provider: model.provider
|
provider: model.provider
|
||||||
}).then((res) => {
|
}, controller.signal).then((res) => {
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
@@ -81,9 +87,10 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
|||||||
title={`${model.provider} - ${t('modelNew.keyConfig')}`}
|
title={`${model.provider} - ${t('modelNew.keyConfig')}`}
|
||||||
open={visible}
|
open={visible}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
okText={t(`common.save`)}
|
footer={[
|
||||||
onOk={handleSave}
|
<Button key="cancel" onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||||
confirmLoading={loading}
|
<Button key="confirm" type="primary" loading={loading} onClick={handleSave}>{t(`common.save`)}</Button>,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:49:55
|
* @Date: 2026-02-03 16:49:55
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-02-03 16:49:55
|
* @Last Modified time: 2026-03-11 15:11:06
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Multi-Key Configuration Modal
|
* Multi-Key Configuration Modal
|
||||||
@@ -28,9 +28,12 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
|||||||
const [model, setModel] = useState<ModelListItem>({} as ModelListItem);
|
const [model, setModel] = useState<ModelListItem>({} as ModelListItem);
|
||||||
const [form] = Form.useForm<MultiKeyForm>();
|
const [form] = Form.useForm<MultiKeyForm>();
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||||
|
|
||||||
/** Close modal and refresh parent */
|
/** Close modal and refresh parent */
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
abortController?.abort()
|
||||||
|
setAbortController(null)
|
||||||
setModel({} as ModelListItem);
|
setModel({} as ModelListItem);
|
||||||
refresh?.()
|
refresh?.()
|
||||||
|
|
||||||
@@ -60,12 +63,14 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
|||||||
.validateFields()
|
.validateFields()
|
||||||
.then((values) => {
|
.then((values) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
const controller = new AbortController()
|
||||||
|
setAbortController(controller)
|
||||||
addModelApiKey(model.id, {
|
addModelApiKey(model.id, {
|
||||||
...values,
|
...values,
|
||||||
model_config_id: model.id,
|
model_config_id: model.id,
|
||||||
model_name: model.name,
|
model_name: model.name,
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
}).then(() => {
|
}, controller.signal).then(() => {
|
||||||
message.success(t('common.saveSuccess'))
|
message.success(t('common.saveSuccess'))
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
getData(model)
|
getData(model)
|
||||||
@@ -98,7 +103,6 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
|||||||
open={visible}
|
open={visible}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
confirmLoading={loading}
|
|
||||||
>
|
>
|
||||||
{model.api_keys && model.api_keys.length > 0 && (
|
{model.api_keys && model.api_keys.length > 0 && (
|
||||||
<div className="rb:mb-4">
|
<div className="rb:mb-4">
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { McpServiceModalRef } from './types';
|
|||||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||||
import Empty from '@/components/Empty/index'
|
import Empty from '@/components/Empty/index'
|
||||||
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
||||||
import BodyWrapper from '@/components/Empty/BodyWrapper';
|
|
||||||
interface MarketSource {
|
interface MarketSource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +74,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
const searchTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 获取市场数据
|
// 获取市场数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,7 +109,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
fetchMarketData();
|
fetchMarketData();
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
const fetchMcpList = async (sourceId: string, page = 1, append = false) => {
|
const fetchMcpList = async (sourceId: string, page = 1, append = false, keywords = '') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let configId = configIdMap[sourceId];
|
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 allTools: any = await getTools({ tool_type: 'mcp' });
|
||||||
const toolsList = Array.isArray(allTools) ? allTools : [];
|
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)) {
|
if (res?.items && Array.isArray(res.items)) {
|
||||||
// 标记已激活和已入库的 MCP
|
// 标记已激活和已入库的 MCP
|
||||||
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
||||||
@@ -176,8 +181,46 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (!selectedSource || loading) return;
|
if (!selectedSource || loading) return;
|
||||||
fetchMcpList(selectedSource, currentPage + 1, true);
|
fetchMcpList(selectedSource, currentPage + 1, true, searchKeyword);
|
||||||
}, [selectedSource, currentPage, loading]);
|
}, [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) => {
|
const handleSelectSource = async (sourceId: string) => {
|
||||||
setSelectedSource(sourceId);
|
setSelectedSource(sourceId);
|
||||||
@@ -279,6 +322,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 = () => {
|
const renderSourceDetail = () => {
|
||||||
if (!selectedSource) {
|
if (!selectedSource) {
|
||||||
return (
|
return (
|
||||||
@@ -299,12 +356,6 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
|
||||||
const mcpList = mcpCache[selectedSource] || [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -344,18 +395,20 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
{t('tool.marketRefresh')}
|
{t('tool.marketRefresh')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{mcpList.length > 0 && (
|
|
||||||
<Input
|
<Input
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
placeholder={t('tool.marketSearchPlaceholder')}
|
placeholder={t('tool.marketSearchPlaceholder')}
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
allowClear
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||||
{t('tool.marketConfig')}
|
{t('tool.marketConfigBtn')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
||||||
{t('tool.marketVisit')}
|
{t('tool.marketVisit')}
|
||||||
@@ -364,17 +417,31 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rb:mt-6">
|
<div className="rb:mt-6">
|
||||||
<BodyWrapper loading={loading} empty={mcpList.length === 0}>
|
|
||||||
<div id="mcpScrollableDiv" className="rb:overflow-y-auto rb:h-[calc(100vh-260px)]">
|
<div id="mcpScrollableDiv" className="rb:overflow-y-auto rb:h-[calc(100vh-260px)]">
|
||||||
|
{!loading && mcpList.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
url={pageEmptyIcon}
|
||||||
|
title={searchKeyword ? t('tool.marketNoSearchResult') : t('tool.marketNoData')}
|
||||||
|
subTitle={searchKeyword ? t('tool.marketNoSearchResultDesc') : t('tool.marketNoDataDesc')}
|
||||||
|
size={200}
|
||||||
|
className="rb:h-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
dataLength={filteredList.length}
|
dataLength={mcpList.length}
|
||||||
next={loadMore}
|
next={loadMore}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loader={<Skeleton active paragraph={{ rows: 2 }} className="rb:mt-4" />}
|
loader={<Skeleton active paragraph={{ rows: 2 }} className="rb:mt-4" />}
|
||||||
scrollableTarget="mcpScrollableDiv"
|
scrollableTarget="mcpScrollableDiv"
|
||||||
>
|
>
|
||||||
<div className="rb:grid rb:grid-cols-3 rb:gap-4">
|
<div
|
||||||
{filteredList.map(mcp => (
|
className="rb:gap-4"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mcpList.map(mcp => (
|
||||||
<div
|
<div
|
||||||
key={mcp.id}
|
key={mcp.id}
|
||||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:pb-2 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:pb-2 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||||
@@ -425,7 +492,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
{mcp.activated && <Tag color="success">{t('tool.marketActivated')}</Tag>}
|
{mcp.activated && <Tag color="success">{t('tool.marketActivated')}</Tag>}
|
||||||
{mcp.inDatabase && <Tag color="blue">{t('tool.marketInDatabase')}</Tag>}
|
{mcp.inDatabase && <Tag color="blue">{t('tool.marketInDatabase')}</Tag>}
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" size="small" onClick={() => handleOpenMcpServiceModal(mcp)}>
|
<Button disabled={mcp.inDatabase} type="primary" size="small" onClick={() => handleOpenMcpServiceModal(mcp)}>
|
||||||
+ {t('tool.marketAdd')}
|
+ {t('tool.marketAdd')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,8 +500,8 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</BodyWrapper>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -495,9 +562,9 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
<span className="rb:flex-1 rb:font-medium rb:text-[12px] rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
<span className="rb:flex-1 rb:font-medium rb:text-[12px] rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||||
{source.name}
|
{source.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full rb:flex-shrink-0">
|
{/* <span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full rb:flex-shrink-0">
|
||||||
{source.mcp_count}
|
{source.mcp_count}
|
||||||
</span>
|
</span> */}
|
||||||
{source.connected && (
|
{source.connected && (
|
||||||
<span className="rb:text-green-500 rb:text-[8px] rb:flex-shrink-0">●</span>
|
<span className="rb:text-green-500 rb:text-[8px] rb:flex-shrink-0">●</span>
|
||||||
)}
|
)}
|
||||||
@@ -523,7 +590,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
|||||||
/>
|
/>
|
||||||
<McpServiceModal
|
<McpServiceModal
|
||||||
ref={mcpServiceModalRef}
|
ref={mcpServiceModalRef}
|
||||||
refresh={() => {}}
|
refresh={handleRefreshAfterAdd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
|||||||
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||||
request.then((res: any) => {
|
request.then((res: any) => {
|
||||||
message.success(t('common.saveSuccess'));
|
message.success(t('common.saveSuccess'));
|
||||||
testConnection(res.tool_id || editVo?.id)
|
setLoading(false);
|
||||||
.finally(() => {
|
handleClose();
|
||||||
setLoading(false);
|
refresh();
|
||||||
handleClose();
|
|
||||||
refresh()
|
// 在后台测试连接,不阻塞用户操作
|
||||||
})
|
testConnection(res.tool_id || editVo?.id).catch((err) => {
|
||||||
|
console.error('测试连接失败:', err);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -146,4 +146,5 @@ export interface MarketQuery {
|
|||||||
mcp_market_config_id?: string;
|
mcp_market_config_id?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
|
keywords?: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user