From 59429be4dff501c639d5b2122c904dcadc8ea818 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 24 Aug 2025 16:08:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat=EF=BC=9A=E3=80=90framework=20=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E3=80=91=E5=A2=9E=E5=8A=A0=20ApiEncryptTest=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../framework/encrypt/ApiEncryptTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java b/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java new file mode 100644 index 000000000..12d406e5f --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-web/src/test/java/cn/iocoder/yudao/framework/encrypt/ApiEncryptTest.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.framework.encrypt; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.asymmetric.AsymmetricAlgorithm; +import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.crypto.asymmetric.RSA; +import cn.hutool.crypto.symmetric.SymmetricAlgorithm; +import org.junit.jupiter.api.Test; + +import java.util.Objects; + +/** + * 各种 API 加解密的测试类:不是单测,而是方便大家生成密钥、加密、解密等操作。 + * + * @author 芋道源码 + */ +@SuppressWarnings("ConstantValue") +public class ApiEncryptTest { + + @Test + public void testGenerateAsymmetric() { + String asymmetricAlgorithm = AsymmetricAlgorithm.RSA.getValue(); +// String asymmetricAlgorithm = "SM2"; +// String asymmetricAlgorithm = SM4.ALGORITHM_NAME; +// String asymmetricAlgorithm = SymmetricAlgorithm.AES.getValue(); + String requestClientKey = null; + String requestServerKey = null; + String responseClientKey = null; + String responseServerKey = null; + if (Objects.equals(asymmetricAlgorithm, AsymmetricAlgorithm.RSA.getValue())) { + // 请求的密钥 + RSA requestRsa = SecureUtil.rsa(); + requestClientKey = requestRsa.getPublicKeyBase64(); + requestServerKey = requestRsa.getPrivateKeyBase64(); + // 响应的密钥 + RSA responseRsa = new RSA(); + responseClientKey = responseRsa.getPrivateKeyBase64(); + responseServerKey = responseRsa.getPublicKeyBase64(); + } else if (Objects.equals(asymmetricAlgorithm, SymmetricAlgorithm.AES.getValue())) { + // AES 密钥可选 32、24、16 位 + // 请求的密钥(前后端密钥一致) + requestClientKey = RandomUtil.randomNumbers(32); + requestServerKey = requestClientKey; + // 响应的密钥(前后端密钥一致) + responseClientKey = RandomUtil.randomNumbers(32); + responseServerKey = responseClientKey; + } + + // 打印结果 + System.out.println("requestClientKey = " + requestClientKey); + System.out.println("requestServerKey = " + requestServerKey); + System.out.println("responseClientKey = " + responseClientKey); + System.out.println("responseServerKey = " + responseServerKey); + } + + @Test + public void testEncrypt_aes() { + String key = "52549111389893486934626385991395"; + String body = "{\n" + + " \"username\": \"admin\",\n" + + " \"password\": \"admin123\",\n" + + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" + + " \"code\": \"1024\"\n" + + "}"; + String encrypt = SecureUtil.aes(StrUtil.utf8Bytes(key)) + .encryptBase64(body); + System.out.println("encrypt = " + encrypt); + } + + @Test + public void testEncrypt_rsa() { + String key = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB"; + String body = "{\n" + + " \"username\": \"admin\",\n" + + " \"password\": \"admin123\",\n" + + " \"uuid\": \"3acd87a09a4f48fb9118333780e94883\",\n" + + " \"code\": \"1024\"\n" + + "}"; + String encrypt = SecureUtil.rsa(null, key) + .encryptBase64(body, KeyType.PublicKey); + System.out.println("encrypt = " + encrypt); + } + +} From 2503432067714ff209b4a302f87b21556c9604aa Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 29 Aug 2025 20:05:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E3=80=90=E5=90=8C=E6=AD=A5=E3=80=91BOOT=20?= =?UTF-8?q?=E5=92=8C=20CLOUD=20=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yudao-dependencies/pom.xml | 16 +- .../core/db/TenantDatabaseInterceptor.java | 2 +- .../rabbitmq/TenantRabbitMQInitializer.java | 3 +- .../rocketmq/TenantRocketMQInitializer.java | 3 +- .../config/YudaoAsyncAutoConfiguration.java | 1 + .../config/YudaoMybatisAutoConfiguration.java | 2 + .../core/handler/DefaultDBFieldHandler.java | 2 +- .../core/handler/GlobalExceptionHandler.java | 1 + .../web/core/util/WebFrameworkUtils.java | 1 + .../src/main/resources/application.yaml | 4 + .../module/ai/enums/model/AiPlatformEnum.java | 2 + .../yudao-module-ai-server/pom.xml | 28 +- .../admin/chat/AiChatMessageController.http | 41 ++- .../chat/vo/message/AiChatMessageRespVO.java | 10 + .../vo/message/AiChatMessageSendReqVO.java | 10 +- .../vo/message/AiChatMessageSendRespVO.java | 7 + .../model/vo/chatRole/AiChatRoleRespVO.java | 3 + .../vo/chatRole/AiChatRoleSaveMyReqVO.java | 3 + .../vo/chatRole/AiChatRoleSaveReqVO.java | 3 + .../dal/dataobject/chat/AiChatMessageDO.java | 24 +- .../ai/dal/dataobject/model/AiChatRoleDO.java | 8 + .../ai/dal/dataobject/model/AiToolDO.java | 4 +- .../ai/config/AiAutoConfiguration.java | 112 ++++-- .../ai/config/YudaoAiProperties.java | 67 ++-- .../ai/core/{ => model}/AiModelFactory.java | 2 +- .../core/{ => model}/AiModelFactoryImpl.java | 52 ++- .../framework/ai/core/model/bocha/README.md | 174 +++++++++ .../ai/core/model/doubao/DouBaoChatModel.java | 4 +- .../ai/core/model/gemini/GeminiChatModel.java | 46 +++ .../core/model/hunyuan/HunYuanChatModel.java | 4 +- .../siliconflow/SiliconFlowChatModel.java | 2 +- .../core/model/xinghuo/XingHuoChatModel.java | 21 +- .../ai/core/webserch/AiWebSearchClient.java | 18 + .../ai/core/webserch/AiWebSearchRequest.java | 34 ++ .../ai/core/webserch/AiWebSearchResponse.java | 62 ++++ .../bocha/AiBoChaWebSearchClient.java | 153 ++++++++ .../config/SecurityConfiguration.java | 9 + .../chat/AiChatMessageServiceImpl.java | 286 ++++++++++++--- .../AiKnowledgeSegmentServiceImpl.java | 72 +++- .../ai/service/model/AiModelServiceImpl.java | 2 +- .../ai/service/model/AiToolServiceImpl.java | 12 +- .../function}/DirectoryListToolFunction.java | 2 +- .../UserProfileQueryToolFunction.java | 2 +- .../function}/WeatherQueryToolFunction.java | 2 +- .../module/ai/tool/function/package-info.java | 4 + .../yudao/module/ai/tool/method/Person.java | 19 + .../module/ai/tool/method/PersonService.java | 80 +++++ .../ai/tool/method/PersonServiceImpl.java | 336 ++++++++++++++++++ .../module/ai/tool/method/package-info.java | 4 + .../iocoder/yudao/module/ai/util/AiUtils.java | 68 +++- .../yudao/module/ai/util/FileTypeUtils.java | 37 ++ .../src/main/resources/application.yaml | 31 +- .../model/chat/AnthropicChatModelTest.java | 87 +++++ .../model/chat/DeepSeekChatModelTests.java | 19 + .../core/model/chat/DouBaoChatModelTests.java | 39 +- .../core/model/chat/GeminiChatModelTests.java | 68 ++++ .../model/chat/HunYuanChatModelTests.java | 62 +++- .../core/model/chat/LlamaChatModelTests.java | 112 ++++-- .../model/chat/MiniMaxChatModelTests.java | 20 ++ .../model/chat/MoonshotChatModelTests.java | 21 ++ .../core/model/chat/OpenAIChatModelTests.java | 33 +- .../model/chat/SiliconFlowChatModelTests.java | 31 +- .../core/model/chat/TongYiChatModelTests.java | 68 +++- .../model/chat/XingHuoChatModelTests.java | 37 +- .../model/chat/ZhiPuAiChatModelTests.java | 22 +- .../websearch/AiBoChaWebSearchClientTest.java | 28 ++ .../file/core/utils/FileTypeUtils.java | 18 +- .../service/coupon/CouponServiceImpl.java | 4 + .../core/PictureWordCaptchaServiceImpl.java | 8 +- .../justauth/core/AuthRequestFactory.java | 3 +- .../service/auth/AdminAuthServiceImpl.java | 6 +- .../src/main/resources/application.yaml | 31 +- 72 files changed, 2340 insertions(+), 272 deletions(-) rename yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/{ => model}/AiModelFactory.java (98%) rename yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/{ => model}/AiModelFactoryImpl.java (94%) create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/bocha/README.md create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java rename yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/{service/model/tool => tool/function}/DirectoryListToolFunction.java (98%) rename yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/{service/model/tool => tool/function}/UserProfileQueryToolFunction.java (97%) rename yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/{service/model/tool => tool/function}/WeatherQueryToolFunction.java (98%) create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java create mode 100644 yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index 38737360d..16e24af83 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -17,11 +17,11 @@ 2025.08-SNAPSHOT 1.7.2 - 3.5.4 + 3.5.5 2025.0.0 2023.0.3.3 - 2.8.9 + 2.8.11 4.5.0 1.2.27 @@ -30,11 +30,11 @@ 1.5.4 4.3.1 3.0.6 - 3.50.0 + 3.51.0 8.1.3.140 8.6.0 5.1.0 - 3.3.3 + 3.7.3 2.3.4 @@ -55,12 +55,12 @@ 7.0.1 1.4.0 - 1.21.1 + 1.21.2 1.18.38 1.6.3 - 5.8.39 + 5.8.40 6.0.0-M22 - 1.2.0 + 1.3.0 2.4.1 1.2.83 33.4.8-jre @@ -82,7 +82,7 @@ 1.4.0 2.0.0 1.9.5 - 4.7.5.B + 4.7.7-20250808.182223 diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java index 47e5df004..11f0a4b4c 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/db/TenantDatabaseInterceptor.java @@ -75,7 +75,7 @@ public class TenantDatabaseInterceptor implements TenantLineHandler { if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) { return false; } - // 如果添加了 @TenantIgnore 注解,显然也不忽略租户 + // 如果添加了 @TenantIgnore 注解,则忽略租户 TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class); return tenantIgnore != null; } diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java index b856ce954..a8079a6bf 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.config.BeanPostProcessor; public class TenantRabbitMQInitializer implements BeanPostProcessor { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof RabbitTemplate) { RabbitTemplate rabbitTemplate = (RabbitTemplate) bean; @@ -20,4 +21,4 @@ public class TenantRabbitMQInitializer implements BeanPostProcessor { return bean; } -} \ No newline at end of file +} diff --git a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java index 7f12ac520..3f6badc61 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java @@ -17,6 +17,7 @@ import org.springframework.beans.factory.config.BeanPostProcessor; public class TenantRocketMQInitializer implements BeanPostProcessor { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof DefaultRocketMQListenerContainer) { DefaultRocketMQListenerContainer container = (DefaultRocketMQListenerContainer) bean; @@ -50,4 +51,4 @@ public class TenantRocketMQInitializer implements BeanPostProcessor { consumerImpl.registerConsumeMessageHook(new TenantRocketMQConsumeMessageHook()); } -} \ No newline at end of file +} diff --git a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java index 4b0821097..ca088d35d 100644 --- a/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/config/YudaoAsyncAutoConfiguration.java @@ -21,6 +21,7 @@ public class YudaoAsyncAutoConfiguration { return new BeanPostProcessor() { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { // 处理 ThreadPoolTaskExecutor if (bean instanceof ThreadPoolTaskExecutor) { diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java index ab2992184..745533b7f 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/config/YudaoMybatisAutoConfiguration.java @@ -42,6 +42,8 @@ public class YudaoMybatisAutoConfiguration { public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 + // ↓↓↓ 按需开启,可能会影响到 updateBatch 的地方:例如说文件配置管理 ↓↓↓ + // mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 拦截没有指定条件的 update 和 delete 语句 return mybatisPlusInterceptor; } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java index b721e77c6..b03f278a5 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/handler/DefaultDBFieldHandler.java @@ -2,7 +2,6 @@ package cn.iocoder.yudao.framework.mybatis.core.handler; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; -import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import org.apache.ibatis.reflection.MetaObject; @@ -19,6 +18,7 @@ import java.util.Objects; public class DefaultDBFieldHandler implements MetaObjectHandler { @Override + @SuppressWarnings("PatternVariableCanBeUsed") public void insertFill(MetaObject metaObject) { if (Objects.nonNull(metaObject) && metaObject.getOriginalObject() instanceof BaseDO) { BaseDO baseDO = (BaseDO) metaObject.getOriginalObject(); diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java index 3f3a871a1..b99925e65 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java @@ -182,6 +182,7 @@ public class GlobalExceptionHandler { * 例如说,接口上设置了 @RequestBody 实体中 xx 属性类型为 Integer,结果传递 xx 参数类型为 String */ @ExceptionHandler(HttpMessageNotReadableException.class) + @SuppressWarnings("PatternVariableCanBeUsed") public CommonResult methodArgumentTypeInvalidFormatExceptionHandler(HttpMessageNotReadableException ex) { log.warn("[methodArgumentTypeInvalidFormatExceptionHandler]", ex); if (ex.getCause() instanceof InvalidFormatException) { diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java index 86b5c3e49..032af1c36 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/util/WebFrameworkUtils.java @@ -148,6 +148,7 @@ public class WebFrameworkUtils { return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); } + @SuppressWarnings("PatternVariableCanBeUsed") public static HttpServletRequest getRequest() { RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (!(requestAttributes instanceof ServletRequestAttributes)) { diff --git a/yudao-gateway/src/main/resources/application.yaml b/yudao-gateway/src/main/resources/application.yaml index 9ee507db7..3786d0527 100644 --- a/yudao-gateway/src/main/resources/application.yaml +++ b/yudao-gateway/src/main/resources/application.yaml @@ -184,6 +184,10 @@ spring: - Path=/admin-api/ai/** filters: - RewritePath=/admin-api/ai/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs + - id: ai-mcp-server # 路由的编号(MCP Server) + uri: grayLb://ai-server + predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 + - Path=/sse, /mcp/message ## iot-server 服务 - id: iot-admin-api # 路由的编号 uri: grayLb://iot-server diff --git a/yudao-module-ai/yudao-module-ai-api/src/main/java/cn/iocoder/yudao/module/ai/enums/model/AiPlatformEnum.java b/yudao-module-ai/yudao-module-ai-api/src/main/java/cn/iocoder/yudao/module/ai/enums/model/AiPlatformEnum.java index cebe0b956..47a4d2d71 100644 --- a/yudao-module-ai/yudao-module-ai-api/src/main/java/cn/iocoder/yudao/module/ai/enums/model/AiPlatformEnum.java +++ b/yudao-module-ai/yudao-module-ai-api/src/main/java/cn/iocoder/yudao/module/ai/enums/model/AiPlatformEnum.java @@ -33,6 +33,8 @@ public enum AiPlatformEnum implements ArrayValuable { OPENAI("OpenAI", "OpenAI"), // OpenAI 官方 AZURE_OPENAI("AzureOpenAI", "AzureOpenAI"), // OpenAI 微软 + ANTHROPIC("Anthropic", "Anthropic"), // Anthropic Claude + GEMINI("Gemini", "Gemini"), // 谷歌 Gemini OLLAMA("Ollama", "Ollama"), STABLE_DIFFUSION("StableDiffusion", "StableDiffusion"), // Stability AI diff --git a/yudao-module-ai/yudao-module-ai-server/pom.xml b/yudao-module-ai/yudao-module-ai-server/pom.xml index 10d4c3c14..bf36cd45c 100644 --- a/yudao-module-ai/yudao-module-ai-server/pom.xml +++ b/yudao-module-ai/yudao-module-ai-server/pom.xml @@ -9,6 +9,7 @@ 4.0.0 yudao-module-ai-server + jar ${project.artifactId} @@ -18,8 +19,8 @@ 国外:OpenAI、Ollama、Midjourney、StableDiffusion、Suno - 1.0.0 - 1.0.0.2 + 1.0.1 + 1.0.0.3 1.0.2 @@ -119,6 +120,11 @@ spring-ai-starter-model-azure-openai ${spring-ai.version} + + org.springframework.ai + spring-ai-starter-model-anthropic + ${spring-ai.version} + org.springframework.ai spring-ai-starter-model-deepseek @@ -217,6 +223,24 @@ + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + ${spring-ai.version} + + + + org.springframework.ai + spring-ai-starter-mcp-client + ${spring-ai.version} + + dev.tinyflow diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/AiChatMessageController.http b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/AiChatMessageController.http index 4c4c8c089..017714e09 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/AiChatMessageController.http +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/AiChatMessageController.http @@ -20,9 +20,46 @@ tenant-id: {{adminTenantId}} "content": "1+1=?" } -### 获得指定对话的消息列表 -GET {{baseUrl}}/ai/chat/message/list-by-conversation-id?conversationId=1781604279872581649 +### 发送消息(流式)【带文件】 +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581797", + "content": "图片里有什么?", + "attachmentUrls": ["http://test.yudao.iocoder.cn/1755531278.jpeg"] +} + +### 发送消息(流式)【追问带文件】 +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581799", + "content": "说下图片里,有哪些字?", + "useContext": true +} + +### 发送消息(流式)【联网搜索】 +POST {{baseUrl}}/ai/chat/message/send-stream +Content-Type: application/json +Authorization: {{token}} +tenant-id: {{adminTenantId}} + +{ + "conversationId": "1781604279872581799", + "content": "今天是周几?", + "useSearch": true +} + +### 获得指定对话的消息列表 +GET {{baseUrl}}/ai/chat/message/list-by-conversation-id?conversationId=1781604279872581799 +Authorization: {{token}} +tenant-id: {{adminTenantId}} ### 删除消息 DELETE {{baseUrl}}/ai/chat/message/delete?id=50 diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java index 5d44e4f96..b0f13e3c2 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -37,6 +38,9 @@ public class AiChatMessageRespVO { @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好,你好啊") private String content; + @Schema(description = "推理内容", example = "要达到这个目标,你需要...") + private String reasoningContent; + @Schema(description = "是否携带上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") private Boolean useContext; @@ -46,6 +50,12 @@ public class AiChatMessageRespVO { @Schema(description = "知识库段落数组") private List segments; + @Schema(description = "联网搜索的网页内容数组") + private List webSearchPages; + + @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png") + private List attachmentUrls; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-05-12 12:51") private LocalDateTime createTime; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java index 89a84bcbd..06ce0d10d 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendReqVO.java @@ -3,9 +3,9 @@ package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import lombok.Data; -import lombok.experimental.Accessors; + +import java.util.List; @Schema(description = "管理后台 - AI 聊天消息发送 Request VO") @Data @@ -22,4 +22,10 @@ public class AiChatMessageSendReqVO { @Schema(description = "是否携带上下文", example = "true") private Boolean useContext; + @Schema(description = "是否联网搜索", example = "true") + private Boolean useSearch; + + @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png") + private List attachmentUrls; + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java index 245a19f7c..520712b9b 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -29,12 +30,18 @@ public class AiChatMessageSendRespVO { @Schema(description = "聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "你好,你好啊") private String content; + @Schema(description = "推理内容", example = "要达到这个目标,你需要...") + private String reasoningContent; + @Schema(description = "知识库段落编号数组", example = "[1,2,3]") private List segmentIds; @Schema(description = "知识库段落数组") private List segments; + @Schema(description = "联网搜索的网页内容数组") + private List webSearchPages; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java index 51e44ed76..2ef9565cc 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleRespVO.java @@ -52,6 +52,9 @@ public class AiChatRoleRespVO implements VO { @Schema(description = "引用的工具编号列表", example = "1,2,3") private List toolIds; + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") + private List mcpClientNames; + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") private Boolean publicStatus; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java index 009e8d8af..bd4a05723 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveMyReqVO.java @@ -37,4 +37,7 @@ public class AiChatRoleSaveMyReqVO { @Schema(description = "引用的工具编号列表", example = "1,2,3") private List toolIds; + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") + private List mcpClientNames; + } \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java index 3c72cf983..8f2913dd5 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/chatRole/AiChatRoleSaveReqVO.java @@ -50,6 +50,9 @@ public class AiChatRoleSaveReqVO { @Schema(description = "引用的工具编号列表", example = "1,2,3") private List toolIds; + @Schema(description = "引用的 MCP Client 名字列表", example = "filesystem") + private List mcpClientNames; + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @NotNull(message = "是否公开不能为空") private Boolean publicStatus; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/chat/AiChatMessageDO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/chat/AiChatMessageDO.java index 2364d750c..722cc6ecf 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/chat/AiChatMessageDO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/chat/AiChatMessageDO.java @@ -2,14 +2,20 @@ package cn.iocoder.yudao.module.ai.dal.dataobject.chat; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler; import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO; import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO; import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import org.springframework.ai.chat.messages.MessageType; import java.util.List; @@ -87,6 +93,10 @@ public class AiChatMessageDO extends BaseDO { * 聊天内容 */ private String content; + /** + * 推理内容 + */ + private String reasoningContent; /** * 是否携带上下文 @@ -101,4 +111,16 @@ public class AiChatMessageDO extends BaseDO { @TableField(typeHandler = LongListTypeHandler.class) private List segmentIds; + /** + * 联网搜索的网页内容数组 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List webSearchPages; + + /** + * 附件 URL 数组 + */ + @TableField(typeHandler = StringListTypeHandler.class) + private List attachmentUrls; + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiChatRoleDO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiChatRoleDO.java index bb6a3ca48..d20b25e88 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiChatRoleDO.java +++ b/yudao-module-ai/yudao-module-ai-server/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; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler; import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableField; @@ -80,6 +81,13 @@ public class AiChatRoleDO extends BaseDO { */ @TableField(typeHandler = LongListTypeHandler.class) private List toolIds; + /** + * 引用的 MCP Client 名字列表 + * + * 关联 spring.ai.mcp.client 下的名字 + */ + @TableField(typeHandler = StringListTypeHandler.class) + private List mcpClientNames; /** * 是否公开 diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiToolDO.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiToolDO.java index 7773e978c..71322132f 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiToolDO.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiToolDO.java @@ -1,8 +1,8 @@ package cn.iocoder.yudao.module.ai.dal.dataobject.model; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; -import cn.iocoder.yudao.module.ai.service.model.tool.DirectoryListToolFunction; -import cn.iocoder.yudao.module.ai.service.model.tool.WeatherQueryToolFunction; +import cn.iocoder.yudao.module.ai.tool.function.DirectoryListToolFunction; +import cn.iocoder.yudao.module.ai.tool.function.WeatherQueryToolFunction; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java index 4ff7c9e4d..26fbe0ad4 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java @@ -2,25 +2,34 @@ package cn.iocoder.yudao.module.ai.framework.ai.config; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.yudao.module.ai.framework.ai.core.AiModelFactory; -import cn.iocoder.yudao.module.ai.framework.ai.core.AiModelFactoryImpl; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.AiModelFactory; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.AiModelFactoryImpl; import cn.iocoder.yudao.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.doubao.DouBaoChatModel; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini.GeminiChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.hunyuan.HunYuanChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowApiConstants; import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.suno.api.SunoApi; import cn.iocoder.yudao.module.ai.framework.ai.core.model.xinghuo.XingHuoChatModel; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha.AiBoChaWebSearchClient; +import cn.iocoder.yudao.module.ai.tool.method.PersonService; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; import org.springframework.ai.embedding.BatchingStrategy; import org.springframework.ai.embedding.TokenCountBatchingStrategy; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator; import org.springframework.ai.tokenizer.TokenCountEstimator; +import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusServiceClientProperties; import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreProperties; import org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreProperties; @@ -30,6 +39,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + /** * 芋道 AI 自动配置 * @@ -51,20 +62,49 @@ public class AiAutoConfiguration { // ========== 各种 AI Client 创建 ========== + @Bean + @ConditionalOnProperty(value = "yudao.ai.gemini.enable", havingValue = "true") + public GeminiChatModel geminiChatModel(YudaoAiProperties yudaoAiProperties) { + YudaoAiProperties.Gemini properties = yudaoAiProperties.getGemini(); + return buildGeminiChatClient(properties); + } + + public GeminiChatModel buildGeminiChatClient(YudaoAiProperties.Gemini properties) { + if (StrUtil.isEmpty(properties.getModel())) { + properties.setModel(GeminiChatModel.MODEL_DEFAULT); + } + OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(OpenAiApi.builder() + .baseUrl(GeminiChatModel.BASE_URL) + .completionsPath(GeminiChatModel.COMPLETE_PATH) + .apiKey(properties.getApiKey()) + .build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(properties.getModel()) + .temperature(properties.getTemperature()) + .maxTokens(properties.getMaxTokens()) + .topP(properties.getTopP()) + .build()) + .toolCallingManager(getToolCallingManager()) + .build(); + return new GeminiChatModel(openAiChatModel); + } + @Bean @ConditionalOnProperty(value = "yudao.ai.doubao.enable", havingValue = "true") public DouBaoChatModel douBaoChatClient(YudaoAiProperties yudaoAiProperties) { - YudaoAiProperties.DouBaoProperties properties = yudaoAiProperties.getDoubao(); + YudaoAiProperties.DouBao properties = yudaoAiProperties.getDoubao(); return buildDouBaoChatClient(properties); } - public DouBaoChatModel buildDouBaoChatClient(YudaoAiProperties.DouBaoProperties properties) { + public DouBaoChatModel buildDouBaoChatClient(YudaoAiProperties.DouBao properties) { if (StrUtil.isEmpty(properties.getModel())) { properties.setModel(DouBaoChatModel.MODEL_DEFAULT); } OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() .openAiApi(OpenAiApi.builder() .baseUrl(DouBaoChatModel.BASE_URL) + .completionsPath(DouBaoChatModel.COMPLETE_PATH) .apiKey(properties.getApiKey()) .build()) .defaultOptions(OpenAiChatOptions.builder() @@ -81,20 +121,20 @@ public class AiAutoConfiguration { @Bean @ConditionalOnProperty(value = "yudao.ai.siliconflow.enable", havingValue = "true") public SiliconFlowChatModel siliconFlowChatClient(YudaoAiProperties yudaoAiProperties) { - YudaoAiProperties.SiliconFlowProperties properties = yudaoAiProperties.getSiliconflow(); + YudaoAiProperties.SiliconFlow properties = yudaoAiProperties.getSiliconflow(); return buildSiliconFlowChatClient(properties); } - public SiliconFlowChatModel buildSiliconFlowChatClient(YudaoAiProperties.SiliconFlowProperties properties) { + public SiliconFlowChatModel buildSiliconFlowChatClient(YudaoAiProperties.SiliconFlow properties) { if (StrUtil.isEmpty(properties.getModel())) { properties.setModel(SiliconFlowApiConstants.MODEL_DEFAULT); } - OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() + DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() .baseUrl(SiliconFlowApiConstants.DEFAULT_BASE_URL) .apiKey(properties.getApiKey()) .build()) - .defaultOptions(OpenAiChatOptions.builder() + .defaultOptions(DeepSeekChatOptions.builder() .model(properties.getModel()) .temperature(properties.getTemperature()) .maxTokens(properties.getMaxTokens()) @@ -108,11 +148,11 @@ public class AiAutoConfiguration { @Bean @ConditionalOnProperty(value = "yudao.ai.hunyuan.enable", havingValue = "true") public HunYuanChatModel hunYuanChatClient(YudaoAiProperties yudaoAiProperties) { - YudaoAiProperties.HunYuanProperties properties = yudaoAiProperties.getHunyuan(); + YudaoAiProperties.HunYuan properties = yudaoAiProperties.getHunyuan(); return buildHunYuanChatClient(properties); } - public HunYuanChatModel buildHunYuanChatClient(YudaoAiProperties.HunYuanProperties properties) { + public HunYuanChatModel buildHunYuanChatClient(YudaoAiProperties.HunYuan properties) { if (StrUtil.isEmpty(properties.getModel())) { properties.setModel(HunYuanChatModel.MODEL_DEFAULT); } @@ -122,13 +162,14 @@ public class AiAutoConfiguration { StrUtil.startWithIgnoreCase(properties.getModel(), "deepseek") ? HunYuanChatModel.DEEP_SEEK_BASE_URL : HunYuanChatModel.BASE_URL); } - // 创建 OpenAiChatModel、HunYuanChatModel 对象 - OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() + // 创建 DeepSeekChatModel、HunYuanChatModel 对象 + DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() .baseUrl(properties.getBaseUrl()) + .completionsPath(HunYuanChatModel.COMPLETE_PATH) .apiKey(properties.getApiKey()) .build()) - .defaultOptions(OpenAiChatOptions.builder() + .defaultOptions(DeepSeekChatOptions.builder() .model(properties.getModel()) .temperature(properties.getTemperature()) .maxTokens(properties.getMaxTokens()) @@ -142,25 +183,30 @@ public class AiAutoConfiguration { @Bean @ConditionalOnProperty(value = "yudao.ai.xinghuo.enable", havingValue = "true") public XingHuoChatModel xingHuoChatClient(YudaoAiProperties yudaoAiProperties) { - YudaoAiProperties.XingHuoProperties properties = yudaoAiProperties.getXinghuo(); + YudaoAiProperties.XingHuo properties = yudaoAiProperties.getXinghuo(); return buildXingHuoChatClient(properties); } - public XingHuoChatModel buildXingHuoChatClient(YudaoAiProperties.XingHuoProperties properties) { + public XingHuoChatModel buildXingHuoChatClient(YudaoAiProperties.XingHuo properties) { if (StrUtil.isEmpty(properties.getModel())) { properties.setModel(XingHuoChatModel.MODEL_DEFAULT); } + OpenAiApi.Builder builder = OpenAiApi.builder() + .baseUrl(XingHuoChatModel.BASE_URL_V1) + .apiKey(properties.getAppKey() + ":" + properties.getSecretKey()); + if ("x1".equals(properties.getModel())) { + builder.baseUrl(XingHuoChatModel.BASE_URL_V2) + .completionsPath(XingHuoChatModel.BASE_COMPLETIONS_PATH_V2); + } OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() - .baseUrl(XingHuoChatModel.BASE_URL) - .apiKey(properties.getAppKey() + ":" + properties.getSecretKey()) - .build()) + .openAiApi(builder.build()) .defaultOptions(OpenAiChatOptions.builder() .model(properties.getModel()) .temperature(properties.getTemperature()) .maxTokens(properties.getMaxTokens()) .topP(properties.getTopP()) .build()) + // TODO @芋艿:星火的 function call 有 bug,会报 ToolResponseMessage must have an id 错误!!! .toolCallingManager(getToolCallingManager()) .build(); return new XingHuoChatModel(openAiChatModel); @@ -169,11 +215,11 @@ public class AiAutoConfiguration { @Bean @ConditionalOnProperty(value = "yudao.ai.baichuan.enable", havingValue = "true") public BaiChuanChatModel baiChuanChatClient(YudaoAiProperties yudaoAiProperties) { - YudaoAiProperties.BaiChuanProperties properties = yudaoAiProperties.getBaichuan(); + YudaoAiProperties.BaiChuan properties = yudaoAiProperties.getBaichuan(); return buildBaiChuanChatClient(properties); } - public BaiChuanChatModel buildBaiChuanChatClient(YudaoAiProperties.BaiChuanProperties properties) { + public BaiChuanChatModel buildBaiChuanChatClient(YudaoAiProperties.BaiChuan properties) { if (StrUtil.isEmpty(properties.getModel())) { properties.setModel(BaiChuanChatModel.MODEL_DEFAULT); } @@ -196,7 +242,7 @@ public class AiAutoConfiguration { @Bean @ConditionalOnProperty(value = "yudao.ai.midjourney.enable", havingValue = "true") public MidjourneyApi midjourneyApi(YudaoAiProperties yudaoAiProperties) { - YudaoAiProperties.MidjourneyProperties config = yudaoAiProperties.getMidjourney(); + YudaoAiProperties.Midjourney config = yudaoAiProperties.getMidjourney(); return new MidjourneyApi(config.getBaseUrl(), config.getApiKey(), config.getNotifyUrl()); } @@ -222,4 +268,22 @@ public class AiAutoConfiguration { return SpringUtil.getBean(ToolCallingManager.class); } + // ========== Web Search 相关 ========== + + @Bean + @ConditionalOnProperty(value = "yudao.ai.web-search.enable", havingValue = "true") + public AiWebSearchClient webSearchClient(YudaoAiProperties yudaoAiProperties) { + return new AiBoChaWebSearchClient(yudaoAiProperties.getWebSearch().getApiKey()); + } + + // ========== MCP 相关 ========== + + /** + * 参考自 MCP Server Boot Starter + */ + @Bean + public List toolCallbacks(PersonService personService) { + return List.of(ToolCallbacks.from(personService)); + } + } \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/YudaoAiProperties.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/YudaoAiProperties.java index 7c26aa89c..67d3bb5f3 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/YudaoAiProperties.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/YudaoAiProperties.java @@ -13,49 +13,54 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @Data public class YudaoAiProperties { + /** + * 谷歌 Gemini + */ + private Gemini gemini; + /** * 字节豆包 */ - @SuppressWarnings("SpellCheckingInspection") - private DouBaoProperties doubao; + private DouBao doubao; /** * 腾讯混元 */ - @SuppressWarnings("SpellCheckingInspection") - private HunYuanProperties hunyuan; + private HunYuan hunyuan; /** * 硅基流动 */ - @SuppressWarnings("SpellCheckingInspection") - private SiliconFlowProperties siliconflow; + private SiliconFlow siliconflow; /** * 讯飞星火 */ - @SuppressWarnings("SpellCheckingInspection") - private XingHuoProperties xinghuo; + private XingHuo xinghuo; /** * 百川 */ - @SuppressWarnings("SpellCheckingInspection") - private BaiChuanProperties baichuan; + private BaiChuan baichuan; /** * Midjourney 绘图 */ - private MidjourneyProperties midjourney; + private Midjourney midjourney; /** * Suno 音乐 */ @SuppressWarnings("SpellCheckingInspection") - private SunoProperties suno; + private Suno suno; + + /** + * 网络搜索 + */ + private WebSearch webSearch; @Data - public static class DouBaoProperties { + public static class Gemini { private String enable; private String apiKey; @@ -68,7 +73,20 @@ public class YudaoAiProperties { } @Data - public static class HunYuanProperties { + public static class DouBao { + + private String enable; + private String apiKey; + + private String model; + private Double temperature; + private Integer maxTokens; + private Double topP; + + } + + @Data + public static class HunYuan { private String enable; private String baseUrl; @@ -82,7 +100,7 @@ public class YudaoAiProperties { } @Data - public static class SiliconFlowProperties { + public static class SiliconFlow { private String enable; private String apiKey; @@ -95,7 +113,7 @@ public class YudaoAiProperties { } @Data - public static class XingHuoProperties { + public static class XingHuo { private String enable; private String appId; @@ -110,7 +128,7 @@ public class YudaoAiProperties { } @Data - public static class BaiChuanProperties { + public static class BaiChuan { private String enable; private String apiKey; @@ -123,7 +141,7 @@ public class YudaoAiProperties { } @Data - public static class MidjourneyProperties { + public static class Midjourney { private String enable; private String baseUrl; @@ -134,12 +152,21 @@ public class YudaoAiProperties { } @Data - public static class SunoProperties { + public static class Suno { - private boolean enable = false; + private boolean enable; private String baseUrl; } + @Data + public static class WebSearch { + + private boolean enable; + + private String apiKey; + + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/AiModelFactory.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactory.java similarity index 98% rename from yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/AiModelFactory.java rename to yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactory.java index 659fa1f92..1c0b808b9 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/AiModelFactory.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactory.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.ai.framework.ai.core; +package cn.iocoder.yudao.module.ai.framework.ai.core.model; import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/AiModelFactoryImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactoryImpl.java similarity index 94% rename from yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/AiModelFactoryImpl.java rename to yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactoryImpl.java index f7b42e30a..75798ebd2 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/AiModelFactoryImpl.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/AiModelFactoryImpl.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.ai.framework.ai.core; +package cn.iocoder.yudao.module.ai.framework.ai.core.model; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; @@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.ai.framework.ai.config.AiAutoConfiguration; import cn.iocoder.yudao.module.ai.framework.ai.config.YudaoAiProperties; import cn.iocoder.yudao.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.doubao.DouBaoChatModel; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini.GeminiChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.hunyuan.HunYuanChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowApiConstants; @@ -67,6 +68,7 @@ import org.springframework.ai.minimax.MiniMaxChatOptions; import org.springframework.ai.minimax.MiniMaxEmbeddingModel; import org.springframework.ai.minimax.MiniMaxEmbeddingOptions; import org.springframework.ai.minimax.api.MiniMaxApi; +import org.springframework.ai.model.anthropic.autoconfigure.AnthropicChatAutoConfiguration; import org.springframework.ai.model.azure.openai.autoconfigure.AzureOpenAiChatAutoConfiguration; import org.springframework.ai.model.azure.openai.autoconfigure.AzureOpenAiEmbeddingAutoConfiguration; import org.springframework.ai.model.azure.openai.autoconfigure.AzureOpenAiEmbeddingProperties; @@ -93,6 +95,8 @@ import org.springframework.ai.openai.OpenAiImageModel; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiImageApi; import org.springframework.ai.openai.api.common.OpenAiApiConstants; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.stabilityai.StabilityAiImageModel; import org.springframework.ai.stabilityai.api.StabilityAiApi; import org.springframework.ai.vectorstore.SimpleVectorStore; @@ -168,6 +172,10 @@ public class AiModelFactoryImpl implements AiModelFactory { return buildOpenAiChatModel(apiKey, url); case AZURE_OPENAI: return buildAzureOpenAiChatModel(apiKey, url); + case ANTHROPIC: + return buildAnthropicChatModel(apiKey, url); + case GEMINI: + return buildGeminiChatModel(apiKey); case OLLAMA: return buildOllamaChatModel(url); default: @@ -206,6 +214,10 @@ public class AiModelFactoryImpl implements AiModelFactory { return SpringUtil.getBean(OpenAiChatModel.class); case AZURE_OPENAI: return SpringUtil.getBean(AzureOpenAiChatModel.class); + case ANTHROPIC: + return SpringUtil.getBean(AnthropicChatModel.class); + case GEMINI: + return SpringUtil.getBean(GeminiChatModel.class); case OLLAMA: return SpringUtil.getBean(OllamaChatModel.class); default: @@ -260,7 +272,7 @@ public class AiModelFactoryImpl implements AiModelFactory { String cacheKey = buildClientCacheKey(MidjourneyApi.class, AiPlatformEnum.MIDJOURNEY.getPlatform(), apiKey, url); return Singleton.get(cacheKey, (Func0) () -> { - YudaoAiProperties.MidjourneyProperties properties = SpringUtil.getBean(YudaoAiProperties.class) + YudaoAiProperties.Midjourney properties = SpringUtil.getBean(YudaoAiProperties.class) .getMidjourney(); return new MidjourneyApi(url, apiKey, properties.getNotifyUrl()); }); @@ -347,7 +359,7 @@ public class AiModelFactoryImpl implements AiModelFactory { * 可参考 {@link DashScopeImageAutoConfiguration} 的 dashScopeImageModel 方法 */ private static DashScopeImageModel buildTongYiImagesModel(String key) { - DashScopeImageApi dashScopeImageApi = new DashScopeImageApi(key); + DashScopeImageApi dashScopeImageApi = DashScopeImageApi.builder().apiKey(key).build(); return DashScopeImageModel.builder() .dashScopeApi(dashScopeImageApi) .build(); @@ -397,7 +409,7 @@ public class AiModelFactoryImpl implements AiModelFactory { * 可参考 {@link AiAutoConfiguration#douBaoChatClient(YudaoAiProperties)} */ private ChatModel buildDouBaoChatModel(String apiKey) { - YudaoAiProperties.DouBaoProperties properties = new YudaoAiProperties.DouBaoProperties() + YudaoAiProperties.DouBao properties = new YudaoAiProperties.DouBao() .setApiKey(apiKey); return new AiAutoConfiguration().buildDouBaoChatClient(properties); } @@ -406,7 +418,7 @@ public class AiModelFactoryImpl implements AiModelFactory { * 可参考 {@link AiAutoConfiguration#hunYuanChatClient(YudaoAiProperties)} */ private ChatModel buildHunYuanChatModel(String apiKey, String url) { - YudaoAiProperties.HunYuanProperties properties = new YudaoAiProperties.HunYuanProperties() + YudaoAiProperties.HunYuan properties = new YudaoAiProperties.HunYuan() .setBaseUrl(url).setApiKey(apiKey); return new AiAutoConfiguration().buildHunYuanChatClient(properties); } @@ -415,7 +427,7 @@ public class AiModelFactoryImpl implements AiModelFactory { * 可参考 {@link AiAutoConfiguration#siliconFlowChatClient(YudaoAiProperties)} */ private ChatModel buildSiliconFlowChatModel(String apiKey) { - YudaoAiProperties.SiliconFlowProperties properties = new YudaoAiProperties.SiliconFlowProperties() + YudaoAiProperties.SiliconFlow properties = new YudaoAiProperties.SiliconFlow() .setApiKey(apiKey); return new AiAutoConfiguration().buildSiliconFlowChatClient(properties); } @@ -473,7 +485,7 @@ public class AiModelFactoryImpl implements AiModelFactory { private static XingHuoChatModel buildXingHuoChatModel(String key) { List keys = StrUtil.split(key, '|'); Assert.equals(keys.size(), 2, "XingHuoChatClient 的密钥需要 (appKey|secretKey) 格式"); - YudaoAiProperties.XingHuoProperties properties = new YudaoAiProperties.XingHuoProperties() + YudaoAiProperties.XingHuo properties = new YudaoAiProperties.XingHuo() .setAppKey(keys.get(0)).setSecretKey(keys.get(1)); return new AiAutoConfiguration().buildXingHuoChatClient(properties); } @@ -482,7 +494,7 @@ public class AiModelFactoryImpl implements AiModelFactory { * 可参考 {@link AiAutoConfiguration#baiChuanChatClient(YudaoAiProperties)} */ private BaiChuanChatModel buildBaiChuanChatModel(String apiKey) { - YudaoAiProperties.BaiChuanProperties properties = new YudaoAiProperties.BaiChuanProperties() + YudaoAiProperties.BaiChuan properties = new YudaoAiProperties.BaiChuan() .setApiKey(apiKey); return new AiAutoConfiguration().buildBaiChuanChatClient(properties); } @@ -512,6 +524,30 @@ public class AiModelFactoryImpl implements AiModelFactory { .build(); } + /** + * 可参考 {@link AnthropicChatAutoConfiguration} 的 anthropicApi 方法 + */ + private static AnthropicChatModel buildAnthropicChatModel(String apiKey, String url) { + AnthropicApi.Builder builder = AnthropicApi.builder().apiKey(apiKey); + if (StrUtil.isNotEmpty(url)) { + builder.baseUrl(url); + } + AnthropicApi anthropicApi = builder.build(); + return AnthropicChatModel.builder() + .anthropicApi(anthropicApi) + .toolCallingManager(getToolCallingManager()) + .build(); + } + + /** + * 可参考 {@link AiAutoConfiguration#buildGeminiChatClient(YudaoAiProperties.Gemini)} + */ + private static GeminiChatModel buildGeminiChatModel(String apiKey) { + YudaoAiProperties.Gemini properties = SpringUtil.getBean(YudaoAiProperties.class) + .getGemini().setApiKey(apiKey); + return new AiAutoConfiguration().buildGeminiChatClient(properties); + } + /** * 可参考 {@link OpenAiImageAutoConfiguration} 的 openAiImageModel 方法 */ diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/bocha/README.md b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/bocha/README.md new file mode 100644 index 000000000..40c91437d --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/bocha/README.md @@ -0,0 +1,174 @@ +# AiBoChaWebSearchClient 使用指南 + +## 概述 + +`AiBoChaWebSearchClient` 是基于博查AI开放平台提供的网页搜索服务的Java客户端,实现了符合项目架构风格的HTTP客户端封装。 + +## 特性 + +- **统一的API调用风格**:参考 SunoApi 和 XunFeiPptApi 的实现方式 +- **Record 类型数据结构**:使用 Record 类型定义请求和响应数据 +- **简洁的响应数据模型**:包含网页搜索结果 +- **灵活的搜索配置**:支持时间范围、域名过滤、结果数量等参数 +- **错误处理机制**:统一的异常处理和日志记录 + +## 快速开始 + +### 1. 创建客户端实例 + +```java +// 使用默认base URL +AiBoChaWebSearchClient client = new AiBoChaWebSearchClient("your-api-key"); + +// 使用自定义base URL +AiBoChaWebSearchClient client = new AiBoChaWebSearchClient("https://custom.api.com", "your-api-key"); +``` + +### 2. 基本搜索 + +```java +// 基本搜索 +WebSearchRequest request = new WebSearchRequest( + "Spring Boot 教程", + null, null, null, null, null +); +AiWebSearchResponse result = client.search(request); +``` + +### 3. 高级搜索 + +```java +// 构建详细的搜索请求 +WebSearchRequest request = new WebSearchRequest( + "人工智能最新进展", + FreshnessType.ONE_WEEK.getValue(), // 搜索一周内的内容 + true, // 显示摘要 + "zhihu.com|csdn.net", // 只搜索指定域名 + "spam.com", // 排除指定域名 + 20 // 返回20条结果 +); + +AiWebSearchResponse result = client.search(request); +``` + +## API参数说明 + +### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| query | String | 是 | 用户的搜索词 | +| freshness | String | 否 | 搜索时间范围,默认为 noLimit | +| summary | Boolean | 否 | 是否显示文本摘要,默认为 false | +| include | String | 否 | 指定搜索的网站范围,多个域名使用\|或,分隔 | +| exclude | String | 否 | 排除搜索的网站范围,多个域名使用\|或,分隔 | +| count | Integer | 否 | 返回结果条数,范围1-50,默认为10 | + +### 时间范围选项 + +使用 `FreshnessType` 枚举: + +```java +FreshnessType.NO_LIMIT // 不限(默认) +FreshnessType.ONE_DAY // 一天内 +FreshnessType.ONE_WEEK // 一周内 +FreshnessType.ONE_MONTH // 一个月内 +FreshnessType.ONE_YEAR // 一年内 +``` + +也可以使用自定义日期范围: +- 日期范围:`"2025-01-01..2025-04-06"` +- 指定日期:`"2025-04-06"` + +### 响应数据结构 + +```java +// 主要响应数据 +AiWebSearchResponse result = client.search(request); + +// 网页搜索结果 +List webPages = result.webPages(); +for (AiWebSearchResponse.WebPage page : webPages) { + String title = page.title(); // 网页标题 + String url = page.url(); // 网页URL + String snippet = page.snippet(); // 内容描述 + String summary = page.summary(); // 文本摘要(如果请求了summary) + String siteName = page.siteName(); // 网站名称 +} +``` + +## 使用示例 + +### 示例1:搜索技术文档 + +```java +WebSearchRequest request = new WebSearchRequest( + "Spring Boot 3.x 新特性", + FreshnessType.ONE_MONTH.getValue(), + true, + "spring.io|baeldung.com|github.com", + null, + 15 +); + +AiWebSearchResponse result = client.search(request); +``` + +### 示例2:搜索新闻资讯 + +```java +WebSearchRequest request = new WebSearchRequest( + "AI大模型发展趋势", + FreshnessType.ONE_WEEK.getValue(), + null, + null, + "advertisement.com|spam.net", + 30 +); + +AiWebSearchResponse result = client.search(request); +``` + +## 注意事项 + +1. **API密钥**:需要先到博查AI开放平台(https://open.bochaai.com)获取API KEY +2. **请求频率**:注意遵守平台的API调用频率限制 +3. **时间范围**:建议使用 `noLimit` 以获得更好的搜索效果 +4. **域名过滤**:include和exclude参数最多支持20个域名 +5. **结果数量**:单次搜索最多返回50条结果 + +## 集成建议 + +在Spring Boot项目中,建议将客户端配置为Bean: + +```java +@Configuration +public class AiConfiguration { + + @Value("${ai.bocha.api-key}") + private String apiKey; + + @Value("${ai.bocha.base-url:https://open.bochaai.com}") + private String baseUrl; + + @Bean + public AiBoChaWebSearchClient boChaWebSearchClient() { + return new AiBoChaWebSearchClient(baseUrl, apiKey); + } +} +``` + +## 故障排查 + +1. **网络连接问题**:检查网络连接和防火墙设置 +2. **API密钥错误**:确认API KEY正确且有效 +3. **请求参数错误**:检查必填参数是否正确填写 +4. **服务器响应错误**:查看日志中的详细错误信息 + +## 更新日志 + +- v2.0.0:重大重构,统一 Record 类型,简化 API 调用,支持新的响应结构 +- v1.3.0:统一使用 Record 类型,移除 Lombok 注解,保持代码风格一致性 +- v1.2.0:进一步简化,移除视频搜索功能,专注于网页搜索 +- v1.1.0:使用 Lombok 简化代码,移除图片搜索功能 +- v1.0.0:初始版本,实现基本的网页搜索功能 \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java index 6e2bfda49..a542cb372 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/doubao/DouBaoChatModel.java @@ -6,7 +6,6 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; import reactor.core.publisher.Flux; /** @@ -19,13 +18,14 @@ import reactor.core.publisher.Flux; public class DouBaoChatModel implements ChatModel { public static final String BASE_URL = "https://ark.cn-beijing.volces.com/api"; + public static final String COMPLETE_PATH = "/v3/chat/completions"; public static final String MODEL_DEFAULT = "doubao-1-5-lite-32k-250115"; /** * 兼容 OpenAI 接口,进行复用 */ - private final OpenAiChatModel openAiChatModel; + private final ChatModel openAiChatModel; @Override public ChatResponse call(Prompt prompt) { diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java new file mode 100644 index 000000000..378a0af1f --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/gemini/GeminiChatModel.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import reactor.core.publisher.Flux; + +/** + * 谷歌 Gemini {@link ChatModel} 实现类,基于 Google AI Studio 提供的 OpenAI 兼容方案 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class GeminiChatModel implements ChatModel { + + public static final String BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; + public static final String COMPLETE_PATH = "/chat/completions"; + + public static final String MODEL_DEFAULT = "gemini-2.5-flash"; + + /** + * 兼容 OpenAI 接口,进行复用 + */ + private final OpenAiChatModel openAiChatModel; + + @Override + public ChatResponse call(Prompt prompt) { + return openAiChatModel.call(prompt); + } + + @Override + public Flux stream(Prompt prompt) { + return openAiChatModel.stream(prompt); + } + + @Override + public ChatOptions getDefaultOptions() { + return openAiChatModel.getDefaultOptions(); + } + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java index debd0a4a9..9513c6c5f 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/hunyuan/HunYuanChatModel.java @@ -6,7 +6,6 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; import reactor.core.publisher.Flux; /** @@ -22,6 +21,7 @@ import reactor.core.publisher.Flux; public class HunYuanChatModel implements ChatModel { public static final String BASE_URL = "https://api.hunyuan.cloud.tencent.com"; + public static final String COMPLETE_PATH = "/v1/chat/completions"; public static final String MODEL_DEFAULT = "hunyuan-turbo"; @@ -32,7 +32,7 @@ public class HunYuanChatModel implements ChatModel { /** * 兼容 OpenAI 接口,进行复用 */ - private final OpenAiChatModel openAiChatModel; + private final ChatModel openAiChatModel; @Override public ChatResponse call(Prompt prompt) { diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/siliconflow/SiliconFlowChatModel.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/siliconflow/SiliconFlowChatModel.java index 631b3455e..a910e3403 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/siliconflow/SiliconFlowChatModel.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/siliconflow/SiliconFlowChatModel.java @@ -23,7 +23,7 @@ public class SiliconFlowChatModel implements ChatModel { /** * 兼容 OpenAI 接口,进行复用 */ - private final OpenAiChatModel openAiChatModel; + private final ChatModel openAiChatModel; @Override public ChatResponse call(Prompt prompt) { diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/xinghuo/XingHuoChatModel.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/xinghuo/XingHuoChatModel.java index d97e26398..cbac3b6df 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/xinghuo/XingHuoChatModel.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/xinghuo/XingHuoChatModel.java @@ -6,7 +6,6 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; import reactor.core.publisher.Flux; /** @@ -18,28 +17,34 @@ import reactor.core.publisher.Flux; @RequiredArgsConstructor public class XingHuoChatModel implements ChatModel { - public static final String BASE_URL = "https://spark-api-open.xf-yun.com"; + public static final String BASE_URL_V1 = "https://spark-api-open.xf-yun.com"; - public static final String MODEL_DEFAULT = "generalv3.5"; + public static final String BASE_URL_V2 = "https://spark-api-open.xf-yun.com"; + public static final String BASE_COMPLETIONS_PATH_V2 = "/v2/chat/completions"; /** - * 兼容 OpenAI 接口,进行复用 + * 已知模型名列表:x1、4.0Ultra、generalv3.5、max-32k、generalv3、pro-128k、lite */ - private final OpenAiChatModel openAiChatModel; + public static final String MODEL_DEFAULT = "4.0Ultra"; + + /** + * v1 兼容 OpenAI 接口,进行复用 + */ + private final ChatModel openAiChatModelV1; @Override public ChatResponse call(Prompt prompt) { - return openAiChatModel.call(prompt); + return openAiChatModelV1.call(prompt); } @Override public Flux stream(Prompt prompt) { - return openAiChatModel.stream(prompt); + return openAiChatModelV1.stream(prompt); } @Override public ChatOptions getDefaultOptions() { - return openAiChatModel.getDefaultOptions(); + return openAiChatModelV1.getDefaultOptions(); } } diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java new file mode 100644 index 000000000..9fbff556c --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchClient.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch; + +/** + * 网络搜索客户端接口 + * + * @author 芋道源码 + */ +public interface AiWebSearchClient { + + /** + * 网页搜索 + * + * @param request 搜索请求 + * @return 搜索结果 + */ + AiWebSearchResponse search(AiWebSearchRequest request); + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java new file mode 100644 index 000000000..9bd2cfef3 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchRequest.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class AiWebSearchRequest { + + /** + * 用户的搜索词 + */ + @NotEmpty(message = "搜索词不能为空") + private String query; + + /** + * 是否显示文本摘要 + * + * true - 显示 + * false - 不显示(默认) + */ + private Boolean summary; + + /** + * 返回结果的条数 + */ + @NotNull(message = "返回结果条数不能为空") + @Min(message = "返回结果条数最小为 1", value = 1) + @Max(message = "返回结果条数最大为 50", value = 50) + private Integer count; + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java new file mode 100644 index 000000000..8755b32ed --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/AiWebSearchResponse.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch; + +import lombok.Data; + +import java.util.List; + +@Data +public class AiWebSearchResponse { + + /** + * 总数(总共匹配的网页数) + */ + private Long total; + + /** + * 数据列表 + */ + private List lists; + + /** + * 网页对象 + */ + @Data + public static class WebPage { + + /** + * 名称 + * + * 例如说:搜狐网 + */ + private String name; + /** + * 图标 + */ + private String icon; + + /** + * 标题 + * + * 例如说:186页|阿里巴巴:2024年环境、社会和治理(ESG)报告 + */ + private String title; + /** + * URL + * + * 例如说:https://m.sohu.com/a/815036254_121819701/?pvid=000115_3w_a + */ + @SuppressWarnings("JavadocLinkAsPlainText") + private String url; + + /** + * 内容的简短描述 + */ + private String snippet; + /** + * 内容的文本摘要 + */ + private String summary; + + } + +} \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java new file mode 100644 index 000000000..7395fe645 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/webserch/bocha/AiBoChaWebSearchClient.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 博查 {@link AiWebSearchClient} 实现类 + * + * @see 博查 AI 开放平台 + * + * @author 芋道源码 + */ +@Slf4j +public class AiBoChaWebSearchClient implements AiWebSearchClient { + + public static final String BASE_URL = "https://api.bochaai.com"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final WebClient webClient; + + private final Predicate STATUS_PREDICATE = status -> !status.is2xxSuccessful(); + + private final Function>> EXCEPTION_FUNCTION = + reqParam -> response -> response.bodyToMono(String.class).handle((responseBody, sink) -> { + log.error("[AiBoChaWebSearchClient] 调用失败!请求参数:[{}],响应数据: [{}]", reqParam, responseBody); + sink.error(new IllegalStateException("[AiBoChaWebSearchClient] 调用失败!")); + }); + + public AiBoChaWebSearchClient(String apiKey) { + this.webClient = WebClient.builder() + .baseUrl(BASE_URL) + .defaultHeaders((headers) -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey); + }) + .build(); + } + + @Override + public AiWebSearchResponse search(AiWebSearchRequest request) { + // 转换请求参数 + WebSearchRequest webSearchRequest = new WebSearchRequest( + request.getQuery(), + request.getSummary(), + request.getCount() + ); + // 调用博查 API + CommonResult response = this.webClient.post() + .uri("/v1/web-search") + .bodyValue(webSearchRequest) + .retrieve() + .onStatus(STATUS_PREDICATE, EXCEPTION_FUNCTION.apply(webSearchRequest)) + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + if (response == null) { + throw new IllegalStateException("[search][搜索结果为空]"); + } + if (response.getData() == null) { + throw new IllegalStateException(String.format("[search][搜索失败,code = %s, msg = %s]", + response.getCode(), response.getMsg())); + } + WebSearchResponse data = response.getData(); + + // 转换结果 + AiWebSearchResponse result = new AiWebSearchResponse(); + if (data.webPages() == null || CollUtil.isEmpty(data.webPages().value())) { + return result.setTotal(0L).setLists(List.of()); + } + return result.setTotal(data.webPages().totalEstimatedMatches()) + .setLists(convertList(data.webPages().value(), page -> new AiWebSearchResponse.WebPage() + .setName(page.siteName()).setIcon(page.siteIcon()) + .setTitle(page.name()).setUrl(page.url()) + .setSnippet(page.snippet()).setSummary(page.summary()))); + } + + /** + * 网页搜索请求参数 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebSearchRequest( + String query, + Boolean summary, + Integer count + ) { + public WebSearchRequest { + Assert.notBlank(query, "query 不能为空"); + } + } + + /** + * 网页搜索响应 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebSearchResponse( + WebSearchWebPages webPages + ) { + } + + /** + * 网页搜索结果 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebSearchWebPages( + String webSearchUrl, + Long totalEstimatedMatches, + List value, + Boolean someResultsRemoved + ) { + + /** + * 网页结果值 + */ + @JsonInclude(value = JsonInclude.Include.NON_NULL) + public record WebPageValue( + String id, + String name, + String url, + String displayUrl, + String snippet, + String summary, + String siteName, + String siteIcon, + String datePublished, + String dateLastCrawled, + String cachedPageUrl, + String language, + Boolean isFamilyFriendly, + Boolean isNavigational + ) { + } + + } + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/security/config/SecurityConfiguration.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/security/config/SecurityConfiguration.java index c5dc12523..a2c1eeee3 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/security/config/SecurityConfiguration.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/framework/security/config/SecurityConfiguration.java @@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.ai.framework.security.config; import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer; import cn.iocoder.yudao.module.infra.enums.ApiConstants; +import jakarta.annotation.Resource; +import org.springframework.ai.mcp.server.autoconfigure.McpServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -13,6 +15,9 @@ import org.springframework.security.config.annotation.web.configurers.AuthorizeH @Configuration(proxyBeanMethods = false, value = "aiSecurityConfiguration") public class SecurityConfiguration { + @Resource + private McpServerProperties serverProperties; + @Bean("aiAuthorizeRequestsCustomizer") public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { return new AuthorizeRequestsCustomizer() { @@ -33,6 +38,10 @@ public class SecurityConfiguration { // TODO 芋艿:这个每个项目都需要重复配置,得捉摸有没通用的方案 // RPC 服务的安全配置 registry.requestMatchers(ApiConstants.PREFIX + "/**").permitAll(); + + // MCP Server + registry.requestMatchers(serverProperties.getSseEndpoint()).permitAll(); + registry.requestMatchers(serverProperties.getSseMessageEndpoint()).permitAll(); } }; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java index 4af65bd8f..0f44eacbf 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java @@ -1,10 +1,11 @@ package cn.iocoder.yudao.module.ai.service.chat; +import cn.hutool.core.codec.Base64; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; -import cn.iocoder.yudao.module.ai.util.AiUtils; +import cn.hutool.http.HttpUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; @@ -21,6 +22,10 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO; import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiToolDO; import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatMessageMapper; import cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants; +import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; import cn.iocoder.yudao.module.ai.service.knowledge.AiKnowledgeDocumentService; import cn.iocoder.yudao.module.ai.service.knowledge.AiKnowledgeSegmentService; import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO; @@ -28,6 +33,10 @@ import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchR import cn.iocoder.yudao.module.ai.service.model.AiChatRoleService; import cn.iocoder.yudao.module.ai.service.model.AiModelService; import cn.iocoder.yudao.module.ai.service.model.AiToolService; +import cn.iocoder.yudao.module.ai.util.AiUtils; +import cn.iocoder.yudao.module.ai.util.FileTypeUtils; +import com.google.common.collect.Maps; +import io.modelcontextprotocol.client.McpSyncClient; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.Message; @@ -39,6 +48,11 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.StreamingChatModel; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; +import org.springframework.ai.mcp.client.autoconfigure.properties.McpClientCommonProperties; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; @@ -64,6 +78,13 @@ import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_MESSAGE_N @Slf4j public class AiChatMessageServiceImpl implements AiChatMessageService { + /** + * 联网搜索的结束数 + */ + private static final Integer WEB_SEARCH_COUNT = 10; + + // TODO @芋艿:后续优化下对话的 Prompt 整体结构 + /** * 知识库转 {@link UserMessage} 的内容模版 */ @@ -71,6 +92,18 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { "%s\n\n" + // 多个 的拼接 "回答要求:\n- 避免提及你是从 获取的知识。"; + private static final String WEB_SEARCH_USER_MESSAGE_TEMPLATE = "使用 标记中的内容作为本次对话的参考:\n\n" + + "%s\n\n" + // 多个 的拼接 + "回答要求:\n- 避免提及你是从 获取的知识。"; + + /** + * 附件转 ${@link UserMessage} 的内容模版 + */ + @SuppressWarnings("TextBlockMigration") + private static final String Attachment_USER_MESSAGE_TEMPLATE = "使用 标记用户对话上传的附件内容:\n\n" + + "%s\n\n" + // 多个 的拼接 + "回答要求:\n- 避免提及 附件的编码格式。"; + @Resource private AiChatMessageMapper chatMessageMapper; @@ -87,6 +120,21 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @Resource private AiToolService toolService; + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") + @Autowired(required = false) // 由于 yudao.ai.web-search.enable 配置项,可以关闭 AiWebSearchClient 的功能,所以这里只能不强制注入 + private AiWebSearchClient webSearchClient; + + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") + @Autowired(required = false) // 由于 yudao.ai.mcp.client.enable 配置项,可以关闭 McpSyncClient 的功能,所以这里只能不强制注入 + private List mcpClients; + + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") + @Autowired(required = false) // 由于 yudao.ai.mcp.client.enable 配置项,可以关闭 McpSyncClient 的功能,所以这里只能不强制注入 + private McpClientCommonProperties mcpClientCommonProperties; + + @Resource + private ToolCallbackResolver toolCallbackResolver; + @Transactional(rollbackFor = Exception.class) public AiChatMessageSendRespVO sendMessage(AiChatMessageSendReqVO sendReqVO, Long userId) { // 1.1 校验对话存在 @@ -100,27 +148,35 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { AiModelDO model = modalService.validateModel(conversation.getModelId()); ChatModel chatModel = modalService.getChatModel(model.getId()); - // 2. 知识库找回 - List knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(), conversation); + // 2.1 知识库召回 + List knowledgeSegments = recallKnowledgeSegment( + sendReqVO.getContent(), conversation); + + // 2.2 联网搜索 + AiWebSearchResponse webSearchResponse = Boolean.TRUE.equals(sendReqVO.getUseSearch()) && webSearchClient != null ? + webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent()) + .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null; // 3. 插入 user 发送消息 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model, userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(), - null); + null, sendReqVO.getAttachmentUrls(), null); - // 3.1 插入 assistant 接收消息 + // 4.1 插入 assistant 接收消息 AiChatMessageDO assistantMessage = createChatMessage(conversation.getId(), userMessage.getId(), model, userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(), - knowledgeSegments); + knowledgeSegments, null, webSearchResponse); - // 3.2 创建 chat 需要的 Prompt - Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, model, sendReqVO); + // 4.2 创建 chat 需要的 Prompt + Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, webSearchResponse, model, sendReqVO); ChatResponse chatResponse = chatModel.call(prompt); - // 3.3 更新响应内容 - String newContent = chatResponse.getResult().getOutput().getText(); - chatMessageMapper.updateById(new AiChatMessageDO().setId(assistantMessage.getId()).setContent(newContent)); - // 3.4 响应结果 + // 4.3 更新响应内容 + String newContent = AiUtils.getChatResponseContent(chatResponse); + String newReasoningContent = AiUtils.getChatResponseReasoningContent(chatResponse); + chatMessageMapper.updateById(new AiChatMessageDO().setId(assistantMessage.getId()) + .setContent(newContent).setReasoningContent(newReasoningContent)); + // 4.4 响应结果 Map documentMap = knowledgeDocumentService.getKnowledgeDocumentMap( convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId)); List segments = BeanUtils.toBean(knowledgeSegments, @@ -131,7 +187,8 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { return new AiChatMessageSendRespVO() .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class)) .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class) - .setContent(newContent).setSegments(segments)); + .setContent(newContent).setSegments(segments) + .setWebSearchPages(webSearchResponse != null ? webSearchResponse.getLists() : null)); } @Override @@ -148,29 +205,36 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { AiModelDO model = modalService.validateModel(conversation.getModelId()); StreamingChatModel chatModel = modalService.getChatModel(model.getId()); - // 2. 知识库找回 - List knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(), - conversation); + // 2.1 知识库找回 + List knowledgeSegments = recallKnowledgeSegment( + sendReqVO.getContent(), conversation); + + // 2.2 联网搜索 + AiWebSearchResponse webSearchResponse = Boolean.TRUE.equals(sendReqVO.getUseSearch()) && webSearchClient != null ? + webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent()) + .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null; // 3. 插入 user 发送消息 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model, userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(), - null); + null, sendReqVO.getAttachmentUrls(), null); // 4.1 插入 assistant 接收消息 AiChatMessageDO assistantMessage = createChatMessage(conversation.getId(), userMessage.getId(), model, userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(), - knowledgeSegments); + knowledgeSegments, null, webSearchResponse); // 4.2 构建 Prompt,并进行调用 - Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, model, sendReqVO); + Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, webSearchResponse, model, sendReqVO); Flux streamResponse = chatModel.stream(prompt); // 4.3 流式返回 StringBuffer contentBuffer = new StringBuffer(); + StringBuffer reasoningContentBuffer = new StringBuffer(); return streamResponse.map(chunk -> { - // 处理知识库的返回,只有首次才有 + // 仅首次:返回知识库、联网搜索 List segments = null; + List webSearchPages = null; if (StrUtil.isEmpty(contentBuffer)) { Map documentMap = TenantUtils.executeIgnore(() -> knowledgeDocumentService.getKnowledgeDocumentMap( @@ -179,24 +243,56 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId()); segment.setDocumentName(document != null ? document.getName() : null); }); + if (webSearchResponse != null) { + webSearchPages = webSearchResponse.getLists(); + } } // 响应结果 - String newContent = chunk.getResult() != null ? chunk.getResult().getOutput().getText() : null; - newContent = StrUtil.nullToDefault(newContent, ""); // 避免 null 的 情况 - contentBuffer.append(newContent); + String newContent = AiUtils.getChatResponseContent(chunk); + String newReasoningContent = AiUtils.getChatResponseReasoningContent(chunk); + if (StrUtil.isNotEmpty(newContent)) { + contentBuffer.append(newContent); + } + if (StrUtil.isNotEmpty(newReasoningContent)) { + reasoningContentBuffer.append(newReasoningContent); + } return success(new AiChatMessageSendRespVO() .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class)) .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class) - .setContent(newContent).setSegments(segments))); + .setContent(StrUtil.nullToDefault(newContent, "")) // 避免 null 的 情况 + .setReasoningContent(StrUtil.nullToDefault(newReasoningContent, "")) // 避免 null 的 情况 + .setSegments(segments).setWebSearchPages(webSearchPages))); // 知识库 + 联网搜索 }).doOnComplete(() -> { // 忽略租户,因为 Flux 异步无法透传租户 TenantUtils.executeIgnore(() -> chatMessageMapper.updateById( - new AiChatMessageDO().setId(assistantMessage.getId()).setContent(contentBuffer.toString()))); + new AiChatMessageDO().setId(assistantMessage.getId()).setContent(contentBuffer.toString()) + .setReasoningContent(reasoningContentBuffer.toString()))); }).doOnError(throwable -> { log.error("[sendChatMessageStream][userId({}) sendReqVO({}) 发生异常]", userId, sendReqVO, throwable); // 忽略租户,因为 Flux 异步无法透传租户 - TenantUtils.executeIgnore(() -> chatMessageMapper.updateById( - new AiChatMessageDO().setId(assistantMessage.getId()).setContent(throwable.getMessage()))); + TenantUtils.executeIgnore(() -> { + // 如果有内容,则更新内容 + if (StrUtil.isNotEmpty(contentBuffer)) { + chatMessageMapper.updateById(new AiChatMessageDO().setId(assistantMessage.getId()) + .setContent(contentBuffer.toString()).setReasoningContent(reasoningContentBuffer.toString())); + } else { + // 否则,则进行删除 + chatMessageMapper.deleteById(assistantMessage.getId()); + } + }); + }).doOnCancel(() -> { + log.info("[sendChatMessageStream][userId({}) sendReqVO({}) 取消请求]", userId, sendReqVO); + // 忽略租户,因为 Flux 异步无法透传租户 + TenantUtils.executeIgnore(() -> { + // 如果有内容,则更新内容 + if (StrUtil.isNotEmpty(contentBuffer)) { + chatMessageMapper.updateById(new AiChatMessageDO().setId(assistantMessage.getId()) + .setContent(contentBuffer.toString()).setReasoningContent(reasoningContentBuffer.toString())); + } else { + // 否则,则进行删除 + chatMessageMapper.deleteById(assistantMessage.getId()); + } + }); }).onErrorResume(error -> Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR))); } @@ -211,7 +307,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { return Collections.emptyList(); } - // 2. 遍历找回 + // 2. 遍历召回 List knowledgeSegments = new ArrayList<>(); for (Long knowledgeId : role.getKnowledgeIds()) { knowledgeSegments.addAll(knowledgeSegmentService.searchKnowledgeSegment(new AiKnowledgeSegmentSearchReqBO() @@ -222,6 +318,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { private Prompt buildPrompt(AiChatConversationDO conversation, List messages, List knowledgeSegments, + AiWebSearchResponse webSearchResponse, AiModelDO model, AiChatMessageSendReqVO sendReqVO) { List chatMessages = new ArrayList<>(); // 1.1 System Context 角色设定 @@ -231,8 +328,14 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { // 1.2 历史 history message 历史消息 List contextMessages = filterContextMessages(messages, conversation, sendReqVO); - contextMessages - .forEach(message -> chatMessages.add(AiUtils.buildMessage(message.getType(), message.getContent()))); + contextMessages.forEach(message -> { + chatMessages.add(AiUtils.buildMessage(message.getType(), message.getContent())); + UserMessage attachmentUserMessage = buildAttachmentUserMessage(message.getAttachmentUrls()); + if (attachmentUserMessage != null) { + chatMessages.add(attachmentUserMessage); + } + // TODO @芋艿:历史的知识库;历史的搜索,要不要拼接? + }); // 1.3 当前 user message 新发送消息 chatMessages.add(new UserMessage(sendReqVO.getContent())); @@ -245,23 +348,76 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { chatMessages.add(new UserMessage(String.format(KNOWLEDGE_USER_MESSAGE_TEMPLATE, reference))); } - // 2.1 查询 tool 工具 - Set toolNames = null; - Map toolContext = Map.of(); - if (conversation.getRoleId() != null) { - AiChatRoleDO chatRole = chatRoleService.getChatRole(conversation.getRoleId()); - if (chatRole != null && CollUtil.isNotEmpty(chatRole.getToolIds())) { - toolNames = convertSet(toolService.getToolList(chatRole.getToolIds()), AiToolDO::getName); - toolContext = AiUtils.buildCommonToolContext(); + // 1.5 联网搜索,通过 UserMessage 实现 + if (webSearchResponse != null && CollUtil.isNotEmpty(webSearchResponse.getLists())) { + String webSearch = webSearchResponse.getLists().stream() + .map(page -> { + String summary = StrUtil.isNotEmpty(page.getSummary()) ? + "\nSummary: " + page.getSummary() : ""; + return "" + + StrUtil.blankToDefault(page.getSummary(), page.getSnippet()) + ""; + }) + .collect(Collectors.joining("\n\n")); + chatMessages.add(new UserMessage(String.format(WEB_SEARCH_USER_MESSAGE_TEMPLATE, webSearch))); + } + + // 1.6 附件,通过 UserMessage 实现 + if (CollUtil.isNotEmpty(sendReqVO.getAttachmentUrls())) { + UserMessage attachmentUserMessage = buildAttachmentUserMessage(sendReqVO.getAttachmentUrls()); + if (attachmentUserMessage != null) { + chatMessages.add(attachmentUserMessage); } } + + // 2.1 查询 tool 工具 + List toolCallbacks = getToolCallbackListByRoleId(conversation.getRoleId()); + Map toolContext = CollUtil.isNotEmpty(toolCallbacks) ? AiUtils.buildCommonToolContext() + : Map.of(); // 2.2 构建 ChatOptions 对象 AiPlatformEnum platform = AiPlatformEnum.validatePlatform(model.getPlatform()); ChatOptions chatOptions = AiUtils.buildChatOptions(platform, model.getModel(), - conversation.getTemperature(), conversation.getMaxTokens(), toolNames, toolContext); + conversation.getTemperature(), conversation.getMaxTokens(), + toolCallbacks, toolContext); return new Prompt(chatMessages, chatOptions); } + private List getToolCallbackListByRoleId(Long roleId) { + if (roleId == null) { + return null; + } + AiChatRoleDO chatRole = chatRoleService.getChatRole(roleId); + if (chatRole == null) { + return null; + } + List toolCallbacks = new ArrayList<>(); + // 1. 通过 toolIds + if (CollUtil.isNotEmpty(chatRole.getToolIds())) { + Set toolNames = convertSet(toolService.getToolList(chatRole.getToolIds()), AiToolDO::getName); + toolNames.forEach(toolName -> { + ToolCallback toolCallback = toolCallbackResolver.resolve(toolName); + if (toolCallback != null) { + toolCallbacks.add(toolCallback); + } + }); + } + // 2. 通过 mcpClients + if (CollUtil.isNotEmpty(mcpClients) && CollUtil.isNotEmpty(chatRole.getMcpClientNames())) { + chatRole.getMcpClientNames().forEach(mcpClientName -> { + // 2.1 标准化名字,参考 McpClientAutoConfiguration 的 connectedClientName 方法 + String finalMcpClientName = mcpClientCommonProperties.getName() + " - " + mcpClientName; + // 2.2 匹配对应的 McpSyncClient + mcpClients.forEach(mcpClient -> { + if (ObjUtil.notEqual(mcpClient.getClientInfo().name(), finalMcpClientName)) { + return; + } + ToolCallback[] mcpToolCallBacks = new SyncMcpToolCallbackProvider(mcpClient).getToolCallbacks(); + CollUtil.addAll(toolCallbacks, mcpToolCallBacks); + }); + }); + } + return toolCallbacks; + } + /** * 从历史消息中,获得倒序的 n 组消息作为消息上下文 *

@@ -302,14 +458,56 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { return contextMessages; } + private UserMessage buildAttachmentUserMessage(List attachmentUrls) { + if (CollUtil.isEmpty(attachmentUrls)) { + return null; + } + // 读取文件内容 + Map attachmentContents = Maps.newLinkedHashMapWithExpectedSize(attachmentUrls.size()); + for (String attachmentUrl : attachmentUrls) { + try { + String name = FileNameUtil.getName(attachmentUrl); + String mineType = FileTypeUtils.getMineType(name); + String content; + if (FileTypeUtils.isImage(mineType)) { + // 特殊:图片则转为 Base64 + byte[] bytes = HttpUtil.downloadBytes(attachmentUrl); + content = Base64.encode(bytes); + } else { + content = knowledgeDocumentService.readUrl(attachmentUrl); + } + if (StrUtil.isNotEmpty(content)) { + attachmentContents.put(name, content); + } + } catch (Exception e) { + log.error("[buildAttachmentUserMessage][读取附件({}) 发生异常]", attachmentUrl, e); + } + } + if (CollUtil.isEmpty(attachmentContents)) { + return null; + } + + // 拼接 UserMessage 消息 + String attachment = attachmentContents.entrySet().stream() + .map(entry -> "" + entry.getValue() + "") + .collect(Collectors.joining("\n\n")); + return new UserMessage(String.format(Attachment_USER_MESSAGE_TEMPLATE, attachment)); + } + private AiChatMessageDO createChatMessage(Long conversationId, Long replyId, - AiModelDO model, Long userId, Long roleId, - MessageType messageType, String content, Boolean useContext, - List knowledgeSegments) { + AiModelDO model, Long userId, Long roleId, + MessageType messageType, String content, Boolean useContext, + List knowledgeSegments, + List attachmentUrls, + AiWebSearchResponse webSearchResponse) { AiChatMessageDO message = new AiChatMessageDO().setConversationId(conversationId).setReplyId(replyId) .setModel(model.getModel()).setModelId(model.getId()).setUserId(userId).setRoleId(roleId) .setType(messageType.getValue()).setContent(content).setUseContext(useContext) - .setSegmentIds(convertList(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getId)); + .setSegmentIds(convertList(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getId)) + .setAttachmentUrls(attachmentUrls); + if (webSearchResponse != null) { + message.setWebSearchPages(webSearchResponse.getLists()); + } message.setCreateTime(LocalDateTime.now()); chatMessageMapper.insert(message); return message; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java index e3a6f08a1..dd0f91315 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/knowledge/AiKnowledgeSegmentServiceImpl.java @@ -18,6 +18,10 @@ import cn.iocoder.yudao.module.ai.dal.mysql.knowledge.AiKnowledgeSegmentMapper; import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO; import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchRespBO; import cn.iocoder.yudao.module.ai.service.model.AiModelService; +import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankOptions; +import com.alibaba.cloud.ai.model.RerankModel; +import com.alibaba.cloud.ai.model.RerankRequest; +import com.alibaba.cloud.ai.model.RerankResponse; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; @@ -27,6 +31,7 @@ import org.springframework.ai.transformer.splitter.TokenTextSplitter; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -36,6 +41,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.KNOWLEDGE_SEGMENT_CONTENT_TOO_LONG; import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.KNOWLEDGE_SEGMENT_NOT_EXISTS; +import static org.springframework.ai.vectorstore.SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL; /** * AI 知识库分片 Service 实现类 @@ -55,6 +61,11 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService VECTOR_STORE_METADATA_DOCUMENT_ID, String.class, VECTOR_STORE_METADATA_SEGMENT_ID, String.class); + /** + * Rerank 在向量检索时,检索数量 * 该系数,目的是为了提升 Rerank 的效果 + */ + private static final Integer RERANK_RETRIEVAL_FACTOR = 4; + @Resource private AiKnowledgeSegmentMapper segmentMapper; @@ -69,6 +80,9 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService @Resource private TokenCountEstimator tokenCountEstimator; + @Autowired(required = false) // 由于 spring.ai.model.rerank 配置项,可以关闭 RerankModel 的功能,所以这里只能不强制注入 + private RerankModel rerankModel; + @Override public PageResult getKnowledgeSegmentPage(AiKnowledgeSegmentPageReqVO pageReqVO) { return segmentMapper.selectPage(pageReqVO); @@ -211,28 +225,16 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService // 1. 校验 AiKnowledgeDO knowledge = knowledgeService.validateKnowledgeExists(reqBO.getKnowledgeId()); - // 2.1 向量检索 - VectorStore vectorStore = getVectorStoreById(knowledge); - List documents = vectorStore.similaritySearch(SearchRequest.builder() - .query(reqBO.getContent()) - .topK(ObjUtil.defaultIfNull(reqBO.getTopK(), knowledge.getTopK())) - .similarityThreshold( - ObjUtil.defaultIfNull(reqBO.getSimilarityThreshold(), knowledge.getSimilarityThreshold())) - .filterExpression(new FilterExpressionBuilder() - .eq(VECTOR_STORE_METADATA_KNOWLEDGE_ID, reqBO.getKnowledgeId().toString()) - .build()) - .build()); - if (CollUtil.isEmpty(documents)) { - return ListUtil.empty(); - } - // 2.2 段落召回 + // 2. 检索 + List documents = searchDocument(knowledge, reqBO); + + // 3.1 段落召回 List segments = segmentMapper .selectListByVectorIds(convertList(documents, Document::getId)); if (CollUtil.isEmpty(segments)) { return ListUtil.empty(); } - - // 3. 增加召回次数 + // 3.2 增加召回次数 segmentMapper.updateRetrievalCountIncrByIds(convertList(segments, AiKnowledgeSegmentDO::getId)); // 4. 构建结果 @@ -249,6 +251,42 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService return result; } + /** + * 基于 Embedding + Rerank Model,检索知识库中的文档 + * + * @param knowledge 知识库 + * @param reqBO 检索请求 + * @return 文档列表 + */ + private List searchDocument(AiKnowledgeDO knowledge, AiKnowledgeSegmentSearchReqBO reqBO) { + VectorStore vectorStore = getVectorStoreById(knowledge); + Integer topK = ObjUtil.defaultIfNull(reqBO.getTopK(), knowledge.getTopK()); + Double similarityThreshold = ObjUtil.defaultIfNull(reqBO.getSimilarityThreshold(), knowledge.getSimilarityThreshold()); + + // 1. 向量检索 + int searchTopK = rerankModel != null ? topK * RERANK_RETRIEVAL_FACTOR : topK; + double searchSimilarityThreshold = rerankModel != null ? SIMILARITY_THRESHOLD_ACCEPT_ALL : similarityThreshold; + SearchRequest.Builder searchRequestBuilder = SearchRequest.builder() + .query(reqBO.getContent()) + .topK(searchTopK).similarityThreshold(searchSimilarityThreshold) + .filterExpression(new FilterExpressionBuilder() + .eq(VECTOR_STORE_METADATA_KNOWLEDGE_ID, reqBO.getKnowledgeId().toString()).build()); + List documents = vectorStore.similaritySearch(searchRequestBuilder.build()); + if (CollUtil.isEmpty(documents)) { + return documents; + } + + // 2. Rerank 重排序 + if (rerankModel != null) { + RerankResponse rerankResponse = rerankModel.call(new RerankRequest(reqBO.getContent(), documents, + DashScopeRerankOptions.builder().withTopN(topK).build())); + documents = convertList(rerankResponse.getResults(), + documentWithScore -> documentWithScore.getScore() >= similarityThreshold + ? documentWithScore.getOutput() : null); + } + return documents; + } + @Override public List splitContent(String url, Integer segmentMaxTokens) { // 1. 读取 URL 内容 diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImpl.java index ec807cf40..235e54a7f 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImpl.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImpl.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.module.ai.service.model; import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; -import cn.iocoder.yudao.module.ai.framework.ai.core.AiModelFactory; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.AiModelFactory; import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; import cn.iocoder.yudao.module.ai.framework.ai.core.model.suno.api.SunoApi; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiToolServiceImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiToolServiceImpl.java index cbf14ca36..cb25a3198 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiToolServiceImpl.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/AiToolServiceImpl.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.ai.service.model; -import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.ai.controller.admin.model.vo.tool.AiToolPageReqVO; @@ -8,7 +7,8 @@ import cn.iocoder.yudao.module.ai.controller.admin.model.vo.tool.AiToolSaveReqVO import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiToolDO; import cn.iocoder.yudao.module.ai.dal.mysql.model.AiToolMapper; import jakarta.annotation.Resource; -import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -31,6 +31,9 @@ public class AiToolServiceImpl implements AiToolService { @Resource private AiToolMapper toolMapper; + @Resource + private ToolCallbackResolver toolCallbackResolver; + @Override public Long createTool(AiToolSaveReqVO createReqVO) { // 校验名称是否存在 @@ -70,9 +73,8 @@ public class AiToolServiceImpl implements AiToolService { } private void validateToolNameExists(String name) { - try { - SpringUtil.getBean(name); - } catch (NoSuchBeanDefinitionException e) { + ToolCallback toolCallback = toolCallbackResolver.resolve(name); + if (toolCallback == null) { throw exception(TOOL_NAME_NOT_EXISTS, name); } } diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/DirectoryListToolFunction.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/DirectoryListToolFunction.java similarity index 98% rename from yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/DirectoryListToolFunction.java rename to yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/DirectoryListToolFunction.java index 787b2e772..8e75d5d9e 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/DirectoryListToolFunction.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/DirectoryListToolFunction.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.ai.service.model.tool; +package cn.iocoder.yudao.module.ai.tool.function; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.io.FileUtil; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/UserProfileQueryToolFunction.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/UserProfileQueryToolFunction.java similarity index 97% rename from yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/UserProfileQueryToolFunction.java rename to yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/UserProfileQueryToolFunction.java index 079f3a42f..06a2641da 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/UserProfileQueryToolFunction.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/UserProfileQueryToolFunction.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.ai.service.model.tool; +package cn.iocoder.yudao.module.ai.tool.function; import cn.iocoder.yudao.module.ai.util.AiUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/WeatherQueryToolFunction.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/WeatherQueryToolFunction.java similarity index 98% rename from yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/WeatherQueryToolFunction.java rename to yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/WeatherQueryToolFunction.java index 99262fafa..689ea0046 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/model/tool/WeatherQueryToolFunction.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/WeatherQueryToolFunction.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.ai.service.model.tool; +package cn.iocoder.yudao.module.ai.tool.function; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.RandomUtil; diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java new file mode 100644 index 000000000..0b5965635 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/function/package-info.java @@ -0,0 +1,4 @@ +/** + * 参考 Tool Calling —— Methods as Tools + */ +package cn.iocoder.yudao.module.ai.tool.function; \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java new file mode 100644 index 000000000..66bab5a7f --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/Person.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.ai.tool.method; + +/** + * 来自 Spring AI 官方文档 + * + * Represents a person with basic information. + * This is an immutable record. + */ +public record Person( + int id, + String firstName, + String lastName, + String email, + String sex, + String ipAddress, + String jobTitle, + int age +) { +} \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java new file mode 100644 index 000000000..52c895494 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonService.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.ai.tool.method; + +import java.util.List; +import java.util.Optional; + +/** + * 来自 Spring AI 官方文档 + * + * Service interface for managing Person data. + * Defines the contract for CRUD operations and search/filter functionalities. + */ +public interface PersonService { + + /** + * Creates a new Person record. + * Assigns a unique ID to the person and stores it. + * + * @param personData The data for the new person (ID field is ignored). Must not be null. + * @return The created Person record, including the generated ID. + */ + Person createPerson(Person personData); + + /** + * Retrieves a Person by their unique ID. + * + * @param id The ID of the person to retrieve. + * @return An Optional containing the found Person, or an empty Optional if not found. + */ + Optional getPersonById(int id); + + /** + * Retrieves all Person records currently stored. + * + * @return An unmodifiable List containing all Persons. Returns an empty list if none exist. + */ + List getAllPersons(); + + /** + * Updates an existing Person record identified by ID. + * Replaces the existing data with the provided data, keeping the original ID. + * + * @param id The ID of the person to update. + * @param updatedPersonData The new data for the person (ID field is ignored). Must not be null. + * @return true if the person was found and updated, false otherwise. + */ + boolean updatePerson(int id, Person updatedPersonData); + + /** + * Deletes a Person record identified by ID. + * + * @param id The ID of the person to delete. + * @return true if the person was found and deleted, false otherwise. + */ + boolean deletePerson(int id); + + /** + * Searches for Persons whose job title contains the given query string (case-insensitive). + * + * @param jobTitleQuery The string to search for within job titles. Can be null or blank. + * @return An unmodifiable List of matching Persons. Returns an empty list if no matches or query is invalid. + */ + List searchByJobTitle(String jobTitleQuery); + + /** + * Filters Persons by their exact sex (case-insensitive). + * + * @param sex The sex to filter by (e.g., "Male", "Female"). Can be null or blank. + * @return An unmodifiable List of matching Persons. Returns an empty list if no matches or filter is invalid. + */ + List filterBySex(String sex); + + /** + * Filters Persons by their exact age. + * + * @param age The age to filter by. + * @return An unmodifiable List of matching Persons. Returns an empty list if no matches. + */ + List filterByAge(int age); + +} \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java new file mode 100644 index 000000000..3b8c31b42 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/PersonServiceImpl.java @@ -0,0 +1,336 @@ +package cn.iocoder.yudao.module.ai.tool.method; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 来自 Spring AI 官方文档 + * + * Implementation of the PersonService interface using an in-memory data store. + * Manages a collection of Person objects loaded from embedded CSV data. + * This class is thread-safe due to the use of ConcurrentHashMap and AtomicInteger. + */ +@Service +@Slf4j +public class PersonServiceImpl implements PersonService { + + private final Map personStore = new ConcurrentHashMap<>(); + + private AtomicInteger idGenerator; + + /** + * Embedded CSV data for initial population + */ + private static final String CSV_DATA = """ + Id,FirstName,LastName,Email,Sex,IpAddress,JobTitle,Age + 1,Fons,Tollfree,ftollfree0@senate.gov,Male,55.1 Tollfree Lane,Research Associate,31 + 2,Emlynne,Tabourier,etabourier1@networksolutions.com,Female,18 Tabourier Way,Associate Professor,38 + 3,Shae,Johncey,sjohncey2@yellowpages.com,Male,1 Johncey Circle,Structural Analysis Engineer,30 + 4,Sebastien,Bradly,sbradly3@mapquest.com,Male,2 Bradly Hill,Chief Executive Officer,40 + 5,Harriott,Kitteringham,hkitteringham4@typepad.com,Female,3 Kitteringham Drive,VP Sales,47 + 6,Anallise,Parradine,aparradine5@miibeian.gov.cn,Female,4 Parradine Street,Analog Circuit Design manager,44 + 7,Gorden,Kirkbright,gkirkbright6@reuters.com,Male,5 Kirkbright Plaza,Senior Editor,40 + 8,Veradis,Ledwitch,vledwitch7@google.com.au,Female,6 Ledwitch Avenue,Computer Systems Analyst IV,44 + 9,Agnesse,Penhalurick,apenhalurick8@google.it,Female,7 Penhalurick Terrace,Automation Specialist IV,41 + 10,Bibby,Hutable,bhutable9@craigslist.org,Female,8 Hutable Place,Account Representative I,43 + 11,Karoly,Lightoller,klightollera@rakuten.co.jp,Female,9 Lightoller Parkway,Senior Developer,46 + 12,Cristine,Durrad,cdurradb@aol.com,Female,10 Durrad Center,Senior Developer,48 + 13,Aggy,Napier,anapierc@hostgator.com,Female,11 Napier Court,VP Product Management,44 + 14,Prisca,Caddens,pcaddensd@vinaora.com,Female,12 Caddens Alley,Business Systems Development Analyst,41 + 15,Khalil,McKernan,kmckernane@google.fr,Male,13 McKernan Pass,Engineer IV,44 + 16,Lorry,MacTrusty,lmactrustyf@eventbrite.com,Male,14 MacTrusty Junction,Design Engineer,42 + 17,Casandra,Worsell,cworsellg@goo.gl,Female,15 Worsell Point,Systems Administrator IV,45 + 18,Ulrikaumeko,Haveline,uhavelineh@usgs.gov,Female,16 Haveline Trail,Financial Advisor,42 + 19,Shurlocke,Albany,salbanyi@artisteer.com,Male,17 Albany Plaza,Software Test Engineer III,46 + 20,Myrilla,Brimilcombe,mbrimilcombej@accuweather.com,Female,18 Brimilcombe Road,Programmer Analyst I,48 + 21,Carlina,Scimonelli,cscimonellik@va.gov,Female,19 Scimonelli Pass,Help Desk Technician,45 + 22,Tina,Goullee,tgoulleel@miibeian.gov.cn,Female,20 Goullee Crossing,Accountant IV,43 + 23,Adriaens,Storek,astorekm@devhub.com,Female,21 Storek Avenue,Recruiting Manager,40 + 24,Tedra,Giraudot,tgiraudotn@wiley.com,Female,22 Giraudot Terrace,Speech Pathologist,47 + 25,Josiah,Soares,jsoareso@google.nl,Male,23 Soares Street,Tax Accountant,45 + 26,Kayle,Gaukrodge,kgaukrodgep@wikispaces.com,Female,24 Gaukrodge Parkway,Accountant II,43 + 27,Ardys,Chuter,achuterq@ustream.tv,Female,25 Chuter Drive,Engineer IV,41 + 28,Francyne,Baudinet,fbaudinetr@newyorker.com,Female,26 Baudinet Center,VP Accounting,48 + 29,Gerick,Bullan,gbullans@seesaa.net,Male,27 Bullan Way,Senior Financial Analyst,43 + 30,Northrup,Grivori,ngrivorit@unc.edu,Male,28 Grivori Plaza,Systems Administrator I,45 + 31,Town,Duguid,tduguidu@squarespace.com,Male,29 Duguid Pass,Safety Technician IV,46 + 32,Pierette,Kopisch,pkopischv@google.com.br,Female,30 Kopisch Lane,Director of Sales,41 + 33,Jacquenetta,Le Prevost,jleprevostw@netlog.com,Female,31 Le Prevost Trail,Senior Developer,47 + 34,Garvy,Rusted,grustedx@aboutads.info,Male,32 Rusted Junction,Senior Developer,42 + 35,Clarice,Aysh,cayshy@merriam-webster.com,Female,33 Aysh Avenue,VP Quality Control,40 + 36,Tracie,Fedorski,tfedorskiz@bloglines.com,Male,34 Fedorski Terrace,Design Engineer,44 + 37,Noelyn,Matushenko,nmatushenko10@globo.com,Female,35 Matushenko Place,VP Sales,48 + 38,Rudiger,Klaesson,rklaesson11@usnews.com,Male,36 Klaesson Road,Database Administrator IV,43 + 39,Mirella,Syddie,msyddie12@geocities.jp,Female,37 Syddie Circle,Geological Engineer,46 + 40,Donalt,O'Lunny,dolunny13@elpais.com,Male,38 O'Lunny Center,Analog Circuit Design manager,41 + 41,Guntar,Deniskevich,gdeniskevich14@google.com.hk,Male,39 Deniskevich Way,Structural Engineer,47 + 42,Hort,Shufflebotham,hshufflebotham15@about.me,Male,40 Shufflebotham Court,Structural Analysis Engineer,45 + 43,Dominique,Thickett,dthickett16@slashdot.org,Male,41 Thickett Crossing,Safety Technician I,42 + 44,Zebulen,Piscopello,zpiscopello17@umich.edu,Male,42 Piscopello Parkway,Web Developer II,40 + 45,Mellicent,Mac Giany,mmacgiany18@state.tx.us,Female,43 Mac Giany Pass,Assistant Manager,44 + 46,Merle,Bounds,mbounds19@amazon.co.jp,Female,44 Bounds Alley,Systems Administrator III,41 + 47,Madelle,Farbrace,mfarbrace1a@xinhuanet.com,Female,45 Farbrace Terrace,Quality Engineer,48 + 48,Galvin,O'Sheeryne,gosheeryne1b@addtoany.com,Male,46 O'Sheeryne Way,Environmental Specialist,43 + 49,Guillemette,Bootherstone,gbootherstone1c@nationalgeographic.com,Female,47 Bootherstone Plaza,Professor,46 + 50,Letti,Aylmore,laylmore1d@vinaora.com,Female,48 Aylmore Circle,Automation Specialist I,40 + 51,Nonie,Rivalland,nrivalland1e@weather.com,Female,49 Rivalland Avenue,Software Test Engineer IV,45 + 52,Jacquelynn,Halfacre,jhalfacre1f@surveymonkey.com,Female,50 Halfacre Pass,Geologist II,42 + 53,Anderea,MacKibbon,amackibbon1g@weibo.com,Female,51 MacKibbon Parkway,Automation Specialist II,47 + 54,Wash,Klimko,wklimko1h@slashdot.org,Male,52 Klimko Alley,Database Administrator I,40 + 55,Flori,Kynett,fkynett1i@auda.org.au,Female,53 Kynett Trail,Quality Control Specialist,46 + 56,Libbey,Penswick,lpenswick1j@google.co.uk,Female,54 Penswick Point,VP Accounting,43 + 57,Silvanus,Skellorne,sskellorne1k@booking.com,Male,55 Skellorne Drive,Account Executive,48 + 58,Carmine,Mateos,cmateos1l@plala.or.jp,Male,56 Mateos Terrace,Systems Administrator I,41 + 59,Sheffie,Blazewicz,sblazewicz1m@google.com.au,Male,57 Blazewicz Center,VP Sales,44 + 60,Leanor,Worsnop,lworsnop1n@uol.com.br,Female,58 Worsnop Plaza,Systems Administrator III,45 + 61,Caspar,Pamment,cpamment1o@google.co.jp,Male,59 Pamment Court,Senior Financial Analyst,42 + 62,Justinian,Pentycost,jpentycost1p@sciencedaily.com,Male,60 Pentycost Way,Senior Quality Engineer,47 + 63,Gerianne,Jarnell,gjarnell1q@bing.com,Female,61 Jarnell Avenue,Help Desk Operator,40 + 64,Boycie,Zanetto,bzanetto1r@about.com,Male,62 Zanetto Place,Quality Engineer,46 + 65,Camilla,Mac Giany,cmacgiany1s@state.gov,Female,63 Mac Giany Parkway,Senior Cost Accountant,43 + 66,Hadlee,Piscopiello,hpiscopiello1t@artisteer.com,Male,64 Piscopiello Street,Account Representative III,48 + 67,Bobbie,Penvarden,bpenvarden1u@google.cn,Male,65 Penvarden Lane,Help Desk Operator,41 + 68,Ali,Gowlett,agowlett1v@parallels.com,Male,66 Gowlett Pass,VP Marketing,44 + 69,Olivette,Acome,oacome1w@qq.com,Female,67 Acome Hill,VP Product Management,45 + 70,Jehanna,Brotherheed,jbrotherheed1x@google.nl,Female,68 Brotherheed Junction,Database Administrator III,42 + 71,Morgan,Berthomieu,mberthomieu1y@artisteer.com,Male,69 Berthomieu Alley,Systems Administrator II,47 + 72,Linzy,Shilladay,lshilladay1z@icq.com,Female,70 Shilladay Trail,Research Assistant IV,40 + 73,Faydra,Brimner,fbrimner20@mozilla.org,Female,71 Brimner Road,Senior Editor,46 + 74,Gwenore,Oxlee,goxlee21@devhub.com,Female,72 Oxlee Terrace,Systems Administrator II,43 + 75,Evangelin,Beinke,ebeinke22@mozilla.com,Female,73 Beinke Circle,Accountant I,48 + 76,Missy,Cockling,mcockling23@si.edu,Female,74 Cockling Way,Software Engineer I,41 + 77,Suzanne,Klimschak,sklimschak24@etsy.com,Female,75 Klimschak Plaza,Tax Accountant,44 + 78,Candide,Goricke,cgoricke25@weebly.com,Female,76 Goricke Pass,Sales Associate,45 + 79,Gerome,Pinsent,gpinsent26@google.com.au,Male,77 Pinsent Junction,Software Consultant,42 + 80,Lezley,Mac Giany,lmacgiany27@scribd.com,Male,78 Mac Giany Alley,Operator,47 + 81,Tobiah,Durn,tdurn28@state.tx.us,Male,79 Durn Court,VP Sales,40 + 82,Sherlocke,Cockshoot,scockshoot29@yelp.com,Male,80 Cockshoot Street,Senior Financial Analyst,46 + 83,Myrle,Speenden,mspeenden2a@utexas.edu,Female,81 Speenden Center,Senior Developer,43 + 84,Isidore,Gorries,igorries2b@flavors.me,Male,82 Gorries Parkway,Sales Representative,48 + 85,Isac,Kitchingman,ikitchingman2c@businessinsider.com,Male,83 Kitchingman Drive,VP Accounting,41 + 86,Benedetta,Purrier,bpurrier2d@admin.ch,Female,84 Purrier Trail,VP Accounting,44 + 87,Tera,Fitchell,tfitchell2e@fotki.com,Female,85 Fitchell Place,Software Engineer IV,45 + 88,Abbe,Pamment,apamment2f@about.com,Male,86 Pamment Avenue,VP Sales,42 + 89,Jandy,Gommowe,jgommowe2g@angelfire.com,Female,87 Gommowe Road,Financial Analyst,47 + 90,Karena,Fussey,kfussey2h@google.com.au,Female,88 Fussey Point,Assistant Professor,40 + 91,Gaspar,Pammenter,gpammenter2i@google.com.br,Male,89 Pammenter Hill,Help Desk Operator,46 + 92,Stanwood,Mac Giany,smacgiany2j@prlog.org,Male,90 Mac Giany Terrace,Research Associate,43 + 93,Byrom,Beedell,bbeedell2k@google.co.jp,Male,91 Beedell Way,VP Sales,48 + 94,Annabella,Rowbottom,arowbottom2l@google.com.au,Female,92 Rowbottom Plaza,Help Desk Operator,41 + 95,Rodolphe,Debell,rdebell2m@imageshack.us,Male,93 Debell Pass,Design Engineer,44 + 96,Tyne,Gommey,tgommey2n@joomla.org,Female,94 Gommey Junction,VP Marketing,45 + 97,Christoper,Pincked,cpincked2o@icq.com,Male,95 Pincked Alley,Human Resources Manager,42 + 98,Kore,Le Prevost,kleprevost2p@tripadvisor.com,Female,96 Le Prevost Street,VP Quality Control,47 + 99,Ceciley,Petrolli,cpetrolli2q@oaic.gov.au,Female,97 Petrolli Court,Senior Developer,40 + 100,Elspeth,Mac Giany,emacgiany2r@icio.us,Female,98 Mac Giany Parkway,Internal Auditor,46 + """; + + /** + * Initializes the service after dependency injection by loading data from the CSV string. + * Uses @PostConstruct to ensure this runs after the bean is created. + */ + @PostConstruct + private void initializeData() { + log.info("Initializing PersonService data store..."); + int maxId = loadDataFromCsv(); + idGenerator = new AtomicInteger(maxId); + log.info("PersonService initialized with {} records. Next ID: {}", personStore.size(), idGenerator.get() + 1); + } + + /** + * Parses the embedded CSV data and populates the in-memory store. + * Calculates the maximum ID found in the data to initialize the ID generator. + * + * @return The maximum ID found in the loaded CSV data. + */ + private int loadDataFromCsv() { + final AtomicInteger currentMaxId = new AtomicInteger(0); + // Clear existing data before loading (important for tests or re-initialization scenarios) + personStore.clear(); + try (Stream lines = CSV_DATA.lines().skip(1)) { // Skip header row + lines.forEach(line -> { + try { + // Split carefully, handling potential commas within quoted fields if necessary (simple split here) + String[] fields = line.split(",", 8); // Limit split to handle potential commas in job title + if (fields.length == 8) { + int id = Integer.parseInt(fields[0].trim()); + String firstName = fields[1].trim(); + String lastName = fields[2].trim(); + String email = fields[3].trim(); + String sex = fields[4].trim(); + String ipAddress = fields[5].trim(); + String jobTitle = fields[6].trim(); + int age = Integer.parseInt(fields[7].trim()); + + Person person = new Person(id, firstName, lastName, email, sex, ipAddress, jobTitle, age); + personStore.put(id, person); + currentMaxId.updateAndGet(max -> Math.max(max, id)); + } else { + log.warn("Skipping malformed CSV line (expected 8 fields, found {}): {}", fields.length, line); + } + } catch (NumberFormatException e) { + log.warn("Skipping line due to parsing error (ID or Age): {} - Error: {}", line, e.getMessage()); + } catch (Exception e) { + log.error("Skipping line due to unexpected error: {} - Error: {}", line, e.getMessage(), e); + } + }); + } catch (Exception e) { + log.error("Fatal error reading embedded CSV data: {}", e.getMessage(), e); + // In a real application, might throw a specific initialization exception + } + return currentMaxId.get(); + } + + @Override + @Tool( + name = "ps_create_person", + description = "Create a new person record in the in-memory store." + ) + public Person createPerson(Person personData) { + if (personData == null) { + throw new IllegalArgumentException("Person data cannot be null"); + } + int newId = idGenerator.incrementAndGet(); + // Create a new Person record using data from the input, but with the generated ID + Person newPerson = new Person( + newId, + personData.firstName(), + personData.lastName(), + personData.email(), + personData.sex(), + personData.ipAddress(), + personData.jobTitle(), + personData.age() + ); + personStore.put(newId, newPerson); + log.debug("Created person: {}", newPerson); + return newPerson; + } + + @Override + @Tool( + name = "ps_get_person_by_id", + description = "Retrieve a person record by ID from the in-memory store." + ) + public Optional getPersonById(int id) { + Person person = personStore.get(id); + log.debug("Retrieved person by ID {}: {}", id, person); + return Optional.ofNullable(person); + } + + @Override + @Tool( + name = "ps_get_all_persons", + description = "Retrieve all person records from the in-memory store." + ) + public List getAllPersons() { + // Return an unmodifiable view of the values + List allPersons = personStore.values().stream().toList(); + log.debug("Retrieved all persons (count: {})", allPersons.size()); + return allPersons; + } + + @Override + @Tool( + name = "ps_update_person", + description = "Update an existing person record by ID in the in-memory store." + ) + public boolean updatePerson(int id, Person updatedPersonData) { + if (updatedPersonData == null) { + throw new IllegalArgumentException("Updated person data cannot be null"); + } + // Use computeIfPresent for atomic update if the key exists + Person result = personStore.computeIfPresent(id, (key, existingPerson) -> + // Create a new Person record with the original ID but updated data + new Person( + id, // Keep original ID + updatedPersonData.firstName(), + updatedPersonData.lastName(), + updatedPersonData.email(), + updatedPersonData.sex(), + updatedPersonData.ipAddress(), + updatedPersonData.jobTitle(), + updatedPersonData.age() + ) + ); + boolean updated = result != null; + log.debug("Update attempt for ID {}: {}", id, updated ? "Successful" : "Failed (Not Found)"); + if(updated) log.trace("Updated person data for ID {}: {}", id, result); + return updated; + } + + @Override + @Tool( + name = "ps_delete_person", + description = "Delete a person record by ID from the in-memory store." + ) + public boolean deletePerson(int id) { + boolean removed = personStore.remove(id) != null; + log.debug("Delete attempt for ID {}: {}", id, removed ? "Successful" : "Failed (Not Found)"); + return removed; + } + + @Override + @Tool( + name = "ps_search_by_job_title", + description = "Search for persons by job title in the in-memory store." + ) + public List searchByJobTitle(String jobTitleQuery) { + if (jobTitleQuery == null || jobTitleQuery.isBlank()) { + log.debug("Search by job title skipped due to blank query."); + return Collections.emptyList(); + } + String lowerCaseQuery = jobTitleQuery.toLowerCase(); + List results = personStore.values().stream() + .filter(person -> person.jobTitle() != null && person.jobTitle().toLowerCase().contains(lowerCaseQuery)) + .collect(Collectors.toList()); + log.debug("Search by job title '{}' found {} results.", jobTitleQuery, results.size()); + return Collections.unmodifiableList(results); + } + + @Override + @Tool( + name = "ps_filter_by_sex", + description = "Filters Persons by sex (case-insensitive)." + ) + public List filterBySex(String sex) { + if (sex == null || sex.isBlank()) { + log.debug("Filter by sex skipped due to blank filter."); + return Collections.emptyList(); + } + List results = personStore.values().stream() + .filter(person -> person.sex() != null && person.sex().equalsIgnoreCase(sex)) + .collect(Collectors.toList()); + log.debug("Filter by sex '{}' found {} results.", sex, results.size()); + return Collections.unmodifiableList(results); + } + + @Override + @Tool( + name = "ps_filter_by_age", + description = "Filters Persons by age." + ) + public List filterByAge(int age) { + if (age < 0) { + log.debug("Filter by age skipped due to negative age: {}", age); + return Collections.emptyList(); // Or throw IllegalArgumentException based on requirements + } + List results = personStore.values().stream() + .filter(person -> person.age() == age) + .collect(Collectors.toList()); + log.debug("Filter by age {} found {} results.", age, results.size()); + return Collections.unmodifiableList(results); + } + +} \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java new file mode 100644 index 000000000..44b53e197 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/tool/method/package-info.java @@ -0,0 +1,4 @@ +/** + * 参考 Tool Calling —— Methods as Tools + */ +package cn.iocoder.yudao.module.ai.tool.method; \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/AiUtils.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/AiUtils.java index 0744ff630..d209c62d4 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/AiUtils.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/AiUtils.java @@ -8,18 +8,20 @@ import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions; import org.springaicommunity.moonshot.MoonshotChatOptions; import org.springaicommunity.qianfan.QianFanChatOptions; +import org.springframework.ai.anthropic.AnthropicChatOptions; import org.springframework.ai.azure.openai.AzureOpenAiChatOptions; import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.deepseek.DeepSeekAssistantMessage; +import org.springframework.ai.deepseek.DeepSeekChatOptions; import org.springframework.ai.minimax.MiniMaxChatOptions; import org.springframework.ai.ollama.api.OllamaOptions; import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.zhipuai.ZhiPuAiChatOptions; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * Spring AI 工具类 @@ -36,40 +38,47 @@ public class AiUtils { } public static ChatOptions buildChatOptions(AiPlatformEnum platform, String model, Double temperature, Integer maxTokens, - Set toolNames, Map toolContext) { - toolNames = ObjUtil.defaultIfNull(toolNames, Collections.emptySet()); + List toolCallbacks, Map toolContext) { + toolCallbacks = ObjUtil.defaultIfNull(toolCallbacks, Collections.emptyList()); toolContext = ObjUtil.defaultIfNull(toolContext, Collections.emptyMap()); // noinspection EnhancedSwitchMigration switch (platform) { case TONG_YI: return DashScopeChatOptions.builder().withModel(model).withTemperature(temperature).withMaxToken(maxTokens) - .withToolNames(toolNames).withToolContext(toolContext).build(); + .withEnableThinking(true) // TODO 芋艿:默认都开启 thinking 模式,后续可以让用户配置 + .withToolCallbacks(toolCallbacks).withToolContext(toolContext).build(); case YI_YAN: return QianFanChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens).build(); + case DEEP_SEEK: + case DOU_BAO: // 复用 DeepSeek 客户端 + case HUN_YUAN: // 复用 DeepSeek 客户端 + case SILICON_FLOW: // 复用 DeepSeek 客户端 + case XING_HUO: // 复用 DeepSeek 客户端 + return DeepSeekChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); case ZHI_PU: return ZhiPuAiChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) - .toolNames(toolNames).toolContext(toolContext).build(); + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); case MINI_MAX: return MiniMaxChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) - .toolNames(toolNames).toolContext(toolContext).build(); + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); case MOONSHOT: return MoonshotChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) - .toolNames(toolNames).toolContext(toolContext).build(); + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); case OPENAI: - case DEEP_SEEK: // 复用 OpenAI 客户端 - case DOU_BAO: // 复用 OpenAI 客户端 - case HUN_YUAN: // 复用 OpenAI 客户端 - case XING_HUO: // 复用 OpenAI 客户端 - case SILICON_FLOW: // 复用 OpenAI 客户端 + case GEMINI: // 复用 OpenAI 客户端 case BAI_CHUAN: // 复用 OpenAI 客户端 return OpenAiChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) - .toolNames(toolNames).toolContext(toolContext).build(); + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); case AZURE_OPENAI: return AzureOpenAiChatOptions.builder().deploymentName(model).temperature(temperature).maxTokens(maxTokens) - .toolNames(toolNames).toolContext(toolContext).build(); + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); + case ANTHROPIC: + return AnthropicChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens) + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); case OLLAMA: return OllamaOptions.builder().model(model).temperature(temperature).numPredict(maxTokens) - .toolNames(toolNames).toolContext(toolContext).build(); + .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); default: throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform)); } @@ -98,4 +107,27 @@ public class AiUtils { return context; } + @SuppressWarnings("ConstantValue") + public static String getChatResponseContent(ChatResponse response) { + if (response == null + || response.getResult() == null + || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } + + @SuppressWarnings("ConstantValue") + public static String getChatResponseReasoningContent(ChatResponse response) { + if (response == null + || response.getResult() == null + || response.getResult().getOutput() == null) { + return null; + } + if (response.getResult().getOutput() instanceof DeepSeekAssistantMessage) { + return ((DeepSeekAssistantMessage) (response.getResult().getOutput())).getReasoningContent(); + } + return null; + } + } \ No newline at end of file diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java new file mode 100644 index 000000000..9c3b202c4 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/util/FileTypeUtils.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.ai.util; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.tika.Tika; + +/** + * 文件类型 Utils + * + * @author 芋道源码 + */ +@Slf4j +public class FileTypeUtils { + + private static final Tika TIKA = new Tika(); + + /** + * 已知文件名,获取文件类型,在某些情况下比通过字节数组准确,例如使用 jar 文件时,通过名字更为准确 + * + * @param name 文件名 + * @return mineType 无法识别时会返回“application/octet-stream” + */ + public static String getMineType(String name) { + return TIKA.detect(name); + } + + /** + * 判断是否是图片 + * + * @param mineType 类型 + * @return 是否是图片 + */ + public static boolean isImage(String mineType) { + return StrUtil.startWith(mineType, "image/"); + } + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/resources/application.yaml b/yudao-module-ai/yudao-module-ai-server/src/main/resources/application.yaml index 157a6c047..121084036 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/resources/application.yaml +++ b/yudao-module-ai/yudao-module-ai-server/src/main/resources/application.yaml @@ -132,7 +132,8 @@ spring: azure: # OpenAI 微软 openai: endpoint: https://eastusprejade.openai.azure.com - api-key: xxx + anthropic: # Anthropic Claude + api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 ollama: base-url: http://127.0.0.1:11434 chat: @@ -140,7 +141,7 @@ spring: stabilityai: api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx dashscope: # 通义千问 - api-key: sk-71800982914041848008480000000000 + api-key: sk-47aa124781be4bfb95244cc62f6xxxx minimax: # Minimax:https://www.minimaxi.com/ api-key: xxxx moonshot: # 月之暗灭(KIMI) @@ -150,9 +151,30 @@ spring: chat: options: model: deepseek-chat + model: + rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 + mcp: + server: + enabled: true + name: yudao-mcp-server + version: 1.0.0 + instructions: 一个 MCP 示例服务 + sse-endpoint: /sse + client: + enabled: true + name: mcp + sse: + connections: + filesystem: + url: http://127.0.0.1:8089 + sse-endpoint: /sse yudao: ai: + gemini: # 谷歌 Gemini + enable: true + api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ + model: gemini-2.5-flash doubao: # 字节豆包 enable: true api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 @@ -169,7 +191,7 @@ yudao: enable: true appKey: 75b161ed2aef4719b275d6e7f2a4d4cd secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz - model: generalv3.5 + model: x1 baichuan: # 百川智能 enable: true api-key: sk-abc @@ -184,6 +206,9 @@ yudao: enable: true # base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app base-url: http://127.0.0.1:3001 + web-search: + enable: true + api-key: sk-40500e52840f4d24b956d0b1d80d9abe --- #################### 芋道相关配置 #################### diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java new file mode 100644 index 000000000..454fad47b --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/AnthropicChatModelTest.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link AnthropicChatModel} 集成测试类 + * + * @author 芋道源码 + */ +public class AnthropicChatModelTest { + + private final AnthropicChatModel chatModel = AnthropicChatModel.builder() + .anthropicApi(AnthropicApi.builder() + .apiKey("sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942") + .baseUrl("https://aihubmix.com") + .build()) + .defaultOptions(AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4) + .temperature(0.7) + .maxTokens(4096) + .build()) + .build(); + + @Test + @Disabled + public void testCall() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + ChatResponse response = chatModel.call(new Prompt(messages)); + // 打印结果 + System.out.println(response); + } + + @Test + @Disabled + public void testStream() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages)); + // 打印结果 + flux.doOnNext(System.out::println).then().block(); + } + + // TODO @芋艿:需要等 spring ai 升级:https://github.com/spring-projects/spring-ai/pull/2800 + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("thkinking 下,1+1 为什么等于 2 ")); + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4) + .thinking(AnthropicApi.ThinkingType.ENABLED, 3096) + .temperature(1D) + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult()); + }).then().block(); + } + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DeepSeekChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DeepSeekChatModelTests.java index 7b51df166..2fbe0ee5d 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DeepSeekChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DeepSeekChatModelTests.java @@ -60,4 +60,23 @@ public class DeepSeekChatModelTests { flux.doOnNext(System.out::println).then().block(); } + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DeepSeekChatOptions options = DeepSeekChatOptions.builder() + .model("deepseek-reasoner") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DouBaoChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DouBaoChatModelTests.java index 7cd3d43bb..38c4f0b01 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DouBaoChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DouBaoChatModelTests.java @@ -8,9 +8,9 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; import reactor.core.publisher.Flux; import java.util.ArrayList; @@ -23,13 +23,18 @@ import java.util.List; */ public class DouBaoChatModelTests { - private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() + /** + * 相比 OpenAIChatModel 来说,DeepSeekChatModel 可以兼容豆包的 thinking 能力! + */ + private final DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() .baseUrl(DouBaoChatModel.BASE_URL) + .completionsPath(DouBaoChatModel.COMPLETE_PATH) .apiKey("5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272") // apiKey .build()) - .defaultOptions(OpenAiChatOptions.builder() + .defaultOptions(DeepSeekChatOptions.builder() .model("doubao-1-5-lite-32k-250115") // 模型(doubao) +// .model("doubao-seed-1-6-thinking-250715") // 模型(doubao) // .model("deepseek-r1-250120") // 模型(deepseek) .temperature(0.7) .build()) @@ -51,14 +56,13 @@ public class DouBaoChatModelTests { System.out.println(response); } - // TODO @芋艿:因为使用的是 v1 api,导致 deepseek-r1-250120 不返回 think 过程,后续需要优化 @Test @Disabled public void testStream() { // 准备参数 List messages = new ArrayList<>(); messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); - messages.add(new UserMessage("1 + 1 = ?")); + messages.add(new UserMessage("详细推理下,帮我设计一个用户中心!")); // 调用 Flux flux = chatModel.stream(new Prompt(messages)); @@ -66,4 +70,23 @@ public class DouBaoChatModelTests { flux.doOnNext(System.out::println).then().block(); } + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DeepSeekChatOptions options = DeepSeekChatOptions.builder() + .model("doubao-seed-1-6-thinking-250715") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java new file mode 100644 index 000000000..964a5f3c3 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/GeminiChatModelTests.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; + +import cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini.GeminiChatModel; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link GeminiChatModel} 集成测试 + * + * @author 芋道源码 + */ +public class GeminiChatModelTests { + + private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() + .openAiApi(OpenAiApi.builder() + .baseUrl(GeminiChatModel.BASE_URL) + .completionsPath(GeminiChatModel.COMPLETE_PATH) + .apiKey("AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ") + .build()) + .defaultOptions(OpenAiChatOptions.builder() + .model(GeminiChatModel.MODEL_DEFAULT) // 模型 + .temperature(0.7) + .build()) + .build(); + + private final GeminiChatModel chatModel = new GeminiChatModel(openAiChatModel); + + @Test + @Disabled + public void testCall() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + ChatResponse response = chatModel.call(new Prompt(messages)); + // 打印结果 + System.out.println(response); + } + + @Test + @Disabled + public void testStream() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages)); + // 打印结果 + flux.doOnNext(System.out::println).then().block(); + } + +} diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/HunYuanChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/HunYuanChatModelTests.java index b568f5ac4..eeafef261 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/HunYuanChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/HunYuanChatModelTests.java @@ -8,9 +8,9 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; import reactor.core.publisher.Flux; import java.util.ArrayList; @@ -23,12 +23,13 @@ import java.util.List; */ public class HunYuanChatModelTests { - private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() + private final DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() .baseUrl(HunYuanChatModel.BASE_URL) - .apiKey("sk-bcd") // apiKey + .completionsPath(HunYuanChatModel.COMPLETE_PATH) + .apiKey("sk-abc") // apiKey .build()) - .defaultOptions(OpenAiChatOptions.builder() + .defaultOptions(DeepSeekChatOptions.builder() .model(HunYuanChatModel.MODEL_DEFAULT) // 模型 .temperature(0.7) .build()) @@ -64,12 +65,33 @@ public class HunYuanChatModelTests { flux.doOnNext(System.out::println).then().block(); } - private final OpenAiChatModel deepSeekOpenAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DeepSeekChatOptions options = DeepSeekChatOptions.builder() + .model("hunyuan-a13b") +// .model("hunyuan-turbos-latest") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + + private final DeepSeekChatModel deepSeekOpenAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() .baseUrl(HunYuanChatModel.DEEP_SEEK_BASE_URL) + .completionsPath(HunYuanChatModel.COMPLETE_PATH) .apiKey("sk-abc") // apiKey .build()) - .defaultOptions(OpenAiChatOptions.builder() + .defaultOptions(DeepSeekChatOptions.builder() // .model(HunYuanChatModel.DEEP_SEEK_MODEL_DEFAULT) // 模型("deepseek-v3") .model("deepseek-r1") // 模型("deepseek-r1") .temperature(0.7) @@ -94,7 +116,7 @@ public class HunYuanChatModelTests { @Test @Disabled - public void testStream_deekseek() { + public void testStream_deepseek() { // 准备参数 List messages = new ArrayList<>(); messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); @@ -106,5 +128,23 @@ public class HunYuanChatModelTests { flux.doOnNext(System.out::println).then().block(); } + @Test + @Disabled + public void testStream_deepseek_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DeepSeekChatOptions options = DeepSeekChatOptions.builder() + .model("deepseek-r1") + .build(); + + // 调用 + Flux flux = deepSeekChatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/LlamaChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/LlamaChatModelTests.java index 69e2c1daa..14f32e06e 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/LlamaChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/LlamaChatModelTests.java @@ -1,6 +1,20 @@ package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaModel; +import org.springframework.ai.ollama.api.OllamaOptions; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; /** * {@link OllamaChatModel} 集成测试 @@ -9,43 +23,65 @@ import org.springframework.ai.ollama.OllamaChatModel; */ public class LlamaChatModelTests { -// private final OllamaChatModel chatModel = OllamaChatModel.builder() -// .ollamaApi(new OllamaApi("http://127.0.0.1:11434")) // Ollama 服务地址 -// .defaultOptions(OllamaOptions.builder() -// .model(OllamaModel.LLAMA3.getName()) // 模型 -// .build()) -// .build(); -// -// @Test -// @Disabled -// public void testCall() { -// // 准备参数 -// List messages = new ArrayList<>(); -// messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); -// messages.add(new UserMessage("1 + 1 = ?")); -// -// // 调用 -// ChatResponse response = chatModel.call(new Prompt(messages)); -// // 打印结果 -// System.out.println(response); -// System.out.println(response.getResult().getOutput()); -// } -// -// @Test -// @Disabled -// public void testStream() { -// // 准备参数 -// List messages = new ArrayList<>(); -// messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); -// messages.add(new UserMessage("1 + 1 = ?")); -// -// // 调用 -// Flux flux = chatModel.stream(new Prompt(messages)); -// // 打印结果 -// flux.doOnNext(response -> { -//// System.out.println(response); -// System.out.println(response.getResult().getOutput()); -// }).then().block(); -// } + private final OllamaChatModel chatModel = OllamaChatModel.builder() + .ollamaApi(OllamaApi.builder() + .baseUrl("http://127.0.0.1:11434") // Ollama 服务地址 + .build()) + .defaultOptions(OllamaOptions.builder() + .model(OllamaModel.LLAMA3.getName()) // 模型 + .build()) + .build(); + + @Test + @Disabled + public void testCall() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + ChatResponse response = chatModel.call(new Prompt(messages)); + // 打印结果 + System.out.println(response); + System.out.println(response.getResult().getOutput()); + } + + @Test + @Disabled + public void testStream() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("1 + 1 = ?")); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + OllamaOptions options = OllamaOptions.builder() + .model("qwen3") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MiniMaxChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MiniMaxChatModelTests.java index ce350ddd2..8fb133dbb 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MiniMaxChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MiniMaxChatModelTests.java @@ -59,4 +59,24 @@ public class MiniMaxChatModelTests { }).then().block(); } + // TODO @芋艿:暂时没解析 reasoning_content 结果,需要等官方修复 + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + MiniMaxChatOptions options = MiniMaxChatOptions.builder() + .model("MiniMax-M1") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MoonshotChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MoonshotChatModelTests.java index 992334b4d..b50ab80f4 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MoonshotChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/MoonshotChatModelTests.java @@ -63,4 +63,25 @@ public class MoonshotChatModelTests { }).then().block(); } + // TODO @芋艿:暂时没解析 reasoning_content 结果,需要等官方修复 + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + MoonshotChatOptions options = MoonshotChatOptions.builder() +// .model("kimi-k2-0711-preview") + .model("kimi-thinking-preview") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/OpenAIChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/OpenAIChatModelTests.java index c650fd042..5bae6c694 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/OpenAIChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/OpenAIChatModelTests.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; +import com.azure.ai.openai.models.ReasoningEffortValue; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.Message; @@ -25,10 +26,11 @@ public class OpenAIChatModelTests { private final OpenAiChatModel chatModel = OpenAiChatModel.builder() .openAiApi(OpenAiApi.builder() .baseUrl("https://api.holdai.top") - .apiKey("sk-PytRecQlmjEteoa2RRN6cGnwslo72UUPLQVNEMS6K9yjbmpD") // apiKey + .apiKey("sk-z5joyRoV1iFEnh2SAi8QPNrIZTXyQSyxTmD5CoNDQbFixK2l") // apiKey .build()) .defaultOptions(OpenAiChatOptions.builder() - .model(OpenAiApi.ChatModel.GPT_4_1_NANO) // 模型 + .model("gpt-5-nano-2025-08-07") // 模型 +// .model(OpenAiApi.ChatModel.O1) // 模型 .temperature(0.7) .build()) .build(); @@ -54,7 +56,7 @@ public class OpenAIChatModelTests { // 准备参数 List messages = new ArrayList<>(); messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); - messages.add(new UserMessage("1 + 1 = ?")); + messages.add(new UserMessage("帮我推理下,怎么实现一个用户中心!")); // 调用 Flux flux = chatModel.stream(new Prompt(messages)); @@ -65,4 +67,29 @@ public class OpenAIChatModelTests { }).then().block(); } + // TODO @芋艿:无法触发思考的字段返回,需要 response api:https://github.com/spring-projects/spring-ai/issues/2962 + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model("gpt-5") +// .model(OpenAiApi.ChatModel.O4_MINI) +// .model("o3-pro") + .reasoningEffort(ReasoningEffortValue.LOW.getValue()) + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + + + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/SiliconFlowChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/SiliconFlowChatModelTests.java index f34c662db..3bb58e68e 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/SiliconFlowChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/SiliconFlowChatModelTests.java @@ -9,9 +9,9 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; import reactor.core.publisher.Flux; import java.util.ArrayList; @@ -24,12 +24,12 @@ import java.util.List; */ public class SiliconFlowChatModelTests { - private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() + private final DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() .baseUrl(SiliconFlowApiConstants.DEFAULT_BASE_URL) .apiKey("sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz") // apiKey .build()) - .defaultOptions(OpenAiChatOptions.builder() + .defaultOptions(DeepSeekChatOptions.builder() .model(SiliconFlowApiConstants.MODEL_DEFAULT) // 模型 // .model("deepseek-ai/DeepSeek-R1") // 模型(deepseek-ai/DeepSeek-R1)可用赠费 // .model("Pro/deepseek-ai/DeepSeek-R1") // 模型(Pro/deepseek-ai/DeepSeek-R1)需要付费 @@ -67,4 +67,23 @@ public class SiliconFlowChatModelTests { flux.doOnNext(System.out::println).then().block(); } + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DeepSeekChatOptions options = DeepSeekChatOptions.builder() + .model("deepseek-ai/DeepSeek-R1") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/TongYiChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/TongYiChatModelTests.java index 4f2e27edd..23bd5d9e0 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/TongYiChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/TongYiChatModelTests.java @@ -1,8 +1,15 @@ package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import com.alibaba.cloud.ai.dashscope.api.DashScopeApi; import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel; import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions; +import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankModel; +import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankOptions; +import com.alibaba.cloud.ai.model.RerankModel; +import com.alibaba.cloud.ai.model.RerankOptions; +import com.alibaba.cloud.ai.model.RerankRequest; +import com.alibaba.cloud.ai.model.RerankResponse; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.Message; @@ -10,11 +17,14 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.Document; import reactor.core.publisher.Flux; import java.util.ArrayList; import java.util.List; +import static java.util.Arrays.asList; + /** * {@link DashScopeChatModel} 集成测试类 * @@ -26,11 +36,13 @@ public class TongYiChatModelTests { .dashScopeApi(DashScopeApi.builder() .apiKey("sk-47aa124781be4bfb95244cc62f63f7d0") .build()) - .defaultOptions( DashScopeChatOptions.builder() - .withModel("qwen1.5-72b-chat") // 模型 + .defaultOptions(DashScopeChatOptions.builder() +// .withModel("qwen1.5-72b-chat") // 模型 + .withModel("qwen3-235b-a22b-thinking-2507") // 模型 // .withModel("deepseek-r1") // 模型(deepseek-r1) // .withModel("deepseek-v3") // 模型(deepseek-v3) // .withModel("deepseek-r1-distill-qwen-1.5b") // 模型(deepseek-r1-distill-qwen-1.5b) +// .withEnableThinking(true) .build()) .build(); @@ -54,8 +66,8 @@ public class TongYiChatModelTests { public void testStream() { // 准备参数 List messages = new ArrayList<>(); - messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); - messages.add(new UserMessage("1 + 1 = ?")); +// messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。")); + messages.add(new UserMessage("帮我推理下,怎么实现一个用户中心!")); // 调用 Flux flux = chatModel.stream(new Prompt(messages)); @@ -66,4 +78,52 @@ public class TongYiChatModelTests { }).then().block(); } + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DashScopeChatOptions options = DashScopeChatOptions.builder() + .withModel("qwen3-235b-a22b-thinking-2507") +// .withModel("qwen-max-2025-01-25") + .withEnableThinking(true) // 必须设置,否则会报错 + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + + @Test + @Disabled + public void testRerank() { + // 准备环境 + RerankModel rerankModel = new DashScopeRerankModel( + DashScopeApi.builder() + .apiKey("sk-47aa124781be4bfb95244cc62f63f7d0") + .build()); + // 准备参数 + String query = "spring"; + Document document01 = new Document("abc"); + Document document02 = new Document("sapring"); + RerankOptions options = DashScopeRerankOptions.builder() + .withTopN(1) + .withModel("gte-rerank-v2") + .build(); + RerankRequest rerankRequest = new RerankRequest( + query, + asList(document01, document02), + options); + + // 调用 + RerankResponse call = rerankModel.call(rerankRequest); + // 打印结果 + System.out.println(JsonUtils.toJsonPrettyString(call)); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/XingHuoChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/XingHuoChatModelTests.java index 5d8dae201..77dbd2bc6 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/XingHuoChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/XingHuoChatModelTests.java @@ -8,9 +8,9 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; import reactor.core.publisher.Flux; import java.util.ArrayList; @@ -23,13 +23,15 @@ import java.util.List; */ public class XingHuoChatModelTests { - private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder() - .openAiApi(OpenAiApi.builder() - .baseUrl(XingHuoChatModel.BASE_URL) + private final DeepSeekChatModel openAiChatModel = DeepSeekChatModel.builder() + .deepSeekApi(DeepSeekApi.builder() + .baseUrl(XingHuoChatModel.BASE_URL_V2) + .completionsPath(XingHuoChatModel.BASE_COMPLETIONS_PATH_V2) .apiKey("75b161ed2aef4719b275d6e7f2a4d4cd:YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz") // appKey:secretKey .build()) - .defaultOptions(OpenAiChatOptions.builder() - .model("generalv3.5") // 模型 + .defaultOptions(DeepSeekChatOptions.builder() +// .model("generalv3.5") // 模型 + .model("x1") // 模型 .temperature(0.7) .build()) .build(); @@ -64,4 +66,23 @@ public class XingHuoChatModelTests { flux.doOnNext(System.out::println).then().block(); } + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + DeepSeekChatOptions options = DeepSeekChatOptions.builder() + .model("x1") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/ZhiPuAiChatModelTests.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/ZhiPuAiChatModelTests.java index ffdb51892..0b0b00693 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/ZhiPuAiChatModelTests.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/ZhiPuAiChatModelTests.java @@ -23,7 +23,7 @@ import java.util.List; public class ZhiPuAiChatModelTests { private final ZhiPuAiChatModel chatModel = new ZhiPuAiChatModel( - new ZhiPuAiApi("32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs"), // 密钥 + new ZhiPuAiApi("2f35fb6ca4ea41fab898729b7fac086c.6ESSfPcCkxaKEUlR"), // 密钥 ZhiPuAiChatOptions.builder() .model(ZhiPuAiApi.ChatModel.GLM_4.getName()) // 模型 .build() @@ -61,4 +61,24 @@ public class ZhiPuAiChatModelTests { }).then().block(); } + // TODO @芋艿:暂时没解析 reasoning_content 结果,需要等官方修复 + @Test + @Disabled + public void testStream_thinking() { + // 准备参数 + List messages = new ArrayList<>(); + messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); + ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder() + .model("GLM-4.5") + .build(); + + // 调用 + Flux flux = chatModel.stream(new Prompt(messages, options)); + // 打印结果 + flux.doOnNext(response -> { +// System.out.println(response); + System.out.println(response.getResult().getOutput()); + }).then().block(); + } + } diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java new file mode 100644 index 000000000..0a02ab589 --- /dev/null +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/websearch/AiBoChaWebSearchClientTest.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.ai.framework.ai.core.websearch; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse; +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha.AiBoChaWebSearchClient; +import org.junit.jupiter.api.Test; + +/** + * {@link AiBoChaWebSearchClient} 集成测试类 + * + * @author 芋道源码 + */ +public class AiBoChaWebSearchClientTest { + + private final AiBoChaWebSearchClient webSearchClient = new AiBoChaWebSearchClient( + "sk-40500e52840f4d24b956d0b1d80d9abe"); + + @Test + public void testSearch() { + AiWebSearchRequest request = new AiWebSearchRequest() + .setQuery("阿里巴巴") + .setCount(3); + AiWebSearchResponse response = webSearchClient.search(request); + System.out.println(JsonUtils.toJsonPrettyString(response)); + } + +} \ No newline at end of file diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java index d8a13e953..28d6a9fa1 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java @@ -80,17 +80,17 @@ public class FileTypeUtils { */ public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { // 设置 header 和 contentType - String contentType = getMineType(content, filename); - response.setContentType(contentType); + String mineType = getMineType(content, filename); + response.setContentType(mineType); // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html - if (StrUtil.containsIgnoreCase(contentType, "image/")) { + if (isImage(mineType)) { // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 response.setHeader("Content-Disposition", "inline;filename=" + HttpUtils.encodeUtf8(filename)); } else { response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename)); } // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 - if (StrUtil.containsIgnoreCase(contentType, "video")) { + if (StrUtil.containsIgnoreCase(mineType, "video")) { response.setHeader("Content-Length", String.valueOf(content.length)); response.setHeader("Content-Range", "bytes 0-" + (content.length - 1) + "/" + content.length); response.setHeader("Accept-Ranges", "bytes"); @@ -99,4 +99,14 @@ public class FileTypeUtils { IoUtil.write(response.getOutputStream(), false, content); } + /** + * 判断是否是图片 + * + * @param mineType 类型 + * @return 是否是图片 + */ + public static boolean isImage(String mineType) { + return StrUtil.startWith(mineType, "image/"); + } + } diff --git a/yudao-module-mall/yudao-module-promotion-server/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponServiceImpl.java b/yudao-module-mall/yudao-module-promotion-server/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponServiceImpl.java index ba607922f..946980f01 100644 --- a/yudao-module-mall/yudao-module-promotion-server/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponServiceImpl.java +++ b/yudao-module-mall/yudao-module-promotion-server/src/main/java/cn/iocoder/yudao/module/promotion/service/coupon/CouponServiceImpl.java @@ -166,6 +166,10 @@ public class CouponServiceImpl implements CouponService { public void invalidateCouponsByAdmin(List giveCouponIds, Long userId) { // 循环收回 for (Long couponId : giveCouponIds) { + // couponId 为空或 0 则跳过 + if (null == couponId || couponId <= 0) { + continue; + } try { getSelf().invalidateCoupon(couponId, userId); } catch (Exception e) { diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java index a9e3d919b..e5f774190 100644 --- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java +++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/captcha/core/PictureWordCaptchaServiceImpl.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.system.framework.captcha.core; -import cn.hutool.core.util.RandomUtil; import com.anji.captcha.model.common.RepCodeEnum; import com.anji.captcha.model.common.ResponseModel; import com.anji.captcha.model.vo.CaptchaVO; @@ -9,7 +8,8 @@ import com.anji.captcha.service.impl.CaptchaServiceFactory; import com.anji.captcha.util.AESUtil; import com.anji.captcha.util.ImageUtils; import com.anji.captcha.util.RandomUtils; -import org.apache.commons.lang3.StringUtils; +import cn.hutool.core.util.RandomUtil; +import org.apache.commons.lang3.Strings; import java.awt.*; import java.awt.geom.AffineTransform; @@ -82,7 +82,7 @@ public class PictureWordCaptchaServiceImpl extends AbstractCaptchaService { // 用户输入的验证码(CaptchaVO 中 没有预留字段,暂时用 pointJson 无需加解密) String userCode = captchaVO.getPointJson(); - if (!StringUtils.equalsIgnoreCase(code, userCode)) { + if (!Strings.CI.equals(code, userCode)) { afterValidateFail(captchaVO); return ResponseModel.errorMsg(RepCodeEnum.API_CAPTCHA_COORDINATE_ERROR); } @@ -209,4 +209,4 @@ public class PictureWordCaptchaServiceImpl extends AbstractCaptchaService { return RandomUtil.randomString(CHARACTERS, length); } -} \ No newline at end of file +} diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java index 4ae8b78c6..830b728ed 100644 --- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java +++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/justauth/core/AuthRequestFactory.java @@ -78,7 +78,6 @@ public class AuthRequestFactory { .keySet() .stream() .filter(x -> names.contains(x.toUpperCase())) - .map(String::toUpperCase) .collect(Collectors.toList()); } @@ -318,4 +317,4 @@ public class AuthRequestFactory { .proxy(new Proxy(Proxy.Type.valueOf(proxyConfig.getType()), new InetSocketAddress(proxyConfig.getHostname(), proxyConfig.getPort()))) .build()); } -} \ No newline at end of file +} diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java index a931efbc6..22d159a8c 100644 --- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java +++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils; import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission; import cn.iocoder.yudao.module.system.api.logger.dto.LoginLogCreateReqDTO; import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO; @@ -97,6 +98,7 @@ public class AdminAuthServiceImpl implements AdminAuthService { } @Override + @DataPermission(enable = false) public AuthLoginRespVO login(AuthLoginReqVO reqVO) { // 校验验证码 validateCaptcha(reqVO); @@ -134,7 +136,7 @@ public class AdminAuthServiceImpl implements AdminAuthService { @Override public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { // 校验验证码 - smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())).checkError(); + smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP())); // 获得用户信息 AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); @@ -296,7 +298,7 @@ public class AdminAuthServiceImpl implements AdminAuthService { .setMobile(reqVO.getMobile()) .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene()) .setUsedIp(getClientIP()) - ).checkError(); + ); userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); } diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index fd68a9ab5..ef77602a8 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -199,7 +199,8 @@ spring: azure: # OpenAI 微软 openai: endpoint: https://eastusprejade.openai.azure.com - api-key: xxx + anthropic: # Anthropic Claude + api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942 ollama: base-url: http://127.0.0.1:11434 chat: @@ -207,7 +208,7 @@ spring: stabilityai: api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx dashscope: # 通义千问 - api-key: sk-71800982914041848008480000000000 + api-key: sk-47aa124781be4bfb95244cc62f6xxxx minimax: # Minimax:https://www.minimaxi.com/ api-key: xxxx moonshot: # 月之暗灭(KIMI) @@ -217,9 +218,30 @@ spring: chat: options: model: deepseek-chat + model: + rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启 + mcp: + server: + enabled: true + name: yudao-mcp-server + version: 1.0.0 + instructions: 一个 MCP 示例服务 + sse-endpoint: /sse + client: + enabled: true + name: mcp + sse: + connections: + filesystem: + url: http://127.0.0.1:8089 + sse-endpoint: /sse yudao: ai: + gemini: # 谷歌 Gemini + enable: true + api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ + model: gemini-2.5-flash doubao: # 字节豆包 enable: true api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272 @@ -236,7 +258,7 @@ yudao: enable: true appKey: 75b161ed2aef4719b275d6e7f2a4d4cd secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz - model: generalv3.5 + model: x1 baichuan: # 百川智能 enable: true api-key: sk-abc @@ -251,6 +273,9 @@ yudao: enable: true # base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app base-url: http://127.0.0.1:3001 + web-search: + enable: true + api-key: sk-40500e52840f4d24b956d0b1d80d9abe --- #################### 芋道相关配置 #################### From 24402eaeef5b921d63febaf21127922582c95aa5 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 29 Aug 2025 20:19:26 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E3=80=90=E5=90=8C=E6=AD=A5=E3=80=91BOOT=20?= =?UTF-8?q?=E5=92=8C=20CLOUD=20=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .image/common/ai-feature.png | Bin 25498 -> 33087 bytes .../common/util/date/LocalDateTimeUtils.java | 12 ++++---- .../impl/DefaultIdempotentKeyResolver.java | 4 +-- .../impl/UserIdempotentKeyResolver.java | 4 +-- .../impl/ClientIpRateLimiterKeyResolver.java | 4 +-- .../impl/DefaultRateLimiterKeyResolver.java | 4 +-- .../ServerNodeRateLimiterKeyResolver.java | 4 +-- .../impl/UserRateLimiterKeyResolver.java | 4 +-- .../core/redis/RateLimiterRedisDAO.java | 12 +++++--- .../ai/service/image/AiImageServiceImpl.java | 8 ++--- .../model/image/TongYiImagesModelTest.java | 7 +++-- .../BpmSimpleModelNodeTypeEnum.java | 2 +- .../impl/weixin/AbstractWxPayClient.java | 17 ++++++++--- .../sms/core/client/impl/AliyunSmsClient.java | 28 +++++++++++------- 14 files changed, 66 insertions(+), 44 deletions(-) diff --git a/.image/common/ai-feature.png b/.image/common/ai-feature.png index 552ed59b424610bf8438003a1c839e197bda7eed..7f8c92f8cdca66cfd6bba53af2bfdf483698685f 100644 GIT binary patch literal 33087 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 Z!>4Z+lZ{pJ@TmVa$jhimBc%+3{vR@)I86Wm literal 25498 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 ArvLx| diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java index 8d18479c8..26b396168 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java @@ -69,17 +69,17 @@ public class LocalDateTimeUtils { * 创建指定时间 * * @param year 年 - * @param mouth 月 + * @param month 月 * @param day 日 * @return 指定时间 */ - public static LocalDateTime buildTime(int year, int mouth, int day) { - return LocalDateTime.of(year, mouth, day, 0, 0, 0); + public static LocalDateTime buildTime(int year, int month, int day) { + return LocalDateTime.of(year, month, day, 0, 0, 0); } - public static LocalDateTime[] buildBetweenTime(int year1, int mouth1, int day1, - int year2, int mouth2, int day2) { - return new LocalDateTime[]{buildTime(year1, mouth1, day1), buildTime(year2, mouth2, day2)}; + public static LocalDateTime[] buildBetweenTime(int year1, int month1, int day1, + int year2, int month2, int day2) { + return new LocalDateTime[]{buildTime(year1, month1, day1), buildTime(year2, month2, day2)}; } /** diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java index 7b5e145e4..402184895 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.framework.idempotent.core.keyresolver.impl; -import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; import cn.iocoder.yudao.framework.idempotent.core.annotation.Idempotent; import cn.iocoder.yudao.framework.idempotent.core.keyresolver.IdempotentKeyResolver; import org.aspectj.lang.JoinPoint; @@ -18,7 +18,7 @@ public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver { @Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { String methodName = joinPoint.getSignature().toString(); - String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String argsStr = StrUtils.joinMethodArgs(joinPoint); return SecureUtil.md5(methodName + argsStr); } diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java index 2fa91ff97..dd8961705 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.framework.idempotent.core.keyresolver.impl; -import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; import cn.iocoder.yudao.framework.idempotent.core.annotation.Idempotent; import cn.iocoder.yudao.framework.idempotent.core.keyresolver.IdempotentKeyResolver; import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; @@ -19,7 +19,7 @@ public class UserIdempotentKeyResolver implements IdempotentKeyResolver { @Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { String methodName = joinPoint.getSignature().toString(); - String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String argsStr = StrUtils.joinMethodArgs(joinPoint); Long userId = WebFrameworkUtils.getLoginUserId(); Integer userType = WebFrameworkUtils.getLoginUserType(); return SecureUtil.md5(methodName + argsStr + userId + userType); diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java index 8d6253caa..4ed68ddb3 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java @@ -1,8 +1,8 @@ package cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.impl; -import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter; import cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; import org.aspectj.lang.JoinPoint; @@ -19,7 +19,7 @@ public class ClientIpRateLimiterKeyResolver implements RateLimiterKeyResolver { @Override public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { String methodName = joinPoint.getSignature().toString(); - String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String argsStr = StrUtils.joinMethodArgs(joinPoint); String clientIp = ServletUtils.getClientIP(); return SecureUtil.md5(methodName + argsStr + clientIp); } diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java index 236ea45cb..9ae2ff3f1 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.impl; -import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter; import cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; import org.aspectj.lang.JoinPoint; @@ -18,7 +18,7 @@ public class DefaultRateLimiterKeyResolver implements RateLimiterKeyResolver { @Override public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { String methodName = joinPoint.getSignature().toString(); - String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String argsStr = StrUtils.joinMethodArgs(joinPoint); return SecureUtil.md5(methodName + argsStr); } diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java index 300a4d2f1..653790dff 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java @@ -1,8 +1,8 @@ package cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.impl; -import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; import cn.hutool.system.SystemUtil; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter; import cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; import org.aspectj.lang.JoinPoint; @@ -19,7 +19,7 @@ public class ServerNodeRateLimiterKeyResolver implements RateLimiterKeyResolver @Override public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { String methodName = joinPoint.getSignature().toString(); - String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String argsStr = StrUtils.joinMethodArgs(joinPoint); String serverNode = String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); return SecureUtil.md5(methodName + argsStr + serverNode); } diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java index a8d1c3a98..eccabf19b 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.impl; -import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; import cn.iocoder.yudao.framework.ratelimiter.core.annotation.RateLimiter; import cn.iocoder.yudao.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; @@ -19,7 +19,7 @@ public class UserRateLimiterKeyResolver implements RateLimiterKeyResolver { @Override public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { String methodName = joinPoint.getSignature().toString(); - String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String argsStr = StrUtils.joinMethodArgs(joinPoint); Long userId = WebFrameworkUtils.getLoginUserId(); Integer userType = WebFrameworkUtils.getLoginUserType(); return SecureUtil.md5(methodName + argsStr + userId + userType); diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java index 18c30682e..2bcce377f 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.framework.ratelimiter.core.redis; import lombok.AllArgsConstructor; import org.redisson.api.*; +import java.time.Duration; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -40,11 +41,13 @@ public class RateLimiterRedisDAO { String redisKey = formatKey(key); RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisKey); long rateInterval = timeUnit.toSeconds(time); + Duration duration = Duration.ofSeconds(rateInterval); // 1. 如果不存在,设置 rate 速率 RateLimiterConfig config = rateLimiter.getConfig(); if (config == null) { - rateLimiter.trySetRate(RateType.OVERALL, count, rateInterval, RateIntervalUnit.SECONDS); - rateLimiter.expire(rateInterval, TimeUnit.SECONDS); // 原因参见 https://t.zsxq.com/lcR0W + rateLimiter.trySetRate(RateType.OVERALL, count, duration); + // 原因参见 https://t.zsxq.com/lcR0W + rateLimiter.expire(duration); return rateLimiter; } // 2. 如果存在,并且配置相同,则直接返回 @@ -54,8 +57,9 @@ public class RateLimiterRedisDAO { return rateLimiter; } // 3. 如果存在,并且配置不同,则进行新建 - rateLimiter.setRate(RateType.OVERALL, count, rateInterval, RateIntervalUnit.SECONDS); - rateLimiter.expire(rateInterval, TimeUnit.SECONDS); // 原因参见 https://t.zsxq.com/lcR0W + rateLimiter.setRate(RateType.OVERALL, count, duration); + // 原因参见 https://t.zsxq.com/lcR0W + rateLimiter.expire(duration); return rateLimiter; } diff --git a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/image/AiImageServiceImpl.java b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/image/AiImageServiceImpl.java index f5f0c5054..79214a032 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/image/AiImageServiceImpl.java +++ b/yudao-module-ai/yudao-module-ai-server/src/main/java/cn/iocoder/yudao/module/ai/service/image/AiImageServiceImpl.java @@ -9,9 +9,6 @@ import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.hutool.http.HttpUtil; -import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; -import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; -import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowImageOptions; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageDrawReqVO; @@ -24,6 +21,9 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.image.AiImageDO; import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO; import cn.iocoder.yudao.module.ai.dal.mysql.image.AiImageMapper; import cn.iocoder.yudao.module.ai.enums.image.AiImageStatusEnum; +import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi; +import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowImageOptions; import cn.iocoder.yudao.module.ai.service.model.AiModelService; import cn.iocoder.yudao.module.infra.api.file.FileApi; import com.alibaba.cloud.ai.dashscope.image.DashScopeImageOptions; @@ -89,7 +89,7 @@ public class AiImageServiceImpl implements AiImageService { if (CollUtil.isEmpty(ids)) { return Collections.emptyList(); } - return imageMapper.selectBatchIds(ids); + return imageMapper.selectByIds(ids); } @Override diff --git a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/image/TongYiImagesModelTest.java b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/image/TongYiImagesModelTest.java index 1bfd9c8c0..b31c07696 100644 --- a/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/image/TongYiImagesModelTest.java +++ b/yudao-module-ai/yudao-module-ai-server/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/image/TongYiImagesModelTest.java @@ -16,8 +16,11 @@ import org.springframework.ai.image.ImageResponse; */ public class TongYiImagesModelTest { - private final DashScopeImageModel imageModel = new DashScopeImageModel( - new DashScopeImageApi("sk-7d903764249848cfa912733146da12d1")); + private final DashScopeImageModel imageModel = DashScopeImageModel.builder() + .dashScopeApi(DashScopeImageApi.builder() + .apiKey("sk-47aa124781be4bfb95244cc62f63f7d0") + .build()) + .build(); @Test @Disabled diff --git a/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/definition/BpmSimpleModelNodeTypeEnum.java b/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/definition/BpmSimpleModelNodeTypeEnum.java index e5ffa1202..d97c145c3 100644 --- a/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/definition/BpmSimpleModelNodeTypeEnum.java +++ b/yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/definition/BpmSimpleModelNodeTypeEnum.java @@ -35,7 +35,7 @@ public enum BpmSimpleModelNodeTypeEnum implements ArrayValuable { // 50 ~ 条件分支 CONDITION_NODE(50, "条件", "sequenceFlow"), // 用于构建流转条件的表达式 CONDITION_BRANCH_NODE(51, "条件分支", "exclusiveGateway"), - PARALLEL_BRANCH_NODE(52, "并行分支", "parallelGateway"), + PARALLEL_BRANCH_NODE(52, "并行分支", "inclusiveGateway"), // 并行分支使用包容网关实现,条件表达式结果设置为 true INCLUSIVE_BRANCH_NODE(53, "包容分支", "inclusiveGateway"), ROUTER_BRANCH_NODE(54, "路由分支", "exclusiveGateway") ; diff --git a/yudao-module-pay/yudao-module-pay-server/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java b/yudao-module-pay/yudao-module-pay-server/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java index 5f38b1ac5..a06f86b15 100644 --- a/yudao-module-pay/yudao-module-pay-server/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java +++ b/yudao-module-pay/yudao-module-pay-server/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java @@ -541,14 +541,23 @@ public abstract class AbstractWxPayClient extends AbstractPayClient官方示例 */ private SignatureHeader getRequestHeader(Map headers) { + // 参见 https://gitee.com/zhijiantianya/yudao-cloud/issues/ICSFL6 return SignatureHeader.builder() - .signature(headers.get("wechatpay-signature")) - .nonce(headers.get("wechatpay-nonce")) - .serial(headers.get("wechatpay-serial")) - .timeStamp(headers.get("wechatpay-timestamp")) + .signature(getHeaderValue(headers, "Wechatpay-Signature", "wechatpay-signature")) + .nonce(getHeaderValue(headers, "Wechatpay-Nonce", "wechatpay-nonce")) + .serial(getHeaderValue(headers, "Wechatpay-Serial", "wechatpay-serial")) + .timeStamp(getHeaderValue(headers, "Wechatpay-Timestamp", "wechatpay-timestamp")) .build(); } + private String getHeaderValue(Map headers, String capitalizedKey, String lowercaseKey) { + String value = headers.get(capitalizedKey); + if (value != null) { + return value; + } + return headers.get(lowercaseKey); + } + // TODO @芋艿:可能是 wxjava 的 bug:https://github.com/binarywang/WxJava/issues/1557 private void fixV3HttpClientConnectionPoolShutDown() { client.getConfig().setApiV3HttpClient(null); diff --git a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java index d14c2cb6d..03898996d 100644 --- a/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java +++ b/yudao-module-system/yudao-module-system-server/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/AliyunSmsClient.java @@ -111,6 +111,7 @@ public class AliyunSmsClient extends AbstractSmsClient { } @VisibleForTesting + @SuppressWarnings("EnhancedSwitchMigration") Integer convertSmsTemplateAuditStatus(Integer templateStatus) { switch (templateStatus) { case 0: return SmsTemplateAuditStatusEnum.CHECKING.getStatus(); @@ -134,20 +135,25 @@ public class AliyunSmsClient extends AbstractSmsClient { .map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue()))) .collect(Collectors.joining("&")); - // 2.1 请求 Header + // 2. 请求 Body + String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空 + String hashedRequestPayload = DigestUtil.sha256Hex(requestBody); + + // 3.1 请求 Header TreeMap headers = new TreeMap<>(); headers.put("host", HOST); headers.put("x-acs-version", VERSION); headers.put("x-acs-action", apiName); headers.put("x-acs-date", FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT")).format(new Date())); headers.put("x-acs-signature-nonce", IdUtil.randomUUID()); + headers.put("x-acs-content-sha256", hashedRequestPayload); - // 2.2 构建签名 Header + // 3.2 构建签名 Header StringBuilder canonicalHeaders = new StringBuilder(); // 构造请求头,多个规范化消息头,按照消息头名称(小写)的字符代码顺序以升序排列后拼接在一起 StringBuilder signedHeadersBuilder = new StringBuilder(); // 已签名消息头列表,多个请求头名称(小写)按首字母升序排列并以英文分号(;)分隔 headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-") - || entry.getKey().equalsIgnoreCase("host") - || entry.getKey().equalsIgnoreCase("content-type")) + || "host".equalsIgnoreCase(entry.getKey()) + || "content-type".equalsIgnoreCase(entry.getKey())) .sorted(Map.Entry.comparingByKey()).forEach(entry -> { String lowerKey = entry.getKey().toLowerCase(); canonicalHeaders.append(lowerKey).append(":").append(String.valueOf(entry.getValue()).trim()).append("\n"); @@ -155,13 +161,13 @@ public class AliyunSmsClient extends AbstractSmsClient { }); String signedHeaders = signedHeadersBuilder.substring(0, signedHeadersBuilder.length() - 1); - // 3. 请求 Body - String requestBody = ""; // 短信 API 为 RPC 接口,query parameters 在 uri 中拼接,因此 request body 如果没有特殊要求,设置为空。 - String hashedRequestBody = DigestUtil.sha256Hex(requestBody); - // 4. 构建 Authorization 签名 - String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n" - + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; + String canonicalRequest = "POST" + "\n" + + "/" + "\n" + + queryString + "\n" + + canonicalHeaders + "\n" + + signedHeaders + "\n" + + hashedRequestPayload; String hashedCanonicalRequest = DigestUtil.sha256Hex(canonicalRequest); String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest; String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名 @@ -188,4 +194,4 @@ public class AliyunSmsClient extends AbstractSmsClient { .replace("%7E", "~"); // 波浪号 "%7E" 被替换为 "~" } -} \ No newline at end of file +}