feat:【ai 大模型】联网搜索 AiWebSearchClient 封装

This commit is contained in:
YunaiV
2025-08-25 22:23:03 +08:00
parent 6f795186e8
commit ca34d1650e
9 changed files with 360 additions and 43 deletions

View File

@@ -13,6 +13,8 @@ import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlo
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 lombok.extern.slf4j.Slf4j;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.ai.deepseek.DeepSeekChatOptions;
@@ -58,11 +60,11 @@ public class AiAutoConfiguration {
@Bean
@ConditionalOnProperty(value = "yudao.ai.gemini.enable", havingValue = "true")
public GeminiChatModel geminiChatModel(YudaoAiProperties yudaoAiProperties) {
YudaoAiProperties.GeminiProperties properties = yudaoAiProperties.getGemini();
YudaoAiProperties.Gemini properties = yudaoAiProperties.getGemini();
return buildGeminiChatClient(properties);
}
public GeminiChatModel buildGeminiChatClient(YudaoAiProperties.GeminiProperties properties) {
public GeminiChatModel buildGeminiChatClient(YudaoAiProperties.Gemini properties) {
if (StrUtil.isEmpty(properties.getModel())) {
properties.setModel(GeminiChatModel.MODEL_DEFAULT);
}
@@ -86,11 +88,11 @@ public class AiAutoConfiguration {
@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);
}
@@ -114,11 +116,11 @@ 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);
}
@@ -141,11 +143,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);
}
@@ -176,11 +178,11 @@ 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);
}
@@ -208,11 +210,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);
}
@@ -235,7 +237,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());
}
@@ -261,4 +263,12 @@ 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());
}
}

View File

@@ -16,51 +16,51 @@ public class YudaoAiProperties {
/**
* 谷歌 Gemini
*/
private GeminiProperties 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 GeminiProperties {
public static class Gemini {
private String enable;
private String apiKey;
@@ -73,7 +73,7 @@ public class YudaoAiProperties {
}
@Data
public static class DouBaoProperties {
public static class DouBao {
private String enable;
private String apiKey;
@@ -86,7 +86,7 @@ public class YudaoAiProperties {
}
@Data
public static class HunYuanProperties {
public static class HunYuan {
private String enable;
private String baseUrl;
@@ -100,7 +100,7 @@ public class YudaoAiProperties {
}
@Data
public static class SiliconFlowProperties {
public static class SiliconFlow {
private String enable;
private String apiKey;
@@ -113,7 +113,7 @@ public class YudaoAiProperties {
}
@Data
public static class XingHuoProperties {
public static class XingHuo {
private String enable;
private String appId;
@@ -128,7 +128,7 @@ public class YudaoAiProperties {
}
@Data
public static class BaiChuanProperties {
public static class BaiChuan {
private String enable;
private String apiKey;
@@ -141,7 +141,7 @@ public class YudaoAiProperties {
}
@Data
public static class MidjourneyProperties {
public static class Midjourney {
private String enable;
private String baseUrl;
@@ -152,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

@@ -272,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());
});
@@ -409,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);
}
@@ -418,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);
}
@@ -427,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);
}
@@ -485,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);
}
@@ -494,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);
}
@@ -540,10 +540,10 @@ public class AiModelFactoryImpl implements AiModelFactory {
}
/**
* 可参考 {@link AiAutoConfiguration#buildGeminiChatClient(YudaoAiProperties.GeminiProperties)}
* 可参考 {@link AiAutoConfiguration#buildGeminiChatClient(YudaoAiProperties.Gemini)}
*/
private static GeminiChatModel buildGeminiChatModel(String apiKey) {
YudaoAiProperties.GeminiProperties properties = SpringUtil.getBean(YudaoAiProperties.class)
YudaoAiProperties.Gemini properties = SpringUtil.getBean(YudaoAiProperties.class)
.getGemini().setApiKey(apiKey);
return new AiAutoConfiguration().buildGeminiChatClient(properties);
}

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页|阿里巴巴2024年环境、社会和治理ESG报告
*/
private String title;
/**
* URL
*
* 例如说https://m.sohu.com/a/815036254_121819701/?pvid=000115_3w_a
*/
@SuppressWarnings("JavadocLinkAsPlainText")
private String url;
/**
* 内容的简短描述
*/
private String snippet;
/**
* 内容的文本摘要
*/
private String summary;
}
}

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

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

@@ -234,6 +234,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
--- #################### 芋道相关配置 ####################