Compare commits
11 Commits
0deb3301ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fdab553ba1 | |||
| 59eb69747b | |||
| f3f320f390 | |||
| 594adcc48d | |||
| d12334fe36 | |||
| 5bfa65bc0b | |||
|
|
0782148820 | ||
| bfbfdbc90d | |||
|
|
ef527aab00 | ||
|
|
62cf0ed548 | ||
|
|
5158a9bcb3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,3 +30,4 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
/logs/
|
||||
|
||||
149
pom.xml
149
pom.xml
@@ -6,41 +6,160 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.hanserwei</groupId>
|
||||
<artifactId>ai-robot</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>han-ai-robot-springboot</name>
|
||||
<description>han-ai-robot-springboot</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<spring-ai.version>1.0.3</spring-ai.version>
|
||||
<commons-lang3.version>3.19.0</commons-lang3.version>
|
||||
<lombok.version>1.18.40</lombok.version>
|
||||
<jsonschema-generator.version>4.38.0</jsonschema-generator.version>
|
||||
<p6spy.version>3.9.1</p6spy.version>
|
||||
<guava.version>33.0.0-jre</guava.version>
|
||||
<okhttp.version>4.12.0</okhttp.version>
|
||||
<jsoup.version>1.17.2</jsoup.version>
|
||||
<hutool.version>5.8.39</hutool.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
<exclusions>
|
||||
<!-- 排除默认的 Logback 依赖 -->
|
||||
<exclusion>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-logging</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ulisesbocchio</groupId>
|
||||
<artifactId>jasypt-spring-boot-starter</artifactId>
|
||||
<version>3.0.5</version>
|
||||
</dependency>
|
||||
<!-- 阿里云AI -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud.ai</groupId>
|
||||
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>${commons-lang3.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.victools</groupId>
|
||||
<artifactId>jsonschema-generator</artifactId>
|
||||
<version>${jsonschema-generator.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
|
||||
</dependency>
|
||||
<!-- Mybatis Plus -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- p6spy 组件 -->
|
||||
<dependency>
|
||||
<groupId>p6spy</groupId>
|
||||
<artifactId>p6spy</artifactId>
|
||||
<version>${p6spy.version}</version>
|
||||
</dependency>
|
||||
<!-- AOP 切面 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<!-- Log4j2 日志 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-log4j2</artifactId>
|
||||
</dependency>
|
||||
<!-- 参数校验 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<!-- 相关工具类 -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
<!-- OKHttp-->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>${okhttp.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Jsoup -->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>${jsoup.version}</version>
|
||||
</dependency>
|
||||
<!-- 分页插件 -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Hutool -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>${hutool.version}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud.ai</groupId>
|
||||
<artifactId>spring-ai-alibaba-bom</artifactId>
|
||||
<version>1.0.0.4</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Mybatis Plus -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-bom</artifactId>
|
||||
<version>3.5.14</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.hanserwei.airobot.advisor;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
|
||||
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
|
||||
import com.hanserwei.airobot.model.vo.chat.AiChatReqVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.ai.chat.client.ChatClientRequest;
|
||||
import org.springframework.ai.chat.client.ChatClientResponse;
|
||||
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Slf4j
|
||||
public class CustomChatMemoryAdvisor implements StreamAdvisor {
|
||||
|
||||
private final ChatMessageMapper chatMessageMapper;
|
||||
private final AiChatReqVO aiChatReqVO;
|
||||
private final int limit;
|
||||
|
||||
public CustomChatMemoryAdvisor(ChatMessageMapper chatMessageMapper, AiChatReqVO aiChatReqVO, int limit) {
|
||||
this.chatMessageMapper = chatMessageMapper;
|
||||
this.aiChatReqVO = aiChatReqVO;
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 2; // order 值越小,越先执行
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {
|
||||
log.info("## 自定义聊天记忆 Advisor...");
|
||||
|
||||
// 对话 UUID
|
||||
String chatUuid = aiChatReqVO.getChatId();
|
||||
|
||||
// 查询数据库拉取最新的聊天消息
|
||||
List<ChatMessageDO> messages = chatMessageMapper.selectList(Wrappers.<ChatMessageDO>lambdaQuery()
|
||||
.eq(ChatMessageDO::getChatUuid, chatUuid) // 查询指定对话 UUID 下的聊天记录
|
||||
.orderByDesc(ChatMessageDO::getCreateTime) // 查询最新的消息
|
||||
.last(String.format("LIMIT %d", limit))); // 仅查询 LIMIT 条
|
||||
|
||||
// 按发布时间升序排列
|
||||
List<ChatMessageDO> sortedMessages = messages.stream()
|
||||
.sorted(Comparator.comparing(ChatMessageDO::getCreateTime)) // 升序排列
|
||||
.toList();
|
||||
|
||||
// 所有消息
|
||||
List<Message> messageList = getMessageList(sortedMessages);
|
||||
|
||||
// 除了记忆消息,还需要添加当前用户消息
|
||||
messageList.addAll(chatClientRequest.prompt().getInstructions());
|
||||
|
||||
// 构建一个新的 ChatClientRequest 请求对象
|
||||
ChatClientRequest processedChatClientRequest = chatClientRequest
|
||||
.mutate()
|
||||
.prompt(chatClientRequest.prompt().mutate().messages(messageList).build())
|
||||
.build();
|
||||
|
||||
return streamAdvisorChain.nextStream(processedChatClientRequest);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static List<Message> getMessageList(List<ChatMessageDO> sortedMessages) {
|
||||
List<Message> messageList = Lists.newArrayList();
|
||||
|
||||
// 将数据库记录转换为对应类型的消息
|
||||
for (ChatMessageDO chatMessageDO : sortedMessages) {
|
||||
// 消息类型
|
||||
String type = chatMessageDO.getRole();
|
||||
if (Objects.equals(type, MessageType.USER.getValue())) { // 用户消息
|
||||
Message userMessage = new UserMessage(chatMessageDO.getContent());
|
||||
messageList.add(userMessage);
|
||||
} else if (Objects.equals(type, MessageType.ASSISTANT.getValue())) { // AI 助手消息
|
||||
Message assistantMessage = new AssistantMessage(chatMessageDO.getContent());
|
||||
messageList.add(assistantMessage);
|
||||
}
|
||||
}
|
||||
return messageList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.hanserwei.airobot.advisor;
|
||||
|
||||
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
|
||||
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
|
||||
import com.hanserwei.airobot.model.vo.chat.AiChatReqVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.ai.chat.client.ChatClientRequest;
|
||||
import org.springframework.ai.chat.client.ChatClientResponse;
|
||||
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@Slf4j
|
||||
public class CustomStreamLoggerAndMessage2DBAdvisor implements StreamAdvisor {
|
||||
|
||||
private final ChatMessageMapper chatMessageMapper;
|
||||
private final AiChatReqVO aiChatReqVO;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public CustomStreamLoggerAndMessage2DBAdvisor(ChatMessageMapper chatMessageMapper,
|
||||
AiChatReqVO aiChatReqVO,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.chatMessageMapper = chatMessageMapper;
|
||||
this.aiChatReqVO = aiChatReqVO;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 99; // order 值越小,越先执行
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public Flux<ChatClientResponse> adviseStream(@NotNull ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {
|
||||
|
||||
Flux<ChatClientResponse> chatClientResponseFlux = streamAdvisorChain.nextStream(chatClientRequest);
|
||||
|
||||
// 对话 UUID
|
||||
String chatUuid = aiChatReqVO.getChatId();
|
||||
// 用户消息
|
||||
String userMessage = aiChatReqVO.getMessage();
|
||||
|
||||
// 创建 AI 流式回答聚合容器(线程安全)
|
||||
AtomicReference<StringBuilder> fullContent = new AtomicReference<>(new StringBuilder());
|
||||
|
||||
// 返回处理后的流
|
||||
return chatClientResponseFlux
|
||||
.doOnNext(response -> {
|
||||
// 逐块收集内容
|
||||
String chunk = null;
|
||||
if (response.chatResponse() != null) {
|
||||
chunk = response.chatResponse().getResult().getOutput().getText();
|
||||
}
|
||||
|
||||
log.info("## chunk: {}", chunk);
|
||||
|
||||
// 若 chunk 块不为空,则追加到 fullContent 中
|
||||
if (chunk != null) {
|
||||
fullContent.get().append(chunk);
|
||||
}
|
||||
})
|
||||
.doOnComplete(() -> {
|
||||
// 流完成后打印完整回答
|
||||
String completeResponse = fullContent.get().toString();
|
||||
log.info("\n==== FULL AI RESPONSE ====\n{}\n========================", completeResponse);
|
||||
|
||||
// 开启编程式事务
|
||||
transactionTemplate.execute(status -> {
|
||||
try {
|
||||
// 1. 存储用户消息
|
||||
chatMessageMapper.insert(ChatMessageDO.builder()
|
||||
.chatUuid(chatUuid)
|
||||
.content(userMessage)
|
||||
.role(MessageType.USER.getValue()) // 用户消息
|
||||
.createTime(LocalDateTime.now())
|
||||
.build());
|
||||
|
||||
// 2. 存储 AI 回答
|
||||
chatMessageMapper.insert(ChatMessageDO.builder()
|
||||
.chatUuid(chatUuid)
|
||||
.content(completeResponse)
|
||||
.role(MessageType.ASSISTANT.getValue()) // AI 回答
|
||||
.createTime(LocalDateTime.now())
|
||||
.build());
|
||||
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
status.setRollbackOnly(); // 标记事务为回滚
|
||||
log.error("", ex);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.hanserwei.airobot.advisor;
|
||||
|
||||
import com.hanserwei.airobot.model.dto.SearchResultDTO;
|
||||
import com.hanserwei.airobot.service.SearXNGService;
|
||||
import com.hanserwei.airobot.service.SearchResultContentFetcherService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.ai.chat.client.ChatClientRequest;
|
||||
import org.springframework.ai.chat.client.ChatClientResponse;
|
||||
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.chat.prompt.PromptTemplate;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 网络搜索增强顾问类,用于在聊天客户端处理用户请求前,通过联网搜索获取相关信息,
|
||||
* 并将搜索结果作为上下文整合进原始提示词中以提升模型回答质量。
|
||||
*
|
||||
* <p>该类实现了 {@link StreamAdvisor} 接口,支持流式调用链中的增强逻辑。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
public class NetworkSearchAdvisor implements StreamAdvisor {
|
||||
|
||||
private final SearXNGService searXNGService;
|
||||
private final SearchResultContentFetcherService searchResultContentFetcherService;
|
||||
|
||||
/**
|
||||
* 联网搜索提示词模板:定义了如何基于用户的提问和网络搜索得到的上下文构造新的提示词。
|
||||
* 包含用户问题、上下文信息以及一系列任务要求(如提取核心信息、交叉验证等)。
|
||||
*/
|
||||
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
|
||||
## 用户问题
|
||||
{question}
|
||||
|
||||
## 上下文
|
||||
上下文信息如下,由以下符号包围:
|
||||
---------------------
|
||||
{context}
|
||||
---------------------
|
||||
|
||||
请根据上下文内容来回复用户:
|
||||
|
||||
## 任务要求
|
||||
|
||||
1. 综合分析上下文内容,提取与用户问题直接相关的核心信息
|
||||
2. 特别关注匹配度较高的结果
|
||||
3. 对矛盾信息进行交叉验证,优先采用多个来源证实的信息
|
||||
4. 请避免使用诸如 “根据上下文……” 或 “所提供的信息……” 这类表述
|
||||
5. 当上下文内容不足或存在知识缺口时,再考虑使用本身已拥有的先验知识
|
||||
6. 在关键信息后标注对应的来源,包含编号与页面跳转链接,格式如 [<a href="https://www.douyin.com/shipin/7532759629252544512" target="_blank">来源1</a>]
|
||||
""");
|
||||
|
||||
/**
|
||||
* 构造方法,注入所需的外部服务依赖。
|
||||
*
|
||||
* @param searXNGService 搜索引擎服务接口,用于发起网络搜索请求
|
||||
* @param searchResultContentFetcherService 内容抓取服务接口,用于并发获取网页正文内容
|
||||
*/
|
||||
public NetworkSearchAdvisor(SearXNGService searXNGService, SearchResultContentFetcherService searchResultContentFetcherService) {
|
||||
this.searXNGService = searXNGService;
|
||||
this.searchResultContentFetcherService = searchResultContentFetcherService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现 StreamAdvisor 的核心方法,在聊天请求处理过程中插入联网搜索逻辑。
|
||||
*
|
||||
* <p>流程包括:</p>
|
||||
* <ul>
|
||||
* <li>从当前请求中解析出用户消息</li>
|
||||
* <li>调用搜索引擎获取初步搜索结果</li>
|
||||
* <li>并发抓取各搜索结果页面的内容</li>
|
||||
* <li>过滤掉未成功抓取的结果</li>
|
||||
* <li>构建结构化的上下文字符串</li>
|
||||
* <li>替换原提示词并继续后续处理链</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param chatClientRequest 当前聊天客户端请求对象,包含原始提示词和其他配置
|
||||
* @param streamAdvisorChain 处理链对象,用于传递控制权到下一个顾问节点
|
||||
* @return 返回经过增强后的响应流
|
||||
*/
|
||||
@NotNull
|
||||
@Override
|
||||
public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) {
|
||||
// 获取用户输入的提示词
|
||||
Prompt prompt = chatClientRequest.prompt();
|
||||
UserMessage userMessage = prompt.getUserMessage();
|
||||
|
||||
// 调用 SearXNG 获取搜索结果
|
||||
List<SearchResultDTO> searchResults = searXNGService.search(userMessage.getText());
|
||||
|
||||
// 并发请求,获取搜索结果页面的内容
|
||||
CompletableFuture<List<SearchResultDTO>> resultsFuture = searchResultContentFetcherService.batchFetch(searchResults, 7, TimeUnit.SECONDS);
|
||||
|
||||
List<SearchResultDTO> results = resultsFuture.join();
|
||||
|
||||
// 过滤掉获取失败的结果
|
||||
List<SearchResultDTO> successfulResults = results.stream()
|
||||
.filter(r -> StringUtils.isNotBlank(r.getContent()))
|
||||
.toList();
|
||||
|
||||
// 构建搜索结果上下文信息
|
||||
String searchContext = buildContext(successfulResults);
|
||||
|
||||
// 填充提示词占位符,转换为 Prompt 提示词对象
|
||||
Prompt newPrompt = DEFAULT_PROMPT_TEMPLATE.create(Map.of("question", userMessage.getText(),
|
||||
"context", searchContext), chatClientRequest.prompt().getOptions());
|
||||
|
||||
log.info("## 重新构建的增强提示词: {}", newPrompt.getUserMessage().getText());
|
||||
|
||||
// 重新构建 ChatClientRequest,设置重新构建的 “增强提示词”
|
||||
ChatClientRequest newChatClientRequest = ChatClientRequest.builder()
|
||||
.prompt(newPrompt)
|
||||
.build();
|
||||
|
||||
return streamAdvisorChain.nextStream(newChatClientRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将成功的搜索结果构建成结构化文本形式的上下文。
|
||||
*
|
||||
* <p>每条记录包括其序号、相关性评分、URL 和实际抓取到的页面内容。</p>
|
||||
*
|
||||
* @param successfulResults 成功抓取内容的搜索结果列表
|
||||
* @return 格式化后的上下文字符串
|
||||
*/
|
||||
private String buildContext(List<SearchResultDTO> successfulResults) {
|
||||
int i = 1;
|
||||
StringBuilder contextTemp = new StringBuilder();
|
||||
for (SearchResultDTO searchResult : successfulResults) {
|
||||
contextTemp.append(String.format("""
|
||||
### 来源 %s | 相关性: %s
|
||||
- 页面链接: %s
|
||||
- 页面文本:
|
||||
%s
|
||||
\n
|
||||
""", i, searchResult.getScore(), searchResult.getUrl(), searchResult.getContent()));
|
||||
i++;
|
||||
}
|
||||
|
||||
return contextTemp.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前顾问器的名称,默认为类名。
|
||||
*
|
||||
* @return 类简单名称
|
||||
*/
|
||||
@NotNull
|
||||
@Override
|
||||
public String getName() {
|
||||
// 获取类名称
|
||||
return this.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义当前顾问器在处理链中的顺序。数值越小表示越早执行。
|
||||
*
|
||||
* @return 执行顺序值(默认为 1)
|
||||
*/
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 1; // order 值越小,越先执行
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hanserwei.airobot.aspect;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.METHOD})
|
||||
@Documented
|
||||
public @interface ApiOperationLog {
|
||||
/**
|
||||
* API 功能描述
|
||||
*
|
||||
* @return API 功能描述
|
||||
*/
|
||||
String description() default "";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hanserwei.airobot.aspect;
|
||||
|
||||
import com.hanserwei.airobot.utils.JsonUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Pointcut;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* API操作日志切面类,用于记录被 @ApiOperationLog 注解标记的方法的执行信息,
|
||||
* 包括方法描述、入参、出参以及执行耗时等。
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ApiOperationLogAspect {
|
||||
|
||||
/** 以自定义 @ApiOperationLog 注解为切点,凡是添加 @ApiOperationLog 的方法,都会执行环绕中的代码 */
|
||||
@Pointcut("@annotation(com.hanserwei.airobot.aspect.ApiOperationLog)")
|
||||
public void apiOperationLog() {}
|
||||
|
||||
/**
|
||||
* 环绕通知方法,用于记录目标方法的执行日志。
|
||||
* 包括方法开始时间、类名、方法名、入参、功能描述、执行结果和耗时。
|
||||
*
|
||||
* @param joinPoint 切点对象,封装了目标方法的相关信息
|
||||
* @return 目标方法的返回值
|
||||
* @throws Throwable 目标方法可能抛出的异常
|
||||
*/
|
||||
@Around("apiOperationLog()")
|
||||
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
// 请求开始时间
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 获取被请求的类和方法
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
|
||||
// 请求入参
|
||||
Object[] args = joinPoint.getArgs();
|
||||
// 入参转 JSON 字符串
|
||||
String argsJsonStr = Arrays.stream(args).map(toJsonStr()).collect(Collectors.joining(", "));
|
||||
|
||||
// 功能描述信息
|
||||
String description = getApiOperationLogDescription(joinPoint);
|
||||
|
||||
// 打印请求相关参数
|
||||
log.info("====== 请求开始: [{}], 入参: {}, 请求类: {}, 请求方法: {} =================================== ",
|
||||
description, argsJsonStr, className, methodName);
|
||||
|
||||
// 执行切点方法
|
||||
Object result = joinPoint.proceed();
|
||||
|
||||
// 执行耗时
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 打印出参等相关信息
|
||||
log.info("====== 请求结束: [{}], 耗时: {}ms, 出参: {} =================================== ",
|
||||
description, executionTime, JsonUtil.toJsonString(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标方法上 @ApiOperationLog 注解的描述信息。
|
||||
*
|
||||
* @param joinPoint 切点对象,用于获取目标方法信息
|
||||
* @return 注解中定义的功能描述字符串
|
||||
*/
|
||||
private String getApiOperationLogDescription(ProceedingJoinPoint joinPoint) {
|
||||
// 1. 从 ProceedingJoinPoint 获取 MethodSignature
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
|
||||
// 2. 使用 MethodSignature 获取当前被注解的 Method
|
||||
Method method = signature.getMethod();
|
||||
|
||||
// 3. 从 Method 中提取 LogExecution 注解
|
||||
ApiOperationLog apiOperationLog = method.getAnnotation(ApiOperationLog.class);
|
||||
|
||||
// 4. 从 LogExecution 注解中获取 description 属性
|
||||
return apiOperationLog.description();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回一个将对象转换为 JSON 字符串的函数。
|
||||
*
|
||||
* @return 将对象序列化为 JSON 字符串的函数
|
||||
*/
|
||||
private Function<Object, String> toJsonStr() {
|
||||
return JsonUtil::toJsonString;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hanserwei.airobot.config;
|
||||
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class ChatClientConfig {
|
||||
|
||||
/**
|
||||
* 初始化 ChatClient 客户端
|
||||
*
|
||||
* @param chatModel 模型
|
||||
* @return ChatClient
|
||||
*/
|
||||
@Bean
|
||||
public ChatClient chatClient(DashScopeChatModel chatModel) {
|
||||
return ChatClient.builder(chatModel)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
19
src/main/java/com/hanserwei/airobot/config/CorsConfig.java
Normal file
19
src/main/java/com/hanserwei/airobot/config/CorsConfig.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.hanserwei.airobot.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**") // 匹配所有路径
|
||||
.allowedOriginPatterns("*") // 允许所有域名(生产环境应指定具体域名)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法
|
||||
.allowedHeaders("*") // 允许所有请求头
|
||||
.allowCredentials(true) // 允许发送 Cookie
|
||||
.maxAge(3600); // 预检请求的有效期(秒)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.hanserwei.airobot.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.YearMonthDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.YearMonthSerializer;
|
||||
import com.hanserwei.airobot.constant.DateConstants;
|
||||
import com.hanserwei.airobot.utils.JsonUtil;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.YearMonth;
|
||||
import java.util.TimeZone;
|
||||
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
// 初始化一个 ObjectMapper 对象,用于自定义 Jackson 的行为
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// 忽略未知属性
|
||||
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
|
||||
// 设置凡是为 null 的字段,返参中均不返回,请根据项目组约定是否开启
|
||||
// objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
|
||||
// 设置时区
|
||||
objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
|
||||
// JavaTimeModule 用于指定序列化和反序列化规则
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
|
||||
// 支持 LocalDateTime、LocalDate、LocalTime
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
|
||||
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
|
||||
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateConstants.DATE_FORMAT_Y_M_D));
|
||||
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateConstants.DATE_FORMAT_Y_M_D));
|
||||
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateConstants.DATE_FORMAT_H_M_S));
|
||||
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateConstants.DATE_FORMAT_H_M_S));
|
||||
// 支持 YearMonth
|
||||
javaTimeModule.addSerializer(YearMonth.class, new YearMonthSerializer(DateConstants.DATE_FORMAT_Y_M));
|
||||
javaTimeModule.addDeserializer(YearMonth.class, new YearMonthDeserializer(DateConstants.DATE_FORMAT_Y_M));
|
||||
|
||||
objectMapper.registerModule(javaTimeModule);
|
||||
|
||||
// 初始化 JsonUtils 中的 ObjectMapper
|
||||
JsonUtil.init(objectMapper);
|
||||
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.hanserwei.airobot.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.hanserwei.airobot.domain.mapper")
|
||||
public class MybatisPlusConfig {
|
||||
/**
|
||||
* 添加分页插件
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL)); // 如果配置多个插件, 切记分页最后添加
|
||||
// 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
31
src/main/java/com/hanserwei/airobot/config/OkHttpConfig.java
Normal file
31
src/main/java/com/hanserwei/airobot/config/OkHttpConfig.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.hanserwei.airobot.config;
|
||||
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Configuration
|
||||
public class OkHttpConfig {
|
||||
|
||||
@Bean
|
||||
public OkHttpClient okHttpClient(
|
||||
@Value("${okhttp.connect-timeout}") int connectTimeout,
|
||||
@Value("${okhttp.read-timeout}") int readTimeout,
|
||||
@Value("${okhttp.write-timeout}") int writeTimeout,
|
||||
@Value("${okhttp.max-idle-connections}") int maxIdleConnections,
|
||||
@Value("${okhttp.keep-alive-duration}") int keepAliveDuration) {
|
||||
|
||||
|
||||
return new OkHttpClient.Builder()
|
||||
.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
|
||||
.connectionPool(new ConnectionPool(maxIdleConnections, keepAliveDuration, TimeUnit.MINUTES))
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.hanserwei.airobot.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
@Configuration
|
||||
public class ThreadPoolConfig {
|
||||
|
||||
/**
|
||||
* HTTP 请求线程池(IO 密集型任务)
|
||||
* @return HTTP 请求线程池
|
||||
*/
|
||||
@Bean("httpRequestExecutor")
|
||||
public ThreadPoolTaskExecutor httpRequestExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(50); // 核心线程数(保持常驻)
|
||||
executor.setMaxPoolSize(200); // 最大线程数(突发流量时扩容)
|
||||
executor.setQueueCapacity(1000); // 任务队列容量(缓冲突发请求)
|
||||
executor.setKeepAliveSeconds(120); // 空闲线程存活时间(秒)
|
||||
executor.setThreadNamePrefix("http-fetcher-"); // 线程名前缀(便于监控)
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略(由调用线程执行)
|
||||
executor.initialize(); // 初始化线程池
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果处理线程池(CPU 密集型任务)
|
||||
* @return 结果处理线程池(CPU 密集型任务)
|
||||
*/
|
||||
@Bean("resultProcessingExecutor")
|
||||
public ThreadPoolTaskExecutor resultProcessingExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors()); // 核心线程数(等于CPU核心数)
|
||||
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2); // 最大线程数(不超过CPU核心数2倍)
|
||||
executor.setQueueCapacity(200); // 较小队列(避免任务堆积)
|
||||
executor.setThreadNamePrefix("result-processor-"); // 线程名前缀(便于监控)
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 拒绝策略(直接抛出异常)
|
||||
executor.initialize(); // 初始化线程池
|
||||
return executor;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hanserwei.airobot.constant;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public interface DateConstants {
|
||||
|
||||
/**
|
||||
* DateTimeFormatter:年-月-日 时:分:秒
|
||||
*/
|
||||
DateTimeFormatter DATE_FORMAT_Y_M_D_H_M_S = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/**
|
||||
* DateTimeFormatter:年-月-日
|
||||
*/
|
||||
DateTimeFormatter DATE_FORMAT_Y_M_D = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/**
|
||||
* DateTimeFormatter:月-日
|
||||
*/
|
||||
DateTimeFormatter DATE_FORMAT_M_D = DateTimeFormatter.ofPattern("MM-dd");
|
||||
|
||||
/**
|
||||
* DateTimeFormatter:时:分:秒
|
||||
*/
|
||||
DateTimeFormatter DATE_FORMAT_H_M_S = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/**
|
||||
* DateTimeFormatter:时:分
|
||||
*/
|
||||
DateTimeFormatter DATE_FORMAT_H_M = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
/**
|
||||
* DateTimeFormatter:年-月
|
||||
*/
|
||||
DateTimeFormatter DATE_FORMAT_Y_M = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.hanserwei.airobot.controller;
|
||||
|
||||
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.google.common.collect.Lists;
|
||||
import com.hanserwei.airobot.advisor.CustomChatMemoryAdvisor;
|
||||
import com.hanserwei.airobot.advisor.CustomStreamLoggerAndMessage2DBAdvisor;
|
||||
import com.hanserwei.airobot.advisor.NetworkSearchAdvisor;
|
||||
import com.hanserwei.airobot.aspect.ApiOperationLog;
|
||||
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
|
||||
import com.hanserwei.airobot.model.vo.chat.*;
|
||||
import com.hanserwei.airobot.service.ChatService;
|
||||
import com.hanserwei.airobot.service.SearXNGService;
|
||||
import com.hanserwei.airobot.service.SearchResultContentFetcherService;
|
||||
import com.hanserwei.airobot.utils.PageResponse;
|
||||
import com.hanserwei.airobot.utils.Response;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.api.Advisor;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/chat")
|
||||
@Slf4j
|
||||
public class ChatController {
|
||||
|
||||
@Resource
|
||||
private ChatService chatService;
|
||||
@Resource
|
||||
private ChatMessageMapper chatMessageMapper;
|
||||
@Resource
|
||||
private TransactionTemplate transactionTemplate;
|
||||
@Resource
|
||||
private SearXNGService searXNGService;
|
||||
@Resource
|
||||
private SearchResultContentFetcherService searchResultContentFetcherService;
|
||||
|
||||
@Value("${spring.ai.dashscope.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
@PostMapping("/new")
|
||||
@ApiOperationLog(description = "新建对话")
|
||||
public Response<?> newChat(@RequestBody @Validated NewChatReqVO newChatReqVO) {
|
||||
return chatService.newChat(newChatReqVO);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/completion", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@ApiOperationLog(description = "流式对话")
|
||||
public Flux<AiResponse> chat(@RequestBody @Validated AiChatReqVO aiChatReqVO) {
|
||||
// 用户消息
|
||||
String message = aiChatReqVO.getMessage();
|
||||
// 模型名称
|
||||
String modelName = aiChatReqVO.getModelName();
|
||||
// 温度
|
||||
Double temperature = aiChatReqVO.getTemperature();
|
||||
|
||||
// 构建ChatModel
|
||||
ChatModel chatModel = DashScopeChatModel.builder()
|
||||
.dashScopeApi(DashScopeApi.builder()
|
||||
.apiKey(apiKey)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
// 动态设置模型名称和温度
|
||||
ChatClient.ChatClientRequestSpec chatClientRequestSpec = ChatClient.create(chatModel)
|
||||
.prompt()
|
||||
.options(DashScopeChatOptions.builder()
|
||||
.withModel(modelName)
|
||||
.withTemperature(temperature)
|
||||
.build())
|
||||
.user(message);
|
||||
|
||||
// 是否开启联网搜索
|
||||
boolean networkSearch = aiChatReqVO.getNetworkSearch();
|
||||
|
||||
// Advisor 集合
|
||||
List<Advisor> advisors = Lists.newArrayList();
|
||||
// 是否开启了联网搜索
|
||||
if (networkSearch) {
|
||||
advisors.add(new NetworkSearchAdvisor(searXNGService, searchResultContentFetcherService));
|
||||
} else {
|
||||
// 添加自定义对话记忆 Advisor(以最新的 50 条消息作为记忆)
|
||||
advisors.add(new CustomChatMemoryAdvisor(chatMessageMapper, aiChatReqVO, 50));
|
||||
}
|
||||
// 添加自定义对话记忆 Advisor(以最新的 50 条消息作为记忆)
|
||||
advisors.add(new CustomChatMemoryAdvisor(chatMessageMapper, aiChatReqVO, 50));
|
||||
// 添加自定义打印流式对话日志 Advisor
|
||||
advisors.add(new CustomStreamLoggerAndMessage2DBAdvisor(chatMessageMapper, aiChatReqVO, transactionTemplate));
|
||||
|
||||
// 应用 Advisor 集合
|
||||
chatClientRequestSpec.advisors(advisors);
|
||||
|
||||
// 流式输出
|
||||
return chatClientRequestSpec.stream()
|
||||
.content()
|
||||
.mapNotNull(text -> AiResponse.builder().v(text).build());
|
||||
}
|
||||
|
||||
@PostMapping("/message/list")
|
||||
@ApiOperationLog(description = "查询对话历史消息")
|
||||
public PageResponse<FindChatHistoryMessagePageListRspVO> findChatMessagePageList(@RequestBody @Validated FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO) {
|
||||
return chatService.findChatHistoryMessagePageList(findChatHistoryMessagePageListReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/list")
|
||||
@ApiOperationLog(description = "查询历史对话")
|
||||
public PageResponse<FindChatHistoryPageListRspVO> findChatHistoryPageList(@RequestBody @Validated FindChatHistoryPageListReqVO findChatHistoryPageListReqVO) {
|
||||
return chatService.findChatHistoryPageList(findChatHistoryPageListReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/summary/rename")
|
||||
@ApiOperationLog(description = "重命名对话摘要")
|
||||
public Response<?> renameChatSummary(@RequestBody @Validated RenameChatReqVO renameChatReqVO) {
|
||||
return chatService.renameChatSummary(renameChatReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@ApiOperationLog(description = "删除对话")
|
||||
public Response<?> deleteChat(@RequestBody @Validated DeleteChatReqVO deleteChatReqVO) {
|
||||
return chatService.deleteChat(deleteChatReqVO);
|
||||
}
|
||||
|
||||
}
|
||||
26
src/main/java/com/hanserwei/airobot/domain/dos/ChatDO.java
Normal file
26
src/main/java/com/hanserwei/airobot/domain/dos/ChatDO.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.hanserwei.airobot.domain.dos;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_chat")
|
||||
public class ChatDO {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String uuid;
|
||||
private String summary;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hanserwei.airobot.domain.dos;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@TableName("t_chat_message")
|
||||
public class ChatMessageDO {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String chatUuid;
|
||||
private String content;
|
||||
private String role;
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hanserwei.airobot.domain.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hanserwei.airobot.domain.dos.ChatDO;
|
||||
|
||||
public interface ChatMapper extends BaseMapper<ChatDO> {
|
||||
|
||||
|
||||
/**
|
||||
* 分页查询聊天记录列表
|
||||
* @param current 当前页码
|
||||
* @param size 每页大小
|
||||
* @return 分页结果
|
||||
*/
|
||||
default Page<ChatDO> selectPageList(Long current, Long size) {
|
||||
// 创建分页对象,指定当前页和每页数量
|
||||
Page<ChatDO> page = new Page<>(current, size);
|
||||
|
||||
// 构建查询条件,按更新时间倒序排列
|
||||
LambdaQueryWrapper<ChatDO> wrapper = Wrappers.<ChatDO>lambdaQuery()
|
||||
.orderByDesc(ChatDO::getUpdateTime);
|
||||
|
||||
// 执行分页查询并返回结果
|
||||
return selectPage(page, wrapper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hanserwei.airobot.domain.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
|
||||
|
||||
public interface ChatMessageMapper extends BaseMapper<ChatMessageDO> {
|
||||
/**
|
||||
* 分页查询聊天消息列表
|
||||
*
|
||||
* @param current 当前页码
|
||||
* @param size 每页显示记录数
|
||||
* @param chatId 聊天对话ID
|
||||
* @return 分页结果,包含聊天消息列表及相关分页信息
|
||||
*/
|
||||
default Page<ChatMessageDO> selectPageList(Long current, Long size, String chatId) {
|
||||
// 分页对象(查询第几页、每页多少数据)
|
||||
Page<ChatMessageDO> page = new Page<>(current, size);
|
||||
|
||||
// 构建查询条件
|
||||
LambdaQueryWrapper<ChatMessageDO> wrapper = Wrappers.<ChatMessageDO>lambdaQuery()
|
||||
.eq(ChatMessageDO::getChatUuid, chatId) // 对话 ID
|
||||
.orderByDesc(ChatMessageDO::getCreateTime); // 按创建时间倒序
|
||||
|
||||
return selectPage(page, wrapper);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hanserwei.airobot.enums;
|
||||
|
||||
import com.hanserwei.airobot.exception.BaseExceptionInterface;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ResponseCodeEnum implements BaseExceptionInterface {
|
||||
|
||||
// ----------- 通用异常状态码 -----------
|
||||
SYSTEM_ERROR("10000", "出错啦,后台小哥正在努力修复中..."),
|
||||
PARAM_NOT_VALID("10001", "参数错误"),
|
||||
|
||||
|
||||
// ----------- 业务异常状态码 -----------
|
||||
CHAT_NOT_EXISTED("20000", "此对话不存在"),
|
||||
// TODO 待填充
|
||||
;
|
||||
|
||||
// 异常码
|
||||
private final String errorCode;
|
||||
// 错误信息
|
||||
private final String errorMessage;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.hanserwei.airobot.exception;
|
||||
|
||||
public interface BaseExceptionInterface {
|
||||
String getErrorCode();
|
||||
|
||||
String getErrorMessage();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.hanserwei.airobot.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class BizException extends RuntimeException {
|
||||
// 异常码
|
||||
private String errorCode;
|
||||
// 错误信息
|
||||
private String errorMessage;
|
||||
|
||||
public BizException(BaseExceptionInterface baseExceptionInterface) {
|
||||
this.errorCode = baseExceptionInterface.getErrorCode();
|
||||
this.errorMessage = baseExceptionInterface.getErrorMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.hanserwei.airobot.exception;
|
||||
|
||||
import com.hanserwei.airobot.enums.ResponseCodeEnum;
|
||||
import com.hanserwei.airobot.utils.Response;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 全局异常处理器,用于统一处理系统中抛出的各类异常,并返回格式化的错误响应。
|
||||
*/
|
||||
@ControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 捕获自定义业务异常 BizException,并记录警告日志,返回对应的失败响应。
|
||||
*
|
||||
* @param request 当前HTTP请求对象
|
||||
* @param e 抛出的业务异常对象
|
||||
* @return 返回封装后的失败响应对象
|
||||
*/
|
||||
@ExceptionHandler({ BizException.class })
|
||||
@ResponseBody
|
||||
public Response<Object> handleBizException(HttpServletRequest request, BizException e) {
|
||||
log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMessage());
|
||||
return Response.fail(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获参数校验异常 MethodArgumentNotValidException,提取字段校验错误信息并组合成可读性较强的错误描述,
|
||||
* 记录警告日志后返回参数校验失败的响应。
|
||||
*
|
||||
* @param request 当前HTTP请求对象
|
||||
* @param e 参数校验异常对象
|
||||
* @return 返回封装后的参数校验失败响应对象
|
||||
*/
|
||||
@ExceptionHandler({ MethodArgumentNotValidException.class })
|
||||
@ResponseBody
|
||||
public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
|
||||
// 参数错误异常码
|
||||
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
|
||||
|
||||
// 获取 BindingResult
|
||||
BindingResult bindingResult = e.getBindingResult();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
|
||||
Optional.of(bindingResult.getFieldErrors()).ifPresent(errors -> {
|
||||
errors.forEach(error ->
|
||||
sb.append(error.getField())
|
||||
.append(" ")
|
||||
.append(error.getDefaultMessage())
|
||||
.append(", 当前值: '")
|
||||
.append(error.getRejectedValue())
|
||||
.append("'; ")
|
||||
|
||||
);
|
||||
});
|
||||
|
||||
// 错误信息
|
||||
String errorMessage = sb.toString();
|
||||
|
||||
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
|
||||
|
||||
return Response.fail(errorCode, errorMessage);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 捕获其他未被处理的异常类型,记录错误日志并返回系统内部错误的响应。
|
||||
*
|
||||
* @param request 当前HTTP请求对象
|
||||
* @param e 抛出的异常对象
|
||||
* @return 返回封装后的系统错误响应对象
|
||||
*/
|
||||
@ExceptionHandler({ Exception.class })
|
||||
@ResponseBody
|
||||
public Response<Object> handleOtherException(HttpServletRequest request, Exception e) {
|
||||
log.error("{} request error, ", request.getRequestURI(), e);
|
||||
return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.hanserwei.airobot.model.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class BasePageQuery {
|
||||
/**
|
||||
* 当前页码, 默认第一页
|
||||
*/
|
||||
private Long current = 1L;
|
||||
/**
|
||||
* 每页展示的数据数量,默认每页展示 10 条数据
|
||||
*/
|
||||
private Long size = 10L;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hanserwei.airobot.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class SearchResultDTO {
|
||||
|
||||
/**
|
||||
* 页面访问链接
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 相关性评分
|
||||
*/
|
||||
private Double score;
|
||||
|
||||
/**
|
||||
* 页面内容
|
||||
*/
|
||||
private String content;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class AiChatReqVO {
|
||||
|
||||
@NotBlank(message = "用户消息不能为空")
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 对话 ID
|
||||
*/
|
||||
private String chatId;
|
||||
|
||||
/**
|
||||
* 联网搜索
|
||||
*/
|
||||
private Boolean networkSearch = false;
|
||||
|
||||
@NotBlank(message = "调用的 AI 大模型名称不能为空")
|
||||
private String modelName;
|
||||
|
||||
/**
|
||||
* 温度值,默认为 0.7
|
||||
*/
|
||||
private Double temperature = 0.7;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class AiResponse {
|
||||
/**
|
||||
* 响应内容
|
||||
*/
|
||||
private String v;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class DeleteChatReqVO {
|
||||
|
||||
@NotBlank(message = "对话 UUID 不能为空")
|
||||
private String uuid;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import com.hanserwei.airobot.model.common.BasePageQuery;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.*;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindChatHistoryMessagePageListReqVO extends BasePageQuery {
|
||||
|
||||
@NotBlank(message = "对话 ID 不能为空")
|
||||
private String chatId;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindChatHistoryMessagePageListRspVO {
|
||||
/**
|
||||
* 消息 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 对话 ID
|
||||
*/
|
||||
private String chatId;
|
||||
/**
|
||||
* 内容
|
||||
*/
|
||||
private String content;
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private String role;
|
||||
/**
|
||||
* 发布时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import com.hanserwei.airobot.model.common.BasePageQuery;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class FindChatHistoryPageListReqVO extends BasePageQuery {
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindChatHistoryPageListRspVO {
|
||||
/**
|
||||
* 对话 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 对话 UUID
|
||||
*/
|
||||
private String uuid;
|
||||
/**
|
||||
* 对话摘要
|
||||
*/
|
||||
private String summary;
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class NewChatReqVO {
|
||||
|
||||
@NotBlank(message = "用户消息不能为空")
|
||||
private String message;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class NewChatRspVO {
|
||||
/**
|
||||
* 摘要
|
||||
*/
|
||||
private String summary;
|
||||
|
||||
/**
|
||||
* 对话 UUID
|
||||
*/
|
||||
private String uuid;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hanserwei.airobot.model.vo.chat;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class RenameChatReqVO {
|
||||
|
||||
@NotNull(message = "对话 ID 不能为空")
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "对话摘要不能为空")
|
||||
private String summary;
|
||||
|
||||
}
|
||||
48
src/main/java/com/hanserwei/airobot/service/ChatService.java
Normal file
48
src/main/java/com/hanserwei/airobot/service/ChatService.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.hanserwei.airobot.service;
|
||||
|
||||
import com.hanserwei.airobot.model.vo.chat.*;
|
||||
import com.hanserwei.airobot.utils.PageResponse;
|
||||
import com.hanserwei.airobot.utils.Response;
|
||||
|
||||
public interface ChatService {
|
||||
|
||||
/**
|
||||
* 新建对话
|
||||
*
|
||||
* @param newChatReqVO 新建对话请求参数
|
||||
* @return 新建对话结果
|
||||
*/
|
||||
Response<NewChatRspVO> newChat(NewChatReqVO newChatReqVO);
|
||||
|
||||
/**
|
||||
* 查询历史消息
|
||||
*
|
||||
* @param findChatHistoryMessagePageListReqVO 查询历史消息请求参数
|
||||
* @return 查询历史消息结果
|
||||
*/
|
||||
PageResponse<FindChatHistoryMessagePageListRspVO> findChatHistoryMessagePageList(FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO);
|
||||
|
||||
/**
|
||||
* 查询历史对话
|
||||
*
|
||||
* @param findChatHistoryPageListReqVO 查询历史对话请求参数
|
||||
* @return 查询历史对话结果
|
||||
*/
|
||||
PageResponse<FindChatHistoryPageListRspVO> findChatHistoryPageList(FindChatHistoryPageListReqVO findChatHistoryPageListReqVO);
|
||||
|
||||
/**
|
||||
* 重命名对话摘要
|
||||
*
|
||||
* @param renameChatReqVO 重命名对话摘要请求参数
|
||||
* @return 重命名对话摘要结果
|
||||
*/
|
||||
Response<?> renameChatSummary(RenameChatReqVO renameChatReqVO);
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*
|
||||
* @param deleteChatReqVO 删除对话请求参数
|
||||
* @return 删除对话结果
|
||||
*/
|
||||
Response<?> deleteChat(DeleteChatReqVO deleteChatReqVO);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.hanserwei.airobot.service;
|
||||
|
||||
import com.hanserwei.airobot.model.dto.SearchResultDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SearXNGService {
|
||||
List<SearchResultDTO> search(String query);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.hanserwei.airobot.service;
|
||||
|
||||
import com.hanserwei.airobot.model.dto.SearchResultDTO;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public interface SearchResultContentFetcherService {
|
||||
|
||||
CompletableFuture<List<SearchResultDTO>> batchFetch(List<SearchResultDTO> searchResults, long timeout, TimeUnit unit);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.hanserwei.airobot.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hanserwei.airobot.domain.dos.ChatDO;
|
||||
import com.hanserwei.airobot.domain.dos.ChatMessageDO;
|
||||
import com.hanserwei.airobot.domain.mapper.ChatMapper;
|
||||
import com.hanserwei.airobot.domain.mapper.ChatMessageMapper;
|
||||
import com.hanserwei.airobot.enums.ResponseCodeEnum;
|
||||
import com.hanserwei.airobot.exception.BizException;
|
||||
import com.hanserwei.airobot.model.vo.chat.*;
|
||||
import com.hanserwei.airobot.service.ChatService;
|
||||
import com.hanserwei.airobot.utils.PageResponse;
|
||||
import com.hanserwei.airobot.utils.Response;
|
||||
import com.hanserwei.airobot.utils.StringUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ChatServiceImpl implements ChatService {
|
||||
|
||||
@Resource
|
||||
private ChatMapper chatMapper;
|
||||
@Resource
|
||||
private ChatMessageMapper chatMessageMapper;
|
||||
|
||||
@Override
|
||||
public Response<NewChatRspVO> newChat(NewChatReqVO newChatReqVO) {
|
||||
// 用户发来的消息
|
||||
String message = newChatReqVO.getMessage();
|
||||
|
||||
// 生成对话的UUID
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
// 截取用户发送的消息,作为对话的摘要
|
||||
String summary = StringUtil.truncate(message, 20);
|
||||
// 存储对话记录到数据库中
|
||||
chatMapper.insert(ChatDO.builder()
|
||||
.summary(summary)
|
||||
.uuid(uuid)
|
||||
.createTime(LocalDateTime.now())
|
||||
.updateTime(LocalDateTime.now())
|
||||
.build());
|
||||
|
||||
// 将摘要、UUID 返回给前端
|
||||
return Response.success(NewChatRspVO.builder()
|
||||
.uuid(uuid)
|
||||
.summary(summary)
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResponse<FindChatHistoryMessagePageListRspVO> findChatHistoryMessagePageList(FindChatHistoryMessagePageListReqVO findChatHistoryMessagePageListReqVO) {
|
||||
// 获取当前页、以及每页需要展示的数据数量
|
||||
Long current = findChatHistoryMessagePageListReqVO.getCurrent();
|
||||
Long size = findChatHistoryMessagePageListReqVO.getSize();
|
||||
String chatId = findChatHistoryMessagePageListReqVO.getChatId();
|
||||
|
||||
// 执行分页查询
|
||||
Page<ChatMessageDO> chatMessageDOPage = chatMessageMapper.selectPageList(current, size, chatId);
|
||||
|
||||
List<ChatMessageDO> chatMessageDOS = chatMessageDOPage.getRecords();
|
||||
// DO 转 VO
|
||||
List<FindChatHistoryMessagePageListRspVO> vos = null;
|
||||
if (CollUtil.isNotEmpty(chatMessageDOS)) {
|
||||
vos = chatMessageDOS.stream()
|
||||
.map(chatMessageDO -> FindChatHistoryMessagePageListRspVO.builder() // 构建返参 VO 实体类
|
||||
.id(chatMessageDO.getId())
|
||||
.chatId(chatMessageDO.getChatUuid())
|
||||
.content(chatMessageDO.getContent())
|
||||
.role(chatMessageDO.getRole())
|
||||
.createTime(chatMessageDO.getCreateTime())
|
||||
.build())
|
||||
// 升序排序
|
||||
.sorted(Comparator.comparing(FindChatHistoryMessagePageListRspVO::getCreateTime))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return PageResponse.success(chatMessageDOPage, vos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResponse<FindChatHistoryPageListRspVO> findChatHistoryPageList(FindChatHistoryPageListReqVO findChatHistoryPageListReqVO) {
|
||||
// 获取当前页、以及每页需要展示的数据数量
|
||||
Long current = findChatHistoryPageListReqVO.getCurrent();
|
||||
Long size = findChatHistoryPageListReqVO.getSize();
|
||||
|
||||
// 执行分页查询
|
||||
Page<ChatDO> chatDOPage = chatMapper.selectPageList(current, size);
|
||||
|
||||
// 获取查询结果
|
||||
List<ChatDO> chatDOS = chatDOPage.getRecords();
|
||||
|
||||
// DO 转 VO
|
||||
List<FindChatHistoryPageListRspVO> vos = null;
|
||||
if (CollUtil.isNotEmpty(chatDOS)) {
|
||||
vos = chatDOS.stream()
|
||||
.map(chatDO -> FindChatHistoryPageListRspVO.builder() // 构建返参 VO
|
||||
.id(chatDO.getId())
|
||||
.uuid(chatDO.getUuid())
|
||||
.summary(chatDO.getSummary())
|
||||
.updateTime(chatDO.getUpdateTime())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return PageResponse.success(chatDOPage, vos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response<?> renameChatSummary(RenameChatReqVO renameChatReqVO) {
|
||||
// 对话 ID
|
||||
Long chatId = renameChatReqVO.getId();
|
||||
// 摘要
|
||||
String summary = renameChatReqVO.getSummary();
|
||||
|
||||
// 根据主键 ID 更新摘要
|
||||
chatMapper.updateById(ChatDO.builder()
|
||||
.id(chatId)
|
||||
.summary(summary)
|
||||
.build());
|
||||
|
||||
return Response.success();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Response<?> deleteChat(DeleteChatReqVO deleteChatReqVO) {
|
||||
// 对话 UUID
|
||||
String uuid = deleteChatReqVO.getUuid();
|
||||
|
||||
// 删除对话
|
||||
int count = chatMapper.delete(Wrappers.<ChatDO>lambdaQuery()
|
||||
.eq(ChatDO::getUuid, uuid));
|
||||
|
||||
// 如果删除操作影响的行数为 0,说明想要删除的对话不存在
|
||||
if (count == 0) {
|
||||
throw new BizException(ResponseCodeEnum.CHAT_NOT_EXISTED);
|
||||
}
|
||||
|
||||
// 批量删除对话下的所有消息
|
||||
chatMessageMapper.delete(Wrappers.<ChatMessageDO>lambdaQuery()
|
||||
.eq(ChatMessageDO::getChatUuid, uuid));
|
||||
|
||||
return Response.success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hanserwei.airobot.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.hanserwei.airobot.model.dto.SearchResultDTO;
|
||||
import com.hanserwei.airobot.service.SearXNGService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
/**
|
||||
* SearXNG 搜索服务实现类
|
||||
* <p>
|
||||
* 该类通过调用 SearXNG 的 API 实现聚合搜索引擎功能,支持从多个搜索引擎获取结果并按评分排序。
|
||||
* </p>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SearXNGServiceImpl implements SearXNGService {
|
||||
@Resource
|
||||
private OkHttpClient okHttpClient;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Value("${searxng.url}")
|
||||
private String searxngUrl;
|
||||
@Value("${searxng.count}")
|
||||
private int count;
|
||||
|
||||
/**
|
||||
* 根据关键词执行搜索操作
|
||||
*
|
||||
* @param query 搜索关键词,不能为空
|
||||
* @return 搜索结果列表,每个元素包含 URL 和评分;若发生异常或无结果则返回空列表
|
||||
*/
|
||||
@Override
|
||||
public List<SearchResultDTO> search(String query) {
|
||||
// 构建 SearXNG API 请求 URL
|
||||
HttpUrl httpUrl = Objects.requireNonNull(HttpUrl.parse(searxngUrl)).newBuilder()
|
||||
.addQueryParameter("q", query) // 设置搜索关键词
|
||||
.addQueryParameter("format", "json") // 指定返回 JSON 格式
|
||||
.addQueryParameter("engines", "wolframalpha,presearch,seznam,mwmbl,encyclosearch,bpb,mojeek,right dao,wikimini,crowdview,searchmysite,bing,naver,360search") // 指定聚合的目标搜索引擎(配置本地网络能够访问的通的搜索引擎)
|
||||
.build();
|
||||
|
||||
// 创建 HTTP GET 请求
|
||||
Request request = new Request.Builder()
|
||||
.url(httpUrl)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
// 发送 HTTP 请求
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
// 判断请求是否成功
|
||||
if (response.isSuccessful()) {
|
||||
// 拿到返回结果
|
||||
String result = response.body().string();
|
||||
log.info("## SearXNG 搜索结果: {}", result);
|
||||
|
||||
// 解析 JSON 响应
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
JsonNode results = root.get("results"); // 获取结果数组节点
|
||||
|
||||
// 定义 Record 类型:用于临时存储分数和节点引用
|
||||
record NodeWithUrlAndScore(double score, JsonNode node) {
|
||||
}
|
||||
|
||||
// 处理搜索结果流:
|
||||
// 1. 提取评分
|
||||
// 2. 按评分降序排序
|
||||
// 3. 限制返回结果数量
|
||||
List<NodeWithUrlAndScore> nodesWithScore = StreamSupport.stream(results.spliterator(), false)
|
||||
.map(node -> {
|
||||
// 只提取分数,避免构建完整对象
|
||||
double score = node.path("score").asDouble(0.0); // 提取评分
|
||||
return new NodeWithUrlAndScore(score, node);
|
||||
})
|
||||
.sorted(Comparator.comparingDouble(NodeWithUrlAndScore::score).reversed()) // 按评分降序
|
||||
.limit(count) // 限制返回结果数量
|
||||
.toList();
|
||||
|
||||
// 转换为 SearchResult 对象集合
|
||||
return nodesWithScore.stream()
|
||||
.map(n -> {
|
||||
JsonNode node = n.node();
|
||||
String originalUrl = node.path("url").asText(""); // 提取 URL
|
||||
return SearchResultDTO.builder()
|
||||
.url(originalUrl)
|
||||
.score(n.score()) // 保留评分
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
// 返回空集合
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.hanserwei.airobot.service.impl;
|
||||
|
||||
import com.hanserwei.airobot.model.dto.SearchResultDTO;
|
||||
import com.hanserwei.airobot.service.SearchResultContentFetcherService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 搜索结果内容抓取服务实现类
|
||||
* <p>
|
||||
* 提供并发批量获取网页内容的功能,支持超时控制与异常处理。
|
||||
* </p>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SearchResultContentFetcherServiceImpl implements SearchResultContentFetcherService {
|
||||
|
||||
@Resource
|
||||
private OkHttpClient okHttpClient;
|
||||
@Resource(name = "httpRequestExecutor")
|
||||
private ThreadPoolTaskExecutor httpExecutor;
|
||||
@Resource(name = "resultProcessingExecutor")
|
||||
private ThreadPoolTaskExecutor processingExecutor;
|
||||
|
||||
|
||||
/**
|
||||
* 并发批量获取搜索结果页面的内容
|
||||
*
|
||||
* @param searchResults 待抓取的搜索结果列表
|
||||
* @param timeout 超时时间
|
||||
* @param unit 时间单位
|
||||
* @return 包含页面内容的搜索结果列表的 CompletableFuture
|
||||
*/
|
||||
@Override
|
||||
public CompletableFuture<List<SearchResultDTO>> batchFetch(List<SearchResultDTO> searchResults, long timeout, TimeUnit unit) {
|
||||
// 步骤1:为每个搜索结果创建独立的异步获取任务
|
||||
List<CompletableFuture<SearchResultDTO>> futures = searchResults.stream()
|
||||
.map(result -> asynFetchContentForResult(result, timeout, unit))
|
||||
.toList();
|
||||
|
||||
// 步骤2:合并所有独立任务为一个聚合任务
|
||||
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
|
||||
futures.toArray(new CompletableFuture[0])
|
||||
);
|
||||
|
||||
// 步骤3:当所有任务完成后收集结果,并提取纯文本内容
|
||||
return allFutures.thenApplyAsync(v ->
|
||||
futures.stream()
|
||||
.map(future -> {
|
||||
SearchResultDTO searchResult = future.join();
|
||||
// 获取页面 HTML 代码
|
||||
String html = searchResult.getContent();
|
||||
|
||||
if (StringUtils.isNotBlank(html)) {
|
||||
// 提取 HTML 中的文本
|
||||
searchResult.setContent(Jsoup.parse(html).text());
|
||||
}
|
||||
|
||||
return searchResult;
|
||||
})
|
||||
.collect(Collectors.toList()),
|
||||
processingExecutor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步获取单个 SearchResult 对象对应的页面内容
|
||||
*
|
||||
* @param result 搜索结果对象
|
||||
* @param timeout 超时时间
|
||||
* @param unit 时间单位
|
||||
* @return 包含页面内容的 SearchResultDTO 的 CompletableFuture
|
||||
*/
|
||||
private CompletableFuture<SearchResultDTO> asynFetchContentForResult(
|
||||
SearchResultDTO result,
|
||||
long timeout,
|
||||
TimeUnit unit) {
|
||||
|
||||
// 异步执行 HTTP 请求并设置超时及异常处理逻辑
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
// 获取 HTML 内容
|
||||
String html = syncFetchHtmlContent(result.getUrl());
|
||||
|
||||
return SearchResultDTO.builder()
|
||||
.url(result.getUrl())
|
||||
.score(result.getScore())
|
||||
.content(html)
|
||||
.build();
|
||||
|
||||
}, httpExecutor)
|
||||
// 超时处理
|
||||
.completeOnTimeout(createFallbackResult(result), timeout, unit)
|
||||
// 异常处理
|
||||
.exceptionally(e -> {
|
||||
// 记录错误日志
|
||||
log.error("## 获取页面内容异常, URL: {}", result.getUrl(), e);
|
||||
return createFallbackResult(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建回退结果(请求失败时使用)
|
||||
*
|
||||
* @param searchResult 原始搜索结果对象
|
||||
* @return 回退用的 SearchResultDTO 实例,其 content 字段为空字符串
|
||||
*/
|
||||
private SearchResultDTO createFallbackResult(SearchResultDTO searchResult) {
|
||||
return SearchResultDTO.builder()
|
||||
.url(searchResult.getUrl())
|
||||
.score(searchResult.getScore())
|
||||
.content("") // 空字符串表示获取页面内容失败
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步获取指定 URL 的 HTML 内容
|
||||
*
|
||||
* @param url 目标网址
|
||||
* @return 页面 HTML 内容;若发生异常或响应无效则返回空字符串
|
||||
*/
|
||||
private String syncFetchHtmlContent(String url) {
|
||||
// 构建 HTTP GET 请求
|
||||
Request request = new Request.Builder()
|
||||
.url(url) // 设置要访问的目标 URL
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") // 设置浏览器标识,模拟真实浏览器访问
|
||||
.header("Accept", "text/html") // 指定接受 HTML 格式的响应
|
||||
.build();
|
||||
|
||||
try (Response response = okHttpClient.newCall(request).execute()) { // 执行请求并自动关闭响应资源
|
||||
// 检查响应状态和内容
|
||||
if (!response.isSuccessful() || response.body() == null) { // 响应失败或响应体为空
|
||||
return ""; // 返回空字符串
|
||||
}
|
||||
|
||||
// 读取响应体内容并返回
|
||||
return response.body().string();
|
||||
} catch (IOException e) { // 捕获网络 IO 异常
|
||||
return ""; // 异常时返回空字符串
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/main/java/com/hanserwei/airobot/utils/JsonUtil.java
Normal file
124
src/main/java/com/hanserwei/airobot/utils/JsonUtil.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.hanserwei.airobot.utils;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.SneakyThrows;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* JSON 工具类,提供对象与 JSON 字符串之间的相互转换功能。
|
||||
* 支持普通对象、List、Set、Map 等结构的序列化和反序列化。
|
||||
*/
|
||||
public class JsonUtil {
|
||||
|
||||
private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
static {
|
||||
// 忽略未知属性,防止反序列化失败
|
||||
OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
// 允许序列化空对象
|
||||
OBJECT_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
|
||||
// 注册 JavaTimeModule 以支持 LocalDateTime 的序列化
|
||||
OBJECT_MAPPER.registerModules(new JavaTimeModule());
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:统一使用 Spring Boot 个性化配置的 ObjectMapper
|
||||
*
|
||||
* @param objectMapper 外部传入的 ObjectMapper 实例
|
||||
*/
|
||||
public static void init(ObjectMapper objectMapper) {
|
||||
OBJECT_MAPPER = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象转换为 JSON 字符串
|
||||
*
|
||||
* @param obj 待转换的对象
|
||||
* @return 转换后的 JSON 字符串
|
||||
*/
|
||||
@SneakyThrows
|
||||
public static String toJsonString(Object obj) {
|
||||
return OBJECT_MAPPER.writeValueAsString(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为指定类型的对象
|
||||
*
|
||||
* @param jsonStr JSON 字符串
|
||||
* @param clazz 目标对象的类类型
|
||||
* @param <T> 泛型参数,表示目标对象的类型
|
||||
* @return 转换后的对象实例,如果输入为空则返回 null
|
||||
*/
|
||||
@SneakyThrows
|
||||
public static <T> T parseObject(String jsonStr, Class<T> clazz) {
|
||||
if (StringUtils.isBlank(jsonStr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OBJECT_MAPPER.readValue(jsonStr, clazz);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为指定键值类型的 Map 对象
|
||||
*
|
||||
* @param jsonStr JSON 字符串
|
||||
* @param keyClass Map 键的类型
|
||||
* @param valueClass Map 值的类型
|
||||
* @param <K> 泛型参数,表示 Map 键的类型
|
||||
* @param <V> 泛型参数,表示 Map 值的类型
|
||||
* @return 转换后的 Map 实例
|
||||
* @throws Exception 当解析失败时抛出异常
|
||||
*/
|
||||
public static <K, V> Map<K, V> parseMap(String jsonStr, Class<K> keyClass, Class<V> valueClass) throws Exception {
|
||||
// 构造 Map 类型并进行反序列化
|
||||
return OBJECT_MAPPER.readValue(jsonStr, OBJECT_MAPPER.getTypeFactory().constructMapType(Map.class, keyClass, valueClass));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串解析为指定元素类型的 List 对象
|
||||
*
|
||||
* @param jsonStr JSON 字符串
|
||||
* @param clazz List 中元素的类型
|
||||
* @param <T> 泛型参数,表示 List 元素的类型
|
||||
* @return 转换后的 List 实例
|
||||
* @throws Exception 当解析失败时抛出异常
|
||||
*/
|
||||
public static <T> List<T> parseList(String jsonStr, Class<T> clazz) throws Exception {
|
||||
// 构造 List 类型并进行反序列化
|
||||
return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<List<T>>() {
|
||||
@Override
|
||||
public CollectionType getType() {
|
||||
return OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, clazz);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串解析为指定元素类型的 Set 对象
|
||||
*
|
||||
* @param jsonStr JSON 字符串
|
||||
* @param clazz Set 中元素的类型
|
||||
* @param <T> 泛型参数,表示 Set 元素的类型
|
||||
* @return 转换后的 Set 实例
|
||||
* @throws Exception 当解析失败时抛出异常
|
||||
*/
|
||||
public static <T> Set<T> parseSet(String jsonStr, Class<T> clazz) throws Exception {
|
||||
// 构造 Set 类型并进行反序列化
|
||||
return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<>() {
|
||||
@Override
|
||||
public CollectionType getType() {
|
||||
return OBJECT_MAPPER.getTypeFactory().constructCollectionType(Set.class, clazz);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
74
src/main/java/com/hanserwei/airobot/utils/PageResponse.java
Normal file
74
src/main/java/com/hanserwei/airobot/utils/PageResponse.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package com.hanserwei.airobot.utils;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class PageResponse<T> extends Response<List<T>> {
|
||||
|
||||
/**
|
||||
* 总记录数
|
||||
*/
|
||||
private long total = 0L;
|
||||
|
||||
/**
|
||||
* 每页显示的记录数,默认每页显示 10 条
|
||||
*/
|
||||
private long size = 10L;
|
||||
|
||||
/**
|
||||
* 当前页码
|
||||
*/
|
||||
private long current;
|
||||
|
||||
/**
|
||||
* 总页数
|
||||
*/
|
||||
private long pages;
|
||||
|
||||
/**
|
||||
* 创建成功的分页响应对象
|
||||
* @param page Mybatis Plus 提供的分页接口
|
||||
* @param data 分页数据列表
|
||||
* @return 包含分页信息的成功响应对象
|
||||
* @param <T> 数据类型泛型参数
|
||||
*/
|
||||
public static <T> PageResponse<T> success(IPage page, List<T> data) {
|
||||
PageResponse<T> response = new PageResponse<>();
|
||||
response.setSuccess(true);
|
||||
response.setCurrent(Objects.isNull(page) ? 1L : page.getCurrent());
|
||||
response.setSize(Objects.isNull(page) ? 10L : page.getSize());
|
||||
response.setPages(Objects.isNull(page) ? 0L : page.getPages());
|
||||
response.setTotal(Objects.isNull(page) ? 0L : page.getTotal());
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功的分页响应对象
|
||||
* @param total 总记录数
|
||||
* @param current 当前页码
|
||||
* @param size 每页显示的记录数
|
||||
* @param data 分页数据列表
|
||||
* @return 包含分页信息的成功响应对象
|
||||
* @param <T> 数据类型泛型参数
|
||||
*/
|
||||
public static <T> PageResponse<T> success(long total, long current, long size, List<T> data) {
|
||||
PageResponse<T> response = new PageResponse<>();
|
||||
response.setSuccess(true);
|
||||
response.setCurrent(current);
|
||||
response.setSize(size);
|
||||
// 计算总页数
|
||||
int pages = (int) Math.ceil((double) total / size);
|
||||
response.setPages(pages);
|
||||
response.setTotal(total);
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
|
||||
}
|
||||
70
src/main/java/com/hanserwei/airobot/utils/Response.java
Normal file
70
src/main/java/com/hanserwei/airobot/utils/Response.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.hanserwei.airobot.utils;
|
||||
|
||||
import com.hanserwei.airobot.exception.BaseExceptionInterface;
|
||||
import com.hanserwei.airobot.exception.BizException;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Data
|
||||
public class Response<T> implements Serializable {
|
||||
|
||||
// 是否成功,默认为 true
|
||||
private boolean success = true;
|
||||
// 响应消息
|
||||
private String message;
|
||||
// 异常码
|
||||
private String errorCode;
|
||||
// 响应数据
|
||||
private T data;
|
||||
|
||||
// =================================== 成功响应 ===================================
|
||||
public static <T> Response<T> success() {
|
||||
return new Response<>();
|
||||
}
|
||||
|
||||
public static <T> Response<T> success(T data) {
|
||||
Response<T> response = new Response<>();
|
||||
response.setData(data);
|
||||
return response;
|
||||
}
|
||||
|
||||
// =================================== 失败响应 ===================================
|
||||
public static <T> Response<T> fail() {
|
||||
Response<T> response = new Response<>();
|
||||
response.setSuccess(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> Response<T> fail(String errorMessage) {
|
||||
Response<T> response = new Response<>();
|
||||
response.setSuccess(false);
|
||||
response.setMessage(errorMessage);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> Response<T> fail(String errorCode, String errorMessage) {
|
||||
Response<T> response = new Response<>();
|
||||
response.setSuccess(false);
|
||||
response.setErrorCode(errorCode);
|
||||
response.setMessage(errorMessage);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> Response<T> fail(BizException bizException) {
|
||||
Response<T> response = new Response<>();
|
||||
response.setSuccess(false);
|
||||
response.setErrorCode(bizException.getErrorCode());
|
||||
response.setMessage(bizException.getErrorMessage());
|
||||
return response;
|
||||
}
|
||||
|
||||
public static <T> Response<T> fail(BaseExceptionInterface baseExceptionInterface) {
|
||||
Response<T> response = new Response<>();
|
||||
response.setSuccess(false);
|
||||
response.setErrorCode(baseExceptionInterface.getErrorCode());
|
||||
response.setMessage(baseExceptionInterface.getErrorMessage());
|
||||
return response;
|
||||
}
|
||||
|
||||
}
|
||||
30
src/main/java/com/hanserwei/airobot/utils/StringUtil.java
Normal file
30
src/main/java/com/hanserwei/airobot/utils/StringUtil.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.hanserwei.airobot.utils;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public class StringUtil {
|
||||
|
||||
/**
|
||||
* 截取用户问题的前面部分文字作为摘要
|
||||
*
|
||||
* @param message 用户问题
|
||||
* @param maxLength 最大截取长度
|
||||
* @return 摘要文本,如果原问题长度不足则返回原问题
|
||||
*/
|
||||
public static String truncate(String message, int maxLength) {
|
||||
// 判空
|
||||
if (StringUtils.isBlank(message)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String trimmed = message.trim();
|
||||
|
||||
// 如果文本长度小于等于最大长度,直接返回
|
||||
if (trimmed.length() <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// 截取指定长度
|
||||
return trimmed.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,54 @@
|
||||
#file: noinspection SpringBootConfigYamlInspection
|
||||
spring:
|
||||
application:
|
||||
name: han-ai-robot-springboot
|
||||
name: han-ai-robot-springboot
|
||||
datasource:
|
||||
driver-class-name: com.p6spy.engine.spy.P6SpyDriver # 数据库驱动类名
|
||||
url: jdbc:p6spy:postgresql://localhost:5432/han_ai_robot # 数据库连接 URL
|
||||
username: postgres # 数据库用户名
|
||||
password: postgressql # 数据库密码
|
||||
hikari: # HikariCP 连接池配置
|
||||
pool-name: AI-Robot-HikariCP # 自定义连接池名称
|
||||
auto-commit: true # 是否自动提交事务
|
||||
connection-timeout: 30000 # 连接超时时间(毫秒)
|
||||
idle-timeout: 600000 # 空闲连接存活最大时间(毫秒)
|
||||
max-lifetime: 1800000 # 连接最大存活时间(毫秒)
|
||||
minimum-idle: 5 # 最小空闲连接数
|
||||
maximum-pool-size: 20 # 最大连接池大小
|
||||
connection-test-query: SELECT 1 # 连接测试查询
|
||||
validation-timeout: 5000 # 验证连接的有效性
|
||||
ai:
|
||||
dashscope:
|
||||
api-key: ENC(cMgcKZkFllyE88DIbGwLKot9Vg02co+gsmY8L8o4/o3UjhcmqO4lJzFU35Sx0n+qFG8pDL0wBjoWrT8X6BuRw9vNlQhY1LgRWHaF9S1zzyM=)
|
||||
chat:
|
||||
options:
|
||||
model: qwen-plus
|
||||
temperature: 0.5
|
||||
chat:
|
||||
memory:
|
||||
repository:
|
||||
cassandra:
|
||||
keyspace: han_ai_robot
|
||||
table: t_ai_chat_memory
|
||||
time-to-live: 1095d
|
||||
initialize-schema: true
|
||||
okhttp: # OkHttp 客户端配置
|
||||
connect-timeout: 5000 # 建立连接的最大等待时间(毫秒)
|
||||
read-timeout: 30000 # 读取数据的最大等待时间(毫秒)
|
||||
write-timeout: 15000 # 写入数据的最大等待时间(毫秒)
|
||||
max-idle-connections: 200 # 连接池中保持的最大空闲连接数
|
||||
keep-alive-duration: 5 # 空闲连接在连接池中的存活时间(分钟)
|
||||
|
||||
searxng: # SearXNG 搜索引擎配置
|
||||
url: http://localhost:8888/search # SearXNG 服务的 API 端点地址
|
||||
count: 10 # 每次从搜索结果中,提取的最大数量
|
||||
|
||||
jasypt:
|
||||
encryptor:
|
||||
password: ${jasypt.encryptor.password}
|
||||
algorithm: PBEWithHMACSHA512AndAES_256
|
||||
iv-generator-classname: org.jasypt.iv.RandomIvGenerator
|
||||
logging:
|
||||
level:
|
||||
org.springframework.ai.chat.client.advisor: debug
|
||||
config: classpath:log4j2.xml # 设置日志配置文件路径
|
||||
94
src/main/resources/log4j2.xml
Normal file
94
src/main/resources/log4j2.xml
Normal file
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
status: 设置 Log4j2 内部日志的级别,可选值: OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL
|
||||
monitorInterval: 配置自动检测间隔(秒),在此时间后检查配置文件是否修改并自动重新加载,无需重启应用
|
||||
-->
|
||||
<Configuration status="WARN" monitorInterval="30">
|
||||
<!-- 定义全局属性 -->
|
||||
<Properties>
|
||||
<!-- 日志输出格式模式 -->
|
||||
<Property name="LOG_PATTERN">%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Property>
|
||||
<!-- 日志文件存储根目录 -->
|
||||
<Property name="APP_LOG_ROOT">./logs</Property>
|
||||
<!-- 使用日期变量作为日志文件名的一部分 -->
|
||||
<Property name="LOG_DATE_PATTERN">%d{yyyy-MM-dd}</Property>
|
||||
</Properties>
|
||||
|
||||
<Appenders>
|
||||
<!-- 控制台输出 -->
|
||||
<Console name="Console" target="SYSTEM_OUT">
|
||||
<!-- 使用预定义的日志格式 -->
|
||||
<PatternLayout pattern="${LOG_PATTERN}"/>
|
||||
</Console>
|
||||
|
||||
<!--
|
||||
滚动文件附加器
|
||||
fileName: 当前正在写入的日志文件路径和名称
|
||||
filePattern: 滚动(归档)日志文件的命名模式
|
||||
-->
|
||||
<RollingFile name="FileAppender"
|
||||
fileName="${APP_LOG_ROOT}/application-${date:yyyy-MM-dd}.log"
|
||||
filePattern="${APP_LOG_ROOT}/application-%d{yyyy-MM-dd}-%i.log">
|
||||
<PatternLayout pattern="${LOG_PATTERN}"/>
|
||||
<Policies>
|
||||
<!-- 基于时间的滚动策略: 每天滚动一次 -->
|
||||
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
|
||||
<!-- 基于文件大小的滚动策略: 当日志文件达到 10MB 时滚动 -->
|
||||
<SizeBasedTriggeringPolicy size="10 MB"/>
|
||||
</Policies>
|
||||
<!--
|
||||
滚动策略配置: 自动删除旧日志文件
|
||||
maxDepth: 搜索深度,1 表示只搜索当前目录
|
||||
-->
|
||||
<DefaultRolloverStrategy>
|
||||
<Delete basePath="${APP_LOG_ROOT}" maxDepth="1">
|
||||
<!-- 匹配文件名模式 -->
|
||||
<IfFileName glob="application-*.log" />
|
||||
<!-- 保留最近 30 天的日志文件 -->
|
||||
<IfLastModified age="30d" />
|
||||
</Delete>
|
||||
</DefaultRolloverStrategy>
|
||||
</RollingFile>
|
||||
|
||||
<!--
|
||||
错误专用日志附加器 - 只记录 ERROR 级别及以上的日志
|
||||
-->
|
||||
<RollingFile name="ErrorAppender"
|
||||
fileName="${APP_LOG_ROOT}/error-${date:yyyy-MM-dd}.log"
|
||||
filePattern="${APP_LOG_ROOT}/error-%d{yyyy-MM-dd}-%i.log">
|
||||
<PatternLayout pattern="${LOG_PATTERN}"/>
|
||||
<!-- Filters: 过滤器配置,只接受 ERROR 及以上级别的日志 -->
|
||||
<Filters>
|
||||
<ThresholdFilter level="ERROR" onMatch="ACCEPT" onMismatch="DENY"/>
|
||||
</Filters>
|
||||
<Policies>
|
||||
<TimeBasedTriggeringPolicy interval="1" modulate="true"/>
|
||||
<SizeBasedTriggeringPolicy size="10 MB"/>
|
||||
</Policies>
|
||||
<!-- 滚动策略: 最多保留 30 个归档文件 -->
|
||||
<DefaultRolloverStrategy max="30"/>
|
||||
</RollingFile>
|
||||
</Appenders>
|
||||
|
||||
<Loggers>
|
||||
<!--
|
||||
根日志记录器: 所有日志记录的默认配置
|
||||
level: 日志级别阈值,只有等于或高于此级别的日志才会被处理
|
||||
-->
|
||||
<Root level="info">
|
||||
<!-- 引用控制台附加器 -->
|
||||
<AppenderRef ref="Console"/>
|
||||
<!-- 引用滚动文件附加器 -->
|
||||
<AppenderRef ref="FileAppender"/>
|
||||
<!-- 引用错误专用日志附加器 -->
|
||||
<AppenderRef ref="ErrorAppender"/>
|
||||
</Root>
|
||||
|
||||
<!-- 设置特定包的日志级别(未来备用) -->
|
||||
<Logger name="com.example.demo" level="debug" additivity="false">
|
||||
<AppenderRef ref="Console"/>
|
||||
<AppenderRef ref="FileAppender"/>
|
||||
<AppenderRef ref="ErrorAppender"/>
|
||||
</Logger>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
33
src/main/resources/spy.properties
Normal file
33
src/main/resources/spy.properties
Normal file
@@ -0,0 +1,33 @@
|
||||
# 模块列表,根据版本选择合适的配置
|
||||
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
|
||||
|
||||
# 自定义日志格式
|
||||
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
|
||||
|
||||
# 日志输出到控制台
|
||||
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
|
||||
|
||||
# 取消JDBC驱动注册
|
||||
deregisterdrivers=true
|
||||
|
||||
# 使用前缀
|
||||
useprefix=true
|
||||
|
||||
# 排除的日志类别
|
||||
excludecategories=info,debug,result,commit,resultset
|
||||
|
||||
# 日期格式
|
||||
dateformat=yyyy-MM-dd HH:mm:ss
|
||||
|
||||
# 实际驱动列表
|
||||
# driverlist=org.h2.Driver
|
||||
|
||||
# 开启慢SQL记录
|
||||
outagedetection=true
|
||||
|
||||
# 慢SQL记录标准(单位:秒)
|
||||
outagedetectioninterval=2
|
||||
|
||||
# 过滤 flw_ 开头的表 SQL 打印
|
||||
filter=true
|
||||
exclude=flw_*
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.hanserwei.airobot;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class HanAiRobotSpringbootApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
31
src/test/java/com/hanserwei/airobot/MybatisPlusTests.java
Normal file
31
src/test/java/com/hanserwei/airobot/MybatisPlusTests.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.hanserwei.airobot;
|
||||
|
||||
import com.hanserwei.airobot.domain.dos.ChatDO;
|
||||
import com.hanserwei.airobot.domain.mapper.ChatMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@SpringBootTest
|
||||
class MybatisPlusTests {
|
||||
|
||||
@Resource
|
||||
private ChatMapper chatMapper;
|
||||
|
||||
/**
|
||||
* 添加数据
|
||||
*/
|
||||
@Test
|
||||
void testInsert() {
|
||||
chatMapper.insert(ChatDO.builder()
|
||||
.uuid(UUID.randomUUID().toString())
|
||||
.summary("新对话")
|
||||
.createTime(LocalDateTime.now())
|
||||
.updateTime(LocalDateTime.now())
|
||||
.build());
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user