【同步】BOOT 和 CLOUD 的功能

pull/207/head
YunaiV 2025-08-29 20:05:55 +08:00
parent 59429be4df
commit 2503432067
72 changed files with 2340 additions and 272 deletions

View File

@ -17,11 +17,11 @@
<revision>2025.08-SNAPSHOT</revision>
<flatten-maven-plugin.version>1.7.2</flatten-maven-plugin.version>
<!-- 统一依赖管理 -->
<spring.boot.version>3.5.4</spring.boot.version>
<spring.boot.version>3.5.5</spring.boot.version>
<spring.cloud.version>2025.0.0</spring.cloud.version>
<spring.cloud.alibaba.version>2023.0.3.3</spring.cloud.alibaba.version>
<!-- Web 相关 -->
<springdoc.version>2.8.9</springdoc.version>
<springdoc.version>2.8.11</springdoc.version>
<knife4j.version>4.5.0</knife4j.version>
<!-- DB 相关 -->
<druid.version>1.2.27</druid.version>
@ -30,11 +30,11 @@
<mybatis-plus-join.version>1.5.4</mybatis-plus-join.version>
<dynamic-datasource.version>4.3.1</dynamic-datasource.version>
<easy-trans.version>3.0.6</easy-trans.version>
<redisson.version>3.50.0</redisson.version>
<redisson.version>3.51.0</redisson.version>
<dm8.jdbc.version>8.1.3.140</dm8.jdbc.version>
<kingbase.jdbc.version>8.6.0</kingbase.jdbc.version>
<opengauss.jdbc.version>5.1.0</opengauss.jdbc.version>
<taos.version>3.3.3</taos.version>
<taos.version>3.7.3</taos.version>
<!-- 消息队列 -->
<rocketmq-spring.version>2.3.4</rocketmq-spring.version>
<!-- RPC 相关 -->
@ -55,12 +55,12 @@
<flowable.version>7.0.1</flowable.version>
<!-- 工具类相关 -->
<anji-plus-captcha.version>1.4.0</anji-plus-captcha.version>
<jsoup.version>1.21.1</jsoup.version>
<jsoup.version>1.21.2</jsoup.version>
<lombok.version>1.18.38</lombok.version>
<mapstruct.version>1.6.3</mapstruct.version>
<hutool-5.version>5.8.39</hutool-5.version>
<hutool-5.version>5.8.40</hutool-5.version>
<hutool-6.version>6.0.0-M22</hutool-6.version>
<fastexcel.version>1.2.0</fastexcel.version>
<fastexcel.version>1.3.0</fastexcel.version>
<velocity.version>2.4.1</velocity.version>
<fastjson.version>1.2.83</fastjson.version>
<guava.version>33.4.8-jre</guava.version>
@ -82,7 +82,7 @@
<justauth-starter.version>1.4.0</justauth-starter.version>
<jimureport.version>2.0.0</jimureport.version>
<jimubi.version>1.9.5</jimubi.version>
<weixin-java.version>4.7.5.B</weixin-java.version>
<weixin-java.version>4.7.7-20250808.182223</weixin-java.version>
</properties>
<dependencyManagement>

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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());
}
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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();

View File

