Commit 7c397bbfae3e768b26e3ee962c5aa7064e8d7c96

Authored by YunaiV
1 parent c1bddc86

feat(ai1): 同步 XXL-AI Cloud 增量,优化请求 Header、技能注册和首页图表

- 前后端请求 Header 统一为 Map 对象,保留顺序、逐值 ENV 解析及会话占位符,支持重复校验和空对象清空
- 升级 spring-ai-agent-utils 至 0.13.0,固定技能注册顺序,明确同名技能首次注册行为
- 避免占位符解析和供应商 HTTP 探测异常回显配置、密钥原文
- 首页图表关闭动画,支持容器尺寸变化、keepalive 恢复及卸载释放
- 补充模型、Header 和技能执行回归测试,统一 AI1 单测代码风格

验证:jdk25、sb4 各 210 项测试通过;前端 ESLint、类型检查、构建及浏览器回归通过
Showing 23 changed files with 546 additions and 120 deletions
yudao-module-ai1-springai/pom.xml
@@ -22,7 +22,7 @@ @@ -22,7 +22,7 @@
22 <properties> 22 <properties>
23 <!-- 以下依赖不在 spring-ai-bom 中,需显式声明版本 --> 23 <!-- 以下依赖不在 spring-ai-bom 中,需显式声明版本 -->
24 <mcp-sdk.version>2.0.1</mcp-sdk.version> 24 <mcp-sdk.version>2.0.1</mcp-sdk.version>
25 - <spring-ai-agent-utils.version>0.12.0</spring-ai-agent-utils.version> 25 + <spring-ai-agent-utils.version>0.13.0</spring-ai-agent-utils.version>
26 <milvus-sdk.version>3.0.10</milvus-sdk.version> 26 <milvus-sdk.version>3.0.10</milvus-sdk.version>
27 </properties> 27 </properties>
28 28
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java
@@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider;
3 import io.swagger.v3.oas.annotations.media.Schema; 3 import io.swagger.v3.oas.annotations.media.Schema;
4 import lombok.Data; 4 import lombok.Data;
5 5
  6 +import java.util.Map;
  7 +
6 import java.time.LocalDateTime; 8 import java.time.LocalDateTime;
7 9
8 @Schema(description = "管理后台 - AI1 供应商 Response VO") 10 @Schema(description = "管理后台 - AI1 供应商 Response VO")
@@ -21,8 +23,8 @@ public class Ai1ProviderRespVO { @@ -21,8 +23,8 @@ public class Ai1ProviderRespVO {
21 @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") 23 @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd")
22 private String apiKey; 24 private String apiKey;
23 25
24 - @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]")  
25 - private String headers; 26 + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}")
  27 + private Map<String, String> headers;
26 28
27 @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") 29 @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
28 private Integer status; 30 private Integer status;
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java
1 package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; 1 package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider;
2 2
3 -import cn.hutool.core.util.StrUtil;  
4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 3 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 import cn.iocoder.yudao.framework.common.validation.InEnum; 4 import cn.iocoder.yudao.framework.common.validation.InEnum;
6 import cn.iocoder.yudao.module.ai1.util.Ai1Utils; 5 import cn.iocoder.yudao.module.ai1.util.Ai1Utils;
@@ -12,6 +11,8 @@ import jakarta.validation.constraints.NotNull; @@ -12,6 +11,8 @@ import jakarta.validation.constraints.NotNull;
12 import jakarta.validation.constraints.Size; 11 import jakarta.validation.constraints.Size;
13 import lombok.Data; 12 import lombok.Data;
14 13
  14 +import java.util.Map;
  15 +
