Commit 9b2f2f581b01361093c543114365ba16c0816a16

Authored by YunaiV
1 parent ca34d165

feat:【ai 大模型】增加联网搜索功能

yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/AiChatMessageController.http
... ... @@ -39,14 +39,27 @@ Authorization: {{token}}
39 39 tenant-id: {{adminTenantId}}
40 40  
41 41 {
42   - "conversationId": "1781604279872581797",
  42 + "conversationId": "1781604279872581799",
43 43 "content": "说下图片里,有哪些字?",
44 44 "useContext": true
45 45 }
46 46  
  47 +### 发送消息(流式)【联网搜索】
  48 +POST {{baseUrl}}/ai/chat/message/send-stream
  49 +Content-Type: application/json
  50 +Authorization: {{token}}
  51 +tenant-id: {{adminTenantId}}
  52 +
  53 +{
  54 + "conversationId": "1781604279872581799",
  55 + "content": "今天是周几?",
  56 + "useSearch": true
  57 +}
  58 +
47 59 ### 获得指定对话的消息列表
48   -GET {{baseUrl}}/ai/chat/message/list-by-conversation-id?conversationId=1781604279872581649
  60 +GET {{baseUrl}}/ai/chat/message/list-by-conversation-id?conversationId=1781604279872581799
49 61 Authorization: {{token}}
  62 +tenant-id: {{adminTenantId}}
50 63  
51 64 ### 删除消息
52 65 DELETE {{baseUrl}}/ai/chat/message/delete?id=50
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageRespVO.java
1 1 package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message;
2 2  
  3 +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse;
