diff --git a/pom.xml b/pom.xml index a35fe1c..285aa6e 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ + ${project.artifactId} diff --git a/yudao-module-ai1-springai/pom.xml b/yudao-module-ai1-springai/pom.xml new file mode 100644 index 0000000..d0a3916 --- /dev/null +++ b/yudao-module-ai1-springai/pom.xml @@ -0,0 +1,128 @@ + + + + cn.iocoder.boot + yudao + ${revision} + + 4.0.0 + jar + yudao-module-ai1-springai + + ${project.artifactId} + + ai1 模块,参考 XXL-AI 优化实现的 AI Agent 能力,基于 Spring AI 接入芋道: + 1、Agent 编排:模型、系统指令、知识库、MCP、SKILL 组合成 Agent,资源支持多选绑定 + 2、多供应商:统一接入 OpenAI 兼容协议的对话与嵌入模型,支持连通测试和远程模型导入 + 3、MCP、SKILL、RAG:远程与本地 MCP、SKILL.md 技能物化执行、Milvus 知识库检索并注入对话 + 4、流式对话:SSE 输出思考过程与回复,Redis Stream 支持集群部署和断线续传 + + + + 2.0.1 + 0.12.0 + 3.0.10 + + + + + cn.iocoder.boot + yudao-module-system + ${revision} + + + cn.iocoder.boot + yudao-module-infra + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + cn.iocoder.boot + yudao-spring-boot-starter-redis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + org.springframework.ai + spring-ai-openai + + + io.swagger.core.v3 + swagger-annotations-jakarta + + + + + + org.springframework.ai + spring-ai-client-chat + + + + org.springframework.ai + spring-ai-advisors-vector-store + + + + org.springframework.ai + spring-ai-milvus-store + + + + org.slf4j + slf4j-reload4j + + + + + io.milvus + milvus-sdk-java + ${milvus-sdk.version} + + + + org.slf4j + slf4j-reload4j + + + + + + org.springaicommunity + spring-ai-agent-utils + ${spring-ai-agent-utils.version} + + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp-sdk.version} + + + + diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java new file mode 100644 index 0000000..c38f70e --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentUpdateStatusReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ProviderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI1 Agent") +@RestController +@RequestMapping("/ai1/agent") +@Validated +public class Ai1AgentController { + + @Resource + private Ai1AgentService agentService; + @Resource + private Ai1ProviderService providerService; + @Resource + private Ai1ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建 Agent") + @PreAuthorize("@ss.hasPermission('ai1:agent:create')") + public CommonResult createAgent(@Valid @RequestBody Ai1AgentSaveReqVO createReqVO) { + return success(agentService.createAgent(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 Agent") + @PreAuthorize("@ss.hasPermission('ai1:agent:update')") + public CommonResult updateAgent(@Valid @RequestBody Ai1AgentSaveReqVO updateReqVO) { + agentService.updateAgent(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 Agent", description = "级联删除会话与消息") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:agent:delete')") + public CommonResult deleteAgent(@RequestParam("id") Long id) { + agentService.deleteAgent(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除 Agent", description = "级联删除会话与消息") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:agent:delete')") + public CommonResult deleteAgentList(@RequestParam("ids") List ids) { + agentService.deleteAgentListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 Agent") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:agent:query')") + public CommonResult getAgent(@RequestParam("id") Long id) { + Ai1AgentDO agent = agentService.getAgent(id); + return success(buildAgentRespVO(agent)); + } + + @GetMapping("/page") + @Operation(summary = "获得 Agent 分页") + @PreAuthorize("@ss.hasPermission('ai1:agent:query')") + public CommonResult> getAgentPage(@Valid Ai1AgentPageReqVO pageReqVO) { + PageResult pageResult = agentService.getAgentPage(pageReqVO); + return success(new PageResult<>(buildAgentRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得已开启的 Agent 精简列表", description = "用于会话页选择 Agent,仅返回已开启的 Agent") + public CommonResult> getAgentSimpleList() { + List list = agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus()); + Map modelMap = modelService.getModelMap(convertSet(list, Ai1AgentDO::getModelId)); + return success(convertList(list, agent -> { + Ai1AgentRespVO respVO = new Ai1AgentRespVO().setId(agent.getId()).setName(agent.getName()) + .setIntroduction(agent.getIntroduction()).setModelId(agent.getModelId()); + MapUtils.findAndThen(modelMap, agent.getModelId(), model -> respVO.setModelName(model.getName())); + return respVO; + })); + } + + @PutMapping("/update-status") + @Operation(summary = "修改 Agent 状态") + @PreAuthorize("@ss.hasPermission('ai1:agent:update')") + public CommonResult updateAgentStatus(@Valid @RequestBody Ai1AgentUpdateStatusReqVO reqVO) { + agentService.updateAgentStatus(reqVO.getId(), reqVO.getStatus()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private Ai1AgentRespVO buildAgentRespVO(Ai1AgentDO agent) { + if (agent == null) { + return null; + } + return CollUtil.getFirst(buildAgentRespVOList(Collections.singletonList(agent))); + } + + private List buildAgentRespVOList(List list) { + Map providerMap = providerService.getProviderMap(convertSet(list, Ai1AgentDO::getProviderId)); + Map modelMap = modelService.getModelMap(convertSet(list, Ai1AgentDO::getModelId)); + return BeanUtils.toBean(list, Ai1AgentRespVO.class, respVO -> { + MapUtils.findAndThen(providerMap, respVO.getProviderId(), provider -> respVO.setProviderName(provider.getName())); + MapUtils.findAndThen(modelMap, respVO.getModelId(), model -> respVO.setModelName(model.getName())); + }); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentPageReqVO.java new file mode 100644 index 0000000..1be4611 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentPageReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 Agent 分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1AgentPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "客服助手") + private String name; + + @Schema(description = "状态", example = "0") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java new file mode 100644 index 0000000..16a95a6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - AI1 Agent Response VO") +@Data +public class Ai1AgentRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客服助手") + private String name; + + @Schema(description = "介绍", example = "解答产品使用问题") + private String introduction; + + @Schema(description = "模型所属 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long providerId; + + @Schema(description = "模型所属 Provider 名称", example = "DeepSeek") + private String providerName; + + @Schema(description = "对话模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long modelId; + + @Schema(description = "对话模型名称", example = "DeepSeek Chat") + private String modelName; + + @Schema(description = "系统指令", example = "你是一名专业的客服") + private String systemPrompt; + + @Schema(description = "知识库编号集合", example = "[1, 2]") + private List knowledgeBaseIds; + + @Schema(description = "MCP 编号集合", example = "[1]") + private List mcpIds; + + @Schema(description = "SKILL 编号集合", example = "[1]") + private List skillIds; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java new file mode 100644 index 0000000..8e724c9 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI1 Agent 新增/修改 Request VO") +@Data +public class Ai1AgentSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客服助手") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "介绍", example = "解答产品使用问题") + @Size(max = 500, message = "介绍长度不能超过 500 个字符") + private String introduction; + + @Schema(description = "模型所属 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "Provider 不能为空") + private Long providerId; + + @Schema(description = "对话模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "对话模型不能为空") + private Long modelId; + + @Schema(description = "系统指令", example = "你是一名专业的客服") + private String systemPrompt; + + @Schema(description = "知识库编号集合", example = "[1, 2]") + private List knowledgeBaseIds; + + @Schema(description = "MCP 编号集合", example = "[1]") + private List mcpIds; + + @Schema(description = "SKILL 编号集合", example = "[1]") + private List skillIds; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentUpdateStatusReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentUpdateStatusReqVO.java new file mode 100644 index 0000000..534a777 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentUpdateStatusReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 Agent 修改状态 Request VO") +@Data +public class Ai1AgentUpdateStatusReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "修改状态必须是 {value}") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/Ai1HomeController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/Ai1HomeController.java new file mode 100644 index 0000000..ee8ab2d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/Ai1HomeController.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.home.Ai1HomeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI1 首页") +@RestController +@RequestMapping("/ai1/home") +@Validated +public class Ai1HomeController { + + @Resource + private Ai1HomeService homeService; + @Resource + private Ai1AgentService agentService; + + @GetMapping("/summary") + @Operation(summary = "获得总量统计") + @PreAuthorize("@ss.hasPermission('ai1:home:query')") + public CommonResult getSummary() { + return success(homeService.getSummary()); + } + + @GetMapping("/message-summary-by-date") + @Operation(summary = "获得按日消息统计", description = "近 days 个自然日(含今天)每日消息数量,没有消息的日期补 0") + @Parameter(name = "days", description = "天数,1 ~ 30,默认 30", example = "30") + @PreAuthorize("@ss.hasPermission('ai1:home:query')") + public CommonResult> getMessageSummaryByDate( + @RequestParam(value = "days", defaultValue = "30") @Min(value = 1, message = "天数不能小于 1") + @Max(value = 30, message = "天数不能大于 30") Integer days) { + return success(homeService.getMessageSummaryByDate(days)); + } + + @GetMapping("/message-summary-by-agent") + @Operation(summary = "获得按 Agent 消息统计", description = "近 days 个自然日(含今天)各 Agent 的消息数量") + @Parameter(name = "days", description = "天数,1 ~ 30,默认 30", example = "30") + @PreAuthorize("@ss.hasPermission('ai1:home:query')") + public CommonResult> getMessageSummaryByAgent( + @RequestParam(value = "days", defaultValue = "30") @Min(value = 1, message = "天数不能小于 1") + @Max(value = 30, message = "天数不能大于 30") Integer days) { + return success(buildMessageSummaryByAgentRespVOList(homeService.getMessageSummaryByAgent(days))); + } + + // ==================== 拼接 VO ==================== + + private List buildMessageSummaryByAgentRespVOList(List list) { + Map agentMap = agentService.getAgentMap(convertSet(list, Ai1HomeMessageSummaryByAgentRespVO::getAgentId)); + list.forEach(item -> + MapUtils.findAndThen(agentMap, item.getAgentId(), agent -> item.setAgentName(agent.getName()))); + return list; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByAgentRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByAgentRespVO.java new file mode 100644 index 0000000..25beb1d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByAgentRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 首页按 Agent 消息统计 Response VO") +@Data +public class Ai1HomeMessageSummaryByAgentRespVO { + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long agentId; + + @Schema(description = "Agent 名称,Agent 已删除时为空", example = "Hi Agent") + private String agentName; + + @Schema(description = "消息数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "36") + private Long count; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByDateRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByDateRespVO.java new file mode 100644 index 0000000..13679e8 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByDateRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 首页按日消息统计 Response VO") +@Data +public class Ai1HomeMessageSummaryByDateRespVO { + + @Schema(description = "日期,格式 yyyy-MM-dd", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-29") + private String date; + + @Schema(description = "当日消息数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Long count; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeSummaryRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeSummaryRespVO.java new file mode 100644 index 0000000..3f122cc --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeSummaryRespVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 首页总量统计 Response VO") +@Data +public class Ai1HomeSummaryRespVO { + + @Schema(description = "Agent 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Long agentCount; + + @Schema(description = "SKILL 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long skillCount; + + @Schema(description = "MCP 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Long mcpCount; + + @Schema(description = "模型数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Long modelCount; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeBaseController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeBaseController.java new file mode 100644 index 0000000..5a727f3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeBaseController.java @@ -0,0 +1,155 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeDocumentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ProviderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; + +@Tag(name = "管理后台 - AI1 知识库") +@RestController +@RequestMapping("/ai1/knowledge") +@Validated +public class Ai1KnowledgeBaseController { + + @Resource + private Ai1KnowledgeBaseService knowledgeBaseService; + @Resource + private Ai1KnowledgeDocumentService knowledgeDocumentService; + @Resource + private Ai1ProviderService providerService; + @Resource + private Ai1ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建知识库") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:create')") + public CommonResult createKnowledgeBase(@Valid @RequestBody Ai1KnowledgeBaseSaveReqVO createReqVO) { + return success(knowledgeBaseService.createKnowledgeBase(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新知识库") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:update')") + public CommonResult updateKnowledgeBase(@Valid @RequestBody Ai1KnowledgeBaseSaveReqVO updateReqVO) { + knowledgeBaseService.updateKnowledgeBase(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库", description = "连带删除文档与向量集合") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge:delete')") + public CommonResult deleteKnowledgeBase(@RequestParam("id") Long id) { + knowledgeBaseService.deleteKnowledgeBase(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除知识库", description = "连带删除文档与向量集合") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge:delete')") + public CommonResult deleteKnowledgeBaseList(@RequestParam("ids") List ids) { + knowledgeBaseService.deleteKnowledgeBaseListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得知识库") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:query')") + public CommonResult getKnowledgeBase(@RequestParam("id") Long id) { + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.getKnowledgeBase(id); + return success(buildKnowledgeBaseRespVO(knowledgeBase)); + } + + @GetMapping("/page") + @Operation(summary = "获得知识库分页") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:query')") + public CommonResult> getKnowledgeBasePage(@Valid Ai1KnowledgeBasePageReqVO pageReqVO) { + PageResult pageResult = knowledgeBaseService.getKnowledgeBasePage(pageReqVO); + return success(new PageResult<>(buildKnowledgeBaseRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得知识库精简列表", description = "只包含开启状态,用于 Agent 绑定知识库的下拉选择") + public CommonResult> getKnowledgeBaseSimpleList() { + List list = knowledgeBaseService.getKnowledgeBaseListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, knowledgeBase -> new Ai1KnowledgeBaseRespVO() + .setId(knowledgeBase.getId()).setName(knowledgeBase.getName()))); + } + + @GetMapping("/search") + @Operation(summary = "知识库检索测试") + @Parameter(name = "id", description = "知识库编号", required = true, example = "1024") + @Parameter(name = "query", description = "查询文本", required = true, example = "三体舰队") + @Parameter(name = "topK", description = "检索数量,为空时使用知识库配置", example = "5") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:search')") + public CommonResult> searchKnowledgeBase( + @RequestParam("id") Long id, + @RequestParam("query") @NotEmpty(message = "检索内容不能为空") String query, + @RequestParam(value = "topK", required = false) Integer topK) { + List list = knowledgeBaseService.searchKnowledgeBase(id, query, topK); + return success(buildKnowledgeSearchRespVOList(list)); + } + + // ==================== 拼接 VO ==================== + + private Ai1KnowledgeBaseRespVO buildKnowledgeBaseRespVO(Ai1KnowledgeBaseDO knowledgeBase) { + if (knowledgeBase == null) { + return null; + } + return CollUtil.getFirst(buildKnowledgeBaseRespVOList(Collections.singletonList(knowledgeBase))); + } + + private List buildKnowledgeBaseRespVOList(List list) { + Map providerMap = providerService.getProviderMap( + convertSet(list, Ai1KnowledgeBaseDO::getEmbeddingProviderId)); + Map modelMap = modelService.getModelMap( + convertSet(list, Ai1KnowledgeBaseDO::getEmbeddingModelId)); + return BeanUtils.toBean(list, Ai1KnowledgeBaseRespVO.class, respVO -> { + MapUtils.findAndThen(providerMap, respVO.getEmbeddingProviderId(), + provider -> respVO.setEmbeddingProviderName(provider.getName())); + MapUtils.findAndThen(modelMap, respVO.getEmbeddingModelId(), + model -> respVO.setEmbeddingModelName(model.getName())); + }); + } + + private List buildKnowledgeSearchRespVOList(List list) { + Map documentMap = knowledgeDocumentService.getKnowledgeDocumentMap( + convertSet(list, Ai1KnowledgeSearchRespVO::getDocumentId)); + list.forEach(respVO -> MapUtils.findAndThen(documentMap, respVO.getDocumentId(), + document -> respVO.setDocumentName(document.getName()))); + return list; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeDocumentController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeDocumentController.java new file mode 100644 index 0000000..458594c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeDocumentController.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - AI1 知识文档") +@RestController +@RequestMapping("/ai1/knowledge/document") +@Validated +public class Ai1KnowledgeDocumentController { + + @Resource + private Ai1KnowledgeDocumentService knowledgeDocumentService; + + @PostMapping("/create") + @Operation(summary = "创建文档(粘贴文本)") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:create')") + public CommonResult createKnowledgeDocument(@Valid @RequestBody Ai1KnowledgeDocumentSaveReqVO createReqVO) { + return success(knowledgeDocumentService.createKnowledgeDocument(createReqVO)); + } + + @PostMapping("/upload") + @Operation(summary = "上传文档", description = "仅支持 .txt / .md 文本文件") + @Parameter(name = "knowledgeBaseId", description = "知识库编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:create')") + public CommonResult uploadKnowledgeDocument(@RequestParam("knowledgeBaseId") Long knowledgeBaseId, + @RequestParam("file") MultipartFile file) { + return success(knowledgeDocumentService.uploadKnowledgeDocument(knowledgeBaseId, file)); + } + + @PutMapping("/update") + @Operation(summary = "更新文档", description = "内容变更后需要重新向量化") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:update')") + public CommonResult updateKnowledgeDocument(@Valid @RequestBody Ai1KnowledgeDocumentSaveReqVO updateReqVO) { + knowledgeDocumentService.updateKnowledgeDocument(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除文档") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:delete')") + public CommonResult deleteKnowledgeDocument(@RequestParam("id") Long id) { + knowledgeDocumentService.deleteKnowledgeDocument(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除文档") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:delete')") + public CommonResult deleteKnowledgeDocumentList(@RequestParam("ids") List ids) { + knowledgeDocumentService.deleteKnowledgeDocumentListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文档", description = "包含完整内容") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:query')") + public CommonResult getKnowledgeDocument(@RequestParam("id") Long id) { + Ai1KnowledgeDocumentDO document = knowledgeDocumentService.getKnowledgeDocument(id); + return success(BeanUtils.toBean(document, Ai1KnowledgeDocumentRespVO.class, + respVO -> respVO.setContentLength(StrUtil.length(respVO.getContent())))); + } + + @GetMapping("/page") + @Operation(summary = "获得文档分页", description = "不返回文档内容") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:query')") + public CommonResult> getKnowledgeDocumentPage(@Valid Ai1KnowledgeDocumentPageReqVO pageReqVO) { + PageResult pageResult = knowledgeDocumentService.getKnowledgeDocumentPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1KnowledgeDocumentRespVO.class, + respVO -> respVO.setContentLength(StrUtil.length(respVO.getContent())).setContent(null))); + } + + @PostMapping("/vectorize") + @Operation(summary = "文档向量化", description = "返回分片数量") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:vectorize')") + public CommonResult vectorizeKnowledgeDocument(@RequestParam("id") Long id) { + return success(knowledgeDocumentService.vectorizeKnowledgeDocument(id)); + } + + @PostMapping("/vectorize-all") + @Operation(summary = "知识库批量向量化") + @Parameter(name = "knowledgeBaseId", description = "知识库编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:vectorize')") + public CommonResult vectorizeKnowledgeDocumentAll( + @RequestParam("knowledgeBaseId") Long knowledgeBaseId) { + return success(knowledgeDocumentService.vectorizeKnowledgeDocumentListByKnowledgeBaseId(knowledgeBaseId)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBasePageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBasePageReqVO.java new file mode 100644 index 0000000..0eaa82c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBasePageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 知识库分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1KnowledgeBasePageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "产品手册") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseRespVO.java new file mode 100644 index 0000000..26920c3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 知识库 Response VO") +@Data +public class Ai1KnowledgeBaseRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品手册") + private String name; + + @Schema(description = "描述", example = "产品使用说明") + private String description; + + @Schema(description = "向量化 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long embeddingProviderId; + + @Schema(description = "向量化 Provider 名称", example = "Ollama") + private String embeddingProviderName; + + @Schema(description = "向量化模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long embeddingModelId; + + @Schema(description = "向量化模型名称", example = "bge-m3") + private String embeddingModelName; + + @Schema(description = "分片大小,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "500") + private Integer chunkSize; + + @Schema(description = "分片重叠,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private Integer chunkOverlap; + + @Schema(description = "检索数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer topK; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseSaveReqVO.java new file mode 100644 index 0000000..f4aeb63 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseSaveReqVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 知识库新增/修改 Request VO") +@Data +public class Ai1KnowledgeBaseSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品手册") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "描述", example = "产品使用说明") + @Size(max = 500, message = "描述长度不能超过 500 个字符") + private String description; + + @Schema(description = "向量化 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "向量化 Provider 不能为空") + private Long embeddingProviderId; + + @Schema(description = "向量化模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "向量化模型不能为空") + private Long embeddingModelId; + + @Schema(description = "分片大小,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "500") + @NotNull(message = "分片大小不能为空") + @Min(value = 100, message = "分片大小不能小于 100") + private Integer chunkSize; + + @Schema(description = "分片重叠,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + @NotNull(message = "分片重叠不能为空") + @Min(value = 0, message = "分片重叠不能小于 0") + private Integer chunkOverlap; + + @Schema(description = "检索数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + @NotNull(message = "检索数量不能为空") + @Min(value = 1, message = "检索数量必须大于 0") + private Integer topK; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @AssertTrue(message = "分片重叠必须小于分片大小") + @JsonIgnore + public boolean isChunkOverlapValid() { + return chunkSize == null || chunkOverlap == null || chunkOverlap < chunkSize; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeSearchRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeSearchRespVO.java new file mode 100644 index 0000000..4c1b330 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeSearchRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 知识库检索命中 Response VO") +@Data +public class Ai1KnowledgeSearchRespVO { + + @Schema(description = "分片文本", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队距离地球还有四光年") + private String text; + + @Schema(description = "文档编号", example = "1") + private Long documentId; + + @Schema(description = "文档名称", example = "三体.txt") + private String documentName; + + @Schema(description = "分片序号", example = "0") + private Integer chunkIndex; + + @Schema(description = "相似度得分", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.86") + private Double score; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentPageReqVO.java new file mode 100644 index 0000000..5e723da --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 知识文档分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1KnowledgeDocumentPageReqVO extends PageParam { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeBaseId; + + @Schema(description = "名称,模糊匹配", example = "三体") + private String name; + + @Schema(description = "向量化状态", example = "1") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentRespVO.java new file mode 100644 index 0000000..8eeac29 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 知识文档 Response VO") +@Data +public class Ai1KnowledgeDocumentRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long knowledgeBaseId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体.txt") + private String name; + + @Schema(description = "内容;分页列表不返回,编辑时通过获取接口加载", example = "三体舰队距离地球还有四光年") + private String content; + + @Schema(description = "内容长度,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer contentLength; + + @Schema(description = "分片数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer chunkCount; + + @Schema(description = "向量化状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentSaveReqVO.java new file mode 100644 index 0000000..9d9a73a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentSaveReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 知识文档新增/修改 Request VO") +@Data +public class Ai1KnowledgeDocumentSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "知识库编号;修改时不允许变更", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeBaseId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体.txt") + @NotEmpty(message = "名称不能为空") + @Size(max = 255, message = "名称长度不能超过 255 个字符") + private String name; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队距离地球还有四光年") + @NotEmpty(message = "内容不能为空") + @Size(max = 16_777_215, message = "内容长度不能超过 16777215 个字符") + private String content; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentVectorizeRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentVectorizeRespVO.java new file mode 100644 index 0000000..40515a4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentVectorizeRespVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - AI1 知识库批量向量化 Response VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Ai1KnowledgeDocumentVectorizeRespVO { + + @Schema(description = "成功数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer successCount; + + @Schema(description = "失败数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer failureCount; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/Ai1McpController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/Ai1McpController.java new file mode 100644 index 0000000..b2261c3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/Ai1McpController.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.service.mcp.Ai1McpService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI1 MCP") +@RestController +@RequestMapping("/ai1/mcp") +@Validated +public class Ai1McpController { + + @Resource + private Ai1McpService mcpService; + + @PostMapping("/create") + @Operation(summary = "创建 MCP") + @PreAuthorize("@ss.hasPermission('ai1:mcp:create')") + public CommonResult createMcp(@Valid @RequestBody Ai1McpSaveReqVO createReqVO) { + return success(mcpService.createMcp(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 MCP") + @PreAuthorize("@ss.hasPermission('ai1:mcp:update')") + public CommonResult updateMcp(@Valid @RequestBody Ai1McpSaveReqVO updateReqVO) { + mcpService.updateMcp(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 MCP") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:mcp:delete')") + public CommonResult deleteMcp(@RequestParam("id") Long id) { + mcpService.deleteMcp(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除 MCP") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:mcp:delete')") + public CommonResult deleteMcpList(@RequestParam("ids") List ids) { + mcpService.deleteMcpListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 MCP") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:mcp:query')") + public CommonResult getMcp(@RequestParam("id") Long id) { + Ai1McpDO mcp = mcpService.getMcp(id); + return success(BeanUtils.toBean(mcp, Ai1McpRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得 MCP 分页") + @PreAuthorize("@ss.hasPermission('ai1:mcp:query')") + public CommonResult> getMcpPage(@Valid Ai1McpPageReqVO pageReqVO) { + PageResult pageResult = mcpService.getMcpPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1McpRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得 MCP 精简列表", description = "只包含开启状态,用于 Agent 绑定 MCP 的下拉选择") + public CommonResult> getMcpSimpleList() { + List list = mcpService.getMcpListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, mcp -> new Ai1McpRespVO() + .setId(mcp.getId()).setName(mcp.getName()).setTransport(mcp.getTransport()))); + } + + @PostMapping("/test") + @Operation(summary = "MCP 连通测试", description = "initialize + tools/list") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:mcp:test')") + public CommonResult testMcp(@RequestParam("id") Long id) { + return success(mcpService.testMcpConnect(id)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpConnectRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpConnectRespVO.java new file mode 100644 index 0000000..a9729b4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpConnectRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI1 MCP 连通测试 Response VO") +@Data +public class Ai1McpConnectRespVO { + + @Schema(description = "是否连通", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean connectable; + + @Schema(description = "服务名称", example = "weather-server") + private String serverName; + + @Schema(description = "服务版本", example = "1.0.0") + private String serverVersion; + + @Schema(description = "服务说明", example = "提供城市天气查询") + private String instructions; + + @Schema(description = "可用工具数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer toolCount; + + @Schema(description = "测试耗时,单位:毫秒", requiredMode = Schema.RequiredMode.REQUIRED, example = "320") + private Long elapsedMs; + + @Schema(description = "测试过程描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "连接成功,发现 2 个工具") + private String message; + + @Schema(description = "可用工具明细") + private List tools; + + @Schema(description = "MCP 工具") + @Data + public static class Tool { + + @Schema(description = "工具名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "get_weather") + private String name; + + @Schema(description = "工具标题", example = "天气查询") + private String title; + + @Schema(description = "工具描述", example = "查询指定城市的天气") + private String description; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpPageReqVO.java new file mode 100644 index 0000000..341fe6a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpPageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 MCP 分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1McpPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "天气") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpRespVO.java new file mode 100644 index 0000000..f62a0d6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Map; + +@Schema(description = "管理后台 - AI1 MCP Response VO") +@Data +public class Ai1McpRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "天气查询") + private String name; + + @Schema(description = "传输方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "http") + private String transport; + + @Schema(description = "服务地址", example = "http://127.0.0.1:8081/mcp") + private String url; + + @Schema(description = "请求头", example = "{\"Authorization\":\"Bearer xxx\"}") + private Map headers; + + @Schema(description = "完整 MCP 配置,JSON 对象", example = "{\"transport\":\"http\",\"url\":\"http://127.0.0.1:8081/mcp\"}") + private String config; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "备注", example = "查询城市天气") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpSaveReqVO.java new file mode 100644 index 0000000..091715d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpSaveReqVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.Map; + +@Schema(description = "管理后台 - AI1 MCP 新增/修改 Request VO") +@Data +public class Ai1McpSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "天气查询") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "传输方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "http") + @NotEmpty(message = "传输方式不能为空") + @InEnum(Ai1McpTransportEnum.class) + private String transport; + + @Schema(description = "服务地址,远程必填;为空时取 config 中的 url", example = "http://127.0.0.1:8081/mcp") + @Size(max = 200, message = "服务地址长度不能超过 200 个字符") + private String url; + + @Schema(description = "请求头,远程配置以此字段为准;为空时清空请求头", example = "{\"Authorization\":\"Bearer xxx\"}") + private Map headers; + + @Schema(description = "完整 MCP 配置,JSON 对象;本地必填 command", example = "{\"transport\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"xxx\"]}") + private String config; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "备注", example = "查询城市天气") + @Size(max = 500, message = "备注长度不能超过 500 个字符") + private String remark; + + @AssertTrue(message = "请求头长度不能超过 500 个字符") + @JsonIgnore + public boolean isHeadersValid() { + return MapUtil.isEmpty(headers) || JsonUtils.toJsonString(headers).length() <= 500; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ModelController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ModelController.java new file mode 100644 index 0000000..3f63524 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ModelController.java @@ -0,0 +1,116 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.*; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI1 模型") +@RestController +@RequestMapping("/ai1/model") +@Validated +public class Ai1ModelController { + + @Resource + private Ai1ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建模型") + @PreAuthorize("@ss.hasPermission('ai1:model:create')") + public CommonResult createModel(@Valid @RequestBody Ai1ModelSaveReqVO createReqVO) { + return success(modelService.createModel(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新模型") + @PreAuthorize("@ss.hasPermission('ai1:model:update')") + public CommonResult updateModel(@Valid @RequestBody Ai1ModelSaveReqVO updateReqVO) { + modelService.updateModel(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除模型") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:model:delete')") + public CommonResult deleteModel(@RequestParam("id") Long id) { + modelService.deleteModel(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除模型") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:model:delete')") + public CommonResult deleteModelList(@RequestParam("ids") List ids) { + modelService.deleteModelListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得模型") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:model:query')") + public CommonResult getModel(@RequestParam("id") Long id) { + Ai1ModelDO model = modelService.getModel(id); + return success(BeanUtils.toBean(model, Ai1ModelRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得模型分页") + @PreAuthorize("@ss.hasPermission('ai1:model:query')") + public CommonResult> getModelPage(@Valid Ai1ModelPageReqVO pageReqVO) { + PageResult pageResult = modelService.getModelPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1ModelRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得模型精简列表", description = "只包含开启状态,用于 Agent 模型、知识库向量化模型的下拉选择") + @Parameter(name = "providerId", description = "供应商编号", example = "1") + @Parameter(name = "type", description = "类型", example = "0") + public CommonResult> getModelSimpleList(@RequestParam(value = "providerId", required = false) Long providerId, + @RequestParam(value = "type", required = false) Integer type) { + List list = modelService.getModelListByProviderIdAndTypeAndStatus( + providerId, type, CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, model -> new Ai1ModelRespVO() + .setId(model.getId()).setProviderId(model.getProviderId()) + .setName(model.getName()).setModel(model.getModel()).setType(model.getType()))); + } + + @GetMapping("/remote-list") + @Operation(summary = "拉取供应商远程可用模型", description = "并标注是否已导入") + @Parameter(name = "providerId", description = "供应商编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:model:import')") + public CommonResult> getRemoteModelList(@RequestParam("providerId") Long providerId) { + List remoteModels = modelService.getRemoteModelList(providerId); + List existModelList = modelService.getModelListByProviderId(providerId); + Set existModels = convertSet(existModelList, Ai1ModelDO::getModel); + return success(convertList(remoteModels, model -> new Ai1ModelRemoteRespVO(model, existModels.contains(model)))); + } + + @PostMapping("/import") + @Operation(summary = "批量导入远程模型", description = "返回实际导入数量") + @PreAuthorize("@ss.hasPermission('ai1:model:import')") + public CommonResult importRemoteModelList(@Valid @RequestBody Ai1ModelImportReqVO importReqVO) { + return success(modelService.importRemoteModelList(importReqVO)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ProviderController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ProviderController.java new file mode 100644 index 0000000..5f27f64 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ProviderController.java @@ -0,0 +1,124 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model; + +import cn.hutool.core.util.ReUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ProviderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI1 供应商") +@RestController +@RequestMapping("/ai1/provider") +@Validated +public class Ai1ProviderController { + + @Resource + private Ai1ProviderService providerService; + + @PostMapping("/create") + @Operation(summary = "创建供应商") + @PreAuthorize("@ss.hasPermission('ai1:provider:create')") + public CommonResult createProvider(@Valid @RequestBody Ai1ProviderSaveReqVO createReqVO) { + return success(providerService.createProvider(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新供应商") + @PreAuthorize("@ss.hasPermission('ai1:provider:update')") + public CommonResult updateProvider(@Valid @RequestBody Ai1ProviderSaveReqVO updateReqVO) { + providerService.updateProvider(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除供应商") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:provider:delete')") + public CommonResult deleteProvider(@RequestParam("id") Long id) { + providerService.deleteProvider(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除供应商") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:provider:delete')") + public CommonResult deleteProviderList(@RequestParam("ids") List ids) { + providerService.deleteProviderListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得供应商") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:provider:query')") + public CommonResult getProvider(@RequestParam("id") Long id) { + Ai1ProviderDO provider = providerService.getProvider(id); + return success(buildProviderRespVO(provider)); + } + + @GetMapping("/page") + @Operation(summary = "获得供应商分页") + @PreAuthorize("@ss.hasPermission('ai1:provider:query')") + public CommonResult> getProviderPage(@Valid Ai1ProviderPageReqVO pageReqVO) { + PageResult pageResult = providerService.getProviderPage(pageReqVO); + return success(new PageResult<>(convertList(pageResult.getList(), this::buildProviderRespVO), pageResult.getTotal())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得供应商精简列表", description = "只包含开启状态,用于 Agent 模型、知识库向量化模型的下拉选择") + public CommonResult> getProviderSimpleList() { + List list = providerService.getProviderListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, provider -> new Ai1ProviderRespVO().setId(provider.getId()).setName(provider.getName()))); + } + + @PostMapping("/test") + @Operation(summary = "供应商连通测试", description = "GET /models 优先,失败时回退 POST /chat/completions") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:provider:test')") + public CommonResult testProvider(@RequestParam("id") Long id) { + return success(providerService.testProviderConnect(id)); + } + + // ==================== 拼接 VO ==================== + + /** + * 构建供应商响应:API 密钥脱敏返回,避免查询权限直接获取完整密钥 + */ + private Ai1ProviderRespVO buildProviderRespVO(Ai1ProviderDO provider) { + if (provider == null) { + return null; + } + Ai1ProviderRespVO respVO = BeanUtils.toBean(provider, Ai1ProviderRespVO.class); + String apiKey = provider.getApiKey(); + if (ReUtil.isMatch("^\\$\\{[a-zA-Z_][a-zA-Z0-9_.-]*\\}$", apiKey)) { + // 【安全性】仅完整的变量占位符原样返回;带默认值或拼接内容的密钥仍需脱敏 + respVO.setApiKey(apiKey); + } else if (StrUtil.isNotEmpty(apiKey)) { + respVO.setApiKey(apiKey.length() <= 8 ? "****" + : StrUtil.hide(apiKey, 3, apiKey.length() - 4)); + } + return respVO; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelImportReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelImportReqVO.java new file mode 100644 index 0000000..8074f66 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelImportReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI1 远程模型导入 Request VO") +@Data +public class Ai1ModelImportReqVO { + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "供应商编号不能为空") + private Long providerId; + + @Schema(description = "模型标识列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"deepseek-chat\"]") + @NotEmpty(message = "请选择要导入的模型") + private List models; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelPageReqVO.java new file mode 100644 index 0000000..e531da4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 模型分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1ModelPageReqVO extends PageParam { + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "供应商编号不能为空") + private Long providerId; + + @Schema(description = "展示名称,模糊匹配", example = "DeepSeek") + private String name; + + @Schema(description = "类型", example = "0") + private Integer type; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRemoteRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRemoteRespVO.java new file mode 100644 index 0000000..5e234ee --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRemoteRespVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - AI1 远程模型 Response VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Ai1ModelRemoteRespVO { + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "deepseek-chat") + private String model; + + @Schema(description = "是否已导入当前供应商", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean imported; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRespVO.java new file mode 100644 index 0000000..cb6a419 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 模型 Response VO") +@Data +public class Ai1ModelRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long providerId; + + @Schema(description = "展示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek Chat") + private String name; + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "deepseek-chat") + private String model; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer type; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelSaveReqVO.java new file mode 100644 index 0000000..3b64568 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelSaveReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 模型新增/修改 Request VO") +@Data +public class Ai1ModelSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "供应商编号不能为空") + private Long providerId; + + @Schema(description = "展示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek Chat") + @NotEmpty(message = "展示名称不能为空") + @Size(max = 50, message = "展示名称长度不能超过 50 个字符") + private String name; + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "deepseek-chat") + @NotEmpty(message = "模型标识不能为空") + @Size(max = 100, message = "模型标识长度不能超过 100 个字符") + private String model; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "类型不能为空") + @InEnum(Ai1ModelTypeEnum.class) + private Integer type; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderConnectRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderConnectRespVO.java new file mode 100644 index 0000000..9d22d21 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderConnectRespVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 供应商连通测试 Response VO") +@Data +public class Ai1ProviderConnectRespVO { + + @Schema(description = "是否连通", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean connectable; + + @Schema(description = "最近一次请求的 HTTP 状态码,0 表示网络异常未收到响应", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer httpCode; + + @Schema(description = "测试耗时,单位:毫秒", requiredMode = Schema.RequiredMode.REQUIRED, example = "320") + private Long elapsedMs; + + @Schema(description = "测试过程描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "连通正常:GET /models 返回 HTTP 200") + private String message; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderPageReqVO.java new file mode 100644 index 0000000..2ad8ac1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderPageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 供应商分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1ProviderPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "DeepSeek") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java new file mode 100644 index 0000000..42aa913 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 供应商 Response VO") +@Data +public class Ai1ProviderRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek") + private String name; + + @Schema(description = "接口地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://api.deepseek.com/v1") + private String baseUrl; + + @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") + private String apiKey; + + @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]") + private String headers; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "备注", example = "官方接口") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java new file mode 100644 index 0000000..b07bdbc --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.util.Ai1Utils; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") +@Data +public class Ai1ProviderSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek") + @NotEmpty(message = "名称不能为空") + @Size(max = 50, message = "名称长度不能超过 50 个字符") + private String name; + + @Schema(description = "接口地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://api.deepseek.com/v1") + @NotEmpty(message = "接口地址不能为空") + @Size(max = 200, message = "接口地址长度不能超过 200 个字符") + private String baseUrl; + + @Schema(description = "API 密钥;修改时为空表示保持原密钥不变", example = "sk-xxx") + @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") + private String apiKey; + + @Schema(description = "请求附属 Header,JSON 数组", example = "[{\"key\":\"X-Session\",\"value\":\"{session}\"}]") + @Size(max = 2000, message = "请求附属 Header 长度不能超过 2000 个字符") + private String headers; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "备注", example = "官方接口") + @Size(max = 255, message = "备注长度不能超过 255 个字符") + private String remark; + + @AssertTrue(message = "请求附属 Header 格式不正确,应为 JSON 数组:[{\"key\":\"...\",\"value\":\"...\"}]") + @JsonIgnore + public boolean isHeadersValid() { + return StrUtil.isBlank(headers) || Ai1Utils.parseHeaders(headers) != null; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java new file mode 100644 index 0000000..1de68df --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI1 会话") +@RestController +@RequestMapping("/ai1/session") +@Validated +public class Ai1SessionController { + + @Resource + private Ai1SessionService sessionService; + + @Resource + private AdminUserApi adminUserApi; + + // ==================== 我的 ==================== + + @PostMapping("/create-my") + @Operation(summary = "创建【我的】会话") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult createSessionMy(@Valid @RequestBody Ai1SessionCreateMyReqVO createReqVO) { + return success(sessionService.createSessionMy(getLoginUserId(), createReqVO)); + } + + @PutMapping("/update-my") + @Operation(summary = "修改【我的】会话标题") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult updateSessionMy(@Valid @RequestBody Ai1SessionUpdateMyReqVO updateReqVO) { + sessionService.updateSessionMy(getLoginUserId(), updateReqVO); + return success(true); + } + + @DeleteMapping("/delete-my") + @Operation(summary = "删除【我的】会话", description = "连带删除消息") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult deleteSessionMy(@RequestParam("id") Long id) { + sessionService.deleteSessionMy(getLoginUserId(), id); + return success(true); + } + + @GetMapping("/my-list") + @Operation(summary = "获得【我的】会话列表", description = "指定 Agent 下,按创建时间倒序") + @Parameter(name = "agentId", description = "Agent 编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult> getSessionMyList(@RequestParam("agentId") Long agentId) { + List list = sessionService.getSessionListByAgentIdAndUserId(agentId, getLoginUserId()); + return success(BeanUtils.toBean(list, Ai1SessionRespVO.class)); + } + + // ==================== 管理 ==================== + + @GetMapping("/page") + @Operation(summary = "获得会话分页", description = "管理端查看 Agent 下全部用户的会话") + @PreAuthorize("@ss.hasPermission('ai1:session:query')") + public CommonResult> getSessionPage(@Valid Ai1SessionPageReqVO pageReqVO) { + PageResult pageResult = sessionService.getSessionPage(pageReqVO); + Map userMap = adminUserApi.getUserMap(convertSet(pageResult.getList(), Ai1SessionDO::getUserId)); + return success(BeanUtils.toBean(pageResult, Ai1SessionRespVO.class, respVO -> + MapUtils.findAndThen(userMap, respVO.getUserId(), user -> respVO.setUserNickname(user.getNickname())))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java new file mode 100644 index 0000000..111727b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI1 会话消息") +@RestController +@RequestMapping("/ai1/session/message") +@Validated +public class Ai1SessionMessageController { + + @Resource + private Ai1SessionMessageService sessionMessageService; + @Resource + private Ai1SessionService sessionService; + + // ==================== 我的 ==================== + + @GetMapping("/my-list") + @Operation(summary = "获得【我的】会话消息列表") + @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult> getSessionMessageMyList(@RequestParam("sessionId") Long sessionId) { + sessionService.validateSessionMy(getLoginUserId(), sessionId); + return success(BeanUtils.toBean(sessionMessageService.getSessionMessageListBySessionId(sessionId), + Ai1SessionMessageRespVO.class)); + } + + @PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "发送消息(流式)", description = "SSE 事件:stream(助手消息编号)、thinking、message、ping、done、error;" + + "事件 data 均为 JSON 字符串,事件 id 为结果流条目编号,可用于续传") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public SseEmitter sendSessionMessageStream(@Valid @RequestBody Ai1SessionMessageSendReqVO sendReqVO) { + return sessionMessageService.sendSessionMessageStream(getLoginUserId(), sendReqVO); + } + + @PostMapping(value = "/resume-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "断线续传(流式)", description = "从结果流 lastEventId 之后继续转发,不重新生成;事件格式同发送消息") + @Parameter(name = "messageId", description = "助手消息编号", required = true, example = "1024") + @Parameter(name = "lastEventId", description = "已收到的最后一个事件编号,为空时从头重放", example = "1727541234567-0") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public SseEmitter resumeSessionMessageStream(@RequestParam("messageId") Long messageId, + @RequestParam(value = "lastEventId", required = false) String lastEventId) { + return sessionMessageService.resumeSessionMessageStream(getLoginUserId(), messageId, lastEventId); + } + + // ==================== 管理 ==================== + + @GetMapping("/list") + @Operation(summary = "获得会话消息列表", description = "管理端查看任意用户的会话明细") + @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:session:query')") + public CommonResult> getSessionMessageList(@RequestParam("sessionId") Long sessionId) { + return success(BeanUtils.toBean(sessionMessageService.getSessionMessageListBySessionId(sessionId), + Ai1SessionMessageRespVO.class)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageRespVO.java new file mode 100644 index 0000000..3772da2 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 会话消息 Response VO") +@Data +public class Ai1SessionMessageRespVO { + + @Schema(description = "编号;助手消息的编号同时作为续传的结果流标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long sessionId; + + @Schema(description = "角色", requiredMode = Schema.RequiredMode.REQUIRED, example = "assistant") + private String role; + + @Schema(description = "思考过程", example = "用户想了解……") + private String reasoning; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "四光年") + private String content; + + @Schema(description = "生成状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageSendReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageSendReqVO.java new file mode 100644 index 0000000..efc7d1a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageSendReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 会话消息发送 Request VO") +@Data +public class Ai1SessionMessageSendReqVO { + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "会话编号不能为空") + private Long sessionId; + + @Schema(description = "提问内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队还有多久到达地球?") + @NotBlank(message = "请输入内容") + private String content; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionCreateMyReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionCreateMyReqVO.java new file mode 100644 index 0000000..805d322 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionCreateMyReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 我的会话创建 Request VO") +@Data +public class Ai1SessionCreateMyReqVO { + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "Agent 编号不能为空") + private Long agentId; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java new file mode 100644 index 0000000..797b41d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 会话分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1SessionPageReqVO extends PageParam { + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "Agent 编号不能为空") + private Long agentId; + + @Schema(description = "标题,模糊匹配", example = "三体") + private String title; + + @Schema(description = "用户编号", example = "1") + private Long userId; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java new file mode 100644 index 0000000..139bcfe --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 会话 Response VO") +@Data +public class Ai1SessionRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long agentId; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道源码") + private String userNickname; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队的航速") + private String title; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间,即最近活跃时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionUpdateMyReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionUpdateMyReqVO.java new file mode 100644 index 0000000..b0749d8 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionUpdateMyReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 我的会话修改 Request VO") +@Data +public class Ai1SessionUpdateMyReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队的航速") + @NotBlank(message = "标题不能为空") + @Size(max = 50, message = "标题长度不能超过 50 个字符") + private String title; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillController.java new file mode 100644 index 0000000..bb34d57 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillController.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI1 SKILL") +@RestController +@RequestMapping("/ai1/skill") +@Validated +public class Ai1SkillController { + + @Resource + private Ai1SkillService skillService; + + @PostMapping("/create") + @Operation(summary = "创建 SKILL", description = "自动播种固定文件 SKILL.md、scripts/、reference/") + @PreAuthorize("@ss.hasPermission('ai1:skill:create')") + public CommonResult createSkill(@Valid @RequestBody Ai1SkillSaveReqVO createReqVO) { + return success(skillService.createSkill(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 SKILL") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult updateSkill(@Valid @RequestBody Ai1SkillSaveReqVO updateReqVO) { + skillService.updateSkill(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 SKILL", description = "级联删除内容文件") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:skill:delete')") + public CommonResult deleteSkill(@RequestParam("id") Long id) { + skillService.deleteSkill(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除 SKILL", description = "级联删除内容文件") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:skill:delete')") + public CommonResult deleteSkillList(@RequestParam("ids") List ids) { + skillService.deleteSkillListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 SKILL") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult getSkill(@RequestParam("id") Long id) { + Ai1SkillDO skill = skillService.getSkill(id); + return success(BeanUtils.toBean(skill, Ai1SkillRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得 SKILL 分页") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult> getSkillPage(@Valid Ai1SkillPageReqVO pageReqVO) { + PageResult pageResult = skillService.getSkillPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1SkillRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得 SKILL 精简列表", description = "只包含开启状态,用于 Agent 绑定 SKILL 的下拉选择") + public CommonResult> getSkillSimpleList() { + List list = skillService.getSkillListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, skill -> new Ai1SkillRespVO() + .setId(skill.getId()).setName(skill.getName()).setDescription(skill.getDescription()))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillFileController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillFileController.java new file mode 100644 index 0000000..ab328ca --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillFileController.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.*; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillFileService; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - AI1 SKILL 内容文件") +@RestController +@RequestMapping("/ai1/skill/file") +@Validated +public class Ai1SkillFileController { + + @Resource + private Ai1SkillFileService skillFileService; + @Resource + private Ai1SkillService skillService; + + @GetMapping("/list") + @Operation(summary = "获得 SKILL 文件列表", description = "平铺返回,不返回文件内容,编辑时通过获取接口加载") + @Parameter(name = "skillId", description = "SKILL 编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult> getSkillFileList(@RequestParam("skillId") Long skillId) { + skillService.validateSkillExists(skillId); + List list = skillFileService.getSkillFileListBySkillId(skillId); + return success(BeanUtils.toBean(list, Ai1SkillFileRespVO.class, skillFile -> skillFile.setContent(null))); + } + + @GetMapping("/get") + @Operation(summary = "获得 SKILL 文件", description = "包含文件内容") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult getSkillFile(@RequestParam("id") Long id) { + Ai1SkillFileDO skillFile = skillFileService.getSkillFile(id); + return success(BeanUtils.toBean(skillFile, Ai1SkillFileRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "创建 SKILL 目录或文件") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult createSkillFile(@Valid @RequestBody Ai1SkillFileCreateReqVO createReqVO) { + return success(skillFileService.createSkillFile(createReqVO)); + } + + @PutMapping("/rename") + @Operation(summary = "重命名 SKILL 目录或文件") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult renameSkillFile(@Valid @RequestBody Ai1SkillFileRenameReqVO renameReqVO) { + skillFileService.renameSkillFile(renameReqVO); + return success(true); + } + + @PutMapping("/move") + @Operation(summary = "移动 SKILL 目录或文件") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult moveSkillFile(@Valid @RequestBody Ai1SkillFileMoveReqVO moveReqVO) { + skillFileService.moveSkillFile(moveReqVO); + return success(true); + } + + @PutMapping("/update-content") + @Operation(summary = "保存 SKILL 文件内容") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult updateSkillFileContent(@Valid @RequestBody Ai1SkillFileContentReqVO contentReqVO) { + skillFileService.updateSkillFileContent(contentReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 SKILL 目录或文件", description = "目录递归删除后代") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:skill:delete')") + public CommonResult deleteSkillFile(@RequestParam("id") Long id) { + skillFileService.deleteSkillFile(id); + return success(true); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileContentReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileContentReqVO.java new file mode 100644 index 0000000..5a66f67 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileContentReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件保存内容 Request VO") +@Data +public class Ai1SkillFileContentReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "文件内容,允许为空字符串", requiredMode = Schema.RequiredMode.REQUIRED, example = "# SKILL") + @NotNull(message = "文件内容不能为空") + @Size(max = 16_777_215, message = "内容长度不能超过 16777215 个字符") + private String content; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileCreateReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileCreateReqVO.java new file mode 100644 index 0000000..82fb016 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileCreateReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件新增 Request VO") +@Data +public class Ai1SkillFileCreateReqVO { + + @Schema(description = "SKILL 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "SKILL 编号不能为空") + private Long skillId; + + @Schema(description = "父目录编号,0 为根级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "父目录编号不能为空") + private Long parentId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "main.py") + @NotEmpty(message = "名称不能为空") + private String name; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "类型不能为空") + @InEnum(Ai1SkillFileTypeEnum.class) + private Integer type; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileMoveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileMoveReqVO.java new file mode 100644 index 0000000..ba538ea --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileMoveReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件移动 Request VO") +@Data +public class Ai1SkillFileMoveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "目标父目录编号,0 为根级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "目标父目录编号不能为空") + private Long parentId; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRenameReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRenameReqVO.java new file mode 100644 index 0000000..9dfff01 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRenameReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件重命名 Request VO") +@Data +public class Ai1SkillFileRenameReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "新名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "main.py") + @NotEmpty(message = "名称不能为空") + private String name; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRespVO.java new file mode 100644 index 0000000..2bf91e7 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件 Response VO") +@Data +public class Ai1SkillFileRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "SKILL 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long skillId; + + @Schema(description = "父目录编号,0 为根级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Long parentId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "SKILL.md") + private String name; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "文件类型,即扩展名;目录为空", example = "md") + private String fileType; + + @Schema(description = "文件内容;文件树中不返回,编辑时通过获取接口加载", example = "# SKILL") + private String content; + + @Schema(description = "是否固定:固定节点不可删除、改名、移动", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean locked; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillPageReqVO.java new file mode 100644 index 0000000..aa72da0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillPageReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 SKILL 分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1SkillPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "pdf") + private String name; + + @Schema(description = "状态", example = "0") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillRespVO.java new file mode 100644 index 0000000..23d7d27 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 SKILL Response VO") +@Data +public class Ai1SkillRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "pdf-reader") + private String name; + + @Schema(description = "描述", example = "读取并总结 PDF 文件") + private String description; + + @Schema(description = "版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.0") + private String version; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillSaveReqVO.java new file mode 100644 index 0000000..cf02232 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillSaveReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 新增/修改 Request VO") +@Data +public class Ai1SkillSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称,同时作为物化目录名,仅支持字母、数字、中划线", requiredMode = Schema.RequiredMode.REQUIRED, example = "pdf-reader") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + @Pattern(regexp = "^[A-Za-z0-9-]+$", message = "名称仅支持字母、数字、中划线") + private String name; + + @Schema(description = "描述", example = "读取并总结 PDF 文件") + @Size(max = 500, message = "描述长度不能超过 500 个字符") + private String description; + + @Schema(description = "版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.0") + @NotEmpty(message = "版本不能为空") + @Size(max = 20, message = "版本长度不能超过 20 个字符") + private String version; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/package-info.java new file mode 100644 index 0000000..a693263 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/package-info.java @@ -0,0 +1,5 @@ +/** + * 提供 RESTful API 给前端: + * 1. admin 包:提供给管理后台 yudao-ui-admin 前端项目 + */ +package cn.iocoder.yudao.module.ai1.controller; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java new file mode 100644 index 0000000..822c481 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.agent; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.util.List; + +/** + * AI1 Agent DO + * + * @author 芋道源码 + */ +@TableName(value = "ai1_agent", autoResultMap = true) +@KeySequence("ai1_agent_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1AgentDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 介绍 + */ + private String introduction; + /** + * 模型所属 Provider 编号 + * + * 关联 {@link Ai1ProviderDO#getId()} + */ + private Long providerId; + /** + * 对话模型编号 + * + * 关联 {@link Ai1ModelDO#getId()} + */ + private Long modelId; + /** + * 系统指令 + */ + private String systemPrompt; + /** + * 知识库编号集合 + * + * 关联 {@link Ai1KnowledgeBaseDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List knowledgeBaseIds; + /** + * MCP 编号集合 + * + * 关联 {@link Ai1McpDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List mcpIds; + /** + * SKILL 编号集合 + * + * 关联 {@link Ai1SkillDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List skillIds; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum};开启后才可在「Agent 会话」中使用 + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeBaseDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeBaseDO.java new file mode 100644 index 0000000..d20f97d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeBaseDO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 知识库 DO + * + * 向量存储在 Milvus 集合 {collectionPrefix}{id}(默认 knowledge_base_{id})中,按知识库隔离 + * + * @author 芋道源码 + */ +@TableName("ai1_knowledge_base") +@KeySequence("ai1_knowledge_base_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1KnowledgeBaseDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 描述 + */ + private String description; + /** + * 向量化 Provider 编号 + * + * 关联 {@link Ai1ProviderDO#getId()} + */ + private Long embeddingProviderId; + /** + * 向量化模型编号 + * + * 关联 {@link Ai1ModelDO#getId()} + */ + private Long embeddingModelId; + /** + * 分片大小,单位:字符 + */ + private Integer chunkSize; + /** + * 分片重叠,单位:字符 + */ + private Integer chunkOverlap; + /** + * 检索数量 + */ + private Integer topK; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeDocumentDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeDocumentDO.java new file mode 100644 index 0000000..3c91505 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeDocumentDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.knowledge.Ai1KnowledgeDocumentStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 知识文档 DO + * + * @author 芋道源码 + */ +@TableName("ai1_knowledge_document") +@KeySequence("ai1_knowledge_document_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1KnowledgeDocumentDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link Ai1KnowledgeBaseDO#getId()} + */ + private Long knowledgeBaseId; + /** + * 名称 + */ + private String name; + /** + * 内容 + */ + private String content; + /** + * 分片数量 + */ + private Integer chunkCount; + /** + * 向量化状态 + * + * 枚举 {@link Ai1KnowledgeDocumentStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/mcp/Ai1McpDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/mcp/Ai1McpDO.java new file mode 100644 index 0000000..215ddf4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/mcp/Ai1McpDO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.*; + +import java.util.Map; + +/** + * AI1 MCP 服务 DO + * + * @author 芋道源码 + */ +@TableName(value = "ai1_mcp", autoResultMap = true) +@KeySequence("ai1_mcp_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1McpDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 传输方式 + * + * 枚举 {@link Ai1McpTransportEnum};以该列为准,保存时同步写回 {@link #config} 的 transport 字段 + */ + private String transport; + /** + * 服务地址,远程必填、本地为空 + * + * 切换为本地时需要清空,更新时由 Ai1McpMapper#updateForSave 主动置空 + */ + private String url; + /** + * 请求头,例如说 {"Authorization":"Bearer xxx"} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map headers; + /** + * 完整 MCP 配置,JSON 对象 + * + * 远程:{transport, url, headers};本地:{transport, command, args, env, cwd} + */ + private String config; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ModelDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ModelDO.java new file mode 100644 index 0000000..171144b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ModelDO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 模型 DO + * + * @author 芋道源码 + */ +@TableName("ai1_model") +@KeySequence("ai1_model_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1ModelDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 供应商编号 + * + * 关联 {@link Ai1ProviderDO#getId()} + */ + private Long providerId; + /** + * 展示名称 + */ + private String name; + /** + * 模型标识,例如说 gpt-4o、deepseek-chat + */ + private String model; + /** + * 类型 + * + * 枚举 {@link Ai1ModelTypeEnum} + */ + private Integer type; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java new file mode 100644 index 0000000..fa8bec1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 供应商 DO + * + * 供应商即模型提供方(OpenAI 兼容端点),维护接口地址与密钥 + * + * @author 芋道源码 + */ +@TableName("ai1_provider") +@KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1ProviderDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 接口地址 + */ + private String baseUrl; + /** + * API 密钥 + */ + private String apiKey; + /** + * 请求附属 Header + * + * JSON 数组,格式为 [{"key":"...","value":"..."}];value 可包含 {session} 占位符,请求时替换为会话编号 + */ + private String headers; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionDO.java new file mode 100644 index 0000000..1e2fc4c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionDO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.session; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 会话 DO + * + * 管理后台登录用户与 Agent 的一次会话 + * + * @author 芋道源码 + */ +@TableName("ai1_session") +@KeySequence("ai1_session_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SessionDO extends TenantBaseDO { + + /** + * 默认标题:首条消息发送后,自动替换为提问内容 + */ + public static final String TITLE_DEFAULT = "新会话"; + /** + * 自动生成的会话标题最大长度:首条提问内容超出时截断 + */ + public static final int TITLE_MAX_LENGTH = 50; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * Agent 编号 + * + * 关联 {@link Ai1AgentDO#getId()} + */ + private Long agentId; + /** + * 用户编号 + * + * 关联 AdminUserDO 的 id 编号 + */ + private Long userId; + /** + * 标题 + */ + private String title; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionMessageDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionMessageDO.java new file mode 100644 index 0000000..3704d7f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionMessageDO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.session; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 会话消息 DO + * + * 助手消息的编号同时作为 Redis 结果流标识,用于断线续传 + * + * @author 芋道源码 + */ +@TableName("ai1_session_message") +@KeySequence("ai1_session_message_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SessionMessageDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 会话编号 + * + * 关联 {@link Ai1SessionDO#getId()} + */ + private Long sessionId; + /** + * 角色 + * + * 枚举 {@link Ai1SessionMessageRoleEnum} + */ + private String role; + /** + * 思考过程,即推理模型的 reasoning_content + */ + private String reasoning; + /** + * 内容 + */ + private String content; + /** + * 生成状态 + * + * 枚举 {@link Ai1SessionMessageStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillDO.java new file mode 100644 index 0000000..26b4073 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillDO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 SKILL DO + * + * @author 芋道源码 + */ +@TableName("ai1_skill") +@KeySequence("ai1_skill_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SkillDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + * + * 同时作为物化目录名,租户内唯一,仅支持字母、数字、中划线 + */ + private String name; + /** + * 描述 + */ + private String description; + /** + * 版本 + */ + private String version; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillFileDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillFileDO.java new file mode 100644 index 0000000..22b51fe --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillFileDO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.skill; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 SKILL 内容文件 DO + * + * 以 parentId 组织为文件树,物化时按树结构写入本地技能目录 + * + * @author 芋道源码 + */ +@TableName("ai1_skill_file") +@KeySequence("ai1_skill_file_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SkillFileDO extends TenantBaseDO { + + /** + * 父目录编号:根级 + */ + public static final Long PARENT_ID_ROOT = 0L; + + /** + * 固定文件名:SKILL 入口文件 + */ + public static final String NAME_SKILL = "SKILL.md"; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * SKILL 编号 + * + * 关联 {@link Ai1SkillDO#getId()} + */ + private Long skillId; + /** + * 父目录编号 + * + * 关联 {@link Ai1SkillFileDO#getId()};根级为 {@link #PARENT_ID_ROOT} + */ + private Long parentId; + /** + * 文件或目录名称 + */ + private String name; + /** + * 类型 + * + * 枚举 {@link Ai1SkillFileTypeEnum} + */ + private Integer type; + /** + * 文件类型,即扩展名;目录为空 + */ + private String fileType; + /** + * 文件内容;目录为空 + */ + private String content; + /** + * 是否固定 + * + * 固定节点(SKILL.md、scripts/、reference/)不可删除、改名、移动 + */ + private Boolean locked; + /** + * 排序 + */ + private Integer sort; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java new file mode 100644 index 0000000..8a6402d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.agent; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 Agent Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1AgentMapper extends BaseMapperX { + + default PageResult selectPage(Ai1AgentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1AgentDO::getName, reqVO.getName()) + .eqIfPresent(Ai1AgentDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1AgentDO::getId)); + } + + default List selectListByStatusOrderById(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1AgentDO::getStatus, status) + .orderByAsc(Ai1AgentDO::getId)); + } + + default Long selectCountByModelIds(Collection modelIds) { + return selectCount(new LambdaQueryWrapperX().in(Ai1AgentDO::getModelId, modelIds)); + } + + default Long selectCountByKnowledgeBaseIds(Collection knowledgeBaseIds) { + return selectCount(new LambdaQueryWrapperX() + .apply("(" + MyBatisUtils.findInSet("knowledge_base_ids", knowledgeBaseIds) + ")", knowledgeBaseIds.toArray())); + } + + default Long selectCountBySkillIds(Collection skillIds) { + return selectCount(new LambdaQueryWrapperX() + .apply("(" + MyBatisUtils.findInSet("skill_ids", skillIds) + ")", skillIds.toArray())); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java new file mode 100644 index 0000000..e4a7d16 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.home; + +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI1 首页统计 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1HomeMapper { + + @Select("SELECT (SELECT COUNT(1) FROM ai1_agent WHERE deleted = FALSE) AS agentCount, " + + "(SELECT COUNT(1) FROM ai1_skill WHERE deleted = FALSE) AS skillCount, " + + "(SELECT COUNT(1) FROM ai1_mcp WHERE deleted = FALSE) AS mcpCount, " + + "(SELECT COUNT(1) FROM ai1_model WHERE deleted = FALSE) AS modelCount") + Ai1HomeSummaryRespVO selectSummary(); + + /** + * 按天统计消息数量,仅返回有消息的日期;使用 CAST AS DATE 兼容 MySQL 与 H2 + */ + @Select("SELECT CAST(create_time AS DATE) AS `date`, COUNT(1) AS `count` " + + "FROM ai1_session_message " + + "WHERE create_time BETWEEN #{beginTime} AND #{endTime} AND deleted = FALSE " + + "GROUP BY CAST(create_time AS DATE) " + + "ORDER BY `date`") + List selectMessageSummaryListByCreateTimeBetweenGroupByDate( + @Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); + + /** + * 按 Agent 统计消息数量,按数量倒序,数量相同按 Agent 编号升序;已删除会话下的消息不计入 + */ + @Select("SELECT c.agent_id AS agentId, COUNT(1) AS `count` " + + "FROM ai1_session_message m " + + "INNER JOIN ai1_session c ON m.session_id = c.id AND c.deleted = FALSE " + + "WHERE m.create_time BETWEEN #{beginTime} AND #{endTime} AND m.deleted = FALSE " + + "GROUP BY c.agent_id " + + "ORDER BY `count` DESC, c.agent_id") + List selectMessageSummaryListByCreateTimeBetweenGroupByAgentId( + @Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeBaseMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeBaseMapper.java new file mode 100644 index 0000000..d7fe0e0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeBaseMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 知识库 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1KnowledgeBaseMapper extends BaseMapperX { + + default PageResult selectPage(Ai1KnowledgeBasePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1KnowledgeBaseDO::getName, reqVO.getName()) + .eqIfPresent(Ai1KnowledgeBaseDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1KnowledgeBaseDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1KnowledgeBaseDO::getStatus, status) + .orderByAsc(Ai1KnowledgeBaseDO::getId)); + } + + default Long selectCountByEmbeddingModelIds(Collection modelIds) { + return selectCount(new LambdaQueryWrapperX() + .in(Ai1KnowledgeBaseDO::getEmbeddingModelId, modelIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeDocumentMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeDocumentMapper.java new file mode 100644 index 0000000..8bad4d8 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeDocumentMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 知识文档 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1KnowledgeDocumentMapper extends BaseMapperX { + + default PageResult selectPage(Ai1KnowledgeDocumentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(Ai1KnowledgeDocumentDO::getKnowledgeBaseId, reqVO.getKnowledgeBaseId()) + .likeIfPresent(Ai1KnowledgeDocumentDO::getName, reqVO.getName()) + .eqIfPresent(Ai1KnowledgeDocumentDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1KnowledgeDocumentDO::getId)); + } + + default List selectListByKnowledgeBaseId(Long knowledgeBaseId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1KnowledgeDocumentDO::getKnowledgeBaseId, knowledgeBaseId) + .orderByAsc(Ai1KnowledgeDocumentDO::getId)); + } + + default int deleteByKnowledgeBaseIds(Collection knowledgeBaseIds) { + return delete(new LambdaQueryWrapperX() + .in(Ai1KnowledgeDocumentDO::getKnowledgeBaseId, knowledgeBaseIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/mcp/Ai1McpMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/mcp/Ai1McpMapper.java new file mode 100644 index 0000000..5e7c7af --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/mcp/Ai1McpMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.mcp; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI1 MCP 服务 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1McpMapper extends BaseMapperX { + + default PageResult selectPage(Ai1McpPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1McpDO::getName, reqVO.getName()) + .eqIfPresent(Ai1McpDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1McpDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1McpDO::getStatus, status) + .orderByAsc(Ai1McpDO::getId)); + } + + default void updateForSave(Ai1McpDO mcp) { + update(mcp, new LambdaUpdateWrapper().eq(Ai1McpDO::getId, mcp.getId()) + .set(mcp.getUrl() == null, Ai1McpDO::getUrl, null)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ModelMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ModelMapper.java new file mode 100644 index 0000000..1f5ee1b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ModelMapper.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 模型 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1ModelMapper extends BaseMapperX { + + default PageResult selectPage(Ai1ModelPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(Ai1ModelDO::getProviderId, reqVO.getProviderId()) + .likeIfPresent(Ai1ModelDO::getName, reqVO.getName()) + .eqIfPresent(Ai1ModelDO::getType, reqVO.getType()) + .orderByAsc(Ai1ModelDO::getId)); + } + + default Ai1ModelDO selectByProviderIdAndModel(Long providerId, String model) { + return selectOne(Ai1ModelDO::getProviderId, providerId, Ai1ModelDO::getModel, model); + } + + default List selectListByProviderId(Long providerId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1ModelDO::getProviderId, providerId) + .orderByAsc(Ai1ModelDO::getId)); + } + + default List selectListByProviderIdAndTypeAndStatus(Long providerId, Integer type, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(Ai1ModelDO::getProviderId, providerId) + .eqIfPresent(Ai1ModelDO::getType, type) + .eq(Ai1ModelDO::getStatus, status) + .orderByAsc(Ai1ModelDO::getId)); + } + + default Long selectCountByProviderIds(Collection providerIds) { + return selectCount(new LambdaQueryWrapperX().in(Ai1ModelDO::getProviderId, providerIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ProviderMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ProviderMapper.java new file mode 100644 index 0000000..bc6e892 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ProviderMapper.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI1 供应商 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1ProviderMapper extends BaseMapperX { + + default PageResult selectPage(Ai1ProviderPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1ProviderDO::getName, reqVO.getName()) + .eqIfPresent(Ai1ProviderDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1ProviderDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1ProviderDO::getStatus, status) + .orderByAsc(Ai1ProviderDO::getId)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/package-info.java new file mode 100644 index 0000000..9e8b5d0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/package-info.java @@ -0,0 +1,9 @@ +/** + * DAL = Data Access Layer 数据访问层 + * 1. data object:数据对象 + * 2. redis:Redis 的 CRUD 操作 + * 3. mysql:MySQL 的 CRUD 操作 + * + * 其中,MySQL 的表以 ai1_ 作为前缀 + */ +package cn.iocoder.yudao.module.ai1.dal.mysql; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java new file mode 100644 index 0000000..0b1c88f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.session; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SessionMapper extends BaseMapperX { + + default PageResult selectPage(Ai1SessionPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(Ai1SessionDO::getAgentId, reqVO.getAgentId()) + .likeIfPresent(Ai1SessionDO::getTitle, reqVO.getTitle()) + .eqIfPresent(Ai1SessionDO::getUserId, reqVO.getUserId()) + .orderByDesc(Ai1SessionDO::getId)); + } + + default List selectListByAgentIdAndUserId(Long agentId, Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SessionDO::getAgentId, agentId) + .eq(Ai1SessionDO::getUserId, userId) + .orderByDesc(Ai1SessionDO::getId)); + } + + default List selectListByAgentIds(Collection agentIds) { + return selectList(new LambdaQueryWrapperX().in(Ai1SessionDO::getAgentId, agentIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java new file mode 100644 index 0000000..6999c76 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.session; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SessionMessageMapper extends BaseMapperX { + + default List selectListBySessionId(Long sessionId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SessionMessageDO::getSessionId, sessionId) + .orderByAsc(Ai1SessionMessageDO::getId)); + } + + /** + * 按编号倒序,取指定消息之前最近的 limit 条 + */ + default List selectListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) { + return selectList(new QueryWrapperX() + .limitN(limit) + .eq("session_id", sessionId) + .lt("id", id) + .orderByDesc("id")); + } + + default Long selectCountBySessionId(Long sessionId) { + return selectCount(Ai1SessionMessageDO::getSessionId, sessionId); + } + + default int deleteBySessionIds(Collection sessionIds) { + return delete(new LambdaQueryWrapperX().in(Ai1SessionMessageDO::getSessionId, sessionIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillFileMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillFileMapper.java new file mode 100644 index 0000000..acc4645 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillFileMapper.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.skill; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 SKILL 内容文件 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SkillFileMapper extends BaseMapperX { + + /** + * 按目录优先、排序号、编号升序返回 SKILL 下的全部节点 + */ + default List selectListBySkillId(Long skillId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SkillFileDO::getSkillId, skillId) + .orderByAsc(Ai1SkillFileDO::getType) + .orderByAsc(Ai1SkillFileDO::getSort) + .orderByAsc(Ai1SkillFileDO::getId)); + } + + default List selectListBySkillIdAndParentId(Long skillId, Long parentId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SkillFileDO::getSkillId, skillId) + .eq(Ai1SkillFileDO::getParentId, parentId)); + } + + default Ai1SkillFileDO selectBySkillIdAndParentIdAndName(Long skillId, Long parentId, String name) { + return selectOne(new LambdaQueryWrapperX() + .eq(Ai1SkillFileDO::getSkillId, skillId) + .eq(Ai1SkillFileDO::getParentId, parentId) + .eq(Ai1SkillFileDO::getName, name)); + } + + default int deleteBySkillIds(Collection skillIds) { + return delete(new LambdaQueryWrapperX().in(Ai1SkillFileDO::getSkillId, skillIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillMapper.java new file mode 100644 index 0000000..a5be25b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillMapper.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.skill; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI1 SKILL Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SkillMapper extends BaseMapperX { + + default PageResult selectPage(Ai1SkillPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1SkillDO::getName, reqVO.getName()) + .eqIfPresent(Ai1SkillDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1SkillDO::getId)); + } + + default Ai1SkillDO selectByName(String name) { + return selectOne(Ai1SkillDO::getName, name); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SkillDO::getStatus, status) + .orderByAsc(Ai1SkillDO::getId)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java new file mode 100644 index 0000000..8a1c01d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.ai1.dal.redis; + +/** + * AI1 Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface Ai1RedisKeyConstants { + + /** + * 会话生成任务队列 + *

+ * KEY 格式:ai1:session:tasks + * VALUE 数据类型:Stream 生成任务,字段为 tenantId、messageId、agentId、sessionId、content + *

+ * 所有租户共用一个队列,KEY 不带 tenantId;worker 从任务的 tenantId 字段还原租户上下文 + */ + String SESSION_TASK_STREAM = "ai1:session:tasks"; + + /** + * 会话生成任务队列的消费组 + */ + String SESSION_TASK_GROUP = "ai1-session-workers"; + + /** + * 会话生成任务执行锁,按助手消息编号隔离;使用 Redisson 看门狗续期 + *

+ * KEY 格式:ai1:session:task-lock:{messageId} + */ + String SESSION_TASK_LOCK = "ai1:session:task-lock:%d"; + + /** + * 会话结果流,按助手消息编号隔离 + *

+ * KEY 格式:ai1:session:result:{messageId} + * VALUE 数据类型:Stream 生成事件,字段为 type、data + *

+ * messageId 全局唯一,KEY 不带 tenantId;续传前已按租户、用户校验消息归属 + */ + String SESSION_RESULT_STREAM = "ai1:session:result:%d"; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java new file mode 100644 index 0000000..f73328f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.ai1.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * AI1 错误码枚举类 + *

+ * ai1 系统,使用 1-041-000-000 段 + */ +public interface Ai1ErrorCodeConstants { + + // ========== 供应商 1-041-000-000 ========== + ErrorCode PROVIDER_NOT_EXISTS = new ErrorCode(1_041_000_000, "供应商不存在"); + ErrorCode PROVIDER_DISABLE = new ErrorCode(1_041_000_001, "供应商({}) 已禁用"); + ErrorCode PROVIDER_HAS_MODEL = new ErrorCode(1_041_000_003, "供应商下存在模型,禁止删除"); + ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); + ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); + ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项"); + + // ========== 模型 1-041-001-000 ========== + ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在"); + ErrorCode MODEL_DISABLE = new ErrorCode(1_041_001_001, "模型({}) 已禁用"); + ErrorCode MODEL_DUPLICATE = new ErrorCode(1_041_001_002, "模型标识({}) 已存在"); + ErrorCode MODEL_USED_BY_AGENT = new ErrorCode(1_041_001_003, "模型已被 Agent 使用,禁止删除"); + ErrorCode MODEL_NOT_BELONG_PROVIDER = new ErrorCode(1_041_001_004, "模型不属于该供应商"); + ErrorCode MODEL_TYPE_NOT_CHAT = new ErrorCode(1_041_001_005, "所选模型不是对话模型"); + ErrorCode MODEL_TYPE_NOT_EMBEDDING = new ErrorCode(1_041_001_006, "所选模型不是嵌入模型"); + ErrorCode MODEL_IMPORT_ALL_EXISTS = new ErrorCode(1_041_001_007, "所选模型均已导入,无需重复导入"); + ErrorCode MODEL_USED_BY_KNOWLEDGE_BASE = new ErrorCode(1_041_001_008, "模型已被知识库使用,禁止删除"); + ErrorCode MODEL_TYPE_USED_BY_AGENT = new ErrorCode(1_041_001_009, "模型已被 Agent 使用,禁止修改类型"); + ErrorCode MODEL_TYPE_USED_BY_KNOWLEDGE_BASE = new ErrorCode(1_041_001_010, "模型已被知识库使用,禁止修改类型"); + + // ========== 知识库 1-041-002-000 ========== + ErrorCode KNOWLEDGE_BASE_NOT_EXISTS = new ErrorCode(1_041_002_000, "知识库不存在"); + ErrorCode KNOWLEDGE_BASE_DOCUMENT_EMPTY = new ErrorCode(1_041_002_001, "知识库下暂无文档"); + ErrorCode KNOWLEDGE_BASE_SEARCH_FAIL = new ErrorCode(1_041_002_002, "向量检索失败:{}"); + ErrorCode KNOWLEDGE_BASE_USED_BY_AGENT = new ErrorCode(1_041_002_003, "知识库已被 Agent 使用,禁止删除"); + + // ========== 知识文档 1-041-003-000 ========== + ErrorCode KNOWLEDGE_DOCUMENT_NOT_EXISTS = new ErrorCode(1_041_003_000, "文档不存在"); + ErrorCode KNOWLEDGE_DOCUMENT_CONTENT_EMPTY = new ErrorCode(1_041_003_001, "文档内容为空,无法向量化"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_TYPE_INVALID = new ErrorCode(1_041_003_002, "仅支持 .txt / .md 文本文件"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_EMPTY = new ErrorCode(1_041_003_003, "文件内容为空"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_READ_FAIL = new ErrorCode(1_041_003_004, "文件读取失败:{}"); + ErrorCode KNOWLEDGE_DOCUMENT_VECTORIZE_FAIL = new ErrorCode(1_041_003_005, "向量化失败:{}"); + + // ========== MCP 1-041-004-000 ========== + ErrorCode MCP_NOT_EXISTS = new ErrorCode(1_041_004_000, "MCP 不存在"); + ErrorCode MCP_CONFIG_INVALID = new ErrorCode(1_041_004_001, "config 配置需为合法的 JSON 对象"); + ErrorCode MCP_COMMAND_REQUIRED = new ErrorCode(1_041_004_002, "本地(stdio)类型必须在 config 中配置 command 命令"); + ErrorCode MCP_URL_REQUIRED = new ErrorCode(1_041_004_003, "远程(http)类型必须配置服务地址"); + + // ========== SKILL 1-041-005-000 ========== + ErrorCode SKILL_NOT_EXISTS = new ErrorCode(1_041_005_000, "SKILL 不存在"); + ErrorCode SKILL_USED_BY_AGENT = new ErrorCode(1_041_005_001, "SKILL 已被 Agent 使用,禁止删除"); + ErrorCode SKILL_NAME_DUPLICATE = new ErrorCode(1_041_005_002, "SKILL 名称({}) 已存在"); + + // ========== SKILL 内容文件 1-041-006-000 ========== + ErrorCode SKILL_FILE_NOT_EXISTS = new ErrorCode(1_041_006_000, "文件或目录不存在"); + ErrorCode SKILL_FILE_NAME_INVALID = new ErrorCode(1_041_006_001, "名称格式不合法(不能包含路径分隔符、控制字符,且不能为 . 或 ..)"); + ErrorCode SKILL_FILE_NAME_DUPLICATE = new ErrorCode(1_041_006_002, "同级下已存在同名节点({})"); + ErrorCode SKILL_FILE_PARENT_INVALID = new ErrorCode(1_041_006_003, "父节点必须是同一 SKILL 下的目录"); + ErrorCode SKILL_FILE_LOCKED = new ErrorCode(1_041_006_004, "固定文件或目录禁止{}"); + ErrorCode SKILL_FILE_MOVE_TO_SELF = new ErrorCode(1_041_006_005, "不能移入自身"); + ErrorCode SKILL_FILE_NOT_FILE = new ErrorCode(1_041_006_006, "目录不支持保存内容"); + ErrorCode SKILL_FILE_MOVE_TO_DESCENDANT = new ErrorCode(1_041_006_007, "不能移入自身的子目录"); + ErrorCode SKILL_FILE_PARENT_NOT_EXISTS = new ErrorCode(1_041_006_008, "父目录不存在"); + + // ========== Agent 1-041-007-000 ========== + ErrorCode AGENT_NOT_EXISTS = new ErrorCode(1_041_007_000, "Agent 不存在"); + ErrorCode AGENT_DISABLE = new ErrorCode(1_041_007_001, "Agent({}) 已关闭"); + + // ========== 会话 1-041-008-000 ========== + ErrorCode SESSION_NOT_EXISTS = new ErrorCode(1_041_008_000, "会话不存在"); + + // ========== 会话消息 1-041-009-000 ========== + ErrorCode SESSION_MESSAGE_NOT_EXISTS = new ErrorCode(1_041_009_000, "会话消息不存在"); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/knowledge/Ai1KnowledgeDocumentStatusEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/knowledge/Ai1KnowledgeDocumentStatusEnum.java new file mode 100644 index 0000000..b8fda48 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/knowledge/Ai1KnowledgeDocumentStatusEnum.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.ai1.enums.knowledge; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 知识文档向量化状态的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1KnowledgeDocumentStatusEnum implements ArrayValuable { + + UNPROCESSED(0, "未处理"), + VECTORIZED(1, "已向量化"), + FAILED(2, "失败"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1KnowledgeDocumentStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/mcp/Ai1McpTransportEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/mcp/Ai1McpTransportEnum.java new file mode 100644 index 0000000..f9da8e7 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/mcp/Ai1McpTransportEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.ai1.enums.mcp; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 MCP 传输方式的枚举 + * + * 取值与 MCP 配置 JSON 中的 transport 字段一致 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1McpTransportEnum implements ArrayValuable { + + HTTP("http", "远程(Streamable HTTP)"), + STDIO("stdio", "本地进程(stdio)"); + + public static final String[] ARRAYS = Arrays.stream(values()).map(Ai1McpTransportEnum::getTransport).toArray(String[]::new); + + /** + * 传输方式 + */ + private final String transport; + /** + * 名称 + */ + private final String name; + + @Override + public String[] array() { + return ARRAYS; + } + + public static boolean isStdio(String transport) { + return ObjUtil.equal(STDIO.transport, transport); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/model/Ai1ModelTypeEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/model/Ai1ModelTypeEnum.java new file mode 100644 index 0000000..d26c5a6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/model/Ai1ModelTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.ai1.enums.model; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 模型类型的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1ModelTypeEnum implements ArrayValuable { + + CHAT(0, "对话"), + EMBEDDING(1, "嵌入"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1ModelTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isChat(Integer type) { + return ObjUtil.equal(CHAT.type, type); + } + + public static boolean isEmbedding(Integer type) { + return ObjUtil.equal(EMBEDDING.type, type); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageRoleEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageRoleEnum.java new file mode 100644 index 0000000..2597969 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageRoleEnum.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.ai1.enums.session; + +import cn.hutool.core.util.ObjUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * AI1 会话消息角色的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SessionMessageRoleEnum { + + USER("user", "用户"), + ASSISTANT("assistant", "助手"); + + /** + * 角色 + */ + private final String role; + /** + * 名称 + */ + private final String name; + + public static boolean isUser(String role) { + return ObjUtil.equal(USER.role, role); + } + + public static boolean isAssistant(String role) { + return ObjUtil.equal(ASSISTANT.role, role); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageStatusEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageStatusEnum.java new file mode 100644 index 0000000..e545c98 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageStatusEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ai1.enums.session; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 会话消息生成状态的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SessionMessageStatusEnum implements ArrayValuable { + + GENERATING(0, "生成中"), // 助手消息占位,可按消息编号续传 + SUCCESS(1, "完成"), + FAILED(2, "失败"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1SessionMessageStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isGenerating(Integer status) { + return ObjUtil.equal(GENERATING.status, status); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionStreamEventEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionStreamEventEnum.java new file mode 100644 index 0000000..b11cd60 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionStreamEventEnum.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.ai1.enums.session; + +import cn.hutool.core.util.ObjUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * AI1 会话流事件的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SessionStreamEventEnum { + + STREAM("stream", "结果流标识"), // 连接建立时下发,data 为助手消息编号 + THINKING("thinking", "思考过程增量"), + MESSAGE("message", "回复内容增量"), + PING("ping", "空闲心跳"), + DONE("done", "生成结束"), + ERROR("error", "生成失败"); // data 为错误提示 + + /** + * 事件 + */ + private final String event; + /** + * 名称 + */ + private final String name; + + public static boolean isTerminal(String event) { + return ObjUtil.equal(DONE.event, event) || ObjUtil.equal(ERROR.event, event); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/skill/Ai1SkillFileTypeEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/skill/Ai1SkillFileTypeEnum.java new file mode 100644 index 0000000..73fa785 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/skill/Ai1SkillFileTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.ai1.enums.skill; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 SKILL 内容节点类型的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SkillFileTypeEnum implements ArrayValuable { + + DIRECTORY(0, "目录"), + FILE(1, "文件"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1SkillFileTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isDirectory(Integer type) { + return ObjUtil.equal(DIRECTORY.type, type); + } + + public static boolean isFile(Integer type) { + return ObjUtil.equal(FILE.type, type); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/Ai1AutoConfiguration.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/Ai1AutoConfiguration.java new file mode 100644 index 0000000..af986cd --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/Ai1AutoConfiguration.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.ai1.framework.ai.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 芋道 AI1 自动配置 + * + * 模型、向量存储、MCP 客户端均由 framework/ai/core 与 tool 层按数据库配置程序化构建,这里只负责注册 {@link YudaoAi1Properties} + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(YudaoAi1Properties.class) +public class Ai1AutoConfiguration { +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java new file mode 100644 index 0000000..d36d982 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.ai1.framework.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 芋道 AI1 配置类 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "yudao.ai1") +@Data +public class YudaoAi1Properties { + + /** + * Milvus 向量数据库 + */ + private Milvus milvus = new Milvus(); + + /** + * 会话流 + */ + private Session session = new Session(); + + /** + * SKILL 技能 + */ + private Skill skill = new Skill(); + + @Data + public static class Milvus { + + /** + * 连接地址 + */ + private String uri = "http://127.0.0.1:19530"; + /** + * 用户名 + */ + private String username; + /** + * 密码 + */ + private String password; + /** + * 数据库 + */ + private String database = "default"; + /** + * 知识库集合名前缀,实际集合名为 {collectionPrefix}{知识库编号} + */ + private String collectionPrefix = "knowledge_base_"; + /** + * 默认检索数量,知识库未配置检索数量时使用 + */ + private Integer defaultTopK = 5; + + } + + @Data + public static class Session { + + /** + * 默认系统指令中的 Agent 名称占位符 + */ + public static final String AGENT_NAME_PLACEHOLDER = "{agentName}"; + + /** + * 默认系统指令:Agent 未配置系统指令时使用,{agentName} 占位符替换为 Agent 名称 + */ + private String defaultSystemPrompt = "你是 " + AGENT_NAME_PLACEHOLDER + " 的智能助手。"; + /** + * 结果流 + */ + private Stream stream = new Stream(); + /** + * 生成 worker + */ + private Worker worker = new Worker(); + /** + * SSE 连接 + */ + private Sse sse = new Sse(); + /** + * 上下文历史 + */ + private History history = new History(); + + } + + @Data + public static class Stream { + + /** + * 单次生成 / 单 SSE 连接最长时长,单位:毫秒 + */ + private Long timeoutMs = 180000L; + /** + * 结果流保留时长,单位:秒;决定断线、刷新后可续传的时间窗 + */ + private Long ttlSeconds = 3600L; + + } + + @Data + public static class Worker { + + /** + * 单节点生成 worker 线程数(多实例部署时为每个实例的数量) + */ + private Integer count = 4; + + } + + @Data + public static class Sse { + + /** + * 单节点 SSE 最大并发连接数,超出时拒绝新连接 + */ + private Integer maxConnections = 64; + + } + + @Data + public static class History { + + /** + * 附加给模型的最近历史消息条数上限 + */ + private Integer limit = 20; + + } + + @Data + public static class Skill { + + /** + * 技能物化根目录,实际目录为 {root}/agent_{agentId}(Agent 编号全局唯一,无需按租户分层); + * 默认放在用户目录下,集群部署建议挂载共享持久卷 + */ + private String root = System.getProperty("user.home") + "/yudao-ai1/skills"; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/package-info.java new file mode 100644 index 0000000..44f4a1f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/package-info.java @@ -0,0 +1,4 @@ +/** + * ai1 模块的 ai 配置 + */ +package cn.iocoder.yudao.module.ai1.framework.ai; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/package-info.java new file mode 100644 index 0000000..04ba873 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/package-info.java @@ -0,0 +1,6 @@ +/** + * 属于 ai1 模块的 framework 封装 + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.ai1.framework; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/config/Ai1WebConfiguration.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/config/Ai1WebConfiguration.java new file mode 100644 index 0000000..fad9d74 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/config/Ai1WebConfiguration.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ai1.framework.web.config; + +import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ai1 模块的 web 组件的 Configuration + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class Ai1WebConfiguration { + + /** + * ai1 模块的 API 分组 + */ + @Bean + public GroupedOpenApi ai1GroupedOpenApi() { + return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("ai1"); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/package-info.java new file mode 100644 index 0000000..955c3f1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * ai1 模块的 web 配置 + */ +package cn.iocoder.yudao.module.ai1.framework.web; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java new file mode 100644 index 0000000..bea83f5 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java @@ -0,0 +1,151 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * AI1 LLM 会话工具 + * + * 只负责「按给定模型与消息执行一次流式会话」:接收已装配好的系统指令、历史消息、工具与 Advisor, + * 经 ChatClient 流式会话,增量通过回调输出;传输方式(SSE、Redis Stream)由调用方决定 + * + * @author 芋道源码 + */ +@Component +public class Ai1LlmChatTool { + + /** + * 思考过程的元数据键(Spring AI OpenAI 推理模型的 reasoning_content) + */ + private static final String METADATA_REASONING = "reasoningContent"; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + /** + * 流式会话:装配消息 → 附加工具、Advisor → 增量输出 + * + * @param model 模型运行时快照(需为对话模型) + * @param systemPrompt 系统指令,可为空 + * @param histories 历史消息,按时间升序;每项为 [role, content] + * @param content 当前用户消息 + * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 + * @param advisors 会话 Advisor,可为空 + * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符 + * @param onThinking 思考过程增量回调,可为空 + * @param onContent 回复内容增量回调,可为空 + * @return 完整回复(内容 + 思考过程) + */ + public ChatText chat(Ai1ModelRespBO model, String systemPrompt, List histories, String content, + List tools, List advisors, Long sessionId, + Consumer onThinking, Consumer onContent) { + // 1. 装配会话请求:消息(系统指令 + 历史 + 当前)+ 工具 + Advisor + ChatClient.ChatClientRequestSpec spec = llmModelFactory.buildChatClient(model, sessionId).prompt() + .messages(buildMessages(systemPrompt, histories, content)); + if (CollUtil.isNotEmpty(tools)) { + // Spring AI 1.x 的 tools 只接受 @Tool 对象,MCP / SkillsTool 的回调需直接注册。 + List callbacks = new ArrayList<>(); + for (Object tool : tools) { + if (tool instanceof ToolCallback callback) { + callbacks.add(callback); + } else { + callbacks.addAll(List.of(ToolCallbacks.from(tool))); + } + } + spec = spec.toolCallbacks(callbacks); + } + if (CollUtil.isNotEmpty(advisors)) { + spec = spec.advisors(advisors); + } + + // 2. 流式执行,逐段回调 + StringBuilder contentText = new StringBuilder(); + StringBuilder thinkingText = new StringBuilder(); + for (ChatResponse chatResponse : spec.stream().chatResponse().toIterable()) { + Generation generation = chatResponse.getResult(); + // 部分供应商在流末尾下发只含 usage、没有输出的分片,跳过避免 NPE + if (generation == null || generation.getOutput() == null) { + continue; + } + // 2.1 思考过程:Spring AI 1.x 的 reasoning_content 是增量分片,直接转发(重复文本也可能是有效增量) + Object reasoningValue = generation.getOutput().getMetadata().get(METADATA_REASONING); + String delta = reasoningValue instanceof String value ? value : null; + if (StrUtil.isNotBlank(delta)) { + thinkingText.append(delta); + if (onThinking != null) { + onThinking.accept(delta); + } + } + // 2.2 回复内容 + String chunk = generation.getOutput().getText(); + if (StrUtil.isNotBlank(chunk)) { + contentText.append(chunk); + if (onContent != null) { + onContent.accept(chunk); + } + } + } + return new ChatText(contentText.toString(), thinkingText.toString()); + } + + /** + * 消息装配:系统指令 + 历史 + 当前用户消息;推理模型的思考过程不进入上下文 + */ + private static List buildMessages(String systemPrompt, List histories, String content) { + List messages = new ArrayList<>(); + if (StrUtil.isNotBlank(systemPrompt)) { + messages.add(new SystemMessage(systemPrompt)); + } + if (CollUtil.isNotEmpty(histories)) { + for (String[] history : histories) { + String text = StrUtil.nullToEmpty(history[1]); + if (Ai1SessionMessageRoleEnum.isUser(history[0])) { + messages.add(new UserMessage(text)); + } else if (Ai1SessionMessageRoleEnum.isAssistant(history[0])) { + messages.add(new AssistantMessage(text)); + } + } + } + messages.add(new UserMessage(content)); + return messages; + } + + /** + * LLM 会话结果 + */ + @Data + @AllArgsConstructor + public static class ChatText { + + /** + * 回复内容 + */ + private String content; + /** + * 思考过程,推理模型才有 + */ + private String thinking; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java new file mode 100644 index 0000000..674dfc3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java @@ -0,0 +1,239 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.net.URI; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * AI1 LLM 模型工厂 + * + * 按 {@link Ai1ModelRespBO} 程序化构建 Spring AI 模型: + * 1. 对话模型:OpenAiChatModel(OpenAI 兼容协议,覆盖 DeepSeek、GLM、Ollama(/v1) 等) + * 2. 嵌入模型:OpenAiEmbeddingModel(向量化) + * + * 模型按 providerId:modelId 缓存复用(编号全局唯一,无需再区分租户),缓存使用 Guava Cache 按容量淘汰最久未访问的模型; + * Provider、模型的配置变更时,由对应 Service 调用 {@link #evictByProviderId}、{@link #evictByModelId} 显式失效 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1LlmModelFactory { + + /** + * 附属 Header 会话占位符:构建模型时替换为当前会话编号 + */ + public static final String SESSION_PLACEHOLDER = "{session}"; + /** + * 缓存上限,达到后逐出最久未访问的模型 + */ + private static final int CACHE_MAX = 128; + + /** + * 缓存写入后的过期时间:修改 Provider / 模型时只能失效本节点,其他节点最迟在该时间后按新配置重建 + */ + private static final Duration CACHE_EXPIRE = Duration.ofMinutes(10); + + /** + * 缓存 key 分隔符 + */ + private static final String KEY_SEPARATOR = ":"; + + /** + * 对话模型缓存:providerId:modelId:sessionKey → ChatModel + */ + private final Cache chatModelCache = CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX).expireAfterWrite(CACHE_EXPIRE).build(); + /** + * 嵌入模型缓存:providerId:modelId → EmbeddingModel + */ + private final Cache embeddingModelCache = CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX).expireAfterWrite(CACHE_EXPIRE).build(); + + /** + * 获取(或构建)对话模型,并包装为 ChatClient + * + * @param model 模型运行时快照 + * @param sessionId 会话编号,用于替换附属 Header 中的 {session} 占位符,可为空 + * @return ChatClient + */ + public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { + return ChatClient.builder(getOrCreateChatModel(model, sessionId)).build(); + } + + /** + * 获取(或构建)嵌入模型 + * + * @param model 模型运行时快照 + * @return 嵌入模型 + */ + public EmbeddingModel getOrCreateEmbeddingModel(Ai1ModelRespBO model) { + return embeddingModelCache.asMap().computeIfAbsent(buildCacheKey(model), key -> { + OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder() + .model(model.getModel()) + .build(); + log.debug("[getOrCreateEmbeddingModel][providerId({}) modelId({}) 嵌入模型构建完成]", model.getProviderId(), model.getModelId()); + return new OpenAiEmbeddingModel(buildOpenAiApi(model, null), MetadataMode.EMBED, options); + }); + } + + /** + * 失效指定 Provider 下的全部模型缓存(Provider 的地址、密钥、Header、状态变更或删除时调用) + * + * @param providerId Provider 编号 + */ + public void evictByProviderId(Long providerId) { + String prefix = providerId + KEY_SEPARATOR; + evict(key -> key.startsWith(prefix)); + } + + /** + * 失效指定模型的缓存(模型标识、类型、状态变更或删除时调用) + * + * @param modelId 模型编号 + */ + public void evictByModelId(Long modelId) { + String modelIdText = String.valueOf(modelId); + evict(key -> modelIdText.equals(StrUtil.split(key, KEY_SEPARATOR).get(1))); + } + + /** + * 按 key 条件失效对话模型、嵌入模型缓存 + * + * @param keyMatcher key 匹配条件 + */ + private void evict(Predicate keyMatcher) { + chatModelCache.asMap().keySet().removeIf(keyMatcher); + embeddingModelCache.asMap().keySet().removeIf(keyMatcher); + } + + /** + * 获取(或构建)对话模型 + * + * 仅当附属 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; + * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例 + */ + private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { + // 1. 计算缓存 key:Header 含 {session} 占位符时按会话隔离,否则跨会话共享 + String sessionKey = usesSessionHeader(model.getHeaders()) && sessionId != null + ? String.valueOf(sessionId) : ""; + String cacheKey = buildCacheKey(model) + KEY_SEPARATOR + sessionKey; + + // 2. 命中缓存直接复用;未命中时按运行时快照构建 OpenAI 兼容对话模型并缓存 + return chatModelCache.asMap().computeIfAbsent(cacheKey, key -> { + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(model.getModel()) + .build(); + log.debug("[getOrCreateChatModel][providerId({}) modelId({}) 对话模型构建完成]", model.getProviderId(), model.getModelId()); + return OpenAiChatModel.builder().openAiApi(buildOpenAiApi(model, sessionId)).defaultOptions(options).build(); + }); + } + + /** + * Spring AI 1.x 将连接参数放在 OpenAiApi,而非模型 Options;仅在模型构建边界适配。 + * normalizeBaseUrl 已包含 /v1 或供应商的自定义前缀,因此端点不再重复添加 /v1。 + */ + private static OpenAiApi buildOpenAiApi(Ai1ModelRespBO model, Long sessionId) { + MultiValueMap headers = new LinkedMultiValueMap<>(); + buildHeaders(model.getHeaders(), sessionId).forEach(headers::add); + return OpenAiApi.builder() + .baseUrl(normalizeBaseUrl(model.getBaseUrl())) + .apiKey(StrUtil.nullToEmpty(model.getApiKey())) + .headers(headers) + .completionsPath("/chat/completions") + .embeddingsPath("/embeddings") + .build(); + } + + /** + * 构建模型缓存 key:providerId:modelId:配置指纹 + * + * 带上配置指纹:配置变化后新请求自然命中新 key;并发下按旧快照构建的实例只会写入旧 key,不会再被读到 + * + * @param model 模型运行时快照 + * @return 缓存 key + */ + private static String buildCacheKey(Ai1ModelRespBO model) { + return model.getProviderId() + KEY_SEPARATOR + model.getModelId() + KEY_SEPARATOR + + Objects.hash(model.getBaseUrl(), model.getApiKey(), model.getModel(), model.getModelType(), model.getHeaders()); + } + + /** + * 归一化接口地址:去掉结尾斜杠;裸地址(无路径)自动补 /v1, + * 保证连通测试、模型拉取与实际会话访问同一个 OpenAI 兼容端点 + * + * 例如说:Ollama 的 http://127.0.0.1:11434 + * 归一化为 http://127.0.0.1:11434/v1 + * + * @param baseUrl 接口地址 + * @return 归一化后的地址 + */ + public static String normalizeBaseUrl(String baseUrl) { + String url = StrUtil.removeSuffix(StrUtil.trim(baseUrl), "/"); + try { + String path = URI.create(url).getPath(); + if (StrUtil.isBlank(path) || "/".equals(path)) { + url = url + "/v1"; + } + } catch (Exception e) { + // 非法 URL 按原值使用,交由后续请求报错 + log.warn("[normalizeBaseUrl][接口地址({}) 解析失败,按原值使用]", url, e); + } + return url; + } + + /** + * 附属 Header 是否使用 {session} 占位符 + */ + private static boolean usesSessionHeader(List> headers) { + return CollUtil.findOne(headers, header -> StrUtil.contains(header.get("value"), SESSION_PLACEHOLDER)) != null; + } + + /** + * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header + */ + private static Map buildHeaders(List> headers, Long sessionId) { + Map result = new LinkedHashMap<>(); + if (CollUtil.isEmpty(headers)) { + return result; + } + for (Map header : headers) { + String key = header.get("key"); + String value = header.get("value"); + if (StrUtil.isBlank(key)) { + continue; + } + if (StrUtil.contains(value, SESSION_PLACEHOLDER)) { + if (sessionId == null) { + continue; + } + value = value.replace(SESSION_PLACEHOLDER, String.valueOf(sessionId)); + } + result.put(key.trim(), StrUtil.nullToEmpty(value)); + } + return result; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientTool.java new file mode 100644 index 0000000..915c5e7 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientTool.java @@ -0,0 +1,456 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.RemovalListener; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.annotation.PreDestroy; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; + +/** + * AI1 MCP 客户端工具(官方 Java MCP SDK):连接、列举工具、调用工具、连通测试 + * + * 1. 传输层:http(Streamable HTTP)/ stdio(本地进程);传输方式以 {@link Ai1McpDO#getTransport()} 列为准, + * 其余参数取自 config JSON(缺失时回退平铺列) + * 2. 客户端与工具列表按 MCP 编号 + 配置指纹缓存,配置变化时重建并关闭旧连接;闲置超时自动关闭 + * + * 注意:stdio 会在服务器上启动本地进程,与源工程行为一致,多租户不可信部署需自行评估 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1McpClientTool { + + /** + * 远程服务默认的消息端点 + */ + private static final String DEFAULT_HTTP_ENDPOINT = "/mcp"; + /** + * 缓存 key 分隔符 + */ + private static final String KEY_SEPARATOR = ":"; + /** + * 缓存的闲置过期时间:超过该时间未使用的客户端自动关闭,释放连接与子进程 + * + * 多节点下,其他节点删除 MCP 后,本节点的残留客户端也依赖该过期释放 + */ + private static final Duration CACHE_EXPIRE_AFTER_ACCESS = Duration.ofMinutes(30); + + /** + * 客户端缓存:MCP 编号:配置指纹 → 客户端 + * + * key 带上配置指纹,配置变化后自然命中不到旧客户端,不需要额外的缓存项对象 + */ + private final Cache clientCache = CacheBuilder.newBuilder() + .expireAfterAccess(CACHE_EXPIRE_AFTER_ACCESS) + .removalListener((RemovalListener) notification -> closeQuietly(notification.getValue())) + .build(); + /** + * 工具列表缓存:MCP 编号:配置指纹 → 工具列表 + */ + private final Cache> toolsCache = CacheBuilder.newBuilder() + .expireAfterAccess(CACHE_EXPIRE_AFTER_ACCESS) + .build(); + /** + * 按 MCP 串行建连的锁,避免不同服务的初始化互相阻塞 + */ + private final Map clientLocks = new ConcurrentHashMap<>(); + + /** + * 获取 MCP 服务暴露的工具列表(带缓存,配置变化自动重建) + * + * 直接返回 SDK 的工具定义(含名称、描述、入参 JSON Schema),仅供 {@link Ai1McpToolFactory} 桥接 ToolCallback 使用 + * + * @param mcp MCP 服务 + * @return 工具列表;列举失败时返回空列表 + */ + public List listTools(Ai1McpDO mcp) { + // 1. 命中缓存:配置指纹一致时,直接返回 + String cacheKey = buildCacheKey(mcp); + List cached = toolsCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + + // 2. 调用 tools/list 拉取工具列表;失败时记录日志,按空列表处理 + try { + McpSchema.ListToolsResult result = getOrCreateClient(mcp).listTools(); + List tools = result != null && result.tools() != null + ? result.tools() : Collections.emptyList(); + // 3. 仅缓存成功的结果(包含服务确实没有工具的空列表),失败后允许下次会话重试 + toolsCache.put(cacheKey, tools); + return tools; + } catch (Exception e) { + log.warn("[listTools][MCP({}/{}) tools/list 失败]", mcp.getId(), mcp.getName(), e); + return Collections.emptyList(); + } + } + + /** + * 调用 MCP 工具(阻塞),返回文本结果 + * + * @param mcp MCP 服务 + * @param toolName 工具名称(MCP 服务侧的原始名称) + * @param arguments 工具参数 + * @return 文本结果 + */ + public String callTool(Ai1McpDO mcp, String toolName, Map arguments) { + McpSyncClient client = getOrCreateClient(mcp); + // 同一客户端的调用串行执行,避免 stdio 管道并发读写错乱 + synchronized (client) { + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder(toolName) + .arguments(MapUtil.emptyIfNull(arguments)).build(); + McpSchema.CallToolResult result; + try { + result = client.callTool(request); + } catch (McpError e) { + // 服务端 JSON-RPC 业务错误不表示连接失效 + throw e; + } catch (Exception e) { + String cacheKey = buildCacheKey(mcp); + synchronized (clientLocks.computeIfAbsent(mcp.getId(), key -> new Object())) { + // 只清理本次失败的客户端,避免旧调用误删已经重建的连接 + if (clientCache.asMap().remove(cacheKey, client)) { + toolsCache.invalidate(cacheKey); + } + } + // 不自动重跑有副作用的工具;下次调用重新建连 + throw e; + } + return readText(result); + } + } + + /** + * 连通测试:initialize + tools/list,使用临时客户端,不影响缓存 + * + * @param mcp MCP 服务 + * @return 测试结果 + */ + public Ai1McpConnectRespVO testConnect(Ai1McpDO mcp) { + long startTime = System.currentTimeMillis(); + McpSyncClient client = null; + try { + // 1. 建立连接 + McpClientTransport transport = buildTransport(mcp); + if (transport == null) { + return buildConnectFailResult("配置不完整(缺少必要参数)", startTime); + } + client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(30)) + .initializationTimeout(Duration.ofSeconds(15)) + .build(); + client.initialize(); + + // 2. 读取服务信息与工具列表 + Ai1McpConnectRespVO result = new Ai1McpConnectRespVO().setConnectable(true) + .setInstructions(client.getServerInstructions()); + McpSchema.Implementation serverInfo = client.getServerInfo(); + if (serverInfo != null) { + result.setServerName(serverInfo.name()).setServerVersion(serverInfo.version()); + } + McpSchema.ListToolsResult listResult = client.listTools(); + result.setTools(convertList(listResult != null ? listResult.tools() : null, tool -> new Ai1McpConnectRespVO.Tool() + .setName(tool.name()).setTitle(tool.title()).setDescription(tool.description()))); + return result.setToolCount(result.getTools().size()) + .setMessage("连接成功,发现 " + result.getTools().size() + " 个工具") + .setElapsedMs(System.currentTimeMillis() - startTime); + } catch (Exception e) { + log.warn("[testConnect][MCP({}/{}) 连通测试失败]", mcp.getId(), mcp.getName(), e); + return buildConnectFailResult(String.valueOf(e.getMessage()), startTime); + } finally { + closeQuietly(client); + } + } + + /** + * 移除并关闭指定 MCP 的客户端(删除、配置变更时调用,释放子进程与连接线程) + * + * @param mcpId MCP 编号 + */ + public void evict(Long mcpId) { + String prefix = mcpId + KEY_SEPARATOR; + toolsCache.asMap().keySet().removeIf(key -> key.startsWith(prefix)); + clientCache.asMap().keySet().removeIf(key -> key.startsWith(prefix)); + } + + /** + * 移除并关闭指定 MCP 除当前指纹外的客户端与工具缓存 + */ + private void evictExcept(Long mcpId, String currentKey) { + String prefix = mcpId + KEY_SEPARATOR; + toolsCache.asMap().keySet().removeIf(key -> key.startsWith(prefix) && !key.equals(currentKey)); + clientCache.asMap().keySet().removeIf(key -> key.startsWith(prefix) && !key.equals(currentKey)); + } + + @PreDestroy + public void destroy() { + toolsCache.invalidateAll(); + clientCache.invalidateAll(); + } + + // ==================== 连接 / 传输 ==================== + + /** + * 获取(或构建)MCP 客户端:配置指纹变化时重建,旧连接由缓存的 RemovalListener 关闭 + */ + private McpSyncClient getOrCreateClient(Ai1McpDO mcp) { + // 1. 命中缓存:配置指纹一致时,直接复用 + String cacheKey = buildCacheKey(mcp); + McpSyncClient cached = clientCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + synchronized (clientLocks.computeIfAbsent(mcp.getId(), key -> new Object())) { + cached = clientCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + // 2. 配置变化:构建并初始化新客户端,成功后再释放旧指纹的客户端(与源码一致,避免打断在途调用) + McpClientTransport transport = buildTransport(mcp); + if (transport == null) { + throw new IllegalStateException("MCP(" + mcp.getName() + ") 配置不完整(缺少必要参数)"); + } + McpSyncClient client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(60)) + .initializationTimeout(Duration.ofSeconds(15)) + .build(); + try { + client.initialize(); + } catch (Exception e) { + // 初始化失败的客户端没有进入缓存,必须主动释放连接或 stdio 子进程 + closeQuietly(client); + throw e; + } + clientCache.put(cacheKey, client); + evictExcept(mcp.getId(), cacheKey); + return client; + } + } + + /** + * 构建传输层:stdio 走本地进程,其余走 Streamable HTTP;参数缺失时返回 null + */ + private McpClientTransport buildTransport(Ai1McpDO mcp) { + McpConfig config = resolveConfig(parseConfig(mcp)); + try { + return Ai1McpTransportEnum.isStdio(config.getTransport()) ? buildStdioTransport(config) : buildHttpTransport(config); + } catch (ServiceException ex) { + throw ex; + } catch (Exception e) { + log.warn("[buildTransport][MCP({}) 传输层构建失败]", mcp.getId(), e); + return null; + } + } + + /** + * Streamable HTTP 传输:按「base = scheme://host:port、endpoint = raw path + query」构建, + * 保证配置的服务地址精确作为 MCP 消息端点(避免 resolve("/mcp") 覆盖路径导致 404) + */ + private McpClientTransport buildHttpTransport(McpConfig config) { + if (StrUtil.isBlank(config.getUrl())) { + return null; + } + String baseUri = config.getUrl(); + String endpoint = DEFAULT_HTTP_ENDPOINT; + try { + URI uri = URI.create(config.getUrl()); + if (uri.getScheme() != null && uri.getAuthority() != null) { + baseUri = uri.getScheme() + "://" + uri.getAuthority(); + endpoint = StrUtil.isEmpty(uri.getRawPath()) ? DEFAULT_HTTP_ENDPOINT : uri.getRawPath(); + if (uri.getRawQuery() != null) { + endpoint += "?" + uri.getRawQuery(); + } + } + } catch (IllegalArgumentException e) { + log.warn("[buildHttpTransport][服务地址({}) 解析失败,按原值使用]", config.getUrl(), e); + } + HttpClientStreamableHttpTransport.Builder builder = HttpClientStreamableHttpTransport.builder(baseUri) + .endpoint(endpoint) + .customizeClient(clientBuilder -> clientBuilder.connectTimeout(Duration.ofSeconds(10))); + if (MapUtil.isNotEmpty(config.getHeaders())) { + builder.httpRequestCustomizer(buildHeaderCustomizer(config.getHeaders())); + } + return builder.build(); + } + + /** + * 本地进程传输:command + args + env + */ + private McpClientTransport buildStdioTransport(McpConfig config) { + if (StrUtil.isBlank(config.getCommand())) { + return null; + } + ServerParameters.Builder params = ServerParameters.builder(config.getCommand()); + if (CollUtil.isNotEmpty(config.getArgs())) { + params.args(config.getArgs()); + } + if (MapUtil.isNotEmpty(config.getEnv())) { + params.env(config.getEnv()); + } + return new StdioClientTransport(params.build(), McpJsonDefaults.getMapper()); + } + + /** + * 请求头自定义器:为 MCP HTTP 请求统一追加 headers + */ + private static McpSyncHttpClientRequestCustomizer buildHeaderCustomizer(Map headers) { + return (builder, method, uri, protocolVersion, context) -> headers.forEach((key, value) -> { + if (StrUtil.isNotEmpty(key) && value != null) { + builder.header(key, value); + } + }); + } + + private static void closeQuietly(McpSyncClient client) { + if (client == null) { + return; + } + try { + client.closeGracefully(); + } catch (Exception e) { + log.warn("[closeQuietly][MCP 客户端关闭失败]", e); + } + } + + // ==================== 配置 / 结果解析 ==================== + + /** + * 解析 MCP 配置:transport 以列为准;url、headers 优先取 config,缺失时回退平铺列 + */ + private static McpConfig parseConfig(Ai1McpDO mcp) { + Map configMap = MapUtil.emptyIfNull(JsonUtils.parseMap(mcp.getConfig())); + McpConfig config = new McpConfig(); + config.setTransport(StrUtil.blankToDefault(mcp.getTransport(), + StrUtil.blankToDefault(MapUtil.getStr(configMap, "transport"), Ai1McpTransportEnum.HTTP.getTransport()))); + config.setUrl(StrUtil.blankToDefault(MapUtil.getStr(configMap, "url"), mcp.getUrl())); + Map headers = parseStringMap(configMap.get("headers")); + config.setHeaders(headers != null ? headers : mcp.getHeaders()); + config.setCommand(MapUtil.getStr(configMap, "command")); + Object args = configMap.get("args"); + config.setArgs(args instanceof List ? Convert.toList(String.class, args) : null); + config.setEnv(parseStringMap(configMap.get("env"))); + return config; + } + + /** + * 调用前解析地址、请求头、命令和环境变量里的 ${ENV},库中的原文不改 + */ + private static McpConfig resolveConfig(McpConfig config) { + config.setUrl(resolveSpringPlaceholders(config.getUrl())); + config.setCommand(resolveSpringPlaceholders(config.getCommand())); + config.setHeaders(resolveSpringPlaceholders(config.getHeaders())); + config.setEnv(resolveSpringPlaceholders(config.getEnv())); + config.setArgs(convertList(config.getArgs(), arg -> resolveSpringPlaceholders(arg))); + return config; + } + + /** + * 字符串 Map 解析(config 中的 headers、env):支持 JSON 对象,或 JSON 对象形式的字符串 + */ + private static Map parseStringMap(Object value) { + if (value instanceof String) { + value = JsonUtils.parseMap((String) value); + } + return value instanceof Map ? MapUtil.removeNullValue(Convert.toMap(String.class, String.class, value)) : null; + } + + /** + * 工具调用结果文本提取:优先文本内容,其次结构化内容 + */ + private static String readText(McpSchema.CallToolResult result) { + if (result == null) { + return ""; + } + String text = CollUtil.join(convertList(result.content(), content -> content instanceof McpSchema.TextContent + ? ((McpSchema.TextContent) content).text() : null), ""); + if (StrUtil.isNotEmpty(text)) { + return text; + } + if (result.structuredContent() != null) { + return JsonUtils.toJsonString(result.structuredContent()); + } + return String.valueOf(result); + } + + /** + * 缓存 key:MCP 编号 + 配置指纹,传输方式、config、平铺列任一变化即得到新的 key + * + * 多节点下,各节点加载到的 MCP 配置变化后指纹不同,自然重建客户端 + */ + private static String buildCacheKey(Ai1McpDO mcp) { + return mcp.getId() + KEY_SEPARATOR + + Objects.hash(mcp.getTransport(), mcp.getConfig(), mcp.getUrl(), mcp.getHeaders()); + } + + /** + * 构建连通测试失败结果 + */ + private static Ai1McpConnectRespVO buildConnectFailResult(String message, long startTime) { + return new Ai1McpConnectRespVO().setConnectable(false).setToolCount(0).setTools(new ArrayList<>()) + .setMessage(message).setElapsedMs(System.currentTimeMillis() - startTime); + } + + /** + * 解析后的 MCP 服务配置 + */ + @Data + private static class McpConfig { + + /** + * 传输方式 + */ + private String transport; + /** + * 服务地址 + */ + private String url; + /** + * 请求头 + */ + private Map headers; + /** + * stdio 命令 + */ + private String command; + /** + * stdio 参数 + */ + private List args; + /** + * stdio 环境变量 + */ + private Map env; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactory.java new file mode 100644 index 0000000..e53639a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactory.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ReUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.service.mcp.Ai1McpService; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.function.Function; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * AI1 MCP 工具工厂:把 Agent 绑定的 MCP 服务暴露的工具,桥接为 Spring AI ToolCallback + * + * 1. 工具名:MCP 名称净化 + "__" + 原始工具名;无有效名称时使用 MCP 编号,超长时保留摘要 + * 2. 入参:透传 MCP 工具的 JSON Schema,调用落地仍走 {@link Ai1McpClientTool} + * + * 工具挂到 ChatClient 后,由 Spring AI 的工具调用机制统一驱动;与 {@link Ai1McpClientTool} 同包 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1McpToolFactory { + + /** + * MCP 名称与工具名称的分隔符 + */ + private static final String NAME_SEPARATOR = "__"; + + @Resource + private Ai1McpService mcpService; + + @Resource + private Ai1McpClientTool mcpClientTool; + + /** + * 构建 Agent 绑定的 MCP 工具集合;单个服务失败时跳过,不影响会话 + * + * tools/list 结果已由 {@link Ai1McpClientTool} 按配置指纹缓存,这里只做 ToolCallback 包装 + * + * @param agent Agent + * @return 工具集合 + */ + public List buildTools(Ai1AgentDO agent) { + List tools = new ArrayList<>(); + if (CollUtil.isEmpty(agent.getMcpIds())) { + return tools; + } + for (Ai1McpDO mcp : mcpService.getMcpList(agent.getMcpIds())) { + if (CommonStatusEnum.isDisable(mcp.getStatus())) { + continue; + } + tools.addAll(convertList(listTools(agent, mcp), tool -> buildToolCallback(mcp, tool))); + } + return tools; + } + + /** + * 列举 MCP 服务暴露的工具;失败时记录日志并返回空列表 + * + * @param agent Agent + * @param mcp MCP 服务 + * @return 工具列表 + */ + private List listTools(Ai1AgentDO agent, Ai1McpDO mcp) { + try { + return mcpClientTool.listTools(mcp); + } catch (Exception e) { + log.warn("[listTools][Agent({}) 加载 MCP({}) 工具失败]", agent.getId(), mcp.getName(), e); + return Collections.emptyList(); + } + } + + /** + * 构建单个工具回调:名称去冲突 + 入参 Schema 透传 + MCP 客户端调用落地 + */ + private ToolCallback buildToolCallback(Ai1McpDO mcp, McpSchema.Tool tool) { + Function, String> function = arguments -> + mcpClientTool.callTool(mcp, tool.name(), arguments); + Map inputSchema = tool.inputSchema(); + if (MapUtil.isEmpty(inputSchema)) { + inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", new HashMap<>()); + } + return FunctionToolCallback.builder(buildToolName(mcp, tool), function) + .description(StrUtil.nullToEmpty(tool.description())) + .inputType(Map.class) + .inputSchema(JsonUtils.toJsonString(inputSchema)) + .build(); + } + + /** + * 构建工具名:无有效 MCP 名称时使用编号;超过 64 字符时截短并追加摘要,避免截断后重名 + * + * @param mcp MCP 服务 + * @param tool MCP 工具 + * @return 工具名 + */ + private static String buildToolName(Ai1McpDO mcp, McpSchema.Tool tool) { + String prefix = slugify(mcp.getName()); + if (StrUtil.isEmpty(prefix)) { + prefix = "mcp_" + mcp.getId(); + } + String name = prefix + NAME_SEPARATOR + tool.name(); + // OpenAI 兼容接口的工具名最多 64 字符,保留 47 字符前缀 + "_" + 16 位摘要。 + // 摘要由 MCP 编号和原始工具名生成,避免不同服务或工具截断后重名。 + if (name.length() > 64) { + String suffix = DigestUtil.sha256Hex(mcp.getId() + ":" + tool.name()).substring(0, 16); + name = name.substring(0, 47) + "_" + suffix; + } + return name; + } + + /** + * 名称净化:只保留小写字母、数字、下划线(工具名需匹配 ^[a-zA-Z0-9_-]+$) + */ + static String slugify(String name) { + if (StrUtil.isBlank(name)) { + return ""; + } + String slug = ReUtil.replaceAll(name.toLowerCase(Locale.ROOT), "[^a-z0-9_]+", "_"); + slug = ReUtil.replaceAll(slug, "_+", "_"); + return ReUtil.replaceAll(slug, "^_+|_+$", ""); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java new file mode 100644 index 0000000..f6f3322 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java @@ -0,0 +1,238 @@ +package cn.iocoder.yudao.module.ai1.harness.model; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.ContentType; +import cn.hutool.http.Header; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpStatus; +import cn.hutool.http.Method; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_EMPTY; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; +import static cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory.normalizeBaseUrl; + +/** + * AI1 供应商工具:面向 OpenAI 兼容接口的原始 HTTP 探测 + * + * 1. {@link #testConnect}:GET /models 优先、回退 POST /chat/completions 的连通测试 + * 2. {@link #listModels}:拉取 GET /models 返回的模型标识 + * + * 入参为已解析 ${ENV} 占位符的地址、密钥、Header,不感知供应商的存储与租户归属 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1ProviderTool { + + /** + * 请求的连接超时,单位:毫秒 + */ + private static final int CONNECT_TIMEOUT = 5000; + /** + * 请求的读取超时,单位:毫秒 + */ + private static final int READ_TIMEOUT = 8000; + /** + * 连通测试回退时的最小会话请求体:仅探测服务可达与鉴权,不做真实会话 + */ + private static final String CONNECT_CHAT_BODY = "{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + + /** + * 连通测试:GET {baseUrl}/models 优先;失败(认证错误除外)时,回退 POST {baseUrl}/chat/completions + * + * @param baseUrl 接口地址 + * @param apiKey API 密钥 + * @param headers 请求附属 Header + * @return 测试结果 + */ + public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, List> headers) { + long startTime = System.currentTimeMillis(); + String url = normalizeBaseUrl(baseUrl); + + // 1. 主校验:GET /models(标准 OpenAI 兼容接口) + HttpOutcome models = executeRequest(Method.GET, url + "/models", apiKey, headers, null); + if (models.getHttpCode() == HttpStatus.HTTP_OK) { + return buildConnectResult(true, HttpStatus.HTTP_OK, "连通正常:GET /models 返回 HTTP 200", startTime); + } + if (isAuthFail(models.getHttpCode())) { + return buildConnectResult(false, models.getHttpCode(), + "认证失败:GET /models 返回 HTTP " + models.getHttpCode() + ",请检查 API 密钥", startTime); + } + + // 2. 回退:POST /chat/completions 最小请求(仅探测服务可达与鉴权,不做真实会话) + HttpOutcome chat = executeRequest(Method.POST, url + "/chat/completions", apiKey, headers, CONNECT_CHAT_BODY); + if (chat.getHttpCode() == HttpStatus.HTTP_OK) { + return buildConnectResult(true, HttpStatus.HTTP_OK, "GET /models 不可用(HTTP " + models.getHttpCode() + + "),回退 POST /chat/completions 连通正常:HTTP 200", startTime); + } + if (isAuthFail(chat.getHttpCode())) { + return buildConnectResult(false, chat.getHttpCode(), + "认证失败:POST /chat/completions 返回 HTTP " + chat.getHttpCode() + ",请检查 API 密钥", startTime); + } + if (chat.getHttpCode() > 0) { + return buildConnectResult(false, chat.getHttpCode(), "服务可达但接口异常:GET /models HTTP " + models.getHttpCode() + + ",POST /chat/completions HTTP " + chat.getHttpCode(), startTime); + } + + // 3. 两次均网络异常:合并报告 + String modelsMessage = StrUtil.isNotBlank(models.getMessage()) ? "GET /models 失败:" + models.getMessage() : "GET /models 无响应"; + String chatMessage = StrUtil.isNotBlank(chat.getMessage()) + ? "POST /chat/completions 失败:" + chat.getMessage() : "POST /chat/completions 无响应"; + return buildConnectResult(false, 0, "连接失败:" + modelsMessage + ";" + chatMessage, startTime); + } + + /** + * 拉取远程模型标识列表:GET {baseUrl}/models,解析 data[].id + * + * @param baseUrl 接口地址 + * @param apiKey API 密钥 + * @param headers 请求附属 Header + * @return 模型标识列表 + */ + public List listModels(String baseUrl, String apiKey, List> headers) { + // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 + String url = normalizeBaseUrl(baseUrl) + "/models"; + HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); + if (outcome.getHttpCode() != HttpStatus.HTTP_OK) { + log.warn("[listModels][地址({}) 模型拉取失败,httpCode({}) message({})]", url, outcome.getHttpCode(), outcome.getMessage()); + throw exception(PROVIDER_REMOTE_MODEL_LOAD_FAIL); + } + + // 2. 解析 data[].id:解析失败时,提示检查配置;解析结果为空时,提示无可用模型 + List models = parseModels(outcome.getBody()); + if (models == null) { + log.warn("[listModels][地址({}) 模型数据解析失败,body({})]", url, StrUtil.maxLength(outcome.getBody(), 200)); + throw exception(PROVIDER_REMOTE_MODEL_LOAD_FAIL); + } + if (CollUtil.isEmpty(models)) { + throw exception(PROVIDER_REMOTE_MODEL_EMPTY); + } + return models; + } + + /** + * 解析 GET /models 响应的 data[].id + * + * 响应不是 JSON 对象、或 data 存在但不是数组时,视为解析失败(与源码一致) + * + * @param body 响应体 + * @return 模型标识列表;解析失败返回 null + */ + static List parseModels(String body) { + Map root = JsonUtils.parseMap(body); + if (root == null) { + return null; + } + Object data = root.get("data"); + if (data != null && !(data instanceof List)) { + return null; + } + return convertList((List) data, item -> item instanceof Map + ? StrUtil.blankToDefault(MapUtil.getStr((Map) item, "id"), null) : null); + } + + /** + * 执行 OpenAI 兼容接口请求,地址非法、网络异常时记录原因 + * + * @param method 请求方法 + * @param url 请求地址 + * @param apiKey API 密钥 + * @param headers 请求附属 Header + * @param body JSON 请求体,可为空 + * @return 请求结果 + */ + private static HttpOutcome executeRequest(Method method, String url, String apiKey, + List> headers, String body) { + try { + HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); + if (body != null) { + request.body(body, ContentType.JSON.getValue()); + } + try (HttpResponse response = request.execute()) { + return new HttpOutcome(response.getStatus(), null, response.body()); + } + } catch (Exception e) { + return new HttpOutcome(0, e.getMessage(), null); + } + } + + /** + * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态附属 Header,包含 {session} 占位符的 Header 跳过 + * + * @param method 请求方法 + * @param url 请求地址 + * @param apiKey API 密钥 + * @param headers 请求附属 Header + * @return HTTP 请求 + */ + private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, List> headers) { + HttpRequest request = HttpRequest.of(url).method(method) + .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) + .header(Header.ACCEPT, ContentType.JSON.getValue()); + if (StrUtil.isNotBlank(apiKey)) { + request.bearerAuth(apiKey); + } + if (CollUtil.isNotEmpty(headers)) { + for (Map header : headers) { + String key = header.get("key"); + String value = header.get("value"); + if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { + continue; + } + request.header(key.trim(), StrUtil.nullToEmpty(value)); + } + } + return request; + } + + /** + * 认证失败:HTTP 401 未授权、403 禁止访问 + */ + private static boolean isAuthFail(int httpCode) { + return httpCode == HttpStatus.HTTP_UNAUTHORIZED || httpCode == HttpStatus.HTTP_FORBIDDEN; + } + + private static Ai1ProviderConnectRespVO buildConnectResult(boolean connectable, int httpCode, String message, long startTime) { + return new Ai1ProviderConnectRespVO().setConnectable(connectable).setHttpCode(httpCode).setMessage(message) + .setElapsedMs(System.currentTimeMillis() - startTime); + } + + /** + * HTTP 请求结果 + */ + @Data + @AllArgsConstructor + private static class HttpOutcome { + + /** + * HTTP 状态码,0 表示网络异常未收到响应 + */ + private int httpCode; + /** + * 异常信息 + */ + private String message; + /** + * 响应体 + */ + private String body; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/package-info.java new file mode 100644 index 0000000..0722d06 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/package-info.java @@ -0,0 +1,7 @@ +/** + * Agent 运行时支撑: + * + * 这里是平台去调用大模型、MCP 服务、知识库和 SKILL 的客户端与工厂, + * 不是注册给模型调用的 function tool。模型可调用的工具,在会话时由 MCP ToolCallback 和 SKILL 另行装配。 + */ +package cn.iocoder.yudao.module.ai1.harness; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagTool.java new file mode 100644 index 0000000..d5f1e90 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagTool.java @@ -0,0 +1,345 @@ +package cn.iocoder.yudao.module.ai1.harness.rag; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.loader.LazyFunLoader; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import io.milvus.client.MilvusServiceClient; +import io.milvus.param.ConnectParam; +import io.milvus.param.IndexType; +import io.milvus.param.MetricType; +import io.milvus.param.R; +import io.milvus.param.RpcStatus; +import io.milvus.param.collection.DropCollectionParam; +import jakarta.annotation.PreDestroy; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; +import org.springframework.ai.vectorstore.milvus.MilvusVectorStore; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_EMBEDDING; + +/** + * AI1 RAG 工具(Spring AI + Milvus) + * + * 知识库的向量化、检索、清理与 Advisor 装配: + * 1. 向量化:分片 → Document(含 documentId、chunkIndex 元数据)→ VectorStore.add(先清理旧向量) + * 2. 检索:VectorStore.similaritySearch + * 3. 清理:按 documentId 元数据删除向量;知识库删除时 drop 整个集合 + * 4. Advisor:为会话装配 QuestionAnswerAdvisor,检索上下文自动注入 + * + * 集合名为 {collectionPrefix}{知识库编号}(编号全局唯一,无需租户前缀);调用方必须先在当前租户下查出知识库,再传入本工具, + * 以此保证不会越权访问其他租户的集合 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1RagTool { + + /** + * 向量存储缓存上限 + */ + private static final int CACHE_MAX = 128; + /** + * 向量元数据:文档编号 + */ + private static final String METADATA_DOCUMENT_ID = "documentId"; + /** + * 向量元数据:分片序号 + */ + private static final String METADATA_CHUNK_INDEX = "chunkIndex"; + + @Resource + private YudaoAi1Properties ai1Properties; + + @Resource + private Ai1ModelService modelService; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + /** + * 向量存储缓存:知识库编号 → 向量存储;按访问顺序淘汰,嵌入模型实例变化(配置变更后重建)时自动重建 + */ + private final Cache vectorStoreCache = CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX).build(); + /** + * 按知识库串行初始化的锁,避免并发首访重复创建集合和索引 + */ + private final Map vectorStoreLocks = new ConcurrentHashMap<>(); + /** + * Milvus 客户端:首次使用时创建,全局复用一个连接 + */ + private final LazyFunLoader milvusClientLoader = LazyFunLoader.on(this::createMilvusClient); + + /** + * 文档向量化:分片 → 嵌入 → 写入 Milvus;先清理旧向量再写入,支持重复向量化 + * + * @param knowledgeBase 知识库(需已配置嵌入模型) + * @param documentId 文档编号 + * @param content 文档内容 + * @return 分片数量 + */ + public int vectorize(Ai1KnowledgeBaseDO knowledgeBase, Long documentId, String content) { + // 1. 分片 + List chunks = split(content, knowledgeBase.getChunkSize(), knowledgeBase.getChunkOverlap()); + if (CollUtil.isEmpty(chunks)) { + throw new IllegalArgumentException("文档无可分片内容"); + } + + // 2. 清理旧向量后写入 + MilvusVectorStore vectorStore = getOrCreateVectorStore(knowledgeBase); + vectorStore.delete(new FilterExpressionBuilder().eq(METADATA_DOCUMENT_ID, documentId).build()); + List documents = new ArrayList<>(chunks.size()); + for (int i = 0; i < chunks.size(); i++) { + Map metadata = new LinkedHashMap<>(); + metadata.put(METADATA_DOCUMENT_ID, documentId); + metadata.put(METADATA_CHUNK_INDEX, i); + documents.add(new Document(chunks.get(i), metadata)); + } + vectorStore.add(documents); + return chunks.size(); + } + + /** + * 向量检索:按查询文本召回知识库相关分片 + * + * @param knowledgeBase 知识库 + * @param query 查询文本 + * @param topK 检索数量,小于等于 0 时使用知识库配置 + * @return 命中分片;文档名称由调用方按文档编号补充 + */ + public List search(Ai1KnowledgeBaseDO knowledgeBase, String query, Integer topK) { + // 1. 相似度检索:未指定检索数量时,使用知识库配置 + MilvusVectorStore vectorStore = getOrCreateVectorStore(knowledgeBase); + List documents = vectorStore.similaritySearch(SearchRequest.builder() + .query(query).topK(topK != null && topK > 0 ? topK : getDefaultTopK(knowledgeBase)).build()); + + // 2. 转换为命中分片:从向量元数据中还原文档编号、分片序号 + return convertList(documents, document -> new Ai1KnowledgeSearchRespVO().setText(document.getText()) + .setDocumentId(Convert.toLong(document.getMetadata().get(METADATA_DOCUMENT_ID))) + .setChunkIndex(Convert.toInt(document.getMetadata().get(METADATA_CHUNK_INDEX))) + .setScore(ObjUtil.defaultIfNull(document.getScore(), 0D))); + } + + /** + * 按文档编号清理向量(文档删除、内容变更时调用);失败时仅记录日志 + * + * @param knowledgeBase 知识库 + * @param documentIds 文档编号集合 + */ + public void deleteByDocumentIds(Ai1KnowledgeBaseDO knowledgeBase, Collection documentIds) { + if (CollUtil.isEmpty(documentIds)) { + return; + } + try { + getOrCreateVectorStore(knowledgeBase).delete(new FilterExpressionBuilder() + .in(METADATA_DOCUMENT_ID, documentIds.toArray()).build()); + } catch (Exception e) { + log.warn("[deleteByDocumentIds][知识库({}) 文档({}) 向量清理失败]", knowledgeBase.getId(), documentIds, e); + } + } + + /** + * 删除知识库的整个向量集合(知识库删除时调用,避免遗留孤儿集合);失败时仅记录日志 + * + * @param knowledgeBaseId 知识库编号 + */ + public void dropCollection(Long knowledgeBaseId) { + evict(knowledgeBaseId); + try { + R result = milvusClientLoader.get().dropCollection(DropCollectionParam.newBuilder() + .withDatabaseName(ai1Properties.getMilvus().getDatabase()) + .withCollectionName(buildCollectionName(knowledgeBaseId)) + .build()); + if (result.getStatus() != R.Status.Success.getCode()) { + log.warn("[dropCollection][知识库({}) 向量集合删除失败:{}]", knowledgeBaseId, result.getMessage()); + } + } catch (Exception e) { + log.warn("[dropCollection][知识库({}) 向量集合删除异常]", knowledgeBaseId, e); + } + } + + /** + * 为知识库构建 RAG Advisor:检索上下文自动注入会话 + * + * @param knowledgeBase 知识库 + * @return RAG Advisor + */ + public Advisor buildAdvisor(Ai1KnowledgeBaseDO knowledgeBase) { + MilvusVectorStore vectorStore = getOrCreateVectorStore(knowledgeBase); + SearchRequest searchRequest = SearchRequest.builder().topK(getDefaultTopK(knowledgeBase)).build(); + return QuestionAnswerAdvisor.builder(vectorStore).searchRequest(searchRequest).build(); + } + + /** + * 失效知识库的向量存储缓存 + * + * 只作用于当前节点;其他节点在嵌入模型变更后,会因嵌入模型实例不一致自然重建 + * + * @param knowledgeBaseId 知识库编号 + */ + public void evict(Long knowledgeBaseId) { + vectorStoreCache.invalidate(knowledgeBaseId); + } + + @PreDestroy + public void destroy() { + milvusClientLoader.ifInitialized(MilvusServiceClient::close); + } + + // ==================== 内部实现:嵌入模型 / 向量存储 / 分片 ==================== + + /** + * 获取(或构建)知识库向量存储:嵌入模型实例不变时复用;Provider、模型配置变更后模型工厂会重建实例,此处随之重建 + */ + private MilvusVectorStore getOrCreateVectorStore(Ai1KnowledgeBaseDO knowledgeBase) { + // 1. 缓存命中、且嵌入模型实例未变化时,直接复用 + EmbeddingModel embeddingModel = getEmbeddingModel(knowledgeBase); + CachedVectorStore cached = vectorStoreCache.getIfPresent(knowledgeBase.getId()); + if (cached != null && cached.getEmbeddingModel() == embeddingModel) { + return cached.getVectorStore(); + } + + // 2. 缓存未命中或嵌入模型实例变化时,构建新的向量存储;按知识库串行初始化,避免并发首访重复创建集合和索引 + synchronized (vectorStoreLocks.computeIfAbsent(knowledgeBase.getId(), key -> new Object())) { + cached = vectorStoreCache.getIfPresent(knowledgeBase.getId()); + if (cached != null && cached.getEmbeddingModel() == embeddingModel) { + return cached.getVectorStore(); + } + // 2.1 构建向量存储:集合不存在时自动创建 + String collectionName = buildCollectionName(knowledgeBase.getId()); + MilvusVectorStore vectorStore = MilvusVectorStore.builder(milvusClientLoader.get(), embeddingModel) + .databaseName(ai1Properties.getMilvus().getDatabase()) + .collectionName(collectionName) + .metricType(MetricType.COSINE) + .indexType(IndexType.FLAT) + .initializeSchema(true) + .build(); + // 2.2 手动构建的实例不会触发 Spring 生命周期回调,需主动初始化:建集合、建索引、加载 + try { + vectorStore.afterPropertiesSet(); + } catch (Exception e) { + throw new IllegalStateException("向量集合(" + collectionName + ") 初始化失败:" + e.getMessage(), e); + } + // 2.3 放入缓存 + vectorStoreCache.put(knowledgeBase.getId(), new CachedVectorStore() + .setEmbeddingModel(embeddingModel).setVectorStore(vectorStore)); + log.debug("[getOrCreateVectorStore][知识库({}) 向量存储构建完成]", knowledgeBase.getId()); + return vectorStore; + } + } + + /** + * 解析知识库的嵌入模型:Provider、模型不存在或被禁用时抛出业务异常 + */ + private EmbeddingModel getEmbeddingModel(Ai1KnowledgeBaseDO knowledgeBase) { + Ai1ModelRespBO model = modelService.getModelRespBO( + knowledgeBase.getEmbeddingProviderId(), knowledgeBase.getEmbeddingModelId()); + if (!Ai1ModelTypeEnum.isEmbedding(model.getModelType())) { + throw exception(MODEL_TYPE_NOT_EMBEDDING); + } + return llmModelFactory.getOrCreateEmbeddingModel(model); + } + + /** + * 创建 Milvus 客户端:配置了用户名时,开启鉴权 + */ + private MilvusServiceClient createMilvusClient() { + YudaoAi1Properties.Milvus milvus = ai1Properties.getMilvus(); + ConnectParam.Builder builder = ConnectParam.newBuilder() + .withUri(milvus.getUri()) + .withDatabaseName(milvus.getDatabase()); + if (StrUtil.isNotBlank(milvus.getUsername())) { + builder.withAuthorization(milvus.getUsername(), milvus.getPassword()); + } + return new MilvusServiceClient(builder.build()); + } + + /** + * 构建知识库的向量集合名:一个知识库对应一个集合 + * + * 不同知识库可能使用不同的嵌入模型,向量维度不同,无法共用一个集合;后续也可能落到不同的向量库,按知识库隔离更便于迁移和整体删除 + */ + private String buildCollectionName(Long knowledgeBaseId) { + return ai1Properties.getMilvus().getCollectionPrefix() + knowledgeBaseId; + } + + /** + * 获得知识库的检索数量:知识库未配置时,使用 yudao.ai1.milvus.default-top-k 配置 + */ + private int getDefaultTopK(Ai1KnowledgeBaseDO knowledgeBase) { + return knowledgeBase.getTopK() != null && knowledgeBase.getTopK() > 0 ? knowledgeBase.getTopK() + : ai1Properties.getMilvus().getDefaultTopK(); + } + + /** + * 文本分片:按 chunkSize、chunkOverlap 做字符级分片 + * + * @param content 文本 + * @param chunkSize 分片大小 + * @param chunkOverlap 分片重叠,限制在 [0, chunkSize - 1] + * @return 分片列表 + */ + @SuppressWarnings("MathClampMigration") + static List split(String content, Integer chunkSize, Integer chunkOverlap) { + List chunks = new ArrayList<>(); + if (StrUtil.isEmpty(content)) { + return chunks; + } + int size = Math.max(ObjUtil.defaultIfNull(chunkSize, 0), 1); + int overlap = Math.min(Math.max(ObjUtil.defaultIfNull(chunkOverlap, 0), 0), size - 1); + int length = content.length(); + int start = 0; + while (start < length) { + int end = Math.min(start + size, length); + chunks.add(content.substring(start, end)); + if (end >= length) { + break; + } + start = end - overlap; + } + return chunks; + } + + /** + * 向量存储缓存项 + */ + @Data + private static class CachedVectorStore { + + /** + * 构建时使用的嵌入模型实例,用于判断是否需要重建 + */ + private EmbeddingModel embeddingModel; + /** + * 向量存储 + */ + private MilvusVectorStore vectorStore; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java new file mode 100644 index 0000000..31801ff --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java @@ -0,0 +1,847 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.thread.ExecutorBuilder; +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants; +import cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionStreamEventEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Lazy; +import org.redisson.api.RStream; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.*; +import org.redisson.client.codec.StringCodec; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * AI1 会话流工具:会话流的「投递 / 续传入口 + 生成 worker + 结果流 + SSE 转发」一体 + * + * 【完整流程】 + * 1. 发送(请求侧,由 Ai1SessionMessageService 调用):{@link #submit} 投递任务(XADD 任务队列)→ {@link #open} 打开 SSE 连接 + * 2. 生成(worker 侧,{@link #start} 拉起的消费线程):{@link #consumeLoop} → {@link #handleTask} → {@link #generate}; + * 增量写入结果流,结束后回填助手消息并 XACK + * 3. 下发与续传(连接侧):{@link #streamResult} 从结果流 XREAD 转发为 SSE;携带 lastEventId 时从断点之后重放,不重新生成 + * + * 【关键约定】 + * 1. 助手消息编号同时作为结果流标识;生成与连接解耦,任意节点都可以转发、续传,worker 可独立扩容 + * 2. 投递时存在租户则携带 tenantId,worker 统一在 {@link TenantUtils#execute(Long, Runnable)} 中执行;关闭多租户时不携带,按空租户上下文执行; + * 禁止使用 executeIgnore(关闭租户过滤会导致跨租户读取) + * 3. SSE 事件的 data 统一为 JSON 字符串,避免换行、首字符空格在 SSE 协议中被截断 + * 4. 使用 Redisson 原生 RStream + StringCodec:字段按纯字符串存储(项目 RedisTemplate 的 JSON 序列化会破坏 Stream 字段); + * 不使用 Spring Data 的 opsForStream,因为 Redisson 适配层在阻塞读取无数据时会抛 ClassCastException, + * 且阻塞时长超过命令超时会占满连接池 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1SessionStreamTool implements SmartLifecycle { + + /** + * SSE 转发线程名前缀 + */ + private static final String SSE_THREAD_NAME_PREFIX = "ai1-session-sse-"; + /** + * 生成 worker 线程名前缀,完整线程名为「前缀 + 消费者名」 + */ + private static final String WORKER_THREAD_NAME_PREFIX = "ai1-session-"; + + /** + * 任务队列最大长度(近似裁剪) + */ + private static final int TASK_STREAM_MAX_LENGTH = 5000; + /** + * XREAD 阻塞窗口,单位:毫秒;即空闲心跳间隔,需小于 Redis 超时时间 + */ + private static final long READ_BLOCK_MILLIS = 5000; + /** + * 结果流单次读取条数 + */ + private static final int READ_BATCH_SIZE = 50; + /** + * 超时任务认领间隔,单位:毫秒 + */ + private static final long RECLAIM_INTERVAL_MILLIS = 60_000; + /** + * 超时任务认领的空闲阈值缓冲,单位:毫秒;空闲阈值 = SSE 连接超时 + 缓冲,执行锁另行保护在途任务 + */ + private static final long RECLAIM_IDLE_BUFFER_MILLIS = 60_000; + /** + * 超时任务单次认领条数 + */ + private static final int RECLAIM_BATCH_SIZE = 100; + /** + * worker 消费异常后的退避时长,单位:毫秒 + */ + private static final long CONSUME_ERROR_BACKOFF_MILLIS = 1000; + /** + * worker 停止时,等待单个消费线程退出的最长时长,单位:毫秒 + */ + private static final long WORKER_JOIN_TIMEOUT_MILLIS = 3000; + /** + * 结果流连续读取失败的最大次数,达到后中断转发 + */ + private static final int READ_ERROR_MAX_COUNT = 3; + /** + * 结果流读取失败后的重试间隔,单位:毫秒 + */ + private static final long READ_ERROR_BACKOFF_MILLIS = 500; + + /** + * 结果流丢失判定:转发到连接超时前该时长,结果流仍不存在时,判定任务丢失(预留两个阻塞读窗口,保证 error 能在连接超时前送达) + */ + private static final long LOST_CHECK_ADVANCE_MILLIS = READ_BLOCK_MILLIS * 2; + + /** + * 结果流丢失时的错误提示 + */ + static final String LOST_MESSAGE = "生成任务已丢失,请重新发送"; + + /** + * worker 中断后的错误提示;保留部分内容,不重新生成以免输出或工具调用重复 + */ + static final String INTERRUPTED_MESSAGE = "生成任务已中断,请重新发送"; + + private static final String FIELD_MESSAGE_ID = "messageId"; + private static final String FIELD_AGENT_ID = "agentId"; + private static final String FIELD_SESSION_ID = "sessionId"; + private static final String FIELD_CONTENT = "content"; + private static final String FIELD_TENANT_ID = "tenantId"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_DATA = "data"; + + @Resource + private RedissonClient redissonClient; + @Resource + private YudaoAi1Properties ai1Properties; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionMessageService sessionMessageService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionService sessionService; + @Resource + private Ai1AgentService agentService; + @Resource + private Ai1ModelService modelService; + @Resource + private Ai1KnowledgeBaseService knowledgeBaseService; + + @Resource + private Ai1LlmChatTool llmChatTool; + @Resource + private Ai1RagTool ragTool; + + @Resource + private Ai1McpToolFactory mcpToolFactory; + @Resource + private Ai1SkillToolFactory skillToolFactory; + + /** + * 结果流 TTL 上次续期时刻:助手消息编号 → 毫秒时间戳,用于 EXPIRE 节流 + */ + private final Map resultExpireTimes = new ConcurrentHashMap<>(); + /** + * 实例标识,消费者名按「实例 + 序号」隔离 + */ + private final String instanceId = UUID.randomUUID().toString().substring(0, 8); + /** + * 生成 worker 消费线程列表,停止时逐个等待退出 + */ + private final List workers = new ArrayList<>(); + /** + * 是否运行中:worker 主循环以此判断是否继续消费,停止时置为 false + */ + private volatile boolean running = false; + /** + * SSE 转发线程池:单个转发阻塞于 XREAD 直到流结束,用有界池控制并发 + */ + private ExecutorService sseExecutor; + + // ==================== 生命周期 ==================== + + /** + * 启动:建 SSE 转发线程池 → 初始化任务消费组 → 拉起 worker 消费线程 + * + * 使用 SmartLifecycle 而非 @PostConstruct:worker 依赖其他 Bean 与 Redis,需在容器刷新完成后启动 + */ + @Override + public void start() { + // 1. 创建 SSE 转发线程池 + // 使用 SynchronousQueue(队列容量为 0):否则线程池在队列未满前不会扩容到 maxPoolSize,且长连接排队同样不可接受; + // 并发已满时直接拒绝(AbortPolicy),由 open 返回“服务繁忙” + // 使用 Hutool ExecutorBuilder 构建线程池 + int maxConnections = ai1Properties.getSession().getSse().getMaxConnections(); + // 线程名前缀见 SSE_THREAD_NAME_PREFIX + sseExecutor = ExecutorBuilder.create() + .setCorePoolSize(Math.max(1, maxConnections / 8)).setMaxPoolSize(maxConnections) + .useSynchronousQueue() + .setThreadFactory(ThreadUtil.newNamedThreadFactory(SSE_THREAD_NAME_PREFIX, false)) + .build(); + + // 2. 初始化任务消费组 + initTaskGroup(); + + // 3. 拉起 worker 消费线程,消费者名按「实例 + 序号」隔离 + running = true; + int workerCount = ai1Properties.getSession().getWorker().getCount(); + for (int i = 0; i < workerCount; i++) { + String consumer = "worker-" + instanceId + "-" + i; + Thread thread = ThreadUtil.newThread(() -> consumeLoop(consumer), WORKER_THREAD_NAME_PREFIX + consumer, true); + workers.add(thread); + thread.start(); + } + log.info("[start][Chat worker 启动完成,实例({}) 数量({})]", instanceId, workerCount); + } + + /** + * 停止:置停止标志,等待各消费线程退出(每个最多 3s),最后关闭 SSE 转发线程池 + */ + @Override + public void stop() { + running = false; + for (Thread worker : workers) { + try { + worker.join(WORKER_JOIN_TIMEOUT_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + workers.clear(); + if (sseExecutor != null) { + sseExecutor.shutdown(); + } + log.info("[stop][Chat worker 已停止,实例({})]", instanceId); + } + + @Override + public boolean isRunning() { + return running; + } + + /** + * 初始化任务消费组(幂等):Stream 不存在时一并创建(MKSTREAM),消费组已存在(BUSYGROUP)视为正常 + */ + private void initTaskGroup() { + try { + getTaskStream().createGroup(StreamCreateGroupArgs.name(Ai1RedisKeyConstants.SESSION_TASK_GROUP).id(StreamMessageId.ALL).makeStream()); + } catch (Exception e) { + if (!isBusyGroup(e)) { + log.warn("[initTaskGroup][Chat 任务消费组初始化失败]", e); + } + } + } + + private static boolean isBusyGroup(Throwable e) { + for (Throwable cause = e; cause != null; cause = cause.getCause()) { + if (StrUtil.contains(cause.getMessage(), "BUSYGROUP")) { + return true; + } + } + return false; + } + + // ==================== 投递 / 连接入口 ==================== + + /** + * 投递生成任务 + * + * @param messageId 助手消息编号,即结果流标识 + * @param agentId Agent 编号 + * @param sessionId 会话编号 + * @param content 用户提问内容 + */ + public void submit(Long messageId, Long agentId, Long sessionId, String content) { + // 1. 组装任务字段 + Map fields = new LinkedHashMap<>(); + // 1.1 租户可选:存在租户才携带,关闭多租户时 worker 直接执行 + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + fields.put(FIELD_TENANT_ID, String.valueOf(tenantId)); + } + // 1.2 生成所需的业务字段 + fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); + fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); + // 生成时,附属 Header 中的 {session} 占位符替换为此处的会话编号 + fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); + fields.put(FIELD_CONTENT, content); + + // 2. 投递到任务队列,超过最大长度时近似裁剪最早的任务 + getTaskStream().add(StreamAddArgs.entries(fields).trimNonStrict().maxLen(TASK_STREAM_MAX_LENGTH).noLimit()); + } + + /** + * 打开 SSE 连接:从结果流 lastEventId 之后转发(为空则从头重放),生成由 worker 异步完成 + * + * @param messageId 助手消息编号 + * @param lastEventId 已收到的最后一个事件编号,可为空 + * @return SSE 连接 + */ + public SseEmitter open(Long messageId, String lastEventId) { + SseEmitter emitter = new SseEmitter(ai1Properties.getSession().getStream().getTimeoutMs()); + // 转发线程没有租户上下文,判定任务丢失后回写消息时使用 + Long tenantId = TenantContextHolder.getTenantId(); + try { + sseExecutor.execute(() -> { + try { + // 发送失败后由 Servlet 容器清理连接,不能再次 complete,避免与请求回收竞争 + if (streamResult(emitter, messageId, lastEventId, tenantId)) { + emitter.complete(); + } + } catch (Exception e) { + log.warn("[open][助手消息({}) 会话流转发异常]", messageId, e); + sendErrorAndComplete(emitter, e.getMessage()); + } + }); + } catch (RejectedExecutionException e) { + log.warn("[open][会话流转发并发已满,助手消息({})]", messageId); + sendErrorAndComplete(emitter, "服务繁忙,请稍后重试"); + } + return emitter; + } + + /** + * 返回只包含 error 终态的 SSE 连接,用于请求校验失败等场景 + * + * @param message 错误提示 + * @return SSE 连接 + */ + public SseEmitter error(String message) { + SseEmitter emitter = new SseEmitter(ai1Properties.getSession().getStream().getTimeoutMs()); + sendErrorAndComplete(emitter, message); + return emitter; + } + + // ==================== worker 消费 ==================== + + /** + * worker 主循环:周期性认领超时遗留任务,其余时间阻塞消费新任务;异常时退避 1s,保证长期稳定 + */ + private void consumeLoop(String consumer) { + long lastReclaimTime = 0L; + while (running) { + try { + // 1. 到达认领间隔时,先接管其他 worker 遗留的超时任务 + long now = System.currentTimeMillis(); + if (now - lastReclaimTime >= RECLAIM_INTERVAL_MILLIS) { + lastReclaimTime = now; + reclaimTasks(consumer).forEach((recordId, fields) -> handleTask(recordId, fields, true)); + } + + // 2. 阻塞读取从未投递过的新任务,每次一条,处理完再读取下一条 + Map> records = getTaskStream().readGroup(Ai1RedisKeyConstants.SESSION_TASK_GROUP, consumer, + StreamReadGroupArgs.neverDelivered().count(1).timeout(Duration.ofMillis(READ_BLOCK_MILLIS))); + if (records != null) { + records.forEach(this::handleTask); + } + } catch (Throwable e) { + // 3. 已停止时直接退出;否则记录异常并退避,避免 Redis 故障时空转 + if (!running) { + return; + } + log.warn("[consumeLoop][Chat worker({}) 消费异常]", consumer, e); + ThreadUtil.sleep(CONSUME_ERROR_BACKOFF_MILLIS); + } + } + } + + /** + * 认领长时间未确认的任务;PEL 空闲时长不代表生成已停止,接管后仍需获取消息执行锁 + */ + private Map> reclaimTasks(String consumer) { + Duration minIdle = Duration.ofMillis(ai1Properties.getSession().getStream().getTimeoutMs() + RECLAIM_IDLE_BUFFER_MILLIS); + try { + // 1. 查询空闲超过阈值的待确认任务 + RStream taskStream = getTaskStream(); + List pendingEntries = taskStream.listPending(StreamPendingRangeArgs.groupName(Ai1RedisKeyConstants.SESSION_TASK_GROUP) + .startId(StreamMessageId.MIN).endId(StreamMessageId.MAX).count(RECLAIM_BATCH_SIZE).idleTime(minIdle)); + if (CollUtil.isEmpty(pendingEntries)) { + return Collections.emptyMap(); + } + + // 2. 认领到当前消费者:认领时再次校验空闲时长,避免与其他节点重复认领 + StreamMessageId[] ids = pendingEntries.stream().map(PendingEntry::getId).toArray(StreamMessageId[]::new); + return taskStream.claim(Ai1RedisKeyConstants.SESSION_TASK_GROUP, consumer, minIdle.toMillis(), TimeUnit.MILLISECONDS, ids); + } catch (Exception e) { + log.warn("[reclaimTasks][Chat 任务认领失败]", e); + return Collections.emptyMap(); + } + } + + /** + * 消费单条新任务 + */ + void handleTask(StreamMessageId recordId, Map fields) { + handleTask(recordId, fields, false); + } + + /** + * 消费任务:执行锁保护生成与回填;已完成的任务直接确认,中断的任务保留部分内容并标记失败 + */ + void handleTask(StreamMessageId recordId, Map fields, boolean reclaimed) { + // 1. 解析任务:没有消息编号的异常记录,直接确认丢弃 + Long messageId = Convert.toLong(fields.get(FIELD_MESSAGE_ID)); + if (messageId == null) { + ackTask(recordId); + return; + } + // 2. 看门狗为长任务自动续期;认领到在途任务时跳过,不确认,交由原 worker 完成 + RLock lock = redissonClient.getLock(String.format(Ai1RedisKeyConstants.SESSION_TASK_LOCK, messageId)); + if (!lock.tryLock()) { + return; + } + long workerThreadId = Thread.currentThread().threadId(); + try { + Long tenantId = Convert.toLong(fields.get(FIELD_TENANT_ID)); + TenantUtils.execute(tenantId, () -> executeTask(recordId, fields, messageId, lock, workerThreadId, reclaimed)); + } catch (Exception e) { + // Redis 或回填失败时保留 pending,后续接管不会重新生成 + log.error("[handleTask][助手消息({}) 处理失败]", messageId, e); + } finally { + resultExpireTimes.remove(messageId); + try { + if (lock.isHeldByThread(workerThreadId)) { + lock.unlock(); + } + } catch (Exception e) { + log.warn("[handleTask][助手消息({}) 执行锁释放失败]", messageId, e); + } + } + } + + /** + * 持有执行锁后处理任务:清理异常记录、补齐已完成任务、收尾中断任务,或执行新一轮生成 + */ + private void executeTask(StreamMessageId recordId, Map fields, Long messageId, + RLock lock, long workerThreadId, boolean reclaimed) { + // 1.1 查询助手消息,不存在或不是助手角色时确认丢弃 + Ai1SessionMessageDO message = sessionMessageService.getSessionMessage(messageId); + if (message == null || !Ai1SessionMessageRoleEnum.isAssistant(message.getRole())) { + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + return; + } + // 1.2 回填成功后 XACK 可能失败,补齐缺失终态后确认,不再调用模型 + if (!Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) { + completeTaskResult(message, lock, workerThreadId); + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + return; + } + // 1.3 接管任务不重跑,保留结果流中的部分输出;新任务已有结果时同样按中断处理 + Long sessionId = Convert.toLong(fields.get(FIELD_SESSION_ID)); + if (reclaimed || getResultStream(messageId).isExists()) { + // 1.3.1 按结果流顺序恢复已产生的正文和思考 + Ai1LlmChatTool.ChatText partialText = readPartialResult(messageId); + // 1.3.2 回填部分结果并标记失败;每次写入前校验执行权,防止旧 worker 继续写入 + checkTaskLock(lock, workerThreadId); + saveAssistantMessage(messageId, sessionId, partialText, Ai1SessionMessageStatusEnum.FAILED.getStatus()); + // 1.3.3 写入错误提示和结束事件,续传连接可及时退出 + checkTaskLock(lock, workerThreadId); + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE); + checkTaskLock(lock, workerThreadId); + appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), ""); + // 1.3.4 确认任务;以上任一步失败时保留 pending,交由后续接管收尾 + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + return; + } + + // 2. 解析任务字段 + Long agentId = Convert.toLong(fields.get(FIELD_AGENT_ID)); + String content = fields.get(FIELD_CONTENT); + + // 3. 生成并回填;增量回调可能在模型线程执行,因此按 worker 线程编号校验锁归属 + StringBuilder contentText = new StringBuilder(); + StringBuilder thinkingText = new StringBuilder(); + Consumer onThinking = delta -> { + checkTaskLock(lock, workerThreadId); + thinkingText.append(delta); + appendResult(messageId, Ai1SessionStreamEventEnum.THINKING.getEvent(), delta); + }; + Consumer onContent = delta -> { + checkTaskLock(lock, workerThreadId); + contentText.append(delta); + appendResult(messageId, Ai1SessionStreamEventEnum.MESSAGE.getEvent(), delta); + }; + // 3.1 标记生成开始,再调用模型并回填完整结果 + try { + checkTaskLock(lock, workerThreadId); + // 标记生成已开始;长思考没有增量时,续传也不会误判结果流丢失 + appendResult(messageId, Ai1SessionStreamEventEnum.PING.getEvent(), ""); + Ai1LlmChatTool.ChatText chatText = generate(messageId, agentId, sessionId, content, onThinking, onContent); + checkTaskLock(lock, workerThreadId); + saveAssistantMessage(messageId, sessionId, chatText, Ai1SessionMessageStatusEnum.SUCCESS.getStatus()); + } catch (Exception e) { + // 失去锁的旧 worker 不得再写结果、终态或 XACK,留给新持有者处理 + if (!lock.isHeldByThread(workerThreadId)) { + return; + } + // 3.2 生成失败时下发错误,保留本轮已产生的增量并回填失败状态 + log.warn("[handleTask][助手消息({}) 生成失败]", messageId, e); + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), StrUtil.blankToDefault(e.getMessage(), "生成失败")); + checkTaskLock(lock, workerThreadId); + saveAssistantMessage(messageId, sessionId, new Ai1LlmChatTool.ChatText(contentText.toString(), + thinkingText.toString()), Ai1SessionMessageStatusEnum.FAILED.getStatus()); + } + // 4. 回填后写终态并确认;失败时由后续接管清理,不能在 finally 中无条件确认 + checkTaskLock(lock, workerThreadId); + appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), ""); + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + } + + /** + * 已回填任务的终态补偿:只补齐缺失的 error/done,不再次生成或回填消息 + * + * 数据库回填与结果流写入不是同一事务,worker 可能在两者之间退出。 + */ + private void completeTaskResult(Ai1SessionMessageDO message, RLock lock, long workerThreadId) { + // 1. 只查询最后一条事件,已有 done 则无需再补写 + Map> last = getResultStream(message.getId()) + .rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1)); + String lastType = last.isEmpty() ? null : CollUtil.getFirst(last.values()).get(FIELD_TYPE); + if (Ai1SessionStreamEventEnum.DONE.getEvent().equals(lastType)) { + return; + } + // 2. 失败消息缺少 error 时补写错误提示,末尾已有 error 时不重复写入 + if (Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()) + && !Ai1SessionStreamEventEnum.ERROR.getEvent().equals(lastType)) { + checkTaskLock(lock, workerThreadId); + appendResult(message.getId(), Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE); + } + // 3. 补写 done,使续传连接能够结束;调用方在补齐后再确认任务 + checkTaskLock(lock, workerThreadId); + appendResult(message.getId(), Ai1SessionStreamEventEnum.DONE.getEvent(), ""); + } + + /** + * 校验执行锁仍属于原 worker;增量回调可能在模型线程执行,因此不能使用当前回调线程编号 + * + * 失去执行权时抛出异常,中止旧 worker 的后续写入,由新持有者收尾。 + */ + private static void checkTaskLock(RLock lock, long workerThreadId) { + if (!lock.isHeldByThread(workerThreadId)) { + throw new IllegalStateException("生成任务执行权已失效"); + } + } + + /** + * 按结果流顺序还原中断任务的正文和思考,忽略心跳、错误提示及结束事件 + */ + private Ai1LlmChatTool.ChatText readPartialResult(Long messageId) { + StringBuilder content = new StringBuilder(); + StringBuilder thinking = new StringBuilder(); + // 读取完整结果流,按条目编号升序拼接各类增量 + getResultStream(messageId).range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX)) + .values().forEach(fields -> { + if (Ai1SessionStreamEventEnum.MESSAGE.getEvent().equals(fields.get(FIELD_TYPE))) { + content.append(StrUtil.nullToEmpty(fields.get(FIELD_DATA))); + } else if (Ai1SessionStreamEventEnum.THINKING.getEvent().equals(fields.get(FIELD_TYPE))) { + thinking.append(StrUtil.nullToEmpty(fields.get(FIELD_DATA))); + } + }); + return new Ai1LlmChatTool.ChatText(content.toString(), thinking.toString()); + } + + private void ackTask(StreamMessageId recordId) { + getTaskStream().ack(Ai1RedisKeyConstants.SESSION_TASK_GROUP, recordId); + } + + // ==================== 生成编排 ==================== + + /** + * 执行一次生成:校验 Agent → 解析模型 → 装配历史、工具、RAG → 流式会话 + * + * 历史只取当前助手占位之前最近 historyLimit 条,跳过未完成的助手占位,并移除末尾的当前提问(由 LLM 工具另行追加) + */ + @SuppressWarnings("SequencedCollectionMethodCanBeUsed") + private Ai1LlmChatTool.ChatText generate(Long messageId, Long agentId, Long sessionId, String content, + Consumer onThinking, Consumer onContent) { + // 1. 生成可能发生在其他节点,重新校验 Agent 与模型;后台会话不要求 Agent 已发布 + Ai1AgentDO agent = agentService.validateAgentExists(agentId); + Ai1ModelRespBO model = modelService.getModelRespBO(agent.getProviderId(), agent.getModelId()); + if (!Ai1ModelTypeEnum.isChat(model.getModelType())) { + throw ServiceExceptionUtil.exception(Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT); + } + + // 2. 历史消息:按编号倒序取最近 N 条,再升序还原为会话顺序 + // TODO @芋艿:【优化点】截取历史的 limit 会导致 prompt cache 频繁失效。后续需要改成 prompt 压缩 + List recentMessages = new ArrayList<>(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan( + sessionId, messageId, ai1Properties.getSession().getHistory().getLimit())); + recentMessages.sort(Comparator.comparing(Ai1SessionMessageDO::getId)); + List histories = new ArrayList<>(); + for (Ai1SessionMessageDO message : recentMessages) { + // 跳过未完成的助手占位(如异常残留),避免把半截回复带入上下文 + if (Ai1SessionMessageRoleEnum.isAssistant(message.getRole()) + && Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) { + continue; + } + histories.add(new String[]{message.getRole(), message.getContent()}); + } + // 末尾为本次刚落库的当前提问,移除以免重复注入 + String[] lastHistory = CollUtil.getLast(histories); + if (lastHistory != null && Ai1SessionMessageRoleEnum.isUser(lastHistory[0])) { + histories.remove(histories.size() - 1); + } + + // 3. 系统指令:未配置时使用默认引导文案 + String systemPrompt = StrUtil.isNotBlank(agent.getSystemPrompt()) ? agent.getSystemPrompt().trim() + : StrUtil.replace(ai1Properties.getSession().getDefaultSystemPrompt(), + YudaoAi1Properties.Session.AGENT_NAME_PLACEHOLDER, agent.getName()); + + // 4. 工具(MCP + SKILL)与 RAG Advisor;知识库不可用时降级跳过,并把提示写入思考流 + List tools = new ArrayList<>(mcpToolFactory.buildTools(agent)); + tools.addAll(skillToolFactory.buildTools(agent)); + StringBuilder noticeText = new StringBuilder(); + List advisors = new ArrayList<>(); + for (Ai1KnowledgeBaseDO knowledgeBase : knowledgeBaseService.getKnowledgeBaseList(agent.getKnowledgeBaseIds())) { + if (CommonStatusEnum.isDisable(knowledgeBase.getStatus())) { + continue; + } + try { + advisors.add(ragTool.buildAdvisor(knowledgeBase)); + } catch (Exception e) { + log.warn("[generate][Agent({}) 知识库({}) RAG 装配失败,已降级跳过]", agent.getId(), knowledgeBase.getId(), e); + String notice = "【知识库降级】「" + knowledgeBase.getName() + "」检索服务不可用,本次会话已跳过 RAG 上下文。\n"; + onThinking.accept(notice); + noticeText.append(notice); + } + } + + // 5. 流式会话;降级提示并入最终思考文本,保证刷新后内容一致 + Ai1LlmChatTool.ChatText chatText = llmChatTool.chat(model, systemPrompt, histories, content, tools, advisors, + sessionId, onThinking, onContent); + if (!noticeText.isEmpty()) { + chatText.setThinking(noticeText + StrUtil.nullToEmpty(chatText.getThinking())); + } + return chatText; + } + + /** + * 回填助手消息(占位记录)的内容、思考过程与生成状态,并刷新会话活跃时间 + */ + private void saveAssistantMessage(Long messageId, Long sessionId, Ai1LlmChatTool.ChatText chatText, Integer status) { + sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(messageId) + .setContent(StrUtil.nullToEmpty(chatText.getContent())) + .setReasoning(StrUtil.emptyToNull(chatText.getThinking())) + .setStatus(status)); + sessionService.touchSession(sessionId); + } + + // ==================== 结果流 ==================== + + /** + * 追加结果流条目并续期 TTL:生成中按半个 TTL 节流续期;终态续期一次完整 TTL 并清理节流状态 + */ + private void appendResult(Long messageId, String type, String data) { + // 1. 追加结果流条目 + RStream resultStream = getResultStream(messageId); + resultStream.add(StreamAddArgs.entries(FIELD_TYPE, type, FIELD_DATA, StrUtil.nullToEmpty(data))); + + // 2. 终态:续期一次完整 TTL,保证续传时间窗从结束时刻起算,并清理节流状态 + Duration ttl = Duration.ofSeconds(ai1Properties.getSession().getStream().getTtlSeconds()); + if (Ai1SessionStreamEventEnum.isTerminal(type)) { + resultStream.expire(ttl); + resultExpireTimes.remove(messageId); + return; + } + + // 3. 生成中:距上次续期超过半个 TTL 才续期,避免每个增量都执行 EXPIRE + long now = System.currentTimeMillis(); + Long lastExpireTime = resultExpireTimes.get(messageId); + if (lastExpireTime == null || now - lastExpireTime >= ttl.toMillis() / 2) { + resultStream.expire(ttl); + resultExpireTimes.put(messageId, now); + } + } + + // ==================== SSE 转发 ==================== + + /** + * SSE 转发主循环:先下发 stream 事件告知结果流标识;随后按条目实时下发,事件编号为结果流条目编号; + * 空闲时下发 ping 心跳;遇到终态、超时、客户端断开、Redis 连续读取失败时退出 + * + * @return 是否需要主动完成连接;断开、超时回调或发送失败时由 Servlet 容器完成清理 + */ + private boolean streamResult(SseEmitter emitter, Long messageId, String lastEventId, Long tenantId) { + // 1. 客户端断开、完成、超时时置取消标志,终止转发循环 + AtomicBoolean cancelled = new AtomicBoolean(false); + emitter.onCompletion(() -> cancelled.set(true)); + emitter.onTimeout(() -> cancelled.set(true)); + emitter.onError(e -> cancelled.set(true)); + + // 2. 先下发 stream 事件,告知前端结果流标识(助手消息编号),用于断线续传 + StreamMessageId fromId = parseStreamMessageId(lastEventId); + long startTime = System.currentTimeMillis(); + long timeoutMs = ai1Properties.getSession().getStream().getTimeoutMs(); + long deadline = startTime + timeoutMs; + long lostCheckTime = startTime + Math.max(timeoutMs - LOST_CHECK_ADVANCE_MILLIS, 0); + boolean lostChecked = false; + int errorCount = 0; + sendEvent(emitter, Ai1SessionStreamEventEnum.STREAM.getEvent(), String.valueOf(messageId), null, cancelled); + + // 3. 循环读取结果流并转发,直到终态、超时或取消 + while (!cancelled.get()) { + // 3.1 超过单连接最长时长,结束转发 + if (System.currentTimeMillis() > deadline) { + log.warn("[streamResult][助手消息({}) 会话流转发超时]", messageId); + return !cancelled.get(); + } + // 3.2 从上次位置之后阻塞读取;读取失败时容错重试,连续失败达到阈值才中断,客户端可再续传 + Map> records; + try { + records = getResultStream(messageId).read(StreamReadArgs.greaterThan(fromId) + .count(READ_BATCH_SIZE).timeout(Duration.ofMillis(READ_BLOCK_MILLIS))); + } catch (Exception e) { + log.warn("[streamResult][助手消息({}) 会话流读取异常]", messageId, e); + if (++errorCount >= READ_ERROR_MAX_COUNT) { + return !cancelled.get(); + } + ThreadUtil.sleep(READ_ERROR_BACKOFF_MILLIS); + continue; + } + errorCount = 0; + // 3.3 阻塞窗口内无新条目时,下发空闲心跳,防止网关、浏览器因长时间无数据断开 + if (MapUtil.isEmpty(records)) { + // 临近连接超时,结果流仍不存在:任务丢失(投递失败、worker 回填失败、结果流已过期等), + // 回写失败并下发 error 终态,避免消息永远停在生成中、前端反复续传 + if (!lostChecked && System.currentTimeMillis() >= lostCheckTime) { + lostChecked = true; + if (!getResultStream(messageId).isExists()) { + log.warn("[streamResult][助手消息({}) 结果流不存在,判定生成任务丢失]", messageId); + failLostMessage(messageId, tenantId); + sendEvent(emitter, Ai1SessionStreamEventEnum.ERROR.getEvent(), LOST_MESSAGE, null, cancelled); + return !cancelled.get(); + } + } + sendEvent(emitter, Ai1SessionStreamEventEnum.PING.getEvent(), "", null, cancelled); + continue; + } + // 3.4 按条目编号顺序下发(Redisson 返回的 Map 有序),事件编号为条目编号;遇到终态结束 + for (Map.Entry> record : records.entrySet()) { + fromId = record.getKey(); + String type = record.getValue().get(FIELD_TYPE); + sendEvent(emitter, type, record.getValue().get(FIELD_DATA), fromId.toString(), cancelled); + if (Ai1SessionStreamEventEnum.isTerminal(type)) { + return !cancelled.get(); + } + } + } + return false; + } + + /** + * 任务丢失时,把仍处于生成中的助手消息回写为失败;已完成或已失败的消息不改动 + */ + void failLostMessage(Long messageId, Long tenantId) { + try { + TenantUtils.execute(tenantId, () -> { + Ai1SessionMessageDO message = sessionMessageService.getSessionMessage(messageId); + if (message == null || !Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) { + return; + } + sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(messageId) + .setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus())); + }); + } catch (Exception e) { + log.error("[failLostMessage][助手消息({}) 回写失败状态失败]", messageId, e); + } + } + + /** + * 发送单个 SSE 事件;发送异常说明客户端已断开,置取消标志以终止转发 + */ + private static void sendEvent(SseEmitter emitter, String event, String data, String id, AtomicBoolean cancelled) { + if (cancelled.get()) { + return; + } + try { + SseEmitter.SseEventBuilder builder = SseEmitter.event().name(event).data(JsonUtils.toJsonString(StrUtil.nullToEmpty(data))); + if (StrUtil.isNotBlank(id)) { + builder.id(id); + } + emitter.send(builder); + } catch (Exception e) { + cancelled.set(true); + } + } + + private static void sendErrorAndComplete(SseEmitter emitter, String message) { + try { + emitter.send(SseEmitter.event().name(Ai1SessionStreamEventEnum.ERROR.getEvent()) + .data(JsonUtils.toJsonString(StrUtil.blankToDefault(message, "生成失败")))); + } catch (Exception ignored) { + // 客户端已断开,由 Servlet 容器清理连接 + return; + } + emitter.complete(); + } + + // ==================== 通用方法 ==================== + + private RStream getTaskStream() { + return redissonClient.getStream(Ai1RedisKeyConstants.SESSION_TASK_STREAM, StringCodec.INSTANCE); + } + + private RStream getResultStream(Long messageId) { + return redissonClient.getStream(String.format(Ai1RedisKeyConstants.SESSION_RESULT_STREAM, messageId), StringCodec.INSTANCE); + } + + /** + * 解析结果流条目编号(格式为 毫秒时间戳-序号);为空或非法时从头读取 + */ + static StreamMessageId parseStreamMessageId(String id) { + if (StrUtil.isBlank(id)) { + return new StreamMessageId(0, 0); + } + List parts = StrUtil.split(id.trim(), '-'); + if (parts.size() != 2 || !NumberUtil.isLong(parts.get(0)) || !NumberUtil.isLong(parts.get(1))) { + return new StreamMessageId(0, 0); + } + return new StreamMessageId(Long.parseLong(parts.get(0)), Long.parseLong(parts.get(1))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java new file mode 100644 index 0000000..8dffdf6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java @@ -0,0 +1,291 @@ +package cn.iocoder.yudao.module.ai1.harness.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillFileService; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import jakarta.annotation.Resource; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springaicommunity.agent.tools.*; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 SKILL 工具工厂(spring-ai-agent-utils SkillsTool) + * + * 把 Agent 绑定的 SKILL(数据库中的文件树)物化为本地技能目录,再构建 SkillsTool 与执行类工具: + *
+ * ${yudao.ai1.skill.root}/
+ * └── agent_{agentId}/              # Agent 级沙箱(Agent 编号全局唯一,无需按租户分层)
+ *     ├── .fingerprint              # 指纹标记,仅用于本地缓存校验
+ *     └── {skillName}/              # 每个 SKILL 一个子目录
+ *         ├── SKILL.md
+ *         ├── scripts/
+ *         └── reference/
+ * 
+ * 变更检测:SKILL 内容变更会刷新其 update_time,这里按「编号:更新时间」计算指纹,不一致时整目录重建 + * (先清空再物化,杜绝换绑、删除后的残留文件) + * + * 注意:执行类工具(Shell、FileSystem 等)会在服务器上执行命令,与源工程行为一致,多租户不可信部署需自行评估 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1SkillToolFactory { + + /** + * 指纹标记文件名 + */ + private static final String FILE_NAME_FINGERPRINT = ".fingerprint"; + + /** + * 快照缓存的最大数量 + */ + private static final int SNAPSHOT_CACHE_MAX_SIZE = 1000; + + @Resource + private YudaoAi1Properties ai1Properties; + + @Resource + private Ai1SkillService skillService; + @Resource + private Ai1SkillFileService skillFileService; + + /** + * Agent 物化快照缓存:Agent 编号 → 快照 + * + * 集群下各节点独立缓存,每次使用前按「编号:更新时间」指纹校验,Agent 换绑或 SKILL 变更后自动重建,无需跨节点失效 + */ + private final Cache snapshotCache = CacheBuilder.newBuilder() + .maximumSize(SNAPSHOT_CACHE_MAX_SIZE).build(); + /** + * 按 Agent 串行物化的锁,避免并发重建互相破坏目录 + */ + private final Map syncLocks = new ConcurrentHashMap<>(); + + /** + * 构建 Agent 绑定 SKILL 所需的全部工具:SkillsTool(注入 SKILL.md 内容)+ 执行类工具(限定于 Agent 物化根目录) + * + * @param agent Agent + * @return 工具列表;未绑定有效 SKILL 时返回空列表 + */ + public List buildTools(Ai1AgentDO agent) { + // 1. 同步物化快照;未绑定有效 SKILL 时,不提供任何工具 + SkillSnapshot snapshot = sync(agent); + if (snapshot == null) { + return Collections.emptyList(); + } + + // 2. SkillsTool:要求至少一个有效 SKILL.md,否则只物化文件,不注册技能工具 + List tools = new ArrayList<>(); + if (snapshot.getSkillsTool() != null) { + tools.add(snapshot.getSkillsTool()); + } + + // 3. 执行类工具:ShellTools(bash)、FileSystemTools(Read/Write/Edit)、Glob、Grep、ListDirectory + Path root = snapshot.getRoot(); + tools.add(ShellTools.builder().workingDirectory(root).build()); + tools.add(FileSystemTools.builder().allowedDirectory(root).build()); + tools.add(GlobTool.builder().workingDirectory(root).allowedDirectory(root).build()); + tools.add(GrepTool.builder().workingDirectory(root).allowedDirectory(root).build()); + tools.add(ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build()); + return tools; + } + + /** + * 清理 Agent 物化沙箱:摘除快照缓存并删除本地目录(Agent 删除时调用) + * + * @param agentId Agent 编号 + */ + public void evict(Long agentId) { + snapshotCache.invalidate(agentId); + try { + FileUtil.del(buildAgentRoot(agentId)); + } catch (Exception e) { + log.warn("[evict][Agent({}) SKILL 沙箱清理失败]", agentId, e); + } + } + + /** + * 同步并返回 Agent 物化快照:指纹变化则整目录重建,否则复用缓存 + */ + private SkillSnapshot sync(Ai1AgentDO agent) { + // 1.1 查询绑定的 SKILL + if (CollUtil.isEmpty(agent.getSkillIds())) { + return null; + } + List skills = skillService.getSkillList(agent.getSkillIds()); + if (CollUtil.isEmpty(skills)) { + return null; + } + // 1.2 指纹一致时,直接复用缓存 + String fingerprint = buildFingerprint(skills); + SkillSnapshot cached = snapshotCache.getIfPresent(agent.getId()); + if (isSnapshotValid(cached, fingerprint)) { + return cached; + } + + // 2. 指纹变化,整目录重建(双重检查,并发请求下避免重复重建) + synchronized (syncLocks.computeIfAbsent(agent.getId(), key -> new Object())) { + cached = snapshotCache.getIfPresent(agent.getId()); + if (isSnapshotValid(cached, fingerprint)) { + return cached; + } + Path root = buildAgentRoot(agent.getId()); + try { + // 2.1 清空目录后,逐个物化开启的 SKILL + FileUtil.del(root); + FileUtil.mkdir(root.toFile()); + boolean hasSkill = false; + for (Ai1SkillDO skill : skills) { + if (CommonStatusEnum.isEnable(skill.getStatus()) + && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) { + hasSkill = true; + } + } + // 2.2 写入指纹标记,构建技能工具并缓存快照 + FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile()); + ToolCallback skillsTool = hasSkill ? SkillsTool.builder().addSkillsDirectory(root.toString()).build() : null; + SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); + snapshotCache.put(agent.getId(), snapshot); + log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root); + return snapshot; + } catch (Exception e) { + log.warn("[sync][Agent({}) SKILL 物化失败]", agent.getId(), e); + return cached; + } + } + } + + /** + * 计算 SKILL 指纹:按编号排序后拼接「编号:更新时间」,任一 SKILL 换绑或内容变更都会导致指纹变化 + * + * @param skills SKILL 列表 + * @return 指纹 + */ + private static String buildFingerprint(List skills) { + return skills.stream().sorted(Comparator.comparing(Ai1SkillDO::getId)) + .map(skill -> skill.getId() + ":" + skill.getUpdateTime()) + .collect(Collectors.joining(";")); + } + + /** + * 本地快照是否有效:指纹一致,且标记文件存在、内容一致(防止目录被外部清理) + */ + private static boolean isSnapshotValid(SkillSnapshot cached, String fingerprint) { + // 1. 内存快照不存在,或指纹不一致 + if (cached == null || ObjUtil.notEqual(fingerprint, cached.getFingerprint())) { + return false; + } + // 2. 标记文件不存在或内容不一致,说明目录已被外部清理或篡改 + File marker = cached.getRoot().resolve(FILE_NAME_FINGERPRINT).toFile(); + return FileUtil.exist(marker) && fingerprint.equals(FileUtil.readUtf8String(marker)); + } + + /** + * 物化单个 SKILL 的文件树到技能目录 + * + * @return 是否包含根级 SKILL.md + */ + private boolean materializeSkill(Path skillDirectory, Long skillId) { + // 1. 查询 SKILL 的全部节点,构建编号映射,用于沿 parentId 解析相对路径 + List files = skillFileService.getSkillFileListBySkillId(skillId); + Map fileMap = convertMap(files, Ai1SkillFileDO::getId); + + // 2. 逐个节点写入:文件写入内容(自动创建父目录),目录直接创建 + Map pathCache = new HashMap<>(); + boolean hasSkillFile = false; + for (Ai1SkillFileDO file : files) { + File target = skillDirectory.resolve(resolveRelativePath(file, fileMap, pathCache)).toFile(); + if (Ai1SkillFileTypeEnum.isFile(file.getType())) { + // 根级的 SKILL.md 是技能入口 + hasSkillFile |= Ai1SkillFileDO.NAME_SKILL.equals(file.getName()) + && ObjUtil.equal(file.getParentId(), Ai1SkillFileDO.PARENT_ID_ROOT); + FileUtil.writeUtf8String(StrUtil.nullToEmpty(file.getContent()), target); + } else { + FileUtil.mkdir(target); + } + } + + // 3. 缺少根级 SKILL.md 时,只物化文件,不作为技能注册 + if (!hasSkillFile) { + log.warn("[materializeSkill][SKILL({}) 缺少根级 SKILL.md]", skillId); + } + return hasSkillFile; + } + + /** + * 解析节点相对路径:沿 parentId 链自底向上拼接,每段名称净化以防目录穿越 + */ + private static Path resolveRelativePath(Ai1SkillFileDO file, Map fileMap, Map pathCache) { + Path cached = pathCache.get(file.getId()); + if (cached != null) { + return cached; + } + Path segment = Path.of(sanitize(file.getName())); + Ai1SkillFileDO parent = fileMap.get(file.getParentId()); + // 父节点为自身时(异常数据)按根级处理,避免无限递归 + Path path = parent == null || ObjUtil.equal(parent.getId(), file.getId()) + ? segment : resolveRelativePath(parent, fileMap, pathCache).resolve(segment); + pathCache.put(file.getId(), path); + return path; + } + + private Path buildAgentRoot(Long agentId) { + return Path.of(ai1Properties.getSkill().getRoot(), "agent_" + agentId); + } + + /** + * 路径净化:屏蔽路径分隔符与穿越片段 + */ + private static String sanitize(String name) { + if (StrUtil.isBlank(name)) { + return "_"; + } + return StrUtil.replace(StrUtil.replaceChars(name, new char[]{'\\', '/'}, "_"), "..", "_"); + } + + /** + * Agent 物化快照 + */ + @Data + @AllArgsConstructor + private static class SkillSnapshot { + + /** + * 指纹 + */ + private final String fingerprint; + /** + * Agent 物化根目录 + */ + private final Path root; + /** + * 技能工具;无有效 SKILL.md 时为空 + */ + private final ToolCallback skillsTool; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/package-info.java new file mode 100644 index 0000000..58a0997 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/package-info.java @@ -0,0 +1,10 @@ +/** + * ai1 模块,参考 XXL-AI 优化实现的 AI Agent 能力。 + * 例如说:Agent 编排、多供应商模型、MCP、SKILL、RAG 知识库、流式会话 + * + * 1. Controller URL:以 /ai1/ 开头,避免和其它 Module 冲突 + * 2. DataObject 表名:以 ai1_ 开头,方便在数据库中区分 + * + * 注意,由于 Ai1 模块下容易和其它模块重名,所以类名都加 Ai1 前缀 + */ +package cn.iocoder.yudao.module.ai1; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java new file mode 100644 index 0000000..14533f2 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.ai1.service.agent; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 Agent Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1AgentService { + + /** + * 创建 Agent,初始为关闭 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createAgent(@Valid Ai1AgentSaveReqVO createReqVO); + + /** + * 更新 Agent;状态不在此修改 + * + * @param updateReqVO 更新信息 + */ + void updateAgent(@Valid Ai1AgentSaveReqVO updateReqVO); + + /** + * 删除 Agent,级联删除会话与消息,并清理 SKILL 物化沙箱 + * + * @param id 编号 + */ + void deleteAgent(Long id); + + /** + * 批量删除 Agent,级联删除会话与消息,并清理 SKILL 物化沙箱 + * + * @param ids 编号列表 + */ + void deleteAgentListByIds(List ids); + + /** + * 获得 Agent + * + * @param id 编号 + * @return Agent + */ + Ai1AgentDO getAgent(Long id); + + /** + * 校验 Agent 是否存在 + * + * @param id 编号 + * @return Agent + */ + Ai1AgentDO validateAgentExists(Long id); + + /** + * 获得 Agent 分页 + * + * @param pageReqVO 分页查询 + * @return Agent 分页 + */ + PageResult getAgentPage(Ai1AgentPageReqVO pageReqVO); + + /** + * 校验 Agent 存在且已开启 + * + * @param id 编号 + * @return Agent + */ + Ai1AgentDO validateAgentEnabled(Long id); + + /** + * 获得指定状态的 Agent 列表,用于会话页选择 Agent + * + * @param status 状态 + * @return Agent 列表 + */ + List getAgentListByStatus(Integer status); + + /** + * 获得 Agent 列表 + * + * @param ids 编号集合 + * @return Agent 列表 + */ + List getAgentList(Collection ids); + + /** + * 获得 Agent Map + * + * @param ids 编号集合 + * @return Agent Map + */ + default Map getAgentMap(Collection ids) { + return convertMap(getAgentList(ids), Ai1AgentDO::getId); + } + + /** + * 获得使用指定模型的 Agent 数量 + * + * @param modelIds 模型编号集合 + * @return Agent 数量 + */ + Long getAgentCountByModelIds(Collection modelIds); + + /** + * 获得绑定指定知识库的 Agent 数量 + * + * @param knowledgeBaseIds 知识库编号集合 + * @return Agent 数量 + */ + Long getAgentCountByKnowledgeBaseIds(Collection knowledgeBaseIds); + + /** + * 获得绑定指定 SKILL 的 Agent 数量 + * + * @param skillIds SKILL 编号集合 + * @return Agent 数量 + */ + Long getAgentCountBySkillIds(Collection skillIds); + + /** + * 修改 Agent 状态 + * + * @param id Agent 编号 + * @param status 状态 + */ + void updateAgentStatus(Long id, Integer status); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java new file mode 100644 index 0000000..d23a22e --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.ai1.service.agent; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.AGENT_DISABLE; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.AGENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT; + +/** + * AI1 Agent Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1AgentServiceImpl implements Ai1AgentService { + + @Resource + private Ai1AgentMapper agentMapper; + + @Resource + private Ai1ModelService modelService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionService sessionService; + + @Resource + private Ai1SkillToolFactory skillToolFactory; + + @Override + public Long createAgent(Ai1AgentSaveReqVO createReqVO) { + // 1. 校验对话模型 + validateChatModel(createReqVO.getProviderId(), createReqVO.getModelId()); + + // 2. 插入 + Ai1AgentDO agent = BeanUtils.toBean(createReqVO, Ai1AgentDO.class).setStatus(CommonStatusEnum.DISABLE.getStatus()); + agentMapper.insert(agent); + return agent.getId(); + } + + @Override + public void updateAgent(Ai1AgentSaveReqVO updateReqVO) { + // 1. 校验存在、对话模型 + validateAgentExists(updateReqVO.getId()); + validateChatModel(updateReqVO.getProviderId(), updateReqVO.getModelId()); + + // 2. 更新 + agentMapper.updateById(BeanUtils.toBean(updateReqVO, Ai1AgentDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAgent(Long id) { + deleteAgentListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAgentListByIds(List ids) { + // 1. 校验存在 + ids.forEach(this::validateAgentExists); + + // 2. 删除 Agent,同一事务内级联删除会话与消息 + agentMapper.deleteByIds(ids); + sessionService.deleteSessionListByAgentIds(ids); + + // 3. 清理 SKILL 物化沙箱 + ids.forEach(skillToolFactory::evict); + } + + @Override + public Ai1AgentDO getAgent(Long id) { + return agentMapper.selectById(id); + } + + @Override + public Ai1AgentDO validateAgentExists(Long id) { + Ai1AgentDO agent = agentMapper.selectById(id); + if (agent == null) { + throw exception(AGENT_NOT_EXISTS); + } + return agent; + } + + @Override + public Ai1AgentDO validateAgentEnabled(Long id) { + Ai1AgentDO agent = validateAgentExists(id); + if (CommonStatusEnum.isDisable(agent.getStatus())) { + throw exception(AGENT_DISABLE, agent.getName()); + } + return agent; + } + + @Override + public PageResult getAgentPage(Ai1AgentPageReqVO pageReqVO) { + return agentMapper.selectPage(pageReqVO); + } + + @Override + public List getAgentListByStatus(Integer status) { + return agentMapper.selectListByStatusOrderById(status); + } + + @Override + public List getAgentList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return agentMapper.selectByIds(ids); + } + + @Override + public Long getAgentCountByModelIds(Collection modelIds) { + if (CollUtil.isEmpty(modelIds)) { + return 0L; + } + return agentMapper.selectCountByModelIds(modelIds); + } + + @Override + public Long getAgentCountByKnowledgeBaseIds(Collection knowledgeBaseIds) { + if (CollUtil.isEmpty(knowledgeBaseIds)) { + return 0L; + } + return agentMapper.selectCountByKnowledgeBaseIds(knowledgeBaseIds); + } + + @Override + public Long getAgentCountBySkillIds(Collection skillIds) { + if (CollUtil.isEmpty(skillIds)) { + return 0L; + } + return agentMapper.selectCountBySkillIds(skillIds); + } + + @Override + public void updateAgentStatus(Long id, Integer status) { + // 1. 校验存在 + validateAgentExists(id); + + // 2. 更新状态 + agentMapper.updateById(new Ai1AgentDO().setId(id).setStatus(status)); + } + + /** + * 校验对话模型:Provider、模型存在且开启,模型归属于该 Provider,且为对话模型 + */ + private void validateChatModel(Long providerId, Long modelId) { + Ai1ModelRespBO model = modelService.getModelRespBO(providerId, modelId); + if (!Ai1ModelTypeEnum.isChat(model.getModelType())) { + throw exception(MODEL_TYPE_NOT_CHAT); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeService.java new file mode 100644 index 0000000..621c3b1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeService.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.service.home; + +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; + +import java.util.List; + +/** + * AI1 首页统计 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1HomeService { + + /** + * 获得总量统计:Agent、SKILL、MCP、模型数量 + * + * @return 总量统计 + */ + Ai1HomeSummaryRespVO getSummary(); + + /** + * 获得近 days 个自然日(含今天)的按日消息统计,没有消息的日期补 0,按日期升序 + * + * @param days 天数 + * @return 每日消息数量 + */ + List getMessageSummaryByDate(Integer days); + + /** + * 获得近 days 个自然日(含今天)的按 Agent 消息统计,按数量倒序 + * + * @param days 天数 + * @return 各 Agent 消息数量 + */ + List getMessageSummaryByAgent(Integer days); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImpl.java new file mode 100644 index 0000000..4fecd18 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImpl.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.ai1.service.home; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import cn.iocoder.yudao.module.ai1.dal.mysql.home.Ai1HomeMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDayBeginTime; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDayEndTime; + +/** + * AI1 首页统计 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1HomeServiceImpl implements Ai1HomeService { + + @Resource + private Ai1HomeMapper homeMapper; + + @Override + public Ai1HomeSummaryRespVO getSummary() { + return homeMapper.selectSummary(); + } + + @Override + public List getMessageSummaryByDate(Integer days) { + // 1. 查询区间内有消息的日期 + LocalDateTime endTime = getDayEndTime(LocalDateTime.now()); + LocalDateTime beginTime = getDayBeginTime(endTime.minusDays(days - 1L)); + // 先查询按日聚合结果,再转换为“日期 -> 消息数量”的 Map,便于补齐没有消息的日期 + List list = homeMapper.selectMessageSummaryListByCreateTimeBetweenGroupByDate(beginTime, endTime); + Map countMap = convertMap(list, Ai1HomeMessageSummaryByDateRespVO::getDate, Ai1HomeMessageSummaryByDateRespVO::getCount); + + // 2. 生成连续日期序列,没有消息的日期补 0,保证折线不断点 + List result = new ArrayList<>(days); + for (int i = 0; i < days; i++) { + String date = LocalDateTimeUtil.format(beginTime.plusDays(i), DatePattern.NORM_DATE_FORMATTER); + result.add(new Ai1HomeMessageSummaryByDateRespVO().setDate(date).setCount(countMap.getOrDefault(date, 0L))); + } + return result; + } + + @Override + public List getMessageSummaryByAgent(Integer days) { + LocalDateTime endTime = getDayEndTime(LocalDateTime.now()); + LocalDateTime beginTime = getDayBeginTime(endTime.minusDays(days - 1L)); + return homeMapper.selectMessageSummaryListByCreateTimeBetweenGroupByAgentId(beginTime, endTime); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseService.java new file mode 100644 index 0000000..32adeb0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseService.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 知识库 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1KnowledgeBaseService { + + /** + * 创建知识库 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createKnowledgeBase(@Valid Ai1KnowledgeBaseSaveReqVO createReqVO); + + /** + * 更新知识库 + * + * @param updateReqVO 更新信息 + */ + void updateKnowledgeBase(@Valid Ai1KnowledgeBaseSaveReqVO updateReqVO); + + /** + * 删除知识库,连带删除文档与 Milvus 向量集合 + * + * @param id 编号 + */ + void deleteKnowledgeBase(Long id); + + /** + * 批量删除知识库,连带删除文档与 Milvus 向量集合 + * + * @param ids 编号列表 + */ + void deleteKnowledgeBaseListByIds(List ids); + + /** + * 获得知识库 + * + * @param id 编号 + * @return 知识库 + */ + Ai1KnowledgeBaseDO getKnowledgeBase(Long id); + + /** + * 校验知识库是否存在 + * + * @param id 编号 + * @return 知识库 + */ + Ai1KnowledgeBaseDO validateKnowledgeBaseExists(Long id); + + /** + * 获得知识库分页 + * + * @param pageReqVO 分页查询 + * @return 知识库分页 + */ + PageResult getKnowledgeBasePage(Ai1KnowledgeBasePageReqVO pageReqVO); + + /** + * 获得指定状态的知识库列表 + * + * @param status 状态 + * @return 知识库列表 + */ + List getKnowledgeBaseListByStatus(Integer status); + + /** + * 获得知识库列表 + * + * @param ids 编号集合 + * @return 知识库列表 + */ + List getKnowledgeBaseList(Collection ids); + + /** + * 获得引用指定嵌入模型的知识库数量 + * + * @param modelIds 模型编号集合 + * @return 知识库数量 + */ + Long getKnowledgeBaseCountByEmbeddingModelIds(Collection modelIds); + + /** + * 向量检索:按查询文本召回知识库相关分片 + * + * @param id 编号 + * @param query 查询文本 + * @param topK 检索数量,为空时使用知识库配置 + * @return 命中分片 + */ + List searchKnowledgeBase(Long id, String query, Integer topK); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImpl.java new file mode 100644 index 0000000..8b7331c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImpl.java @@ -0,0 +1,182 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeBaseMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 知识库 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class Ai1KnowledgeBaseServiceImpl implements Ai1KnowledgeBaseService { + + @Resource + private Ai1KnowledgeBaseMapper knowledgeBaseMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1KnowledgeDocumentService knowledgeDocumentService; + @Resource + private Ai1ModelService modelService; + + @Resource + private Ai1RagTool ragTool; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + + @Override + public Long createKnowledgeBase(Ai1KnowledgeBaseSaveReqVO createReqVO) { + // 1. 校验向量化模型 + validateEmbeddingModel(createReqVO.getEmbeddingProviderId(), createReqVO.getEmbeddingModelId()); + + // 2. 插入 + Ai1KnowledgeBaseDO knowledgeBase = BeanUtils.toBean(createReqVO, Ai1KnowledgeBaseDO.class); + knowledgeBaseMapper.insert(knowledgeBase); + return knowledgeBase.getId(); + } + + @Override + public void updateKnowledgeBase(Ai1KnowledgeBaseSaveReqVO updateReqVO) { + // 1. 校验存在、向量化模型 + Ai1KnowledgeBaseDO knowledgeBase = validateKnowledgeBaseExists(updateReqVO.getId()); + validateEmbeddingModel(updateReqVO.getEmbeddingProviderId(), updateReqVO.getEmbeddingModelId()); + + // 2. 更新 + Ai1KnowledgeBaseDO updateObj = BeanUtils.toBean(updateReqVO, Ai1KnowledgeBaseDO.class); + knowledgeBaseMapper.updateById(updateObj); + + // 3. 向量化模型变更时,失效向量存储缓存,保证后续按新模型重建 + if (ObjUtil.notEqual(knowledgeBase.getEmbeddingProviderId(), updateObj.getEmbeddingProviderId()) + || ObjUtil.notEqual(knowledgeBase.getEmbeddingModelId(), updateObj.getEmbeddingModelId())) { + ragTool.evict(updateObj.getId()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteKnowledgeBase(Long id) { + deleteKnowledgeBaseListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteKnowledgeBaseListByIds(List ids) { + // 1.1 校验存在 + ids.forEach(this::validateKnowledgeBaseExists); + // 1.2 校验未被 Agent 绑定 + if (agentService.getAgentCountByKnowledgeBaseIds(ids) > 0) { + throw exception(KNOWLEDGE_BASE_USED_BY_AGENT); + } + + // 2. 删除知识库与其下文档 + knowledgeBaseMapper.deleteByIds(ids); + knowledgeDocumentService.deleteKnowledgeDocumentListByKnowledgeBaseIds(ids); + + // 3. 删除 Milvus 向量集合:外部资源无法回滚,事务提交后再执行,失败仅记录日志 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + ids.forEach(ragTool::dropCollection); + } + }); + } + + @Override + public Ai1KnowledgeBaseDO getKnowledgeBase(Long id) { + return knowledgeBaseMapper.selectById(id); + } + + @Override + public Ai1KnowledgeBaseDO validateKnowledgeBaseExists(Long id) { + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseMapper.selectById(id); + if (knowledgeBase == null) { + throw exception(KNOWLEDGE_BASE_NOT_EXISTS); + } + return knowledgeBase; + } + + @Override + public PageResult getKnowledgeBasePage(Ai1KnowledgeBasePageReqVO pageReqVO) { + return knowledgeBaseMapper.selectPage(pageReqVO); + } + + @Override + public List getKnowledgeBaseListByStatus(Integer status) { + return knowledgeBaseMapper.selectListByStatus(status); + } + + @Override + public List getKnowledgeBaseList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return knowledgeBaseMapper.selectByIds(ids); + } + + @Override + public Long getKnowledgeBaseCountByEmbeddingModelIds(Collection modelIds) { + if (CollUtil.isEmpty(modelIds)) { + return 0L; + } + return knowledgeBaseMapper.selectCountByEmbeddingModelIds(modelIds); + } + + @Override + public List searchKnowledgeBase(Long id, String query, Integer topK) { + // 1. 校验存在(当前租户下),再访问其向量集合 + Ai1KnowledgeBaseDO knowledgeBase = validateKnowledgeBaseExists(id); + + // 2. 检索:业务异常(如模型被禁用)直接抛出,其余异常包装为检索失败 + try { + return ragTool.search(knowledgeBase, query, topK); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.warn("[searchKnowledgeBase][知识库({}) query({}) topK({}) 向量检索失败]", id, query, topK, e); + throw exception(KNOWLEDGE_BASE_SEARCH_FAIL, e.getMessage()); + } + } + + /** + * 校验向量化模型:Provider、模型存在且开启,模型归属于该 Provider,且为嵌入模型 + */ + private void validateEmbeddingModel(Long providerId, Long modelId) { + Ai1ModelRespBO model = modelService.getModelRespBO(providerId, modelId); + if (!Ai1ModelTypeEnum.isEmbedding(model.getModelType())) { + throw exception(MODEL_TYPE_NOT_EMBEDDING); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentService.java new file mode 100644 index 0000000..13e0c96 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentService.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 知识文档 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1KnowledgeDocumentService { + + /** + * 创建文档(粘贴文本),初始为未处理状态 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createKnowledgeDocument(@Valid Ai1KnowledgeDocumentSaveReqVO createReqVO); + + /** + * 上传文档:读取 .txt / .md 文本文件内容后创建 + * + * @param knowledgeBaseId 知识库编号 + * @param file 文件 + * @return 编号 + */ + Long uploadKnowledgeDocument(Long knowledgeBaseId, MultipartFile file); + + /** + * 更新文档;内容变更时重置为未处理状态,并清理旧向量 + * + * @param updateReqVO 更新信息 + */ + void updateKnowledgeDocument(@Valid Ai1KnowledgeDocumentSaveReqVO updateReqVO); + + /** + * 删除文档,连带清理向量 + * + * @param id 编号 + */ + void deleteKnowledgeDocument(Long id); + + /** + * 批量删除文档,连带清理向量 + * + * @param ids 编号列表 + */ + void deleteKnowledgeDocumentListByIds(List ids); + + /** + * 删除知识库下的全部文档;向量由知识库删除时整体 drop,这里不逐条清理 + * + * @param knowledgeBaseIds 知识库编号集合 + */ + void deleteKnowledgeDocumentListByKnowledgeBaseIds(Collection knowledgeBaseIds); + + /** + * 获得文档 + * + * @param id 编号 + * @return 文档 + */ + Ai1KnowledgeDocumentDO getKnowledgeDocument(Long id); + + /** + * 获得文档分页 + * + * @param pageReqVO 分页查询 + * @return 文档分页 + */ + PageResult getKnowledgeDocumentPage(Ai1KnowledgeDocumentPageReqVO pageReqVO); + + /** + * 获得文档列表 + * + * @param ids 编号集合 + * @return 文档列表 + */ + List getKnowledgeDocumentList(Collection ids); + + /** + * 获得指定编号的文档 Map + * + * @param ids 编号集合 + * @return 文档 Map + */ + default Map getKnowledgeDocumentMap(Collection ids) { + return convertMap(getKnowledgeDocumentList(ids), Ai1KnowledgeDocumentDO::getId); + } + + /** + * 文档向量化:分片 → 嵌入 → 写入 Milvus,并回写状态与分片数;失败时回写失败状态并抛出异常 + * + * @param id 编号 + * @return 分片数量 + */ + Integer vectorizeKnowledgeDocument(Long id); + + /** + * 知识库批量向量化:逐个向量化库下有内容的文档,单个失败不中断 + * + * @param knowledgeBaseId 知识库编号 + * @return 成功、失败数量 + */ + Ai1KnowledgeDocumentVectorizeRespVO vectorizeKnowledgeDocumentListByKnowledgeBaseId(Long knowledgeBaseId); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImpl.java new file mode 100644 index 0000000..dd971fe --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImpl.java @@ -0,0 +1,244 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeDocumentMapper; +import cn.iocoder.yudao.module.ai1.enums.knowledge.Ai1KnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 知识文档 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class Ai1KnowledgeDocumentServiceImpl implements Ai1KnowledgeDocumentService { + + // TODO @芋艿:【优化】后续可以考虑支持更多文件类型; + /** + * 允许上传的文本文件扩展名 + */ + private static final List UPLOAD_FILE_SUFFIXES = Arrays.asList(".txt", ".md"); + + @Resource + private Ai1KnowledgeDocumentMapper knowledgeDocumentMapper; + + @Resource + private Ai1KnowledgeBaseService knowledgeBaseService; + @Resource + private Ai1ModelService modelService; + + @Resource + private Ai1RagTool ragTool; + + @Override + public Long createKnowledgeDocument(Ai1KnowledgeDocumentSaveReqVO createReqVO) { + // 1. 校验知识库存在 + knowledgeBaseService.validateKnowledgeBaseExists(createReqVO.getKnowledgeBaseId()); + + // 2. 插入:初始为未处理,需手动向量化 + Ai1KnowledgeDocumentDO document = BeanUtils.toBean(createReqVO, Ai1KnowledgeDocumentDO.class) + .setChunkCount(0).setStatus(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus()); + knowledgeDocumentMapper.insert(document); + return document.getId(); + } + + @Override + public Long uploadKnowledgeDocument(Long knowledgeBaseId, MultipartFile file) { + // 1.1 校验文件类型 + String filename = file.getOriginalFilename(); + if (StrUtil.isBlank(filename) || !StrUtil.endWithAnyIgnoreCase(filename, UPLOAD_FILE_SUFFIXES.toArray(new String[0]))) { + throw exception(KNOWLEDGE_DOCUMENT_FILE_TYPE_INVALID); + } + // 1.2 读取文本内容(UTF-8),并校验非空 + String content; + try (InputStream inputStream = file.getInputStream()) { + content = IoUtil.readUtf8(inputStream); + } catch (Exception e) { + log.warn("[uploadKnowledgeDocument][知识库({}) 文件({}) 读取失败]", knowledgeBaseId, filename, e); + throw exception(KNOWLEDGE_DOCUMENT_FILE_READ_FAIL, e.getMessage()); + } + if (StrUtil.isBlank(content)) { + throw exception(KNOWLEDGE_DOCUMENT_FILE_EMPTY); + } + + // 2. 创建文档 + Ai1KnowledgeDocumentSaveReqVO createReqVO = new Ai1KnowledgeDocumentSaveReqVO() + .setKnowledgeBaseId(knowledgeBaseId).setName(filename).setContent(content); + return createKnowledgeDocument(createReqVO); + } + + @Override + public void updateKnowledgeDocument(Ai1KnowledgeDocumentSaveReqVO updateReqVO) { + // 1. 校验存在 + Ai1KnowledgeDocumentDO document = validateKnowledgeDocumentExists(updateReqVO.getId()); + + // 2. 更新:归属知识库不可变更;内容变更时重置为未处理,需重新向量化 + Ai1KnowledgeDocumentDO updateObj = new Ai1KnowledgeDocumentDO().setId(document.getId()) + .setName(updateReqVO.getName()).setContent(updateReqVO.getContent()); + boolean contentChanged = ObjUtil.notEqual(document.getContent(), updateReqVO.getContent()); + if (contentChanged) { + updateObj.setChunkCount(0).setStatus(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus()); + } + knowledgeDocumentMapper.updateById(updateObj); + + // 3. 内容变更时清理旧向量 + if (contentChanged) { + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.getKnowledgeBase(document.getKnowledgeBaseId()); + if (knowledgeBase != null) { + ragTool.deleteByDocumentIds(knowledgeBase, Collections.singletonList(document.getId())); + } + } + } + + @Override + public void deleteKnowledgeDocument(Long id) { + deleteKnowledgeDocumentListByIds(Collections.singletonList(id)); + } + + @Override + public void deleteKnowledgeDocumentListByIds(List ids) { + // 1. 校验存在 + List documents = convertList(ids, this::validateKnowledgeDocumentExists); + + // 2. 删除 + knowledgeDocumentMapper.deleteByIds(ids); + + // 3. 按知识库分组清理向量 + Map> documentIdsMap = convertMultiMap(documents, + Ai1KnowledgeDocumentDO::getKnowledgeBaseId, Ai1KnowledgeDocumentDO::getId); + for (Ai1KnowledgeBaseDO knowledgeBase : knowledgeBaseService.getKnowledgeBaseList(documentIdsMap.keySet())) { + ragTool.deleteByDocumentIds(knowledgeBase, documentIdsMap.get(knowledgeBase.getId())); + } + } + + @Override + public void deleteKnowledgeDocumentListByKnowledgeBaseIds(Collection knowledgeBaseIds) { + if (CollUtil.isEmpty(knowledgeBaseIds)) { + return; + } + knowledgeDocumentMapper.deleteByKnowledgeBaseIds(knowledgeBaseIds); + } + + @Override + public Ai1KnowledgeDocumentDO getKnowledgeDocument(Long id) { + return knowledgeDocumentMapper.selectById(id); + } + + @Override + public PageResult getKnowledgeDocumentPage(Ai1KnowledgeDocumentPageReqVO pageReqVO) { + return knowledgeDocumentMapper.selectPage(pageReqVO); + } + + @Override + public List getKnowledgeDocumentList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return knowledgeDocumentMapper.selectByIds(ids); + } + + @Override + public Integer vectorizeKnowledgeDocument(Long id) { + // 1. 校验文档、所属知识库存在 + Ai1KnowledgeDocumentDO document = validateKnowledgeDocumentExists(id); + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.validateKnowledgeBaseExists(document.getKnowledgeBaseId()); + + // 2. 向量化,并回写状态与分片数 + return vectorizeKnowledgeDocument(knowledgeBase, document); + } + + @Override + public Ai1KnowledgeDocumentVectorizeRespVO vectorizeKnowledgeDocumentListByKnowledgeBaseId(Long knowledgeBaseId) { + // 1. 校验知识库存在、且有文档 + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.validateKnowledgeBaseExists(knowledgeBaseId); + List documents = knowledgeDocumentMapper.selectListByKnowledgeBaseId(knowledgeBaseId); + if (CollUtil.isEmpty(documents)) { + throw exception(KNOWLEDGE_BASE_DOCUMENT_EMPTY); + } + // 1.2 校验嵌入模型可用:配置有误时直接提示,避免逐篇失败把整库文档刷成失败状态 + Ai1ModelRespBO embeddingModel = modelService.getModelRespBO( + knowledgeBase.getEmbeddingProviderId(), knowledgeBase.getEmbeddingModelId()); + if (!Ai1ModelTypeEnum.isEmbedding(embeddingModel.getModelType())) { + throw exception(MODEL_TYPE_NOT_EMBEDDING); + } + + // 2. 逐个向量化:跳过空内容文档,单个失败不中断 + int successCount = 0; + int failureCount = 0; + for (Ai1KnowledgeDocumentDO document : documents) { + if (StrUtil.isBlank(document.getContent())) { + continue; + } + try { + vectorizeKnowledgeDocument(knowledgeBase, document); + successCount++; + } catch (ServiceException e) { + failureCount++; + log.warn("[vectorizeKnowledgeDocumentListByKnowledgeBaseId][知识库({}) 文档({}) 向量化失败:{}]", + knowledgeBaseId, document.getId(), e.getMessage()); + } + } + return new Ai1KnowledgeDocumentVectorizeRespVO(successCount, failureCount); + } + + /** + * 单文档向量化:调用 RAG 工具写入向量后回写已向量化;任何失败都回写失败状态,并抛出业务异常 + */ + private Integer vectorizeKnowledgeDocument(Ai1KnowledgeBaseDO knowledgeBase, Ai1KnowledgeDocumentDO document) { + if (StrUtil.isBlank(document.getContent())) { + throw exception(KNOWLEDGE_DOCUMENT_CONTENT_EMPTY); + } + try { + int chunkCount = ragTool.vectorize(knowledgeBase, document.getId(), document.getContent()); + knowledgeDocumentMapper.updateById(new Ai1KnowledgeDocumentDO().setId(document.getId()) + .setChunkCount(chunkCount).setStatus(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus())); + return chunkCount; + } catch (Exception e) { + log.warn("[vectorizeKnowledgeDocument][文档({}) 向量化失败]", document.getId(), e); + knowledgeDocumentMapper.updateById(new Ai1KnowledgeDocumentDO().setId(document.getId()) + .setStatus(Ai1KnowledgeDocumentStatusEnum.FAILED.getStatus())); + if (e instanceof ServiceException) { + throw (ServiceException) e; + } + throw exception(KNOWLEDGE_DOCUMENT_VECTORIZE_FAIL, e.getMessage()); + } + } + + private Ai1KnowledgeDocumentDO validateKnowledgeDocumentExists(Long id) { + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(id); + if (document == null) { + throw exception(KNOWLEDGE_DOCUMENT_NOT_EXISTS); + } + return document; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpService.java new file mode 100644 index 0000000..9cd1601 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpService.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.ai1.service.mcp; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 MCP 服务 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1McpService { + + /** + * 创建 MCP + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createMcp(@Valid Ai1McpSaveReqVO createReqVO); + + /** + * 更新 MCP + * + * @param updateReqVO 更新信息 + */ + void updateMcp(@Valid Ai1McpSaveReqVO updateReqVO); + + /** + * 删除 MCP + * + * @param id 编号 + */ + void deleteMcp(Long id); + + /** + * 批量删除 MCP + * + * @param ids 编号列表 + */ + void deleteMcpListByIds(List ids); + + /** + * 获得 MCP + * + * @param id 编号 + * @return MCP + */ + Ai1McpDO getMcp(Long id); + + /** + * 获得 MCP 分页 + * + * @param pageReqVO 分页查询 + * @return MCP 分页 + */ + PageResult getMcpPage(Ai1McpPageReqVO pageReqVO); + + /** + * 获得指定状态的 MCP 列表 + * + * @param status 状态 + * @return MCP 列表 + */ + List getMcpListByStatus(Integer status); + + /** + * 获得 MCP 列表 + * + * @param ids 编号集合 + * @return MCP 列表 + */ + List getMcpList(Collection ids); + + /** + * 测试 MCP 连通性 + * + * @param id 编号 + * @return 测试结果 + */ + Ai1McpConnectRespVO testMcpConnect(Long id); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImpl.java new file mode 100644 index 0000000..9507738 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImpl.java @@ -0,0 +1,159 @@ +package cn.iocoder.yudao.module.ai1.service.mcp; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.mcp.Ai1McpMapper; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpClientTool; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 MCP 服务 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1McpServiceImpl implements Ai1McpService { + + @Resource + private Ai1McpMapper mcpMapper; + + @Resource + private Ai1McpClientTool mcpClientTool; + + @Override + public Long createMcp(Ai1McpSaveReqVO createReqVO) { + Ai1McpDO mcp = buildMcp(createReqVO); + mcpMapper.insert(mcp); + return mcp.getId(); + } + + @Override + public void updateMcp(Ai1McpSaveReqVO updateReqVO) { + // 1. 校验存在 + validateMcpExists(updateReqVO.getId()); + + // 2. 更新:切换为本地时服务地址为空,由 updateForSave 主动置空 + mcpMapper.updateForSave(buildMcp(updateReqVO)); + + // 3. 释放本节点的旧连接,下次使用时按新配置重建 + mcpClientTool.evict(updateReqVO.getId()); + } + + @Override + public void deleteMcp(Long id) { + deleteMcpListByIds(Collections.singletonList(id)); + } + + @Override + public void deleteMcpListByIds(List ids) { + // 1. 校验存在 + ids.forEach(this::validateMcpExists); + + // 2. 删除 + mcpMapper.deleteByIds(ids); + + // 3. 释放本节点的连接与子进程;其他节点的闲置连接按访问过期自动释放 + ids.forEach(mcpClientTool::evict); + } + + @Override + public Ai1McpDO getMcp(Long id) { + return mcpMapper.selectById(id); + } + + @Override + public PageResult getMcpPage(Ai1McpPageReqVO pageReqVO) { + return mcpMapper.selectPage(pageReqVO); + } + + @Override + public List getMcpListByStatus(Integer status) { + return mcpMapper.selectListByStatus(status); + } + + @Override + public List getMcpList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return mcpMapper.selectByIds(ids); + } + + @Override + public Ai1McpConnectRespVO testMcpConnect(Long id) { + Ai1McpDO mcp = validateMcpExists(id); + return mcpClientTool.testConnect(mcp); + } + + private Ai1McpDO validateMcpExists(Long id) { + Ai1McpDO mcp = mcpMapper.selectById(id); + if (mcp == null) { + throw exception(MCP_NOT_EXISTS); + } + return mcp; + } + + /** + * 校验并归一化 MCP 配置,保证传输方式、config、平铺列一致: + * 1. 传输方式以 transport 列为准,写回 config 的 transport 字段 + * 2. 远程:服务地址优先取平铺列,为空时取 config.url;请求头以平铺列为准,统一写回 config + * 3. 本地:config 必须包含 command,服务地址置空 + */ + private Ai1McpDO buildMcp(Ai1McpSaveReqVO reqVO) { + // 1.1 解析 config + Map config = new LinkedHashMap<>(); + if (StrUtil.isNotBlank(reqVO.getConfig())) { + Map configMap = JsonUtils.parseMap(reqVO.getConfig()); + if (configMap == null) { + throw exception(MCP_CONFIG_INVALID); + } + config.putAll(configMap); + } + // 1.2 传输方式以列为准 + config.put("transport", reqVO.getTransport()); + + // 2. 按传输方式校验必填项 + String url = StrUtil.blankToDefault(reqVO.getUrl(), MapUtil.getStr(config, "url")); + Map headers = reqVO.getHeaders(); + if (Ai1McpTransportEnum.isStdio(reqVO.getTransport())) { + if (StrUtil.isBlank(MapUtil.getStr(config, "command"))) { + throw exception(MCP_COMMAND_REQUIRED); + } + url = null; + } else { + if (StrUtil.isBlank(url)) { + throw exception(MCP_URL_REQUIRED); + } + config.put("url", url); + // 空请求头统一保存为空集合,避免更新时跳过 null 后残留旧鉴权 + headers = MapUtil.emptyIfNull(headers); + if (MapUtil.isNotEmpty(headers)) { + config.put("headers", headers); + } else { + config.remove("headers"); + } + } + + // 3. 构建 + return BeanUtils.toBean(reqVO, Ai1McpDO.class).setUrl(url).setHeaders(headers) + .setConfig(JsonUtils.toJsonString(config)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelService.java new file mode 100644 index 0000000..8f7d559 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelService.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelImportReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 模型 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1ModelService { + + /** + * 创建模型 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createModel(@Valid Ai1ModelSaveReqVO createReqVO); + + /** + * 更新模型 + * + * @param updateReqVO 更新信息 + */ + void updateModel(@Valid Ai1ModelSaveReqVO updateReqVO); + + /** + * 删除模型 + * + * @param id 编号 + */ + void deleteModel(Long id); + + /** + * 批量删除模型 + * + * @param ids 编号列表 + */ + void deleteModelListByIds(List ids); + + /** + * 获得模型 + * + * @param id 编号 + * @return 模型 + */ + Ai1ModelDO getModel(Long id); + + /** + * 校验模型是否存在 + * + * @param id 编号 + * @return 模型 + */ + Ai1ModelDO validateModelExists(Long id); + + /** + * 获得模型分页 + * + * @param pageReqVO 分页查询 + * @return 模型分页 + */ + PageResult getModelPage(Ai1ModelPageReqVO pageReqVO); + + /** + * 获得指定供应商下的模型列表 + * + * @param providerId 供应商编号 + * @return 模型列表 + */ + List getModelListByProviderId(Long providerId); + + /** + * 获得指定供应商、类型、状态的模型列表 + * + * @param providerId 供应商编号,为空时不过滤 + * @param type 类型,为空时不过滤 + * @param status 状态 + * @return 模型列表 + */ + List getModelListByProviderIdAndTypeAndStatus(Long providerId, Integer type, Integer status); + + /** + * 获得模型列表 + * + * @param ids 编号集合 + * @return 模型列表 + */ + List getModelList(Collection ids); + + /** + * 获得指定编号的模型 Map + * + * @param ids 编号集合 + * @return 模型 Map + */ + default Map getModelMap(Collection ids) { + return convertMap(getModelList(ids), Ai1ModelDO::getId); + } + + /** + * 获得指定供应商下的模型数量 + * + * @param providerIds 供应商编号集合 + * @return 模型数量 + */ + Long getModelCountByProviderIds(Collection providerIds); + + /** + * 拉取供应商远程可用的模型标识列表 + * + * @param providerId 供应商编号 + * @return 模型标识列表 + */ + List getRemoteModelList(Long providerId); + + /** + * 获得模型调用参数 + * + * @param providerId 供应商编号 + * @param modelId 模型编号 + * @return 模型调用参数 + */ + Ai1ModelRespBO getModelRespBO(Long providerId, Long modelId); + + /** + * 批量导入远程模型 + * + * @param importReqVO 导入信息 + * @return 实际导入数量 + */ + Integer importRemoteModelList(@Valid Ai1ModelImportReqVO importReqVO); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java new file mode 100644 index 0000000..d2389ff --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java @@ -0,0 +1,238 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelImportReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ModelMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; + +/** + * AI1 模型 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1ModelServiceImpl implements Ai1ModelService { + + @Resource + private Ai1ModelMapper modelMapper; + + @Resource + private Ai1ProviderService providerService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1KnowledgeBaseService knowledgeBaseService; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + @Resource + private Ai1ProviderTool providerTool; + + @Override + public Long createModel(Ai1ModelSaveReqVO createReqVO) { + // 1. 校验供应商存在、模型标识唯一 + providerService.validateProviderExists(createReqVO.getProviderId()); + validateModelUnique(null, createReqVO.getProviderId(), createReqVO.getModel()); + + // 2. 插入 + Ai1ModelDO model = BeanUtils.toBean(createReqVO, Ai1ModelDO.class); + modelMapper.insert(model); + return model.getId(); + } + + @Override + public void updateModel(Ai1ModelSaveReqVO updateReqVO) { + // 1.1 校验存在、供应商归属不可变、模型标识唯一 + Ai1ModelDO model = validateModelExists(updateReqVO.getId()); + if (ObjUtil.notEqual(model.getProviderId(), updateReqVO.getProviderId())) { + throw exception(MODEL_NOT_BELONG_PROVIDER); + } + providerService.validateProviderExists(updateReqVO.getProviderId()); + validateModelUnique(updateReqVO.getId(), updateReqVO.getProviderId(), updateReqVO.getModel()); + // 1.2 被使用的模型禁止修改类型,避免 Agent 或知识库配置失效 + if (ObjUtil.notEqual(model.getType(), updateReqVO.getType())) { + List ids = Collections.singletonList(model.getId()); + if (agentService.getAgentCountByModelIds(ids) > 0) { + throw exception(MODEL_TYPE_USED_BY_AGENT); + } + if (knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(ids) > 0) { + throw exception(MODEL_TYPE_USED_BY_KNOWLEDGE_BASE); + } + } + + // 2. 更新 + Ai1ModelDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ModelDO.class); + modelMapper.updateById(updateObj); + + // 3. 失效本节点已缓存的模型实例 + llmModelFactory.evictByModelId(updateReqVO.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteModel(Long id) { + deleteModelListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteModelListByIds(List ids) { + // 1. 校验存在;被 Agent 或知识库使用时禁止删除 + ids.forEach(this::validateModelExists); + if (agentService.getAgentCountByModelIds(ids) > 0) { + throw exception(MODEL_USED_BY_AGENT); + } + if (knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(ids) > 0) { + throw exception(MODEL_USED_BY_KNOWLEDGE_BASE); + } + + // 2. 删除 + modelMapper.deleteByIds(ids); + + // 3. 失效本节点已缓存的模型实例 + ids.forEach(llmModelFactory::evictByModelId); + } + + @Override + public Ai1ModelDO getModel(Long id) { + return modelMapper.selectById(id); + } + + @Override + public Ai1ModelDO validateModelExists(Long id) { + Ai1ModelDO model = modelMapper.selectById(id); + if (model == null) { + throw exception(MODEL_NOT_EXISTS); + } + return model; + } + + @Override + public PageResult getModelPage(Ai1ModelPageReqVO pageReqVO) { + return modelMapper.selectPage(pageReqVO); + } + + @Override + public List getModelListByProviderId(Long providerId) { + return modelMapper.selectListByProviderId(providerId); + } + + @Override + public List getModelListByProviderIdAndTypeAndStatus(Long providerId, Integer type, Integer status) { + return modelMapper.selectListByProviderIdAndTypeAndStatus(providerId, type, status); + } + + @Override + public List getModelList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return modelMapper.selectByIds(ids); + } + + @Override + public Long getModelCountByProviderIds(Collection providerIds) { + if (CollUtil.isEmpty(providerIds)) { + return 0L; + } + return modelMapper.selectCountByProviderIds(providerIds); + } + + @Override + public List getRemoteModelList(Long providerId) { + // 1. 校验供应商存在 + Ai1ProviderDO provider = providerService.validateProviderExists(providerId); + + // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 + return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()), + resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); + } + + @Override + public Ai1ModelRespBO getModelRespBO(Long providerId, Long modelId) { + // 1.1 校验供应商存在且开启 + Ai1ProviderDO provider = providerService.validateProviderExists(providerId); + if (CommonStatusEnum.isDisable(provider.getStatus())) { + throw exception(PROVIDER_DISABLE, provider.getName()); + } + // 1.2 校验模型存在、开启,且归属于该供应商 + Ai1ModelDO model = validateModelExists(modelId); + if (ObjUtil.notEqual(model.getProviderId(), providerId)) { + throw exception(MODEL_NOT_BELONG_PROVIDER); + } + if (CommonStatusEnum.isDisable(model.getStatus())) { + throw exception(MODEL_DISABLE, model.getName()); + } + + // 2. 组装调用参数:解析 ${ENV} 占位符,库中的原文不改,响应也不返回解析结果 + return new Ai1ModelRespBO().setProviderId(provider.getId()) + .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) + .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) + .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) + .setHeaders(parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Integer importRemoteModelList(Ai1ModelImportReqVO importReqVO) { + // 1.1 校验供应商存在 + providerService.validateProviderExists(importReqVO.getProviderId()); + // 1.2 过滤空白与已存在的模型标识(同时对入参去重,保持入参顺序) + List existModelList = modelMapper.selectListByProviderId(importReqVO.getProviderId()); + Set existModels = convertSet(existModelList, Ai1ModelDO::getModel); + Set importModels = convertSetBySupplier(importReqVO.getModels(), StrUtil::trim, LinkedHashSet::new); + importModels.removeIf(model -> StrUtil.isBlank(model) || existModels.contains(model)); + if (CollUtil.isEmpty(importModels)) { + throw exception(MODEL_IMPORT_ALL_EXISTS); + } + + // 2. 批量插入:展示名称默认同模型标识,类型默认会话,状态默认开启 + List models = convertList(importModels, model -> Ai1ModelDO.builder() + .providerId(importReqVO.getProviderId()).name(StrUtil.maxLength(model, 47)).model(model) + .type(Ai1ModelTypeEnum.CHAT.getType()).status(CommonStatusEnum.ENABLE.getStatus()).build()); + modelMapper.insertBatch(models); + return models.size(); + } + + /** + * 校验同一供应商下模型标识唯一 + */ + private void validateModelUnique(Long id, Long providerId, String model) { + Ai1ModelDO existModel = modelMapper.selectByProviderIdAndModel(providerId, model); + if (existModel != null && ObjUtil.notEqual(existModel.getId(), id)) { + throw exception(MODEL_DUPLICATE, model); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderService.java new file mode 100644 index 0000000..079d7c2 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderService.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 供应商 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1ProviderService { + + /** + * 创建供应商 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createProvider(@Valid Ai1ProviderSaveReqVO createReqVO); + + /** + * 更新供应商 + * + * @param updateReqVO 更新信息 + */ + void updateProvider(@Valid Ai1ProviderSaveReqVO updateReqVO); + + /** + * 删除供应商 + * + * @param id 编号 + */ + void deleteProvider(Long id); + + /** + * 批量删除供应商 + * + * @param ids 编号列表 + */ + void deleteProviderListByIds(List ids); + + /** + * 获得供应商 + * + * @param id 编号 + * @return 供应商 + */ + Ai1ProviderDO getProvider(Long id); + + /** + * 校验供应商是否存在 + * + * @param id 编号 + * @return 供应商 + */ + Ai1ProviderDO validateProviderExists(Long id); + + /** + * 获得供应商分页 + * + * @param pageReqVO 分页查询 + * @return 供应商分页 + */ + PageResult getProviderPage(Ai1ProviderPageReqVO pageReqVO); + + /** + * 获得指定状态的供应商列表 + * + * @param status 状态 + * @return 供应商列表 + */ + List getProviderListByStatus(Integer status); + + /** + * 获得供应商列表 + * + * @param ids 编号集合 + * @return 供应商列表 + */ + List getProviderList(Collection ids); + + /** + * 获得供应商 Map + * + * @param ids 编号集合 + * @return 供应商 Map + */ + default Map getProviderMap(Collection ids) { + return convertMap(getProviderList(ids), Ai1ProviderDO::getId); + } + + /** + * 测试供应商连通性 + * + * @param id 编号 + * @return 测试结果 + */ + Ai1ProviderConnectRespVO testProviderConnect(Long id); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java new file mode 100644 index 0000000..f2cb53c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ProviderMapper; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.parseHeaders; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; + +/** + * AI1 供应商 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1ProviderServiceImpl implements Ai1ProviderService { + + @Resource + private Ai1ProviderMapper providerMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1ModelService modelService; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + @Resource + private Ai1ProviderTool providerTool; + + @Override + public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { + Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); + providerMapper.insert(provider); + return provider.getId(); + } + + @Override + public void updateProvider(Ai1ProviderSaveReqVO updateReqVO) { + // 1. 校验存在 + validateProviderExists(updateReqVO.getId()); + + // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 + Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); + if (StrUtil.isBlank(updateObj.getApiKey())) { + updateObj.setApiKey(null); + } + providerMapper.updateById(updateObj); + + // 3. 地址、密钥、Header、状态可能变化,失效本节点已缓存的模型实例 + llmModelFactory.evictByProviderId(updateReqVO.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProvider(Long id) { + deleteProviderListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProviderListByIds(List ids) { + // 1. 校验存在;其下存在模型时禁止删除 + ids.forEach(this::validateProviderExists); + if (modelService.getModelCountByProviderIds(ids) > 0) { + throw exception(PROVIDER_HAS_MODEL); + } + + // 2. 删除 + providerMapper.deleteByIds(ids); + + // 3. 失效本节点已缓存的模型实例 + ids.forEach(llmModelFactory::evictByProviderId); + } + + @Override + public Ai1ProviderDO getProvider(Long id) { + return providerMapper.selectById(id); + } + + @Override + public Ai1ProviderDO validateProviderExists(Long id) { + Ai1ProviderDO provider = providerMapper.selectById(id); + if (provider == null) { + throw exception(PROVIDER_NOT_EXISTS); + } + return provider; + } + + @Override + public PageResult getProviderPage(Ai1ProviderPageReqVO pageReqVO) { + return providerMapper.selectPage(pageReqVO); + } + + @Override + public List getProviderListByStatus(Integer status) { + return providerMapper.selectListByStatus(status); + } + + @Override + public List getProviderList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return providerMapper.selectByIds(ids); + } + + @Override + public Ai1ProviderConnectRespVO testProviderConnect(Long id) { + // 1. 校验存在 + Ai1ProviderDO provider = validateProviderExists(id); + + // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 + return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()), + resolveSpringPlaceholders(provider.getApiKey()), parseHeaders(resolveSpringPlaceholders(provider.getHeaders()))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java new file mode 100644 index 0000000..e1b3b54 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.ai1.service.model.bo; + +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; +import java.util.Map; + +/** + * AI1 模型调用参数 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class Ai1ModelRespBO { + + /** + * 模型编号 + */ + private Long modelId; + /** + * 模型标识,例如说 gpt-4o + */ + private String model; + /** + * 模型类型 + * + * 枚举 {@link Ai1ModelTypeEnum} + */ + private Integer modelType; + + // ==================== 供应商 ==================== + + /** + * 供应商编号 + */ + private Long providerId; + /** + * 接口地址 + */ + private String baseUrl; + /** + * API 密钥 + */ + private String apiKey; + /** + * 请求附属 Header,每项包含 key、value;value 可包含 {session} 占位符 + */ + private List> headers; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java new file mode 100644 index 0000000..61c3170 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import jakarta.validation.Valid; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话消息 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1SessionMessageService { + + /** + * 获得会话的消息列表,按编号升序 + * + * @param sessionId 会话编号 + * @return 消息列表 + */ + List getSessionMessageListBySessionId(Long sessionId); + + /** + * 获得会话中指定消息之前最近的若干条消息,按编号倒序 + * + * @param sessionId 会话编号 + * @param id 消息编号(不含) + * @param limit 数量上限 + * @return 消息列表 + */ + List getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit); + + /** + * 发送消息(SSE 流式):校验会话归属 → 落库用户消息与助手占位 → 投递生成任务 → 打开 SSE 连接 + * + * 校验、落库失败时不抛出异常,而是返回只包含 error 事件的 SSE 连接 + * + * @param userId 用户编号 + * @param sendReqVO 发送信息 + * @return SSE 连接 + */ + SseEmitter sendSessionMessageStream(Long userId, @Valid Ai1SessionMessageSendReqVO sendReqVO); + + /** + * 断线续传(SSE 流式):校验消息 → 会话 → 用户归属后,从结果流 lastEventId 之后继续转发,不重新生成 + * + * @param userId 用户编号 + * @param messageId 助手消息编号,即结果流标识 + * @param lastEventId 已收到的最后一个事件编号,可为空 + * @return SSE 连接 + */ + SseEmitter resumeSessionMessageStream(Long userId, Long messageId, String lastEventId); + + /** + * 获得消息 + * + * @param id 编号 + * @return 消息 + */ + Ai1SessionMessageDO getSessionMessage(Long id); + + /** + * 更新消息(worker 回填助手消息) + * + * @param updateObj 更新对象 + */ + void updateSessionMessage(Ai1SessionMessageDO updateObj); + + /** + * 删除会话下的全部消息 + * + * @param sessionIds 会话编号集合 + */ + void deleteSessionMessageListBySessionIds(Collection sessionIds); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java new file mode 100644 index 0000000..d46e724 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.harness.session.Ai1SessionStreamTool; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_MESSAGE_NOT_EXISTS; + +/** + * AI1 会话消息 Service 实现类 + * + * 流式生成与下发委托 {@link Ai1SessionStreamTool};本类只负责会话校验与消息落库 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class Ai1SessionMessageServiceImpl implements Ai1SessionMessageService { + + @Resource + private Ai1SessionMessageMapper sessionMessageMapper; + + @Resource + private Ai1SessionService sessionService; + @Resource + private Ai1AgentService agentService; + + @Resource + private Ai1SessionStreamTool sessionStreamTool; + + @Override + public List getSessionMessageListBySessionId(Long sessionId) { + return sessionMessageMapper.selectListBySessionId(sessionId); + } + + @Override + public List getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) { + return sessionMessageMapper.selectListBySessionIdAndIdLessThan(sessionId, id, limit); + } + + @Override + public SseEmitter sendSessionMessageStream(Long userId, Ai1SessionMessageSendReqVO sendReqVO) { + try { + // 1. 校验会话归属、Agent 存在且已开启 + Ai1SessionDO session = sessionService.validateSessionMy(userId, sendReqVO.getSessionId()); + agentService.validateAgentEnabled(session.getAgentId()); + + // 2. 同一事务内落库用户消息与助手占位 + Long messageId = getSelf().createRoundMessages(session, sendReqVO.getContent()); + + // 3. 投递生成任务,并打开 SSE 连接 + try { + sessionStreamTool.submit(messageId, session.getAgentId(), session.getId(), sendReqVO.getContent()); + } catch (Exception e) { + // 仅补偿投递失败;SSE 打开失败不影响已投递的后台任务 + failSubmittedMessage(messageId); + throw e; + } + return sessionStreamTool.open(messageId, null); + } catch (ServiceException e) { + return sessionStreamTool.error(e.getMessage()); + } catch (Exception e) { + log.warn("[sendSessionMessageStream][用户({}) 会话({}) 提交失败]", userId, sendReqVO.getSessionId(), e); + return sessionStreamTool.error("会话提交失败,请稍后重试"); + } + } + + private void failSubmittedMessage(Long messageId) { + try { + // Redis 超时不代表任务一定未写入,只更新仍在生成中的占位,避免覆盖 worker 已回填的终态 + sessionMessageMapper.update(new Ai1SessionMessageDO().setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus()), + new LambdaQueryWrapperX() + .eq(Ai1SessionMessageDO::getId, messageId) + .eq(Ai1SessionMessageDO::getRole, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .eq(Ai1SessionMessageDO::getStatus, Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + } catch (Exception e) { + // 补偿失败不能覆盖原始投递异常,后续仍由结果流丢失检查兜底 + log.error("[failSubmittedMessage][助手消息({}) 投递失败状态回填失败]", messageId, e); + } + } + + @Override + public SseEmitter resumeSessionMessageStream(Long userId, Long messageId, String lastEventId) { + try { + // 1. 校验消息存在、为助手消息,且所属会话归属当前用户 + Ai1SessionMessageDO message = sessionMessageMapper.selectById(messageId); + if (message == null || !Ai1SessionMessageRoleEnum.isAssistant(message.getRole())) { + throw exception(SESSION_MESSAGE_NOT_EXISTS); + } + sessionService.validateSessionMy(userId, message.getSessionId()); + + // 2. 校验通过后,从 lastEventId 之后续传结果流,不重新生成 + return sessionStreamTool.open(messageId, lastEventId); + } catch (ServiceException e) { + return sessionStreamTool.error(e.getMessage()); + } catch (Exception e) { + log.warn("[resumeSessionMessageStream][用户({}) 消息({}) 续传失败]", userId, messageId, e); + return sessionStreamTool.error("会话续传失败,请稍后重试"); + } + } + + /** + * 开启一轮会话:首条消息自动生成标题 → 落库用户消息(完成)与助手占位(生成中)→ 刷新会话活跃时间 + * + * @param session 已校验归属的会话 + * @param content 提问内容 + * @return 助手消息编号,即结果流标识 + */ + @Transactional(rollbackFor = Exception.class) + public Long createRoundMessages(Ai1SessionDO session, String content) { + // 1. 首条消息时,使用提问内容作为标题(超长截断) + if (Ai1SessionDO.TITLE_DEFAULT.equals(session.getTitle()) + && sessionMessageMapper.selectCountBySessionId(session.getId()) == 0) { + String title = content.trim(); + sessionService.updateSessionTitle(session.getId(), title.length() > Ai1SessionDO.TITLE_MAX_LENGTH + ? StrUtil.sub(title, 0, Ai1SessionDO.TITLE_MAX_LENGTH - 3) + "..." : title); + } + + // 2. 用户消息 + 助手占位;刷新页面后,前端按生成中的助手消息编号续传 + sessionMessageMapper.insert(new Ai1SessionMessageDO().setSessionId(session.getId()) + .setRole(Ai1SessionMessageRoleEnum.USER.getRole()).setContent(content) + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus())); + Ai1SessionMessageDO assistantMessage = new Ai1SessionMessageDO().setSessionId(session.getId()) + .setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()).setContent("") + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()); + sessionMessageMapper.insert(assistantMessage); + + // 3. 刷新会话活跃时间 + sessionService.touchSession(session.getId()); + return assistantMessage.getId(); + } + + @Override + public Ai1SessionMessageDO getSessionMessage(Long id) { + return sessionMessageMapper.selectById(id); + } + + @Override + public void updateSessionMessage(Ai1SessionMessageDO updateObj) { + sessionMessageMapper.updateById(updateObj); + } + + @Override + public void deleteSessionMessageListBySessionIds(Collection sessionIds) { + if (CollUtil.isEmpty(sessionIds)) { + return; + } + sessionMessageMapper.deleteBySessionIds(sessionIds); + } + + /** + * 获得自身的代理对象,解决 AOP 生效问题 + */ + private Ai1SessionMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java new file mode 100644 index 0000000..e034443 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话 Service 接口 + * + * 「我的」系列方法按登录用户校验归属,避免越权访问他人会话 + * + * @author 芋道源码 + */ +public interface Ai1SessionService { + + /** + * 创建我的会话;后台会话不要求 Agent 已发布 + * + * @param userId 用户编号 + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSessionMy(Long userId, @Valid Ai1SessionCreateMyReqVO createReqVO); + + /** + * 修改我的会话标题 + * + * @param userId 用户编号 + * @param updateReqVO 修改信息 + */ + void updateSessionMy(Long userId, @Valid Ai1SessionUpdateMyReqVO updateReqVO); + + /** + * 删除我的会话,连带删除消息 + * + * @param userId 用户编号 + * @param id 编号 + */ + void deleteSessionMy(Long userId, Long id); + + /** + * 校验会话存在,且归属于指定用户 + * + * @param userId 用户编号 + * @param id 编号 + * @return 会话 + */ + Ai1SessionDO validateSessionMy(Long userId, Long id); + + /** + * 获得指定用户在指定 Agent 下的会话列表,按编号倒序 + * + * @param agentId Agent 编号 + * @param userId 用户编号 + * @return 会话列表 + */ + List getSessionListByAgentIdAndUserId(Long agentId, Long userId); + + /** + * 获得会话分页(管理端查看 Agent 的全部会话) + * + * @param pageReqVO 分页查询 + * @return 会话分页 + */ + PageResult getSessionPage(Ai1SessionPageReqVO pageReqVO); + + /** + * 修改会话标题(首条消息发送时自动命名) + * + * @param id 编号 + * @param title 标题 + */ + void updateSessionTitle(Long id, String title); + + /** + * 刷新会话的更新时间,即最近活跃时间 + * + * @param id 编号 + */ + void touchSession(Long id); + + /** + * 删除 Agent 下的全部会话与消息(Agent 删除时调用) + * + * @param agentIds Agent 编号集合 + */ + void deleteSessionListByAgentIds(Collection agentIds); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java new file mode 100644 index 0000000..96d31d6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; + +/** + * AI1 会话 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1SessionServiceImpl implements Ai1SessionService { + + @Resource + private Ai1SessionMapper sessionMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionMessageService sessionMessageService; + + @Override + public Long createSessionMy(Long userId, Ai1SessionCreateMyReqVO createReqVO) { + // 1. 校验 Agent 存在且已开启 + agentService.validateAgentEnabled(createReqVO.getAgentId()); + + // 2. 插入:首条消息发送后,标题自动替换为提问内容 + Ai1SessionDO session = new Ai1SessionDO().setAgentId(createReqVO.getAgentId()) + .setUserId(userId).setTitle(Ai1SessionDO.TITLE_DEFAULT); + sessionMapper.insert(session); + return session.getId(); + } + + @Override + public void updateSessionMy(Long userId, Ai1SessionUpdateMyReqVO updateReqVO) { + // 1. 校验归属 + validateSessionMy(userId, updateReqVO.getId()); + + // 2. 更新标题 + updateSessionTitle(updateReqVO.getId(), updateReqVO.getTitle().trim()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSessionMy(Long userId, Long id) { + // 1. 校验归属 + validateSessionMy(userId, id); + + // 2. 删除会话与消息 + sessionMapper.deleteById(id); + sessionMessageService.deleteSessionMessageListBySessionIds(Collections.singletonList(id)); + } + + @Override + public Ai1SessionDO validateSessionMy(Long userId, Long id) { + Ai1SessionDO session = sessionMapper.selectById(id); + if (session == null || ObjUtil.notEqual(session.getUserId(), userId)) { + throw exception(SESSION_NOT_EXISTS); + } + return session; + } + + @Override + public List getSessionListByAgentIdAndUserId(Long agentId, Long userId) { + return sessionMapper.selectListByAgentIdAndUserId(agentId, userId); + } + + @Override + public PageResult getSessionPage(Ai1SessionPageReqVO pageReqVO) { + return sessionMapper.selectPage(pageReqVO); + } + + @Override + public void updateSessionTitle(Long id, String title) { + sessionMapper.updateById(new Ai1SessionDO().setId(id).setTitle(title)); + } + + @Override + public void touchSession(Long id) { + sessionMapper.updateById(new Ai1SessionDO().setId(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSessionListByAgentIds(Collection agentIds) { + // 1. 查询 Agent 下的全部会话编号 + if (CollUtil.isEmpty(agentIds)) { + return; + } + List sessionIds = convertList(sessionMapper.selectListByAgentIds(agentIds), Ai1SessionDO::getId); + if (CollUtil.isEmpty(sessionIds)) { + return; + } + + // 2. 删除会话与消息 + sessionMapper.deleteByIds(sessionIds); + sessionMessageService.deleteSessionMessageListBySessionIds(sessionIds); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileService.java new file mode 100644 index 0000000..a4cdd26 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileService.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileContentReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileCreateReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileMoveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileRenameReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 SKILL 内容文件 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1SkillFileService { + + /** + * 创建 SKILL 的默认文件:SKILL.md(含 frontmatter)、scripts/、reference/,均为固定节点 + * + * @param skill SKILL + */ + void createDefaultSkillFileList(Ai1SkillDO skill); + + /** + * 创建目录或文件;文件按扩展名推断类型,并填充默认内容 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSkillFile(@Valid Ai1SkillFileCreateReqVO createReqVO); + + /** + * 重命名节点;固定节点禁止 + * + * @param renameReqVO 重命名信息 + */ + void renameSkillFile(@Valid Ai1SkillFileRenameReqVO renameReqVO); + + /** + * 移动节点;固定节点禁止,且不能移入自身或其后代 + * + * @param moveReqVO 移动信息 + */ + void moveSkillFile(@Valid Ai1SkillFileMoveReqVO moveReqVO); + + /** + * 保存文件内容;目录不支持 + * + * @param contentReqVO 内容信息 + */ + void updateSkillFileContent(@Valid Ai1SkillFileContentReqVO contentReqVO); + + /** + * 删除节点;固定节点禁止,目录递归删除后代 + * + * @param id 编号 + */ + void deleteSkillFile(Long id); + + /** + * 删除 SKILL 下的全部节点(SKILL 删除时调用) + * + * @param skillIds SKILL 编号集合 + */ + void deleteSkillFileListBySkillIds(Collection skillIds); + + /** + * 获得节点 + * + * @param id 编号 + * @return 节点 + */ + Ai1SkillFileDO getSkillFile(Long id); + + /** + * 获得 SKILL 下的全部节点,目录优先、按排序号升序 + * + * @param skillId SKILL 编号 + * @return 节点列表 + */ + List getSkillFileListBySkillId(Long skillId); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImpl.java new file mode 100644 index 0000000..040d859 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImpl.java @@ -0,0 +1,310 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileContentReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileCreateReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileMoveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileRenameReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillFileMapper; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 SKILL 内容文件 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1SkillFileServiceImpl implements Ai1SkillFileService { + + /** + * 固定目录:scripts,存放可执行脚本 + */ + private static final String DIRECTORY_NAME_SCRIPTS = "scripts"; + /** + * 固定目录:reference,存放参考文档 + */ + private static final String DIRECTORY_NAME_REFERENCE = "reference"; + + /** + * SKILL.md 默认内容模板:占位符依次为名称、描述、名称 + */ + private static final String SKILL_FILE_CONTENT_TEMPLATE = "---\n" + + "name: {}\n" + + "description: {}\n" + + "---\n\n" + + "# {}\n\n" + + "本 Skill 文件树符合社区规范:`SKILL.md` 为入口,引用文件用相对路径。\n\n" + + "- `scripts/`:可存放可执行脚本(.py / .sh / .js 等)\n" + + "- `reference/`:可存放参考文档(.md / .json / .yaml 等)\n" + + "- 其他文件/目录可自由新增扩展\n"; + /** + * 新建 Markdown 文件的默认内容模板 + */ + private static final String MARKDOWN_FILE_CONTENT_TEMPLATE = "# 新文档\n\n请在此编写内容(Markdown)。\n"; + + /** + * 名称最大长度 + */ + private static final int NAME_MAX_LENGTH = 200; + + @Resource + private Ai1SkillFileMapper skillFileMapper; + + @Resource + private Ai1SkillService skillService; + + @Override + public void createDefaultSkillFileList(Ai1SkillDO skill) { + // 1. 新建时用管理信息初始化 SKILL.md,后续面向模型的名称、描述由文件编辑器独立维护 + String content = StrUtil.format(SKILL_FILE_CONTENT_TEMPLATE, toFrontmatterValue(skill.getName()), + toFrontmatterValue(skill.getDescription()), skill.getName()); + + // 2. 插入固定节点:SKILL.md + scripts/ + reference/ + Ai1SkillFileDO skillFile = Ai1SkillFileDO.builder().skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).fileType("md").content(content) + .locked(true).sort(1).build(); + Ai1SkillFileDO scriptsDirectory = Ai1SkillFileDO.builder().skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(DIRECTORY_NAME_SCRIPTS).type(Ai1SkillFileTypeEnum.DIRECTORY.getType()).locked(true).sort(2).build(); + Ai1SkillFileDO referenceDirectory = Ai1SkillFileDO.builder().skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(DIRECTORY_NAME_REFERENCE).type(Ai1SkillFileTypeEnum.DIRECTORY.getType()).locked(true).sort(3).build(); + skillFileMapper.insertBatch(Arrays.asList(skillFile, scriptsDirectory, referenceDirectory)); + } + + private static String toFrontmatterValue(String value) { + // 元数据值保持单行,避免名称、描述中的换行被解析成额外的字段 + return StrUtil.trim(StrUtil.replaceChars(StrUtil.nullToEmpty(value), new char[]{'\r', '\n'}, " ")); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSkillFile(Ai1SkillFileCreateReqVO createReqVO) { + // 1.1 校验 SKILL 存在、名称格式、父目录 + skillService.validateSkillExists(createReqVO.getSkillId()); + String name = validateSkillFileName(createReqVO.getName()); + validateParentDirectory(createReqVO.getSkillId(), createReqVO.getParentId()); + // 1.2 校验同级唯一 + validateSkillFileNameUnique(null, createReqVO.getSkillId(), createReqVO.getParentId(), name); + + // 2. 插入:排序号追加到同级末尾;文件按扩展名推断类型并填充默认内容 + List siblings = skillFileMapper.selectListBySkillIdAndParentId(createReqVO.getSkillId(), createReqVO.getParentId()); + Integer maxSort = getMaxValue(siblings, Ai1SkillFileDO::getSort); + Ai1SkillFileDO skillFile = Ai1SkillFileDO.builder().skillId(createReqVO.getSkillId()).parentId(createReqVO.getParentId()) + .name(name).type(createReqVO.getType()).locked(false).sort(maxSort != null ? maxSort + 1 : 1).build(); + // Markdown 文件使用默认模板内容,其他文件为空内容 + if (Ai1SkillFileTypeEnum.isFile(createReqVO.getType())) { + skillFile.setFileType(parseFileType(name)); + skillFile.setContent("md".equals(skillFile.getFileType()) ? MARKDOWN_FILE_CONTENT_TEMPLATE : ""); + } + skillFileMapper.insert(skillFile); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(createReqVO.getSkillId()); + return skillFile.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void renameSkillFile(Ai1SkillFileRenameReqVO renameReqVO) { + // 1. 校验存在、非固定、名称格式、同级唯一 + Ai1SkillFileDO skillFile = validateSkillFileExists(renameReqVO.getId()); + validateSkillFileNotLocked(skillFile, "重命名"); + String name = validateSkillFileName(renameReqVO.getName()); + validateSkillFileNameUnique(skillFile.getId(), skillFile.getSkillId(), skillFile.getParentId(), name); + + // 2. 更新:文件同步刷新扩展名 + Ai1SkillFileDO updateObj = new Ai1SkillFileDO().setId(skillFile.getId()).setName(name); + if (Ai1SkillFileTypeEnum.isFile(skillFile.getType())) { + updateObj.setFileType(parseFileType(name)); + } + skillFileMapper.updateById(updateObj); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void moveSkillFile(Ai1SkillFileMoveReqVO moveReqVO) { + // 1.1 校验存在、非固定 + Ai1SkillFileDO skillFile = validateSkillFileExists(moveReqVO.getId()); + validateSkillFileNotLocked(skillFile, "移动"); + // 1.2 校验目标目录:不能是自身或其后代,且必须是同一 SKILL 下的目录 + Long parentId = moveReqVO.getParentId(); + if (ObjUtil.equal(parentId, skillFile.getId())) { + throw exception(SKILL_FILE_MOVE_TO_SELF); + } + if (isDescendant(skillFile.getSkillId(), skillFile.getId(), parentId)) { + throw exception(SKILL_FILE_MOVE_TO_DESCENDANT); + } + validateParentDirectory(skillFile.getSkillId(), parentId); + // 1.3 校验目标目录下同名唯一 + validateSkillFileNameUnique(skillFile.getId(), skillFile.getSkillId(), parentId, skillFile.getName()); + + // 2. 更新 + skillFileMapper.updateById(new Ai1SkillFileDO().setId(skillFile.getId()).setParentId(parentId)); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSkillFileContent(Ai1SkillFileContentReqVO contentReqVO) { + // 1. 校验存在,且为文件 + Ai1SkillFileDO skillFile = validateSkillFileExists(contentReqVO.getId()); + if (!Ai1SkillFileTypeEnum.isFile(skillFile.getType())) { + throw exception(SKILL_FILE_NOT_FILE); + } + + // 2. 更新 + skillFileMapper.updateById(new Ai1SkillFileDO().setId(skillFile.getId()).setContent(contentReqVO.getContent())); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSkillFile(Long id) { + // 1. 校验存在、非固定 + Ai1SkillFileDO skillFile = validateSkillFileExists(id); + validateSkillFileNotLocked(skillFile, "删除"); + + // 2.1 收集自身及全部后代 + List skillFiles = skillFileMapper.selectListBySkillId(skillFile.getSkillId()); + Map> childIdsMap = convertMultiMap(skillFiles, Ai1SkillFileDO::getParentId, Ai1SkillFileDO::getId); + List ids = new ArrayList<>(); + ids.add(skillFile.getId()); + Deque queue = new ArrayDeque<>(ids); + // 按层级向下广度遍历,最多 Short.MAX_VALUE 次,避免异常数据中的环导致死循环 + for (int i = 0; i < Short.MAX_VALUE && !queue.isEmpty(); i++) { + List childIds = childIdsMap.get(queue.poll()); + if (CollUtil.isNotEmpty(childIds)) { + ids.addAll(childIds); + queue.addAll(childIds); + } + } + // 2.2 逻辑删除 + skillFileMapper.deleteByIds(ids); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + public void deleteSkillFileListBySkillIds(Collection skillIds) { + if (CollUtil.isEmpty(skillIds)) { + return; + } + skillFileMapper.deleteBySkillIds(skillIds); + } + + @Override + public Ai1SkillFileDO getSkillFile(Long id) { + return skillFileMapper.selectById(id); + } + + @Override + public List getSkillFileListBySkillId(Long skillId) { + return skillFileMapper.selectListBySkillId(skillId); + } + + private Ai1SkillFileDO validateSkillFileExists(Long id) { + Ai1SkillFileDO skillFile = skillFileMapper.selectById(id); + if (skillFile == null) { + throw exception(SKILL_FILE_NOT_EXISTS); + } + return skillFile; + } + + private static void validateSkillFileNotLocked(Ai1SkillFileDO skillFile, String operation) { + if (Boolean.TRUE.equals(skillFile.getLocked())) { + throw exception(SKILL_FILE_LOCKED, operation); + } + } + + /** + * 校验名称格式:去除首尾空白,长度不超过 200,且不包含路径分隔符、控制字符,不能为 . 或 .. + * + * @return 去除首尾空白后的名称 + */ + private static String validateSkillFileName(String name) { + String trimmed = StrUtil.trim(name); + if (StrUtil.isEmpty(trimmed) || trimmed.length() > NAME_MAX_LENGTH || StrUtil.equalsAny(trimmed, ".", "..") + || StrUtil.containsAny(trimmed, '/', '\\') || trimmed.chars().anyMatch(Character::isISOControl)) { + throw exception(SKILL_FILE_NAME_INVALID); + } + return trimmed; + } + + /** + * 校验父目录:根级直接通过;否则必须是同一 SKILL 下的目录 + */ + private void validateParentDirectory(Long skillId, Long parentId) { + if (ObjUtil.equal(parentId, Ai1SkillFileDO.PARENT_ID_ROOT)) { + return; + } + Ai1SkillFileDO parent = skillFileMapper.selectById(parentId); + if (parent == null) { + throw exception(SKILL_FILE_PARENT_NOT_EXISTS); + } + if (ObjUtil.notEqual(parent.getSkillId(), skillId) || !Ai1SkillFileTypeEnum.isDirectory(parent.getType())) { + throw exception(SKILL_FILE_PARENT_INVALID); + } + } + + private void validateSkillFileNameUnique(Long id, Long skillId, Long parentId, String name) { + Ai1SkillFileDO skillFile = skillFileMapper.selectBySkillIdAndParentIdAndName(skillId, parentId, name); + if (skillFile != null && ObjUtil.notEqual(skillFile.getId(), id)) { + throw exception(SKILL_FILE_NAME_DUPLICATE, name); + } + } + + /** + * 判断 candidateId 是否为 nodeId 的后代(移动时的成环保护) + */ + private boolean isDescendant(Long skillId, Long nodeId, Long candidateId) { + // 1. 查询 SKILL 下全部节点,构建编号映射 + List skillFiles = skillFileMapper.selectListBySkillId(skillId); + Map fileMap = convertMap(skillFiles, Ai1SkillFileDO::getId); + + // 2. 沿父链向上查找,最多遍历节点总数次,防御异常数据中的环 + Long currentId = candidateId; + for (int i = 0; i <= fileMap.size() && ObjUtil.notEqual(currentId, Ai1SkillFileDO.PARENT_ID_ROOT); i++) { + Ai1SkillFileDO current = fileMap.get(currentId); + if (current == null) { + return false; + } + if (ObjUtil.equal(current.getParentId(), nodeId)) { + return true; + } + currentId = current.getParentId(); + } + return false; + } + + /** + * 从文件名推断扩展名:小写,无扩展名时默认 txt + */ + private static String parseFileType(String name) { + String suffix = FileUtil.getSuffix(name); + return StrUtil.isEmpty(suffix) ? "txt" : suffix.toLowerCase(Locale.ROOT); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillService.java new file mode 100644 index 0000000..78e5e16 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillService.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 SKILL Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1SkillService { + + /** + * 创建 SKILL + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSkill(@Valid Ai1SkillSaveReqVO createReqVO); + + /** + * 更新 SKILL + * + * @param updateReqVO 更新信息 + */ + void updateSkill(@Valid Ai1SkillSaveReqVO updateReqVO); + + /** + * 删除 SKILL,级联删除内容文件 + * + * @param id 编号 + */ + void deleteSkill(Long id); + + /** + * 批量删除 SKILL,级联删除内容文件 + * + * @param ids 编号列表 + */ + void deleteSkillListByIds(List ids); + + /** + * 获得 SKILL + * + * @param id 编号 + * @return SKILL + */ + Ai1SkillDO getSkill(Long id); + + /** + * 校验 SKILL 是否存在 + * + * @param id 编号 + * @return SKILL + */ + Ai1SkillDO validateSkillExists(Long id); + + /** + * 获得 SKILL 分页 + * + * @param pageReqVO 分页查询 + * @return SKILL 分页 + */ + PageResult getSkillPage(Ai1SkillPageReqVO pageReqVO); + + /** + * 获得指定状态的 SKILL 列表 + * + * @param status 状态 + * @return SKILL 列表 + */ + List getSkillListByStatus(Integer status); + + /** + * 获得 SKILL 列表 + * + * @param ids 编号集合 + * @return SKILL 列表 + */ + List getSkillList(Collection ids); + + /** + * 刷新 SKILL 的更新时间:内容文件变更后调用,作为本地物化的变更指纹 + * + * @param id 编号 + */ + void touchSkill(Long id); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImpl.java new file mode 100644 index 0000000..36da4eb --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImpl.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 SKILL Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1SkillServiceImpl implements Ai1SkillService { + + @Resource + private Ai1SkillMapper skillMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SkillFileService skillFileService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSkill(Ai1SkillSaveReqVO createReqVO) { + // 1. 校验名称唯一 + validateSkillNameUnique(null, createReqVO.getName()); + + // 2. 插入 + Ai1SkillDO skill = BeanUtils.toBean(createReqVO, Ai1SkillDO.class); + skillMapper.insert(skill); + + // 3. 创建默认文件:SKILL.md + scripts/ + reference/ + skillFileService.createDefaultSkillFileList(skill); + return skill.getId(); + } + + @Override + public void updateSkill(Ai1SkillSaveReqVO updateReqVO) { + // 1. 校验存在、名称唯一 + validateSkillExists(updateReqVO.getId()); + validateSkillNameUnique(updateReqVO.getId(), updateReqVO.getName()); + + // 2. 更新管理信息;SKILL.md 中面向模型的名称、描述由文件编辑器独立维护 + Ai1SkillDO updateObj = BeanUtils.toBean(updateReqVO, Ai1SkillDO.class); + skillMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSkill(Long id) { + deleteSkillListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSkillListByIds(List ids) { + // 1.1 校验存在 + ids.forEach(this::validateSkillExists); + // 1.2 校验未被 Agent 绑定 + if (agentService.getAgentCountBySkillIds(ids) > 0) { + throw exception(SKILL_USED_BY_AGENT); + } + + // 2. 删除 SKILL,并级联删除内容文件 + skillMapper.deleteByIds(ids); + skillFileService.deleteSkillFileListBySkillIds(ids); + } + + @Override + public Ai1SkillDO getSkill(Long id) { + return skillMapper.selectById(id); + } + + @Override + public Ai1SkillDO validateSkillExists(Long id) { + Ai1SkillDO skill = skillMapper.selectById(id); + if (skill == null) { + throw exception(SKILL_NOT_EXISTS); + } + return skill; + } + + @Override + public PageResult getSkillPage(Ai1SkillPageReqVO pageReqVO) { + return skillMapper.selectPage(pageReqVO); + } + + @Override + public List getSkillListByStatus(Integer status) { + return skillMapper.selectListByStatus(status); + } + + @Override + public List getSkillList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return skillMapper.selectByIds(ids); + } + + @Override + public void touchSkill(Long id) { + skillMapper.updateById(new Ai1SkillDO().setId(id)); + } + + /** + * 校验名称租户内唯一 + */ + private void validateSkillNameUnique(Long id, String name) { + Ai1SkillDO skill = skillMapper.selectByName(name); + if (skill != null && ObjUtil.notEqual(skill.getId(), id)) { + throw exception(SKILL_NAME_DUPLICATE, name); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java new file mode 100644 index 0000000..f4b7b0f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.ai1.util; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ClassUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.core.env.Environment; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED; + +/** + * AI1 工具类 + * + * @author 芋道源码 + */ +public class Ai1Utils { + + /** + * 解析 DB 等动态配置里的 Spring 占位符,例如 ${OPENAI_API_KEY} + * + * 库里只保存占位符,真实密钥留在环境变量或配置文件;仅在发起调用前解析,响应里仍返回占位符本身 + * + * @param value 待解析的配置值 + * @return 解析后的配置值;不含占位符时原样返回 + */ + public static String resolveSpringPlaceholders(String value) { + if (StrUtil.isBlank(value) || !StrUtil.contains(value, "${")) { + return value; + } + try { + return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); + } catch (IllegalArgumentException ex) { + throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, value); + } + } + + /** + * 解析 Map 中每个值里的 Spring 占位符,键保持不变 + * + * @param values 待解析的值 + * @return 解析后的 Map;入参为 null 时返回 null + */ + public static Map resolveSpringPlaceholders(Map values) { + if (values == null) { + return null; + } + Map resolved = new LinkedHashMap<>(); + values.forEach((key, value) -> resolved.put(key, resolveSpringPlaceholders(value))); + return resolved; + } + + /** + * 解析供应商的请求附属 Header:JSON 数组,格式为 [{"key":"...","value":"..."}] + * + * @param headersJson JSON 字符串 + * @return Header 列表,每项包含 key、value;为空或格式错误时返回 null + */ + public static List> parseHeaders(String headersJson) { + if (StrUtil.isBlank(headersJson)) { + return null; + } + List> items = JsonUtils.parseObjectQuietly(headersJson, new TypeReference>>() {}); + if (items == null) { + return null; + } + List> headers = new ArrayList<>(items.size()); + for (Map item : items) { + // 每项必须是对象,且 key 为标量 + if (item == null || !isSimpleValue(item.get("key"))) { + return null; + } + headers.add(MapUtil.builder("key", MapUtil.getStr(item, "key")) + .put("value", isSimpleValue(item.get("value")) ? MapUtil.getStr(item, "value") : "").build()); + } + return headers; + } + + /** + * 是否为 JSON 标量值:字符串、数字、布尔 + */ + private static boolean isSimpleValue(Object value) { + return value != null && ClassUtil.isSimpleValueType(value.getClass()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java new file mode 100644 index 0000000..67d51f2 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.function.FunctionToolCallback; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1LlmChatTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1LlmChatToolTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1LlmChatTool llmChatTool; + @Mock + private Ai1LlmModelFactory llmModelFactory; + + @Test + public void testChat_mixedTools() { + // 准备参数 + AnnotatedTools annotatedTools = new AnnotatedTools(); + AtomicInteger mcpCalls = new AtomicInteger(); + AtomicInteger skillCalls = new AtomicInteger(); + List tools = List.of(annotatedTools, + FunctionToolCallback.builder("mcp_probe", (String value) -> { + mcpCalls.incrementAndGet(); + return "mcp:" + value; + }).inputType(String.class).description("MCP 回调").build(), + FunctionToolCallback.builder("skill_probe", (String value) -> { + skillCalls.incrementAndGet(); + return "skill:" + value; + }).inputType(String.class).description("SKILL 回调").build()); + + // mock chatModel 和 llmModelFactory 的方法 + ChatModel chatModel = mock(ChatModel.class); + when(chatModel.getDefaultOptions()).thenReturn(ToolCallingChatOptions.builder().build()); + ToolCallingManager manager = ToolCallingManager.builder().build(); + when(chatModel.stream(any(Prompt.class))).thenAnswer(invocation -> { + Prompt prompt = invocation.getArgument(0); + ToolCallingChatOptions options = (ToolCallingChatOptions) prompt.getOptions(); + assertEquals(3, options.getToolCallbacks().size()); + assertEquals(3, manager.resolveToolDefinitions(options).size()); + AssistantMessage toolCalls = AssistantMessage.builder().content("").toolCalls(List.of( + new AssistantMessage.ToolCall("1", "function", "annotated_probe", "{}"), + new AssistantMessage.ToolCall("2", "function", "mcp_probe", "\"hello\""), + new AssistantMessage.ToolCall("3", "function", "skill_probe", "\"hello\""))).build(); + ChatResponse response = new ChatResponse(List.of(new Generation(toolCalls))); + List results = manager.executeToolCalls(prompt, response) + .conversationHistory().stream().filter(ToolResponseMessage.class::isInstance) + .map(ToolResponseMessage.class::cast).flatMap(message -> message.getResponses().stream()).toList(); + assertEquals(3, results.size()); + assertTrue(results.stream().anyMatch(result -> result.responseData().contains("annotated"))); + assertTrue(results.stream().anyMatch(result -> result.responseData().contains("mcp:hello"))); + assertTrue(results.stream().anyMatch(result -> result.responseData().contains("skill:hello"))); + return Flux.just(new ChatResponse(List.of(new Generation(new AssistantMessage("工具调用成功"))))); + }); + ChatClient chatClient = ChatClient.create(chatModel); + when(llmModelFactory.buildChatClient(any(), any())).thenReturn(chatClient); + + // 调用 + Ai1LlmChatTool.ChatText result = llmChatTool.chat(new Ai1ModelRespBO(), "", List.of(), "调用工具", + tools, List.of(), 1L, null, null); + // 断言 + assertEquals("工具调用成功", result.getContent()); + assertEquals(1, annotatedTools.calls); + assertEquals(1, mcpCalls.get()); + assertEquals(1, skillCalls.get()); + } + + // ========== 测试工具 ========== + + public static class AnnotatedTools { + + private int calls; + + @Tool(name = "annotated_probe", description = "注解工具") + public String probe() { + calls++; + return "annotated"; + } + + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java new file mode 100644 index 0000000..73558b8 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java @@ -0,0 +1,149 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Spring AI 1.x 适配回归:通过本地 HTTP 端点验证真实请求、流式分片和模型缓存。 + */ +public class Ai1LlmModelFactoryTest { + + private final Ai1LlmModelFactory factory = new Ai1LlmModelFactory(); + private final List requests = new CopyOnWriteArrayList<>(); + private HttpServer server; + private String baseUrl; + + @BeforeEach + public void before() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requests.add(new Request(exchange.getRequestURI().getPath(), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("X-Session"), + exchange.getRequestHeaders().getFirst("X-Provider"), + JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); + boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings"); + String response = embedding + ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" + : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") + + sse("{\"reasoning_content\":\"想\"}") + + sse("{\"reasoning_content\":\"想到\"}") + + sse("{\"content\":\"你好\"}") + + "data: [DONE]\n\n"; + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", embedding ? "application/json" : "text/event-stream"); + exchange.sendResponseHeaders(200, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + public void after() { + server.stop(0); + } + + @ParameterizedTest + @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) + public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { + Ai1ModelRespBO model = model(baseUrl + prefix); + Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); + ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); + List thinking = new ArrayList<>(); + List content = new ArrayList<>(); + + Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.of(new String[]{"user", "历史"}), + "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); + + assertEquals("你好", result.getContent()); + assertEquals("想想想到", result.getThinking()); + assertEquals(List.of("想", "想", "想到"), thinking); + assertEquals(List.of("你好"), content); + assertEquals(1, requests.size()); + Request request = requests.get(0); + String normalizedPrefix = prefix.isEmpty() ? "/v1" : prefix.replaceAll("/$", ""); + assertEquals(normalizedPrefix + "/chat/completions", request.path()); + assertEquals("Bearer test-key", request.authorization()); + assertEquals("42", request.session()); + assertEquals("provider", request.provider()); + assertEquals("test-model", request.body().get("model").asText()); + assertEquals(3, request.body().get("messages").size()); + assertTrue(request.body().get("stream").asBoolean()); + } + + @Test + public void testEmbedding_endpointAndHeaders() { + Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); + EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); + + assertArrayEquals(new float[]{0.1F, 0.2F}, embedding.embed("文档内容")); + Request request = requests.get(0); + assertEquals("/proxy/v1/embeddings", request.path()); + assertEquals("Bearer test-key", request.authorization()); + assertNull(request.session()); + assertEquals("provider", request.provider()); + assertEquals("test-model", request.body().get("model").asText()); + assertEquals("文档内容", request.body().get("input").get(0).asText()); + } + + @Test + public void testCache_sessionIsolationAndInvalidation() { + Ai1ModelRespBO model = model(baseUrl); + Object chat = chatModel(model, 1L); + assertSame(chat, chatModel(model, 1L)); + assertNotSame(chat, chatModel(model, 2L)); + EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); + assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); + + factory.evictByModelId(model.getModelId()); + assertNotSame(chat, chatModel(model, 1L)); + assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model)); + model.setHeaders(List.of(Map.of("key", "X-Provider", "value", "provider"))); + chat = chatModel(model, 1L); + assertSame(chat, chatModel(model, 2L)); + model.setApiKey("changed-key"); + assertNotSame(chat, chatModel(model, 1L)); + chat = chatModel(model, 1L); + factory.evictByProviderId(model.getProviderId()); + assertNotSame(chat, chatModel(model, 1L)); + } + + private Object chatModel(Ai1ModelRespBO model, Long sessionId) { + return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); + } + + private static Ai1ModelRespBO model(String url) { + return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key") + .setModel("test-model").setHeaders(List.of(Map.of("key", "X-Session", "value", "{session}"), + Map.of("key", "X-Provider", "value", "provider"))); + } + + private static String sse(String delta) { + return "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test-model\"," + + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n"; + } + + private record Request(String path, String authorization, String session, String provider, JsonNode body) { + } +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolConcurrencyTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolConcurrencyTest.java new file mode 100644 index 0000000..3768f4b --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolConcurrencyTest.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1McpClientTool} 的并发单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpClientToolConcurrencyTest { + + @Test + public void testGetOrCreateClient_differentMcpConnectsIndependently() throws Exception { + // mock 数据 + Ai1McpClientTool tool = new Ai1McpClientTool(); + McpSyncClient slowClient = mock(McpSyncClient.class); + McpSyncClient readyClient = mock(McpSyncClient.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // mock slowClient 的方法 + when(slowClient.initialize()).thenAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + throw new IllegalStateException("连接失败"); + }); + // 准备参数 + Ai1McpDO slowMcp = new Ai1McpDO().setId(1L).setName("不可达服务").setTransport("http") + .setUrl("http://127.0.0.1:8081/mcp"); + Ai1McpDO readyMcp = new Ai1McpDO().setId(2L).setName("可用服务").setTransport("http") + .setUrl("http://127.0.0.1:8082/mcp"); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future slow = executor.submit(() -> getOrCreateClient(tool, slowMcp, slowClient)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future ready = executor.submit(() -> getOrCreateClient(tool, readyMcp, readyClient)); + // 断言 + assertSame(readyClient, ready.get(2, TimeUnit.SECONDS)); + assertFalse(slow.isDone()); + verify(readyClient).initialize(); + release.countDown(); + ExecutionException exception = assertThrows(ExecutionException.class, () -> slow.get(5, TimeUnit.SECONDS)); + assertEquals("连接失败", exception.getCause().getMessage()); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + tool.destroy(); + } + } + + @Test + public void testGetOrCreateClient_sameMcpInitializesOnce() throws Exception { + // mock 数据 + Ai1McpClientTool tool = new Ai1McpClientTool(); + McpSyncClient firstClient = mock(McpSyncClient.class); + McpSyncClient secondClient = mock(McpSyncClient.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicReference secondThread = new AtomicReference<>(); + CountDownLatch release = new CountDownLatch(1); + // mock firstClient 的方法 + when(firstClient.initialize()).thenAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }); + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("测试服务").setTransport("http") + .setUrl("http://127.0.0.1:8081/mcp"); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future first = executor.submit(() -> getOrCreateClient(tool, mcp, firstClient)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + secondStarted.countDown(); + return getOrCreateClient(tool, mcp, secondClient); + }); + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> { + while (secondThread.get().getState() != Thread.State.BLOCKED + || Arrays.stream(secondThread.get().getStackTrace()).noneMatch(frame -> + frame.getClassName().equals(Ai1McpClientTool.class.getName()) + && frame.getMethodName().equals("getOrCreateClient"))) { + Thread.sleep(5); + } + }); + release.countDown(); + // 断言 + assertSame(firstClient, first.get(5, TimeUnit.SECONDS)); + assertSame(firstClient, second.get(5, TimeUnit.SECONDS)); + verify(firstClient).initialize(); + verifyNoInteractions(secondClient); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + tool.destroy(); + } + } + + private static McpSyncClient getOrCreateClient(Ai1McpClientTool tool, Ai1McpDO mcp, McpSyncClient client) { + AtomicReference transport = new AtomicReference<>(); + McpClient.SyncSpec spec = mock(McpClient.SyncSpec.class, RETURNS_SELF); + when(spec.build()).thenReturn(client); + try (MockedStatic clientFactory = mockStatic(McpClient.class)) { + clientFactory.when(() -> McpClient.sync(any(McpClientTransport.class))).thenAnswer(invocation -> { + transport.set(invocation.getArgument(0)); + return spec; + }); + return ReflectionTestUtils.invokeMethod(tool, "getOrCreateClient", mcp); + } finally { + if (transport.get() != null) { + transport.get().closeGracefully().block(Duration.ofSeconds(5)); + } + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolRecoveryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolRecoveryTest.java new file mode 100644 index 0000000..4b31874 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolRecoveryTest.java @@ -0,0 +1,330 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpTransportException; +import io.modelcontextprotocol.spec.McpTransportSessionClosedException; +import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1McpClientTool} 的故障恢复单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpClientToolRecoveryTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1McpClientTool mcpClientTool; + + @Mock + private McpSyncClient failedClient; + @Mock + private McpSyncClient readyClient; + + @AfterEach + public void after() { + mcpClientTool.destroy(); + } + + @Test + public void testListTools_initializeFailureCanRetry() { + // mock failedClient 和 readyClient 的方法 + when(failedClient.initialize()).thenThrow(new IllegalStateException("建连失败")); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, failedClient).isEmpty()); + verify(failedClient).closeGracefully(); + assertEquals(tools, listTools(mcp, readyClient)); + assertEquals(tools, listTools(mcp, readyClient)); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + } + + @Test + public void testListTools_listFailureCanRetry() { + // mock readyClient 的方法 + List tools = buildTools(); + when(readyClient.listTools()).thenThrow(new IllegalStateException("列举失败")) + .thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, readyClient).isEmpty()); + assertEquals(tools, listTools(mcp, readyClient)); + assertEquals(tools, listTools(mcp, readyClient)); + verify(readyClient).initialize(); + verify(readyClient, times(2)).listTools(); + } + + @Test + public void testListTools_successfulEmptyResultIsCached() { + // mock readyClient 的方法 + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(List.of(), null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, readyClient).isEmpty()); + assertTrue(listTools(mcp, readyClient).isEmpty()); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + } + + @Test + public void testListTools_closeFailureDoesNotPreventRetry() { + // mock failedClient 和 readyClient 的方法 + when(failedClient.initialize()).thenThrow(new IllegalStateException("建连失败")); + doThrow(new IllegalStateException("关闭失败")).when(failedClient).closeGracefully(); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, failedClient).isEmpty()); + assertEquals(tools, listTools(mcp, readyClient)); + verify(failedClient).closeGracefully(); + verify(readyClient).initialize(); + } + + @ParameterizedTest + @MethodSource("callFailures") + public void testCallTool_failureRebuildsClient(RuntimeException failure) { + // mock failedClient 和 readyClient 的方法 + List tools = buildTools(); + when(failedClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(failedClient.callTool(any())).thenAnswer(invocation -> { + throw failure; + }); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言失败原样返回,本次不自动重跑 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertSame(failure, assertThrows(RuntimeException.class, + () -> mcpClientTool.callTool(mcp, "calculator", null))); + verify(failedClient).callTool(any()); + verify(failedClient).closeGracefully(); + verify(readyClient, never()).callTool(any()); + // 调用,并断言工具缓存已清理,下次列举和调用使用新客户端 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient).callTool(any()); + return null; + }, failedClient, readyClient); + } + + @Test + public void testCallTool_businessErrorKeepsClient() { + // mock readyClient 的方法 + McpError failure = McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS).message("参数错误").build(); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenThrow(failure).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言业务错误保留客户端和工具缓存 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertSame(failure, assertThrows(McpError.class, + () -> mcpClientTool.callTool(mcp, "calculator", Map.of()))); + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient, never()).closeGracefully(); + return null; + }, readyClient); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testCallTool_resultKeepsClient(boolean isError) { + // mock readyClient 的方法 + when(readyClient.callTool(any())).thenReturn(buildResult(isError)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言工具结果(含 isError)均不会断开连接 + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient, times(2)).callTool(any()); + verify(readyClient, never()).closeGracefully(); + return null; + }, readyClient); + } + + @Test + public void testCallTool_closeFailureKeepsOriginalError() { + // mock failedClient 和 readyClient 的方法 + McpTransportException failure = new McpTransportException("连接断开"); + when(failedClient.callTool(any())).thenThrow(failure); + doThrow(new IllegalStateException("关闭失败")).when(failedClient).closeGracefully(); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言释放失败不掩盖调用错误,也不妨碍下次重建 + assertSame(failure, assertThrows(McpTransportException.class, + () -> mcpClientTool.callTool(mcp, "calculator", Map.of()))); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(failedClient).closeGracefully(); + verify(readyClient).initialize(); + return null; + }, failedClient, readyClient); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testCallTool_lateFailureKeepsNewClient(boolean configChanged) { + // mock failedClient 和 readyClient 的方法 + List tools = buildTools(); + when(failedClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + Ai1McpDO newMcp = buildMcp(); + if (configChanged) { + newMcp.setUrl("http://127.0.0.1:8082/mcp"); + } + McpTransportException failure = new McpTransportException("旧连接断开"); + when(failedClient.callTool(any())).thenAnswer(invocation -> { + // mock 在途调用期间被配置更新或手动清理,随后已重建新客户端 + if (!configChanged) { + mcpClientTool.evict(mcp.getId()); + } + assertEquals(tools, mcpClientTool.listTools(newMcp)); + throw failure; + }); + + withClients(() -> { + // 调用,并断言旧调用失败不能清理新客户端或新工具缓存 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertSame(failure, assertThrows(McpTransportException.class, + () -> mcpClientTool.callTool(mcp, "calculator", Map.of()))); + assertEquals(tools, mcpClientTool.listTools(newMcp)); + assertEquals("工具结果", mcpClientTool.callTool(newMcp, "calculator", Map.of())); + verify(failedClient).closeGracefully(); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient, never()).closeGracefully(); + return null; + }, failedClient, readyClient); + } + + @Test + public void testCallTool_failureKeepsOtherMcpClient() { + // mock failedClient 和 readyClient 的方法 + McpTransportException failure = new McpTransportException("连接断开"); + when(failedClient.callTool(any())).thenThrow(failure); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + Ai1McpDO otherMcp = buildMcp().setId(2L); + + withClients(() -> { + // 调用,并断言某个 MCP 失败不影响其他服务的连接与工具缓存 + assertThrows(McpTransportException.class, () -> mcpClientTool.callTool(mcp, "calculator", Map.of())); + assertEquals(tools, mcpClientTool.listTools(otherMcp)); + assertThrows(McpTransportException.class, () -> mcpClientTool.callTool(mcp, "calculator", Map.of())); + assertEquals(tools, mcpClientTool.listTools(otherMcp)); + assertEquals("工具结果", mcpClientTool.callTool(otherMcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient, never()).closeGracefully(); + return null; + }, failedClient, readyClient, failedClient); + } + + // ========== 测试数据 ========== + + private static Ai1McpDO buildMcp() { + return new Ai1McpDO().setId(1L).setName("测试服务").setTransport("http") + .setUrl("http://127.0.0.1:8081/mcp"); + } + + private static List buildTools() { + return List.of(McpSchema.Tool.builder("calculator").description("计算器") + .inputSchema(Map.of("type", "object")).build()); + } + + private static McpSchema.CallToolResult buildResult(boolean isError) { + return McpSchema.CallToolResult.builder().addTextContent("工具结果").isError(isError).build(); + } + + private static Stream callFailures() { + return Stream.of(new McpTransportSessionClosedException(), new McpTransportSessionNotFoundException("session"), + new McpTransportException("HTTP 连接失败"), new RuntimeException("Failed to enqueue message"), + new RuntimeException("MCP session with server terminated"), + new RuntimeException(new IOException("管道已关闭")), + reactor.core.Exceptions.propagate(new TimeoutException("工具调用超时"))); + } + + // ========== mock 方法 ========== + + private List listTools(Ai1McpDO mcp, McpSyncClient client) { + return withClients(() -> mcpClientTool.listTools(mcp), client); + } + + private T withClients(Supplier action, McpSyncClient... clients) { + List transports = new ArrayList<>(); + McpClient.SyncSpec spec = mock(McpClient.SyncSpec.class, RETURNS_SELF); + lenient().when(spec.build()).thenReturn(clients[0], Arrays.copyOfRange(clients, 1, clients.length)); + try (MockedStatic clientFactory = mockStatic(McpClient.class)) { + clientFactory.when(() -> McpClient.sync(any(McpClientTransport.class))).thenAnswer(invocation -> { + transports.add(invocation.getArgument(0)); + return spec; + }); + return action.get(); + } finally { + for (McpClientTransport transport : transports) { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolTest.java new file mode 100644 index 0000000..ab68b44 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolTest.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1McpClientTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpClientToolTest { + + @Test + public void testParseConfig_argsArray() { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("stdio") + .setConfig("{\"command\":\"npx\",\"args\":[\"-y\",\"server\"]}"); + + // 调用 + Object config = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", mcp); + // 断言 + assertNotNull(config); + assertEquals(List.of("-y", "server"), ReflectionTestUtils.getField(config, "args")); + } + + @Test + public void testParseConfig_argsNotArray() { + // 准备参数 + List argsValues = List.of("\"-y,server\"", "123", "true", "{\"value\":\"server\"}", "null"); + + for (String args : argsValues) { + Ai1McpDO mcp = new Ai1McpDO().setTransport("stdio") + .setConfig("{\"command\":\"npx\",\"args\":" + args + "}"); + + // 调用 + Object config = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", mcp); + // 断言 + assertNotNull(config); + assertNull(ReflectionTestUtils.getField(config, "args")); + } + } + + @Test + public void testParseConfig_argsEmptyOrMissing() { + // 准备参数 + List configs = List.of("{\"command\":\"npx\",\"args\":[]}", "{\"command\":\"npx\"}"); + + // 调用 + Object emptyConfig = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", + new Ai1McpDO().setTransport("stdio").setConfig(configs.get(0))); + Object missingConfig = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", + new Ai1McpDO().setTransport("stdio").setConfig(configs.get(1))); + // 断言 + assertNotNull(emptyConfig); + assertEquals(List.of(), ReflectionTestUtils.getField(emptyConfig, "args")); + assertNotNull(missingConfig); + assertNull(ReflectionTestUtils.getField(missingConfig, "args")); + } + + @Test + public void testBuildTransport_httpQuery() { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("http") + .setUrl("https://example.com/custom/mcp?token=a%2Bb%3D&tag=x&tag=y#ignored"); + + // 调用 + McpClientTransport transport = ReflectionTestUtils.invokeMethod(new Ai1McpClientTool(), "buildTransport", mcp); + // 断言 + assertNotNull(transport); + try { + assertEquals(URI.create("https://example.com"), ReflectionTestUtils.getField(transport, "baseUri")); + assertEquals("/custom/mcp?token=a%2Bb%3D&tag=x&tag=y", ReflectionTestUtils.getField(transport, "endpoint")); + } finally { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + + @Test + public void testBuildTransport_httpDefaultEndpoint() { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("http").setUrl("https://example.com?api_key=x"); + + // 调用 + McpClientTransport transport = ReflectionTestUtils.invokeMethod(new Ai1McpClientTool(), "buildTransport", mcp); + // 断言 + assertNotNull(transport); + try { + assertEquals("/mcp?api_key=x", ReflectionTestUtils.getField(transport, "endpoint")); + } finally { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + + @Test + public void testBuildTransport_httpRequestPreservesEncoding() throws Exception { + // mock 数据 + AtomicReference requestUri = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requestUri.set(exchange.getRequestURI().toASCIIString()); + exchange.getRequestBody().readAllBytes(); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + server.start(); + try { + for (String endpoint : List.of("/custom%2Fmcp?token=a%2Bb%3D&tag=x&tag=y", "?api_key=x")) { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("http") + .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + endpoint); + McpClientTransport transport = ReflectionTestUtils.invokeMethod(new Ai1McpClientTool(), "buildTransport", mcp); + assertNotNull(transport); + try { + // 调用 + transport.sendMessage(new McpSchema.JSONRPCNotification("notifications/initialized")) + .block(Duration.ofSeconds(5)); + // 断言 + assertEquals(endpoint.startsWith("?") ? "/mcp" + endpoint : endpoint, requestUri.get()); + } finally { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + } finally { + server.stop(0); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java new file mode 100644 index 0000000..be5d2db --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 验证 SDK 2.0.1 的 Jackson 2 实现在 Boot 3 下完成真实 MCP 握手及工具调用。 + */ +public class Ai1McpRuntimeCompatibilityTest { + + @Test + @Timeout(15) + public void testStreamableHttp_initializeListAndCall() throws Exception { + List methods = new CopyOnWriteArrayList<>(); + List paths = new CopyOnWriteArrayList<>(); + List headers = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/proxy/mcp", exchange -> { + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + exchange.close(); + return; + } + paths.add(exchange.getRequestURI().toString()); + headers.add(exchange.getRequestHeaders().getFirst("X-Probe")); + JsonNode request = JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + String method = request.get("method").asText(); + methods.add(method); + if (!request.has("id")) { + exchange.sendResponseHeaders(202, -1); + exchange.close(); + return; + } + Object result = switch (method) { + case "initialize" -> Map.of("protocolVersion", request.path("params").path("protocolVersion").asText(), + "capabilities", Map.of("tools", Map.of("listChanged", false)), + "serverInfo", Map.of("name", "ai1-probe", "version", "1.0")); + case "tools/list" -> Map.of("tools", List.of(Map.of("name", "echo", "description", "Echo tool", + "inputSchema", Map.of("type", "object", "properties", Map.of("value", Map.of("type", "string")))))); + case "tools/call" -> Map.of("content", List.of(Map.of("type", "text", + "text", request.path("params").path("arguments").path("value").asText())), "isError", false); + default -> Map.of(); + }; + byte[] response = JsonUtils.toJsonByte(Map.of("jsonrpc", "2.0", "id", request.get("id"), "result", result)); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (var output = exchange.getResponseBody()) { + output.write(response); + } + }); + server.start(); + Ai1McpClientTool client = new Ai1McpClientTool(); + try { + Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") + .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") + .setHeaders(Map.of("X-Probe", "compatibility")); + + assertEquals(1, client.listTools(mcp).size()); + assertEquals("echo", client.listTools(mcp).get(0).name()); + assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); + assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); + assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); + assertTrue(headers.stream().allMatch("compatibility"::equals)); + } finally { + client.destroy(); + server.stop(0); + } + } +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactoryTest.java new file mode 100644 index 0000000..bf83222 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactoryTest.java @@ -0,0 +1,151 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.service.mcp.Ai1McpService; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.ai.tool.ToolCallback; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link Ai1McpToolFactory} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpToolFactoryTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1McpToolFactory mcpToolFactory; + + @Mock + private Ai1McpService mcpService; + @Mock + private Ai1McpClientTool mcpClientTool; + + @Test + public void testBuildTools_chineseNames() { + // mock 数据 + Ai1McpDO first = buildMcp(1L, "高德地图"); + Ai1McpDO second = buildMcp(2L, "百度地图"); + + // 调用 + List callbacks = buildTools(List.of(first, second), List.of("search")); + // 断言 + assertEquals(List.of("mcp_1__search", "mcp_2__search"), callbacks.stream() + .map(callback -> callback.getToolDefinition().name()).toList()); + } + + @Test + public void testBuildTools_emptyNames() { + // mock 数据 + List mcps = List.of(buildMcp(1L, " "), buildMcp(2L, "___"), buildMcp(3L, "!!!")); + + // 调用 + List callbacks = buildTools(mcps, List.of("search")); + // 断言 + assertEquals(List.of("mcp_1__search", "mcp_2__search", "mcp_3__search"), callbacks.stream() + .map(callback -> callback.getToolDefinition().name()).toList()); + } + + @Test + public void testBuildTools_englishName() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "GitHub"); + + // 调用 + List callbacks = buildTools(List.of(mcp), List.of("search")); + // 断言 + assertEquals("github__search", callbacks.getFirst().getToolDefinition().name()); + } + + @Test + public void testBuildTools_nameAtLimit() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "a".repeat(56)); + + // 调用 + List callbacks = buildTools(List.of(mcp), List.of("search")); + // 断言 + assertEquals("a".repeat(56) + "__search", callbacks.getFirst().getToolDefinition().name()); + assertEquals(64, callbacks.getFirst().getToolDefinition().name().length()); + } + + @Test + public void testBuildTools_longToolNames() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "GitHub"); + String prefix = "search_" + "a".repeat(80); + + // 调用 + List callbacks = buildTools(List.of(mcp), List.of(prefix + "_first", prefix + "_second")); + // 断言 + String firstName = callbacks.get(0).getToolDefinition().name(); + String secondName = callbacks.get(1).getToolDefinition().name(); + assertEquals(64, firstName.length()); + assertEquals(64, secondName.length()); + assertTrue(firstName.matches("[a-zA-Z0-9_-]+")); + assertNotEquals(firstName, secondName); + } + + @Test + public void testBuildTools_longMcpNames() { + // mock 数据 + String prefix = "a".repeat(80); + Ai1McpDO first = buildMcp(1L, prefix + "_first"); + Ai1McpDO second = buildMcp(2L, prefix + "_second"); + + // 调用 + List callbacks = buildTools(List.of(first, second), List.of("search")); + // 断言 + String firstName = callbacks.get(0).getToolDefinition().name(); + String secondName = callbacks.get(1).getToolDefinition().name(); + assertEquals(64, firstName.length()); + assertEquals(64, secondName.length()); + assertNotEquals(firstName, secondName); + } + + @Test + public void testBuildTools_callOriginalTool() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "高德地图"); + String toolName = "search_" + "a".repeat(80); + Map arguments = Map.of("query", "北京"); + // mock mcpClientTool 的方法 + when(mcpClientTool.callTool(mcp, toolName, arguments)).thenReturn("搜索结果"); + List callbacks = buildTools(List.of(mcp), List.of(toolName)); + + // 调用 + String result = callbacks.getFirst().call("{\"query\":\"北京\"}"); + // 断言 + assertEquals("搜索结果", JsonUtils.parseObject(result, String.class)); + verify(mcpClientTool).callTool(mcp, toolName, arguments); + } + + private List buildTools(List mcps, List toolNames) { + Ai1AgentDO agent = new Ai1AgentDO().setId(1L).setMcpIds(mcps.stream().map(Ai1McpDO::getId).toList()); + when(mcpService.getMcpList(agent.getMcpIds())).thenReturn(mcps); + List tools = toolNames.stream().map(name -> McpSchema.Tool.builder(name) + .description("测试工具").inputSchema(Map.of("type", "object")).build()).toList(); + for (Ai1McpDO mcp : mcps) { + when(mcpClientTool.listTools(mcp)).thenReturn(tools); + } + return mcpToolFactory.buildTools(agent); + } + + private static Ai1McpDO buildMcp(Long id, String name) { + return new Ai1McpDO().setId(id).setName(name).setStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java new file mode 100644 index 0000000..6dcf872 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.ai1.harness.model; + +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1ProviderTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1ProviderToolTest { + + // 本机不可达的接口地址 + private static final String UNREACHABLE_BASE_URL = "http://127.0.0.1:1/v1"; + + private final Ai1ProviderTool providerTool = new Ai1ProviderTool(); + + @Test + public void testTestConnect_unreachable() { + // 调用 + Ai1ProviderConnectRespVO result = providerTool.testConnect(UNREACHABLE_BASE_URL, null, null); + // 断言 + assertFalse(result.getConnectable()); + assertEquals(0, result.getHttpCode()); + assertTrue(result.getMessage().startsWith("连接失败:")); + assertNotNull(result.getElapsedMs()); + } + + @Test + public void testListModels_unreachable() { + // 调用,并断言异常 + assertServiceException(() -> providerTool.listModels(UNREACHABLE_BASE_URL, null, null), PROVIDER_REMOTE_MODEL_LOAD_FAIL); + } + + @Test + public void testParseModels() { + // 调用,并断言过滤无效模型 + assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels( + "{\"data\":[{\"id\":\"gpt-4o\"},{\"id\":\"\"},\"x\",{\"id\":\"qwen3\"}]}")); + // 调用,并断言空列表 + assertTrue(Ai1ProviderTool.parseModels("{\"object\":\"list\"}").isEmpty()); + assertTrue(Ai1ProviderTool.parseModels("{\"data\":[]}").isEmpty()); + // 调用,并断言解析失败 + assertNull(Ai1ProviderTool.parseModels("{\"data\":{\"id\":\"gpt-4o\"}}")); + assertNull(Ai1ProviderTool.parseModels("not json")); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolConcurrencyTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolConcurrencyTest.java new file mode 100644 index 0000000..6e9aa2e --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolConcurrencyTest.java @@ -0,0 +1,202 @@ +package cn.iocoder.yudao.module.ai1.harness.rag; + +import cn.hutool.core.lang.loader.LazyFunLoader; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import io.milvus.client.MilvusServiceClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.milvus.MilvusVectorStore; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1RagTool} 的并发单元测试 + * + * @author 芋道源码 + */ +public class Ai1RagToolConcurrencyTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1RagTool ragTool; + + @Mock + private Ai1ModelService modelService; + @Mock + private Ai1LlmModelFactory llmModelFactory; + @Mock + private EmbeddingModel embeddingModel; + @Mock + private MilvusServiceClient milvusClient; + + @BeforeEach + public void before() { + ReflectionTestUtils.setField(ragTool, "ai1Properties", new YudaoAi1Properties()); + ReflectionTestUtils.setField(ragTool, "milvusClientLoader", LazyFunLoader.on(() -> milvusClient)); + when(modelService.getModelRespBO(any(), any())).thenReturn(new Ai1ModelRespBO() + .setModelType(Ai1ModelTypeEnum.EMBEDDING.getType())); + when(llmModelFactory.getOrCreateEmbeddingModel(any())).thenReturn(embeddingModel); + } + + @Test + public void testGetOrCreateVectorStore_sameKnowledgeBaseInitializesOnce() throws Exception { + // mock 数据 + MilvusVectorStore firstStore = mock(MilvusVectorStore.class); + MilvusVectorStore secondStore = mock(MilvusVectorStore.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference secondThread = new AtomicReference<>(); + // mock firstStore 的方法 + doAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }).when(firstStore).afterPropertiesSet(); + // 准备参数 + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future first = executor.submit(() -> getOrCreateVectorStore(knowledgeBase, firstStore)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + return getOrCreateVectorStore(knowledgeBase, secondStore); + }); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> { + while (secondThread.get() == null || secondThread.get().getState() != Thread.State.BLOCKED + || Arrays.stream(secondThread.get().getStackTrace()).noneMatch(frame -> + frame.getClassName().equals(Ai1RagTool.class.getName()) + && frame.getMethodName().equals("getOrCreateVectorStore"))) { + Thread.sleep(5); + } + }); + release.countDown(); + // 断言 + assertSame(firstStore, first.get(5, TimeUnit.SECONDS)); + assertSame(firstStore, second.get(5, TimeUnit.SECONDS)); + verify(firstStore).afterPropertiesSet(); + verifyNoInteractions(secondStore); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + ragTool.destroy(); + } + } + + @Test + public void testGetOrCreateVectorStore_differentKnowledgeBasesInitializeIndependently() throws Exception { + // mock 数据 + MilvusVectorStore firstStore = mock(MilvusVectorStore.class); + MilvusVectorStore secondStore = mock(MilvusVectorStore.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // mock firstStore 的方法 + doAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }).when(firstStore).afterPropertiesSet(); + // 准备参数 + Ai1KnowledgeBaseDO firstBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + Ai1KnowledgeBaseDO secondBase = new Ai1KnowledgeBaseDO().setId(2L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future first = executor.submit(() -> getOrCreateVectorStore(firstBase, firstStore)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> getOrCreateVectorStore(secondBase, secondStore)); + // 断言 + assertSame(secondStore, second.get(2, TimeUnit.SECONDS)); + assertFalse(first.isDone()); + release.countDown(); + assertSame(firstStore, first.get(5, TimeUnit.SECONDS)); + verify(firstStore).afterPropertiesSet(); + verify(secondStore).afterPropertiesSet(); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + ragTool.destroy(); + } + } + + @Test + public void testGetOrCreateVectorStore_initializationFailureCanRetry() throws Exception { + // mock 数据 + MilvusVectorStore failedStore = mock(MilvusVectorStore.class); + MilvusVectorStore readyStore = mock(MilvusVectorStore.class); + // mock failedStore 的方法 + doThrow(new IllegalStateException("初始化失败")).when(failedStore).afterPropertiesSet(); + // 准备参数 + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + + // 调用,并断言异常 + assertThrows(IllegalStateException.class, () -> getOrCreateVectorStore(knowledgeBase, failedStore)); + // 调用 + MilvusVectorStore result = getOrCreateVectorStore(knowledgeBase, readyStore); + // 断言 + assertSame(readyStore, result); + verify(failedStore).afterPropertiesSet(); + verify(readyStore).afterPropertiesSet(); + } + + @Test + public void testGetOrCreateVectorStore_embeddingModelChanged() throws Exception { + // mock 数据 + MilvusVectorStore firstStore = mock(MilvusVectorStore.class); + MilvusVectorStore secondStore = mock(MilvusVectorStore.class); + EmbeddingModel nextModel = mock(EmbeddingModel.class); + // mock llmModelFactory 的方法 + when(llmModelFactory.getOrCreateEmbeddingModel(any())).thenReturn(embeddingModel, nextModel); + // 准备参数 + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + + // 调用 + MilvusVectorStore first = getOrCreateVectorStore(knowledgeBase, firstStore); + MilvusVectorStore second = getOrCreateVectorStore(knowledgeBase, secondStore); + // 断言 + assertSame(firstStore, first); + assertSame(secondStore, second); + verify(firstStore).afterPropertiesSet(); + verify(secondStore).afterPropertiesSet(); + } + + private MilvusVectorStore getOrCreateVectorStore(Ai1KnowledgeBaseDO knowledgeBase, MilvusVectorStore store) { + MilvusVectorStore.Builder builder = mock(MilvusVectorStore.Builder.class, RETURNS_SELF); + when(builder.build()).thenReturn(store); + try (MockedStatic factory = mockStatic(MilvusVectorStore.class)) { + factory.when(() -> MilvusVectorStore.builder(any(MilvusServiceClient.class), any(EmbeddingModel.class))) + .thenReturn(builder); + return ReflectionTestUtils.invokeMethod(ragTool, "getOrCreateVectorStore", knowledgeBase); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolTest.java new file mode 100644 index 0000000..78cc95c --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolTest.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.ai1.harness.rag; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link Ai1RagTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1RagToolTest { + + @Test + public void testSplit_withOverlap() { + // 准备参数 + String content = "abcdefghij"; + + // 调用 + List chunks = Ai1RagTool.split(content, 4, 2); + // 断言 + assertEquals(Arrays.asList("abcd", "cdef", "efgh", "ghij"), chunks); + } + + @Test + public void testSplit_overlapLimitedToChunkSizeMinusOne() { + // 准备参数 + String content = "abcde"; + + // 调用 + List chunks = Ai1RagTool.split(content, 3, 10); + // 断言重叠长度不超过分片大小 - 1 + assertEquals(Arrays.asList("abc", "bcd", "cde"), chunks); + } + + @Test + public void testSplit_empty() { + // 调用 + List emptyChunks = Ai1RagTool.split("", 500, 50); + List shortChunks = Ai1RagTool.split("abc", 500, 50); + // 断言 + assertEquals(Collections.emptyList(), emptyChunks); + assertEquals(Collections.singletonList("abc"), shortChunks); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1RedisBlockingReadIntegrationTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1RedisBlockingReadIntegrationTest.java new file mode 100644 index 0000000..1e0b249 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1RedisBlockingReadIntegrationTest.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.redisson.Redisson; +import org.redisson.api.RStream; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.StreamCreateGroupArgs; +import org.redisson.api.stream.StreamMessageId; +import org.redisson.api.stream.StreamReadArgs; +import org.redisson.api.stream.StreamReadGroupArgs; +import org.redisson.client.codec.StringCodec; +import org.redisson.config.Config; + +import java.time.Duration; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Redis Stream 阻塞读的集成测试 + * + * 默认禁用,使用 -Dai1.integration.redis=true 启用;ai1.integration.redis.address 可指定独立 Redis + * + * @author 芋道源码 + */ +@EnabledIfSystemProperty(named = "ai1.integration.redis", matches = "true") +public class Ai1RedisBlockingReadIntegrationTest { + + @Test + @Timeout(40) + public void testIdleBlockingReads() { + // 准备参数 + Config config = new Config(); + config.useSingleServer().setAddress(System.getProperty("ai1.integration.redis.address", "redis://127.0.0.1:6379")); + // 断言默认命令超时 + assertEquals(3000, config.useSingleServer().getTimeout()); + RedissonClient client = Redisson.create(config); + RStream tasks = client.getStream("ai1:p3-test:tasks:" + UUID.randomUUID(), StringCodec.INSTANCE); + RStream results = client.getStream("ai1:p3-test:results:" + UUID.randomUUID(), StringCodec.INSTANCE); + try { + // mock 数据 + tasks.createGroup(StreamCreateGroupArgs.name("test-workers").makeStream()); + for (int i = 0; i < 2; i++) { + // 调用,并断言 worker 空闲阻塞读 + long start = System.nanoTime(); + assertTrue(tasks.readGroup("test-workers", "test-consumer", StreamReadGroupArgs.neverDelivered() + .count(1).timeout(Duration.ofSeconds(5))).isEmpty()); + long workerMillis = Duration.ofNanos(System.nanoTime() - start).toMillis(); + assertTrue(workerMillis >= 4500, "worker 未等待 5 秒:" + workerMillis); + // 调用,并断言 SSE 空闲阻塞读 + start = System.nanoTime(); + assertTrue(results.read(StreamReadArgs.greaterThan(new StreamMessageId(0, 0)) + .count(64).timeout(Duration.ofSeconds(5))).isEmpty()); + long sseMillis = Duration.ofNanos(System.nanoTime() - start).toMillis(); + assertTrue(sseMillis >= 4500, "SSE 未等待 5 秒:" + sseMillis); + System.out.println("SESS-22 round=" + i + " workerMs=" + workerMillis + " sseMs=" + sseMillis); + } + } finally { + // 清理数据 + tasks.delete(); + results.delete(); + client.shutdown(); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java new file mode 100644 index 0000000..7833e90 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java @@ -0,0 +1,468 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.redisson.api.RStream; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.StreamAddArgs; +import org.redisson.api.stream.StreamMessageId; +import org.redisson.api.stream.StreamRangeArgs; +import org.redisson.api.stream.StreamReadArgs; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.redisson.client.codec.Codec; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SessionStreamTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { + + private static final Long TENANT_ID = 1L; + private static final Long MESSAGE_ID = 1024L; + private static final Long AGENT_ID = 1L; + private static final Long SESSION_ID = 10L; + private static final StreamMessageId RECORD_ID = new StreamMessageId(1, 0); + + @InjectMocks + private Ai1SessionStreamTool sessionStreamTool; + + @Mock + private RedissonClient redissonClient; + @Mock + private RStream taskStream; + @Mock + private RStream resultStream; + @Mock + private RLock taskLock; + @Spy + private YudaoAi1Properties ai1Properties = new YudaoAi1Properties(); + + @Mock + private Ai1SessionMessageService sessionMessageService; + @Mock + private Ai1SessionService sessionService; + @Mock + private Ai1AgentService agentService; + @Mock + private Ai1ModelService modelService; + @Mock + private Ai1KnowledgeBaseService knowledgeBaseService; + @Mock + private Ai1LlmChatTool llmChatTool; + @Mock + private Ai1McpToolFactory mcpToolFactory; + @Mock + private Ai1SkillToolFactory skillToolFactory; + @Mock + private Ai1RagTool ragTool; + + @BeforeEach + public void before() { + // mock redissonClient 的方法(部分用例不访问 Redis) + lenient().doReturn(taskStream).when(redissonClient).getStream(eq("ai1:session:tasks"), any(Codec.class)); + lenient().doReturn(resultStream).when(redissonClient).getStream(eq("ai1:session:result:" + MESSAGE_ID), any(Codec.class)); + lenient().when(redissonClient.getLock("ai1:session:task-lock:" + MESSAGE_ID)).thenReturn(taskLock); + lenient().when(taskLock.tryLock()).thenReturn(true); + lenient().when(taskLock.isHeldByThread(anyLong())).thenReturn(true); + lenient().when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + } + + @Test + public void testStreamResult_disconnectedMustNotComplete() throws Exception { + SseEmitter emitter = mock( + SseEmitter.class); + doThrow(new IOException("client disconnected")).when(emitter).send( + any(SseEmitter.SseEventBuilder.class)); + + Boolean shouldComplete = ReflectionTestUtils.invokeMethod( + sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); + + assertEquals(Boolean.FALSE, shouldComplete); + verifyNoInteractions(resultStream); + verify(emitter, never()).complete(); + } + + @Test + public void testStreamResult_terminalMustComplete() { + SseEmitter emitter = mock( + SseEmitter.class); + when(resultStream.read(any(StreamReadArgs.class))) + .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + + Boolean shouldComplete = ReflectionTestUtils.invokeMethod( + sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); + + assertEquals(Boolean.TRUE, shouldComplete); + } + + @Test + public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { + SseEmitter emitter = mock( + SseEmitter.class); + doThrow(new IOException("client disconnected")).when(emitter).send( + any(SseEmitter.SseEventBuilder.class)); + + ReflectionTestUtils.invokeMethod( + Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); + + verify(emitter, never()).complete(); + } + + @Test + public void testFailLostMessage_generating() { + // mock 方法 + AtomicReference tenantRef = new AtomicReference<>(); + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> { + tenantRef.set(TenantContextHolder.getTenantId()); + return new Ai1SessionMessageDO().setId(MESSAGE_ID).setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()); + }); + + // 调用 + sessionStreamTool.failLostMessage(MESSAGE_ID, TENANT_ID); + // 断言 + assertEquals(TENANT_ID, tenantRef.get()); + verify(sessionMessageService).updateSessionMessage(argThat(message -> MESSAGE_ID.equals(message.getId()) + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); + } + + @Test + public void testFailLostMessage_finished() { + // mock sessionMessageService 的方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn( + new Ai1SessionMessageDO().setId(MESSAGE_ID).setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus())); + + // 调用 + sessionStreamTool.failLostMessage(MESSAGE_ID, TENANT_ID); + // 断言 + verify(sessionMessageService, never()).updateSessionMessage(any()); + } + + @Test + public void testHandleTask_withoutTenant() { + // mock 方法 + AtomicReference tenantIdInGenerate = new AtomicReference<>(-1L); + when(agentService.validateAgentExists(AGENT_ID)).thenAnswer(invocation -> { + tenantIdInGenerate.set(TenantContextHolder.getTenantId()); + return new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L); + }); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), eq("你是 客服 的智能助手。"), anyList(), eq("你好"), anyList(), anyList(), eq(SESSION_ID), any(), any())) + .thenReturn(new Ai1LlmChatTool.ChatText("您好", "")); + // 准备参数 + Map fields = buildTaskFields(); + fields.remove("tenantId"); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, fields); + // 断言 + assertNull(tenantIdInGenerate.get()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); + verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messageCaptor.getValue().getStatus()); + assertEquals(List.of("ping", "done"), captureResultTypes()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + } + + @Test + public void testHandleTask_generateInTaskTenant() { + // mock 方法 + AtomicReference tenantIdInGenerate = new AtomicReference<>(); + when(agentService.validateAgentExists(AGENT_ID)).thenAnswer(invocation -> { + tenantIdInGenerate.set(TenantContextHolder.getTenantId()); + return new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L); + }); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), eq("你好"), anyList(), anyList(), eq(SESSION_ID), any(), any())) + .thenAnswer(invocation -> { + Consumer onContent = invocation.getArgument(8); + onContent.accept("您好"); + return new Ai1LlmChatTool.ChatText("您好", ""); + }); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + // 断言 + assertEquals(TENANT_ID, tenantIdInGenerate.get()); + assertNull(TenantContextHolder.getTenantId()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); + verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); + assertEquals(MESSAGE_ID, messageCaptor.getValue().getId()); + assertEquals("您好", messageCaptor.getValue().getContent()); + assertNull(messageCaptor.getValue().getReasoning()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messageCaptor.getValue().getStatus()); + verify(sessionService).touchSession(SESSION_ID); + assertEquals(List.of("ping", "message", "done"), captureResultTypes()); + } + + @Test + public void testHandleTask_generateFailed() { + // mock agentService 的方法 + when(agentService.validateAgentExists(AGENT_ID)).thenThrow(new IllegalStateException("Agent 不存在")); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + // 断言 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); + verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), messageCaptor.getValue().getStatus()); + assertEquals(List.of("ping", "error", "done"), captureResultTypes()); + } + + @Test + public void testHandleTask_locked() { + // mock taskLock 的方法 + when(taskLock.tryLock()).thenReturn(false); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(sessionMessageService, llmChatTool, resultStream, taskStream); + verify(taskLock, never()).unlock(); + } + + @Test + public void testHandleTask_finished() { + // mock sessionMessageService 的方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus())); + + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))) + .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(resultStream, never()).add(any()); + verify(sessionMessageService, never()).updateSessionMessage(any()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + verify(taskLock).unlock(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testHandleTask_reclaimedKeepsPartialResult(boolean reclaimed) { + // mock resultStream 的方法 + Map> results = new LinkedHashMap<>(); + results.put(new StreamMessageId(1, 0), Map.of("type", "thinking", "data", "思考")); + results.put(new StreamMessageId(1, 1), Map.of("type", "message", "data", "第一段")); + results.put(new StreamMessageId(1, 2), Map.of("type", "message", "data", "第二段")); + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(results); + lenient().when(resultStream.isExists()).thenReturn(true); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), reclaimed); + // 断言 + verifyNoInteractions(llmChatTool, agentService); + verify(sessionMessageService).updateSessionMessage(argThat(message -> "第一段第二段".equals(message.getContent()) + && "思考".equals(message.getReasoning()) + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); + assertEquals(List.of("error", "done"), captureResultTypes()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + } + + @Test + public void testHandleTask_reclaimedWithoutResult() { + // mock resultStream 的方法 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(Map.of()); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(sessionMessageService).updateSessionMessage(argThat(message -> "".equals(message.getContent()) + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); + assertEquals(List.of("error", "done"), captureResultTypes()); + } + + @Test + public void testHandleTask_resultReadFailedKeepsPending() { + // mock resultStream 的方法 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenThrow(new IllegalStateException("Redis 不可用")); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(sessionMessageService, never()).updateSessionMessage(any()); + verify(taskStream, never()).ack(anyString(), any(StreamMessageId.class)); + verify(resultStream, never()).add(any()); + verify(taskLock).unlock(); + } + + @Test + public void testHandleTask_lockLostDuringGenerate() { + // mock 方法 + when(agentService.validateAgentExists(AGENT_ID)).thenReturn( + new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L)); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) + .thenAnswer(invocation -> { + when(taskLock.isHeldByThread(anyLong())).thenReturn(false); + Consumer onContent = invocation.getArgument(8); + onContent.accept("旧 worker 输出"); + return new Ai1LlmChatTool.ChatText("旧 worker 输出", ""); + }); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + // 断言 + assertEquals(List.of("ping"), captureResultTypes()); + verify(sessionMessageService, never()).updateSessionMessage(any()); + verify(taskStream, never()).ack(anyString(), any(StreamMessageId.class)); + verify(taskLock, never()).unlock(); + } + + @Test + public void testHandleTask_saveFailedKeepsPending() { + // mock 方法 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(Map.of()); + doThrow(new IllegalStateException("数据库不可用")).when(sessionMessageService).updateSessionMessage(any()); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(taskStream, never()).ack(anyString(), any(StreamMessageId.class)); + verify(resultStream, never()).add(any()); + verify(taskLock).unlock(); + } + + @Test + public void testHandleTask_ackFailedDoesNotRegenerate() { + // mock 方法 + Ai1SessionMessageDO message = new Ai1SessionMessageDO().setId(MESSAGE_ID) + .setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()); + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(message); + doAnswer(invocation -> { + message.setStatus(invocation.getArgument(0).getStatus()); + return null; + }).when(sessionMessageService).updateSessionMessage(any()); + when(agentService.validateAgentExists(AGENT_ID)).thenReturn( + new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L)); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) + .thenReturn(new Ai1LlmChatTool.ChatText("回复", "")); + when(taskStream.ack("ai1-session-workers", RECORD_ID)).thenThrow(new IllegalStateException("ACK 失败")) + .thenReturn(1L); + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))) + .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verify(llmChatTool).chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()); + verify(sessionMessageService).updateSessionMessage(any()); + assertEquals(List.of("ping", "done"), captureResultTypes()); + verify(taskStream, times(2)).ack("ai1-session-workers", RECORD_ID); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testHandleTask_finishedWithoutTerminal(Integer status) { + // mock 方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()).setStatus(status)); + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))).thenReturn(Map.of()); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(sessionMessageService, never()).updateSessionMessage(any()); + assertEquals(status.equals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()) + ? List.of("done") : List.of("error", "done"), captureResultTypes()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + } + + // ========== 测试数据 ========== + + private static Map buildTaskFields() { + Map fields = new HashMap<>(); + fields.put("tenantId", String.valueOf(TENANT_ID)); + fields.put("messageId", String.valueOf(MESSAGE_ID)); + fields.put("agentId", String.valueOf(AGENT_ID)); + fields.put("sessionId", String.valueOf(SESSION_ID)); + fields.put("content", "你好"); + return fields; + } + + // ========== 断言工具 ========== + + @SuppressWarnings({"unchecked", "rawtypes"}) + private List captureResultTypes() { + ArgumentCaptor captor = ArgumentCaptor.forClass(StreamAddArgs.class); + verify(resultStream, atLeastOnce()).add(captor.capture()); + List types = new ArrayList<>(); + for (StreamAddArgs args : captor.getAllValues()) { + // StreamAddArgs 没有读取方法,通过 entries 字段获取事件类型 + types.add(String.valueOf(readEntries(args).get("type"))); + } + return types; + } + + @SuppressWarnings("unchecked") + private static Map readEntries(StreamAddArgs args) { + try { + java.lang.reflect.Field field = args.getClass().getDeclaredField("entries"); + field.setAccessible(true); + return (Map) field.get(args); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java new file mode 100644 index 0000000..136e5aa --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java @@ -0,0 +1,265 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.mockito.Mock; +import org.redisson.Redisson; +import org.redisson.api.RScript; +import org.redisson.api.RStream; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.StreamAddArgs; +import org.redisson.api.stream.StreamCreateGroupArgs; +import org.redisson.api.stream.StreamMessageId; +import org.redisson.api.stream.StreamRangeArgs; +import org.redisson.api.stream.StreamReadGroupArgs; +import org.redisson.client.codec.StringCodec; +import org.redisson.config.Config; +import org.redisson.config.NameMapper; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 会话任务认领与执行锁的 Redis 集成测试 + * + * 默认禁用,使用 -Dai1.integration.redis=true 启用;ai1.integration.redis.address 可指定独立 Redis + * LLM 与数据库使用 mock + * + * @author 芋道源码 + */ +@EnabledIfSystemProperty(named = "ai1.integration.redis", matches = "true") +@Timeout(30) +public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { + + private static final Long MESSAGE_ID = 1024L; + private static final Long SESSION_ID = 10L; + + @Mock + private Ai1SessionMessageService sessionMessageService; + @Mock + private Ai1SessionService sessionService; + @Mock + private Ai1AgentService agentService; + @Mock + private Ai1ModelService modelService; + @Mock + private Ai1KnowledgeBaseService knowledgeBaseService; + @Mock + private Ai1LlmChatTool llmChatTool; + @Mock + private Ai1McpToolFactory mcpToolFactory; + @Mock + private Ai1SkillToolFactory skillToolFactory; + @Mock + private Ai1RagTool ragTool; + + private RedissonClient firstClient; + private RedissonClient secondClient; + private Ai1SessionStreamTool firstWorker; + private Ai1SessionStreamTool secondWorker; + private RStream taskStream; + private RStream resultStream; + private ExecutorService executor; + private final CountDownLatch generated = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicReference message = new AtomicReference<>(); + private final String keyPrefix = "ai1:task-recovery-test:" + UUID.randomUUID() + ":"; + + @BeforeEach + public void before() { + // 准备两个节点与消息 + firstClient = createClient(); + secondClient = createClient(); + firstWorker = createWorker(firstClient); + secondWorker = createWorker(secondClient); + taskStream = secondClient.getStream(SESSION_TASK_STREAM, StringCodec.INSTANCE); + resultStream = secondClient.getStream(String.format(SESSION_RESULT_STREAM, MESSAGE_ID), StringCodec.INSTANCE); + taskStream.createGroup(StreamCreateGroupArgs.name(SESSION_TASK_GROUP).makeStream()); + executor = Executors.newSingleThreadExecutor(); + message.set(new Ai1SessionMessageDO().setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + + // mock 业务方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> message.get()); + doAnswer(invocation -> { + message.set(invocation.getArgument(0)); + return null; + }).when(sessionMessageService).updateSessionMessage(any()); + when(agentService.validateAgentExists(1L)).thenReturn(new Ai1AgentDO().setId(1L).setName("客服") + .setProviderId(1L).setModelId(2L)); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) + .thenAnswer(invocation -> { + Consumer onContent = invocation.getArgument(8); + onContent.accept("第一段"); + generated.countDown(); + assertTrue(release.await(15, TimeUnit.SECONDS)); + onContent.accept("第二段"); + return new Ai1LlmChatTool.ChatText("第一段第二段", ""); + }); + } + + @AfterEach + public void after() throws InterruptedException { + release.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + taskStream.delete(); + resultStream.delete(); + secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).forceUnlock(); + firstClient.shutdown(); + secondClient.shutdown(); + } + + @Test + public void testReclaim_activeWorkerIsNotRegenerated() throws Exception { + // 准备任务,交给第一个节点生成 + StreamMessageId recordId = addTask(); + Map fields = taskStream.readGroup(SESSION_TASK_GROUP, "first", + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId); + Future generation = executor.submit(() -> firstWorker.handleTask(recordId, fields)); + assertTrue(generated.await(5, TimeUnit.SECONDS)); + + // 等待超过两次看门狗租期,再模拟 PEL 空闲超过认领阈值 + Thread.sleep(3200); + assertTrue(secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).isLocked()); + Map> reclaimed = reclaimTask(recordId); + assertTrue(reclaimed.containsKey(recordId)); + secondWorker.handleTask(recordId, reclaimed.get(recordId), true); + + // 断言第二个节点没有生成或确认在途任务 + assertEquals(1, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); + assertEquals(List.of("ping", "message"), resultTypes()); + verify(llmChatTool).chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()); + + // 调用第一个节点继续完成,并断言只有一份输出 + release.countDown(); + generation.get(5, TimeUnit.SECONDS); + assertEquals("第一段第二段", message.get().getContent()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), message.get().getStatus()); + assertEquals(List.of("ping", "message", "message", "done"), resultTypes()); + assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); + } + + @Test + public void testReclaim_stoppedWorkerKeepsPartialResult() throws Exception { + // 准备任务,交给第一个节点生成 + StreamMessageId recordId = addTask(); + Map fields = taskStream.readGroup(SESSION_TASK_GROUP, "first", + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId); + Future generation = executor.submit(() -> firstWorker.handleTask(recordId, fields)); + assertTrue(generated.await(5, TimeUnit.SECONDS)); + + // 停止节点 Redis 客户端,模拟节点退出导致看门狗停止续期 + firstClient.shutdown(); + Thread.sleep(2000); + assertFalse(secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).isLocked()); + Map> reclaimed = reclaimTask(recordId); + secondWorker.handleTask(recordId, reclaimed.get(recordId), true); + + // 断言接管只保留部分内容并标记失败,不再次调用模型 + assertEquals("第一段", message.get().getContent()); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), message.get().getStatus()); + assertEquals(List.of("ping", "message", "error", "done"), resultTypes()); + assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); + verify(llmChatTool).chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()); + + // 调用旧节点恢复,断言不追加旧增量或覆盖回填结果 + release.countDown(); + generation.get(5, TimeUnit.SECONDS); + assertEquals("第一段", message.get().getContent()); + assertEquals(List.of("ping", "message", "error", "done"), resultTypes()); + } + + // ========== 测试数据 ========== + + private RedissonClient createClient() { + Config config = new Config().setLockWatchdogTimeout(1500); + // 两个节点共享本用例的独立 Key 前缀,测试及清理不会访问业务队列 + config.setNameMapper(new NameMapper() { + @Override + public String map(String name) { + return name == null ? null : keyPrefix + name; + } + + @Override + public String unmap(String name) { + return name != null && name.startsWith(keyPrefix) ? name.substring(keyPrefix.length()) : name; + } + }); + config.useSingleServer().setAddress(System.getProperty("ai1.integration.redis.address", "redis://127.0.0.1:6379")); + return Redisson.create(config); + } + + private Ai1SessionStreamTool createWorker(RedissonClient client) { + Ai1SessionStreamTool worker = new Ai1SessionStreamTool(); + ReflectionTestUtils.setField(worker, "redissonClient", client); + ReflectionTestUtils.setField(worker, "ai1Properties", new YudaoAi1Properties()); + ReflectionTestUtils.setField(worker, "sessionMessageService", sessionMessageService); + ReflectionTestUtils.setField(worker, "sessionService", sessionService); + ReflectionTestUtils.setField(worker, "agentService", agentService); + ReflectionTestUtils.setField(worker, "modelService", modelService); + ReflectionTestUtils.setField(worker, "knowledgeBaseService", knowledgeBaseService); + ReflectionTestUtils.setField(worker, "llmChatTool", llmChatTool); + ReflectionTestUtils.setField(worker, "mcpToolFactory", mcpToolFactory); + ReflectionTestUtils.setField(worker, "skillToolFactory", skillToolFactory); + ReflectionTestUtils.setField(worker, "ragTool", ragTool); + return worker; + } + + private StreamMessageId addTask() { + return taskStream.add(StreamAddArgs.entries(Map.of("messageId", String.valueOf(MESSAGE_ID), + "agentId", "1", "sessionId", String.valueOf(SESSION_ID), "content", "你好"))); + } + + private Map> reclaimTask(StreamMessageId recordId) { + // 只调整本用例独立前缀下的任务,复用生产的查询与认领逻辑 + secondClient.getScript(StringCodec.INSTANCE).eval(RScript.Mode.READ_WRITE, + "return #redis.call('XCLAIM', KEYS[1], ARGV[1], 'first', 0, ARGV[2], 'IDLE', 300000, 'JUSTID')", + RScript.ReturnType.LONG, List.of(SESSION_TASK_STREAM), SESSION_TASK_GROUP, recordId.toString()); + return ReflectionTestUtils.invokeMethod(secondWorker, "reclaimTasks", "second"); + } + + private List resultTypes() { + return resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX)).values().stream() + .map(fields -> fields.get("type")).toList(); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java new file mode 100644 index 0000000..18ac5d6 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.ai1.harness.skill; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springaicommunity.agent.tools.*; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 验证 AI1 使用的 agent-utils 0.12.0 工具在 Spring AI 1.x 下的注册、JSON 入参和实际执行。 + */ +public class Ai1SkillRuntimeCompatibilityTest { + + @TempDir + private Path root; + + @Test + public void testSkillAndExecutionTools() throws Exception { + Path skill = Files.createDirectory(root.resolve("probe")); + Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); + Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); + ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); + assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); + assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); + + Map callbacks = Arrays.stream(ToolCallbacks.from( + ShellTools.builder().workingDirectory(root).build(), + FileSystemTools.builder().allowedDirectory(root).build(), + GlobTool.builder().workingDirectory(root).allowedDirectory(root).build(), + GrepTool.builder().workingDirectory(root).allowedDirectory(root).build(), + ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) + .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); + + assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); + assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); + assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); + assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); + assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); + + call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); + assertEquals("before", Files.readString(root.resolve("written.txt"))); + call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); + assertEquals("after", Files.readString(root.resolve("written.txt"))); + + // 保留 0.12.0 的目录约束,不能因迁移而放宽文件和搜索工具访问范围。 + Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); + try { + Files.writeString(outside, "private-marker"); + assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); + assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); + } finally { + Files.deleteIfExists(outside); + } + } + + private static String call(Map callbacks, String name, Map arguments) { + ToolCallback callback = callbacks.get(name); + assertNotNull(callback, name); + assertNotNull(JsonUtils.parseTree(callback.getToolDefinition().inputSchema())); + return callback.call(JsonUtils.toJsonString(arguments)); + } +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java new file mode 100644 index 0000000..f845c2a --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java @@ -0,0 +1,191 @@ +package cn.iocoder.yudao.module.ai1.harness.skill; + +import cn.hutool.core.io.FileUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillFileService; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.ai.tool.ToolCallback; + +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SkillToolFactory} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { + + private static final Long AGENT_ID = 1L; + private static final Long SKILL_ID = 10L; + private static final String SKILL_CONTENT = "---\nname: pdf-reader\ndescription: 读取 PDF\n---\n\n# pdf-reader\n"; + + @InjectMocks + private Ai1SkillToolFactory skillToolFactory; + + @Spy + private YudaoAi1Properties ai1Properties = new YudaoAi1Properties(); + @Mock + private Ai1SkillService skillService; + @Mock + private Ai1SkillFileService skillFileService; + + @TempDir + private Path tempDir; + + @BeforeEach + public void before() { + // 设置 SKILL 文件目录 + ai1Properties.getSkill().setRoot(tempDir.toString()); + } + + @Test + public void testBuildTools_materialize() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo hi"); + + // 调用 + List tools = skillToolFactory.buildTools(buildAgent()); + // 断言 + assertEquals(6, tools.size()); + assertInstanceOf(ToolCallback.class, tools.get(0)); + Path skillDirectory = agentRoot().resolve("pdf-reader"); + assertEquals(SKILL_CONTENT, FileUtil.readUtf8String(skillDirectory.resolve("SKILL.md").toFile())); + assertEquals("echo hi", FileUtil.readUtf8String(skillDirectory.resolve("scripts/run.sh").toFile())); + assertTrue(FileUtil.exist(agentRoot().resolve(".fingerprint").toFile())); + } + + @Test + public void testBuildTools_fingerprintReuseAndRebuild() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo v1"); + Ai1AgentDO agent = buildAgent(); + + // 调用 + skillToolFactory.buildTools(agent); + skillToolFactory.buildTools(agent); + // 断言 + verify(skillFileService, times(1)).getSkillFileListBySkillId(SKILL_ID); + + // mock 残留文件 + FileUtil.writeUtf8String("stale", agentRoot().resolve("stale.txt").toFile()); + // mock 更新时间和文件内容变化 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 11, 0)); + mockSkillFiles("echo v2"); + + // 调用 + skillToolFactory.buildTools(agent); + // 断言 + verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); + assertEquals("echo v2", FileUtil.readUtf8String(agentRoot().resolve("pdf-reader/scripts/run.sh").toFile())); + assertFalse(FileUtil.exist(agentRoot().resolve("stale.txt").toFile())); + } + + @Test + public void testBuildTools_rebuildWhenDirectoryRemoved() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo hi"); + Ai1AgentDO agent = buildAgent(); + skillToolFactory.buildTools(agent); + + // mock 物化目录被删除 + FileUtil.del(agentRoot()); + + // 调用 + skillToolFactory.buildTools(agent); + // 断言 + verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); + assertTrue(FileUtil.exist(agentRoot().resolve("pdf-reader/SKILL.md").toFile())); + } + + @Test + public void testBuildTools_disabledSkill() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.DISABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + + // 调用 + List tools = skillToolFactory.buildTools(buildAgent()); + // 断言只保留执行工具,不物化 SKILL 文件 + assertEquals(5, tools.size()); + assertTrue(tools.stream().noneMatch(ToolCallback.class::isInstance)); + assertFalse(FileUtil.exist(agentRoot().resolve("pdf-reader").toFile())); + verify(skillFileService, never()).getSkillFileListBySkillId(anyLong()); + } + + @Test + public void testBuildTools_noSkill() { + // 调用,并断言 + assertTrue(skillToolFactory.buildTools(new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of())).isEmpty()); + verify(skillService, never()).getSkillList(anyCollection()); + } + + @Test + public void testEvict() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo hi"); + Ai1AgentDO agent = buildAgent(); + skillToolFactory.buildTools(agent); + assertTrue(FileUtil.exist(agentRoot().toFile())); + + // 调用 + skillToolFactory.evict(AGENT_ID); + // 断言 + assertFalse(FileUtil.exist(agentRoot().toFile())); + + // 调用 + skillToolFactory.buildTools(agent); + // 断言重新物化 + verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); + } + + // ========== 测试数据 ========== + + private Path agentRoot() { + return tempDir.resolve("agent_" + AGENT_ID); + } + + private static Ai1AgentDO buildAgent() { + return new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of(SKILL_ID)); + } + + // ========== mock 方法 ========== + + private void mockSkill(Integer status, LocalDateTime updateTime) { + Ai1SkillDO skill = new Ai1SkillDO().setId(SKILL_ID).setName("pdf-reader").setStatus(status); + skill.setUpdateTime(updateTime); + when(skillService.getSkillList(List.of(SKILL_ID))).thenReturn(List.of(skill)); + } + + private void mockSkillFiles(String scriptContent) { + Ai1SkillFileDO skillFile = Ai1SkillFileDO.builder().id(100L).skillId(SKILL_ID).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).content(SKILL_CONTENT).build(); + Ai1SkillFileDO scripts = Ai1SkillFileDO.builder().id(101L).skillId(SKILL_ID).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name("scripts").type(Ai1SkillFileTypeEnum.DIRECTORY.getType()).build(); + Ai1SkillFileDO script = Ai1SkillFileDO.builder().id(102L).skillId(SKILL_ID).parentId(101L) + .name("run.sh").type(Ai1SkillFileTypeEnum.FILE.getType()).content(scriptContent).build(); + when(skillFileService.getSkillFileListBySkillId(SKILL_ID)).thenReturn(Arrays.asList(skillFile, scripts, script)); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java new file mode 100644 index 0000000..537a009 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.module.ai1.service.agent; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.AGENT_DISABLE; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link Ai1AgentServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1AgentServiceImpl.class) +public class Ai1AgentServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1AgentServiceImpl agentService; + + @Resource + private Ai1AgentMapper agentMapper; + + @MockitoBean + private Ai1ModelService modelService; + @MockitoBean + private Ai1SessionService sessionService; + @MockitoBean + private Ai1SkillToolFactory skillToolFactory; + + @Test + public void testCreateAgent_modelNotChat() { + // mock modelService 的方法 + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO() + .setModelType(Ai1ModelTypeEnum.EMBEDDING.getType())); + + // 调用,并断言异常 + assertServiceException(() -> agentService.createAgent(buildAgentSaveReqVO()), MODEL_TYPE_NOT_CHAT); + } + + @Test + public void testCreateAgent_success() { + // mock modelService 的方法 + mockChatModel(); + + // 调用 + Long id = agentService.createAgent(buildAgentSaveReqVO().setKnowledgeBaseIds(Arrays.asList(1L, 2L))); + // 断言 + Ai1AgentDO agent = agentMapper.selectById(id); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), agent.getStatus()); + assertEquals(Arrays.asList(1L, 2L), agent.getKnowledgeBaseIds()); + assertTrue(agent.getMcpIds().isEmpty()); + } + + @Test + public void testUpdateAgent_clearBindings() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO().setSkillIds(Arrays.asList(3L))); + + // 调用 + agentService.updateAgent(buildAgentSaveReqVO().setId(id)); + // 断言 + assertTrue(agentMapper.selectById(id).getSkillIds().isEmpty()); + } + + @Test + public void testUpdateAgentStatus_success() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO()); + + // 调用 + agentService.updateAgentStatus(id, CommonStatusEnum.ENABLE.getStatus()); + // 断言 + Ai1AgentDO agent = agentMapper.selectById(id); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), agent.getStatus()); + assertEquals("客服助手", agent.getName()); + } + + @Test + public void testValidateAgentEnabled_disable() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO()); + + // 调用,并断言异常 + assertServiceException(() -> agentService.validateAgentEnabled(id), AGENT_DISABLE, "客服助手"); + } + + @Test + public void testGetAgentListByStatus() { + // mock 数据 + mockChatModel(); + Long enabledId = agentService.createAgent(buildAgentSaveReqVO()); + agentService.createAgent(buildAgentSaveReqVO()); + agentService.updateAgentStatus(enabledId, CommonStatusEnum.ENABLE.getStatus()); + + // 调用 + List list = agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus()); + // 断言 + assertEquals(1, list.size()); + assertEquals(enabledId, list.get(0).getId()); + } + + @Test + public void testDeleteAgent_cascade() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO()); + + // 调用 + agentService.deleteAgent(id); + // 断言 + assertNull(agentMapper.selectById(id)); + verify(sessionService).deleteSessionListByAgentIds(Collections.singletonList(id)); + verify(skillToolFactory).evict(id); + } + + @Test + public void testGetAgentCountByKnowledgeBaseIdsAndSkillIds() { + // mock 数据 + mockChatModel(); + agentService.createAgent(buildAgentSaveReqVO().setKnowledgeBaseIds(List.of(1L, 12L)).setSkillIds(List.of(3L))); + + // 调用,并断言精确匹配 + assertEquals(1L, agentService.getAgentCountByKnowledgeBaseIds(List.of(12L))); + assertEquals(1L, agentService.getAgentCountByKnowledgeBaseIds(List.of(2L, 1L))); + assertEquals(0L, agentService.getAgentCountByKnowledgeBaseIds(List.of(2L))); + assertEquals(1L, agentService.getAgentCountBySkillIds(List.of(3L))); + assertEquals(0L, agentService.getAgentCountBySkillIds(List.of(1L))); + assertEquals(0L, agentService.getAgentCountBySkillIds(Collections.emptyList())); + } + + // ========== mock 方法 ========== + + private void mockChatModel() { + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO() + .setModelType(Ai1ModelTypeEnum.CHAT.getType())); + } + + // ========== 测试数据 ========== + + private static Ai1AgentSaveReqVO buildAgentSaveReqVO() { + return new Ai1AgentSaveReqVO().setName("客服助手").setProviderId(1L).setModelId(2L).setSystemPrompt("你是客服") + .setKnowledgeBaseIds(Collections.emptyList()).setMcpIds(Collections.emptyList()).setSkillIds(Collections.emptyList()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImplTest.java new file mode 100644 index 0000000..fbb36af --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImplTest.java @@ -0,0 +1,167 @@ +package cn.iocoder.yudao.module.ai1.service.home; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.home.Ai1HomeMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.mcp.Ai1McpMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ModelMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static cn.hutool.core.util.RandomUtil.randomEle; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDayBeginTime; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link Ai1HomeServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1HomeServiceImpl.class) +public class Ai1HomeServiceImplTest extends BaseDbUnitTest { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Resource + private Ai1HomeServiceImpl homeService; + + @Resource + private Ai1HomeMapper homeMapper; + @Resource + private Ai1AgentMapper agentMapper; + @Resource + private Ai1SkillMapper skillMapper; + @Resource + private Ai1McpMapper mcpMapper; + @Resource + private Ai1ModelMapper modelMapper; + @Resource + private Ai1SessionMapper sessionMapper; + @Resource + private Ai1SessionMessageMapper sessionMessageMapper; + + @Test + public void testGetSummary() { + // mock 数据 + agentMapper.insert(randomAgent()); + Ai1AgentDO deletedAgent = randomAgent(); + agentMapper.insert(deletedAgent); + agentMapper.deleteById(deletedAgent.getId()); + agentMapper.insert(randomAgent()); + skillMapper.insert(randomPojo(Ai1SkillDO.class, o -> o.setStatus(0))); + mcpMapper.insert(randomPojo(Ai1McpDO.class, o -> o.setStatus(0))); + for (int i = 0; i < 3; i++) { + modelMapper.insert(randomPojo(Ai1ModelDO.class, o -> o.setType(0).setStatus(0))); + } + + // 调用 + Ai1HomeSummaryRespVO summary = homeService.getSummary(); + // 断言 + assertEquals(2L, summary.getAgentCount()); + assertEquals(1L, summary.getSkillCount()); + assertEquals(1L, summary.getMcpCount()); + assertEquals(3L, summary.getModelCount()); + } + + @Test + public void testGetMessageSummaryByDate() { + // mock 数据 + LocalDateTime now = LocalDateTime.now(); + Long sessionId = insertSession(1L); + insertSessionMessage(sessionId, now); + insertSessionMessage(sessionId, now); + insertSessionMessage(sessionId, now.minusDays(2)); + insertSessionMessage(sessionId, now.minusDays(8)); + Long deletedMessageId = insertSessionMessage(sessionId, now); + sessionMessageMapper.deleteById(deletedMessageId); + + // 调用 + List list = homeService.getMessageSummaryByDate(7); + // 断言 + assertEquals(7, list.size()); + assertEquals(getDayBeginTime(now.minusDays(6)).format(DATE_FORMATTER), list.get(0).getDate()); + assertEquals(now.format(DATE_FORMATTER), list.get(6).getDate()); + assertEquals(2L, list.get(6).getCount()); + assertEquals(0L, list.get(5).getCount()); + assertEquals(1L, list.get(4).getCount()); + assertEquals(3L, list.stream().mapToLong(Ai1HomeMessageSummaryByDateRespVO::getCount).sum()); + } + + @Test + public void testGetMessageSummaryByDate_daysGreaterThan30() { + // 调用 + List list = homeService.getMessageSummaryByDate(60); + // 断言 + assertEquals(60, list.size()); + } + + @Test + public void testGetMessageSummaryByAgent() { + // mock 数据 + LocalDateTime now = LocalDateTime.now(); + Long session1 = insertSession(1L); + Long session2 = insertSession(2L); + Long deletedSession = insertSession(3L); + insertSessionMessage(session1, now); + insertSessionMessage(session1, now.minusDays(1)); + insertSessionMessage(session1, now.minusDays(3)); + insertSessionMessage(session2, now); + insertSessionMessage(session2, now.minusDays(10)); + for (int i = 0; i < 5; i++) { + insertSessionMessage(deletedSession, now); + } + sessionMapper.deleteById(deletedSession); + + // 调用 + List list = homeService.getMessageSummaryByAgent(7); + // 断言 + assertEquals(2, list.size()); + assertEquals(1L, list.get(0).getAgentId()); + assertEquals(3L, list.get(0).getCount()); + assertEquals(2L, list.get(1).getAgentId()); + assertEquals(1L, list.get(1).getCount()); + } + + // ========== 随机对象 ========== + + private static Ai1AgentDO randomAgent() { + return randomPojo(Ai1AgentDO.class, o -> { + o.setStatus(randomEle(new Integer[]{0, 1})); + o.setKnowledgeBaseIds(null).setMcpIds(null).setSkillIds(null); + }); + } + + private Long insertSession(Long agentId) { + Ai1SessionDO session = randomPojo(Ai1SessionDO.class, o -> o.setAgentId(agentId)); + sessionMapper.insert(session); + return session.getId(); + } + + private Long insertSessionMessage(Long sessionId, LocalDateTime createTime) { + Ai1SessionMessageDO message = randomPojo(Ai1SessionMessageDO.class, o -> { + o.setSessionId(sessionId).setRole("user").setStatus(1); + o.setCreateTime(createTime); + }); + sessionMessageMapper.insert(message); + return message.getId(); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImplTest.java new file mode 100644 index 0000000..acca7db --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImplTest.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeBaseMapper; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1KnowledgeBaseServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1KnowledgeBaseServiceImpl.class) +public class Ai1KnowledgeBaseServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1KnowledgeBaseServiceImpl knowledgeBaseService; + @Resource + private Ai1KnowledgeBaseMapper knowledgeBaseMapper; + + @MockitoBean + private Ai1KnowledgeDocumentService knowledgeDocumentService; + @MockitoBean + private Ai1ModelService modelService; + @MockitoBean + private Ai1RagTool ragTool; + @MockitoBean + private Ai1AgentService agentService; + + @Test + public void testGetKnowledgeBaseCountByEmbeddingModelIds() { + // mock 数据:开启与关闭的知识库都算引用,已逻辑删除和其他模型的不算 + knowledgeBaseMapper.insert(new Ai1KnowledgeBaseDO().setName("开启的知识库").setEmbeddingModelId(10L) + .setStatus(CommonStatusEnum.ENABLE.getStatus())); + knowledgeBaseMapper.insert(new Ai1KnowledgeBaseDO().setName("关闭的知识库").setEmbeddingModelId(20L) + .setStatus(CommonStatusEnum.DISABLE.getStatus())); + knowledgeBaseMapper.insert(new Ai1KnowledgeBaseDO().setName("其他模型").setEmbeddingModelId(30L)); + Ai1KnowledgeBaseDO deleted = new Ai1KnowledgeBaseDO().setName("已删除").setEmbeddingModelId(10L); + knowledgeBaseMapper.insert(deleted); + knowledgeBaseMapper.deleteById(deleted.getId()); + + // 调用,并断言引用数量 + assertEquals(2L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Arrays.asList(10L, 20L))); + assertEquals(1L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(10L))); + } + + @Test + public void testGetKnowledgeBaseCountByEmbeddingModelIds_empty() { + // 调用,并断言空参数及未引用模型 + assertEquals(0L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(null)); + assertEquals(0L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.emptyList())); + assertEquals(0L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(999L))); + } + + @ParameterizedTest + @CsvSource({"99,0,false", "100,0,true", "100,99,true", "100,100,false", "500,501,false", "500,-1,false"}) + public void testSaveReqVO_chunkValidation(int chunkSize, int chunkOverlap, boolean valid) { + // 准备参数 + Ai1KnowledgeBaseSaveReqVO reqVO = new Ai1KnowledgeBaseSaveReqVO().setName("知识库") + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L).setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setChunkSize(chunkSize).setChunkOverlap(chunkOverlap).setTopK(5); + + // 调用,并断言边界与组合校验 + if (valid) { + assertDoesNotThrow(() -> ValidationUtils.validate(reqVO)); + } else { + assertThrows(ConstraintViolationException.class, () -> ValidationUtils.validate(reqVO)); + } + } + + @Test + public void testSaveReqVO_chunkRequired() { + // 准备参数,缺少分片大小和重叠 + Ai1KnowledgeBaseSaveReqVO reqVO = new Ai1KnowledgeBaseSaveReqVO().setName("知识库") + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L).setStatus(CommonStatusEnum.ENABLE.getStatus()).setTopK(5); + + // 调用,并断言必填错误而非组合校验空指针 + ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, + () -> ValidationUtils.validate(reqVO)); + assertTrue(ex.getConstraintViolations().stream().anyMatch(v -> "chunkSize".equals(v.getPropertyPath().toString()))); + assertTrue(ex.getConstraintViolations().stream().anyMatch(v -> "chunkOverlap".equals(v.getPropertyPath().toString()))); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImplTest.java new file mode 100644 index 0000000..cee5621 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImplTest.java @@ -0,0 +1,224 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeDocumentMapper; +import cn.iocoder.yudao.module.ai1.enums.knowledge.Ai1KnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1KnowledgeDocumentServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1KnowledgeDocumentServiceImpl.class) +public class Ai1KnowledgeDocumentServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1KnowledgeDocumentServiceImpl knowledgeDocumentService; + + @Resource + private Ai1KnowledgeDocumentMapper knowledgeDocumentMapper; + + @MockitoBean + private Ai1KnowledgeBaseService knowledgeBaseService; + @MockitoBean + private Ai1RagTool ragTool; + @MockitoBean + private Ai1ModelService modelService; + + @Test + public void testCreateKnowledgeDocument_initUnprocessed() { + // 准备参数 + Ai1KnowledgeDocumentSaveReqVO reqVO = new Ai1KnowledgeDocumentSaveReqVO() + .setKnowledgeBaseId(1L).setName("三体.txt").setContent("三体舰队"); + + // 调用 + Long id = knowledgeDocumentService.createKnowledgeDocument(reqVO); + // 断言 + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(id); + assertEquals(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), document.getStatus()); + assertEquals(0, document.getChunkCount()); + verify(knowledgeBaseService).validateKnowledgeBaseExists(1L); + } + + @Test + public void testUploadKnowledgeDocument_fileTypeInvalid() { + // 准备参数 + MockMultipartFile file = new MockMultipartFile("file", "a.pdf", null, "x".getBytes(StandardCharsets.UTF_8)); + + // 调用,并断言异常 + assertServiceException(() -> knowledgeDocumentService.uploadKnowledgeDocument(1L, file), KNOWLEDGE_DOCUMENT_FILE_TYPE_INVALID); + } + + @Test + public void testUploadKnowledgeDocument_success() { + // 准备参数 + MockMultipartFile file = new MockMultipartFile("file", "说明.MD", null, "# 标题".getBytes(StandardCharsets.UTF_8)); + + // 调用 + Long id = knowledgeDocumentService.uploadKnowledgeDocument(1L, file); + // 断言 + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(id); + assertEquals("说明.MD", document.getName()); + assertEquals("# 标题", document.getContent()); + } + + @Test + public void testUpdateKnowledgeDocument_contentChanged() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "旧内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(dbDocument.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.getKnowledgeBase(dbDocument.getKnowledgeBaseId())).thenReturn(knowledgeBase); + + // 调用 + knowledgeDocumentService.updateKnowledgeDocument(new Ai1KnowledgeDocumentSaveReqVO().setId(dbDocument.getId()) + .setKnowledgeBaseId(999L).setName("新名称").setContent("新内容")); + // 断言 + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(dbDocument.getId()); + assertEquals(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), document.getStatus()); + assertEquals(0, document.getChunkCount()); + assertEquals(dbDocument.getKnowledgeBaseId(), document.getKnowledgeBaseId()); + verify(ragTool).deleteByDocumentIds(knowledgeBase, Collections.singletonList(dbDocument.getId())); + } + + @Test + public void testUpdateKnowledgeDocument_nameOnly() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "内容"); + + // 调用 + knowledgeDocumentService.updateKnowledgeDocument(new Ai1KnowledgeDocumentSaveReqVO().setId(dbDocument.getId()) + .setKnowledgeBaseId(dbDocument.getKnowledgeBaseId()).setName("新名称").setContent("内容")); + // 断言 + assertEquals(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), + knowledgeDocumentMapper.selectById(dbDocument.getId()).getStatus()); + verifyNoInteractions(ragTool); + } + + @Test + public void testVectorizeKnowledgeDocument_success() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(dbDocument.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(dbDocument.getKnowledgeBaseId())).thenReturn(knowledgeBase); + when(ragTool.vectorize(knowledgeBase, dbDocument.getId(), "内容")).thenReturn(3); + + // 调用 + Integer chunkCount = knowledgeDocumentService.vectorizeKnowledgeDocument(dbDocument.getId()); + // 断言 + assertEquals(3, chunkCount); + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(dbDocument.getId()); + assertEquals(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), document.getStatus()); + assertEquals(3, document.getChunkCount()); + } + + @Test + public void testVectorizeKnowledgeDocument_fail() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(dbDocument.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(dbDocument.getKnowledgeBaseId())).thenReturn(knowledgeBase); + when(ragTool.vectorize(any(), anyLong(), anyString())).thenThrow(new IllegalStateException("连接失败")); + + // 调用,并断言异常 + assertServiceException(() -> knowledgeDocumentService.vectorizeKnowledgeDocument(dbDocument.getId()), + KNOWLEDGE_DOCUMENT_VECTORIZE_FAIL, "连接失败"); + // 断言 + assertEquals(Ai1KnowledgeDocumentStatusEnum.FAILED.getStatus(), + knowledgeDocumentMapper.selectById(dbDocument.getId()).getStatus()); + } + + @Test + public void testVectorizeListByKnowledgeBaseId_partialFail() { + // mock 数据 + Ai1KnowledgeDocumentDO success = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "成功"); + Ai1KnowledgeDocumentDO failure = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "失败"); + insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), ""); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(success.getKnowledgeBaseId()) + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(knowledgeBase.getId())).thenReturn(knowledgeBase); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.EMBEDDING.getType())); + when(ragTool.vectorize(knowledgeBase, success.getId(), "成功")).thenReturn(1); + when(ragTool.vectorize(knowledgeBase, failure.getId(), "失败")).thenThrow(new IllegalStateException("嵌入失败")); + + // 调用 + Ai1KnowledgeDocumentVectorizeRespVO result = knowledgeDocumentService + .vectorizeKnowledgeDocumentListByKnowledgeBaseId(knowledgeBase.getId()); + // 断言 + assertEquals(1, result.getSuccessCount()); + assertEquals(1, result.getFailureCount()); + } + + @Test + public void testVectorizeListByKnowledgeBaseId_embeddingModelDisabled() { + // mock 数据 + Ai1KnowledgeDocumentDO document = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(document.getKnowledgeBaseId()) + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(knowledgeBase.getId())).thenReturn(knowledgeBase); + when(modelService.getModelRespBO(1L, 2L)).thenThrow(exception(MODEL_DISABLE, "text-embedding")); + + // 调用,并断言异常 + assertServiceException(() -> knowledgeDocumentService.vectorizeKnowledgeDocumentListByKnowledgeBaseId(knowledgeBase.getId()), + MODEL_DISABLE, "text-embedding"); + // 断言 + verify(ragTool, never()).vectorize(any(), anyLong(), anyString()); + assertEquals(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), + knowledgeDocumentMapper.selectById(document.getId()).getStatus()); + } + + @Test + public void testDeleteKnowledgeDocumentList_cleanVectorsByKnowledgeBase() { + // mock 数据 + Ai1KnowledgeDocumentDO document1 = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "1"); + Ai1KnowledgeDocumentDO document2 = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "2"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(document1.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.getKnowledgeBaseList(anyCollection())).thenReturn(Collections.singletonList(knowledgeBase)); + + // 调用 + knowledgeDocumentService.deleteKnowledgeDocumentListByIds(Arrays.asList(document1.getId(), document2.getId())); + // 断言 + assertNull(knowledgeDocumentMapper.selectById(document1.getId())); + assertNull(knowledgeDocumentMapper.selectById(document2.getId())); + verify(ragTool).deleteByDocumentIds(knowledgeBase, Arrays.asList(document1.getId(), document2.getId())); + } + + // ========== 测试数据 ========== + + private Ai1KnowledgeDocumentDO insertDocument(Integer status, String content) { + Ai1KnowledgeDocumentDO document = new Ai1KnowledgeDocumentDO().setKnowledgeBaseId(1L).setName("三体.txt") + .setContent(content).setChunkCount(0).setStatus(status); + knowledgeDocumentMapper.insert(document); + return document; + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImplTest.java new file mode 100644 index 0000000..99dc7c2 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImplTest.java @@ -0,0 +1,288 @@ +package cn.iocoder.yudao.module.ai1.service.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.mcp.Ai1McpMapper; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpClientTool; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link Ai1McpServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1McpServiceImpl.class) +public class Ai1McpServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1McpServiceImpl mcpService; + + @Resource + private Ai1McpMapper mcpMapper; + + @MockitoBean + private Ai1McpClientTool mcpClientTool; + + @Test + public void testCreateMcp_httpGenerateConfig() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(Collections.singletonMap("Authorization", "Bearer x")); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpMapper.selectById(id); + assertEquals("Bearer x", mcp.getHeaders().get("Authorization")); + Map config = JsonUtils.parseMap(mcp.getConfig()); + assertEquals("http", config.get("transport")); + assertEquals("http://127.0.0.1:8081/mcp", config.get("url")); + assertEquals("Bearer x", ((Map) config.get("headers")).get("Authorization")); + } + + @Test + public void testCreateMcp_transportColumnWins() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()).setUrl("http://ignored") + .setConfig("{\"transport\":\"http\",\"command\":\"npx\",\"args\":[\"-y\",\"server\"]}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpMapper.selectById(id); + assertEquals("stdio", mcp.getTransport()); + assertNull(mcp.getUrl()); + assertEquals("stdio", JsonUtils.parseMap(mcp.getConfig()).get("transport")); + } + + @Test + public void testCreateMcp_stdioWithoutCommand() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()).setConfig("{\"args\":[]}"); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.createMcp(reqVO), MCP_COMMAND_REQUIRED); + } + + @Test + public void testCreateMcp_httpWithoutUrl() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.createMcp(reqVO), MCP_URL_REQUIRED); + } + + @Test + public void testCreateMcp_configInvalid() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setConfig("[1,2]"); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.createMcp(reqVO), MCP_CONFIG_INVALID); + } + + @Test + public void testUpdateMcp_switchToStdioClearsUrl() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setUrl("http://127.0.0.1:8081/mcp")); + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()).setId(id) + .setConfig("{\"command\":\"uvx\"}"); + + // 调用 + mcpService.updateMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpMapper.selectById(id); + assertEquals("stdio", mcp.getTransport()); + assertNull(mcp.getUrl()); + verify(mcpClientTool).evict(id); + } + + @Test + public void testUpdateMcp_httpEvictCache() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp")); + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setId(id) + .setUrl("http://127.0.0.1:8082/mcp").setHeaders(Map.of("Authorization", "Bearer new")) + .setConfig("{\"url\":\"http://127.0.0.1:8081/mcp\",\"headers\":{\"Authorization\":\"Bearer old\"}}"); + + // 调用 + mcpService.updateMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals(reqVO.getUrl(), mcp.getUrl()); + assertEquals(reqVO.getHeaders(), mcp.getHeaders()); + Map config = JsonUtils.parseMap(mcp.getConfig()); + assertEquals(reqVO.getUrl(), config.get("url")); + assertEquals(reqVO.getHeaders(), config.get("headers")); + verify(mcpClientTool).evict(id); + } + + @ParameterizedTest + @NullSource + @MethodSource("emptyHeaders") + public void testUpdateMcp_clearHeaders(Map headers) { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(Map.of("Authorization", "Bearer old"))); + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setId(id) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(headers) + .setConfig("{\"transport\":\"http\",\"url\":\"http://127.0.0.1:8081/mcp\"," + + "\"headers\":{\"Authorization\":\"Bearer old\"}}"); + + // 调用 + mcpService.updateMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpService.getMcp(id); + assertTrue(mcp.getHeaders().isEmpty()); + assertFalse(JsonUtils.parseMap(mcp.getConfig()).containsKey("headers")); + verify(mcpClientTool).evict(id); + } + + @Test + public void testCreateMcp_headersColumnWins() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(Map.of("Authorization", "Bearer column")) + .setConfig("{\"headers\":{\"Authorization\":\"Bearer config\"}}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言平铺请求头覆盖 config 中的旧请求头 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals(reqVO.getHeaders(), mcp.getHeaders()); + assertEquals(mcp.getHeaders(), JsonUtils.parseMap(mcp.getConfig()).get("headers")); + } + + @ParameterizedTest + @NullSource + @MethodSource("emptyHeaders") + public void testCreateMcp_emptyHeadersRemoveConfigHeaders(Map headers) { + // 准备参数:平铺请求头为空,config 中仍有旧鉴权 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(headers) + .setConfig("{\"headers\":{\"Authorization\":\"Bearer old\"}}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言列表回显和运行时配置均不保留旧请求头 + Ai1McpDO mcp = mcpService.getMcp(id); + assertTrue(mcp.getHeaders().isEmpty()); + assertFalse(JsonUtils.parseMap(mcp.getConfig()).containsKey("headers")); + } + + @Test + public void testCreateMcp_configUrlFallback() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setConfig("{\"url\":\"http://127.0.0.1:8081/mcp\"}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals("http://127.0.0.1:8081/mcp", mcp.getUrl()); + assertEquals(mcp.getUrl(), JsonUtils.parseMap(mcp.getConfig()).get("url")); + } + + @Test + public void testCreateMcp_conflictingUrls() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp") + .setConfig("{\"url\":\"http://127.0.0.1:8082/mcp\"}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言平铺地址覆盖 config 中的旧地址 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals(reqVO.getUrl(), mcp.getUrl()); + assertEquals(reqVO.getUrl(), JsonUtils.parseMap(mcp.getConfig()).get("url")); + } + + @Test + public void testDeleteMcp_success() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()) + .setConfig("{\"command\":\"npx\"}")); + + // 调用 + mcpService.deleteMcp(id); + // 断言 + assertNull(mcpMapper.selectById(id)); + verify(mcpClientTool).evict(id); + } + + @Test + public void testDeleteMcpListByIds_success() { + // mock 数据 + Long id1 = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp")); + Long id2 = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()) + .setConfig("{\"command\":\"npx\"}")); + Long otherId = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8082/mcp")); + // 准备参数 + List ids = List.of(id1, id2); + + // 调用 + mcpService.deleteMcpListByIds(ids); + // 断言 + assertNull(mcpMapper.selectById(id1)); + assertNull(mcpMapper.selectById(id2)); + assertNotNull(mcpMapper.selectById(otherId)); + verify(mcpClientTool).evict(id1); + verify(mcpClientTool).evict(id2); + } + + @Test + public void testDeleteMcpListByIds_notExists() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp")); + // 准备参数 + List ids = List.of(id, Long.MAX_VALUE); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.deleteMcpListByIds(ids), MCP_NOT_EXISTS); + // 断言 + assertNotNull(mcpMapper.selectById(id)); + verifyNoInteractions(mcpClientTool); + } + + // ========== 测试数据 ========== + + private static Stream> emptyHeaders() { + return Stream.of(Collections.emptyMap()); + } + + private static Ai1McpSaveReqVO buildMcpSaveReqVO(String transport) { + return new Ai1McpSaveReqVO().setName("天气查询").setTransport(transport).setStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java new file mode 100644 index 0000000..58e0909 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java @@ -0,0 +1,367 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelImportReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ModelMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link Ai1ModelServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1ModelServiceImpl.class) +public class Ai1ModelServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1ModelServiceImpl modelService; + + @Resource + private Ai1ModelMapper modelMapper; + + @MockitoBean + private Ai1ProviderService providerService; + @MockitoBean + private Ai1AgentService agentService; + @MockitoBean + private Ai1KnowledgeBaseService knowledgeBaseService; + @MockitoBean + private Ai1LlmModelFactory llmModelFactory; + @MockitoBean + private Ai1ProviderTool providerTool; + + @Test + public void testCreateModel_duplicate() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setProviderId(dbModel.getProviderId()).setModel(dbModel.getModel())); + + // 调用,并断言异常 + assertServiceException(() -> modelService.createModel(reqVO), MODEL_DUPLICATE, dbModel.getModel()); + } + + @Test + public void testCreateModel_sameModelInOtherProvider() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setProviderId(dbModel.getProviderId() + 1).setModel(dbModel.getModel())); + + // 调用 + Long id = modelService.createModel(reqVO); + // 断言 + assertEquals(reqVO.getModel(), modelMapper.selectById(id).getModel()); + } + + @Test + public void testUpdateModel_evictCache() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()).setProviderId(dbModel.getProviderId())); + + // 调用 + modelService.updateModel(reqVO); + // 断言 + assertEquals(reqVO.getModel(), modelMapper.selectById(dbModel.getId()).getModel()); + assertEquals(dbModel.getProviderId(), modelMapper.selectById(dbModel.getId()).getProviderId()); + verify(llmModelFactory).evictByModelId(dbModel.getId()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1}) + public void testUpdateModel_providerCannotChange(int type) { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(type)); + modelMapper.insert(dbModel); + // 准备参数:同类型模型改挂到其他供应商,并同时修改名称与标识 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()) + .setProviderId(dbModel.getProviderId() + 1).setType(type) + .setName("新名称").setModel("new-model")); + + // 调用,并断言异常 + assertServiceException(() -> modelService.updateModel(reqVO), MODEL_NOT_BELONG_PROVIDER); + // 断言归属和其他字段均未修改,也未校验新供应商或清理缓存 + Ai1ModelDO model = modelMapper.selectById(dbModel.getId()); + assertEquals(dbModel.getProviderId(), model.getProviderId()); + assertEquals(dbModel.getName(), model.getName()); + assertEquals(dbModel.getModel(), model.getModel()); + assertEquals(dbModel.getType(), model.getType()); + verifyNoInteractions(providerService, agentService, knowledgeBaseService, llmModelFactory); + } + + @Test + public void testDeleteModel_usedByAgent() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // mock agentService 的方法 + when(agentService.getAgentCountByModelIds(anyList())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.deleteModel(dbModel.getId()), MODEL_USED_BY_AGENT); + // 断言 + assertNotNull(modelMapper.selectById(dbModel.getId())); + } + + @Test + public void testDeleteModel_usedByKnowledgeBase() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(dbModel.getId()))) + .thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.deleteModel(dbModel.getId()), MODEL_USED_BY_KNOWLEDGE_BASE); + // 断言 + assertNotNull(modelMapper.selectById(dbModel.getId())); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testDeleteModelListByIds_usedByKnowledgeBase() { + // mock 数据 + Ai1ModelDO first = randomModelDO(); + Ai1ModelDO second = randomModelDO(); + modelMapper.insert(first); + modelMapper.insert(second); + // 准备参数 + List ids = Arrays.asList(first.getId(), second.getId()); + when(knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(ids)).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.deleteModelListByIds(ids), MODEL_USED_BY_KNOWLEDGE_BASE); + // 断言整批未删除或清理缓存 + assertNotNull(modelMapper.selectById(first.getId())); + assertNotNull(modelMapper.selectById(second.getId())); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testDeleteModelListByIds_success() { + // mock 数据 + Ai1ModelDO first = randomModelDO(); + Ai1ModelDO second = randomModelDO(); + modelMapper.insert(first); + modelMapper.insert(second); + List ids = Arrays.asList(first.getId(), second.getId()); + + // 调用 + modelService.deleteModelListByIds(ids); + // 断言 + assertNull(modelMapper.selectById(first.getId())); + assertNull(modelMapper.selectById(second.getId())); + verify(knowledgeBaseService).getKnowledgeBaseCountByEmbeddingModelIds(ids); + verify(llmModelFactory).evictByModelId(first.getId()); + verify(llmModelFactory).evictByModelId(second.getId()); + } + + @Test + public void testUpdateModel_typeUsedByKnowledgeBase() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(Ai1ModelTypeEnum.EMBEDDING.getType())); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()) + .setProviderId(dbModel.getProviderId()).setType(Ai1ModelTypeEnum.CHAT.getType())); + when(knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(dbModel.getId()))) + .thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.updateModel(reqVO), MODEL_TYPE_USED_BY_KNOWLEDGE_BASE); + // 断言 + assertEquals(Ai1ModelTypeEnum.EMBEDDING.getType(), modelMapper.selectById(dbModel.getId()).getType()); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testUpdateModel_typeUsedByAgent() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(Ai1ModelTypeEnum.CHAT.getType())); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()).setProviderId(dbModel.getProviderId())); + when(agentService.getAgentCountByModelIds(Collections.singletonList(dbModel.getId()))).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.updateModel(reqVO), MODEL_TYPE_USED_BY_AGENT); + // 断言 + assertEquals(Ai1ModelTypeEnum.CHAT.getType(), modelMapper.selectById(dbModel.getId()).getType()); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testUpdateModel_sameTypeDoesNotCheckReferences() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()).setProviderId(dbModel.getProviderId()).setType(dbModel.getType())); + + // 调用 + modelService.updateModel(reqVO); + // 断言改名等操作不查询引用 + assertEquals(reqVO.getName(), modelMapper.selectById(dbModel.getId()).getName()); + verifyNoInteractions(agentService, knowledgeBaseService); + verify(llmModelFactory).evictByModelId(dbModel.getId()); + } + + @Test + public void testUpdateModel_unusedTypeCanChange() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(Ai1ModelTypeEnum.EMBEDDING.getType())); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()) + .setProviderId(dbModel.getProviderId()).setType(Ai1ModelTypeEnum.CHAT.getType())); + + // 调用 + modelService.updateModel(reqVO); + // 断言 + assertEquals(Ai1ModelTypeEnum.CHAT.getType(), modelMapper.selectById(dbModel.getId()).getType()); + verify(llmModelFactory).evictByModelId(dbModel.getId()); + } + + @Test + public void testImportRemoteModelList_skipExisting() { + // mock 数据 + Long providerId = randomLongId(); + modelMapper.insert(randomModelDO(o -> o.setProviderId(providerId).setModel("deepseek-chat"))); + // 准备参数 + Ai1ModelImportReqVO reqVO = new Ai1ModelImportReqVO().setProviderId(providerId) + .setModels(Arrays.asList("deepseek-chat", "deepseek-reasoner", " deepseek-reasoner ", " ")); + + // 调用 + Integer count = modelService.importRemoteModelList(reqVO); + // 断言 + assertEquals(1, count); + List models = modelMapper.selectListByProviderId(providerId); + assertEquals(Arrays.asList("deepseek-chat", "deepseek-reasoner"), convertList(models, Ai1ModelDO::getModel)); + Ai1ModelDO imported = models.get(1); + assertEquals(Ai1ModelTypeEnum.CHAT.getType(), imported.getType()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), imported.getStatus()); + } + + @Test + public void testImportRemoteModelList_allExists() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelImportReqVO reqVO = new Ai1ModelImportReqVO().setProviderId(dbModel.getProviderId()) + .setModels(Arrays.asList(dbModel.getModel())); + + // 调用,并断言异常 + assertServiceException(() -> modelService.importRemoteModelList(reqVO), MODEL_IMPORT_ALL_EXISTS); + } + + @Test + public void testGetRemoteModelList_success() { + // mock providerService 和 providerTool 的方法 + Long providerId = randomLongId(); + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setId(providerId).setHeaders(null)); + when(providerService.validateProviderExists(providerId)).thenReturn(provider); + List remoteModels = Arrays.asList("deepseek-chat", "deepseek-reasoner"); + when(providerTool.listModels(provider.getBaseUrl(), provider.getApiKey(), null)).thenReturn(remoteModels); + + // 调用 + List models = modelService.getRemoteModelList(providerId); + // 断言 + assertEquals(remoteModels, models); + } + + @Test + public void testGetModelRespBO_providerDisable() { + // mock 方法 + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())); + when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); + + // 调用,并断言异常 + assertServiceException(() -> modelService.getModelRespBO(provider.getId(), randomLongId()), + PROVIDER_DISABLE, provider.getName()); + } + + @Test + public void testGetModelRespBO_modelNotBelong() { + // mock 方法 + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); + // mock 数据 + Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId() + 1)); + modelMapper.insert(model); + + // 调用,并断言异常 + assertServiceException(() -> modelService.getModelRespBO(provider.getId(), model.getId()), MODEL_NOT_BELONG_PROVIDER); + } + + @Test + public void testGetModelRespBO_success() { + // mock 方法 + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); + when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); + // mock 数据 + Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); + modelMapper.insert(model); + + // 调用 + Ai1ModelRespBO respBO = modelService.getModelRespBO(provider.getId(), model.getId()); + // 断言 + assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); + assertEquals(model.getModel(), respBO.getModel()); + assertEquals(model.getType(), respBO.getModelType()); + assertEquals(Collections.singletonList("X-App"), convertList(respBO.getHeaders(), header -> header.get("key"))); + } + + // ========== 随机对象 ========== + + @SafeVarargs + private static Ai1ModelDO randomModelDO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setName(StrUtil.maxLength(o.getName(), 10)) + .setType(Ai1ModelTypeEnum.CHAT.getType()).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ModelDO.class, ArrayUtils.append(consumer, consumers)); + } + + @SafeVarargs + private static Ai1ModelSaveReqVO randomModelSaveReqVO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setName(StrUtil.maxLength(o.getName(), 10)) + .setType(Ai1ModelTypeEnum.EMBEDDING.getType()).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ModelSaveReqVO.class, ArrayUtils.append(consumer, consumers)); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java new file mode 100644 index 0000000..9ab4fde --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java @@ -0,0 +1,148 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ProviderMapper; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1ProviderServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1ProviderServiceImpl.class) +public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1ProviderServiceImpl providerService; + + @Resource + private Ai1ProviderMapper providerMapper; + + @MockitoBean + private Ai1ModelService modelService; + @MockitoBean + private Ai1LlmModelFactory llmModelFactory; + @MockitoBean + private Ai1ProviderTool providerTool; + + @Test + public void testCreateProvider_success() { + // 准备参数 + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders("[{\"key\":\"X-Session\",\"value\":\"{session}\"}]")); + + // 调用 + Long id = providerService.createProvider(reqVO); + // 断言 + Ai1ProviderDO provider = providerMapper.selectById(id); + assertEquals(reqVO.getName(), provider.getName()); + assertEquals(reqVO.getApiKey(), provider.getApiKey()); + } + + @Test + public void testUpdateProvider_blankApiKeyKeepsOriginal() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(); + providerMapper.insert(dbProvider); + // 准备参数 + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setId(dbProvider.getId()).setApiKey("")); + + // 调用 + providerService.updateProvider(reqVO); + // 断言 + Ai1ProviderDO provider = providerMapper.selectById(dbProvider.getId()); + assertEquals(dbProvider.getApiKey(), provider.getApiKey()); + assertEquals(reqVO.getBaseUrl(), provider.getBaseUrl()); + verify(llmModelFactory).evictByProviderId(dbProvider.getId()); + } + + @Test + public void testDeleteProvider_hasModel() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(); + providerMapper.insert(dbProvider); + // mock modelService 的方法 + when(modelService.getModelCountByProviderIds(anyList())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> providerService.deleteProvider(dbProvider.getId()), PROVIDER_HAS_MODEL); + // 断言 + assertNotNull(providerMapper.selectById(dbProvider.getId())); + } + + @Test + public void testDeleteProvider_success() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(); + providerMapper.insert(dbProvider); + // mock modelService 的方法 + when(modelService.getModelCountByProviderIds(anyList())).thenReturn(0L); + + // 调用 + providerService.deleteProvider(dbProvider.getId()); + // 断言 + assertNull(providerMapper.selectById(dbProvider.getId())); + verify(llmModelFactory).evictByProviderId(dbProvider.getId()); + } + + @Test + public void testTestProviderConnect_notExists() { + // 调用,并断言异常 + assertServiceException(() -> providerService.testProviderConnect(randomLongId()), PROVIDER_NOT_EXISTS); + } + + @Test + public void testTestProviderConnect_success() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders("[{\"key\":\"X-App\",\"value\":\"yudao\"}]")); + providerMapper.insert(dbProvider); + // mock 方法 + Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); + when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()), + argThat(headers -> Collections.singletonList("X-App").equals(convertList(headers, header -> header.get("key")))))) + .thenReturn(result); + + // 调用 + Ai1ProviderConnectRespVO connectResult = providerService.testProviderConnect(dbProvider.getId()); + // 断言 + assertSame(result, connectResult); + } + + // ========== 随机对象 ========== + + @SafeVarargs + private static Ai1ProviderDO randomProviderDO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setHeaders(null).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ProviderDO.class, ArrayUtils.append(consumer, consumers)); + } + + @SafeVarargs + private static Ai1ProviderSaveReqVO randomProviderSaveReqVO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setHeaders(null).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ProviderSaveReqVO.class, ArrayUtils.append(consumer, consumers)); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java new file mode 100644 index 0000000..63f5d7b --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java @@ -0,0 +1,334 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.harness.session.Ai1SessionStreamTool; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redisson.client.RedisConnectionException; +import org.redisson.client.RedisTimeoutException; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SessionMessageServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SessionMessageServiceImpl.class) +public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { + + private static final Long TENANT_ID = 1L; + private static final Long USER_ID = 100L; + + @Resource + private Ai1SessionMessageServiceImpl sessionMessageService; + + @MockitoSpyBean + private Ai1SessionMessageMapper sessionMessageMapper; + + @MockitoBean + private Ai1SessionService sessionService; + @MockitoBean + private Ai1AgentService agentService; + @MockitoBean + private Ai1SessionStreamTool sessionStreamTool; + + @BeforeEach + public void before() { + // 设置租户上下文 + TenantContextHolder.setTenantId(TENANT_ID); + } + + @AfterEach + public void after() { + // 清理租户上下文 + TenantContextHolder.clear(); + } + + @Test + public void testSendSessionMessageStream_success() { + // mock 方法 + Ai1SessionDO session = new Ai1SessionDO().setId(10L).setAgentId(1L).setUserId(USER_ID) + .setTitle(Ai1SessionDO.TITLE_DEFAULT); + // mock sessionService 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(session); + SseEmitter emitter = new SseEmitter(); + when(sessionStreamTool.open(anyLong(), isNull())).thenReturn(emitter); + // 准备参数 + String content = "三体舰队还有多久到达地球?请结合原著中的时间线详细说明,并对比面壁计划、执剑人与掩体计划等人类应对方案的技术发展差异"; + + // 调用 + SseEmitter result = sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent(content)); + // 断言 + assertSame(emitter, result); + List messages = sessionMessageMapper.selectListBySessionId(10L); + assertEquals(2, messages.size()); + assertEquals(Ai1SessionMessageRoleEnum.USER.getRole(), messages.get(0).getRole()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messages.get(0).getStatus()); + Ai1SessionMessageDO assistant = messages.get(1); + assertEquals(Ai1SessionMessageRoleEnum.ASSISTANT.getRole(), assistant.getRole()); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), assistant.getStatus()); + // 断言首条消息命名和任务投递 + verify(sessionService).updateSessionTitle(eq(10L), + argThat(title -> title.length() == Ai1SessionDO.TITLE_MAX_LENGTH && title.endsWith("..."))); + verify(sessionStreamTool).submit(assistant.getId(), 1L, 10L, content); + verify(sessionStreamTool).open(assistant.getId(), null); + } + + @Test + public void testSendSessionMessageStream_submitFailed() { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doThrow(new RedisConnectionException("Redis 不可用")).when(sessionStreamTool) + .submit(anyLong(), eq(1L), eq(10L), eq("你好")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言用户消息保留、助手不再生成中 + List messages = sessionMessageMapper.selectListBySessionId(10L); + assertEquals(2, messages.size()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messages.get(0).getStatus()); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), messages.get(1).getStatus()); + assertEquals("", messages.get(1).getContent()); + verify(sessionStreamTool, never()).open(anyLong(), any()); + verify(sessionStreamTool).error("会话提交失败,请稍后重试"); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testSendSessionMessageStream_submitTimeoutKeepsWorkerResult(int status) { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doAnswer(invocation -> { + Long messageId = invocation.getArgument(0); + sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(messageId) + .setStatus(status).setContent("worker 已回填").setReasoning("已有思考")); + throw new RedisTimeoutException("投递响应丢失"); + }).when(sessionStreamTool).submit(anyLong(), eq(1L), eq(10L), eq("你好")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言已有终态和内容不被补偿覆盖 + Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); + assertEquals(status, assistant.getStatus()); + assertEquals("worker 已回填", assistant.getContent()); + assertEquals("已有思考", assistant.getReasoning()); + verify(sessionStreamTool, never()).open(anyLong(), any()); + } + + @Test + public void testSendSessionMessageStream_workerCanCompleteAfterSubmitTimeout() { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doThrow(new RedisTimeoutException("投递响应丢失")).when(sessionStreamTool) + .submit(anyLong(), eq(1L), eq(10L), eq("你好")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), assistant.getStatus()); + // 调用 worker 回填,模拟 Redis 实际写入后响应丢失 + sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(assistant.getId()) + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()).setContent("稍后生成成功")); + // 断言后续完成结果仍可落库 + Ai1SessionMessageDO result = sessionMessageMapper.selectById(assistant.getId()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), result.getStatus()); + assertEquals("稍后生成成功", result.getContent()); + } + + @Test + public void testSendSessionMessageStream_openFailedKeepsGenerating() { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + when(sessionStreamTool.open(anyLong(), isNull())).thenThrow(new IllegalStateException("SSE 不可用")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言已投递任务不因 SSE 异常而被标失败 + Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), assistant.getStatus()); + verify(sessionStreamTool).submit(assistant.getId(), 1L, 10L, "你好"); + verify(sessionStreamTool).error("会话提交失败,请稍后重试"); + } + + @Test + public void testSendSessionMessageStream_compensationFailedKeepsOriginalError() { + // mock sessionService、sessionStreamTool 和 sessionMessageMapper 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doThrow(exception(SESSION_NOT_EXISTS)).when(sessionStreamTool) + .submit(anyLong(), eq(1L), eq(10L), eq("你好")); + doThrow(new IllegalStateException("数据库不可用")).when(sessionMessageMapper) + .update(any(Ai1SessionMessageDO.class), any(Wrapper.class)); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言补偿异常不替换原投递业务异常 + verify(sessionStreamTool).error("会话不存在"); + verify(sessionStreamTool, never()).open(anyLong(), any()); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), + sessionMessageMapper.selectListBySessionId(10L).get(1).getStatus()); + } + + @ParameterizedTest + @ValueSource(ints = {47, 48, 49, 50, 51, 100}) + public void testCreateRoundMessages_titleLength(int length) { + // 准备参数 + Ai1SessionDO session = new Ai1SessionDO().setId(10L).setTitle(Ai1SessionDO.TITLE_DEFAULT); + String title = "问".repeat(length); + + // 调用 + sessionMessageService.createRoundMessages(session, " " + title + " "); + // 断言 + verify(sessionService).updateSessionTitle(10L, length > 50 ? "问".repeat(47) + "..." : title); + } + + @Test + public void testCreateRoundMessages_existingMessagesKeepTitle() { + // mock 数据 + insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + // 准备参数 + Ai1SessionDO session = new Ai1SessionDO().setId(10L).setTitle(Ai1SessionDO.TITLE_DEFAULT); + + // 调用 + sessionMessageService.createRoundMessages(session, "已有消息不覆盖标题"); + // 断言 + verify(sessionService, never()).updateSessionTitle(anyLong(), anyString()); + } + + @Test + public void testSendSessionMessageStream_notOwner() { + // mock sessionService 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(exception(SESSION_NOT_EXISTS)); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言 + verify(sessionStreamTool).error("会话不存在"); + verify(sessionStreamTool, never()).submit(anyLong(), anyLong(), anyLong(), anyString()); + assertTrue(sessionMessageMapper.selectListBySessionId(10L).isEmpty()); + } + + @Test + public void testResumeSessionMessageStream_otherUser() { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + // mock sessionService 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(exception(SESSION_NOT_EXISTS)); + + // 调用 + sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), null); + // 断言 + verify(sessionStreamTool).error("会话不存在"); + verify(sessionStreamTool, never()).open(anyLong(), any()); + } + + @Test + public void testResumeSessionMessageStream_userMessage() { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + + // 调用 + sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), null); + // 断言 + verify(sessionStreamTool).error("会话消息不存在"); + verify(sessionStreamTool, never()).open(anyLong(), any()); + } + + @Test + public void testResumeSessionMessageStream_success() { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + + // 调用 + sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), "1-0"); + // 断言 + verify(sessionService).validateSessionMy(USER_ID, 10L); + verify(sessionStreamTool).open(message.getId(), "1-0"); + } + + @ParameterizedTest + @ValueSource(strings = {"database", "ownership", "open"}) + public void testResumeSessionMessageStream_unexpectedException(String stage) { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(message.getId()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + // mock 数据库、归属校验或 SSE 打开异常 + IllegalStateException failure = new IllegalStateException("内部异常详情"); + switch (stage) { + case "database" -> doThrow(failure).when(sessionMessageMapper).selectById(message.getId()); + case "ownership" -> when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(failure); + case "open" -> when(sessionStreamTool.open(message.getId(), "1-0")).thenThrow(failure); + } + SseEmitter emitter = new SseEmitter(); + when(sessionStreamTool.error("会话续传失败,请稍后重试")).thenReturn(emitter); + + // 调用 + SseEmitter result = sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), "1-0"); + // 断言通过 SSE 返回安全提示,不改变服务端生成状态或重新投递任务 + assertSame(emitter, result); + verify(sessionStreamTool).error("会话续传失败,请稍后重试"); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), + sessionMessageMapper.selectListBySessionId(10L).get(0).getStatus()); + verify(sessionStreamTool, never()).submit(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + public void testGetSessionMessageListBySessionIdAndIdLessThan() { + // mock 数据 + Ai1SessionMessageDO message1 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + Ai1SessionMessageDO message2 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + Ai1SessionMessageDO message3 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + Ai1SessionMessageDO message4 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + + // 调用 + List list = sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(10L, message4.getId(), 2); + // 断言 + assertEquals(List.of(message3.getId(), message2.getId()), list.stream().map(Ai1SessionMessageDO::getId).toList()); + assertNotEquals(message1.getId(), list.get(1).getId()); + } + + // ========== 测试数据 ========== + + private Ai1SessionMessageDO insertSessionMessage(Long sessionId, String role) { + Ai1SessionMessageDO message = new Ai1SessionMessageDO().setSessionId(sessionId).setRole(role).setContent("内容") + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()); + sessionMessageMapper.insert(message); + return message; + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java new file mode 100644 index 0000000..d8201bc --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link Ai1SessionServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SessionServiceImpl.class) +public class Ai1SessionServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1SessionServiceImpl sessionService; + + @Resource + private Ai1SessionMapper sessionMapper; + + @MockitoBean + private Ai1AgentService agentService; + @MockitoBean + private Ai1SessionMessageService sessionMessageService; + + @Test + public void testCreateSessionMy_success() { + // 准备参数 + Ai1SessionCreateMyReqVO reqVO = new Ai1SessionCreateMyReqVO().setAgentId(1L); + + // 调用 + Long id = sessionService.createSessionMy(100L, reqVO); + // 断言 + Ai1SessionDO session = sessionMapper.selectById(id); + assertEquals(100L, session.getUserId()); + assertEquals(Ai1SessionDO.TITLE_DEFAULT, session.getTitle()); + verify(agentService).validateAgentEnabled(1L); + } + + @Test + public void testUpdateSessionMy_otherUser() { + // mock 数据 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + + // 调用,并断言异常 + assertServiceException(() -> sessionService.updateSessionMy(200L, + new Ai1SessionUpdateMyReqVO().setId(id).setTitle("改标题")), SESSION_NOT_EXISTS); + // 断言 + assertEquals(Ai1SessionDO.TITLE_DEFAULT, sessionMapper.selectById(id).getTitle()); + } + + @Test + public void testDeleteSessionMy_otherUser() { + // mock 数据 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + + // 调用,并断言异常 + assertServiceException(() -> sessionService.deleteSessionMy(200L, id), SESSION_NOT_EXISTS); + // 断言 + assertNotNull(sessionMapper.selectById(id)); + verifyNoInteractions(sessionMessageService); + } + + @Test + public void testDeleteSessionMy_success() { + // mock 数据 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + + // 调用 + sessionService.deleteSessionMy(100L, id); + // 断言 + assertNull(sessionMapper.selectById(id)); + verify(sessionMessageService).deleteSessionMessageListBySessionIds(Collections.singletonList(id)); + } + + @Test + public void testGetSessionListByAgentIdAndUserId() { + // mock 数据 + Long id1 = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + Long id2 = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + sessionService.createSessionMy(200L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(2L)); + + // 调用 + List list = sessionService.getSessionListByAgentIdAndUserId(1L, 100L); + // 断言 + assertEquals(Arrays.asList(id2, id1), list.stream().map(Ai1SessionDO::getId).toList()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImplTest.java new file mode 100644 index 0000000..73d4cd9 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImplTest.java @@ -0,0 +1,210 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileContentReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileCreateReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileMoveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileRenameReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillFileMapper; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springaicommunity.agent.utils.MarkdownParser; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SkillFileServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SkillFileServiceImpl.class) +public class Ai1SkillFileServiceImplTest extends BaseDbUnitTest { + + private static final Long SKILL_ID = 1L; + + @Resource + private Ai1SkillFileServiceImpl skillFileService; + + @Resource + private Ai1SkillFileMapper skillFileMapper; + + @MockitoBean + private Ai1SkillService skillService; + + @Test + public void testCreateDefaultSkillFileList() { + // 准备参数 + Ai1SkillDO skill = new Ai1SkillDO().setId(SKILL_ID).setName("pdf-reader").setDescription("读取 PDF"); + + // 调用 + skillFileService.createDefaultSkillFileList(skill); + // 断言 + List files = skillFileMapper.selectListBySkillId(SKILL_ID); + assertEquals(3, files.size()); + assertTrue(files.stream().allMatch(file -> Boolean.TRUE.equals(file.getLocked()) + && Ai1SkillFileDO.PARENT_ID_ROOT.equals(file.getParentId()))); + Ai1SkillFileDO skillFile = CollUtil.findOne(files, file -> Ai1SkillFileDO.NAME_SKILL.equals(file.getName())); + assertNotNull(skillFile); + assertTrue(skillFile.getContent().startsWith("---\nname: pdf-reader\ndescription: 读取 PDF\n---")); + } + + @Test + public void testCreateDefaultSkillFileList_descriptionWithNewlines() { + // 准备参数 + Ai1SkillDO skill = new Ai1SkillDO().setId(SKILL_ID).setName("pdf-reader") + .setDescription("读取 PDF\r\nversion: 2"); + + // 调用 + skillFileService.createDefaultSkillFileList(skill); + // 断言 + Ai1SkillFileDO skillFile = skillFileMapper.selectBySkillIdAndParentIdAndName( + SKILL_ID, Ai1SkillFileDO.PARENT_ID_ROOT, Ai1SkillFileDO.NAME_SKILL); + MarkdownParser parser = new MarkdownParser(skillFile.getContent()); + assertEquals(Map.of("name", "pdf-reader", "description", "读取 PDF version: 2"), parser.getFrontMatter()); + assertTrue(parser.getContent().startsWith("# pdf-reader\n\n")); + } + + @Test + public void testCreateSkillFile_success() { + // 准备参数 + Ai1SkillFileCreateReqVO reqVO = buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, " guide.MD ", + Ai1SkillFileTypeEnum.FILE.getType()); + + // 调用 + Long id = skillFileService.createSkillFile(reqVO); + // 断言 + Ai1SkillFileDO file = skillFileMapper.selectById(id); + assertEquals("guide.MD", file.getName()); + assertEquals("md", file.getFileType()); + assertTrue(file.getContent().startsWith("# 新文档")); + assertFalse(file.getLocked()); + verify(skillService).touchSkill(SKILL_ID); + } + + @Test + public void testCreateSkillFile_nameInvalid() { + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "..", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_NAME_INVALID); + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "a/b", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_NAME_INVALID); + } + + @Test + public void testCreateSkillFile_parentIsFile() { + // mock 数据 + Ai1SkillFileDO parent = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(parent.getId(), "b.txt", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_PARENT_INVALID); + } + + @Test + public void testCreateSkillFile_duplicate() { + // mock 数据 + insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "a.txt", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_NAME_DUPLICATE, "a.txt"); + } + + @Test + public void testCreateSkillFile_parentNotExists() { + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(99999L, "b.txt", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_PARENT_NOT_EXISTS); + } + + @Test + public void testRenameSkillFile_locked() { + // mock 数据 + Ai1SkillFileDO file = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "SKILL.md", Ai1SkillFileTypeEnum.FILE.getType(), true); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.renameSkillFile(new Ai1SkillFileRenameReqVO().setId(file.getId()) + .setName("README.md")), SKILL_FILE_LOCKED, "重命名"); + } + + @Test + public void testMoveSkillFile_intoDescendant() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + Ai1SkillFileDO b = insertSkillFile(a.getId(), "b", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.moveSkillFile(new Ai1SkillFileMoveReqVO().setId(a.getId()) + .setParentId(b.getId())), SKILL_FILE_MOVE_TO_DESCENDANT); + assertServiceException(() -> skillFileService.moveSkillFile(new Ai1SkillFileMoveReqVO().setId(a.getId()) + .setParentId(a.getId())), SKILL_FILE_MOVE_TO_SELF); + } + + @Test + public void testMoveSkillFile_success() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + Ai1SkillFileDO c = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "c.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用 + skillFileService.moveSkillFile(new Ai1SkillFileMoveReqVO().setId(c.getId()).setParentId(a.getId())); + // 断言 + assertEquals(a.getId(), skillFileMapper.selectById(c.getId()).getParentId()); + } + + @Test + public void testUpdateSkillFileContent_directory() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.updateSkillFileContent(new Ai1SkillFileContentReqVO().setId(a.getId()) + .setContent("x")), SKILL_FILE_NOT_FILE); + } + + @Test + public void testDeleteSkillFile_cascadeAndRecreate() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + Ai1SkillFileDO b = insertSkillFile(a.getId(), "b", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + insertSkillFile(b.getId(), "c.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + Ai1SkillFileDO d = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "d.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用 + skillFileService.deleteSkillFile(a.getId()); + Long newId = skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "a", + Ai1SkillFileTypeEnum.DIRECTORY.getType())); + // 断言 + List files = skillFileMapper.selectListBySkillId(SKILL_ID); + assertEquals(2, files.size()); + assertTrue(convertList(files, Ai1SkillFileDO::getId).containsAll(Arrays.asList(d.getId(), newId))); + } + + // ========== 测试数据 ========== + + private static Ai1SkillFileCreateReqVO buildCreateReqVO(Long parentId, String name, Integer type) { + return new Ai1SkillFileCreateReqVO().setSkillId(SKILL_ID).setParentId(parentId).setName(name).setType(type); + } + + private Ai1SkillFileDO insertSkillFile(Long parentId, String name, Integer type, boolean locked) { + Ai1SkillFileDO file = Ai1SkillFileDO.builder().skillId(SKILL_ID).parentId(parentId).name(name).type(type) + .locked(locked).sort(1).content(Ai1SkillFileTypeEnum.isFile(type) ? "" : null).build(); + skillFileMapper.insert(file); + return file; + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImplTest.java new file mode 100644 index 0000000..d893ee6 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImplTest.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SkillServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SkillServiceImpl.class) +public class Ai1SkillServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1SkillServiceImpl skillService; + + @Resource + private Ai1SkillMapper skillMapper; + + @MockitoBean + private Ai1SkillFileService skillFileService; + @MockitoBean + private Ai1AgentService agentService; + + @Test + public void testCreateSkill_success() { + // 准备参数 + Ai1SkillSaveReqVO reqVO = buildSkillSaveReqVO("pdf-reader"); + + // 调用 + Long id = skillService.createSkill(reqVO); + // 断言 + Ai1SkillDO skill = skillMapper.selectById(id); + assertEquals("pdf-reader", skill.getName()); + verify(skillFileService).createDefaultSkillFileList(any(Ai1SkillDO.class)); + } + + @Test + public void testCreateSkill_duplicate() { + // mock 数据 + skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + + // 调用,并断言异常 + assertServiceException(() -> skillService.createSkill(buildSkillSaveReqVO("pdf-reader")), + SKILL_NAME_DUPLICATE, "pdf-reader"); + } + + @Test + public void testDeleteSkill_thenRecreateSameName() { + // mock 数据 + Long id = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + + // 调用 + skillService.deleteSkill(id); + Long newId = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + // 断言 + assertNull(skillMapper.selectById(id)); + assertNotEquals(id, newId); + assertEquals("pdf-reader", skillMapper.selectById(newId).getName()); + verify(skillFileService).deleteSkillFileListBySkillIds(eq(Collections.singletonList(id))); + } + + @Test + public void testUpdateSkill_duplicate() { + // mock 数据 + skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + Long id = skillService.createSkill(buildSkillSaveReqVO("excel-reader")); + + // 准备参数 + Ai1SkillSaveReqVO reqVO = buildSkillSaveReqVO("pdf-reader").setId(id); + + // 调用,并断言异常 + assertServiceException(() -> skillService.updateSkill(reqVO), SKILL_NAME_DUPLICATE, "pdf-reader"); + } + + @Test + public void testDeleteSkill_usedByAgent() { + // mock 数据 + Long id = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + // mock agentService 的方法 + when(agentService.getAgentCountBySkillIds(Collections.singletonList(id))).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> skillService.deleteSkill(id), SKILL_USED_BY_AGENT); + // 断言 + assertNotNull(skillMapper.selectById(id)); + verify(skillFileService, never()).deleteSkillFileListBySkillIds(any()); + } + + @Test + public void testUpdateSkill_managementInfoOnly() { + // mock 数据 + Long id = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + clearInvocations(skillFileService); + + // 调用 + skillService.updateSkill(buildSkillSaveReqVO("财务 PDF 工具").setId(id).setDescription("给财务人员使用")); + // 断言 + Ai1SkillDO skill = skillMapper.selectById(id); + assertEquals("财务 PDF 工具", skill.getName()); + assertEquals("给财务人员使用", skill.getDescription()); + verifyNoInteractions(skillFileService); + } + + // ========== 测试数据 ========== + + private static Ai1SkillSaveReqVO buildSkillSaveReqVO(String name) { + return new Ai1SkillSaveReqVO().setName(name).setDescription("读取 PDF").setVersion("1.0") + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/resources/application-unit-test.yaml b/yudao-module-ai1-springai/src/test/resources/application-unit-test.yaml new file mode 100644 index 0000000..1442d6d --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/application-unit-test.yaml @@ -0,0 +1,49 @@ +spring: + main: + lazy-initialization: true # 开启懒加载,加快速度 + banner-mode: off # 单元测试,禁用 Banner + allow-bean-definition-overriding: true # 允许 Bean 覆盖,解决 Redis 配置冲突 + +--- #################### 数据库相关配置 #################### + +spring: + # 数据源配置项 + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 + initial-size: 1 # 单元测试,配置为 1,提升启动速度 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 127.0.0.1 # 地址 + port: 16379 # 端口(单元测试,使用 16379 端口) + database: 0 # 数据库索引 + +mybatis: + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 + +--- #################### 定时任务相关配置 #################### + +--- #################### 配置中心相关配置 #################### + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项(单元测试,禁用 Lock4j) + +--- #################### 监控相关配置 #################### + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + info: + base-package: cn.iocoder.yudao.module diff --git a/yudao-module-ai1-springai/src/test/resources/logback.xml b/yudao-module-ai1-springai/src/test/resources/logback.xml new file mode 100644 index 0000000..daf756b --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/logback.xml @@ -0,0 +1,4 @@ + + + + diff --git a/yudao-module-ai1-springai/src/test/resources/provider-api-key-cases.json b/yudao-module-ai1-springai/src/test/resources/provider-api-key-cases.json new file mode 100644 index 0000000..54e4c6e --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/provider-api-key-cases.json @@ -0,0 +1,74 @@ +[ + { + "apiKey": "${OPENAI_KEY}", + "placeholder": true + }, + { + "apiKey": "${openai_key}", + "placeholder": true + }, + { + "apiKey": "${openai.api-key}", + "placeholder": true + }, + { + "apiKey": "${_API_KEY}", + "placeholder": true + }, + { + "apiKey": "sk-secret-value", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY:sk-secret-value}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY:}", + "placeholder": false + }, + { + "apiKey": "prefix-${OPENAI_KEY}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY}-suffix", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY}${OTHER_KEY}", + "placeholder": false + }, + { + "apiKey": "${}", + "placeholder": false + }, + { + "apiKey": "${ OPENAI_KEY}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY }", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY}\n", + "placeholder": false + }, + { + "apiKey": " ${OPENAI_KEY}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY", + "placeholder": false + }, + { + "apiKey": "", + "placeholder": false + }, + { + "apiKey": null, + "placeholder": false + } +] diff --git a/yudao-module-ai1-springai/src/test/resources/sql/clean.sql b/yudao-module-ai1-springai/src/test/resources/sql/clean.sql new file mode 100644 index 0000000..a48e1e2 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/sql/clean.sql @@ -0,0 +1,10 @@ +DELETE FROM "ai1_provider"; +DELETE FROM "ai1_model"; +DELETE FROM "ai1_mcp"; +DELETE FROM "ai1_skill"; +DELETE FROM "ai1_skill_file"; +DELETE FROM "ai1_knowledge_base"; +DELETE FROM "ai1_knowledge_document"; +DELETE FROM "ai1_agent"; +DELETE FROM "ai1_session"; +DELETE FROM "ai1_session_message"; diff --git a/yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql b/yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql new file mode 100644 index 0000000..c4ef951 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql @@ -0,0 +1,199 @@ +-- ---------------------------- +-- AI1 Provider +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_provider" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(50) NOT NULL, + "base_url" varchar(200) NOT NULL, + "api_key" varchar(200) DEFAULT NULL, + "headers" varchar(2000) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "remark" varchar(255) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 Provider 模型 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_model" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "provider_id" bigint NOT NULL, + "name" varchar(50) NOT NULL, + "model" varchar(100) NOT NULL, + "type" tinyint NOT NULL DEFAULT 0, + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 MCP 服务 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_mcp" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "transport" varchar(32) NOT NULL, + "url" varchar(200) DEFAULT NULL, + "headers" varchar(500) DEFAULT NULL, + "config" text DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "remark" varchar(500) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 SKILL +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_skill" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "description" varchar(500) DEFAULT NULL, + "version" varchar(20) NOT NULL DEFAULT '1.0', + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 SKILL 内容文件 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_skill_file" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "skill_id" bigint NOT NULL, + "parent_id" bigint NOT NULL DEFAULT 0, + "name" varchar(200) NOT NULL, + "type" tinyint NOT NULL DEFAULT 1, + "file_type" varchar(20) DEFAULT NULL, + "content" text DEFAULT NULL, + "locked" tinyint NOT NULL DEFAULT 0, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 知识库 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_knowledge_base" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "description" varchar(500) DEFAULT NULL, + "embedding_provider_id" bigint NOT NULL DEFAULT 0, + "embedding_model_id" bigint NOT NULL DEFAULT 0, + "chunk_size" int NOT NULL DEFAULT 500, + "chunk_overlap" int NOT NULL DEFAULT 50, + "top_k" int NOT NULL DEFAULT 5, + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 知识文档 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_knowledge_document" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "knowledge_base_id" bigint NOT NULL, + "name" varchar(255) NOT NULL, + "content" text DEFAULT NULL, + "chunk_count" int NOT NULL DEFAULT 0, + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 Agent +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_agent" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "introduction" varchar(500) DEFAULT NULL, + "provider_id" bigint NOT NULL DEFAULT 0, + "model_id" bigint NOT NULL DEFAULT 0, + "system_prompt" text DEFAULT NULL, + "knowledge_base_ids" varchar(500) DEFAULT NULL, + "mcp_ids" varchar(500) DEFAULT NULL, + "skill_ids" varchar(500) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 1, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 会话 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_session" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "agent_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "title" varchar(100) NOT NULL DEFAULT '新会话', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 会话消息 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_session_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "session_id" bigint NOT NULL, + "role" varchar(10) NOT NULL, + "reasoning" mediumtext DEFAULT NULL, + "content" mediumtext NOT NULL, + "status" tinyint NOT NULL DEFAULT 1, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); diff --git a/yudao-server/pom.xml b/yudao-server/pom.xml index 88ebde5..1a43548 100644 --- a/yudao-server/pom.xml +++ b/yudao-server/pom.xml @@ -108,6 +108,13 @@ + + + + + + +