15 @Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") 16 @Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO")
16 @Data 17 @Data
17 public class Ai1ProviderSaveReqVO { 18 public class Ai1ProviderSaveReqVO {
@@ -33,9 +34,8 @@ public class Ai1ProviderSaveReqVO { @@ -33,9 +34,8 @@ public class Ai1ProviderSaveReqVO {
33 @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") 34 @Size(max = 200, message = "API 密钥长度不能超过 200 个字符")
34 private String apiKey; 35 private String apiKey;
35 36
36 - @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]")  
37 - @Size(max = 2000, message = "请求附属 Header 长度不能超过 2000 个字符")  
38 - private String headers; 37 + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}")
  38 + private Map<String, String> headers;
39 39
40 @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") 40 @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
41 @NotNull(message = "状态不能为空") 41 @NotNull(message = "状态不能为空")
@@ -46,10 +46,10 @@ public class Ai1ProviderSaveReqVO { @@ -46,10 +46,10 @@ public class Ai1ProviderSaveReqVO {
46 @Size(max = 255, message = "备注长度不能超过 255 个字符") 46 @Size(max = 255, message = "备注长度不能超过 255 个字符")
47 private String remark; 47 private String remark;
48 48
49 - @AssertTrue(message = "请求附属 Header 格式不正确,应为 JSON 数组:[{\"key\":\"...\",\"value\":\"...\"}]") 49 + @AssertTrue(message = "请求 Header 名称不能为空或重复,值必须为字符串,JSON 长度不能超过 2000 个字符")
50 @JsonIgnore 50 @JsonIgnore
51 public boolean isHeadersValid() { 51 public boolean isHeadersValid() {
52 - return StrUtil.isBlank(headers) || Ai1Utils.parseHeaders(headers) != null; 52 + return Ai1Utils.isHeadersValid(headers);
53 } 53 }
54 54
55 } 55 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java
@@ -4,9 +4,13 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; @@ -4,9 +4,13 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; 4 import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
5 import com.baomidou.mybatisplus.annotation.KeySequence; 5 import com.baomidou.mybatisplus.annotation.KeySequence;
6 import com.baomidou.mybatisplus.annotation.TableId; 6 import com.baomidou.mybatisplus.annotation.TableId;
  7 +import com.baomidou.mybatisplus.annotation.TableField;
  8 +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
7 import com.baomidou.mybatisplus.annotation.TableName; 9 import com.baomidou.mybatisplus.annotation.TableName;
8 import lombok.*; 10 import lombok.*;
9 11
  12 +import java.util.Map;
  13 +
10 /** 14 /**
11 * AI1 供应商 DO 15 * AI1 供应商 DO
12 * 16 *
@@ -14,7 +18,7 @@ import lombok.*; @@ -14,7 +18,7 @@ import lombok.*;
14 * 18 *
15 * @author 芋道源码 19 * @author 芋道源码
16 */ 20 */
17 -@TableName("ai1_provider") 21 +@TableName(value = "ai1_provider", autoResultMap = true)
18 @KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 22 @KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
19 @Data 23 @Data
20 @EqualsAndHashCode(callSuper = true) 24 @EqualsAndHashCode(callSuper = true)
@@ -42,11 +46,12 @@ public class Ai1ProviderDO extends TenantBaseDO { @@ -42,11 +46,12 @@ public class Ai1ProviderDO extends TenantBaseDO {
42 */ 46 */
43 private String apiKey; 47 private String apiKey;
44 /** 48 /**
45 - * 请求附属 Header 49 + * 请求 Header
46 * 50 *
47 - * JSON 数组,格式为 [{"key":"...","value":"..."}];value 可包含 {session} 占位符,请求时替换为会话编号 51 + * JSON 对象,格式为 {"X-Session":"{session}"};value 可包含 {session} 占位符,请求时替换为会话编号
48 */ 52 */
49 - private String headers; 53 + @TableField(typeHandler = JacksonTypeHandler.class)
  54 + private Map<String, String> headers;
50 /** 55 /**
51 * 状态 56 * 状态
52 * 57 *
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java
@@ -16,6 +16,7 @@ public interface Ai1ErrorCodeConstants { @@ -16,6 +16,7 @@ public interface Ai1ErrorCodeConstants {
16 ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); 16 ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥");
17 ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); 17 ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型");
18 ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项"); 18 ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项");
  19 + ErrorCode PROVIDER_HEADERS_INVALID = new ErrorCode(1_041_000_007, "请求 Header 格式不正确或名称重复,请检查供应商配置");
19 20
20 // ========== 模型 1-041-001-000 ========== 21 // ========== 模型 1-041-001-000 ==========
21 ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在"); 22 ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在");
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java
@@ -51,7 +51,7 @@ public class Ai1LlmChatTool { @@ -51,7 +51,7 @@ public class Ai1LlmChatTool {
51 * @param content 当前用户消息 51 * @param content 当前用户消息
52 * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 52 * @param tools 工具(ToolCallback 或 @Tool 对象),可为空
53 * @param advisors 会话 Advisor,可为空 53 * @param advisors 会话 Advisor,可为空
54 - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符 54 + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符
55 * @param onThinking 思考过程增量回调,可为空 55 * @param onThinking 思考过程增量回调,可为空
56 * @param onContent 回复内容增量回调,可为空 56 * @param onContent 回复内容增量回调,可为空
57 * @return 完整回复(内容 + 思考过程) 57 * @return 完整回复(内容 + 思考过程)
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java
1 package cn.iocoder.yudao.module.ai1.harness.llm; 1 package cn.iocoder.yudao.module.ai1.harness.llm;
2 2
3 -import cn.hutool.core.collection.CollUtil;  
4 import cn.hutool.core.util.StrUtil; 3 import cn.hutool.core.util.StrUtil;
5 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; 4 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO;
6 import com.google.common.cache.Cache; 5 import com.google.common.cache.Cache;
@@ -21,7 +20,6 @@ import org.springframework.util.MultiValueMap; @@ -21,7 +20,6 @@ import org.springframework.util.MultiValueMap;
21 import java.net.URI; 20 import java.net.URI;
22 import java.time.Duration; 21 import java.time.Duration;
23 import java.util.LinkedHashMap; 22 import java.util.LinkedHashMap;
24 -import java.util.List;  
25 import java.util.Map; 23 import java.util.Map;
26 import java.util.Objects; 24 import java.util.Objects;
27 import java.util.function.Predicate; 25 import java.util.function.Predicate;
@@ -43,7 +41,7 @@ import java.util.function.Predicate; @@ -43,7 +41,7 @@ import java.util.function.Predicate;
43 public class Ai1LlmModelFactory { 41 public class Ai1LlmModelFactory {
44 42
45 /** 43 /**
46 - * 附属 Header 会话占位符:构建模型时替换为当前会话编号 44 + * 请求 Header 会话占位符:构建模型时替换为当前会话编号
47 */ 45 */
48 public static final String SESSION_PLACEHOLDER = "{session}"; 46 public static final String SESSION_PLACEHOLDER = "{session}";
49 /** 47 /**
@@ -76,7 +74,7 @@ public class Ai1LlmModelFactory { @@ -76,7 +74,7 @@ public class Ai1LlmModelFactory {
76 * 获取(或构建)对话模型,并包装为 ChatClient 74 * 获取(或构建)对话模型,并包装为 ChatClient
77 * 75 *
78 * @param model 模型运行时快照 76 * @param model 模型运行时快照
79 - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符,可为空 77 + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符,可为空
80 * @return ChatClient 78 * @return ChatClient
81 */ 79 */
82 public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { 80 public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) {
@@ -132,7 +130,7 @@ public class Ai1LlmModelFactory { @@ -132,7 +130,7 @@ public class Ai1LlmModelFactory {
132 /** 130 /**
133 * 获取(或构建)对话模型 131 * 获取(或构建)对话模型
134 * 132 *
135 - * 仅当附属 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; 133 + * 仅当请求 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key;
136 * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例 134 * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例
137 */ 135 */
138 private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { 136 private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) {
@@ -205,23 +203,23 @@ public class Ai1LlmModelFactory { @@ -205,23 +203,23 @@ public class Ai1LlmModelFactory {
205 } 203 }
206 204
207 /** 205 /**
208 - * 附属 Header 是否使用 {session} 占位符 206 + * 请求 Header 是否使用 {session} 占位符
209 */ 207 */
210 - private static boolean usesSessionHeader(List<Map<String, String>> headers) {  
211 - return CollUtil.findOne(headers, header -> StrUtil.contains(header.get("value"), SESSION_PLACEHOLDER)) != null; 208 + private static boolean usesSessionHeader(Map<String, String> headers) {
  209 + return headers != null && headers.values().stream().anyMatch(value -> StrUtil.contains(value, SESSION_PLACEHOLDER));
212 } 210 }
213 211
214 /** 212 /**
215 * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header 213 * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header
216 */ 214 */
217 - private static Map<String, String> buildHeaders(List<Map<String, String>> headers, Long sessionId) { 215 + private static Map<String, String> buildHeaders(Map<String, String> headers, Long sessionId) {
218 Map<String, String> result = new LinkedHashMap<>(); 216 Map<String, String> result = new LinkedHashMap<>();
219 - if (CollUtil.isEmpty(headers)) { 217 + if (headers == null || headers.isEmpty()) {
220 return result; 218 return result;
221 } 219 }
222 - for (Map<String, String> header : headers) {  
223 - String key = header.get("key");  
224 - String value = header.get("value"); 220 + for (Map.Entry<String, String> header : headers.entrySet()) {
  221 + String key = header.getKey();
  222 + String value = header.getValue();
225 if (StrUtil.isBlank(key)) { 223 if (StrUtil.isBlank(key)) {
226 continue; 224 continue;
227 } 225 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java
@@ -58,10 +58,10 @@ public class Ai1ProviderTool { @@ -58,10 +58,10 @@ public class Ai1ProviderTool {
58 * 58 *
59 * @param baseUrl 接口地址 59 * @param baseUrl 接口地址
60 * @param apiKey API 密钥 60 * @param apiKey API 密钥
61 - * @param headers 请求附属 Header 61 + * @param headers 请求 Header
62 * @return 测试结果 62 * @return 测试结果
63 */ 63 */
64 - public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, List<Map<String, String>> headers) { 64 + public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, Map<String, String> headers) {
65 long startTime = System.currentTimeMillis(); 65 long startTime = System.currentTimeMillis();
66 String url = normalizeBaseUrl(baseUrl); 66 String url = normalizeBaseUrl(baseUrl);
67 67
@@ -102,10 +102,10 @@ public class Ai1ProviderTool { @@ -102,10 +102,10 @@ public class Ai1ProviderTool {
102 * 102 *
103 * @param baseUrl 接口地址 103 * @param baseUrl 接口地址
104 * @param apiKey API 密钥 104 * @param apiKey API 密钥
105 - * @param headers 请求附属 Header 105 + * @param headers 请求 Header
106 * @return 模型标识列表 106 * @return 模型标识列表
107 */ 107 */
108 - public List<String> listModels(String baseUrl, String apiKey, List<Map<String, String>> headers) { 108 + public List<String> listModels(String baseUrl, String apiKey, Map<String, String> headers) {
109 // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 109 // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置
110 String url = normalizeBaseUrl(baseUrl) + "/models"; 110 String url = normalizeBaseUrl(baseUrl) + "/models";
111 HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); 111 HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null);
@@ -153,12 +153,12 @@ public class Ai1ProviderTool { @@ -153,12 +153,12 @@ public class Ai1ProviderTool {
153 * @param method 请求方法 153 * @param method 请求方法
154 * @param url 请求地址 154 * @param url 请求地址
155 * @param apiKey API 密钥 155 * @param apiKey API 密钥
156 - * @param headers 请求附属 Header 156 + * @param headers 请求 Header
157 * @param body JSON 请求体,可为空 157 * @param body JSON 请求体,可为空
158 * @return 请求结果 158 * @return 请求结果
159 */ 159 */
160 private static HttpOutcome executeRequest(Method method, String url, String apiKey, 160 private static HttpOutcome executeRequest(Method method, String url, String apiKey,
161 - List<Map<String, String>> headers, String body) { 161 + Map<String, String> headers, String body) {
162 try { 162 try {
163 HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); 163 HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers);
164 if (body != null) { 164 if (body != null) {
@@ -168,30 +168,32 @@ public class Ai1ProviderTool { @@ -168,30 +168,32 @@ public class Ai1ProviderTool {
168 return new HttpOutcome(response.getStatus(), null, response.body()); 168 return new HttpOutcome(response.getStatus(), null, response.body());
169 } 169 }
170 } catch (Exception e) { 170 } catch (Exception e) {
171 - return new HttpOutcome(0, e.getMessage(), null); 171 + // HTTP 客户端异常可能包含密钥或 Header 原文,不能直接回传。
  172 + return new HttpOutcome(0, "请求发送失败,请检查服务地址、密钥及 Header("
  173 + + e.getClass().getSimpleName() + ")", null);
172 } 174 }
173 } 175 }
174 176
175 /** 177 /**
176 - * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态附属 Header,包含 {session} 占位符的 Header 跳过 178 + * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态 Header,包含 {session} 占位符的 Header 跳过
177 * 179 *
178 * @param method 请求方法 180 * @param method 请求方法
179 * @param url 请求地址 181 * @param url 请求地址
180 * @param apiKey API 密钥 182 * @param apiKey API 密钥
181 - * @param headers 请求附属 Header 183 + * @param headers 请求 Header
182 * @return HTTP 请求 184 * @return HTTP 请求
183 */ 185 */
184 - private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, List<Map<String, String>> headers) { 186 + private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, Map<String, String> headers) {
185 HttpRequest request = HttpRequest.of(url).method(method) 187 HttpRequest request = HttpRequest.of(url).method(method)
186 .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) 188 .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT)
187 .header(Header.ACCEPT, ContentType.JSON.getValue()); 189 .header(Header.ACCEPT, ContentType.JSON.getValue());
188 if (StrUtil.isNotBlank(apiKey)) { 190 if (StrUtil.isNotBlank(apiKey)) {
189 request.bearerAuth(apiKey); 191 request.bearerAuth(apiKey);
190 } 192 }
191 - if (CollUtil.isNotEmpty(headers)) {  
192 - for (Map<String, String> header : headers) {  
193 - String key = header.get("key");  
194 - String value = header.get("value"); 193 + if (MapUtil.isNotEmpty(headers)) {
  194 + for (Map.Entry<String, String> header : headers.entrySet()) {
  195 + String key = header.getKey();
  196 + String value = header.getValue();
195 if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { 197 if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) {
196 continue; 198 continue;
197 } 199 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java
@@ -304,7 +304,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle { @@ -304,7 +304,7 @@ public class Ai1SessionStreamTool implements SmartLifecycle {
304 // 1.2 生成所需的业务字段 304 // 1.2 生成所需的业务字段
305 fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); 305 fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId));
306 fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); 306 fields.put(FIELD_AGENT_ID, String.valueOf(agentId));
307 - // 生成时,附属 Header 中的 {session} 占位符替换为此处的会话编号 307 + // 生成时,请求 Header 中的 {session} 占位符替换为此处的会话编号
308 fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); 308 fields.put(FIELD_SESSION_ID, String.valueOf(sessionId));
309 fields.put(FIELD_CONTENT, content); 309 fields.put(FIELD_CONTENT, content);
310 310
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java
@@ -157,16 +157,18 @@ public class Ai1SkillToolFactory { @@ -157,16 +157,18 @@ public class Ai1SkillToolFactory {
157 // 2.1 清空目录后,逐个物化开启的 SKILL 157 // 2.1 清空目录后,逐个物化开启的 SKILL
158 FileUtil.del(root); 158 FileUtil.del(root);
159 FileUtil.mkdir(root.toFile()); 159 FileUtil.mkdir(root.toFile());
160 - boolean hasSkill = false;  
161 - for (Ai1SkillDO skill : skills) { 160 + List<String> skillDirectories = new ArrayList<>();
  161 + // 固定注册顺序;同名 frontmatter 按 agent-utils 的首次注册规则处理。
  162 + for (Ai1SkillDO skill : skills.stream().sorted(Comparator.comparing(Ai1SkillDO::getId)).toList()) {
162 if (CommonStatusEnum.isEnable(skill.getStatus()) 163 if (CommonStatusEnum.isEnable(skill.getStatus())
163 && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) { 164 && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) {
164 - hasSkill = true; 165 + skillDirectories.add(root.resolve(sanitize(skill.getName())).toString());
165 } 166 }
166 } 167 }
167 // 2.2 写入指纹标记,构建技能工具并缓存快照 168 // 2.2 写入指纹标记,构建技能工具并缓存快照
168 FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile()); 169 FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile());
169 - ToolCallback skillsTool = hasSkill ? SkillsTool.builder().addSkillsDirectory(root.toString()).build() : null; 170 + ToolCallback skillsTool = skillDirectories.isEmpty() ? null
  171 + : SkillsTool.builder().addSkillsDirectories(skillDirectories).build();
170 SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); 172 SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool);
171 snapshotCache.put(agent.getId(), snapshot); 173 snapshotCache.put(agent.getId(), snapshot);
172 log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root); 174 log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root);
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java
@@ -29,7 +29,6 @@ import java.util.*; @@ -29,7 +29,6 @@ import java.util.*;
29 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 29 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
30 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; 30 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
31 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; 31 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*;
32 -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders;  
33 import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; 32 import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders;
34 33
35 /** 34 /**
@@ -176,7 +175,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { @@ -176,7 +175,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService {
176 175
177 // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 176 // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改
178 return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()), 177 return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()),
179 - resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); 178 + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders()));
180 } 179 }
181 180
182 @Override 181 @Override
@@ -200,7 +199,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { @@ -200,7 +199,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService {
200 .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) 199 .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType())
201 .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) 200 .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl()))
202 .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) 201 .setApiKey(resolveSpringPlaceholders(provider.getApiKey()))
203 - .setHeaders(parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); 202 + .setHeaders(resolveSpringPlaceholders(provider.getHeaders()));
204 } 203 }
205 204
206 @Override 205 @Override
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java
@@ -23,7 +23,7 @@ import java.util.List; @@ -23,7 +23,7 @@ import java.util.List;
23 23
24 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 24 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
25 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; 25 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*;
26 -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; 26 +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.normalizeHeaders;
27 import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; 27 import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders;
28 28
29 /** 29 /**
@@ -51,6 +51,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { @@ -51,6 +51,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService {
51 @Override 51 @Override
52 public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { 52 public Long createProvider(Ai1ProviderSaveReqVO createReqVO) {
53 Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); 53 Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class);
  54 + provider.setHeaders(normalizeHeaders(createReqVO.getHeaders()));
54 providerMapper.insert(provider); 55 providerMapper.insert(provider);
55 return provider.getId(); 56 return provider.getId();
56 } 57 }
@@ -62,6 +63,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { @@ -62,6 +63,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService {
62 63
63 // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 64 // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥
64 Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); 65 Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class);
  66 + updateObj.setHeaders(normalizeHeaders(updateReqVO.getHeaders()));
65 if (StrUtil.isBlank(updateObj.getApiKey())) { 67 if (StrUtil.isBlank(updateObj.getApiKey())) {
66 updateObj.setApiKey(null); 68 updateObj.setApiKey(null);
67 } 69 }
@@ -132,7 +134,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { @@ -132,7 +134,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService {
132 134
133 // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 135 // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改
134 return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()), 136 return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()),
135 - resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); 137 + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders()));
136 } 138 }
137 139
138 } 140 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java
@@ -4,7 +4,6 @@ import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; @@ -4,7 +4,6 @@ import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
4 import lombok.Data; 4 import lombok.Data;
5 import lombok.experimental.Accessors; 5 import lombok.experimental.Accessors;
6 6
7 -import java.util.List;  
8 import java.util.Map; 7 import java.util.Map;
9 8
10 /** 9 /**
@@ -46,8 +45,8 @@ public class Ai1ModelRespBO { @@ -46,8 +45,8 @@ public class Ai1ModelRespBO {
46 */ 45 */
47 private String apiKey; 46 private String apiKey;
48 /** 47 /**
49 - * 请求附属 Header,每项包含 key、value;value 可包含 {session} 占位符 48 + * 请求 Header,value 可包含 {session} 占位符
50 */ 49 */
51 - private List<Map<String, String>> headers; 50 + private Map<String, String> headers;
52 51
53 } 52 }
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java
1 package cn.iocoder.yudao.module.ai1.util; 1 package cn.iocoder.yudao.module.ai1.util;
2 2
3 -import cn.hutool.core.map.MapUtil;  
4 -import cn.hutool.core.util.ClassUtil;  
5 import cn.hutool.core.util.StrUtil; 3 import cn.hutool.core.util.StrUtil;
6 import cn.hutool.extra.spring.SpringUtil; 4 import cn.hutool.extra.spring.SpringUtil;
7 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 5 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
8 -import com.fasterxml.jackson.core.type.TypeReference; 6 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
9 import org.springframework.core.env.Environment; 7 import org.springframework.core.env.Environment;
10 8
11 -import java.util.ArrayList;  
12 -import java.util.LinkedHashMap;  
13 -import java.util.List;  
14 -import java.util.Map; 9 +import java.util.*;
15 10
16 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 11 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
17 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED; 12 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED;
  13 +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_HEADERS_INVALID;
18 14
19 /** 15 /**
20 * AI1 工具类 16 * AI1 工具类
@@ -38,7 +34,7 @@ public class Ai1Utils { @@ -38,7 +34,7 @@ public class Ai1Utils {
38 try { 34 try {
39 return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); 35 return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value);
40 } catch (IllegalArgumentException ex) { 36 } catch (IllegalArgumentException ex) {
41 - throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, value); 37 + throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, "动态配置项");
42 } 38 }
43 } 39 }
44 40
@@ -58,36 +54,37 @@ public class Ai1Utils { @@ -58,36 +54,37 @@ public class Ai1Utils {
58 } 54 }
59 55
60 /** 56 /**
61 - * 解析供应商的请求附属 Header:JSON 数组,格式为 [{"key":"...","value":"..."}]  
62 - *  
63 - * @param headersJson JSON 字符串  
64 - * @return Header 列表,每项包含 key、value;为空或格式错误时返回 null 57 + * 规范化请求 Header,保留输入顺序;不解析环境占位符
65 */ 58 */
66 - public static List<Map<String, String>> parseHeaders(String headersJson) {  
67 - if (StrUtil.isBlank(headersJson)) {  
68 - return null;  
69 - }  
70 - List<Map<String, Object>> items = JsonUtils.parseObjectQuietly(headersJson, new TypeReference<List<Map<String, Object>>>() {});  
71 - if (items == null) { 59 + public static Map<String, String> normalizeHeaders(Map<String, String> values) {
  60 + if (values == null) {
72 return null; 61 return null;
73 } 62 }
74 - List<Map<String, String>> headers = new ArrayList<>(items.size());  
75 - for (Map<String, Object> item : items) {  
76 - // 每项必须是对象,且 key 为标量  
77 - if (item == null || !isSimpleValue(item.get("key"))) {  
78 - return null; 63 + Map<String, String> headers = new LinkedHashMap<>();
  64 + Set<String> names = new HashSet<>();
  65 + values.forEach((key, value) -> {
  66 + if (StrUtil.isBlank(key) || value == null) {
  67 + throw exception(PROVIDER_HEADERS_INVALID);
  68 + }
  69 + String name = key.trim();
  70 + if (!names.add(name.toLowerCase(Locale.ROOT))) {
  71 + throw exception(PROVIDER_HEADERS_INVALID);
79 } 72 }
80 - headers.add(MapUtil.builder("key", MapUtil.getStr(item, "key"))  
81 - .put("value", isSimpleValue(item.get("value")) ? MapUtil.getStr(item, "value") : "").build()); 73 + headers.put(name, value);
  74 + });
  75 + if (JsonUtils.toJsonString(headers).length() > 2000) {
  76 + throw exception(PROVIDER_HEADERS_INVALID);
82 } 77 }
83 return headers; 78 return headers;
84 } 79 }
85 80
86 - /**  
87 - * 是否为 JSON 标量值:字符串、数字、布尔  
88 - */  
89 - private static boolean isSimpleValue(Object value) {  
90 - return value != null && ClassUtil.isSimpleValueType(value.getClass()); 81 + public static boolean isHeadersValid(Map<String, String> headers) {
  82 + try {
  83 + normalizeHeaders(headers);
  84 + return true;
  85 + } catch (ServiceException ex) {
  86 + return false;
  87 + }
91 } 88 }
92 89
93 } 90 }
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java
@@ -7,13 +7,18 @@ import com.sun.net.httpserver.HttpServer; @@ -7,13 +7,18 @@ import com.sun.net.httpserver.HttpServer;
7 import org.junit.jupiter.api.AfterEach; 7 import org.junit.jupiter.api.AfterEach;
8 import org.junit.jupiter.api.BeforeEach; 8 import org.junit.jupiter.api.BeforeEach;
9 import org.junit.jupiter.api.Test; 9 import org.junit.jupiter.api.Test;
  10 +import org.junit.jupiter.api.io.TempDir;
10 import org.junit.jupiter.params.ParameterizedTest; 11 import org.junit.jupiter.params.ParameterizedTest;
11 import org.junit.jupiter.params.provider.ValueSource; 12 import org.junit.jupiter.params.provider.ValueSource;
  13 +import org.springaicommunity.agent.tools.SkillsTool;
12 import org.springframework.ai.embedding.EmbeddingModel; 14 import org.springframework.ai.embedding.EmbeddingModel;
  15 +import org.springframework.ai.tool.ToolCallback;
13 import org.springframework.test.util.ReflectionTestUtils; 16 import org.springframework.test.util.ReflectionTestUtils;
14 17
15 import java.net.InetSocketAddress; 18 import java.net.InetSocketAddress;
16 import java.nio.charset.StandardCharsets; 19 import java.nio.charset.StandardCharsets;
  20 +import java.nio.file.Files;
  21 +import java.nio.file.Path;
17 import java.util.ArrayList; 22 import java.util.ArrayList;
18 import java.util.List; 23 import java.util.List;
19 import java.util.Map; 24 import java.util.Map;
@@ -22,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArrayList; @@ -22,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
22 import static org.junit.jupiter.api.Assertions.*; 27 import static org.junit.jupiter.api.Assertions.*;
23 28
24 /** 29 /**
25 - * 模型调用回归:通过本地 HTTP 端点验证真实请求、流式分片和模型缓存。 30 + * {@link Ai1LlmModelFactory} 的单元测试
26 */ 31 */
27 public class Ai1LlmModelFactoryTest { 32 public class Ai1LlmModelFactoryTest {
28 33
@@ -30,9 +35,11 @@ public class Ai1LlmModelFactoryTest { @@ -30,9 +35,11 @@ public class Ai1LlmModelFactoryTest {
30 private final List<Request> requests = new CopyOnWriteArrayList<>(); 35 private final List<Request> requests = new CopyOnWriteArrayList<>();
31 private HttpServer server; 36 private HttpServer server;
32 private String baseUrl; 37 private String baseUrl;
  38 + private String requestedTool;
33 39
34 @BeforeEach 40 @BeforeEach
35 public void before() throws Exception { 41 public void before() throws Exception {
  42 + // mock HTTP 服务
36 server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); 43 server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
37 server.createContext("/", exchange -> { 44 server.createContext("/", exchange -> {
38 requests.add(new Request(exchange.getRequestURI().getPath(), 45 requests.add(new Request(exchange.getRequestURI().getPath(),
@@ -41,7 +48,8 @@ public class Ai1LlmModelFactoryTest { @@ -41,7 +48,8 @@ public class Ai1LlmModelFactoryTest {
41 exchange.getRequestHeaders().getFirst("X-Provider"), 48 exchange.getRequestHeaders().getFirst("X-Provider"),
42 JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); 49 JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8))));
43 boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings"); 50 boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings");
44 - String response = embedding 51 + boolean callTool = requestedTool != null && requests.size() == 1;
  52 + String response = callTool ? toolCallSse(requestedTool) : embedding
45 ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" 53 ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}"
46 : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") 54 : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}")
47 + sse("{\"reasoning_content\":\"想\"}") 55 + sse("{\"reasoning_content\":\"想\"}")
@@ -67,15 +75,18 @@ public class Ai1LlmModelFactoryTest { @@ -67,15 +75,18 @@ public class Ai1LlmModelFactoryTest {
67 @ParameterizedTest 75 @ParameterizedTest
68 @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) 76 @ValueSource(strings = {"", "/v1/", "/proxy/v1"})
69 public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { 77 public void testChat_endpointHeadersAndReasoningDeltas(String prefix) {
  78 + // 准备参数
70 Ai1ModelRespBO model = model(baseUrl + prefix); 79 Ai1ModelRespBO model = model(baseUrl + prefix);
71 Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); 80 Ai1LlmChatTool chatTool = new Ai1LlmChatTool();
72 ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); 81 ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory);
73 List<String> thinking = new ArrayList<>(); 82 List<String> thinking = new ArrayList<>();
74 List<String> content = new ArrayList<>(); 83 List<String> content = new ArrayList<>();
75 84
  85 + // 调用
76 Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.<String[]>of(new String[]{"user", "历史"}), 86 Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.<String[]>of(new String[]{"user", "历史"}),
77 "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); 87 "当前消息", List.of(), List.of(), 42L, thinking::add, content::add);
78 88
  89 + // 断言
79 assertEquals("你好", result.getContent()); 90 assertEquals("你好", result.getContent());
80 assertEquals("想想想到", result.getThinking()); 91 assertEquals("想想想到", result.getThinking());
81 assertEquals(List.of("想", "想", "想到"), thinking); 92 assertEquals(List.of("想", "想", "想到"), thinking);
@@ -94,10 +105,14 @@ public class Ai1LlmModelFactoryTest { @@ -94,10 +105,14 @@ public class Ai1LlmModelFactoryTest {
94 105
95 @Test 106 @Test
96 public void testEmbedding_endpointAndHeaders() { 107 public void testEmbedding_endpointAndHeaders() {
  108 + // 准备参数
97 Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); 109 Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/");
98 EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); 110 EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model);
99 111
100 - assertArrayEquals(new float[]{0.1F, 0.2F}, embedding.embed("文档内容")); 112 + // 调用
  113 + float[] result = embedding.embed("文档内容");
  114 + // 断言
  115 + assertArrayEquals(new float[]{0.1F, 0.2F}, result);
101 Request request = requests.get(0); 116 Request request = requests.get(0);
102 assertEquals("/proxy/v1/embeddings", request.path()); 117 assertEquals("/proxy/v1/embeddings", request.path());
103 assertEquals("Bearer test-key", request.authorization()); 118 assertEquals("Bearer test-key", request.authorization());
@@ -108,35 +123,79 @@ public class Ai1LlmModelFactoryTest { @@ -108,35 +123,79 @@ public class Ai1LlmModelFactoryTest {
108 } 123 }
109 124
110 @Test 125 @Test
  126 + public void testChat_skillCallback(@TempDir Path root) throws Exception {
  127 + // 准备参数
  128 + Files.writeString(root.resolve("SKILL.md"), "---\nname: probe\ndescription: test\n---\nunique-skill-content\n");
  129 + ToolCallback skill = SkillsTool.builder().addSkillsDirectory(root.toString()).build();
  130 + requestedTool = skill.getToolDefinition().name();
  131 + Ai1LlmChatTool chat = new Ai1LlmChatTool();
  132 + ReflectionTestUtils.setField(chat, "llmModelFactory", factory);
  133 +
  134 + // 调用
  135 + Ai1LlmChatTool.ChatText result = chat.chat(model(baseUrl), "system", List.of(), "use skill",
  136 + List.of(skill), List.of(), 42L, null, null);
  137 + // 断言
  138 + assertEquals("你好", result.getContent());
  139 + assertEquals(2, requests.size());
  140 + assertEquals(requestedTool, requests.get(0).body().get("tools").get(0).get("function").get("name").asText());
  141 + boolean hasToolResult = false;
  142 + for (JsonNode message : requests.get(1).body().get("messages")) {
  143 + if ("tool".equals(message.get("role").asText())) {
  144 + hasToolResult = true;
  145 + assertTrue(message.get("content").asText().contains("unique-skill-content"));
  146 + }
  147 + }
  148 + assertTrue(hasToolResult);
  149 + assertEquals("42", requests.get(1).session());
  150 + }
  151 +
  152 + @Test
111 public void testCache_sessionIsolationAndInvalidation() { 153 public void testCache_sessionIsolationAndInvalidation() {
  154 + // 准备参数
112 Ai1ModelRespBO model = model(baseUrl); 155 Ai1ModelRespBO model = model(baseUrl);
  156 +
  157 + // 调用
113 Object chat = chatModel(model, 1L); 158 Object chat = chatModel(model, 1L);
  159 + // 断言
114 assertSame(chat, chatModel(model, 1L)); 160 assertSame(chat, chatModel(model, 1L));
115 assertNotSame(chat, chatModel(model, 2L)); 161 assertNotSame(chat, chatModel(model, 2L));
116 EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); 162 EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model);
117 assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); 163 assertSame(embedding, factory.getOrCreateEmbeddingModel(model));
118 164
  165 + // 调用
119 factory.evictByModelId(model.getModelId()); 166 factory.evictByModelId(model.getModelId());
  167 + // 断言
120 assertNotSame(chat, chatModel(model, 1L)); 168 assertNotSame(chat, chatModel(model, 1L));
121 assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model)); 169 assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model));
122 - model.setHeaders(List.of(Map.of("key", "X-Provider", "value", "provider"))); 170 +
  171 + // 准备参数
  172 + model.setHeaders(Map.of("X-Provider", "provider"));
  173 + // 调用
123 chat = chatModel(model, 1L); 174 chat = chatModel(model, 1L);
  175 + // 断言
124 assertSame(chat, chatModel(model, 2L)); 176 assertSame(chat, chatModel(model, 2L));
  177 +
  178 + // 调用
125 model.setApiKey("changed-key"); 179 model.setApiKey("changed-key");
  180 + // 断言
126 assertNotSame(chat, chatModel(model, 1L)); 181 assertNotSame(chat, chatModel(model, 1L));
  182 +
  183 + // 调用
127 chat = chatModel(model, 1L); 184 chat = chatModel(model, 1L);
128 factory.evictByProviderId(model.getProviderId()); 185 factory.evictByProviderId(model.getProviderId());
  186 + // 断言
129 assertNotSame(chat, chatModel(model, 1L)); 187 assertNotSame(chat, chatModel(model, 1L));
130 } 188 }
131 189
  190 + // ========== 工具方法 ==========
  191 +
132 private Object chatModel(Ai1ModelRespBO model, Long sessionId) { 192 private Object chatModel(Ai1ModelRespBO model, Long sessionId) {
133 return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); 193 return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId);
134 } 194 }
135 195
136 private static Ai1ModelRespBO model(String url) { 196 private static Ai1ModelRespBO model(String url) {
137 return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key") 197 return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key")
138 - .setModel("test-model").setHeaders(List.of(Map.of("key", "X-Session", "value", "{session}"),  
139 - Map.of("key", "X-Provider", "value", "provider"))); 198 + .setModel("test-model").setHeaders(Map.of("X-Session", "{session}", "X-Provider", "provider"));
140 } 199 }
141 200
142 private static String sse(String delta) { 201 private static String sse(String delta) {
@@ -144,6 +203,16 @@ public class Ai1LlmModelFactoryTest { @@ -144,6 +203,16 @@ public class Ai1LlmModelFactoryTest {
144 + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n"; 203 + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n";
145 } 204 }
146 205
  206 + private static String toolCallSse(String name) {
  207 + String delta = JsonUtils.toJsonString(Map.of("role", "assistant", "tool_calls", List.of(Map.of(
  208 + "index", 0, "id", "call-probe", "type", "function", "function", Map.of(
  209 + "name", name, "arguments", "{\"command\":\"probe\"}")))));
  210 + return sse(delta)
  211 + + "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test-model\","
  212 + + "\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n"
  213 + + "data: [DONE]\n\n";
  214 + }
  215 +
147 private record Request(String path, String authorization, String session, String provider, JsonNode body) { 216 private record Request(String path, String authorization, String session, String provider, JsonNode body) {
148 } 217 }
149 } 218 }
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java
@@ -16,13 +16,14 @@ import java.util.concurrent.CopyOnWriteArrayList; @@ -16,13 +16,14 @@ import java.util.concurrent.CopyOnWriteArrayList;
16 import static org.junit.jupiter.api.Assertions.*; 16 import static org.junit.jupiter.api.Assertions.*;
17 17
18 /** 18 /**
19 - * 验证 MCP SDK 的真实 HTTP 握手及工具调用。 19 + * {@link Ai1McpClientTool} 的运行时测试
20 */ 20 */
21 public class Ai1McpRuntimeCompatibilityTest { 21 public class Ai1McpRuntimeCompatibilityTest {
22 22
23 @Test 23 @Test
24 @Timeout(15) 24 @Timeout(15)
25 public void testStreamableHttp_initializeListAndCall() throws Exception { 25 public void testStreamableHttp_initializeListAndCall() throws Exception {
  26 + // mock HTTP 服务
26 List<String> methods = new CopyOnWriteArrayList<>(); 27 List<String> methods = new CopyOnWriteArrayList<>();
27 List<String> paths = new CopyOnWriteArrayList<>(); 28 List<String> paths = new CopyOnWriteArrayList<>();
28 List<String> headers = new CopyOnWriteArrayList<>(); 29 List<String> headers = new CopyOnWriteArrayList<>();
@@ -63,13 +64,16 @@ public class Ai1McpRuntimeCompatibilityTest { @@ -63,13 +64,16 @@ public class Ai1McpRuntimeCompatibilityTest {
63 server.start(); 64 server.start();
64 Ai1McpClientTool client = new Ai1McpClientTool(); 65 Ai1McpClientTool client = new Ai1McpClientTool();
65 try { 66 try {
  67 + // 准备参数
66 Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") 68 Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http")
67 .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") 69 .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test")
68 .setHeaders(Map.of("X-Probe", "compatibility")); 70 .setHeaders(Map.of("X-Probe", "compatibility"));
69 71
  72 + // 调用,并断言工具发现和调用
70 assertEquals(1, client.listTools(mcp).size()); 73 assertEquals(1, client.listTools(mcp).size());
71 assertEquals("echo", client.listTools(mcp).get(0).name()); 74 assertEquals("echo", client.listTools(mcp).get(0).name());
72 assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); 75 assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello")));
  76 + // 断言 HTTP 请求
73 assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); 77 assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call")));
74 assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); 78 assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals));
75 assertTrue(headers.stream().allMatch("compatibility"::equals)); 79 assertTrue(headers.stream().allMatch("compatibility"::equals));
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java
1 package cn.iocoder.yudao.module.ai1.harness.model; 1 package cn.iocoder.yudao.module.ai1.harness.model;
2 2
  3 +import cn.hutool.extra.spring.SpringUtil;
3 import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; 4 import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO;
  5 +import cn.iocoder.yudao.module.ai1.util.Ai1Utils;
  6 +import com.sun.net.httpserver.HttpServer;
4 import org.junit.jupiter.api.Test; 7 import org.junit.jupiter.api.Test;
  8 +import org.mockito.MockedStatic;
  9 +import org.springframework.core.env.Environment;
  10 +import org.springframework.mock.env.MockEnvironment;
5 11
  12 +import java.net.InetSocketAddress;
  13 +import java.nio.charset.StandardCharsets;
6 import java.util.List; 14 import java.util.List;
  15 +import java.util.Map;
  16 +import java.util.concurrent.CopyOnWriteArrayList;
7 17
8 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; 18 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
9 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; 19 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL;
10 import static org.junit.jupiter.api.Assertions.*; 20 import static org.junit.jupiter.api.Assertions.*;
  21 +import static org.mockito.Mockito.mockStatic;
11 22
12 /** 23 /**
13 * {@link Ai1ProviderTool} 的单元测试 24 * {@link Ai1ProviderTool} 的单元测试
@@ -39,6 +50,68 @@ public class Ai1ProviderToolTest { @@ -39,6 +50,68 @@ public class Ai1ProviderToolTest {
39 } 50 }
40 51
41 @Test 52 @Test
  53 + public void testTestConnectAndListModels_staticHeaders() throws Exception {
  54 + // mock HTTP 服务
  55 + List<Map<String, String>> received = new CopyOnWriteArrayList<>();
  56 + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
  57 + server.createContext("/proxy/v1/models", exchange -> {
  58 + received.add(Map.of("auth", exchange.getRequestHeaders().getFirst("Authorization"),
  59 + "test", exchange.getRequestHeaders().getFirst("X-Test"),
  60 + "session", String.valueOf(exchange.getRequestHeaders().getFirst("X-Session"))));
  61 + byte[] body = "{\"data\":[{\"id\":\"test-model\"}]}".getBytes(StandardCharsets.UTF_8);
  62 + exchange.sendResponseHeaders(200, body.length);
  63 + try (var output = exchange.getResponseBody()) {
  64 + output.write(body);
  65 + }
  66 + });
  67 + server.start();
  68 + try {
  69 + // 准备参数
  70 + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/v1/";
  71 + Map<String, String> headers = Map.of("X-Test", "a\"b\\c", "X-Session", "{session}");
  72 + // 调用
  73 + Ai1ProviderConnectRespVO result = providerTool.testConnect(url, "synthetic-key", headers);
  74 + List<String> models = providerTool.listModels(url, "synthetic-key", headers);
  75 + // 断言
  76 + assertTrue(result.getConnectable());
  77 + assertEquals(List.of("test-model"), models);
  78 + assertEquals(2, received.size());
  79 + received.forEach(request -> assertEquals(Map.of("auth", "Bearer synthetic-key", "test", "a\"b\\c", "session", "null"), request));
  80 + } finally {
  81 + server.stop(0);
  82 + }
  83 + }
  84 +
  85 + @Test
  86 + public void testTestConnect_invalidConfiguration() throws Exception {
  87 + // 准备参数
  88 + String secret = "synthetic-private-token\nprivate-suffix";
  89 + // mock HTTP 服务
  90 + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
  91 + server.start();
  92 + try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) {
  93 + // mock 环境变量
  94 + spring.when(() -> SpringUtil.getBean(Environment.class))
  95 + .thenReturn(new MockEnvironment().withProperty("HTTP_HEADER_TOKEN", secret));
  96 + // 准备参数
  97 + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1";
  98 + Map<String, String> headers = Ai1Utils.resolveSpringPlaceholders(Map.of("X-Token", "${HTTP_HEADER_TOKEN}"));
  99 + // 调用
  100 + List<Ai1ProviderConnectRespVO> results = List.of(
  101 + providerTool.testConnect(url, null, headers), providerTool.testConnect(url, secret, null));
  102 + // 断言
  103 + for (Ai1ProviderConnectRespVO result : results) {
  104 + assertFalse(result.getConnectable());
  105 + assertEquals(0, result.getHttpCode());
  106 + assertFalse(result.getMessage().contains("synthetic-private-token"));
  107 + assertFalse(result.getMessage().contains("private-suffix"));
  108 + }
  109 + } finally {
  110 + server.stop(0);
  111 + }
  112 + }
  113 +
  114 + @Test
42 public void testParseModels() { 115 public void testParseModels() {
43 // 调用,并断言过滤无效模型 116 // 调用,并断言过滤无效模型
44 assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels( 117 assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels(
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java
@@ -4,20 +4,20 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; @@ -4,20 +4,20 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
4 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; 4 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
5 import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; 5 import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO;
6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; 6 import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO;
7 -import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;  
8 -import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;  
9 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; 7 import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum;
  8 +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum;
  9 +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum;
10 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; 10 import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties;
  11 +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;
  12 +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory;
  13 +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool;
11 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; 14 import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory;
12 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; 15 import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService;
13 -import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;  
14 -import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService;  
15 import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; 16 import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService;
16 import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; 17 import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService;
17 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; 18 import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO;
18 -import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool;  
19 -import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory;  
20 -import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; 19 +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService;
  20 +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService;
21 import org.junit.jupiter.api.BeforeEach; 21 import org.junit.jupiter.api.BeforeEach;
22 import org.junit.jupiter.api.Test; 22 import org.junit.jupiter.api.Test;
23 import org.junit.jupiter.params.ParameterizedTest; 23 import org.junit.jupiter.params.ParameterizedTest;
@@ -26,23 +26,23 @@ import org.mockito.ArgumentCaptor; @@ -26,23 +26,23 @@ import org.mockito.ArgumentCaptor;
26 import org.mockito.InjectMocks; 26 import org.mockito.InjectMocks;
27 import org.mockito.Mock; 27 import org.mockito.Mock;
28 import org.mockito.Spy; 28 import org.mockito.Spy;
29 -import org.redisson.api.RStream;  
30 import org.redisson.api.RLock; 29 import org.redisson.api.RLock;
  30 +import org.redisson.api.RStream;
31 import org.redisson.api.RedissonClient; 31 import org.redisson.api.RedissonClient;
32 import org.redisson.api.stream.StreamAddArgs; 32 import org.redisson.api.stream.StreamAddArgs;
33 import org.redisson.api.stream.StreamMessageId; 33 import org.redisson.api.stream.StreamMessageId;
34 import org.redisson.api.stream.StreamRangeArgs; 34 import org.redisson.api.stream.StreamRangeArgs;
35 import org.redisson.api.stream.StreamReadArgs; 35 import org.redisson.api.stream.StreamReadArgs;
  36 +import org.redisson.client.codec.Codec;
36 import org.springframework.test.util.ReflectionTestUtils; 37 import org.springframework.test.util.ReflectionTestUtils;
37 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 38 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
38 -import org.redisson.client.codec.Codec;  
39 39
40 import java.io.IOException; 40 import java.io.IOException;
41 import java.util.ArrayList; 41 import java.util.ArrayList;
42 import java.util.HashMap; 42 import java.util.HashMap;
  43 +import java.util.LinkedHashMap;
43 import java.util.List; 44 import java.util.List;
44 import java.util.Map; 45 import java.util.Map;
45 -import java.util.LinkedHashMap;  
46 import java.util.concurrent.atomic.AtomicReference; 46 import java.util.concurrent.atomic.AtomicReference;
47 import java.util.function.Consumer; 47 import java.util.function.Consumer;
48 48
@@ -111,14 +111,17 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -111,14 +111,17 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
111 111
112 @Test 112 @Test
113 public void testStreamResult_disconnectedMustNotComplete() throws Exception { 113 public void testStreamResult_disconnectedMustNotComplete() throws Exception {
114 - SseEmitter emitter = mock(  
115 - SseEmitter.class); 114 + // 准备参数
  115 + SseEmitter emitter = mock(SseEmitter.class);
  116 + // mock 连接断开
116 doThrow(new IOException("client disconnected")).when(emitter).send( 117 doThrow(new IOException("client disconnected")).when(emitter).send(
117 any(SseEmitter.SseEventBuilder.class)); 118 any(SseEmitter.SseEventBuilder.class));
118 119
  120 + // 调用
119 Boolean shouldComplete = ReflectionTestUtils.invokeMethod( 121 Boolean shouldComplete = ReflectionTestUtils.invokeMethod(
120 sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); 122 sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID);
121 123
  124 + // 断言
122 assertEquals(Boolean.FALSE, shouldComplete); 125 assertEquals(Boolean.FALSE, shouldComplete);
123 verifyNoInteractions(resultStream); 126 verifyNoInteractions(resultStream);
124 verify(emitter, never()).complete(); 127 verify(emitter, never()).complete();
@@ -126,27 +129,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { @@ -126,27 +129,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest {
126 129
127 @Test 130 @Test
128 public void testStreamResult_terminalMustComplete() { 131 public void testStreamResult_terminalMustComplete() {
129 - SseEmitter emitter = mock(  
130 - SseEmitter.class); 132 + // 准备参数
  133 + SseEmitter emitter = mock(SseEmitter.class);
  134 + // mock 结果流
131 when(resultStream.read(any(StreamReadArgs.class))) 135 when(resultStream.read(any(StreamReadArgs.class)))
132 .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); 136 .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", "")));
133 137
  138 + // 调用
134 Boolean shouldComplete = ReflectionTestUtils.invokeMethod( 139 Boolean shouldComplete = ReflectionTestUtils.invokeMethod(
135 sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); 140 sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID);
136 141
  142 + // 断言
137 assertEquals(Boolean.TRUE, shouldComplete); 143 assertEquals(Boolean.TRUE, shouldComplete);
138 } 144 }
139 145
140 @Test 146 @Test
141 public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { 147 public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception {
142 - SseEmitter emitter = mock(  
143 - SseEmitter.class); 148 + // 准备参数
  149 + SseEmitter emitter = mock(SseEmitter.class);
  150 + // mock 连接断开
144 doThrow(new IOException("client disconnected")).when(emitter).send( 151 doThrow(new IOException("client disconnected")).when(emitter).send(
145 any(SseEmitter.SseEventBuilder.class)); 152 any(SseEmitter.SseEventBuilder.class));
146 153
  154 + // 调用
147 ReflectionTestUtils.invokeMethod( 155 ReflectionTestUtils.invokeMethod(
148 Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); 156 Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error");
149 157
  158 + // 断言
150 verify(emitter, never()).complete(); 159 verify(emitter, never()).complete();
151 } 160 }
152 161
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java
@@ -10,29 +10,87 @@ import org.springframework.ai.tool.ToolCallback; @@ -10,29 +10,87 @@ import org.springframework.ai.tool.ToolCallback;
10 import java.nio.file.Files; 10 import java.nio.file.Files;
11 import java.nio.file.Path; 11 import java.nio.file.Path;
12 import java.util.Arrays; 12 import java.util.Arrays;
  13 +import java.util.List;
13 import java.util.Map; 14 import java.util.Map;
14 import java.util.function.Function; 15 import java.util.function.Function;
15 import java.util.stream.Collectors; 16 import java.util.stream.Collectors;
16 17
17 import static org.junit.jupiter.api.Assertions.*; 18 import static org.junit.jupiter.api.Assertions.*;
18 19
19 -/**  
20 - * 验证 AI1 使用的 agent-utils 工具的注册、JSON 入参和实际执行。  
21 - */  
22 public class Ai1SkillRuntimeCompatibilityTest { 20 public class Ai1SkillRuntimeCompatibilityTest {
23 21
24 @TempDir 22 @TempDir
25 private Path root; 23 private Path root;
26 24
27 @Test 25 @Test
28 - public void testSkillAndExecutionTools() throws Exception { 26 + public void testSkillsTool_duplicateName() throws Exception {
  27 + // 准备参数
  28 + Path first = Files.createDirectory(root.resolve("first"));
  29 + Path second = Files.createDirectory(root.resolve("second"));
  30 + Files.writeString(first.resolve("SKILL.md"), "---\nname: same\ndescription: first\n---\nfirst-content\n");
  31 + Files.writeString(second.resolve("SKILL.md"), "---\nname: same\ndescription: second\n---\nsecond-content\n");
  32 + ToolCallback callback = SkillsTool.builder().addSkillsDirectories(List.of(first.toString(), second.toString())).build();
  33 +
  34 + // 调用
  35 + String result = callback.call("{\"command\":\"same\"}");
  36 + // 断言
  37 + assertTrue(result.contains("first-content"));
  38 + assertTrue(result.contains(first.toString()));
  39 + assertFalse(result.contains("second-content"));
  40 + assertFalse(callback.getToolDefinition().description().contains("<description>second</description>"));
  41 + }
  42 +
  43 + @Test
  44 + public void testFileTools_pathConfinement() throws Exception {
  45 + // 准备参数
  46 + Path nested = Files.createDirectory(root.resolve("nested"));
  47 + Files.writeString(nested.resolve("marker.txt"), "inside-marker");
  48 + Path outside = Files.createTempDirectory(root.getParent(), "ai1-outside-");
  49 + try {
  50 + Files.writeString(outside.resolve("private.txt"), "outside-marker");
  51 + Files.createSymbolicLink(root.resolve("escape"), outside);
  52 + Map<String, ToolCallback> callbacks = callbacks();
  53 +
  54 + // 调用,并断言目录内搜索
  55 + for (String path : List.of("nested", nested.toString())) {
  56 + assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("marker.txt"));
  57 + assertTrue(call(callbacks, "Grep", Map.of("pattern", "inside-marker", "path", path)).contains("marker.txt"));
  58 + assertTrue(call(callbacks, "ListDirectory", Map.of("path", path)).contains("marker.txt"));
  59 + }
  60 + // 调用,并断言目录外搜索
  61 + for (String path : List.of("../" + outside.getFileName(), outside.toString(), "escape")) {
  62 + assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("private.txt"));
  63 + assertFalse(call(callbacks, "Grep", Map.of("pattern", "outside-marker", "path", path)).contains("private.txt"));
  64 + assertFalse(call(callbacks, "ListDirectory", Map.of("path", path)).contains("private.txt"));
  65 + }
  66 + // 调用,并断言目录外文件读写
  67 + Path privateFile = outside.resolve("private.txt");
  68 + assertFalse(call(callbacks, "Read", Map.of("filePath", privateFile.toString())).contains("outside-marker"));
  69 + call(callbacks, "Write", Map.of("filePath", privateFile.toString(), "content", "changed"));
  70 + call(callbacks, "Edit", Map.of("filePath", root.resolve("escape/private.txt").toString(),
  71 + "old_string", "outside-marker", "new_string", "changed"));
  72 + // 断言
  73 + assertEquals("outside-marker", Files.readString(privateFile));
  74 + } finally {
  75 + Files.deleteIfExists(root.resolve("escape"));
  76 + Files.deleteIfExists(outside.resolve("private.txt"));
  77 + Files.deleteIfExists(outside);
  78 + }
  79 + }
  80 +
  81 + @Test
  82 + public void testTools_execute() throws Exception {
  83 + // 准备参数
29 Path skill = Files.createDirectory(root.resolve("probe")); 84 Path skill = Files.createDirectory(root.resolve("probe"));
30 Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); 85 Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n");
31 Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); 86 Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n");
32 ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); 87 ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build();
  88 +
  89 + // 调用,并断言技能读取
33 assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); 90 assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command"));
34 assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); 91 assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本"));
35 92
  93 + // 准备执行工具
36 Map<String, ToolCallback> callbacks = Arrays.stream(ToolCallbacks.from( 94 Map<String, ToolCallback> callbacks = Arrays.stream(ToolCallbacks.from(
37 ShellTools.builder().workingDirectory(root).build(), 95 ShellTools.builder().workingDirectory(root).build(),
38 FileSystemTools.builder().allowedDirectory(root).build(), 96 FileSystemTools.builder().allowedDirectory(root).build(),
@@ -41,21 +99,27 @@ public class Ai1SkillRuntimeCompatibilityTest { @@ -41,21 +99,27 @@ public class Ai1SkillRuntimeCompatibilityTest {
41 ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) 99 ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build()))
42 .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); 100 .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity()));
43 101
  102 + // 调用,并断言命令执行和文件搜索
44 assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); 103 assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString()));
45 assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); 104 assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok"));
46 assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); 105 assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt"));
47 assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); 106 assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt"));
48 assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); 107 assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt"));
49 108
  109 + // 调用
50 call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); 110 call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before"));
  111 + // 断言