3 4 import io.swagger.v3.oas.annotations.media.Schema;
4 5 import lombok.Data;
5 6  
... ... @@ -49,6 +50,9 @@ public class AiChatMessageRespVO {
49 50 @Schema(description = "知识库段落数组")
50 51 private List<KnowledgeSegment> segments;
51 52  
  53 + @Schema(description = "联网搜索的网页内容数组")
  54 + private List<AiWebSearchResponse.WebPage> webSearchPages;
  55 +
52 56 @Schema(description = "附件 URL 数组", example = "https://www.iocoder.cn/1.png")
53 57 private List<String> attachmentUrls;
54 58  
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/chat/vo/message/AiChatMessageSendRespVO.java
1 1 package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message;
2 2  
  3 +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse;
3 4 import io.swagger.v3.oas.annotations.media.Schema;
4 5 import lombok.Data;
5 6  
... ... @@ -38,6 +39,9 @@ public class AiChatMessageSendRespVO {
38 39 @Schema(description = "知识库段落数组")
39 40 private List<AiChatMessageRespVO.KnowledgeSegment> segments;
40 41  
  42 + @Schema(description = "联网搜索的网页内容数组")
  43 + private List<AiWebSearchResponse.WebPage> webSearchPages;
  44 +
41 45 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
42 46 private LocalDateTime createTime;
43 47  
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/chat/AiChatMessageDO.java
... ... @@ -6,11 +6,16 @@ import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler;
6 6 import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO;
7 7 import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO;
8 8 import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO;
  9 +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse;
9 10 import com.baomidou.mybatisplus.annotation.KeySequence;
10 11 import com.baomidou.mybatisplus.annotation.TableField;
11 12 import com.baomidou.mybatisplus.annotation.TableId;
12 13 import com.baomidou.mybatisplus.annotation.TableName;
13   -import lombok.*;
  14 +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
  15 +import lombok.AllArgsConstructor;
  16 +import lombok.Builder;
  17 +import lombok.Data;
  18 +import lombok.NoArgsConstructor;
14 19 import org.springframework.ai.chat.messages.MessageType;
15 20  
16 21 import java.util.List;
... ... @@ -107,6 +112,12 @@ public class AiChatMessageDO extends BaseDO {
107 112 private List<Long> segmentIds;
108 113  
109 114 /**
  115 + * 联网搜索的网页内容数组
  116 + */
  117 + @TableField(typeHandler = JacksonTypeHandler.class)
  118 + private List<AiWebSearchResponse.WebPage> webSearchPages;
  119 +
  120 + /**
110 121 * 附件 URL 数组
111 122 */
112 123 @TableField(typeHandler = StringListTypeHandler.class)
... ...
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java
... ... @@ -23,6 +23,9 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiToolDO;
23 23 import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatMessageMapper;
24 24 import cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants;
25 25 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
  26 +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient;
  27 +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest;
  28 +import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchResponse;
26 29 import cn.iocoder.yudao.module.ai.service.knowledge.AiKnowledgeDocumentService;
27 30 import cn.iocoder.yudao.module.ai.service.knowledge.AiKnowledgeSegmentService;
28 31 import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO;
... ... @@ -44,6 +47,7 @@ import org.springframework.ai.chat.model.ChatResponse;
44 47 import org.springframework.ai.chat.model.StreamingChatModel;
45 48 import org.springframework.ai.chat.prompt.ChatOptions;
46 49 import org.springframework.ai.chat.prompt.Prompt;
  50 +import org.springframework.beans.factory.annotation.Autowired;
47 51 import org.springframework.stereotype.Service;
48 52 import org.springframework.transaction.annotation.Transactional;
49 53 import reactor.core.publisher.Flux;
... ... @@ -69,6 +73,11 @@ import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_MESSAGE_N
69 73 @Slf4j
70 74 public class AiChatMessageServiceImpl implements AiChatMessageService {
71 75  
  76 + /**
  77 + * 联网搜索的结束数
  78 + */
  79 + private static final Integer WEB_SEARCH_COUNT = 10;
  80 +
72 81 // TODO @芋艿:后续优化下对话的 Prompt 整体结构
73 82  
74 83 /**
... ... @@ -78,6 +87,10 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
78 87 "%s\n\n" + // 多个 <Reference></Reference> 的拼接
79 88 "回答要求:\n- 避免提及你是从 <Reference></Reference> 获取的知识。";
80 89  
  90 + private static final String WEB_SEARCH_USER_MESSAGE_TEMPLATE = "使用 <WebSearch></WebSearch> 标记中的内容作为本次对话的参考:\n\n" +
  91 + "%s\n\n" + // 多个 <WebSearch></WebSearch> 的拼接
  92 + "回答要求:\n- 避免提及你是从 <WebSearch></WebSearch> 获取的知识。";
  93 +
81 94 /**
82 95 * 附件转 ${@link UserMessage} 的内容模版
83 96 */
... ... @@ -102,6 +115,10 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
102 115 @Resource
103 116 private AiToolService toolService;
104 117  
  118 + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection")
  119 + @Autowired(required = false) // 由于 yudao.ai.web-search.enable 配置项,可以关闭 AiWebSearchClient 的功能,所以这里只能不强制注入
  120 + private AiWebSearchClient webSearchClient;
  121 +
105 122 @Transactional(rollbackFor = Exception.class)
106 123 public AiChatMessageSendRespVO sendMessage(AiChatMessageSendReqVO sendReqVO, Long userId) {
107 124 // 1.1 校验对话存在
... ... @@ -115,30 +132,35 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
115 132 AiModelDO model = modalService.validateModel(conversation.getModelId());
116 133 ChatModel chatModel = modalService.getChatModel(model.getId());
117 134  
118   - // 2. 知识库找回
  135 + // 2.1 知识库召回
119 136 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(
120 137 sendReqVO.getContent(), conversation);
121 138  
  139 + // 2.2 联网搜索
  140 + AiWebSearchResponse webSearchResponse = Boolean.TRUE.equals(sendReqVO.getUseSearch()) && webSearchClient != null ?
  141 + webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent())
  142 + .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null;
  143 +
122 144 // 3. 插入 user 发送消息
123 145 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model,
124 146 userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(),
125   - null, sendReqVO.getAttachmentUrls());
  147 + null, sendReqVO.getAttachmentUrls(), null);
126 148  
127   - // 3.1 插入 assistant 接收消息
  149 + // 4.1 插入 assistant 接收消息
128 150 AiChatMessageDO assistantMessage = createChatMessage(conversation.getId(), userMessage.getId(), model,
129 151 userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(),
130   - knowledgeSegments, null);
  152 + knowledgeSegments, null, webSearchResponse);
131 153  
132   - // 3.2 创建 chat 需要的 Prompt
133   - Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, model, sendReqVO);
  154 + // 4.2 创建 chat 需要的 Prompt
  155 + Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, webSearchResponse, model, sendReqVO);
