Compare commits

..

3 Commits

Author SHA1 Message Date
409c29c1c2 feat(ai): 支持图片上传与COS存储
- 新增图片上传功能,支持PNG、JPEG等常见格式
- 集成腾讯云COS对象存储服务,实现文件云端存储
-优化文档上传逻辑,图片文件不再进行向量化处理
- 升级DashScope模型配置,启用多模态支持
- 移除废弃的SaveDocumentsTools工具类
- 添加hutool和腾讯云COS SDK依赖
- 调整文件上传大小限制,支持更大文件上传
-修复部分空指针异常问题,增强代码健壮性
2025-11-01 11:09:46 +08:00
a9fce282ed feat(document): 实现多格式文档上传与解析功能
- 移除 AiChatController 中的 PDF 读取相关逻辑与依赖- 新增 DocumentController 支持文件上传接口
- 新增 DocumentIngestionService 接口及实现,负责文档处理流程
- 抽象 DocumentParser 接口统一各类文档解析器行为
- 重构所有具体文档读取器(PDF、HTML、JSON 等)实现新的解析接口- 引入 MultipartFileResource 工具类以适配 Spring AI 读取器
- 添加 DocumentUploadResponse 响应模型类
- 各文档读取器增加对文件扩展名和 MIME 类型的支持判断
2025-10-31 21:31:44 +08:00
5ee2a0f11c refactor(chat):重构AI助手功能并集成文档读取能力
- 移除原有的手机号识别与消息发送逻辑
- 删除RabbitMQ和邮件相关配置及代码
- 引入PDF、HTML、JSON等多种文档读取器
- 集成向量存储与检索功能支持问答
- 更新Spring AI依赖并调整内存存储方式
- 添加新的工具类用于保存文档到向量库- 修改提示词模板去除强制附加句规则
- 调整Cassandra和PgVector相关配置项- 新增多种文件格式读取组件实现类
2025-10-31 20:48:28 +08:00
26 changed files with 681 additions and 359 deletions

60
pom.xml
View File