@ -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) {

View File

@ -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)) {

View File

@ -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

View File

@ -33,6 +33,8 @@ public enum AiPlatformEnum implements ArrayValuable<String> {
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

View File

@ -9,6 +9,7 @@
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>yudao-module-ai-server</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>
@ -18,8 +19,8 @@
国外OpenAI、Ollama、Midjourney、StableDiffusion、Suno
</description>
<properties>
<spring-ai.version>1.0.0</spring-ai.version>
<alibaba-ai.version>1.0.0.2</alibaba-ai.version>
<spring-ai.version>1.0.1</spring-ai.version>
<alibaba-ai.version>1.0.0.3</alibaba-ai.version>
<tinyflow.version>1.0.2</tinyflow.version>
</properties>
@ -119,6 +120,11 @@
<artifactId>spring-ai-starter-model-azure-openai</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
@ -217,6 +223,24 @@
</exclusions>
</dependency>
<!-- MCP 相关 -->
<!--
特殊说明:不能使用 spring-ai-starter-mcp-server-webflux 或 spring-ai-starter-mcp-client-webflux
原因:项目使用了 SpringMVC而不是 WebFlux。引入上述 2 个,会导致 SSE Server 失效。
-->
<dependency>
<!-- 服务端 -->
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<!-- 客户端 -->
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- TinyFlowAI 工作流 -->
<dependency>
<groupId>dev.tinyflow</groupId>

View File

@ -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

View File

@ -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<KnowledgeSegment> segments;
@Schema(description = "联网搜索的网页内容数组")
private List<AiWebSearchResponse.WebPage> webSearchPages;
@Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png")
private List<String> attachmentUrls;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-05-12 12:51")
private LocalDateTime createTime;

View File

@ -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<String> attachmentUrls;
}

View File

@ -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<Long> segmentIds;
@Schema(description = "知识库段落数组")
private List<AiChatMessageRespVO.KnowledgeSegment> segments;
@Schema(description = "联网搜索的网页内容数组")
private List<AiWebSearchResponse.WebPage> webSearchPages;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;

View File

@ -52,6 +52,9 @@ public class AiChatRoleRespVO implements VO {
@Schema(description = "引用的工具编号列表", example = "1,2,3")
private List<Long> toolIds;
@Schema(description = "引用的 MCP Client 名字列表", example = "filesystem")
private List<String> mcpClientNames;
@Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Boolean publicStatus;

View File

@ -37,4 +37,7 @@ public class AiChatRoleSaveMyReqVO {
@Schema(description = "引用的工具编号列表", example = "1,2,3")
private List<Long> toolIds;
@Schema(description = "引用的 MCP Client 名字列表", example = "filesystem")
private List<String> mcpClientNames;
}

View File

@ -50,6 +50,9 @@ public class AiChatRoleSaveReqVO {
@Schema(description = "引用的工具编号列表", example = "1,2,3")
private List<Long> toolIds;
@Schema(description = "引用的 MCP Client 名字列表", example = "filesystem")
private List<String> mcpClientNames;
@Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "是否公开不能为空")
private Boolean publicStatus;

View File

@ -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<Long> segmentIds;
/**
*
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private List<AiWebSearchResponse.WebPage> webSearchPages;
/**
* URL
*/
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> attachmentUrls;
}

View File

@ -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<Long> toolIds;
/**
* MCP Client
*
* spring.ai.mcp.client
*/
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> mcpClientNames;
/**
*

View File

@ -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;

View File

@ -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 相关 ==========
/**
* <a href="https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html">MCP Server Boot Starter</>
*/
@Bean
public List<ToolCallback> toolCallbacks(PersonService personService) {
return List.of(ToolCallbacks.from(personService));
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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<MidjourneyApi>) () -> {
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<String> 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
*/

View File

@ -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<AiWebSearchResponse.WebPage> 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:初始版本,实现基本的网页搜索功能

View File

@ -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) {

View File

@ -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 <a href="https://ai.google.dev/gemini-api/docs/openai">OpenAI </a>
*
* @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<ChatResponse> stream(Prompt prompt) {
return openAiChatModel.stream(prompt);
}
@Override
public ChatOptions getDefaultOptions() {
return openAiChatModel.getDefaultOptions();
}
}

View File

@ -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) {

View File

@ -23,7 +23,7 @@ public class SiliconFlowChatModel implements ChatModel {
/**
* OpenAI
*/
private final OpenAiChatModel openAiChatModel;
private final ChatModel openAiChatModel;
@Override
public ChatResponse call(Prompt prompt) {

View File

@ -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
* x14.0Ultrageneralv3.5max-32kgeneralv3pro-128klite
*/
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<ChatResponse> stream(Prompt prompt) {
return openAiChatModel.stream(prompt);
return openAiChatModelV1.stream(prompt);
}
@Override
public ChatOptions getDefaultOptions() {
return openAiChatModel.getDefaultOptions();
return openAiChatModelV1.getDefaultOptions();
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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<WebPage> lists;
/**
*
*/
@Data
public static class WebPage {
/**
*
*
*
*/
private String name;
/**
*
*/
private String icon;
/**
*
*
* 186|2024ESG
*/
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;
}
}

View File

@ -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 <a href="https://open.bochaai.com/overview"> AI </a>
*
* @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<HttpStatusCode> STATUS_PREDICATE = status -> !status.is2xxSuccessful();
private final Function<Object, Function<ClientResponse, Mono<? extends Throwable>>> 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<WebSearchResponse> response = this.webClient.post()
.uri("/v1/web-search")
.bodyValue(webSearchRequest)
.retrieve()
.onStatus(STATUS_PREDICATE, EXCEPTION_FUNCTION.apply(webSearchRequest))
.bodyToMono(new ParameterizedTypeReference<CommonResult<WebSearchResponse>>() {})
.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<WebPageValue> 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
) {
}
}
}

View File

@ -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();
}
};

View File

