feat(ai): 支持图片上传与COS存储
- 新增图片上传功能,支持PNG、JPEG等常见格式 - 集成腾讯云COS对象存储服务,实现文件云端存储 -优化文档上传逻辑,图片文件不再进行向量化处理 - 升级DashScope模型配置,启用多模态支持 - 移除废弃的SaveDocumentsTools工具类 - 添加hutool和腾讯云COS SDK依赖 - 调整文件上传大小限制,支持更大文件上传 -修复部分空指针异常问题,增强代码健壮性
This commit is contained in:
13
pom.xml
13
pom.xml
@@ -113,7 +113,18 @@
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.hanserwei.chat.config;
|
||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
|
||||
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
|
||||
import com.hanserwei.chat.tools.AiDBTools;
|
||||
import com.hanserwei.chat.tools.SaveDocumentsTools;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
|
||||
@@ -14,7 +13,6 @@ 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.aot.hint.annotation.RegisterReflection;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -52,14 +50,13 @@ public class ChatClientConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ChatClient dashScopeChatClient(ChatMemory chatMemory,
|
||||
SaveDocumentsTools saveDocumentsTools
|
||||
public ChatClient dashScopeChatClient(ChatMemory chatMemory
|
||||
) {
|
||||
PromptChatMemoryAdvisor chatMemoryAdvisor = PromptChatMemoryAdvisor.builder(chatMemory).build();
|
||||
SimpleLoggerAdvisor simpleLoggerAdvisor = new SimpleLoggerAdvisor();
|
||||
QuestionAnswerAdvisor questionAnswerAdvisor = new QuestionAnswerAdvisor(vectorStore);
|
||||
return ChatClient.builder(dashScopeChatModel)
|
||||
.defaultTools(aiDBTools, saveDocumentsTools)
|
||||
.defaultTools(aiDBTools)
|
||||
.defaultSystem(aiAssistantResource)
|
||||
.defaultAdvisors(chatMemoryAdvisor,simpleLoggerAdvisor,questionAnswerAdvisor)
|
||||
.build();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,10 +7,21 @@ import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
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.web.bind.annotation.*;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
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.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/ai")
|
||||
@@ -23,9 +34,20 @@ public class AiChatController {
|
||||
public Flux<AIResponse> chatWithAi(@RequestBody ChatMessageDTO chatMessageDTO) {
|
||||
log.info("会话ID:{}", chatMessageDTO.getConversionId());
|
||||
ConversationContext.setConversationId(chatMessageDTO.getConversionId());
|
||||
|
||||
return dashScopeChatClient.prompt()
|
||||
.user(chatMessageDTO.getMessage())
|
||||
Media media;
|
||||
UserMessage userMessage;
|
||||
if (Objects.nonNull(chatMessageDTO.getImage())) {
|
||||
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()))
|
||||
.stream()
|
||||
.chatResponse()
|
||||
|
||||
@@ -22,8 +22,8 @@ public class DocumentController {
|
||||
|
||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<DocumentUploadResponse> upload(@RequestParam("file") MultipartFile file) {
|
||||
int documentCount = documentIngestionService.ingest(file);
|
||||
String result = documentIngestionService.ingest(file);
|
||||
log.info("文件 {} 上传成功。", file.getOriginalFilename());
|
||||
return ResponseEntity.ok(new DocumentUploadResponse(file.getOriginalFilename(), documentCount, "ok"));
|
||||
return ResponseEntity.ok(new DocumentUploadResponse(file.getOriginalFilename(), result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@@ -13,5 +15,7 @@ public class ChatMessageDTO {
|
||||
|
||||
private String message;
|
||||
|
||||
private String image;
|
||||
|
||||
private Long conversionId;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hanserwei.chat.model.vo;
|
||||
|
||||
public record DocumentUploadResponse(String filename, int documentCount, String message) {
|
||||
public record DocumentUploadResponse(String filename, String message) {
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -19,7 +20,7 @@ public class MyHtmlReader implements DocumentParser {
|
||||
.charset("UTF-8") // 使用 UTF-8 编码
|
||||
.includeLinkUrls(true) // 在元数据中包含链接 URL(绝对链接)
|
||||
.metadataTags(List.of("author", "date")) // 提取 author 和 date 元标签
|
||||
.additionalMetadata("source", file.getOriginalFilename()) // 添加自定义元数据
|
||||
.additionalMetadata("source", Objects.requireNonNull(file.getOriginalFilename())) // 添加自定义元数据
|
||||
.build();
|
||||
|
||||
// 新建 JsoupDocumentReader 阅读器
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -18,7 +19,7 @@ public class MyMarkdownReader implements DocumentParser {
|
||||
.withHorizontalRuleCreateDocument(true) // 遇到水平线 ---,则创建新文档
|
||||
.withIncludeCodeBlock(false) // 排除代码块(代码块生成单独文档)
|
||||
.withIncludeBlockquote(false) // 排除块引用(块引用生成单独文档)
|
||||
.withAdditionalMetadata("filename", file.getOriginalFilename()) // 添加自定义元数据,如文件名称
|
||||
.withAdditionalMetadata("filename", Objects.requireNonNull(file.getOriginalFilename())) // 添加自定义元数据,如文件名称
|
||||
.build();
|
||||
|
||||
// 新建 MarkdownDocumentReader 阅读器
|
||||
|
||||
@@ -4,5 +4,5 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface DocumentIngestionService {
|
||||
|
||||
int ingest(MultipartFile file);
|
||||
String ingest(MultipartFile file);
|
||||
}
|
||||
|
||||
@@ -1,43 +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 lombok.RequiredArgsConstructor;
|
||||
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
|
||||
@RequiredArgsConstructor
|
||||
|
||||
public class DocumentIngestionServiceImpl implements DocumentIngestionService {
|
||||
|
||||
private final List<DocumentParser> documentParsers;
|
||||
private final VectorStore vectorStore;
|
||||
@Resource
|
||||
private List<DocumentParser> documentParsers;
|
||||
@Resource
|
||||
private VectorStore vectorStore;
|
||||
@Resource
|
||||
private COSClient cosClient;
|
||||
@Resource
|
||||
private CosProperties cosProperties;
|
||||
|
||||
@Override
|
||||
public int ingest(MultipartFile file) {
|
||||
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(file.getOriginalFilename(), file.getContentType()))
|
||||
.filter(candidate -> candidate.supports(originalFilename, file.getContentType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("不支持的文件类型:" + file.getOriginalFilename()));
|
||||
.orElseThrow(() -> new IllegalArgumentException("不支持的文件类型:" + originalFilename));
|
||||
|
||||
List<Document> documents = parser.parse(file);
|
||||
if (documents.isEmpty()) {
|
||||
log.warn("文件 {} 解析后未生成任何文档,跳过入库。", file.getOriginalFilename());
|
||||
return 0;
|
||||
log.warn("文件 {} 解析后未生成任何文档,跳过入库。", originalFilename);
|
||||
return "";
|
||||
}
|
||||
|
||||
vectorStore.add(documents);
|
||||
log.info("文件 {} 入库成功,共写入 {} 条向量。", file.getOriginalFilename(), documents.size());
|
||||
return documents.size();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.hanserwei.chat.tools;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.ai.vectorstore.SimpleVectorStore;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SaveDocumentsTools {
|
||||
|
||||
@Resource
|
||||
private SimpleVectorStore simpleVectorStore;
|
||||
|
||||
@Tool(name = "SaveDocuments",
|
||||
description = "保存文档为本地的向量化知识库。")
|
||||
public boolean saveDocuments(
|
||||
@ToolParam(description = "文档内容") String documents
|
||||
) {
|
||||
simpleVectorStore.add(List.of(new Document(documents)));
|
||||
//把内存中的向量数据,持久化到磁盘
|
||||
File file = new File("/home/hanserwei/IdeaProjects/snails-ai-backend/snails-chat/src/main/resources/documents/vector.json");
|
||||
simpleVectorStore.save(file);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ server:
|
||||
charset: utf-8
|
||||
force: true
|
||||
spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 20MB
|
||||
max-request-size: 100MB
|
||||
application:
|
||||
name: snails-ai
|
||||
banner:
|
||||
@@ -60,8 +64,9 @@ spring:
|
||||
api-key: ENC(cMgcKZkFllyE88DIbGwLKot9Vg02co+gsmY8L8o4/o3UjhcmqO4lJzFU35Sx0n+qFG8pDL0wBjoWrT8X6BuRw9vNlQhY1LgRWHaF9S1zzyM=)
|
||||
chat:
|
||||
options:
|
||||
model: qwen-plus
|
||||
model: qwen3-omni-flash
|
||||
temperature: 0.5
|
||||
multi-model: true
|
||||
embedding:
|
||||
options:
|
||||
model: text-embedding-v4
|
||||
@@ -83,4 +88,11 @@ jasypt:
|
||||
encryptor:
|
||||
password: ${jasypt.encryptor.password}
|
||||
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=)
|
||||
Reference in New Issue
Block a user