Commit 0b1889a8e88c82890762cceb2ae93abe6b18598e

Authored by YunaiV
1 parent 842bbb55

feat(ai): 支持模型输入模态配置与图片多模态传参

Showing 16 changed files with 741 additions and 27 deletions
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/AiModelController.java
@@ -83,7 +83,8 @@ public class AiModelController { @@ -83,7 +83,8 @@ public class AiModelController {
83 List<AiModelDO> list = modelService.getModelListByStatusAndType( 83 List<AiModelDO> list = modelService.getModelListByStatusAndType(
84 CommonStatusEnum.ENABLE.getStatus(), type, platform); 84 CommonStatusEnum.ENABLE.getStatus(), type, platform);
85 return success(convertList(list, model -> new AiModelRespVO().setId(model.getId()) 85 return success(convertList(list, model -> new AiModelRespVO().setId(model.getId())
86 - .setName(model.getName()).setModel(model.getModel()).setPlatform(model.getPlatform()))); 86 + .setName(model.getName()).setModel(model.getModel()).setPlatform(model.getPlatform())
  87 + .setInputModalities(model.getInputModalities())));
87 } 88 }
88 89
89 } 90 }
90 \ No newline at end of file 91 \ No newline at end of file
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/model/AiModelRespVO.java
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
4 import lombok.Data; 4 import lombok.Data;
5 5
6 import java.time.LocalDateTime; 6 import java.time.LocalDateTime;
  7 +import java.util.List;