@ -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" + // 多个 <Reference></Reference> 的拼接
"回答要求:\n- 避免提及你是从 <Reference></Reference> 获取的知识。";
private static final String WEB_SEARCH_USER_MESSAGE_TEMPLATE = "使用 <WebSearch></WebSearch> 标记中的内容作为本次对话的参考:\n\n" +
"%s\n\n" + // 多个 <WebSearch></WebSearch> 的拼接
"回答要求:\n- 避免提及你是从 <WebSearch></WebSearch> 获取的知识。";
/**
* ${@link UserMessage}
*/
@SuppressWarnings("TextBlockMigration")
private static final String Attachment_USER_MESSAGE_TEMPLATE = "使用 <Attachment></Attachment> 标记用户对话上传的附件内容:\n\n" +
"%s\n\n" + // 多个 <Attachment></Attachment> 的拼接
"回答要求:\n- 避免提及 <Attachment></Attachment> 附件的编码格式。";
@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<McpSyncClient> 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<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(), conversation);
// 2.1 知识库召回
List<AiKnowledgeSegmentSearchRespBO> 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<Long, AiKnowledgeDocumentDO> documentMap = knowledgeDocumentService.getKnowledgeDocumentMap(
convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId));
List<AiChatMessageRespVO.KnowledgeSegment> 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<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(sendReqVO.getContent(),
conversation);
// 2.1 知识库找回
List<AiKnowledgeSegmentSearchRespBO> 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<ChatResponse> streamResponse = chatModel.stream(prompt);
// 4.3 流式返回
StringBuffer contentBuffer = new StringBuffer();
StringBuffer reasoningContentBuffer = new StringBuffer();
return streamResponse.map(chunk -> {
// 处理知识库的返回,只有首次才有
// 仅首次:返回知识库、联网搜索
List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
List<AiWebSearchResponse.WebPage> webSearchPages = null;
if (StrUtil.isEmpty(contentBuffer)) {
Map<Long, AiKnowledgeDocumentDO> 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<AiKnowledgeSegmentSearchRespBO> 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<AiChatMessageDO> messages,
List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments,
AiWebSearchResponse webSearchResponse,
AiModelDO model, AiChatMessageSendReqVO sendReqVO) {
List<Message> chatMessages = new ArrayList<>();
// 1.1 System Context 角色设定
@ -231,8 +328,14 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
// 1.2 历史 history message 历史消息
List<AiChatMessageDO> 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<String> toolNames = null;
Map<String,Object> 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 "<WebSearch title=\"" + page.getTitle() + "\" url=\"" + page.getUrl() + "\">"
+ StrUtil.blankToDefault(page.getSummary(), page.getSnippet()) + "</WebSearch>";
})
.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<ToolCallback> toolCallbacks = getToolCallbackListByRoleId(conversation.getRoleId());
Map<String,Object> 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<ToolCallback> getToolCallbackListByRoleId(Long roleId) {
if (roleId == null) {
return null;
}
AiChatRoleDO chatRole = chatRoleService.getChatRole(roleId);
if (chatRole == null) {
return null;
}
List<ToolCallback> toolCallbacks = new ArrayList<>();
// 1. 通过 toolIds
if (CollUtil.isNotEmpty(chatRole.getToolIds())) {
Set<String> 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
* <p>
@ -302,14 +458,56 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
return contextMessages;
}
private UserMessage buildAttachmentUserMessage(List<String> attachmentUrls) {
if (CollUtil.isEmpty(attachmentUrls)) {
return null;
}
// 读取文件内容
Map<String, String> 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 -> "<Attachment name=\"" + entry.getKey() + "\">" + entry.getValue() + "</Attachment>")
.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<AiKnowledgeSegmentSearchRespBO> knowledgeSegments) {
AiModelDO model, Long userId, Long roleId,
MessageType messageType, String content, Boolean useContext,
List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments,
List<String> 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;

View File

@ -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<AiKnowledgeSegmentDO> 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<Document> 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<Document> documents = searchDocument(knowledge, reqBO);
// 3.1 段落召回
List<AiKnowledgeSegmentDO> 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<Document> 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<Document> 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<AiKnowledgeSegmentDO> splitContent(String url, Integer segmentMaxTokens) {
// 1. 读取 URL 内容

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,4 @@
/**
* <a href="https://docs.spring.io/spring-ai/reference/api/tools.html#_methods_as_tools">Tool Calling Methods as Tools</a>
*/
package cn.iocoder.yudao.module.ai.tool.function;

View File

@ -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
) {
}

View File

@ -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<Person> getPersonById(int id);
/**
* Retrieves all Person records currently stored.
*
* @return An unmodifiable List containing all Persons. Returns an empty list if none exist.
*/
List<Person> 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<Person> 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<Person> 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<Person> filterByAge(int age);
}

View File

@ -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<Integer, Person> 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<String> 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<Person> 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<Person> getAllPersons() {
// Return an unmodifiable view of the values
List<Person> 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<Person> 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<Person> 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<Person> filterBySex(String sex) {
if (sex == null || sex.isBlank()) {
log.debug("Filter by sex skipped due to blank filter.");
return Collections.emptyList();
}
List<Person> 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<Person> 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<Person> 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);
}
}

