From 409c29c1c23418e1291fa779066de77fddadedb2 Mon Sep 17 00:00:00 2001 From: Hanserwei <2628273921@qq.com> Date: Sat, 1 Nov 2025 11:09:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=94=AF=E6=8C=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E4=B8=8ECOS=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增图片上传功能,支持PNG、JPEG等常见格式 - 集成腾讯云COS对象存储服务,实现文件云端存储 -优化文档上传逻辑,图片文件不再进行向量化处理 - 升级DashScope模型配置,启用多模态支持 - 移除废弃的SaveDocumentsTools工具类 - 添加hutool和腾讯云COS SDK依赖 - 调整文件上传大小限制,支持更大文件上传 -修复部分空指针异常问题,增强代码健壮性 --- pom.xml | 13 ++- .../chat/config/ChatClientConfiguration.java | 7 +- .../hanserwei/chat/config/cos/CosConfig.java | 48 ++++++++++ .../chat/config/cos/CosProperties.java | 16 ++++ .../chat/controller/AiChatController.java | 30 ++++++- .../chat/controller/DocumentController.java | 4 +- .../chat/model/dto/ChatMessageDTO.java | 4 + .../chat/model/vo/DocumentUploadResponse.java | 2 +- .../hanserwei/chat/reader/MyHtmlReader.java | 3 +- .../chat/reader/MyMarkdownReader.java | 3 +- .../service/DocumentIngestionService.java | 2 +- .../impl/DocumentIngestionServiceImpl.java | 90 ++++++++++++++++--- .../chat/tools/SaveDocumentsTools.java | 33 ------- .../src/main/resources/config/application.yml | 16 +++- 14 files changed, 209 insertions(+), 62 deletions(-) create mode 100644 snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosConfig.java create mode 100644 snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosProperties.java delete mode 100644 snails-chat/src/main/java/com/hanserwei/chat/tools/SaveDocumentsTools.java diff --git a/pom.xml b/pom.xml index 92a7cde..8eb8a05 100644 --- a/pom.xml +++ b/pom.xml @@ -113,7 +113,18 @@ org.springframework.ai spring-ai-tika-document-reader - + + + cn.hutool + hutool-all + 5.8.40 + + + + com.qcloud + cos_api + 5.6.227 + diff --git a/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java b/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java index 9154f57..05143c9 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java @@ -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(); diff --git a/snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosConfig.java b/snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosConfig.java new file mode 100644 index 0000000..78447eb --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosConfig.java @@ -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); + } +} \ No newline at end of file diff --git a/snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosProperties.java b/snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosProperties.java new file mode 100644 index 0000000..d0539bf --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/config/cos/CosProperties.java @@ -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; +} \ No newline at end of file diff --git a/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java b/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java index e53ed3a..ea4718b 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java @@ -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 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() diff --git a/snails-chat/src/main/java/com/hanserwei/chat/controller/DocumentController.java b/snails-chat/src/main/java/com/hanserwei/chat/controller/DocumentController.java index 5505bdc..4281058 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/controller/DocumentController.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/controller/DocumentController.java @@ -22,8 +22,8 @@ public class DocumentController { @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity 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)); } } diff --git a/snails-chat/src/main/java/com/hanserwei/chat/model/dto/ChatMessageDTO.java b/snails-chat/src/main/java/com/hanserwei/chat/model/dto/ChatMessageDTO.java index 56090d5..d8e6671 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/model/dto/ChatMessageDTO.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/model/dto/ChatMessageDTO.java @@ -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; } \ No newline at end of file diff --git a/snails-chat/src/main/java/com/hanserwei/chat/model/vo/DocumentUploadResponse.java b/snails-chat/src/main/java/com/hanserwei/chat/model/vo/DocumentUploadResponse.java index aafc3e9..80eb31e 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/model/vo/DocumentUploadResponse.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/model/vo/DocumentUploadResponse.java @@ -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) { } diff --git a/snails-chat/src/main/java/com/hanserwei/chat/reader/MyHtmlReader.java b/snails-chat/src/main/java/com/hanserwei/chat/reader/MyHtmlReader.java index 50aaef5..fad2014 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/reader/MyHtmlReader.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/reader/MyHtmlReader.java @@ -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 阅读器 diff --git a/snails-chat/src/main/java/com/hanserwei/chat/reader/MyMarkdownReader.java b/snails-chat/src/main/java/com/hanserwei/chat/reader/MyMarkdownReader.java index 9c4a702..09849e6 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/reader/MyMarkdownReader.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/reader/MyMarkdownReader.java @@ -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 阅读器 diff --git a/snails-chat/src/main/java/com/hanserwei/chat/service/DocumentIngestionService.java b/snails-chat/src/main/java/com/hanserwei/chat/service/DocumentIngestionService.java index b90192b..b2e4521 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/service/DocumentIngestionService.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/service/DocumentIngestionService.java @@ -4,5 +4,5 @@ import org.springframework.web.multipart.MultipartFile; public interface DocumentIngestionService { - int ingest(MultipartFile file); + String ingest(MultipartFile file); } diff --git a/snails-chat/src/main/java/com/hanserwei/chat/service/impl/DocumentIngestionServiceImpl.java b/snails-chat/src/main/java/com/hanserwei/chat/service/impl/DocumentIngestionServiceImpl.java index ee57329..9394fba 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/service/impl/DocumentIngestionServiceImpl.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/service/impl/DocumentIngestionServiceImpl.java @@ -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 documentParsers; - private final VectorStore vectorStore; + @Resource + private List 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 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; } } diff --git a/snails-chat/src/main/java/com/hanserwei/chat/tools/SaveDocumentsTools.java b/snails-chat/src/main/java/com/hanserwei/chat/tools/SaveDocumentsTools.java deleted file mode 100644 index fdd3837..0000000 --- a/snails-chat/src/main/java/com/hanserwei/chat/tools/SaveDocumentsTools.java +++ /dev/null @@ -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; - } -} diff --git a/snails-chat/src/main/resources/config/application.yml b/snails-chat/src/main/resources/config/application.yml index 740b3b2..0e3d064 100644 --- a/snails-chat/src/main/resources/config/application.yml +++ b/snails-chat/src/main/resources/config/application.yml @@ -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 \ No newline at end of file + 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=) \ No newline at end of file