51 assertEquals("before", Files.readString(root.resolve("written.txt"))); 112 assertEquals("before", Files.readString(root.resolve("written.txt")));
  113 + // 调用
52 call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); 114 call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after"));
  115 + // 断言
53 assertEquals("after", Files.readString(root.resolve("written.txt"))); 116 assertEquals("after", Files.readString(root.resolve("written.txt")));
54 117
55 - // 保留 0.12.0 的目录约束,不能因迁移而放宽文件和搜索工具访问范围。 118 + // 准备目录外文件
56 Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); 119 Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt");
57 try { 120 try {
58 Files.writeString(outside, "private-marker"); 121 Files.writeString(outside, "private-marker");
  122 + // 调用,并断言目录外访问
59 assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); 123 assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker"));
60 assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); 124 assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString()));
61 } finally { 125 } finally {
@@ -63,6 +127,17 @@ public class Ai1SkillRuntimeCompatibilityTest { @@ -63,6 +127,17 @@ public class Ai1SkillRuntimeCompatibilityTest {
63 } 127 }
64 } 128 }
65 129
  130 + // ========== 工具方法 ==========
  131 +
  132 + private Map<String, ToolCallback> callbacks() {
  133 + return Arrays.stream(ToolCallbacks.from(
  134 + FileSystemTools.builder().allowedDirectory(root).build(),
  135 + GlobTool.builder().workingDirectory(root).allowedDirectory(root).build(),
  136 + GrepTool.builder().workingDirectory(root).allowedDirectory(root).build(),
  137 + ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build()))
  138 + .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity()));
  139 + }
  140 +