View File

@ -0,0 +1,4 @@
/**
* <a href="https://docs.spring.io/spring-ai/reference/api/tools.html#_methods_as_tools">Tool Calling Methods as Tools</a>
*/
package cn.iocoder.yudao.module.ai.tool.method;

View File

@ -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<String> toolNames, Map<String, Object> toolContext) {
toolNames = ObjUtil.defaultIfNull(toolNames, Collections.emptySet());
List<ToolCallback> toolCallbacks, Map<String, Object> 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;
}
}

View File

@ -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/");
}
}

View File

@ -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: # Minimaxhttps://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
--- #################### 芋道相关配置 ####################

View File

@ -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<Message> 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<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
// 调用
Flux<ChatResponse> 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<Message> 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<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult());
}).then().block();
}
}

View File

@ -60,4 +60,23 @@ public class DeepSeekChatModelTests {
flux.doOnNext(System.out::println).then().block();
}
@Test
@Disabled
public void testStream_thinking() {
// 准备参数
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
DeepSeekChatOptions options = DeepSeekChatOptions.builder()
.model("deepseek-reasoner")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
messages.add(new UserMessage("详细推理下,帮我设计一个用户中心!"));
// 调用
Flux<ChatResponse> 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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
DeepSeekChatOptions options = DeepSeekChatOptions.builder()
.model("doubao-seed-1-6-thinking-250715")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> 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<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages));
// 打印结果
flux.doOnNext(System.out::println).then().block();
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
DeepSeekChatOptions options = DeepSeekChatOptions.builder()
.model("hunyuan-a13b")
// .model("hunyuan-turbos-latest")
.build();
// 调用
Flux<ChatResponse> 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<Message> 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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
DeepSeekChatOptions options = DeepSeekChatOptions.builder()
.model("deepseek-r1")
.build();
// 调用
Flux<ChatResponse> flux = deepSeekChatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> 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<Message> messages = new ArrayList<>();
// messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
// messages.add(new UserMessage("1 + 1 = "));
//
// // 调用
// Flux<ChatResponse> 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<Message> 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<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
// 调用
Flux<ChatResponse> 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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
OllamaOptions options = OllamaOptions.builder()
.model("qwen3")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -59,4 +59,24 @@ public class MiniMaxChatModelTests {
}).then().block();
}
// TODO @芋艿:暂时没解析 reasoning_content 结果,需要等官方修复
@Test
@Disabled
public void testStream_thinking() {
// 准备参数
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
MiniMaxChatOptions options = MiniMaxChatOptions.builder()
.model("MiniMax-M1")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -63,4 +63,25 @@ public class MoonshotChatModelTests {
}).then().block();
}
// TODO @芋艿:暂时没解析 reasoning_content 结果,需要等官方修复
@Test
@Disabled
public void testStream_thinking() {
// 准备参数
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
MoonshotChatOptions options = MoonshotChatOptions.builder()
// .model("kimi-k2-0711-preview")
.model("kimi-thinking-preview")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
messages.add(new UserMessage("帮我推理下,怎么实现一个用户中心!"));
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages));
@ -65,4 +67,29 @@ public class OpenAIChatModelTests {
}).then().block();
}
// TODO @芋艿:无法触发思考的字段返回,需要 response apihttps://github.com/spring-projects/spring-ai/issues/2962
@Test
@Disabled
public void testStream_thinking() {
// 准备参数
List<Message> 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<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
DeepSeekChatOptions options = DeepSeekChatOptions.builder()
.model("deepseek-ai/DeepSeek-R1")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
// messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("帮我推理下,怎么实现一个用户中心!"));
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages));
@ -66,4 +78,52 @@ public class TongYiChatModelTests {
}).then().block();
}
@Test
@Disabled
public void testStream_thinking() {
// 准备参数
List<Message> 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<ChatResponse> 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));
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
DeepSeekChatOptions options = DeepSeekChatOptions.builder()
.model("x1")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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<Message> messages = new ArrayList<>();
messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder()
.model("GLM-4.5")
.build();
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages, options));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -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));
}
}

View File

@ -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/");
}
}

View File

@ -166,6 +166,10 @@ public class CouponServiceImpl implements CouponService {
public void invalidateCouponsByAdmin(List<Long> giveCouponIds, Long userId) {
// 循环收回
for (Long couponId : giveCouponIds) {
// couponId 为空或 0 则跳过
if (null == couponId || couponId <= 0) {
continue;
}
try {
getSelf().invalidateCoupon(couponId, userId);
} catch (Exception e) {

View File

@ -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);
}
}
}

View File

@ -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());
}
}
}

View File

@ -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());
}

View File

@ -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: # Minimaxhttps://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
--- #################### 芋道相关配置 ####################