134 156 ChatResponse chatResponse = chatModel.call(prompt);
135 157  
136   - // 3.3 更新响应内容
  158 + // 4.3 更新响应内容
137 159 String newContent = AiUtils.getChatResponseContent(chatResponse);
138 160 String newReasoningContent = AiUtils.getChatResponseReasoningContent(chatResponse);
139 161 chatMessageMapper.updateById(new AiChatMessageDO().setId(assistantMessage.getId())
140 162 .setContent(newContent).setReasoningContent(newReasoningContent));
141   - // 3.4 响应结果
  163 + // 4.4 响应结果
142 164 Map<Long, AiKnowledgeDocumentDO> documentMap = knowledgeDocumentService.getKnowledgeDocumentMap(
143 165 convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId));
144 166 List<AiChatMessageRespVO.KnowledgeSegment> segments = BeanUtils.toBean(knowledgeSegments,
... ... @@ -149,7 +171,8 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
149 171 return new AiChatMessageSendRespVO()
150 172 .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class))
151 173 .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class)
152   - .setContent(newContent).setSegments(segments));
  174 + .setContent(newContent).setSegments(segments)
  175 + .setWebSearchPages(webSearchResponse != null ? webSearchResponse.getLists() : null));
153 176 }
154 177  
155 178 @Override
... ... @@ -166,30 +189,36 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
166 189 AiModelDO model = modalService.validateModel(conversation.getModelId());
167 190 StreamingChatModel chatModel = modalService.getChatModel(model.getId());
168 191  
169   - // 2. 知识库找回
  192 + // 2.1 知识库找回
170 193 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(
171 194 sendReqVO.getContent(), conversation);
172 195  
  196 + // 2.2 联网搜索
  197 + AiWebSearchResponse webSearchResponse = Boolean.TRUE.equals(sendReqVO.getUseSearch()) && webSearchClient != null ?
  198 + webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent())
  199 + .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null;
  200 +
173 201 // 3. 插入 user 发送消息
174 202 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model,
175 203 userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(),
176   - null, sendReqVO.getAttachmentUrls());
  204 + null, sendReqVO.getAttachmentUrls(), null);
177 205  
178 206 // 4.1 插入 assistant 接收消息
179 207 AiChatMessageDO assistantMessage = createChatMessage(conversation.getId(), userMessage.getId(), model,
180 208 userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(),
181   - knowledgeSegments, null);
  209 + knowledgeSegments, null, webSearchResponse);
182 210  
183 211 // 4.2 构建 Prompt,并进行调用
184   - Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, model, sendReqVO);
  212 + Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, webSearchResponse, model, sendReqVO);
185 213 Flux<ChatResponse> streamResponse = chatModel.stream(prompt);
186 214  
187 215 // 4.3 流式返回
188 216 StringBuffer contentBuffer = new StringBuffer();
189 217 StringBuffer reasoningContentBuffer = new StringBuffer();
190 218 return streamResponse.map(chunk -> {
191   - // 处理知识库的返回,只有首次才有
  219 + // 仅首次:返回知识库、联网搜索
192 220 List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
  221 + List<AiWebSearchResponse.WebPage> webSearchPages = null;
193 222 if (StrUtil.isEmpty(contentBuffer)) {
194 223 Map<Long, AiKnowledgeDocumentDO> documentMap = TenantUtils.executeIgnore(() ->
195 224 knowledgeDocumentService.getKnowledgeDocumentMap(
... ... @@ -198,6 +227,9 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
198 227 AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId());
199 228 segment.setDocumentName(document != null ? document.getName() : null);
200 229 });
  230 + if (webSearchResponse != null) {
  231 + webSearchPages = webSearchResponse.getLists();
  232 + }
201 233 }
202 234 // 响应结果
203 235 String newContent = AiUtils.getChatResponseContent(chunk);
... ... @@ -213,7 +245,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
213 245 .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class)
214 246 .setContent(StrUtil.nullToDefault(newContent, "")) // 避免 null 的 情况
215 247 .setReasoningContent(StrUtil.nullToDefault(newReasoningContent, "")) // 避免 null 的 情况
216   - .setSegments(segments))); // 知识库返回
  248 + .setSegments(segments).setWebSearchPages(webSearchPages))); // 知识库 + 联网搜索