7 8
8 @Schema(description = "管理后台 - AI 模型 Response VO") 9 @Schema(description = "管理后台 - AI 模型 Response VO")
9 @Data 10 @Data
@@ -42,6 +43,9 @@ public class AiModelRespVO { @@ -42,6 +43,9 @@ public class AiModelRespVO {
42 @Schema(description = "上下文的最大 Message 数量", example = "8192") 43 @Schema(description = "上下文的最大 Message 数量", example = "8192")
43 private Integer maxContexts; 44 private Integer maxContexts;
44 45
  46 + @Schema(description = "输入模态数组", example = "[\"text\", \"image\"]")
  47 + private List<String> inputModalities;
  48 +
45 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) 49 @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
46 private LocalDateTime createTime; 50 private LocalDateTime createTime;
47 51
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/controller/admin/model/vo/model/AiModelSaveReqVO.java
1 package cn.iocoder.yudao.module.ai.controller.admin.model.vo.model; 1 package cn.iocoder.yudao.module.ai.controller.admin.model.vo.model;
2 2
  3 +import cn.iocoder.yudao.module.ai.enums.model.AiModelInputModalityEnum;
3 import cn.iocoder.yudao.module.ai.enums.model.AiModelTypeEnum; 4 import cn.iocoder.yudao.module.ai.enums.model.AiModelTypeEnum;
4 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; 5 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
@@ -9,6 +10,8 @@ import jakarta.validation.constraints.NotEmpty; @@ -9,6 +10,8 @@ import jakarta.validation.constraints.NotEmpty;
9 import jakarta.validation.constraints.NotNull; 10 import jakarta.validation.constraints.NotNull;
10 import lombok.Data; 11 import lombok.Data;
11 12
  13 +import java.util.List;
  14 +
12 @Schema(description = "管理后台 - API 模型新增/修改 Request VO") 15 @Schema(description = "管理后台 - API 模型新增/修改 Request VO")
13 @Data 16 @Data
14 public class AiModelSaveReqVO { 17 public class AiModelSaveReqVO {
@@ -56,4 +59,8 @@ public class AiModelSaveReqVO { @@ -56,4 +59,8 @@ public class AiModelSaveReqVO {
56 @Schema(description = "上下文的最大 Message 数量", example = "8192") 59 @Schema(description = "上下文的最大 Message 数量", example = "8192")
57 private Integer maxContexts; 60 private Integer maxContexts;
58 61
  62 + @Schema(description = "输入模态数组", example = "[\"text\", \"image\"]")
  63 + @InEnum(AiModelInputModalityEnum.class)
  64 + private List<String> inputModalities;
  65 +
59 } 66 }
60 \ No newline at end of file 67 \ No newline at end of file
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiModelDO.java
1 package cn.iocoder.yudao.module.ai.dal.dataobject.model; 1 package cn.iocoder.yudao.module.ai.dal.dataobject.model;
2 2
  3 +import cn.iocoder.yudao.module.ai.enums.model.AiModelInputModalityEnum;
3 import cn.iocoder.yudao.module.ai.enums.model.AiModelTypeEnum; 4 import cn.iocoder.yudao.module.ai.enums.model.AiModelTypeEnum;
4 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; 5 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
6 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; 7 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  8 +import cn.iocoder.yudao.framework.mybatis.core.type.StringListTypeHandler;
7 import com.baomidou.mybatisplus.annotation.KeySequence; 9 import com.baomidou.mybatisplus.annotation.KeySequence;
  10 +import com.baomidou.mybatisplus.annotation.TableField;
8 import com.baomidou.mybatisplus.annotation.TableId; 11 import com.baomidou.mybatisplus.annotation.TableId;
9 import com.baomidou.mybatisplus.annotation.TableName; 12 import com.baomidou.mybatisplus.annotation.TableName;
10 import lombok.*; 13 import lombok.*;
11 14
  15 +import java.util.List;
  16 +
12 /** 17 /**
13 * AI 模型 DO 18 * AI 模型 DO
14 * 19 *
@@ -17,7 +22,7 @@ import lombok.*; @@ -17,7 +22,7 @@ import lombok.*;
17 * @author fansili 22 * @author fansili
18 * @since 2024/4/24 19:39 23 * @since 2024/4/24 19:39
19 */ 24 */
20 -@TableName("ai_model") 25 +@TableName(value = "ai_model", autoResultMap = true)
21 @KeySequence("ai_model_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 26 @KeySequence("ai_model_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
22 @Data 27 @Data
23 @Builder 28 @Builder
@@ -84,5 +89,12 @@ public class AiModelDO extends BaseDO { @@ -84,5 +89,12 @@ public class AiModelDO extends BaseDO {
84 * 上下文的最大 Message 数量 89 * 上下文的最大 Message 数量
85 */ 90 */
86 private Integer maxContexts; 91 private Integer maxContexts;
  92 + /**
  93 + * 输入模态数组
  94 + *
  95 + * 枚举 {@link AiModelInputModalityEnum},仅 {@link AiModelTypeEnum#CHAT} 使用;为空时视为仅支持 text
  96 + */
  97 + @TableField(typeHandler = StringListTypeHandler.class)
  98 + private List<String> inputModalities;
87 99
88 } 100 }
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/ErrorCodeConstants.java
@@ -31,6 +31,7 @@ public interface ErrorCodeConstants { @@ -31,6 +31,7 @@ public interface ErrorCodeConstants {
31 // ========== API 聊天消息 1-040-004-000 ========== 31 // ========== API 聊天消息 1-040-004-000 ==========
32 ErrorCode CHAT_MESSAGE_NOT_EXIST = new ErrorCode(1_040_004_000, "消息不存在!"); 32 ErrorCode CHAT_MESSAGE_NOT_EXIST = new ErrorCode(1_040_004_000, "消息不存在!");
33 ErrorCode CHAT_STREAM_ERROR = new ErrorCode(1_040_004_001, "对话生成异常!"); 33 ErrorCode CHAT_STREAM_ERROR = new ErrorCode(1_040_004_001, "对话生成异常!");
  34 + ErrorCode CHAT_MESSAGE_IMAGE_READ_FAIL = new ErrorCode(1_040_004_002, "读取图片附件({})失败,请重新上传后重试");
34 35
35 // ========== API 绘画 1-040-005-000 ========== 36 // ========== API 绘画 1-040-005-000 ==========
36 ErrorCode IMAGE_NOT_EXISTS = new ErrorCode(1_040_005_000, "图片不存在!"); 37 ErrorCode IMAGE_NOT_EXISTS = new ErrorCode(1_040_005_000, "图片不存在!");
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/enums/model/AiModelInputModalityEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai.enums.model;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  5 +import lombok.Getter;
  6 +import lombok.RequiredArgsConstructor;
  7 +
  8 +import java.util.Arrays;
  9 +import java.util.List;
  10 +
  11 +/**
  12 + * AI 模型输入模态的枚举
  13 + *
  14 + * 用于 {@link AiModelTypeEnum#CHAT} 对话模型,描述模型可以接收的输入类型,由用户按需配置
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Getter
  19 +@RequiredArgsConstructor
  20 +public enum AiModelInputModalityEnum implements ArrayValuable<String> {
  21 +
  22 + TEXT("text", "文本"),
  23 + IMAGE("image", "图片"),
  24 + VIDEO("video", "视频");
  25 +
  26 + /**
  27 + * 模态
  28 + */
  29 + private final String modality;
  30 + /**
  31 + * 模态名
  32 + */
  33 + private final String name;
  34 +
  35 + public static final String[] ARRAYS = Arrays.stream(values()).map(AiModelInputModalityEnum::getModality).toArray(String[]::new);
  36 +
  37 + @Override
  38 + public String[] array() {
  39 + return ARRAYS;
  40 + }
  41 +
  42 + /**
  43 + * 判断是否支持该输入模态;为空时(历史数据),视为仅支持 text
  44 + *
  45 + * @param modalities 模型配置的输入模态数组
  46 + * @param modality 输入模态
  47 + * @return 是否支持
  48 + */
  49 + public static boolean isSupported(List<String> modalities, AiModelInputModalityEnum modality) {
  50 + if (CollUtil.isEmpty(modalities)) {
  51 + return modality == TEXT;
  52 + }
  53 + return modalities.contains(modality.getModality());
  54 + }
  55 +
  56 +}
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/minimax/MiniMaxChatModel.java
@@ -20,7 +20,7 @@ public class MiniMaxChatModel implements ChatModel { @@ -20,7 +20,7 @@ public class MiniMaxChatModel implements ChatModel {
20 20
21 public static final String BASE_URL = "https://api.minimaxi.com/v1"; 21 public static final String BASE_URL = "https://api.minimaxi.com/v1";
22 22
23 - public static final String MODEL_DEFAULT = "MiniMax-M3"; 23 + public static final String MODEL_DEFAULT = "MiniMax-M3.1-Flash";
24 24
25 /** 25 /**
26 * 兼容 OpenAI 接口,复用 DeepSeek 客户端 26 * 兼容 OpenAI 接口,复用 DeepSeek 客户端
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/moonshot/MoonshotChatModel.java
@@ -22,7 +22,7 @@ public class MoonshotChatModel implements ChatModel { @@ -22,7 +22,7 @@ public class MoonshotChatModel implements ChatModel {
22 22
23 public static final String COMPLETE_PATH = "/v1/chat/completions"; 23 public static final String COMPLETE_PATH = "/v1/chat/completions";
24 24
25 - public static final String MODEL_DEFAULT = "kimi-k2.6"; 25 + public static final String MODEL_DEFAULT = "kimi-k3.1-pro";
26 26
27 /** 27 /**
28 * 兼容 OpenAI 接口,复用 DeepSeek 客户端 28 * 兼容 OpenAI 接口,复用 DeepSeek 客户端
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/zhipu/ZhiPuChatModel.java
@@ -20,7 +20,7 @@ public class ZhiPuChatModel implements ChatModel { @@ -20,7 +20,7 @@ public class ZhiPuChatModel implements ChatModel {
20 20
21 public static final String BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; 21 public static final String BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
22 22
23 - public static final String MODEL_DEFAULT = "glm-5.2"; 23 + public static final String MODEL_DEFAULT = "glm-5.3-flash";
24 24
25 /** 25 /**
26 * 兼容 OpenAI 接口,复用 DeepSeek 客户端 26 * 兼容 OpenAI 接口,复用 DeepSeek 客户端
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImpl.java
@@ -6,8 +6,10 @@ import cn.hutool.core.io.file.FileNameUtil; @@ -6,8 +6,10 @@ import cn.hutool.core.io.file.FileNameUtil;
6 import cn.hutool.core.util.ObjUtil; 6 import cn.hutool.core.util.ObjUtil;
7 import cn.hutool.core.util.StrUtil; 7 import cn.hutool.core.util.StrUtil;
8 import cn.hutool.http.HttpUtil; 8 import cn.hutool.http.HttpUtil;
  9 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
9 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 10 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
10 import cn.iocoder.yudao.framework.common.pojo.PageResult; 11 import cn.iocoder.yudao.framework.common.pojo.PageResult;
  12 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
11 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 13 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
12 import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; 14 import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
13 import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message.AiChatMessagePageReqVO; 15 import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message.AiChatMessagePageReqVO;
@@ -22,6 +24,7 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO; @@ -22,6 +24,7 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO;
22 import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiToolDO; 24 import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiToolDO;
23 import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatMessageMapper; 25 import cn.iocoder.yudao.module.ai.dal.mysql.chat.AiChatMessageMapper;
24 import cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants; 26 import cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants;
  27 +import cn.iocoder.yudao.module.ai.enums.model.AiModelInputModalityEnum;
25 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum; 28 import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
26 import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient; 29 import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient;
27 import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest; 30 import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchRequest;
@@ -48,13 +51,16 @@ import org.springframework.ai.chat.model.ChatResponse; @@ -48,13 +51,16 @@ import org.springframework.ai.chat.model.ChatResponse;
48 import org.springframework.ai.chat.model.StreamingChatModel; 51 import org.springframework.ai.chat.model.StreamingChatModel;
49 import org.springframework.ai.chat.prompt.ChatOptions; 52 import org.springframework.ai.chat.prompt.ChatOptions;
50 import org.springframework.ai.chat.prompt.Prompt; 53 import org.springframework.ai.chat.prompt.Prompt;
  54 +import org.springframework.ai.content.Media;
51 import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; 55 import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
52 import org.springframework.ai.mcp.client.common.autoconfigure.properties.McpClientCommonProperties; 56 import org.springframework.ai.mcp.client.common.autoconfigure.properties.McpClientCommonProperties;
53 import org.springframework.ai.tool.ToolCallback; 57 import org.springframework.ai.tool.ToolCallback;
54 import org.springframework.ai.tool.resolution.ToolCallbackResolver; 58 import org.springframework.ai.tool.resolution.ToolCallbackResolver;
55 import org.springframework.beans.factory.annotation.Autowired; 59 import org.springframework.beans.factory.annotation.Autowired;
  60 +import org.springframework.core.io.ByteArrayResource;
56 import org.springframework.stereotype.Service; 61 import org.springframework.stereotype.Service;
57 import org.springframework.transaction.annotation.Transactional; 62 import org.springframework.transaction.annotation.Transactional;
  63 +import org.springframework.util.MimeTypeUtils;
58 import reactor.core.publisher.Flux; 64 import reactor.core.publisher.Flux;
59 65
60 import java.time.LocalDateTime; 66 import java.time.LocalDateTime;
@@ -70,6 +76,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils. @@ -70,6 +76,7 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
70 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; 76 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
71 import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_CONVERSATION_NOT_EXISTS; 77 import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_CONVERSATION_NOT_EXISTS;
72 import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_MESSAGE_NOT_EXIST; 78 import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_MESSAGE_NOT_EXIST;
  79 +import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_MESSAGE_IMAGE_READ_FAIL;
73 80
74 /** 81 /**
75 * AI 聊天消息 Service 实现类 82 * AI 聊天消息 Service 实现类
@@ -149,6 +156,8 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -149,6 +156,8 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
149 // 1.2 校验模型 156 // 1.2 校验模型
150 AiModelDO model = modalService.validateModel(conversation.getModelId()); 157 AiModelDO model = modalService.validateModel(conversation.getModelId());
151 ChatModel chatModel = modalService.getChatModel(model.getId()); 158 ChatModel chatModel = modalService.getChatModel(model.getId());
  159 + // 1.3 选出上下文消息:附件按各自所属消息组装
  160 + List<AiChatMessageDO> contextMessages = filterContextMessages(historyMessages, conversation, sendReqVO);
152 161
153 // 2.1 知识库召回 162 // 2.1 知识库召回
154 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment( 163 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(
@@ -159,6 +168,9 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -159,6 +168,9 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
159 webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent()) 168 webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent())
160 .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null; 169 .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null;
161 170
  171 + // 2.3 创建 chat 需要的 Prompt:在插入消息前构建,图片读取失败时不留下消息
  172 + Prompt prompt = buildPrompt(conversation, contextMessages, knowledgeSegments, webSearchResponse, model, sendReqVO);
  173 +
162 // 3. 插入 user 发送消息 174 // 3. 插入 user 发送消息
163 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model, 175 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model,
164 userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(), 176 userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(),
@@ -169,8 +181,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -169,8 +181,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
169 userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(), 181 userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(),
170 knowledgeSegments, null, webSearchResponse); 182 knowledgeSegments, null, webSearchResponse);
171 183
172 - // 4.2 创建 chat 需要的 Prompt  
173 - Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, webSearchResponse, model, sendReqVO); 184 + // 4.2 调用模型
174 ChatResponse chatResponse = chatModel.call(prompt); 185 ChatResponse chatResponse = chatModel.call(prompt);
175 186
176 // 4.3 更新响应内容 187 // 4.3 更新响应内容
@@ -206,6 +217,8 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -206,6 +217,8 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
206 // 1.2 校验模型 217 // 1.2 校验模型
207 AiModelDO model = modalService.validateModel(conversation.getModelId()); 218 AiModelDO model = modalService.validateModel(conversation.getModelId());
208 StreamingChatModel chatModel = modalService.getChatModel(model.getId()); 219 StreamingChatModel chatModel = modalService.getChatModel(model.getId());
  220 + // 1.3 选出上下文消息:附件按各自所属消息组装
  221 + List<AiChatMessageDO> contextMessages = filterContextMessages(historyMessages, conversation, sendReqVO);
209 222
210 // 2.1 知识库找回 223 // 2.1 知识库找回
211 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment( 224 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments = recallKnowledgeSegment(
@@ -216,6 +229,14 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -216,6 +229,14 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
216 webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent()) 229 webSearchClient.search(new AiWebSearchRequest().setQuery(sendReqVO.getContent())
217 .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null; 230 .setSummary(true).setCount(WEB_SEARCH_COUNT)) : null;
218 231
  232 + // 2.3 构建 Prompt:在插入消息前构建,图片读取失败时返回错误帧,不留下消息
  233 + Prompt prompt;
  234 + try {
  235 + prompt = buildPrompt(conversation, contextMessages, knowledgeSegments, webSearchResponse, model, sendReqVO);
  236 + } catch (ServiceException ex) {
  237 + return Flux.just(error(ex.getCode(), ex.getMessage()));
  238 + }
  239 +
219 // 3. 插入 user 发送消息 240 // 3. 插入 user 发送消息
220 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model, 241 AiChatMessageDO userMessage = createChatMessage(conversation.getId(), null, model,
221 userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(), 242 userId, conversation.getRoleId(), MessageType.USER, sendReqVO.getContent(), sendReqVO.getUseContext(),
@@ -226,8 +247,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -226,8 +247,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
226 userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(), 247 userId, conversation.getRoleId(), MessageType.ASSISTANT, "", sendReqVO.getUseContext(),
227 knowledgeSegments, null, webSearchResponse); 248 knowledgeSegments, null, webSearchResponse);
228 249
229 - // 4.2 构建 Prompt,并进行调用  
230 - Prompt prompt = buildPrompt(conversation, historyMessages, knowledgeSegments, webSearchResponse, model, sendReqVO); 250 + // 4.2 调用模型
231 Flux<ChatResponse> streamResponse = chatModel.stream(prompt); 251 Flux<ChatResponse> streamResponse = chatModel.stream(prompt);
232 252
233 // 4.3 流式返回 253 // 4.3 流式返回
@@ -322,7 +342,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -322,7 +342,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
322 return knowledgeSegments; 342 return knowledgeSegments;
323 } 343 }
324 344
325 - private Prompt buildPrompt(AiChatConversationDO conversation, List<AiChatMessageDO> messages, 345 + private Prompt buildPrompt(AiChatConversationDO conversation, List<AiChatMessageDO> contextMessages,
326 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments, 346 List<AiKnowledgeSegmentSearchRespBO> knowledgeSegments,
327 AiWebSearchResponse webSearchResponse, 347 AiWebSearchResponse webSearchResponse,
328 AiModelDO model, AiChatMessageSendReqVO sendReqVO) { 348 AiModelDO model, AiChatMessageSendReqVO sendReqVO) {
@@ -332,19 +352,20 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -332,19 +352,20 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
332 chatMessages.add(new SystemMessage(conversation.getSystemMessage())); 352 chatMessages.add(new SystemMessage(conversation.getSystemMessage()));
333 } 353 }
334 354
335 - // 1.2 历史 history message 历史消息  
336 - List<AiChatMessageDO> contextMessages = filterContextMessages(messages, conversation, sendReqVO); 355 + // 1.2 历史 history message 历史消息(已由 filterContextMessages 选出);user 消息带上该轮的图片
337 contextMessages.forEach(message -> { 356 contextMessages.forEach(message -> {
338 - chatMessages.add(AiUtils.buildMessage(message.getType(), message.getContent()));  
339 - UserMessage attachmentUserMessage = buildAttachmentUserMessage(message.getAttachmentUrls()); 357 + chatMessages.add(MessageType.USER.getValue().equals(message.getType())
  358 + ? buildUserMessage(message.getContent(), message.getAttachmentUrls(), model)
  359 + : AiUtils.buildMessage(message.getType(), message.getContent()));
  360 + UserMessage attachmentUserMessage = buildAttachmentUserMessage(message.getAttachmentUrls(), model);
340 if (attachmentUserMessage != null) { 361 if (attachmentUserMessage != null) {
341 chatMessages.add(attachmentUserMessage); 362 chatMessages.add(attachmentUserMessage);
342 } 363 }
343 // TODO @芋艿:历史的知识库;历史的搜索,要不要拼接? 364 // TODO @芋艿:历史的知识库;历史的搜索,要不要拼接?
344 }); 365 });
345 366
346 - // 1.3 当前 user message 新发送消息  
347 - chatMessages.add(new UserMessage(sendReqVO.getContent())); 367 + // 1.3 当前 user message 新发送消息:文本和本轮图片放在同一条 UserMessage
  368 + chatMessages.add(buildUserMessage(sendReqVO.getContent(), sendReqVO.getAttachmentUrls(), model));
348 369
349 // 1.4 知识库,通过 UserMessage 实现 370 // 1.4 知识库,通过 UserMessage 实现
350 if (CollUtil.isNotEmpty(knowledgeSegments)) { 371 if (CollUtil.isNotEmpty(knowledgeSegments)) {
@@ -369,7 +390,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -369,7 +390,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
369 390
370 // 1.6 附件,通过 UserMessage 实现 391 // 1.6 附件,通过 UserMessage 实现
371 if (CollUtil.isNotEmpty(sendReqVO.getAttachmentUrls())) { 392 if (CollUtil.isNotEmpty(sendReqVO.getAttachmentUrls())) {
372 - UserMessage attachmentUserMessage = buildAttachmentUserMessage(sendReqVO.getAttachmentUrls()); 393 + UserMessage attachmentUserMessage = buildAttachmentUserMessage(sendReqVO.getAttachmentUrls(), model);
373 if (attachmentUserMessage != null) { 394 if (attachmentUserMessage != null) {
374 chatMessages.add(attachmentUserMessage); 395 chatMessages.add(attachmentUserMessage);
375 } 396 }
@@ -381,12 +402,24 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -381,12 +402,24 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
381 : Map.of(); 402 : Map.of();
382 // 2.2 构建 ChatOptions 对象 403 // 2.2 构建 ChatOptions 对象
383 AiPlatformEnum platform = AiPlatformEnum.validatePlatform(model.getPlatform()); 404 AiPlatformEnum platform = AiPlatformEnum.validatePlatform(model.getPlatform());
  405 + boolean hasMedia = CollUtil.anyMatch(chatMessages, message -> message instanceof UserMessage
  406 + && CollUtil.isNotEmpty(((UserMessage) message).getMedia()));
384 ChatOptions chatOptions = AiUtils.buildChatOptions(platform, model.getModel(), 407 ChatOptions chatOptions = AiUtils.buildChatOptions(platform, model.getModel(),
385 conversation.getTemperature(), conversation.getMaxTokens(), 408 conversation.getTemperature(), conversation.getMaxTokens(),
386 - toolCallbacks, toolContext); 409 + toolCallbacks, toolContext, hasMedia);
387 return new Prompt(chatMessages, chatOptions); 410 return new Prompt(chatMessages, chatOptions);
388 } 411 }
389 412
  413 +
  414 + /**
  415 + * 获得角色关联的工具回调列表
  416 + *
  417 + * 1. 通过角色的 toolIds,解析本地注册的工具
  418 + * 2. 通过角色的 mcpClientNames,匹配已连接的 MCP Client,获取它们提供的工具
  419 + *
  420 + * @param roleId 角色编号
  421 + * @return 工具回调列表;角色编号为空、或角色不存在时,返回 null
  422 + */
390 private List<ToolCallback> getToolCallbackListByRoleId(Long roleId) { 423 private List<ToolCallback> getToolCallbackListByRoleId(Long roleId) {
391 if (roleId == null) { 424 if (roleId == null) {
392 return null; 425 return null;
@@ -464,15 +497,19 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -464,15 +497,19 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
464 return contextMessages; 497 return contextMessages;
465 } 498 }
466 499
467 - private UserMessage buildAttachmentUserMessage(List<String> attachmentUrls) { 500 + private UserMessage buildAttachmentUserMessage(List<String> attachmentUrls, AiModelDO model) {
468 if (CollUtil.isEmpty(attachmentUrls)) { 501 if (CollUtil.isEmpty(attachmentUrls)) {
469 return null; 502 return null;
470 } 503 }
471 // 读取文件内容 504 // 读取文件内容
472 Map<String, String> attachmentContents = Maps.newLinkedHashMapWithExpectedSize(attachmentUrls.size()); 505 Map<String, String> attachmentContents = Maps.newLinkedHashMapWithExpectedSize(attachmentUrls.size());
473 for (String attachmentUrl : attachmentUrls) { 506 for (String attachmentUrl : attachmentUrls) {
  507 + // 模型配置了 image 输入的图片,已通过 UserMessage 的 Media 传输
  508 + if (isMediaImage(attachmentUrl, model)) {
  509 + continue;
  510 + }
474 try { 511 try {
475 - String name = FileNameUtil.getName(attachmentUrl); 512 + String name = getAttachmentFileName(attachmentUrl);
476 String mineType = FileTypeUtils.getMineType(name); 513 String mineType = FileTypeUtils.getMineType(name);
477 String content; 514 String content;
478 if (FileTypeUtils.isImage(mineType)) { 515 if (FileTypeUtils.isImage(mineType)) {
@@ -500,6 +537,64 @@ public class AiChatMessageServiceImpl implements AiChatMessageService { @@ -500,6 +537,64 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
500 return new UserMessage(String.format(Attachment_USER_MESSAGE_TEMPLATE, attachment)); 537 return new UserMessage(String.format(Attachment_USER_MESSAGE_TEMPLATE, attachment));
501 } 538 }
502 539
  540 + /**
  541 + * 构建 user 消息:文本和该轮的图片放在同一条 {@link UserMessage},图片以 Resource 形式作为 {@link Media}
  542 + *
  543 + * @param content 文本
  544 + * @param attachmentUrls 该轮附件 URL 数组
  545 + * @param model 模型
  546 + * @return user 消息;没有图片时为纯文本消息。图片读取失败时抛出异常,不降级为纯文本
  547 + */
  548 + private UserMessage buildUserMessage(String content, List<String> attachmentUrls, AiModelDO model) {
  549 + // 1. 读取该轮图片:仅模型配置了 image 输入的图片,以 Resource 作为 Media;读取失败直接报错,不降级为纯文本
  550 + List<Media> medias = new ArrayList<>();
  551 + if (CollUtil.isNotEmpty(attachmentUrls)) {
  552 + for (String url : attachmentUrls) {
  553 + if (!isMediaImage(url, model)) {
  554 + continue;
  555 + }
  556 + try {
  557 + String mineType = FileTypeUtils.getMineType(getAttachmentFileName(url));
  558 + medias.add(new Media(MimeTypeUtils.parseMimeType(mineType), new ByteArrayResource(HttpUtil.downloadBytes(url))));
  559 + } catch (Exception e) {
  560 + log.error("[buildUserMessage][读取图片({}) 发生异常]", url, e);
  561 + throw exception(CHAT_MESSAGE_IMAGE_READ_FAIL, getAttachmentFileName(url));
  562 + }
  563 + }
  564 + }
  565 + // 2.1 没有图片:纯文本的 user 消息
  566 + if (CollUtil.isEmpty(medias)) {
  567 + return new UserMessage(content);
  568 + }
  569 + // 2.2 有图片:文本和图片放在同一条 user 消息
  570 + return UserMessage.builder().text(content).media(medias).build();
  571 + }
  572 +
  573 + /**
  574 + * 判断附件是否作为 {@link Media} 传输:是图片,并且模型配置了 image 输入
  575 + *
  576 + * 其它附件(未配置 image 的图片、视频、文档)走原来的 Attachment 文本逻辑
  577 + *
  578 + * @param url 附件 URL
  579 + * @param model 模型
  580 + * @return 是否作为 Media 传输
  581 + */
  582 + private static boolean isMediaImage(String url, AiModelDO model) {
  583 + return FileTypeUtils.isImage(FileTypeUtils.getMineType(getAttachmentFileName(url)))
  584 + && AiModelInputModalityEnum.isSupported(model.getInputModalities(), AiModelInputModalityEnum.IMAGE);
  585 + }
  586 +
  587 + /**
  588 + * 获得附件的文件名:复用 {@link HttpUtils#removeUrlPathQueryAndFragment(String)} 去掉 ?query 和 #fragment
  589 + * (例如签名 URL 的 X-Amz-Signature),再取文件名
  590 + *
  591 + * @param url 附件 URL
  592 + * @return 文件名
  593 + */
  594 + private static String getAttachmentFileName(String url) {
  595 + return FileNameUtil.getName(HttpUtils.removeUrlPathQueryAndFragment(url));
  596 + }
  597 +
503 private AiChatMessageDO createChatMessage(Long conversationId, Long replyId, 598 private AiChatMessageDO createChatMessage(Long conversationId, Long replyId,
504 AiModelDO model, Long userId, Long roleId, 599 AiModelDO model, Long userId, Long roleId,
505 MessageType messageType, String content, Boolean useContext, 600 MessageType messageType, String content, Boolean useContext,
yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/util/AiUtils.java
@@ -93,6 +93,17 @@ public class AiUtils { @@ -93,6 +93,17 @@ public class AiUtils {
93 93
94 public static ChatOptions buildChatOptions(AiPlatformEnum platform, String model, Double temperature, Integer maxTokens, 94 public static ChatOptions buildChatOptions(AiPlatformEnum platform, String model, Double temperature, Integer maxTokens,
95 List<ToolCallback> toolCallbacks, Map<String, Object> toolContext) { 95 List<ToolCallback> toolCallbacks, Map<String, Object> toolContext) {
  96 + return buildChatOptions(platform, model, temperature, maxTokens, toolCallbacks, toolContext, false);
  97 + }
  98 +
  99 + /**
  100 + * 构建 ChatOptions
  101 + *
  102 + * @param hasMedia 本次 Prompt 是否含 Media(包括选入上下文的历史消息)。通义含 Media 时必须走多模态接口
  103 + */
  104 + public static ChatOptions buildChatOptions(AiPlatformEnum platform, String model, Double temperature, Integer maxTokens,
  105 + List<ToolCallback> toolCallbacks, Map<String, Object> toolContext,
  106 + boolean hasMedia) {
96 toolCallbacks = ObjUtil.defaultIfNull(toolCallbacks, Collections.emptyList()); 107 toolCallbacks = ObjUtil.defaultIfNull(toolCallbacks, Collections.emptyList());
97 toolContext = ObjUtil.defaultIfNull(toolContext, Collections.emptyMap()); 108 toolContext = ObjUtil.defaultIfNull(toolContext, Collections.emptyMap());
98 // noinspection EnhancedSwitchMigration 109 // noinspection EnhancedSwitchMigration
@@ -100,7 +111,7 @@ public class AiUtils { @@ -100,7 +111,7 @@ public class AiUtils {
100 case TONG_YI: 111 case TONG_YI:
101 return DashScopeChatOptions.builder().model(model).temperature(temperature).maxToken(maxTokens) 112 return DashScopeChatOptions.builder().model(model).temperature(temperature).maxToken(maxTokens)
102 .enableThinking(true) // TODO 芋艿:默认都开启 thinking 模式,后续可以让用户配置 113 .enableThinking(true) // TODO 芋艿:默认都开启 thinking 模式,后续可以让用户配置
103 - .multiModel(TONG_YI_MULTI_MODELS.contains(model)) // 是否多模态模型 114 + .multiModel(hasMedia || TONG_YI_MULTI_MODELS.contains(model)) // 含 Media 必须走多模态接口;纯文本按名单保持原通道
104 .toolCallbacks(toolCallbacks).toolContext(toolContext).build(); 115 .toolCallbacks(toolCallbacks).toolContext(toolContext).build();
105 case DEEP_SEEK: 116 case DEEP_SEEK:
106 case DOU_BAO: // 复用 DeepSeek 客户端 117 case DOU_BAO: // 复用 DeepSeek 客户端
yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/DeepSeekChatModelTests.java
@@ -29,7 +29,7 @@ public class DeepSeekChatModelTests { @@ -29,7 +29,7 @@ public class DeepSeekChatModelTests {
29 private static final String API_KEY = SystemUtil.get("DEEPSEEK_API_KEY", 29 private static final String API_KEY = SystemUtil.get("DEEPSEEK_API_KEY",
30 "sk-xxxx"); 30 "sk-xxxx");
31 private static final String MODEL = SystemUtil.get("DEEPSEEK_MODEL", 31 private static final String MODEL = SystemUtil.get("DEEPSEEK_MODEL",
32 - "deepseek-v4-flash"); 32 + "deepseek-flash");
33 33
34 private final DeepSeekChatModel chatModel = DeepSeekChatModel.builder() 34 private final DeepSeekChatModel chatModel = DeepSeekChatModel.builder()
35 .deepSeekApi(DeepSeekApi.builder() 35 .deepSeekApi(DeepSeekApi.builder()
yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/framework/ai/core/model/chat/TongYiChatModelTests.java
1 package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat; 1 package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat;
2 2
  3 +import cn.hutool.system.SystemUtil;
3 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 4 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
4 import com.alibaba.cloud.ai.dashscope.api.DashScopeApi; 5 import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
5 import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel; 6 import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
@@ -32,13 +33,16 @@ import static java.util.Arrays.asList; @@ -32,13 +33,16 @@ import static java.util.Arrays.asList;
32 */ 33 */
33 public class TongYiChatModelTests { 34 public class TongYiChatModelTests {
34 35
  36 + private static final String API_KEY = SystemUtil.get("DASHSCOPE_API_KEY",
  37 + "sk-xxxx"); // 按需改成你的 DashScope API Key
  38 +
35 private final DashScopeChatModel chatModel = DashScopeChatModel.builder() 39 private final DashScopeChatModel chatModel = DashScopeChatModel.builder()
36 .dashScopeApi(DashScopeApi.builder() 40 .dashScopeApi(DashScopeApi.builder()
37 - .apiKey("sk-cd9f39e99ea54840bd1888282325f55a") // https://bailian.console.aliyun.com/cn-beijing/?tab=model#/api-key 获取密钥 41 + .apiKey(API_KEY) // https://bailian.console.aliyun.com/cn-beijing/?tab=model#/api-key 获取密钥
38 .build()) 42 .build())
39 .defaultOptions(DashScopeChatOptions.builder() 43 .defaultOptions(DashScopeChatOptions.builder()
40 - .multiModel(true) // 注意:当使用 qwen3.6-plus 等多模态模型,需要设置为 true,可见 https://help.aliyun.com/zh/model-studio/error-code#error-url 链接  
41 - .model("qwen3.6-plus") // 模型 44 + .multiModel(true) // 注意:当使用 qwen3.8-max 等多模态模型,需要设置为 true,可见 https://help.aliyun.com/zh/model-studio/error-code#error-url 链接
  45 + .model("qwen3.8-max") // 模型
42 // .model("deepseek-r1") // 模型(deepseek-r1) 46 // .model("deepseek-r1") // 模型(deepseek-r1)
43 // .model("deepseek-v3") // 模型(deepseek-v3) 47 // .model("deepseek-v3") // 模型(deepseek-v3)
44 // .model("deepseek-r1-distill-qwen-1.5b") // 模型(deepseek-r1-distill-qwen-1.5b) 48 // .model("deepseek-r1-distill-qwen-1.5b") // 模型(deepseek-r1-distill-qwen-1.5b)
@@ -85,7 +89,7 @@ public class TongYiChatModelTests { @@ -85,7 +89,7 @@ public class TongYiChatModelTests {
85 List<Message> messages = new ArrayList<>(); 89 List<Message> messages = new ArrayList<>();
86 messages.add(new UserMessage("详细分析下,如何设计一个电商系统?")); 90 messages.add(new UserMessage("详细分析下,如何设计一个电商系统?"));
87 DashScopeChatOptions options = DashScopeChatOptions.builder() 91 DashScopeChatOptions options = DashScopeChatOptions.builder()
88 - .model("qwen3.6-plus").multiModel(true) 92 + .model("qwen3.8-max").multiModel(true)
89 // .withModel("qwen-max-2025-01-25") 93 // .withModel("qwen-max-2025-01-25")
90 .enableThinking(true) // 必须设置,否则会报错 94 .enableThinking(true) // 必须设置,否则会报错
91 .build(); 95 .build();
@@ -105,7 +109,7 @@ public class TongYiChatModelTests { @@ -105,7 +109,7 @@ public class TongYiChatModelTests {
105 // 准备环境 109 // 准备环境
106 RerankModel rerankModel = new DashScopeRerankModel( 110 RerankModel rerankModel = new DashScopeRerankModel(
107 DashScopeApi.builder() 111 DashScopeApi.builder()
108 - .apiKey("sk-47aa124781be4bfb95244cc62f63f7d0") 112 + .apiKey(API_KEY)
109 .build()); 113 .build());
110 // 准备参数 114 // 准备参数
111 String query = "spring"; 115 String query = "spring";
yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageServiceImplTest.java
1 package cn.iocoder.yudao.module.ai.service.chat; 1 package cn.iocoder.yudao.module.ai.service.chat;
2 2
  3 +import cn.hutool.core.codec.Base64;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.hutool.http.HttpUtil;
  6 +import cn.iocoder.yudao.module.ai.enums.model.AiModelInputModalityEnum;
  7 +import org.mockito.MockedStatic;
  8 +import org.springframework.ai.chat.messages.AssistantMessage;
  9 +import org.springframework.ai.chat.messages.Message;
  10 +import org.springframework.ai.chat.messages.MessageType;
  11 +import org.springframework.ai.chat.messages.UserMessage;
  12 +import org.springframework.ai.chat.model.ChatModel;
  13 +import org.springframework.ai.chat.model.ChatResponse;
  14 +import org.springframework.ai.chat.model.Generation;
  15 +import org.springframework.ai.chat.prompt.Prompt;
  16 +import org.springframework.ai.content.Media;
  17 +import java.util.ArrayList;
  18 +import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
3 import cn.hutool.core.collection.CollUtil; 19 import cn.hutool.core.collection.CollUtil;
4 import cn.iocoder.yudao.framework.common.pojo.CommonResult; 20 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
5 import cn.iocoder.yudao.framework.security.core.LoginUser; 21 import cn.iocoder.yudao.framework.security.core.LoginUser;
@@ -50,6 +66,8 @@ import java.util.concurrent.CopyOnWriteArrayList; @@ -50,6 +66,8 @@ import java.util.concurrent.CopyOnWriteArrayList;
50 import java.util.concurrent.atomic.AtomicLong; 66 import java.util.concurrent.atomic.AtomicLong;
51 import java.util.stream.Collectors; 67 import java.util.stream.Collectors;
52 68
  69 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  70 +import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_MESSAGE_IMAGE_READ_FAIL;
53 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; 71 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
54 import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_STREAM_ERROR; 72 import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_STREAM_ERROR;
55 import static cn.iocoder.yudao.module.ai.util.AiUtils.TOOL_CONTEXT_LOGIN_USER; 73 import static cn.iocoder.yudao.module.ai.util.AiUtils.TOOL_CONTEXT_LOGIN_USER;
@@ -377,6 +395,348 @@ public class AiChatMessageServiceImplTest extends BaseMockitoUnitTest { @@ -377,6 +395,348 @@ public class AiChatMessageServiceImplTest extends BaseMockitoUnitTest {
377 : ChatCompletionFinishReason.STOP; 395 : ChatCompletionFinishReason.STOP;
378 } 396 }
379 397
  398 + // ========== 附件多模态:模型配置 image 的图片走 Media,其它附件走原 Attachment 文本 ==========
  399 +
  400 + /**
  401 + * 自定义型号:不在任何已知名单里,是否传图只看模型配置的 inputModalities
  402 + */
  403 + private static final String CUSTOM_MODEL_NAME = "my-vlm-x";
  404 + private static final String IMAGE_URL = "https://www.iocoder.cn/a.png?X-Amz-Signature=xxx#v1";
  405 + private static final String OLD_IMAGE_URL = "https://www.iocoder.cn/old.png";
  406 + private static final String PDF_URL = "https://www.iocoder.cn/b.pdf";
  407 + private static final String VIDEO_URL = "https://www.iocoder.cn/c.mp4";
  408 + private static final byte[] IMAGE_BYTES = new byte[]{1, 2, 3};
  409 + private static final List<String> TEXT_IMAGE = asList(AiModelInputModalityEnum.TEXT.getModality(),
  410 + AiModelInputModalityEnum.IMAGE.getModality());
  411 +
  412 + @Mock
  413 + private ChatModel chatModel;
  414 +
  415 + @Test
  416 + public void testSendMessage_imageWithImageModality() {
  417 + // 准备参数:签名 URL 的图片 + pdf 文档
  418 + Long userId = randomLongId();
  419 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  420 + .setContent("这是我的车?").setAttachmentUrls(asList(IMAGE_URL, PDF_URL));
  421 + // mock 对话、模型:自定义型号配置了 image
  422 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  423 + // mock 方法
  424 + when(knowledgeDocumentService.readUrl(eq(PDF_URL))).thenReturn("文档内容");
  425 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  426 +
  427 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  428 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL))).thenReturn(IMAGE_BYTES);
  429 + // 调用
  430 + chatMessageService.sendMessage(reqVO, userId);
  431 + }
  432 + // 断言:图片和问题在同一条 UserMessage 中,以 Media 传输
  433 + Prompt prompt = captureCallPrompt();
  434 + UserMessage currentMessage = findUserMessage(prompt, reqVO.getContent());
  435 + assertEquals(1, currentMessage.getMedia().size());
  436 + Media media = CollUtil.getFirst(currentMessage.getMedia());
  437 + assertEquals("image/png", media.getMimeType().toString());
  438 + assertArrayEquals(IMAGE_BYTES, (byte[]) media.getData());
  439 + // 断言:文档走 Attachment 文本,图片不再拼进文本
  440 + UserMessage attachmentMessage = findUserMessage(prompt, "<Attachment");
  441 + assertTrue(attachmentMessage.getText().contains("文档内容"));
  442 + assertFalse(attachmentMessage.getText().contains("a.png"));
  443 + verify(knowledgeDocumentService, never()).readUrl(eq(IMAGE_URL));
  444 + }
  445 +
  446 + @Test
  447 + public void testSendMessage_imageWithoutImageModality() {
  448 + // 准备参数
  449 + Long userId = randomLongId();
  450 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  451 + .setContent("这是我的车?").setAttachmentUrls(singletonList(IMAGE_URL));
  452 + // mock 对话、模型:未配置 image
  453 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK,
  454 + singletonList(AiModelInputModalityEnum.TEXT.getModality()));
  455 + // mock 方法
  456 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  457 +
  458 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  459 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL))).thenReturn(IMAGE_BYTES);
  460 + // 调用:不拒绝
  461 + chatMessageService.sendMessage(reqVO, userId);
  462 + }
  463 + // 断言:没有 Media,图片沿用原逻辑,以 Base64 拼进 Attachment 文本
  464 + Prompt prompt = captureCallPrompt();
  465 + assertNoMedia(prompt);
  466 + UserMessage attachmentMessage = findUserMessage(prompt, "<Attachment");
  467 + assertTrue(attachmentMessage.getText().contains("name=\"a.png\""));
  468 + assertTrue(attachmentMessage.getText().contains(Base64.encode(IMAGE_BYTES)));
  469 + }
  470 +
  471 + @Test
  472 + public void testSendMessage_historyImage() {
  473 + // 准备参数
  474 + Long userId = randomLongId();
  475 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  476 + .setContent("这是我的车?").setUseContext(true);
  477 + // mock 对话、模型:maxContexts = 1,只选入最近一组
  478 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  479 + when(chatMessageMapper.selectListByConversationId(eq(reqVO.getConversationId())))
  480 + .thenReturn(buildHistoryMessages());
  481 + // mock 方法
  482 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  483 +
  484 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  485 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL))).thenReturn(IMAGE_BYTES);
  486 + // 调用
  487 + chatMessageService.sendMessage(reqVO, userId);
  488 + // 断言:未选入上下文的历史图片不下载
  489 + httpUtilMock.verify(() -> HttpUtil.downloadBytes(eq(OLD_IMAGE_URL)), never());
  490 + }
  491 + // 断言:历史图片挂在它所属的 user 消息上;当前消息没有图片
  492 + Prompt prompt = captureCallPrompt();
  493 + assertEquals(1, findUserMessage(prompt, "上一问").getMedia().size());
  494 + assertTrue(CollUtil.isEmpty(findUserMessage(prompt, reqVO.getContent()).getMedia()));
  495 + assertNull(findUserMessageOrNull(prompt, "旧问题"));
  496 + }
  497 +
  498 + @Test
  499 + public void testSendMessage_historyImageWithoutContext() {
  500 + // 准备参数:不使用上下文
  501 + Long userId = randomLongId();
  502 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  503 + .setContent("这是我的车?").setUseContext(false);
  504 + // mock 对话、模型
  505 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  506 + when(chatMessageMapper.selectListByConversationId(eq(reqVO.getConversationId())))
  507 + .thenReturn(buildHistoryMessages());
  508 + // mock 方法
  509 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  510 +
  511 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  512 + // 调用
  513 + chatMessageService.sendMessage(reqVO, userId);
  514 + // 断言:历史图片不下载
  515 + httpUtilMock.verify(() -> HttpUtil.downloadBytes(any(String.class)), never());
  516 + }
  517 + // 断言:没有 Media,也没有历史消息
  518 + Prompt prompt = captureCallPrompt();
  519 + assertNoMedia(prompt);
  520 + assertNull(findUserMessageOrNull(prompt, "上一问"));
  521 + }
  522 +
  523 + @Test
  524 + public void testSendMessage_imageReadFail() {
  525 + // 准备参数
  526 + Long userId = randomLongId();
  527 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  528 + .setContent("这是我的车?").setAttachmentUrls(singletonList(IMAGE_URL));
  529 + // mock 对话、模型:配置了 image
  530 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  531 +
  532 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  533 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL)))
  534 + .thenThrow(new IllegalStateException("下载失败"));
  535 + // 调用,并断言异常
  536 + assertServiceException(() -> chatMessageService.sendMessage(reqVO, userId),
  537 + CHAT_MESSAGE_IMAGE_READ_FAIL, "a.png");
  538 + }
  539 + // 断言:不调用模型、不写消息
  540 + verify(chatModel, never()).call(any(Prompt.class));
  541 + verify(chatMessageMapper, never()).insert(any(AiChatMessageDO.class));
  542 + }
  543 +
  544 + @Test
  545 + public void testSendChatMessageStream_imageReadFail() {
  546 + // 准备参数
  547 + Long userId = randomLongId();
  548 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  549 + .setContent("这是我的车?").setAttachmentUrls(singletonList(IMAGE_URL));
  550 + // mock 对话、模型:配置了 image
  551 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  552 +
  553 + List<CommonResult<AiChatMessageSendRespVO>> results;
  554 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  555 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL)))
  556 + .thenThrow(new IllegalStateException("下载失败"));
  557 + // 调用
  558 + results = chatMessageService.sendChatMessageStream(reqVO, userId).collectList().block();
  559 + }
  560 + // 断言:返回错误帧,不调用模型、不写消息
  561 + assertNotNull(results);
  562 + assertEquals(1, results.size());
  563 + assertEquals(CHAT_MESSAGE_IMAGE_READ_FAIL.getCode(), results.get(0).getCode());
  564 + verify(chatModel, never()).stream(any(Prompt.class));
  565 + verify(chatMessageMapper, never()).insert(any(AiChatMessageDO.class));
  566 + }
  567 +
  568 + @Test
  569 + public void testSendChatMessageStream_imageWithImageModality() {
  570 + // 准备参数
  571 + Long userId = randomLongId();
  572 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  573 + .setContent("这是我的车?").setAttachmentUrls(singletonList(IMAGE_URL));
  574 + // mock 对话、模型:配置了 image
  575 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  576 + // mock 方法
  577 + when(chatModel.stream(any(Prompt.class))).thenReturn(Flux.just(buildChatResponse()));
  578 +
  579 + List<CommonResult<AiChatMessageSendRespVO>> results;
  580 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  581 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL))).thenReturn(IMAGE_BYTES);
  582 + // 调用
  583 + results = chatMessageService.sendChatMessageStream(reqVO, userId).collectList().block();
  584 + }
  585 + // 断言:正常返回,图片以 Media 传输
  586 + assertNotNull(results);
  587 + assertTrue(CollUtil.getLast(results).isSuccess());
  588 + ArgumentCaptor<Prompt> promptCaptor = ArgumentCaptor.forClass(Prompt.class);
  589 + verify(chatModel).stream(promptCaptor.capture());
  590 + assertEquals(1, findUserMessage(promptCaptor.getValue(), reqVO.getContent()).getMedia().size());
  591 + }
  592 +
  593 + @Test
  594 + public void testSendMessage_video() {
  595 + // 准备参数
  596 + Long userId = randomLongId();
  597 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  598 + .setContent("视频里有什么?").setAttachmentUrls(singletonList(VIDEO_URL));
  599 + // mock 对话、模型:即使配置了 video,本期也不实现视频传输
  600 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK,
  601 + asList(AiModelInputModalityEnum.TEXT.getModality(), AiModelInputModalityEnum.IMAGE.getModality(),
  602 + AiModelInputModalityEnum.VIDEO.getModality()));
  603 + // mock 方法
  604 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  605 +
  606 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  607 + // 调用:不拒绝
  608 + chatMessageService.sendMessage(reqVO, userId);
  609 + // 断言:视频不当作图片下载
  610 + httpUtilMock.verify(() -> HttpUtil.downloadBytes(any(String.class)), never());
  611 + }
  612 + // 断言:没有 Media,视频沿用原 Attachment 逻辑
  613 + assertNoMedia(captureCallPrompt());
  614 + verify(knowledgeDocumentService).readUrl(eq(VIDEO_URL));
  615 + }
  616 +
  617 + @Test
  618 + public void testSendMessage_textOnly() {
  619 + // 准备参数
  620 + Long userId = randomLongId();
  621 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  622 + .setContent("你好");
  623 + // mock 对话、模型
  624 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.DEEP_SEEK, TEXT_IMAGE);
  625 + // mock 方法
  626 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  627 +
  628 + // 调用
  629 + chatMessageService.sendMessage(reqVO, userId);
  630 + // 断言:纯文本,没有 Media
  631 + Prompt prompt = captureCallPrompt();
  632 + assertNoMedia(prompt);
  633 + assertEquals(reqVO.getContent(), findUserMessage(prompt, reqVO.getContent()).getText());
  634 + }
  635 +
  636 + @Test
  637 + public void testSendMessage_tongYiMultiModelByMedia() {
  638 + // 准备参数:当前消息纯文本,历史消息带图
  639 + Long userId = randomLongId();
  640 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  641 + .setContent("这是我的车?").setUseContext(true);
  642 + // mock 对话、模型:通义的自定义型号,不在 TONG_YI_MULTI_MODELS 名单
  643 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.TONG_YI, TEXT_IMAGE);
  644 + when(chatMessageMapper.selectListByConversationId(eq(reqVO.getConversationId())))
  645 + .thenReturn(buildHistoryMessages());
  646 + // mock 方法
  647 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  648 +
  649 + try (MockedStatic<HttpUtil> httpUtilMock = mockStatic(HttpUtil.class)) {
  650 + httpUtilMock.when(() -> HttpUtil.downloadBytes(eq(IMAGE_URL))).thenReturn(IMAGE_BYTES);
  651 + // 调用
  652 + chatMessageService.sendMessage(reqVO, userId);
  653 + }
  654 + // 断言:Prompt 含历史 Media,走多模态接口
  655 + DashScopeChatOptions options = (DashScopeChatOptions) captureCallPrompt().getOptions();
  656 + assertEquals(Boolean.TRUE, options.getMultiModel());
  657 + }
  658 +
  659 + @Test
  660 + public void testSendMessage_tongYiTextOnly() {
  661 + // 准备参数
  662 + Long userId = randomLongId();
  663 + AiChatMessageSendReqVO reqVO = new AiChatMessageSendReqVO().setConversationId(randomLongId())
  664 + .setContent("你好");
  665 + // mock 对话、模型:通义的自定义型号
  666 + mockMediaConversation(reqVO.getConversationId(), userId, AiPlatformEnum.TONG_YI, TEXT_IMAGE);
  667 + // mock 方法
  668 + when(chatModel.call(any(Prompt.class))).thenReturn(buildChatResponse());
  669 +
  670 + // 调用
  671 + chatMessageService.sendMessage(reqVO, userId);
  672 + // 断言:纯文本保持原通道
  673 + DashScopeChatOptions options = (DashScopeChatOptions) captureCallPrompt().getOptions();
  674 + assertEquals(Boolean.FALSE, options.getMultiModel());
  675 + }
  676 +
  677 +
  678 + private void mockMediaConversation(Long conversationId, Long userId, AiPlatformEnum platform,
  679 + List<String> inputModalities) {
  680 + // mock 对话
  681 + AiChatConversationDO conversation = new AiChatConversationDO().setId(conversationId).setUserId(userId)
  682 + .setModelId(randomLongId()).setTemperature(TEMPERATURE).setMaxTokens(MAX_TOKENS).setMaxContexts(1);
  683 + when(chatConversationService.validateChatConversationExists(eq(conversationId))).thenReturn(conversation);
  684 + // mock 模型:mock 的 ChatModel,用于捕获 Prompt
  685 + AiModelDO model = new AiModelDO().setId(conversation.getModelId()).setName("自定义视觉模型")
  686 + .setModel(CUSTOM_MODEL_NAME).setPlatform(platform.getPlatform()).setInputModalities(inputModalities);
  687 + when(modalService.validateModel(eq(model.getId()))).thenReturn(model);
  688 + when(modalService.getChatModel(eq(model.getId()))).thenReturn(chatModel);
  689 + }
  690 +
  691 + /**
  692 + * 构建历史消息:两组 user + assistant,各带一张图片
  693 + */
  694 + private static List<AiChatMessageDO> buildHistoryMessages() {
  695 + List<AiChatMessageDO> messages = new ArrayList<>();
  696 + messages.add(new AiChatMessageDO().setId(11L).setType(MessageType.USER.getValue())
  697 + .setContent("旧问题").setAttachmentUrls(singletonList(OLD_IMAGE_URL)));
  698 + messages.add(new AiChatMessageDO().setId(12L).setReplyId(11L).setType(MessageType.ASSISTANT.getValue())
  699 + .setContent("旧回答"));
  700 + messages.add(new AiChatMessageDO().setId(13L).setType(MessageType.USER.getValue())
  701 + .setContent("上一问").setAttachmentUrls(singletonList(IMAGE_URL)));
  702 + messages.add(new AiChatMessageDO().setId(14L).setReplyId(13L).setType(MessageType.ASSISTANT.getValue())
  703 + .setContent("上一答"));
  704 + return messages;
  705 + }
  706 +
  707 + private Prompt captureCallPrompt() {
  708 + ArgumentCaptor<Prompt> promptCaptor = ArgumentCaptor.forClass(Prompt.class);
  709 + verify(chatModel).call(promptCaptor.capture());
  710 + return promptCaptor.getValue();
  711 + }
  712 +
  713 + private static UserMessage findUserMessage(Prompt prompt, String text) {
  714 + UserMessage message = findUserMessageOrNull(prompt, text);
  715 + assertNotNull(message, "未找到包含(" + text + ")的 UserMessage");
  716 + return message;
  717 + }
  718 +
  719 + private static UserMessage findUserMessageOrNull(Prompt prompt, String text) {
  720 + for (Message message : prompt.getInstructions()) {
  721 + if (message instanceof UserMessage && StrUtil.contains(message.getText(), text)) {
  722 + return (UserMessage) message;
  723 + }
  724 + }
  725 + return null;
  726 + }
  727 +
  728 + private static void assertNoMedia(Prompt prompt) {
  729 + for (Message message : prompt.getInstructions()) {
  730 + if (message instanceof UserMessage) {
  731 + assertTrue(CollUtil.isEmpty(((UserMessage) message).getMedia()));
  732 + }
  733 + }
  734 + }
  735 +
  736 + private static ChatResponse buildChatResponse() {
  737 + return new ChatResponse(singletonList(new Generation(new AssistantMessage("好的"))));
  738 + }
  739 +
380 @Data 740 @Data
381 public static class WeatherRequest { 741 public static class WeatherRequest {
382 742
yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/service/model/AiModelServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai.service.model;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.ai.controller.admin.model.vo.model.AiModelSaveReqVO;
  7 +import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO;
  8 +import cn.iocoder.yudao.module.ai.dal.mysql.model.AiChatMapper;
  9 +import cn.iocoder.yudao.module.ai.enums.model.AiModelTypeEnum;
  10 +import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
  11 +import cn.iocoder.yudao.module.ai.framework.ai.core.model.AiModelFactory;
  12 +import jakarta.validation.ConstraintViolationException;
  13 +import org.junit.jupiter.api.Test;
  14 +import org.mockito.ArgumentCaptor;
  15 +import org.mockito.InjectMocks;
  16 +import org.mockito.Mock;
  17 +
  18 +import java.util.List;
  19 +
  20 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  21 +import static java.util.Arrays.asList;
  22 +import static org.junit.jupiter.api.Assertions.*;
  23 +import static org.mockito.ArgumentMatchers.eq;
  24 +import static org.mockito.Mockito.verify;
  25 +import static org.mockito.Mockito.when;
  26 +
  27 +/**
  28 + * {@link AiModelServiceImpl} 的单元测试类
  29 + *
  30 + * 重点:输入模态 inputModalities 按前端传入的数组原样保存,后端不做规范化
  31 + *
  32 + * @author 芋道源码
  33 + */
  34 +public class AiModelServiceImplTest extends BaseMockitoUnitTest {
  35 +
  36 + @InjectMocks
  37 + private AiModelServiceImpl modelService;
  38 +
  39 + @Mock
  40 + private AiApiKeyService apiKeyService;
  41 + @Mock
  42 + private AiChatMapper modelMapper;
  43 + @Mock
  44 + private AiModelFactory modelFactory;
  45 +
  46 + @Test
  47 + public void testCreateModel_inputModalities() {
  48 + // 准备参数:顺序、重复按前端原样
  49 + List<String> inputModalities = asList("image", "text");
  50 + AiModelSaveReqVO reqVO = buildSaveReqVO(null).setInputModalities(inputModalities);
  51 +
  52 + // 调用
  53 + modelService.createModel(reqVO);
  54 + // 断言
  55 + ArgumentCaptor<AiModelDO> modelCaptor = ArgumentCaptor.forClass(AiModelDO.class);
  56 + verify(modelMapper).insert(modelCaptor.capture());
  57 + assertEquals(inputModalities, modelCaptor.getValue().getInputModalities());
  58 + }
  59 +
  60 + @Test
  61 + public void testUpdateModel_inputModalities() {
  62 + // mock 数据:库里原有 image、video
  63 + Long id = randomLongId();
  64 + when(modelMapper.selectById(eq(id))).thenReturn(new AiModelDO().setId(id)
  65 + .setInputModalities(asList("text", "image", "video")));
  66 + // 准备参数:前端只传 text
  67 + List<String> inputModalities = asList("text");
  68 + AiModelSaveReqVO reqVO = buildSaveReqVO(id).setInputModalities(inputModalities);
  69 +
  70 + // 调用
  71 + modelService.updateModel(reqVO);
  72 + // 断言:直接采用前端数组,不合并库里旧值
  73 + ArgumentCaptor<AiModelDO> modelCaptor = ArgumentCaptor.forClass(AiModelDO.class);
  74 + verify(modelMapper).updateById(modelCaptor.capture());
  75 + assertEquals(inputModalities, modelCaptor.getValue().getInputModalities());
  76 + }
  77 +
  78 + @Test
  79 + public void testSaveReqVO_inputModalitiesValid() {
  80 + // 准备参数
  81 + AiModelSaveReqVO reqVO = buildSaveReqVO(null).setInputModalities(asList("text", "image", "video"));
  82 +
  83 + // 调用,并断言:合法枚举值通过
  84 + assertDoesNotThrow(() -> ValidationUtils.validate(reqVO));
  85 + }
  86 +
  87 + @Test
  88 + public void testSaveReqVO_inputModalitiesInvalid() {
  89 + // 准备参数:audio 不在枚举中
  90 + AiModelSaveReqVO reqVO = buildSaveReqVO(null).setInputModalities(asList("text", "audio"));
  91 +
  92 + // 调用,并断言:@InEnum 拒绝非法值
  93 + assertThrows(ConstraintViolationException.class, () -> ValidationUtils.validate(reqVO));
  94 + }
  95 +
  96 + private static AiModelSaveReqVO buildSaveReqVO(Long id) {
  97 + AiModelSaveReqVO reqVO = new AiModelSaveReqVO();
  98 + reqVO.setId(id);
  99 + reqVO.setKeyId(randomLongId());
  100 + reqVO.setName("自定义视觉模型");
  101 + reqVO.setModel("my-vlm-x");
  102 + reqVO.setPlatform(AiPlatformEnum.DEEP_SEEK.getPlatform());
  103 + reqVO.setType(AiModelTypeEnum.CHAT.getType());
  104 + reqVO.setSort(1);
  105 + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
  106 + return reqVO;
  107 + }
  108 +
  109 +}
yudao-module-ai/src/test/java/cn/iocoder/yudao/module/ai/util/AiUtilsTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai.util;
  2 +
  3 +import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
  4 +import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
  5 +import org.junit.jupiter.api.Test;
  6 +
  7 +import static org.junit.jupiter.api.Assertions.assertEquals;
  8 +
  9 +/**
  10 + * {@link AiUtils} 的单元测试类
  11 + *
  12 + * 重点:通义的 multiModel 按本次 Prompt 是否含 Media 选择请求通道,不受型号名单限制
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +public class AiUtilsTest {
  17 +
  18 + /**
  19 + * 自定义型号:不在 {@link AiUtils#TONG_YI_MULTI_MODELS} 名单中
  20 + */
  21 + private static final String CUSTOM_MODEL = "my-qwen-vl";
  22 + /**
  23 + * 名单内的型号
  24 + */
  25 + private static final String LISTED_MODEL = "qwen3-vl-plus";
  26 +
  27 + @Test
  28 + public void testBuildChatOptions_tongYiCustomModelWithMedia() {
  29 + // 调用
  30 + DashScopeChatOptions options = (DashScopeChatOptions) AiUtils.buildChatOptions(AiPlatformEnum.TONG_YI,
  31 + CUSTOM_MODEL, 0.7, 1024, null, null, true);
  32 + // 断言:含 Media 走多模态接口
  33 + assertEquals(Boolean.TRUE, options.getMultiModel());
  34 + }
  35 +
  36 + @Test
  37 + public void testBuildChatOptions_tongYiCustomModelTextOnly() {
  38 + // 调用
  39 + DashScopeChatOptions options = (DashScopeChatOptions) AiUtils.buildChatOptions(AiPlatformEnum.TONG_YI,
  40 + CUSTOM_MODEL, 0.7, 1024, null, null, false);
  41 + // 断言:纯文本保持原通道
  42 + assertEquals(Boolean.FALSE, options.getMultiModel());
  43 + }
  44 +
  45 + @Test
  46 + public void testBuildChatOptions_tongYiListedModelTextOnly() {
  47 + // 调用:原有重载,默认不含 Media
  48 + DashScopeChatOptions options = (DashScopeChatOptions) AiUtils.buildChatOptions(AiPlatformEnum.TONG_YI,
  49 + LISTED_MODEL, 0.7, 1024);
  50 + // 断言:名单内型号纯文本仍按名单走多模态接口,与改动前一致
  51 + assertEquals(Boolean.TRUE, options.getMultiModel());
  52 + }
  53 +
  54 +}