66 private static String call(Map<String, ToolCallback> callbacks, String name, Map<String, Object> arguments) { 141 private static String call(Map<String, ToolCallback> callbacks, String name, Map<String, Object> arguments) {
67 ToolCallback callback = callbacks.get(name); 142 ToolCallback callback = callbacks.get(name);
68 assertNotNull(callback, name); 143 assertNotNull(callback, name);
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java
@@ -160,6 +160,37 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { @@ -160,6 +160,37 @@ public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest {
160 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); 160 verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID);
161 } 161 }
162 162
  163 + @Test
  164 + public void testBuildTools_duplicateFrontmatter() {
  165 + // 准备参数
  166 + Ai1SkillDO first = new Ai1SkillDO().setId(10L).setName("first").setStatus(CommonStatusEnum.ENABLE.getStatus());
  167 + Ai1SkillDO second = new Ai1SkillDO().setId(20L).setName("second").setStatus(CommonStatusEnum.ENABLE.getStatus());
  168 + Ai1AgentDO agent = new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of(20L, 10L));
  169 + // mock skillService 和 skillFileService 的方法
  170 + when(skillService.getSkillList(agent.getSkillIds())).thenReturn(List.of(second, first));
  171 + for (Ai1SkillDO skill : List.of(first, second)) {
  172 + String content = "---\nname: same\ndescription: " + skill.getName() + "\n---\n" + skill.getName() + "-content\n";
  173 + when(skillFileService.getSkillFileListBySkillId(skill.getId())).thenReturn(List.of(
  174 + Ai1SkillFileDO.builder().id(skill.getId()).skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT)
  175 + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).content(content).build()));
  176 + }
  177 +
  178 + // 调用
  179 + ToolCallback tool = (ToolCallback) skillToolFactory.buildTools(agent).get(0);
  180 + String result = tool.call("{\"command\":\"same\"}");
  181 + // 断言
  182 + assertTrue(result.contains("first-content"));
  183 + assertTrue(result.contains(agentRoot().resolve("first").toString()));
  184 + assertFalse(result.contains("second-content"));
  185 +
  186 + // 调用
  187 + skillToolFactory.evict(AGENT_ID);
  188 + ToolCallback rebuiltTool = (ToolCallback) skillToolFactory.buildTools(agent).get(0);
  189 + String rebuiltResult = rebuiltTool.call("{\"command\":\"same\"}");
  190 + // 断言
  191 + assertEquals(result, rebuiltResult);
  192 + }
  193 +
