Commit 7c397bbfae3e768b26e3ee962c5aa7064e8d7c96
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 | 22 | <properties> |
| 23 | 23 | <!-- 以下依赖不在 spring-ai-bom 中,需显式声明版本 --> |
| 24 | 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 | 26 | <milvus-sdk.version>3.0.10</milvus-sdk.version> |
| 27 | 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 | 3 | import io.swagger.v3.oas.annotations.media.Schema; |
| 4 | 4 | import lombok.Data; |
| 5 | 5 | |
| 6 | +import java.util.Map; | |
| 7 | + | |
| 6 | 8 | import java.time.LocalDateTime; |
| 7 | 9 | |
| 8 | 10 | @Schema(description = "管理后台 - AI1 供应商 Response VO") |
| ... | ... | @@ -21,8 +23,8 @@ public class Ai1ProviderRespVO { |
| 21 | 23 | @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") |
| 22 | 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 | 29 | @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") |
| 28 | 30 | private Integer status; | ... | ... |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java
| 1 | 1 | package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; |
| 2 | 2 | |
| 3 | -import cn.hutool.core.util.StrUtil; | |
| 4 | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | 4 | import cn.iocoder.yudao.framework.common.validation.InEnum; |
| 6 | 5 | import cn.iocoder.yudao.module.ai1.util.Ai1Utils; |
| ... | ... | @@ -12,6 +11,8 @@ import jakarta.validation.constraints.NotNull; |
| 12 | 11 | import jakarta.validation.constraints.Size; |
| 13 | 12 | import lombok.Data; |
| 14 | 13 | |
| 14 | +import java.util.Map; | |
| 15 | + | |
| 15 | 16 | @Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") |
| 16 | 17 | @Data |
| 17 | 18 | public class Ai1ProviderSaveReqVO { |
| ... | ... | @@ -33,9 +34,8 @@ public class Ai1ProviderSaveReqVO { |
| 33 | 34 | @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") |
| 34 | 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 | 40 | @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") |
| 41 | 41 | @NotNull(message = "状态不能为空") |
| ... | ... | @@ -46,10 +46,10 @@ public class Ai1ProviderSaveReqVO { |
| 46 | 46 | @Size(max = 255, message = "备注长度不能超过 255 个字符") |
| 47 | 47 | private String remark; |
| 48 | 48 | |
| 49 | - @AssertTrue(message = "请求附属 Header 格式不正确,应为 JSON 数组:[{\"key\":\"...\",\"value\":\"...\"}]") | |
| 49 | + @AssertTrue(message = "请求 Header 名称不能为空或重复,值必须为字符串,JSON 长度不能超过 2000 个字符") | |
| 50 | 50 | @JsonIgnore |
| 51 | 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 | 4 | import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; |
| 5 | 5 | import com.baomidou.mybatisplus.annotation.KeySequence; |
| 6 | 6 | import com.baomidou.mybatisplus.annotation.TableId; |
| 7 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 8 | +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; | |
| 7 | 9 | import com.baomidou.mybatisplus.annotation.TableName; |
| 8 | 10 | import lombok.*; |
| 9 | 11 | |
| 12 | +import java.util.Map; | |
| 13 | + | |
| 10 | 14 | /** |
| 11 | 15 | * AI1 供应商 DO |
| 12 | 16 | * |
| ... | ... | @@ -14,7 +18,7 @@ import lombok.*; |
| 14 | 18 | * |
| 15 | 19 | * @author 芋道源码 |
| 16 | 20 | */ |
| 17 | -@TableName("ai1_provider") | |
| 21 | +@TableName(value = "ai1_provider", autoResultMap = true) | |
| 18 | 22 | @KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 |
| 19 | 23 | @Data |
| 20 | 24 | @EqualsAndHashCode(callSuper = true) |
| ... | ... | @@ -42,11 +46,12 @@ public class Ai1ProviderDO extends TenantBaseDO { |
| 42 | 46 | */ |
| 43 | 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 | 16 | ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); |
| 17 | 17 | ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); |
| 18 | 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 | 21 | // ========== 模型 1-041-001-000 ========== |
| 21 | 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 | 51 | * @param content 当前用户消息 |
| 52 | 52 | * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 |
| 53 | 53 | * @param advisors 会话 Advisor,可为空 |
| 54 | - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符 | |
| 54 | + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符 | |
| 55 | 55 | * @param onThinking 思考过程增量回调,可为空 |
| 56 | 56 | * @param onContent 回复内容增量回调,可为空 |
| 57 | 57 | * @return 完整回复(内容 + 思考过程) | ... | ... |
yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java
| 1 | 1 | package cn.iocoder.yudao.module.ai1.harness.llm; |
| 2 | 2 | |
| 3 | -import cn.hutool.core.collection.CollUtil; | |
| 4 | 3 | import cn.hutool.core.util.StrUtil; |
| 5 | 4 | import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; |
| 6 | 5 | import com.google.common.cache.Cache; |
| ... | ... | @@ -21,7 +20,6 @@ import org.springframework.util.MultiValueMap; |
| 21 | 20 | import java.net.URI; |
| 22 | 21 | import java.time.Duration; |
| 23 | 22 | import java.util.LinkedHashMap; |
| 24 | -import java.util.List; | |
| 25 | 23 | import java.util.Map; |
| 26 | 24 | import java.util.Objects; |
| 27 | 25 | import java.util.function.Predicate; |
| ... | ... | @@ -43,7 +41,7 @@ import java.util.function.Predicate; |
| 43 | 41 | public class Ai1LlmModelFactory { |
| 44 | 42 | |
| 45 | 43 | /** |
| 46 | - * 附属 Header 会话占位符:构建模型时替换为当前会话编号 | |
| 44 | + * 请求 Header 会话占位符:构建模型时替换为当前会话编号 | |
| 47 | 45 | */ |
| 48 | 46 | public static final String SESSION_PLACEHOLDER = "{session}"; |
| 49 | 47 | /** |
| ... | ... | @@ -76,7 +74,7 @@ public class Ai1LlmModelFactory { |
| 76 | 74 | * 获取(或构建)对话模型,并包装为 ChatClient |
| 77 | 75 | * |
| 78 | 76 | * @param model 模型运行时快照 |
| 79 | - * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符,可为空 | |
| 77 | + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符,可为空 | |
| 80 | 78 | * @return ChatClient |
| 81 | 79 | */ |
| 82 | 80 | public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { |
| ... | ... | @@ -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 | 136 | private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { |
| ... | ... | @@ -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 | 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 | 216 | Map<String, String> result = new LinkedHashMap<>(); |
| 219 | - if (CollUtil.isEmpty(headers)) { | |
| 217 | + if (headers == null || headers.isEmpty()) { | |
| 220 | 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 | 223 | if (StrUtil.isBlank(key)) { |
| 226 | 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 | 58 | * |
| 59 | 59 | * @param baseUrl 接口地址 |
| 60 | 60 | * @param apiKey API 密钥 |
| 61 | - * @param headers 请求附属 Header | |
| 61 | + * @param headers 请求 Header | |
| 62 | 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 | 65 | long startTime = System.currentTimeMillis(); |
| 66 | 66 | String url = normalizeBaseUrl(baseUrl); |
| 67 | 67 | |
| ... | ... | @@ -102,10 +102,10 @@ public class Ai1ProviderTool { |
| 102 | 102 | * |
| 103 | 103 | * @param baseUrl 接口地址 |
| 104 | 104 | * @param apiKey API 密钥 |
| 105 | - * @param headers 请求附属 Header | |
| 105 | + * @param headers 请求 Header | |
| 106 | 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 | 109 | // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 |
| 110 | 110 | String url = normalizeBaseUrl(baseUrl) + "/models"; |
| 111 | 111 | HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); |
| ... | ... | @@ -153,12 +153,12 @@ public class Ai1ProviderTool { |
| 153 | 153 | * @param method 请求方法 |
| 154 | 154 | * @param url 请求地址 |
| 155 | 155 | * @param apiKey API 密钥 |
| 156 | - * @param headers 请求附属 Header | |
| 156 | + * @param headers 请求 Header | |
| 157 | 157 | * @param body JSON 请求体,可为空 |
| 158 | 158 | * @return 请求结果 |
| 159 | 159 | */ |
| 160 | 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 | 162 | try { |
| 163 | 163 | HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); |
| 164 | 164 | if (body != null) { |
| ... | ... | @@ -168,30 +168,32 @@ public class Ai1ProviderTool { |
| 168 | 168 | return new HttpOutcome(response.getStatus(), null, response.body()); |
| 169 | 169 | } |
| 170 | 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 | 180 | * @param method 请求方法 |
| 179 | 181 | * @param url 请求地址 |
| 180 | 182 | * @param apiKey API 密钥 |
| 181 | - * @param headers 请求附属 Header | |
| 183 | + * @param headers 请求 Header | |
| 182 | 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 | 187 | HttpRequest request = HttpRequest.of(url).method(method) |
| 186 | 188 | .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) |
| 187 | 189 | .header(Header.ACCEPT, ContentType.JSON.getValue()); |
| 188 | 190 | if (StrUtil.isNotBlank(apiKey)) { |
| 189 | 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 | 197 | if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { |
| 196 | 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 | 304 | // 1.2 生成所需的业务字段 |
| 305 | 305 | fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); |
| 306 | 306 | fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); |
| 307 | - // 生成时,附属 Header 中的 {session} 占位符替换为此处的会话编号 | |
| 307 | + // 生成时,请求 Header 中的 {session} 占位符替换为此处的会话编号 | |
| 308 | 308 | fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); |
| 309 | 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 | 157 | // 2.1 清空目录后,逐个物化开启的 SKILL |
| 158 | 158 | FileUtil.del(root); |
| 159 | 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 | 163 | if (CommonStatusEnum.isEnable(skill.getStatus()) |
| 163 | 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 | 168 | // 2.2 写入指纹标记,构建技能工具并缓存快照 |
| 168 | 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 | 172 | SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); |
| 171 | 173 | snapshotCache.put(agent.getId(), snapshot); |
| 172 | 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 | 29 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 30 | 30 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; |
| 31 | 31 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; |
| 32 | -import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; | |
| 33 | 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 | 175 | |
| 177 | 176 | // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 |
| 178 | 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 | 181 | @Override |
| ... | ... | @@ -200,7 +199,7 @@ public class Ai1ModelServiceImpl implements Ai1ModelService { |
| 200 | 199 | .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) |
| 201 | 200 | .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) |
| 202 | 201 | .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) |
| 203 | - .setHeaders(parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); | |
| 202 | + .setHeaders(resolveSpringPlaceholders(provider.getHeaders())); | |
| 204 | 203 | } |
| 205 | 204 | |
| 206 | 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 | 23 | |
| 24 | 24 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 25 | 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 | 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 | 51 | @Override |
| 52 | 52 | public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { |
| 53 | 53 | Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); |
| 54 | + provider.setHeaders(normalizeHeaders(createReqVO.getHeaders())); | |
| 54 | 55 | providerMapper.insert(provider); |
| 55 | 56 | return provider.getId(); |
| 56 | 57 | } |
| ... | ... | @@ -62,6 +63,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { |
| 62 | 63 | |
| 63 | 64 | // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 |
| 64 | 65 | Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); |
| 66 | + updateObj.setHeaders(normalizeHeaders(updateReqVO.getHeaders())); | |
| 65 | 67 | if (StrUtil.isBlank(updateObj.getApiKey())) { |
| 66 | 68 | updateObj.setApiKey(null); |
| 67 | 69 | } |
| ... | ... | @@ -132,7 +134,7 @@ public class Ai1ProviderServiceImpl implements Ai1ProviderService { |
| 132 | 134 | |
| 133 | 135 | // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 |
| 134 | 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 | 4 | import lombok.Data; |
| 5 | 5 | import lombok.experimental.Accessors; |
| 6 | 6 | |
| 7 | -import java.util.List; | |
| 8 | 7 | import java.util.Map; |
| 9 | 8 | |
| 10 | 9 | /** |
| ... | ... | @@ -46,8 +45,8 @@ public class Ai1ModelRespBO { |
| 46 | 45 | */ |
| 47 | 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 | 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 | 3 | import cn.hutool.core.util.StrUtil; |
| 6 | 4 | import cn.hutool.extra.spring.SpringUtil; |
| 7 | 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 | 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 | 11 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 17 | 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 | 16 | * AI1 工具类 |
| ... | ... | @@ -38,7 +34,7 @@ public class Ai1Utils { |
| 38 | 34 | try { |
| 39 | 35 | return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); |
| 40 | 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 | 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 | 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 | 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 | 7 | import org.junit.jupiter.api.AfterEach; |
| 8 | 8 | import org.junit.jupiter.api.BeforeEach; |
| 9 | 9 | import org.junit.jupiter.api.Test; |
| 10 | +import org.junit.jupiter.api.io.TempDir; | |
| 10 | 11 | import org.junit.jupiter.params.ParameterizedTest; |
| 11 | 12 | import org.junit.jupiter.params.provider.ValueSource; |
| 13 | +import org.springaicommunity.agent.tools.SkillsTool; | |
| 12 | 14 | import org.springframework.ai.embedding.EmbeddingModel; |
| 15 | +import org.springframework.ai.tool.ToolCallback; | |
| 13 | 16 | import org.springframework.test.util.ReflectionTestUtils; |
| 14 | 17 | |
| 15 | 18 | import java.net.InetSocketAddress; |
| 16 | 19 | import java.nio.charset.StandardCharsets; |
| 20 | +import java.nio.file.Files; | |
| 21 | +import java.nio.file.Path; | |
| 17 | 22 | import java.util.ArrayList; |
| 18 | 23 | import java.util.List; |
| 19 | 24 | import java.util.Map; |
| ... | ... | @@ -22,7 +27,7 @@ import java.util.concurrent.CopyOnWriteArrayList; |
| 22 | 27 | import static org.junit.jupiter.api.Assertions.*; |
| 23 | 28 | |
| 24 | 29 | /** |
| 25 | - * 模型调用回归:通过本地 HTTP 端点验证真实请求、流式分片和模型缓存。 | |
| 30 | + * {@link Ai1LlmModelFactory} 的单元测试 | |
| 26 | 31 | */ |
| 27 | 32 | public class Ai1LlmModelFactoryTest { |
| 28 | 33 | |
| ... | ... | @@ -30,9 +35,11 @@ public class Ai1LlmModelFactoryTest { |
| 30 | 35 | private final List<Request> requests = new CopyOnWriteArrayList<>(); |
| 31 | 36 | private HttpServer server; |
| 32 | 37 | private String baseUrl; |
| 38 | + private String requestedTool; | |
| 33 | 39 | |
| 34 | 40 | @BeforeEach |
| 35 | 41 | public void before() throws Exception { |
| 42 | + // mock HTTP 服务 | |
| 36 | 43 | server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); |
| 37 | 44 | server.createContext("/", exchange -> { |
| 38 | 45 | requests.add(new Request(exchange.getRequestURI().getPath(), |
| ... | ... | @@ -41,7 +48,8 @@ public class Ai1LlmModelFactoryTest { |
| 41 | 48 | exchange.getRequestHeaders().getFirst("X-Provider"), |
| 42 | 49 | JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); |
| 43 | 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 | 53 | ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" |
| 46 | 54 | : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") |
| 47 | 55 | + sse("{\"reasoning_content\":\"想\"}") |
| ... | ... | @@ -67,15 +75,18 @@ public class Ai1LlmModelFactoryTest { |
| 67 | 75 | @ParameterizedTest |
| 68 | 76 | @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) |
| 69 | 77 | public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { |
| 78 | + // 准备参数 | |
| 70 | 79 | Ai1ModelRespBO model = model(baseUrl + prefix); |
| 71 | 80 | Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); |
| 72 | 81 | ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); |
| 73 | 82 | List<String> thinking = new ArrayList<>(); |
| 74 | 83 | List<String> content = new ArrayList<>(); |
| 75 | 84 | |
| 85 | + // 调用 | |
| 76 | 86 | Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.<String[]>of(new String[]{"user", "历史"}), |
| 77 | 87 | "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); |
| 78 | 88 | |
| 89 | + // 断言 | |
| 79 | 90 | assertEquals("你好", result.getContent()); |
| 80 | 91 | assertEquals("想想想到", result.getThinking()); |
| 81 | 92 | assertEquals(List.of("想", "想", "想到"), thinking); |
| ... | ... | @@ -94,10 +105,14 @@ public class Ai1LlmModelFactoryTest { |
| 94 | 105 | |
| 95 | 106 | @Test |
| 96 | 107 | public void testEmbedding_endpointAndHeaders() { |
| 108 | + // 准备参数 | |
| 97 | 109 | Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); |
| 98 | 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 | 116 | Request request = requests.get(0); |
| 102 | 117 | assertEquals("/proxy/v1/embeddings", request.path()); |
| 103 | 118 | assertEquals("Bearer test-key", request.authorization()); |
| ... | ... | @@ -108,35 +123,79 @@ public class Ai1LlmModelFactoryTest { |
| 108 | 123 | } |
| 109 | 124 | |
| 110 | 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 | 153 | public void testCache_sessionIsolationAndInvalidation() { |
| 154 | + // 准备参数 | |
| 112 | 155 | Ai1ModelRespBO model = model(baseUrl); |
| 156 | + | |
| 157 | + // 调用 | |
| 113 | 158 | Object chat = chatModel(model, 1L); |
| 159 | + // 断言 | |
| 114 | 160 | assertSame(chat, chatModel(model, 1L)); |
| 115 | 161 | assertNotSame(chat, chatModel(model, 2L)); |
| 116 | 162 | EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); |
| 117 | 163 | assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); |
| 118 | 164 | |
| 165 | + // 调用 | |
| 119 | 166 | factory.evictByModelId(model.getModelId()); |
| 167 | + // 断言 | |
| 120 | 168 | assertNotSame(chat, chatModel(model, 1L)); |
| 121 | 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 | 174 | chat = chatModel(model, 1L); |
| 175 | + // 断言 | |
| 124 | 176 | assertSame(chat, chatModel(model, 2L)); |
| 177 | + | |
| 178 | + // 调用 | |
| 125 | 179 | model.setApiKey("changed-key"); |
| 180 | + // 断言 | |
| 126 | 181 | assertNotSame(chat, chatModel(model, 1L)); |
| 182 | + | |
| 183 | + // 调用 | |
| 127 | 184 | chat = chatModel(model, 1L); |
| 128 | 185 | factory.evictByProviderId(model.getProviderId()); |
| 186 | + // 断言 | |
| 129 | 187 | assertNotSame(chat, chatModel(model, 1L)); |
| 130 | 188 | } |
| 131 | 189 | |
| 190 | + // ========== 工具方法 ========== | |
| 191 | + | |
| 132 | 192 | private Object chatModel(Ai1ModelRespBO model, Long sessionId) { |
| 133 | 193 | return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); |
| 134 | 194 | } |
| 135 | 195 | |
| 136 | 196 | private static Ai1ModelRespBO model(String url) { |
| 137 | 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 | 201 | private static String sse(String delta) { |
| ... | ... | @@ -144,6 +203,16 @@ public class Ai1LlmModelFactoryTest { |
| 144 | 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 | 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 | 16 | import static org.junit.jupiter.api.Assertions.*; |
| 17 | 17 | |
| 18 | 18 | /** |
| 19 | - * 验证 MCP SDK 的真实 HTTP 握手及工具调用。 | |
| 19 | + * {@link Ai1McpClientTool} 的运行时测试 | |
| 20 | 20 | */ |
| 21 | 21 | public class Ai1McpRuntimeCompatibilityTest { |
| 22 | 22 | |
| 23 | 23 | @Test |
| 24 | 24 | @Timeout(15) |
| 25 | 25 | public void testStreamableHttp_initializeListAndCall() throws Exception { |
| 26 | + // mock HTTP 服务 | |
| 26 | 27 | List<String> methods = new CopyOnWriteArrayList<>(); |
| 27 | 28 | List<String> paths = new CopyOnWriteArrayList<>(); |
| 28 | 29 | List<String> headers = new CopyOnWriteArrayList<>(); |
| ... | ... | @@ -63,13 +64,16 @@ public class Ai1McpRuntimeCompatibilityTest { |
| 63 | 64 | server.start(); |
| 64 | 65 | Ai1McpClientTool client = new Ai1McpClientTool(); |
| 65 | 66 | try { |
| 67 | + // 准备参数 | |
| 66 | 68 | Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") |
| 67 | 69 | .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") |
| 68 | 70 | .setHeaders(Map.of("X-Probe", "compatibility")); |
| 69 | 71 | |
| 72 | + // 调用,并断言工具发现和调用 | |
| 70 | 73 | assertEquals(1, client.listTools(mcp).size()); |
| 71 | 74 | assertEquals("echo", client.listTools(mcp).get(0).name()); |
| 72 | 75 | assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); |
| 76 | + // 断言 HTTP 请求 | |
| 73 | 77 | assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); |
| 74 | 78 | assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); |
| 75 | 79 | assertTrue(headers.stream().allMatch("compatibility"::equals)); | ... | ... |
yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java
| 1 | 1 | package cn.iocoder.yudao.module.ai1.harness.model; |
| 2 | 2 | |
| 3 | +import cn.hutool.extra.spring.SpringUtil; | |
| 3 | 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 | 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 | 14 | import java.util.List; |
| 15 | +import java.util.Map; | |
| 16 | +import java.util.concurrent.CopyOnWriteArrayList; | |
| 7 | 17 | |
| 8 | 18 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 9 | 19 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; |
| 10 | 20 | import static org.junit.jupiter.api.Assertions.*; |
| 21 | +import static org.mockito.Mockito.mockStatic; | |
| 11 | 22 | |
| 12 | 23 | /** |
| 13 | 24 | * {@link Ai1ProviderTool} 的单元测试 |
| ... | ... | @@ -39,6 +50,68 @@ public class Ai1ProviderToolTest { |
| 39 | 50 | } |
| 40 | 51 | |
| 41 | 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 | 115 | public void testParseModels() { |
| 43 | 116 | // 调用,并断言过滤无效模型 |
| 44 | 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 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | 5 | import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; |
| 6 | 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 | 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 | 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 | 14 | import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; |
| 12 | 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 | 16 | import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; |
| 16 | 17 | import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; |
| 17 | 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 | 21 | import org.junit.jupiter.api.BeforeEach; |
| 22 | 22 | import org.junit.jupiter.api.Test; |
| 23 | 23 | import org.junit.jupiter.params.ParameterizedTest; |
| ... | ... | @@ -26,23 +26,23 @@ import org.mockito.ArgumentCaptor; |
| 26 | 26 | import org.mockito.InjectMocks; |
| 27 | 27 | import org.mockito.Mock; |
| 28 | 28 | import org.mockito.Spy; |
| 29 | -import org.redisson.api.RStream; | |
| 30 | 29 | import org.redisson.api.RLock; |
| 30 | +import org.redisson.api.RStream; | |
| 31 | 31 | import org.redisson.api.RedissonClient; |
| 32 | 32 | import org.redisson.api.stream.StreamAddArgs; |
| 33 | 33 | import org.redisson.api.stream.StreamMessageId; |
| 34 | 34 | import org.redisson.api.stream.StreamRangeArgs; |
| 35 | 35 | import org.redisson.api.stream.StreamReadArgs; |
| 36 | +import org.redisson.client.codec.Codec; | |
| 36 | 37 | import org.springframework.test.util.ReflectionTestUtils; |
| 37 | 38 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| 38 | -import org.redisson.client.codec.Codec; | |
| 39 | 39 | |
| 40 | 40 | import java.io.IOException; |
| 41 | 41 | import java.util.ArrayList; |
| 42 | 42 | import java.util.HashMap; |
| 43 | +import java.util.LinkedHashMap; | |
| 43 | 44 | import java.util.List; |
| 44 | 45 | import java.util.Map; |
| 45 | -import java.util.LinkedHashMap; | |
| 46 | 46 | import java.util.concurrent.atomic.AtomicReference; |
| 47 | 47 | import java.util.function.Consumer; |
| 48 | 48 | |
| ... | ... | @@ -111,14 +111,17 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { |
| 111 | 111 | |
| 112 | 112 | @Test |
| 113 | 113 | public void testStreamResult_disconnectedMustNotComplete() throws Exception { |
| 114 | - SseEmitter emitter = mock( | |
| 115 | - SseEmitter.class); | |
| 114 | + // 准备参数 | |
| 115 | + SseEmitter emitter = mock(SseEmitter.class); | |
| 116 | + // mock 连接断开 | |
| 116 | 117 | doThrow(new IOException("client disconnected")).when(emitter).send( |
| 117 | 118 | any(SseEmitter.SseEventBuilder.class)); |
| 118 | 119 | |
| 120 | + // 调用 | |
| 119 | 121 | Boolean shouldComplete = ReflectionTestUtils.invokeMethod( |
| 120 | 122 | sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); |
| 121 | 123 | |
| 124 | + // 断言 | |
| 122 | 125 | assertEquals(Boolean.FALSE, shouldComplete); |
| 123 | 126 | verifyNoInteractions(resultStream); |
| 124 | 127 | verify(emitter, never()).complete(); |
| ... | ... | @@ -126,27 +129,33 @@ public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { |
| 126 | 129 | |
| 127 | 130 | @Test |
| 128 | 131 | public void testStreamResult_terminalMustComplete() { |
| 129 | - SseEmitter emitter = mock( | |
| 130 | - SseEmitter.class); | |
| 132 | + // 准备参数 | |
| 133 | + SseEmitter emitter = mock(SseEmitter.class); | |
| 134 | + // mock 结果流 | |
| 131 | 135 | when(resultStream.read(any(StreamReadArgs.class))) |
| 132 | 136 | .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); |
| 133 | 137 | |
| 138 | + // 调用 | |
| 134 | 139 | Boolean shouldComplete = ReflectionTestUtils.invokeMethod( |
| 135 | 140 | sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); |
| 136 | 141 | |
| 142 | + // 断言 | |
| 137 | 143 | assertEquals(Boolean.TRUE, shouldComplete); |
| 138 | 144 | } |
| 139 | 145 | |
| 140 | 146 | @Test |
| 141 | 147 | public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { |
| 142 | - SseEmitter emitter = mock( | |
| 143 | - SseEmitter.class); | |
| 148 | + // 准备参数 | |
| 149 | + SseEmitter emitter = mock(SseEmitter.class); | |
| 150 | + // mock 连接断开 | |
| 144 | 151 | doThrow(new IOException("client disconnected")).when(emitter).send( |
| 145 | 152 | any(SseEmitter.SseEventBuilder.class)); |
| 146 | 153 | |
| 154 | + // 调用 | |
| 147 | 155 | ReflectionTestUtils.invokeMethod( |
| 148 | 156 | Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); |
| 149 | 157 | |
| 158 | + // 断言 | |
| 150 | 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 | 10 | import java.nio.file.Files; |
| 11 | 11 | import java.nio.file.Path; |
| 12 | 12 | import java.util.Arrays; |
| 13 | +import java.util.List; | |
| 13 | 14 | import java.util.Map; |
| 14 | 15 | import java.util.function.Function; |
| 15 | 16 | import java.util.stream.Collectors; |
| 16 | 17 | |
| 17 | 18 | import static org.junit.jupiter.api.Assertions.*; |
| 18 | 19 | |
| 19 | -/** | |
| 20 | - * 验证 AI1 使用的 agent-utils 工具的注册、JSON 入参和实际执行。 | |
| 21 | - */ | |
| 22 | 20 | public class Ai1SkillRuntimeCompatibilityTest { |
| 23 | 21 | |
| 24 | 22 | @TempDir |
| 25 | 23 | private Path root; |
| 26 | 24 | |
| 27 | 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 | 84 | Path skill = Files.createDirectory(root.resolve("probe")); |
| 30 | 85 | Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); |
| 31 | 86 | Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); |
| 32 | 87 | ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); |
| 88 | + | |
| 89 | + // 调用,并断言技能读取 | |
| 33 | 90 | assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); |
| 34 | 91 | assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); |
| 35 | 92 | |
| 93 | + // 准备执行工具 | |
| 36 | 94 | Map<String, ToolCallback> callbacks = Arrays.stream(ToolCallbacks.from( |
| 37 | 95 | ShellTools.builder().workingDirectory(root).build(), |
| 38 | 96 | FileSystemTools.builder().allowedDirectory(root).build(), |
| ... | ... | @@ -41,21 +99,27 @@ public class Ai1SkillRuntimeCompatibilityTest { |
| 41 | 99 | ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) |
| 42 | 100 | .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); |
| 43 | 101 | |
| 102 | + // 调用,并断言命令执行和文件搜索 | |
| 44 | 103 | assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); |
| 45 | 104 | assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); |
| 46 | 105 | assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); |
| 47 | 106 | assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); |
| 48 | 107 | assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); |
| 49 | 108 | |
| 109 | + // 调用 | |
| 50 | 110 | call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); |
| 111 | + // 断言 | |
| 51 | 112 | assertEquals("before", Files.readString(root.resolve("written.txt"))); |
| 113 | + // 调用 | |
| 52 | 114 | call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); |
| 115 | + // 断言 | |
| 53 | 116 | assertEquals("after", Files.readString(root.resolve("written.txt"))); |
| 54 | 117 | |
| 55 | - // 保留 0.12.0 的目录约束,不能因迁移而放宽文件和搜索工具访问范围。 | |
| 118 | + // 准备目录外文件 | |
| 56 | 119 | Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); |
| 57 | 120 | try { |
| 58 | 121 | Files.writeString(outside, "private-marker"); |
| 122 | + // 调用,并断言目录外访问 | |
| 59 | 123 | assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); |
| 60 | 124 | assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); |
| 61 | 125 | } finally { |
| ... | ... | @@ -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 | 141 | private static String call(Map<String, ToolCallback> callbacks, String name, Map<String, Object> arguments) { |
| 67 | 142 | ToolCallback callback = callbacks.get(name); |
| 68 | 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 | 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 | 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 | 25 | import java.util.Arrays; |
| 26 | 26 | import java.util.Collections; |
| 27 | 27 | import java.util.List; |
| 28 | +import java.util.Map; | |
| 28 | 29 | import java.util.function.Consumer; |
| 29 | 30 | |
| 30 | 31 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| ... | ... | @@ -333,7 +334,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { |
| 333 | 334 | public void testGetModelRespBO_success() { |
| 334 | 335 | // mock 方法 |
| 335 | 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 | 338 | when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); |
| 338 | 339 | // mock 数据 |
| 339 | 340 | Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); |
| ... | ... | @@ -345,7 +346,7 @@ public class Ai1ModelServiceImplTest extends BaseDbUnitTest { |
| 345 | 346 | assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); |
| 346 | 347 | assertEquals(model.getModel(), respBO.getModel()); |
| 347 | 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 | 14 | import org.springframework.context.annotation.Import; |
| 15 | 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 | 20 | import java.util.function.Consumer; |
| 19 | 21 | |
| 20 | -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | |
| 21 | 22 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 22 | 23 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; |
| 23 | 24 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 24 | 25 | import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; |
| 25 | 26 | import static org.junit.jupiter.api.Assertions.*; |
| 26 | 27 | import static org.mockito.ArgumentMatchers.anyList; |
| 27 | -import static org.mockito.ArgumentMatchers.argThat; | |
| 28 | 28 | import static org.mockito.ArgumentMatchers.eq; |
| 29 | 29 | import static org.mockito.Mockito.*; |
| 30 | 30 | |
| ... | ... | @@ -52,7 +52,7 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { |
| 52 | 52 | @Test |
| 53 | 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 | 58 | Long id = providerService.createProvider(reqVO); |
| ... | ... | @@ -117,12 +117,12 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { |
| 117 | 117 | @Test |
| 118 | 118 | public void testTestProviderConnect_success() { |
| 119 | 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 | 121 | providerMapper.insert(dbProvider); |
| 122 | 122 | // mock 方法 |
| 123 | 123 | Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); |
| 124 | 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 | 126 | .thenReturn(result); |
| 127 | 127 | |
| 128 | 128 | // 调用 |
| ... | ... | @@ -131,6 +131,44 @@ public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { |
| 131 | 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 | 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 | +} | ... | ... |