@@ -58,14 +58,6 @@
<artifactId>jasypt-spring-boot-starter</artifactId> <artifactId>jasypt-spring-boot-starter</artifactId>
<version>${jasypt-starter-version}</version> <version>${jasypt-starter-version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
@@ -83,18 +75,58 @@
<groupId>com.fasterxml.jackson.datatype</groupId> <groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId> <artifactId>jackson-datatype-jsr310</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-boot-starter-mail</artifactId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency> </dependency>
<!-- 向量 Advisor -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
<!-- Cassandra -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-cassandra</artifactId>
</dependency>
<!-- 读取 Markdown -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
<!-- 读取 HTML -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-jsoup-document-reader</artifactId>
</dependency>
<!-- 读取 PDF -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- Tika -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<!--hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.40</version>
</dependency>
<!-- 腾讯云 OSS -->
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.227</version>
</dependency>
</dependencies> </dependencies>

View File

@@ -1,15 +1,18 @@
package com.hanserwei.chat.config; package com.hanserwei.chat.config;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel; import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.memory.redis.BaseRedisChatMemoryRepository; import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.memory.redis.LettuceRedisChatMemoryRepository;
import com.hanserwei.chat.tools.AiDBTools; import com.hanserwei.chat.tools.AiDBTools;
import com.hanserwei.chat.tools.SendMQMessageTools;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepository;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -17,49 +20,45 @@ import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
public class ChatClientConfiguration { public class ChatClientConfiguration {
@Value("${spring.ai.memory.redis.host}")
private String redisHost;
@Value("${spring.ai.memory.redis.port}")
private int redisPort;
@Value("${spring.ai.memory.redis.password}")
private String redisPassword;
@Value("${spring.ai.memory.redis.timeout}")
private int redisTimeout;
@Resource @Resource
private DashScopeChatModel dashScopeChatModel; private DashScopeChatModel dashScopeChatModel;
@Resource @Resource
private AiDBTools aiDBTools; private AiDBTools aiDBTools;
@Resource
private DashScopeEmbeddingModel dashScopeEmbeddingModel;
@Resource
private CassandraChatMemoryRepository chatMemoryRepository;
@Resource
private VectorStore vectorStore;
@Value("classpath:prompt/aiAssistant.st") @Value("classpath:prompt/aiAssistant.st")
private org.springframework.core.io.Resource aiAssistantResource; private org.springframework.core.io.Resource aiAssistantResource;
@Bean @Bean
public BaseRedisChatMemoryRepository redisChatMemoryRepository() { public ChatMemory chatMemory() {
// 构建RedissonRedisChatMemoryRepository实例 return MessageWindowChatMemory.builder()
return LettuceRedisChatMemoryRepository.builder() .maxMessages(50)
.host(redisHost) .chatMemoryRepository(chatMemoryRepository)
.port(redisPort)
.password(redisPassword)
.timeout(redisTimeout)
.build(); .build();
} }
@Bean @Bean
public ChatMemory chatMemory(BaseRedisChatMemoryRepository chatMemoryRepository) { public SimpleVectorStore simpleVectorStore() {
return MessageWindowChatMemory return SimpleVectorStore.builder(dashScopeEmbeddingModel)
.builder() .build();
.maxMessages(100000)
.chatMemoryRepository(chatMemoryRepository).build();
} }
@Bean @Bean
public ChatClient dashScopeChatClient(ChatMemory chatMemory, SendMQMessageTools sendMQMessageTools) { public ChatClient dashScopeChatClient(ChatMemory chatMemory
) {
PromptChatMemoryAdvisor chatMemoryAdvisor = PromptChatMemoryAdvisor.builder(chatMemory).build();
SimpleLoggerAdvisor simpleLoggerAdvisor = new SimpleLoggerAdvisor();
QuestionAnswerAdvisor questionAnswerAdvisor = new QuestionAnswerAdvisor(vectorStore);
return ChatClient.builder(dashScopeChatModel) return ChatClient.builder(dashScopeChatModel)
.defaultTools(aiDBTools, sendMQMessageTools) .defaultTools(aiDBTools)
.defaultSystem(aiAssistantResource) .defaultSystem(aiAssistantResource)
.defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build()) .defaultAdvisors(chatMemoryAdvisor,simpleLoggerAdvisor,questionAnswerAdvisor)
.build(); .build();
} }
} }

View File

@@ -1,37 +0,0 @@
package com.hanserwei.chat.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Bean
public DirectExchange directExchange() {
return new DirectExchange("chat.exchange");
}
@Bean
public Queue queue() {
return new Queue("chat.queue");
}
@Bean
public Binding binding() {
return BindingBuilder.bind(queue())
.to(directExchange())
.with("chat.routing.key");
}
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
}

View File

@@ -1,30 +0,0 @@
package com.hanserwei.chat.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置 RedisTemplate 的连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}

View File

@@ -0,0 +1,48 @@
package com.hanserwei.chat.config.cos;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.endpoint.EndpointBuilder;
import com.qcloud.cos.region.Region;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CosConfig {
@Resource
private CosProperties cosProperties;
@Bean
public COSClient cosClient() {
// 1. 初始化用户身份信息SecretId, SecretKey
COSCredentials cred = new BasicCOSCredentials(
cosProperties.getSecretId(),
cosProperties.getSecretKey()
);
// 2. 设置 bucket 的地域
Region region = new Region(cosProperties.getRegion());
ClientConfig clientConfig = new ClientConfig(region);
if (cosProperties.getEndpoint() != null && !cosProperties.getEndpoint().isEmpty()) {
clientConfig.setEndpointBuilder(new EndpointBuilder() {
@Override
public String buildGeneralApiEndpoint(String bucketName) {
// 所有 API 请求都会使用自定义域名
return cosProperties.getEndpoint();
}
@Override
public String buildGetServiceApiEndpoint() {
return cosProperties.getEndpoint();
}
});
}
// 3. 构建 COSClient
return new COSClient(cred, clientConfig);
}
}

View File

@@ -0,0 +1,16 @@
package com.hanserwei.chat.config.cos;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@Data
@ConfigurationProperties(prefix = "storage.cos")
public class CosProperties {
private String endpoint;
private String secretId;
private String secretKey;
private String appId;
private String region;
}