163 // ========== 测试数据 ========== 194 // ========== 测试数据 ==========
164 195
165 private Path agentRoot() { 196 private Path agentRoot() {
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java
@@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
25 import java.util.Arrays; 25 import java.util.Arrays;
26 import java.util.Collections; 26 import java.util.Collections;
27 import java.util.List; 27 import java.util.List;
  28 +import java.util.Map;
28 import java.util.function.Consumer; 29 import java.util.function.Consumer;
29 30
30 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; 31 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -333,7 +334,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { @@ -333,7 +334,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest {
333 public void testGetModelRespBO_success() { 334 public void testGetModelRespBO_success() {
334 // mock 方法 335 // mock 方法
335 Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()) 336 Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())
336 - .setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); 337 + .setHeaders(Map.of("X-App", "yudao")));
337 when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); 338 when(providerService.validateProviderExists(provider.getId())).thenReturn(provider);
338 // mock 数据 339 // mock 数据
339 Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); 340 Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId()));
@@ -345,7 +346,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { @@ -345,7 +346,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest {
345 assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); 346 assertEquals(provider.getBaseUrl(), respBO.getBaseUrl());
346 assertEquals(model.getModel(), respBO.getModel()); 347 assertEquals(model.getModel(), respBO.getModel());
347 assertEquals(model.getType(), respBO.getModelType()); 348 assertEquals(model.getType(), respBO.getModelType());
348 - assertEquals(Collections.singletonList("X-App"), convertList(respBO.getHeaders(), header -> header.get("key"))); 349 + assertEquals(Map.of("X-App", "yudao"), respBO.getHeaders());
349 } 350 }
350 351
351 // ========== 随机对象 ========== 352 // ========== 随机对象 ==========
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java
@@ -14,17 +14,17 @@ import org.junit.jupiter.api.Test; @@ -14,17 +14,17 @@ import org.junit.jupiter.api.Test;
14 import org.springframework.context.annotation.Import; 14 import org.springframework.context.annotation.Import;
15 import org.springframework.test.context.bean.override.mockito.MockitoBean; 15 import org.springframework.test.context.bean.override.mockito.MockitoBean;
16 16
17 -import java.util.Collections; 17 +import java.util.LinkedHashMap;
  18 +import java.util.List;
  19 +import java.util.Map;
