feat(ai): 支持图片上传与COS存储

- 新增图片上传功能,支持PNG、JPEG等常见格式
- 集成腾讯云COS对象存储服务,实现文件云端存储
-优化文档上传逻辑,图片文件不再进行向量化处理
- 升级DashScope模型配置,启用多模态支持
- 移除废弃的SaveDocumentsTools工具类
- 添加hutool和腾讯云COS SDK依赖
- 调整文件上传大小限制,支持更大文件上传
-修复部分空指针异常问题,增强代码健壮性
This commit is contained in:
2025-11-01 11:09:46 +08:00
parent a9fce282ed
commit 409c29c1c2
14 changed files with 209 additions and 62 deletions

13
pom.xml
View File

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

View File

@@ -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();

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

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

View File

@@ -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));
}
}

View File

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

View File

@@ -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) {
}

View File

@@ -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 阅读器

View File

@@ -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 阅读器

View File

@@ -4,5 +4,5 @@ import org.springframework.web.multipart.MultipartFile;
public interface DocumentIngestionService {
int ingest(MultipartFile file);
String ingest(MultipartFile file);
}

View 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;
}
}

View File

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

View File

@@ -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
@@ -84,3 +89,10 @@ jasypt:
password: ${jasypt.encryptor.password}
algorithm: PBEWithHMACSHA512AndAES_256
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=)