Commit 50b5aeb4428643f543573083ccfe7a8cf7942dfa
1 parent
369ca68a
feat:【ai 大模型】支持 Chat Role 的 mcp 配置
Showing
8 changed files
with
93 additions
and
27 deletions
yudao-module-ai/pom.xml
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java
| ... | ... | @@ -52,6 +52,9 @@ public class AiChatRoleRespVO implements VO { |
| 52 | 52 | @Schema(description = "引用的工具编号列表", example = "1,2,3") |
| 53 | 53 | private List<Long> toolIds; |
| 54 | 54 | |
| 55 | + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") | |
| 56 | + private List<String> mcpClientNames; | |
| 57 | + | |
| 55 | 58 | @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") |
| 56 | 59 | private Boolean publicStatus; |
| 57 | 60 | ... | ... |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java
| ... | ... | @@ -37,4 +37,7 @@ public class AiChatRoleSaveMyReqVO { |
| 37 | 37 | @Schema(description = "引用的工具编号列表", example = "1,2,3") |
| 38 | 38 | private List<Long> toolIds; |
| 39 | 39 | |
| 40 | + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") | |
| 41 | + private List<String> mcpClientNames; | |
| 42 | + | |
| 40 | 43 | } |
| 41 | 44 | \ No newline at end of file | ... | ... |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java
| ... | ... | @@ -50,6 +50,9 @@ public class AiChatRoleSaveReqVO { |
| 50 | 50 | @Schema(description = "引用的工具编号列表", example = "1,2,3") |
| 51 | 51 | private List<Long> toolIds; |
| 52 | 52 | |
| 53 | + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") | |
| 54 | + private List<String> mcpClientNames; | |
| 55 | + | |
| 53 | 56 | @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") |
| 54 | 57 | @NotNull(message = "是否公开不能为空") |
| 55 | 58 | private Boolean publicStatus; | ... | ... |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiChatRoleDO.java
| ... | ... | @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.ai.dal.dataobject.model; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | 4 | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; |
| 5 | 5 | import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; |
| 6 | +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler; | |
| 6 | 7 | import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; |
| 7 | 8 | import com.baomidou.mybatisplus.annotation.KeySequence; |
| 8 | 9 | import com.baomidou.mybatisplus.annotation.TableField; |
| ... | ... | @@ -80,6 +81,13 @@ public class AiChatRoleDO extends BaseDO { |
| 80 | 81 | */ |
| 81 | 82 | @TableField(typeHandler = LongListTypeHandler.class) |
| 82 | 83 | private List<Long> toolIds; |
| 84 | + /** | |
| 85 | + * 引用的 MCP Client 名字列表 | |
| 86 | + * | |
| 87 | + * 关联 spring.ai.mcp.client 下的名字 | |
| 88 | + */ | |
| 89 | + @TableField(typeHandler = StringListTypeHandler.class) | |
| 90 | + private List<String> mcpClientNames; | |
| 83 | 91 | |
| 84 | 92 | /** |
| 85 | 93 | * 是否公开 | ... | ... |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java
| ... | ... | @@ -36,6 +36,7 @@ import cn.iocoder.yudao.module.ai.service.model.AiToolService; |
| 36 | 36 | import cn.iocoder.yudao.module.ai.util.AiUtils; |
| 37 | 37 | import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; |
| 38 | 38 | import com.google.common.collect.Maps; |
| 39 | +import io.modelcontextprotocol.client.McpSyncClient; | |
| 39 | 40 | import jakarta.annotation.Resource; |
| 40 | 41 | import lombok.extern.slf4j.Slf4j; |
| 41 | 42 | import org.springframework.ai.chat.messages.Message; |
| ... | ... | @@ -47,6 +48,10 @@ import org.springframework.ai.chat.model.ChatResponse; |
| 47 | 48 | import org.springframework.ai.chat.model.StreamingChatModel; |
| 48 | 49 | import org.springframework.ai.chat.prompt.ChatOptions; |
| 49 | 50 | import org.springframework.ai.chat.prompt.Prompt; |
| 51 | +import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; | |
| 52 | +import org.springframework.ai.mcp.client.autoconfigure.properties.McpClientCommonProperties; | |
| 53 | +import org.springframework.ai.tool.ToolCallback; | |
| 54 | +import org.springframework.ai.tool.resolution.ToolCallbackResolver; | |
| 50 | 55 | import org.springframework.beans.factory.annotation.Autowired; |
| 51 | 56 | import org.springframework.stereotype.Service; |
| 52 | 57 | import org.springframework.transaction.annotation.Transactional; |
| ... | ... | @@ -119,6 +124,17 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { |
| 119 | 124 | @Autowired(required = false) // 由于 yudao.ai.web-search.enable 配置项,可以关闭 AiWebSearchClient 的功能,所以这里只能不强制注入 |
| 120 | 125 | private AiWebSearchClient webSearchClient; |
| 121 | 126 | |
| 127 | + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") | |
| 128 | + @Autowired(required = false) // 由于 yudao.ai.mcp.client.enable 配置项,可以关闭 McpSyncClient 的功能,所以这里只能不强制注入 | |
| 129 | + private List<McpSyncClient> mcpClients; | |
| 130 | + | |
| 131 | + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") | |
| 132 | + @Autowired(required = false) // 由于 yudao.ai.mcp.client.enable 配置项,可以关闭 McpSyncClient 的功能,所以这里只能不强制注入 | |
| 133 | + private McpClientCommonProperties mcpClientCommonProperties; | |
| 134 | + | |
| 135 | + @Resource | |
| 136 | + private ToolCallbackResolver toolCallbackResolver; | |
| 137 | + | |
| 122 | 138 | @Transactional(rollbackFor = Exception.class) |
| 123 | 139 | public AiChatMessageSendRespVO sendMessage(AiChatMessageSendReqVO sendReqVO, Long userId) { |
| 124 | 140 | // 1.1 校验对话存在 |
| ... | ... | @@ -334,23 +350,54 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { |
| 334 | 350 | } |
| 335 | 351 | |
| 336 | 352 | // 2.1 查询 tool 工具 |
| 337 | - Set<String> toolNames = null; | |
| 338 | - Map<String,Object> toolContext = Map.of(); | |
| 339 | - if (conversation.getRoleId() != null) { | |
| 340 | - AiChatRoleDO chatRole = chatRoleService.getChatRole(conversation.getRoleId()); | |
| 341 | - if (chatRole != null && CollUtil.isNotEmpty(chatRole.getToolIds())) { | |
| 342 | - toolNames = convertSet(toolService.getToolList(chatRole.getToolIds()), AiToolDO::getName); | |
| 343 | - toolContext = AiUtils.buildCommonToolContext(); | |
| 344 | - } | |
| 345 | - } | |
| 353 | + List<ToolCallback> toolCallbacks = getToolCallbackListByRoleId(conversation.getRoleId()); | |
| 354 | + Map<String,Object> toolContext = CollUtil.isNotEmpty(toolCallbacks) ? AiUtils.buildCommonToolContext() | |
| 355 | + : Map.of(); | |
| 346 | 356 | // 2.2 构建 ChatOptions 对象 |
| 347 | 357 | AiPlatformEnum platform = AiPlatformEnum.validatePlatform(model.getPlatform()); |
| 348 | 358 | ChatOptions chatOptions = AiUtils.buildChatOptions(platform, model.getModel(), |
| 349 | 359 | conversation.getTemperature(), conversation.getMaxTokens(), |
| 350 | - toolNames, toolContext); | |
| 360 | + toolCallbacks, toolContext); | |
| 351 | 361 | return new Prompt(chatMessages, chatOptions); |
| 352 | 362 | } |
| 353 | 363 | |
| 364 | + private List<ToolCallback> getToolCallbackListByRoleId(Long roleId) { | |
| 365 | + if (roleId == null) { | |
| 366 | + return null; | |
| 367 | + } | |
| 368 | + AiChatRoleDO chatRole = chatRoleService.getChatRole(roleId); | |
| 369 | + if (chatRole == null) { | |
| 370 | + return null; | |
| 371 | + } | |
| 372 | + List<ToolCallback> toolCallbacks = new ArrayList<>(); | |
| 373 | + // 1. 通过 toolIds | |
| 374 | + if (CollUtil.isNotEmpty(chatRole.getToolIds())) { | |
| 375 | + Set<String> toolNames = convertSet(toolService.getToolList(chatRole.getToolIds()), AiToolDO::getName); | |
| 376 | + toolNames.forEach(toolName -> { | |
| 377 | + ToolCallback toolCallback = toolCallbackResolver.resolve(toolName); | |
| 378 | + if (toolCallback != null) { | |
| 379 | + toolCallbacks.add(toolCallback); | |
| 380 | + } | |
| 381 | + }); | |
| 382 | + } | |
| 383 | + // 2. 通过 mcpClients | |
| 384 | + if (CollUtil.isNotEmpty(mcpClients) && CollUtil.isNotEmpty(chatRole.getMcpClientNames())) { | |
| 385 | + chatRole.getMcpClientNames().forEach(mcpClientName -> { | |
| 386 | + // 2.1 标准化名字,参考 McpClientAutoConfiguration 的 connectedClientName 方法 | |
| 387 | + String finalMcpClientName = mcpClientCommonProperties.getName() + " - " + mcpClientName; | |
| 388 | + // 2.2 匹配对应的 McpSyncClient | |
| 389 | + mcpClients.forEach(mcpClient -> { | |
| 390 | + if (ObjUtil.notEqual(mcpClient.getClientInfo().name(), finalMcpClientName)) { | |
| 391 | + return; | |
| 392 | + } | |
| 393 | + ToolCallback[] mcpToolCallBacks = new SyncMcpToolCallbackProvider(mcpClient).getToolCallbacks(); | |
| 394 | + CollUtil.addAll(toolCallbacks, mcpToolCallBacks); | |
| 395 | + }); | |
| 396 | + }); | |
| 397 | + } | |
| 398 | + return toolCallbacks; | |
| 399 | + } | |
| 400 | + | |
| 354 | 401 | /** |
| 355 | 402 | * 从历史消息中,获得倒序的 n 组消息作为消息上下文 |
| 356 | 403 | * <p> | ... | ... |
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/util/AiUtils.java
| ... | ... | @@ -18,12 +18,10 @@ import org.springframework.ai.deepseek.DeepSeekChatOptions; |
| 18 | 18 | import org.springframework.ai.minimax.MiniMaxChatOptions; |
| 19 | 19 | import org.springframework.ai.ollama.api.OllamaOptions; |
| 20 | 20 | import org.springframework.ai.openai.OpenAiChatOptions; |
| 21 | +import org.springframework.ai.tool.ToolCallback; | |
| 21 | 22 | import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; |
| 22 | 23 | |
| 23 | -import java.util.Collections; | |
| 24 | -import java.util.HashMap; | |
| 25 | -import java.util.Map; | |
| 26 | -import java.util.Set; | |
| 24 | +import java.util.*; | |
| 27 | 25 | |
| 28 | 26 | /** |
| 29 | 27 | * Spring AI 工具类 |
| ... | ... | @@ -40,15 +38,15 @@ public class AiUtils { |
| 40 | 38 | } |
| 41 | 39 | |
| 42 | 40 | public static ChatOptions buildChatOptions(AiPlatformEnum platform, String model, Double temperature, Integer maxTokens, |
| 43 | - Set<String> toolNames, Map<String, Object> toolContext) { | |
| 44 | - toolNames = ObjUtil.defaultIfNull(toolNames, Collections.emptySet()); | |
| 41 | + List<ToolCallback> toolCallbacks, Map<String, Object> toolContext) { | |
| 42 | + toolCallbacks = ObjUtil.defaultIfNull(toolCallbacks, Collections.emptyList()); | |
| 45 | 43 | toolContext = ObjUtil.defaultIfNull(toolContext, Collections.emptyMap()); |
| 46 | 44 | // noinspection EnhancedSwitchMigration |
| 47 | 45 | switch (platform) { |
| 48 | 46 | case TONG_YI: |
| 49 | 47 | return DashScopeChatOptions.builder().withModel(model).withTemperature(temperature).withMaxToken(maxTokens) |
| 50 | 48 | .withEnableThinking(true) // TODO 芋艿:默认都开启 thinking 模式,后续可以让用户配置 |
| 51 | - .withToolNames(toolNames).withToolContext(toolContext).build(); | |
| 49 | + .withToolCallbacks(toolCallbacks).withToolContext(toolContext).build(); | |
| 52 | 50 | case YI_YAN: |
| 53 | 51 | return QianFanChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens).build(); |
| 54 | 52 | case DEEP_SEEK: |
| ... | ... | @@ -57,30 +55,30 @@ public class AiUtils { |
| 57 | 55 | case SILICON_FLOW: // 复用 DeepSeek 客户端 |
| 58 | 56 | case XING_HUO: // 复用 DeepSeek 客户端 |
| 59 | 57 | return DeepSeekChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) |
| 60 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 58 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 61 | 59 | case ZHI_PU: |
| 62 | 60 | return ZhiPuAiChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) |
| 63 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 61 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 64 | 62 | case MINI_MAX: |
| 65 | 63 | return MiniMaxChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) |
| 66 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 64 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 67 | 65 | case MOONSHOT: |
| 68 | 66 | return MoonshotChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) |
| 69 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 67 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 70 | 68 | case OPENAI: |
| 71 | 69 | case GEMINI: // 复用 OpenAI 客户端 |
| 72 | 70 | case BAI_CHUAN: // 复用 OpenAI 客户端 |
| 73 | 71 | return OpenAiChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) |
| 74 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 72 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 75 | 73 | case AZURE_OPENAI: |
| 76 | 74 | return AzureOpenAiChatOptions.builder().deploymentName(model).temperature(temperature).maxTokens(maxTokens) |
| 77 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 75 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 78 | 76 | case ANTHROPIC: |
| 79 | 77 | return AnthropicChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) |
| 80 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 78 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 81 | 79 | case OLLAMA: |
| 82 | 80 | return OllamaOptions.builder().model(model).temperature(temperature).numPredict(maxTokens) |
| 83 | - .toolNames(toolNames).toolContext(toolContext).build(); | |
| 81 | + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); | |
| 84 | 82 | default: |
| 85 | 83 | throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); |
| 86 | 84 | } | ... | ... |
yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/mcp/DouBaoMcpTests.java
| ... | ... | @@ -36,44 +36,47 @@ public class DouBaoMcpTests { |
| 36 | 36 | |
| 37 | 37 | @Test |
| 38 | 38 | public void testMcpGetUserInfo() { |
| 39 | - | |
| 40 | 39 | // 打印结果 |
| 41 | 40 | System.out.println(chatClient.prompt() |
| 42 | 41 | .user("目前有哪些工具可以使用") |
| 43 | 42 | .call() |
| 44 | 43 | .content()); |
| 45 | 44 | System.out.println("===================================="); |
| 45 | + | |
| 46 | 46 | // 打印结果 |
| 47 | 47 | System.out.println(chatClient.prompt() |
| 48 | 48 | .user("小新的年龄是多少") |
| 49 | 49 | .call() |
| 50 | 50 | .content()); |
| 51 | 51 | System.out.println("===================================="); |
| 52 | + | |
| 52 | 53 | // 打印结果 |
| 53 | 54 | System.out.println(chatClient.prompt() |
| 54 | 55 | .user("获取小新的基本信息") |
| 55 | 56 | .call() |
| 56 | 57 | .content()); |
| 57 | 58 | System.out.println("===================================="); |
| 59 | + | |
| 58 | 60 | // 打印结果 |
| 59 | 61 | System.out.println(chatClient.prompt() |
| 60 | 62 | .user("小新是什么职业的") |
| 61 | 63 | .call() |
| 62 | 64 | .content()); |
| 63 | 65 | System.out.println("===================================="); |
| 66 | + | |
| 64 | 67 | // 打印结果 |
| 65 | 68 | System.out.println(chatClient.prompt() |
| 66 | 69 | .user("小新的教育背景") |
| 67 | 70 | .call() |
| 68 | 71 | .content()); |
| 69 | 72 | System.out.println("===================================="); |
| 73 | + | |
| 70 | 74 | // 打印结果 |
| 71 | 75 | System.out.println(chatClient.prompt() |
| 72 | 76 | .user("小新的兴趣爱好是什么") |
| 73 | 77 | .call() |
| 74 | 78 | .content()); |
| 75 | 79 | System.out.println("===================================="); |
| 76 | - | |
| 77 | 80 | } |
| 78 | 81 | |
| 79 | 82 | ... | ... |