18 import java.util.function.Consumer; 20 import java.util.function.Consumer;
19 21
20 -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;  
21 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; 22 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
22 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; 23 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
23 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; 24 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
24 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; 25 import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*;
25 import static org.junit.jupiter.api.Assertions.*; 26 import static org.junit.jupiter.api.Assertions.*;
26 import static org.mockito.ArgumentMatchers.anyList; 27 import static org.mockito.ArgumentMatchers.anyList;
27 -import static org.mockito.ArgumentMatchers.argThat;  
28 import static org.mockito.ArgumentMatchers.eq; 28 import static org.mockito.ArgumentMatchers.eq;
29 import static org.mockito.Mockito.*; 29 import static org.mockito.Mockito.*;
30 30
@@ -52,7 +52,7 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { @@ -52,7 +52,7 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest {
52 @Test 52 @Test
53 public void testCreateProvider_success() { 53 public void testCreateProvider_success() {
54 // 准备参数 54 // 准备参数
55 - Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders("[{\"key\":\"X-Session\",\"value\":\"{session}\"}]")); 55 + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders(Map.of("X-Session", "{session}")));
56 56
57 // 调用 57 // 调用
58 Long id = providerService.createProvider(reqVO); 58 Long id = providerService.createProvider(reqVO);
@@ -117,12 +117,12 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { @@ -117,12 +117,12 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest {
117 @Test 117 @Test
118 public void testTestProviderConnect_success() { 118 public void testTestProviderConnect_success() {
119 // mock 数据 119 // mock 数据
120 - Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); 120 + Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders(Map.of("X-App", "yudao")));
121 providerMapper.insert(dbProvider); 121 providerMapper.insert(dbProvider);
122 // mock 方法 122 // mock 方法
123 Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); 123 Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class);
124 when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()), 124 when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()),
125 - argThat(headers -> Collections.singletonList("X-App").equals(convertList(headers, header -> header.get("key")))))) 125 + eq(Map.of("X-App", "yudao"))))
126 .thenReturn(result); 126 .thenReturn(result);
127 127
128 // 调用 128 // 调用
@@ -131,6 +131,44 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { @@ -131,6 +131,44 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest {
131 assertSame(result, connectResult); 131 assertSame(result, connectResult);
132 } 132 }
133 133
  134 + @Test
  135 + public void testCreateAndUpdateProvider_headers() {
  136 + // 准备参数
  137 + Map<String, String> headers = new LinkedHashMap<>();
  138 + headers.put(" X-Z ", "${HEADER_TOKEN}");
  139 + headers.put("X-A", "{session}");
  140 + Ai1ProviderSaveReqVO req = randomProviderSaveReqVO(o -> o.setHeaders(headers));
  141 +
  142 + // 调用
  143 + Long id = providerService.createProvider(req);
  144 + // 断言
  145 + Ai1ProviderDO created = providerMapper.selectById(id);
  146 + assertEquals(List.of("X-Z", "X-A"), List.copyOf(created.getHeaders().keySet()));
  147 + assertEquals("${HEADER_TOKEN}", created.getHeaders().get("X-Z"));
  148 + assertEquals("{session}", created.getHeaders().get("X-A"));
  149 +
  150 + // 准备参数
  151 + req.setId(id).setHeaders(null);
  152 + // 调用
  153 + providerService.updateProvider(req);
  154 + // 断言
  155 + assertEquals(created.getHeaders(), providerMapper.selectById(id).getHeaders());
  156 +
  157 + // 准备参数
  158 + req.setHeaders(Map.of("X-New", "new"));
  159 + // 调用
  160 + providerService.updateProvider(req);
  161 + // 断言
  162 + assertEquals(req.getHeaders(), providerMapper.selectById(id).getHeaders());
  163 +
  164 + // 准备参数
  165 + req.setHeaders(Map.of());
  166 + // 调用
  167 + providerService.updateProvider(req);
  168 + // 断言
  169 + assertEquals(Map.of(), providerMapper.selectById(id).getHeaders());
  170 + }
  171 +