View File

@@ -1,47 +0,0 @@
package com.hanserwei.chat.consumer;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component
public class RabbitMQConsumer {
@Resource
private JavaMailSender mailSender;
@RabbitListener(queues = "chat.queue")
public void receiveMessage(Object message) {
String messageText = extractMessage(message);
log.info("Received message text:\n{}", messageText);
SimpleMailMessage mailMessage = new SimpleMailMessage();
// 配置发送者邮箱
mailMessage.setFrom("2628273921@qq.com");
// 配置接受者邮箱
mailMessage.setTo("ssw010723@gmail.com");
// 配置邮件主题
mailMessage.setSubject("主题:及时联系客户");
// 配置邮件内容
mailMessage.setText(messageText);
// 发送邮件
mailSender.send(mailMessage);
}
private String extractMessage(Object message) {
return switch (message) {
case null -> "";
case String str -> str;
case byte[] body -> new String(body, StandardCharsets.UTF_8);
case Message amqpMessage -> new String(amqpMessage.getBody(), StandardCharsets.UTF_8);
default -> String.valueOf(message);
};
}
}

View File

@@ -2,21 +2,25 @@ package com.hanserwei.chat.controller;
import com.hanserwei.chat.model.dto.ChatMessageDTO; import com.hanserwei.chat.model.dto.ChatMessageDTO;
import com.hanserwei.chat.model.vo.AIResponse; import com.hanserwei.chat.model.vo.AIResponse;
import com.hanserwei.chat.tools.SendMQMessageTools;
import com.hanserwei.chat.utils.ConversationContext; import com.hanserwei.chat.utils.ConversationContext;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.util.regex.Matcher; import java.net.URI;
import java.util.regex.Pattern; import java.util.List;
import java.util.Objects;
@Slf4j @Slf4j
@RestController @RestController
@@ -25,19 +29,25 @@ public class AiChatController {
@Resource @Resource
private ChatClient dashScopeChatClient; private ChatClient dashScopeChatClient;
@Resource
private SendMQMessageTools sendMQMessageTools;
private static final Pattern PHONE_PATTERN = Pattern.compile("(?<!\\d)(1[3-9]\\d{9})(?!\\d)");
@PostMapping(path = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(path = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<AIResponse> chatWithAi(@RequestBody ChatMessageDTO chatMessageDTO) { public Flux<AIResponse> chatWithAi(@RequestBody ChatMessageDTO chatMessageDTO) {
log.info("会话ID{}", chatMessageDTO.getConversionId()); log.info("会话ID{}", chatMessageDTO.getConversionId());
ConversationContext.setConversationId(chatMessageDTO.getConversionId()); ConversationContext.setConversationId(chatMessageDTO.getConversionId());
triggerSendMessageIfPhonePresent(chatMessageDTO); Media media;
UserMessage userMessage;
return dashScopeChatClient.prompt() if (Objects.nonNull(chatMessageDTO.getImage())) {
.user(chatMessageDTO.getMessage()) media = new Media(MimeTypeUtils.IMAGE_PNG, URI.create(chatMessageDTO.getImage()));
userMessage = UserMessage.builder()
.media(media)
.text(chatMessageDTO.getMessage())
.build();
} else {
userMessage = UserMessage.builder()
.text(chatMessageDTO.getMessage())
.build();
}
return dashScopeChatClient.prompt(new Prompt(List.of(userMessage)))
.advisors(p -> p.param(ChatMemory.CONVERSATION_ID, chatMessageDTO.getConversionId())) .advisors(p -> p.param(ChatMemory.CONVERSATION_ID, chatMessageDTO.getConversionId()))
.stream() .stream()
.chatResponse() .chatResponse()
@@ -47,22 +57,4 @@ public class AiChatController {
.contextWrite(ctx -> ConversationContext.withConversationId(chatMessageDTO.getConversionId())) .contextWrite(ctx -> ConversationContext.withConversationId(chatMessageDTO.getConversionId()))
.doFinally(signalType -> ConversationContext.clear()); .doFinally(signalType -> ConversationContext.clear());
} }
private void triggerSendMessageIfPhonePresent(ChatMessageDTO chatMessageDTO) {
String message = chatMessageDTO.getMessage();
if (message == null || message.isEmpty()) {
return;
}
Matcher matcher = PHONE_PATTERN.matcher(message);
if (!matcher.find()) {
return;
}
String phoneNumber = matcher.group(1);
log.info("检测到手机号:{}会话ID{}", phoneNumber, chatMessageDTO.getConversionId());
sendMQMessageTools.sendMQMessage(phoneNumber)
.doOnError(error -> log.error("触发发送消息工具失败,手机号:{},错误:{}", phoneNumber, error.getMessage(), error))
.subscribe(result -> log.info("已触发发送消息工具,手机号:{},结果:{}", phoneNumber, result));
}
} }

View File

@@ -0,0 +1,29 @@
package com.hanserwei.chat.controller;
import com.hanserwei.chat.model.vo.DocumentUploadResponse;
import com.hanserwei.chat.service.DocumentIngestionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@RestController
@RequestMapping("/documents")
@RequiredArgsConstructor
public class DocumentController {
private final DocumentIngestionService documentIngestionService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<DocumentUploadResponse> upload(@RequestParam("file") MultipartFile file) {
String result = documentIngestionService.ingest(file);
log.info("文件 {} 上传成功。", file.getOriginalFilename());
return ResponseEntity.ok(new DocumentUploadResponse(file.getOriginalFilename(), result));
}
}

View File

@@ -5,6 +5,8 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
@@ -13,5 +15,7 @@ public class ChatMessageDTO {
private String message; private String message;
private String image;
private Long conversionId; private Long conversionId;
} }

View File

@@ -0,0 +1,4 @@
package com.hanserwei.chat.model.vo;
public record DocumentUploadResponse(String filename, String message) {
}

View File

@@ -1,29 +0,0 @@
package com.hanserwei.chat.publisher;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class RabbitMQPublisher {
@Resource
private RabbitTemplate rabbitTemplate;
/**
* 发送消息
*
* @param exchange 交换机
* @param routingKey 路由键
* @param message 消息
*/
public void send(String exchange, String routingKey, Object message) {
try {
rabbitTemplate.convertAndSend(exchange, routingKey, message);
} catch (Exception e) {
log.error("RabbitMQ发送消息失败{}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,59 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.lang.Nullable;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 将上传文件转换为Spring AI {@link Document}实例的策略接口。
*/
public interface DocumentParser {
/**
* 判断此解析器是否可以处理提供的文件。
*
* @param filename 客户端提供的原始文件名(可能为{@code null}
* @param contentType 从上传中派生的MIME类型可能为{@code null}
* @return 如果此解析器应该处理该文件则返回{@code true}
*/
boolean supports(@Nullable String filename, @Nullable String contentType);
/**
* 将文件解析为Spring AI文档。
*
* @param file 多部分上传载荷
* @return 解析后的文档列表
*/
List<Document> parse(MultipartFile file);
default boolean hasExtension(@Nullable String filename, String... extensions) {
if (filename == null) {
return false;
}
int index = filename.lastIndexOf('.');
if (index < 0 || index == filename.length() - 1) {
return false;
}
String actualExtension = filename.substring(index + 1);
for (String extension : extensions) {
if (actualExtension.equalsIgnoreCase(extension)) {
return true;
}
}
return false;
}
default boolean matchesContentType(@Nullable String contentType, String... supportedContentTypes) {
if (contentType == null) {
return false;
}
for (String supportedContentType : supportedContentTypes) {
if (contentType.equalsIgnoreCase(supportedContentType)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,36 @@
package com.hanserwei.chat.reader;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Objects;
/**
* 简单的 {@link ByteArrayResource},用于保留多部分文件的文件名。
*/
final class MultipartFileResource extends ByteArrayResource {
private final String filename;
private MultipartFileResource(byte[] byteArray, String filename) {
super(byteArray);
this.filename = filename;
}
static MultipartFileResource of(MultipartFile file) {
try {
String originalFilename = Objects.requireNonNullElse(file.getOriginalFilename(), "upload");
return new MultipartFileResource(file.getBytes(), originalFilename);
}
catch (IOException ex) {
throw new UncheckedIOException("读取多部分文件内容失败", ex);
}
}
@Override
public String getFilename() {
return filename;
}
}

View File

@@ -0,0 +1,38 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.jsoup.JsoupDocumentReader;
import org.springframework.ai.reader.jsoup.config.JsoupDocumentReaderConfig;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Objects;
@Component
public class MyHtmlReader implements DocumentParser {
@Override
public List<Document> parse(MultipartFile file) {
// JsoupDocumentReader 阅读器配置类
JsoupDocumentReaderConfig config = JsoupDocumentReaderConfig.builder()
.selector("article p") // 提取 <article> 标签内的 p 段落
.charset("UTF-8") // 使用 UTF-8 编码
.includeLinkUrls(true) // 在元数据中包含链接 URL绝对链接
.metadataTags(List.of("author", "date")) // 提取 author 和 date 元标签
.additionalMetadata("source", Objects.requireNonNull(file.getOriginalFilename())) // 添加自定义元数据
.build();
// 新建 JsoupDocumentReader 阅读器
JsoupDocumentReader reader = new JsoupDocumentReader(MultipartFileResource.of(file), config);
// 读取并转换为 Document 文档集合
return reader.get();
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "html", "htm") ||
matchesContentType(contentType, "text/html", "application/xhtml+xml");
}
}

View File

@@ -0,0 +1,29 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.JsonReader;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Component
public class MyJsonReader implements DocumentParser {
/**
* 读取 Json 文件
* @return
*/
@Override
public List<Document> parse(MultipartFile file) {
// 创建 JsonReader 阅读器实例,配置需要读取的字段
JsonReader jsonReader = new JsonReader(MultipartFileResource.of(file), "description", "content", "title");
// 执行读取操作,并转换为 Document 对象集合
return jsonReader.get();
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "json") || matchesContentType(contentType, "application/json");
}
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Objects;
@Component
public class MyMarkdownReader implements DocumentParser {
@Override
public List<Document> parse(MultipartFile file) {
// MarkdownDocumentReader 阅读器配置类
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
.withHorizontalRuleCreateDocument(true) // 遇到水平线 ---,则创建新文档
.withIncludeCodeBlock(false) // 排除代码块(代码块生成单独文档)
.withIncludeBlockquote(false) // 排除块引用(块引用生成单独文档)
.withAdditionalMetadata("filename", Objects.requireNonNull(file.getOriginalFilename())) // 添加自定义元数据,如文件名称
.build();
// 新建 MarkdownDocumentReader 阅读器
MarkdownDocumentReader reader = new MarkdownDocumentReader(MultipartFileResource.of(file), config);
// 读取并转换为 Document 文档集合
return reader.get();
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "md", "markdown") ||
matchesContentType(contentType, "text/markdown", "text/x-markdown");
}
}

View File

@@ -0,0 +1,34 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.ExtractedTextFormatter;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Component
public class MyPdfReader implements DocumentParser {
@Override
public List<Document> parse(MultipartFile file) {
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(
MultipartFileResource.of(file),
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageExtractedTextFormatter(ExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(0)
.build())
.withPagesPerDocument(1)
.build());
return pdfReader.read();
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "pdf") || matchesContentType(contentType, "application/pdf");
}
}

View File

@@ -0,0 +1,28 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Component
public class MyTextReader implements DocumentParser {
/**
* 读取 Txt 文档
* @return 读取的文档集合
*/
@Override
public List<Document> parse(MultipartFile file) {
TextReader textReader = new TextReader(MultipartFileResource.of(file));
textReader.getCustomMetadata().put("filename", file.getOriginalFilename());
return textReader.read();
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "txt") || matchesContentType(contentType, "text/plain");
}
}

View File

@@ -0,0 +1,34 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Component
public class MyTikaPptReader implements DocumentParser {
@Override
public List<Document> parse(MultipartFile file) {
// 新建 TikaDocumentReader 阅读器
TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(MultipartFileResource.of(file));
// 读取并转换为 Document 文档集合
List<Document> documents = tikaDocumentReader.get();
// 文档分块
// 使用自定义设置
TokenTextSplitter splitter = new TokenTextSplitter(1000, 400, 10, 5000, true);
return splitter.apply(documents);
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "ppt", "pptx") ||
matchesContentType(contentType,
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation");
}
}

View File

@@ -0,0 +1,33 @@
package com.hanserwei.chat.reader;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Component
public class MyTikaWordReader implements DocumentParser {
@Override
public List<Document> parse(MultipartFile file) {
// 新建 TikaDocumentReader 阅读器
TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(MultipartFileResource.of(file));
// 读取并转换为 Document 文档集合
List<Document> documents = tikaDocumentReader.get();
// 文档分块
TokenTextSplitter splitter = new TokenTextSplitter(); // 不设置任何构造参数,表示使用默认设置
return splitter.apply(documents);
}
@Override
public boolean supports(String filename, String contentType) {
return hasExtension(filename, "doc", "docx") ||
matchesContentType(contentType,
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
}
}

View File

@@ -0,0 +1,8 @@
package com.hanserwei.chat.service;
import org.springframework.web.multipart.MultipartFile;
public interface DocumentIngestionService {
String ingest(MultipartFile file);
}

View File

@@ -0,0 +1,111 @@
package com.hanserwei.chat.service.impl;
import cn.hutool.core.io.FileUtil;
import com.hanserwei.chat.config.cos.CosProperties;
import com.hanserwei.chat.reader.DocumentParser;
import com.hanserwei.chat.service.DocumentIngestionService;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.ObjectMetadata;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@Slf4j
@Service
public class DocumentIngestionServiceImpl implements DocumentIngestionService {
@Resource
private List<DocumentParser> documentParsers;
@Resource
private VectorStore vectorStore;
@Resource
private COSClient cosClient;
@Resource
private CosProperties cosProperties;
@Override
public String ingest(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("上传文件不能为空");
}
// 如果是图片,则不进行向量化,选择上传到图片存储桶
String originalFilename = file.getOriginalFilename();
String extName = Objects.requireNonNull(FileUtil.extName(originalFilename)).toLowerCase();
// 判断是否为图片类型
boolean isImage = extName.matches("jpg|jpeg|png|gif|bmp|webp");
if (isImage) {
log.info("上传文件 {} 为图片,不进行向量化。", originalFilename);
return uploadFile(file);
}
DocumentParser parser = documentParsers.stream()
.filter(candidate -> candidate.supports(originalFilename, file.getContentType()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("不支持的文件类型:" + originalFilename));
List<Document> documents = parser.parse(file);
if (documents.isEmpty()) {
log.warn("文件 {} 解析后未生成任何文档,跳过入库。", originalFilename);
return "";
}
vectorStore.add(documents);
log.info("文件 {} 入库成功,共写入 {} 条向量。", originalFilename, documents.size());
return documents.size() + "";
}
@SneakyThrows
public String uploadFile(MultipartFile file) {
log.info("## 上传文件至腾讯云Cos ...");
// 判断文件是否为空
if (file == null || file.getSize() == 0) {
log.error("==> 上传文件异常:文件大小为空 ...");
throw new RuntimeException("文件大小不能为空");
}
// 文件的原始名称
String originalFileName = file.getOriginalFilename();
// 生成存储对象的名称(将 UUID 字符串中的 - 替换成空字符串)
String key = UUID.randomUUID().toString().replace("-", "");
// 获取文件的后缀,如 .jpg
String suffix = null;
if (originalFileName != null) {
suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
}
// 拼接上文件后缀,即为要存储的文件名
String objectName = String.format("%s%s", key, suffix);
log.info("==> 开始上传文件至腾讯云Cos, ObjectName: {}", objectName);
// 设置元数据
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
// 执行上传
try (InputStream inputStream = file.getInputStream()) {
cosClient.putObject("snails-ai-1308845726", objectName, inputStream, metadata);
}
// 返回文件的访问链接
String url = String.format("https://%s/%s", cosProperties.getEndpoint(), objectName);
log.info("==> 上传文件至腾讯云 Cos 成功,访问路径: {}", url);
return url;
}
}

View File

@@ -1,105 +0,0 @@
package com.hanserwei.chat.tools;
import com.hanserwei.chat.publisher.RabbitMQPublisher;
import com.hanserwei.chat.utils.ConversationContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AbstractMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Slf4j
@Component
public class SendMQMessageTools {
@Resource
private RabbitMQPublisher rabbitMQPublisher;
@Resource
private ChatMemory chatMemory;
private static final int HISTORY_LIMIT = 10;
/**
* 发送RabbitMQ消息
*
* @param phoneNumber 手机号码
*/
@Tool(name = "SendMessage",
description = "当用户留下手机号码时立即调用此工具发送消息通知管理员。手机号码应该是11位数字。")
public Mono<String> sendMQMessage(
@ToolParam(description = "用户提供的手机号码必须是11位数字") String phoneNumber
) {
return ConversationContext.getConversationIdMono()
.defaultIfEmpty(ConversationContext.getConversationId())
.map(conversationId -> {
String exchange = "chat.exchange";
String routingKey = "chat.routing.key";
String recentConversation = buildRecentConversation(conversationId);
String message = "用户留了手机号:" + phoneNumber + "会话ID" + conversationId + recentConversation;
log.info("SendMQMessageTools 发送消息: {}", message);
rabbitMQPublisher.send(exchange, routingKey, message);
// 确保日志能被记录
log.info("SendMQMessageTools 消息发送完成");
return "消息发送成功";
});
}
private String buildRecentConversation(Long conversationId) {
if (conversationId == null) {
return "。最近对话:暂无记录";
}
List<Message> history = null;
try {
history = chatMemory.get(String.valueOf(conversationId));
} catch (Exception ex) {
log.warn("获取会话{}历史记录失败: {}", conversationId, ex.getMessage(), ex);
}
if (history == null || history.isEmpty()) {
return "。最近对话:暂无记录";
}
int skip = Math.max(0, history.size() - HISTORY_LIMIT);
String recent = history.stream()
.skip(skip)
.map(SendMQMessageTools::formatMessage)
.filter(Objects::nonNull)
.collect(Collectors.joining("\n"));
if (recent.isEmpty()) {
return "。最近对话:暂无记录";
}
return "。最近对话:\n" + recent;
}
private static String formatMessage(Message message) {
if (message == null) {
return null;
}
MessageType messageType = message.getMessageType();
String role = messageType != null ? messageType.name() : "UNKNOWN";
String text = (message instanceof AbstractMessage abstractMessage)
? abstractMessage.getText()
: message.toString();
if (text == null || text.isBlank()) {
return null;
}
return role + ": " + text.strip();
}
}

View File

@@ -5,22 +5,21 @@ server:
charset: utf-8 charset: utf-8
force: true force: true
spring: spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 100MB
application: application:
name: snails-ai name: snails-ai
banner: banner:
location: config/banner.txt location: config/banner.txt
cassandra:
contact-points: 127.0.0.1 # Cassandra 集群节点地址(可配置多个,用逗号分隔)
port: 9042 # 端口号
local-datacenter: datacenter1 # 必须与集群配置的数据中心名称一致(大小写敏感)
jackson: jackson:
serialization: serialization:
write-dates-as-timestamps: false write-dates-as-timestamps: false
mail:
host: ${MAIL_HOST:smtp.qq.com}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:2628273921@qq.com}
password: ENC(ARrAyZNZhbaG6tebogv6WSQbtCO+Vq93NfSA6tMAiD0tTogujERVwEGBECakH0LUhYq9oTaXgfw7tonxNAFEwg==)
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: true
data: data:
redis: redis:
host: localhost host: localhost
@@ -35,15 +34,6 @@ spring:
max-wait: 10000 max-wait: 10000
min-idle: 10 min-idle: 10
time-between-eviction-runs: 10000 time-between-eviction-runs: 10000
rabbitmq:
host: localhost
port: 5672
username: admin
password: admin123
virtual-host: /snailsAi
listener:
simple:
prefetch: 1
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/postgres?serverTimezone=Asia/Shanghai url: jdbc:postgresql://localhost:5432/postgres?serverTimezone=Asia/Shanghai
@@ -56,18 +46,32 @@ spring:
connection-timeout: 5000 # 获取连接超时 5 秒 connection-timeout: 5000 # 获取连接超时 5 秒
max-lifetime: 28800000 # 8 小时(确保在数据库连接超时前被回收) max-lifetime: 28800000 # 8 小时(确保在数据库连接超时前被回收)
ai: ai:
memory: vectorstore:
redis: pgvector:
host: localhost initialize-schema: true
port: 6379 table-name: snails_ai_vector
timeout: 5000 index-type: hnsw
password: redis dimensions: 1024
chat:
memory:
repository:
cassandra:
keyspace: snails_ai
table: t_ai_chat_memory # 表名
time-to-live: 1095d # 数据的自动过期时间1095天 ≈ 3年
initialize-schema: true # 自动初始化表结构
dashscope: dashscope:
api-key: ENC(cMgcKZkFllyE88DIbGwLKot9Vg02co+gsmY8L8o4/o3UjhcmqO4lJzFU35Sx0n+qFG8pDL0wBjoWrT8X6BuRw9vNlQhY1LgRWHaF9S1zzyM=) api-key: ENC(cMgcKZkFllyE88DIbGwLKot9Vg02co+gsmY8L8o4/o3UjhcmqO4lJzFU35Sx0n+qFG8pDL0wBjoWrT8X6BuRw9vNlQhY1LgRWHaF9S1zzyM=)
chat: chat:
options: options:
model: qwen-plus model: qwen3-omni-flash
temperature: 0.5 temperature: 0.5
multi-model: true
embedding:
options:
model: text-embedding-v4
dimensions: 1024
read-timeout: 60000
mybatis-plus: mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
@@ -85,3 +89,10 @@ jasypt:
password: ${jasypt.encryptor.password} password: ${jasypt.encryptor.password}
algorithm: PBEWithHMACSHA512AndAES_256 algorithm: PBEWithHMACSHA512AndAES_256
iv-generator-classname: org.jasypt.iv.RandomIvGenerator iv-generator-classname: org.jasypt.iv.RandomIvGenerator
storage:
cos:
endpoint: snails-ai-1308845726.cos.ap-chengdu.myqcloud.com
appId: 1308845726
region: ap-chengdu
secretId: ENC(nbQSc/HpYon4HMw0sVHOB/mIkC3Z0r2bZ2ndI/V0GahiBN1Hc3Ki4+CDzch6dn+2AksNzvdazHyoiaozaUIpC9t/QGiAJ7Mdbdwpl6/F6S4=)
secretKey: ENC(f6Rkk1rf6DwEYCyW0xV584+fShN7c/fGvWbRdWnp46/MHk/EmOIJ5HHxhT+VtvdM6XXNSIprDmggPCdaOwdmOpdWzpoMnidnTsmSUsRw5NA=)

View File

@@ -5,19 +5,7 @@
使 / / // 使 / / //
### 📜 **** ### 📜 ****
1. **** **** 1. **** ****
2. **** ********
> **"如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。"**
3. ****
* ****11位数字**SendMessage**
* **** "感谢您留下手机号码我们已收到您的信息专业的客服人员将在24小时内与您联系请保持手机畅通。"
### 💡 ****
| 步骤 | | AI助手的行为 |
| :--- | :--- | :--- |
| **1 ()** | "你们产品的价格是多少?" | 1. <br> 2. **** "如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。" |
| **2 ()** | "好的这是我的手机号13800001234" | 1. **** <br> 2. **** `SendMessage` <br> 3. **** "感谢您留下手机号码我们已收到您的信息专业的客服人员将在24小时内与您联系请保持手机畅通。" |
| **3 ()** | "你们的退货政策呢?" | 1. 退 <br> 2. **** "如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。" |