217 249 }).doOnComplete(() -> {
218 250 // 忽略租户,因为 Flux 异步无法透传租户
219 251 TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
... ... @@ -239,7 +271,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
239 271 return Collections.emptyList();
240 272 }
241 273  
242   - // 2. 遍历找回
  274 + // 2. 遍历召回
243 275 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = new ArrayList<>();
244 276 for (Long knowledgeId : role.getKnowledgeIds()) {
245 277 knowledgeSegments.addAll(knowledgeSegmentService.searchKnowledgeSegment(new AiKnowledgeSegmentSearchReqBO()
... ... @@ -250,6 +282,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
250 282  
251 283 private Prompt buildPrompt(AiChatConversationDO conversation, List<AiChatMessageDO> messages,
252 284 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments,
  285 + AiWebSearchResponse webSearchResponse,
253 286 AiModelDO model, AiChatMessageSendReqVO sendReqVO) {
254 287 List<Message> chatMessages = new ArrayList<>();
255 288 // 1.1 System Context 角色设定
... ... @@ -265,6 +298,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
265 298 if (attachmentUserMessage != null) {
266 299 chatMessages.add(attachmentUserMessage);
267 300 }
  301 + // TODO @芋艿:历史的知识库;历史的搜索,要不要拼接?
268 302 });
269 303  
270 304 // 1.3 当前 user message 新发送消息
... ... @@ -278,7 +312,20 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
278 312 chatMessages.add(new UserMessage(String.format(KNOWLEDGE_USER_MESSAGE_TEMPLATE, reference)));
279 313 }
280 314  
281   - // 1.5 附件,通过 UserMessage 实现
  315 + // 1.5 联网搜索,通过 UserMessage 实现
  316 + if (webSearchResponse != null && CollUtil.isNotEmpty(webSearchResponse.getLists())) {
  317 + String webSearch = webSearchResponse.getLists().stream()
  318 + .map(page -> {
  319 + String summary = StrUtil.isNotEmpty(page.getSummary()) ?
  320 + "\nSummary: " + page.getSummary() : "";
  321 + return "<WebSearch title=\"" + page.getTitle() + "\" url=\"" + page.getUrl() + "\">"
  322 + + StrUtil.blankToDefault(page.getSummary(), page.getSnippet()) + "</WebSearch>";
  323 + })
  324 + .collect(Collectors.joining("\n\n"));
  325 + chatMessages.add(new UserMessage(String.format(WEB_SEARCH_USER_MESSAGE_TEMPLATE, webSearch)));
  326 + }
  327 +
  328 + // 1.6 附件,通过 UserMessage 实现
282 329 if (CollUtil.isNotEmpty(sendReqVO.getAttachmentUrls())) {
283 330 UserMessage attachmentUserMessage = buildAttachmentUserMessage(sendReqVO.getAttachmentUrls());
284 331 if (attachmentUserMessage != null) {
... ... @@ -383,12 +430,16 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
383 430 AiModelDO model, Long userId, Long roleId,
384 431 MessageType messageType, String content, Boolean useContext,
385 432 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments,
386   - List<String> attachmentUrls) {
  433 + List<String> attachmentUrls,
  434 + AiWebSearchResponse webSearchResponse) {
387 435 AiChatMessageDO message = new AiChatMessageDO().setConversationId(conversationId).setReplyId(replyId)
388 436 .setModel(model.getModel()).setModelId(model.getId()).setUserId(userId).setRoleId(roleId)
389 437 .setType(messageType.getValue()).setContent(content).setUseContext(useContext)
390 438 .setSegmentIds(convertList(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getId))
391 439 .setAttachmentUrls(attachmentUrls);
  440 + if (webSearchResponse != null) {
  441 + message.setWebSearchPages(webSearchResponse.getLists());
  442 + }
392 443 message.setCreateTime(LocalDateTime.now());
393 444 chatMessageMapper.insert(message);
394 445 return message;
... ...