134 // ========== 随机对象 ========== 172 // ========== 随机对象 ==========
135 173
136 @SafeVarargs 174 @SafeVarargs
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.ai1.util;
  2 +
  3 +import cn.hutool.extra.spring.SpringUtil;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.mockito.MockedStatic;
  7 +import org.springframework.core.env.Environment;
  8 +import org.springframework.mock.env.MockEnvironment;
  9 +
  10 +import java.util.LinkedHashMap;
  11 +import java.util.List;
  12 +import java.util.Map;
  13 +
  14 +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*;
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +import static org.mockito.Mockito.mockStatic;
  17 +
  18 +public class Ai1UtilsTest {
  19 +
  20 + @Test
  21 + public void testNormalizeHeaders_orderAndValues() {
  22 + // 准备参数
  23 + Map<String, String> input = new LinkedHashMap<>();
  24 + input.put(" X-Z ", "${TEST}");
  25 + input.put("X-A", "{session}");
  26 +
  27 + // 调用
  28 + Map<String, String> normalized = Ai1Utils.normalizeHeaders(input);
  29 +
  30 + // 断言
  31 + assertInstanceOf(LinkedHashMap.class, normalized);
  32 + assertEquals(List.of("X-Z", "X-A"), List.copyOf(normalized.keySet()));
  33 + assertEquals("${TEST}", normalized.get("X-Z"));
  34 + assertEquals("{session}", normalized.get("X-A"));
  35 + assertTrue(input.containsKey(" X-Z "));
  36 + assertNotSame(input, normalized);
  37 + }
  38 +
  39 + @Test
  40 + public void testNormalizeHeaders_invalid() {
  41 + // 准备参数
  42 + Map<String, String> duplicate = new LinkedHashMap<>();
  43 + duplicate.put("X-Test", "static");
  44 + duplicate.put(" x-test ", "{session}");
  45 + Map<String, String> nullValue = new LinkedHashMap<>();
  46 + nullValue.put("X-Test", null);
  47 + Map<String, String> nullKey = new LinkedHashMap<>();
  48 + nullKey.put(null, "value");
  49 +
  50 + // 调用,并断言异常
  51 + for (Map<String, String> input : List.of(duplicate, nullValue, nullKey, Map.of(" ", "value"))) {
  52 + assertFalse(Ai1Utils.isHeadersValid(input));
  53 + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.normalizeHeaders(input));
  54 + assertEquals(PROVIDER_HEADERS_INVALID.getCode(), ex.getCode());
  55 + assertFalse(ex.getMessage().contains("static"));
  56 + }
  57 + }
  58 +
  59 + @Test
  60 + public void testNormalizeHeaders_nullAndEmpty() {
  61 + // 调用,并断言
  62 + assertNull(Ai1Utils.normalizeHeaders(null));
  63 + assertEquals(Map.of(), Ai1Utils.normalizeHeaders(Map.of()));
  64 + assertEquals(Map.of("X-Test", ""), Ai1Utils.normalizeHeaders(Map.of("X-Test", "")));
  65 + assertNull(Ai1Utils.resolveSpringPlaceholders((Map<String, String>) null));
  66 + }
  67 +
  68 + @Test
  69 + public void testIsHeadersValid_jsonLength() {
  70 + // 调用,并断言
  71 + assertTrue(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1992))));
  72 + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1993))));
  73 + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "\"".repeat(1000))));
  74 + }
  75 +
  76 + @Test
  77 + public void testResolveSpringPlaceholders_unresolved() {
  78 + // 准备参数
  79 + try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) {
  80 + spring.when(() -> SpringUtil.getBean(Environment.class)).thenReturn(new MockEnvironment());
  81 + // 调用,并断言异常
  82 + for (String value : List.of("literal-private-api-key-${MISSING}", "https://private-token@host/${MISSING}")) {
  83 + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.resolveSpringPlaceholders(value));
  84 + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode());
  85 + assertFalse(ex.getMessage().contains("private"));
  86 + assertFalse(ex.getMessage().contains("Header"));
  87 + assertFalse(ex.getMessage().contains(value));
  88 + }
  89 + ServiceException env = assertThrows(ServiceException.class, () ->
  90 + Ai1Utils.resolveSpringPlaceholders(Map.of("MCP_TOKEN", "private-env-${MISSING}")));
  91 + assertFalse(env.getMessage().contains("Header"));
  92 + assertFalse(env.getMessage().contains("private-env"));
  93 + }
  94 + }
  95 +
  96 + @Test
  97 + public void testResolveSpringPlaceholders_map() {
  98 + // 准备参数
  99 + try (MockedStatic<SpringUtil> spring = mockStatic(SpringUtil.class)) {
  100 + spring.when(() -> SpringUtil.getBean(Environment.class))
  101 + .thenReturn(new MockEnvironment().withProperty("HEADER_TEST", "a\"b\\c"));
  102 + Map<String, String> input = new LinkedHashMap<>();
  103 + input.put("X-Z", "${HEADER_TEST}-{session}");
  104 + input.put("X-A", "unchanged");
  105 +
  106 + // 调用
  107 + Map<String, String> resolved = Ai1Utils.resolveSpringPlaceholders(input);
  108 +
  109 + // 断言
  110 + assertEquals("a\"b\\c-{session}", resolved.get("X-Z"));
  111 + assertEquals(List.of("X-Z", "X-A"), List.copyOf(resolved.keySet()));
  112 + assertEquals("${HEADER_TEST}-{session}", input.get("X-Z"));
  113 + ServiceException ex = assertThrows(ServiceException.class, () ->
  114 + Ai1Utils.resolveSpringPlaceholders(Map.of("X-Test", "literal-secret-${MISSING_HEADER_TEST}")));
  115 + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode());
  116 + assertFalse(ex.getMessage().contains("literal-secret"));
  117 + }
  118 + }
  119 +}