From 950148b3f7594de784771d27af5b0a36703401da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 8 Jun 2026 16:52:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E8=AF=95=E9=AA=8C=E6=80=A7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=AD=89=E5=BE=85=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- easyflow-api/easyflow-api-admin/pom.xml | 4 + .../support/AiResourceCreatorNameSupport.java | 10 + .../skill/SkillCategoryController.java | 91 ++++ .../controller/skill/SkillController.java | 359 ++++++++++++++ .../impl/LocalFileStorageServiceImpl.java | 19 +- .../approval/enums/ApprovalResourceType.java | 1 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../easyflow-module-skill/pom.xml | 59 +++ .../skill/config/SkillModuleConfig.java | 14 + .../tech/easyflow/skill/entity/Skill.java | 135 +++++ .../easyflow/skill/entity/SkillAsset.java | 56 +++ .../skill/entity/SkillAssetContent.java | 43 ++ .../easyflow/skill/entity/SkillCategory.java | 60 +++ .../easyflow/skill/entity/SkillReference.java | 53 ++ .../easyflow/skill/entity/SkillScript.java | 53 ++ .../easyflow/skill/file/SkillFileContent.java | 31 ++ .../easyflow/skill/file/SkillFileNode.java | 37 ++ .../skill/file/SkillFileSaveRequest.java | 21 + .../easyflow/skill/file/SkillFileService.java | 66 +++ .../skill/file/SkillFileServiceImpl.java | 460 ++++++++++++++++++ .../easyflow/skill/file/SkillFileType.java | 28 ++ .../skill/imports/SkillExportService.java | 20 + .../skill/imports/SkillExportServiceImpl.java | 106 ++++ .../skill/imports/SkillImportPreview.java | 31 ++ .../skill/imports/SkillImportPreviewItem.java | 47 ++ .../skill/imports/SkillImportService.java | 32 ++ .../skill/imports/SkillImportServiceImpl.java | 102 ++++ .../skill/mapper/SkillAssetContentMapper.java | 10 + .../skill/mapper/SkillAssetMapper.java | 10 + .../skill/mapper/SkillCategoryMapper.java | 10 + .../easyflow/skill/mapper/SkillMapper.java | 10 + .../skill/mapper/SkillReferenceMapper.java | 10 + .../skill/mapper/SkillScriptMapper.java | 10 + .../publish/SkillApprovalSubjectHandler.java | 152 ++++++ .../skill/publish/SkillPublishAppService.java | 72 +++ .../skill/repository/DBSkillRepository.java | 117 +++++ .../service/SkillApprovalStateService.java | 25 + .../service/SkillAssetContentService.java | 10 + .../skill/service/SkillAssetService.java | 10 + .../skill/service/SkillCategoryService.java | 19 + .../skill/service/SkillReferenceService.java | 10 + .../skill/service/SkillScriptService.java | 10 + .../easyflow/skill/service/SkillService.java | 60 +++ .../impl/SkillApprovalStateServiceImpl.java | 115 +++++ .../impl/SkillAssetContentServiceImpl.java | 14 + .../service/impl/SkillAssetServiceImpl.java | 14 + .../impl/SkillCategoryServiceImpl.java | 120 +++++ .../impl/SkillReferenceServiceImpl.java | 14 + .../service/impl/SkillScriptServiceImpl.java | 14 + .../skill/service/impl/SkillServiceImpl.java | 327 +++++++++++++ .../skill/store/DBSkillContentStore.java | 138 ++++++ .../skill/support/SkillModelConverter.java | 201 ++++++++ .../system/enums/CategoryResourceType.java | 1 + easyflow-modules/pom.xml | 1 + easyflow-starter/easyflow-starter-all/pom.xml | 4 + .../mysql/V24__mysql_skill_schema.sql | 109 +++++ .../migration/mysql/V25__mysql_skill_menu.sql | 172 +++++++ easyflow-ui-admin/app/package.json | 7 + .../components/editor/CodeMirrorEditor.vue | 164 +++++++ .../app/src/router/routes/modules/skill.ts | 18 + .../src/types/codemirror-legacy-modes.d.ts | 5 + .../app/src/types/markdown-it.d.ts | 2 + .../app/src/views/ai/skill/SkillDetail.vue | 398 +++++++++++++++ .../app/src/views/ai/skill/SkillList.vue | 335 +++++++++++++ .../app/src/views/ai/skill/api.ts | 106 ++++ .../app/src/views/ai/skill/types.ts | 99 ++++ easyflow-ui-admin/pnpm-lock.yaml | 164 ++----- pom.xml | 10 + 68 files changed, 4901 insertions(+), 135 deletions(-) create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java create mode 100644 easyflow-modules/easyflow-module-skill/pom.xml create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/config/SkillModuleConfig.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileType.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillApprovalStateService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V24__mysql_skill_schema.sql create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V25__mysql_skill_menu.sql create mode 100644 easyflow-ui-admin/app/src/components/editor/CodeMirrorEditor.vue create mode 100644 easyflow-ui-admin/app/src/router/routes/modules/skill.ts create mode 100644 easyflow-ui-admin/app/src/types/codemirror-legacy-modes.d.ts create mode 100644 easyflow-ui-admin/app/src/types/markdown-it.d.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/api.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/types.ts diff --git a/easyflow-api/easyflow-api-admin/pom.xml b/easyflow-api/easyflow-api-admin/pom.xml index 8ab3cbd1..1739df30 100644 --- a/easyflow-api/easyflow-api-admin/pom.xml +++ b/easyflow-api/easyflow-api-admin/pom.xml @@ -24,6 +24,10 @@ tech.easyflow easyflow-module-agent + + tech.easyflow + easyflow-module-skill + tech.easyflow easyflow-module-chatlog diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java index 99cb7156..ba879302 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/support/AiResourceCreatorNameSupport.java @@ -6,6 +6,7 @@ import tech.easyflow.ai.entity.Bot; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.skill.entity.Skill; import tech.easyflow.system.service.SysAccountService; import javax.annotation.Resource; @@ -76,6 +77,15 @@ public class AiResourceCreatorNameSupport { fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName); } + /** + * 批量填充 Skill 创建人名称。 + * + * @param skills Skill 集合 + */ + public void fillSkillCreatorNames(Collection skills) { + fillCreatorNames(skills, Skill::getCreatedBy, Skill::setCreatedByName); + } + /** * 通用的创建人名称填充逻辑。 * diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java new file mode 100644 index 00000000..2ab438fa --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillCategoryController.java @@ -0,0 +1,91 @@ +package tech.easyflow.admin.controller.skill; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.common.annotation.UsePermission; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillCategory; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.service.CategoryPermissionService; + +import javax.annotation.Resource; +import java.io.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Skill 分类管理控制器。 + */ +@RestController +@RequestMapping("/api/v1/skillCategory") +@UsePermission(moduleName = "/api/v1/skill") +public class SkillCategoryController extends BaseCurdController { + + @Resource + private SkillMapper skillMapper; + @Resource + private CategoryPermissionService categoryPermissionService; + + /** + * 创建 Skill 分类管理控制器。 + * + * @param service Skill 分类服务 + */ + public SkillCategoryController(SkillCategoryService service) { + super(service); + } + + /** + * 查询当前用户可见的 Skill 分类。 + * + * @param entity 查询条件 + * @param asTree 是否转树 + * @param sortKey 排序字段 + * @param sortType 排序方式 + * @return 可见分类列表 + */ + @GetMapping("visibleList") + public Result> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) { + QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity)); + RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); + if (access.isRestricted()) { + if (access.getCategoryIds().isEmpty()) { + return Result.ok(Collections.emptyList()); + } + queryWrapper.in("id", access.getCategoryIds()); + } + queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); + return Result.ok(service.list(queryWrapper)); + } + + /** + * 删除分类前校验是否仍被 Skill 使用。 + * + * @param ids 分类 ID 集合 + * @return 校验结果 + */ + @Override + protected Result onRemoveBefore(Collection ids) { + for (Serializable id : ids) { + List skills = skillMapper.selectListByQuery(QueryWrapper.create().eq(Skill::getCategoryId, id)); + if (skills != null && !skills.isEmpty()) { + throw new BusinessException("请先迁移或删除该分类下的 Skill"); + } + List children = service.list(QueryWrapper.create().eq(SkillCategory::getParentId, id)); + if (children != null && !children.isEmpty()) { + throw new BusinessException("请先删除子分类"); + } + } + return super.onRemoveBefore(ids); + } +} + diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java new file mode 100644 index 00000000..56a225fd --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java @@ -0,0 +1,359 @@ +package tech.easyflow.admin.controller.skill; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.mybatisflex.core.paginate.Page; +import com.mybatisflex.core.query.QueryWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.util.StreamUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.approval.entity.vo.ApprovalActionResult; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.file.SkillFileContent; +import tech.easyflow.skill.file.SkillFileNode; +import tech.easyflow.skill.file.SkillFileSaveRequest; +import tech.easyflow.skill.file.SkillFileService; +import tech.easyflow.skill.imports.SkillExportService; +import tech.easyflow.skill.imports.SkillImportPreview; +import tech.easyflow.skill.imports.SkillImportService; +import tech.easyflow.skill.publish.SkillPublishAppService; +import tech.easyflow.skill.service.SkillApprovalStateService; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import javax.annotation.Resource; +import java.io.InputStream; +import java.io.Serializable; +import java.math.BigInteger; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static tech.easyflow.skill.entity.table.SkillTableDef.SKILL; + +/** + * Skill 管理端控制器。 + */ +@RestController +@RequestMapping("/api/v1/skill") +public class SkillController extends BaseCurdController { + + @Resource + private SkillApprovalStateService skillApprovalStateService; + @Resource + private SkillPublishAppService skillPublishAppService; + @Resource + private SkillImportService skillImportService; + @Resource + private SkillExportService skillExportService; + @Resource + private SkillFileService skillFileService; + @Resource + private ResourceAccessService resourceAccessService; + @Resource + private CategoryPermissionService categoryPermissionService; + @Resource + private AiResourceCreatorNameSupport aiResourceCreatorNameSupport; + + /** + * 创建 Skill 控制器。 + * + * @param service Skill 服务 + */ + public SkillController(SkillService service) { + super(service); + } + + /** + * 获取 Skill 详情。 + * + * @param id Skill ID + * @return Skill 详情 + */ + @GetMapping("/getDetail") + public Result getDetail(BigInteger id) { + Skill skill = service.getDetail(id); + skillApprovalStateService.fillSkillApprovalState(skill); + return Result.ok(skill); + } + + /** + * 保存 Skill 草稿。 + * + * @param skill Skill 草稿 + * @return 保存后的 Skill + */ + @Override + @PostMapping("save") + public Result save(@JsonBody Skill skill) { + return Result.ok(service.saveDraft(skill)); + } + + /** + * 更新 Skill 草稿。 + * + * @param skill Skill 草稿 + * @return 保存后的 Skill + */ + @Override + @PostMapping("update") + public Result update(@JsonBody Skill skill) { + return Result.ok(service.updateDraft(skill)); + } + + /** + * 预览 zip 导入结果。 + * + * @param file zip 文件 + * @return 导入预览 + */ + @PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission("/api/v1/skill/save") + public Result importPreview(MultipartFile file) throws Exception { + return Result.ok(skillImportService.preview(file.getInputStream())); + } + + /** + * 确认导入 zip。 + * + * @param file zip 文件 + * @param categoryId 分类 ID + * @param overwriteDraft 是否覆盖草稿 + * @return 导入后的 Skill 列表 + */ + @PostMapping(value = "/import/confirm", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission("/api/v1/skill/save") + public Result> importConfirm(MultipartFile file, BigInteger categoryId, Boolean overwriteDraft) throws Exception { + return Result.ok(skillImportService.importZip(file.getInputStream(), categoryId, Boolean.TRUE.equals(overwriteDraft))); + } + + /** + * 导出 Skill zip。 + * + * @param ids Skill ID 集合 + * @param response HTTP 响应 + */ + @PostMapping("/export") + public void export(@JsonBody(value = "ids", required = true) List ids, HttpServletResponse response) throws Exception { + response.setContentType("application/zip"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode("skills.zip", StandardCharsets.UTF_8) + "\""); + skillExportService.exportZip(ids, response.getOutputStream()); + } + + /** + * 获取 Skill 文件树。 + * + * @param skillId Skill ID + * @return 文件树 + */ + @GetMapping("/file/tree") + public Result> fileTree(BigInteger skillId) { + return Result.ok(skillFileService.tree(skillId)); + } + + /** + * 获取 Skill 文件内容。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + * @return 文件内容 + */ + @GetMapping("/file/content") + public Result fileContent(BigInteger skillId, String path) { + return Result.ok(skillFileService.getContent(skillId, path)); + } + + /** + * 保存 Skill 文本文件。 + * + * @param request 保存请求 + * @return 保存后的文件内容 + */ + @PostMapping("/file/save") + @SaCheckPermission("/api/v1/skill/save") + public Result saveFile(@JsonBody SkillFileSaveRequest request) { + return Result.ok(skillFileService.saveContent(request)); + } + + /** + * 删除 Skill 逻辑文件。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + * @return 操作结果 + */ + @PostMapping("/file/delete") + @SaCheckPermission("/api/v1/skill/save") + public Result deleteFile(@JsonBody(value = "skillId", required = true) BigInteger skillId, + @JsonBody(value = "path", required = true) String path) { + skillFileService.deleteFile(skillId, path); + return Result.ok(); + } + + /** + * 上传 Skill asset。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + * @param file 上传文件 + * @return asset 内容 + */ + @PostMapping(value = "/file/asset/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @SaCheckPermission("/api/v1/skill/save") + public Result uploadAsset(BigInteger skillId, String path, MultipartFile file) { + return Result.ok(skillFileService.uploadAsset(skillId, path, file)); + } + + /** + * 下载或预览 Skill asset。 + * + * @param skillId Skill ID + * @param path asset 逻辑路径 + * @param response HTTP 响应 + */ + @GetMapping("/file/asset") + public void asset(BigInteger skillId, String path, HttpServletResponse response) throws Exception { + SkillFileContent content = skillFileService.getContent(skillId, path); + response.setContentType(content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType()); + response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(fileName(path), StandardCharsets.UTF_8) + "\""); + try (InputStream inputStream = skillFileService.openAsset(skillId, path)) { + StreamUtils.copy(inputStream, response.getOutputStream()); + } + } + + /** + * 提交发布审批。 + * + * @param id Skill ID + * @return 审批实例 ID + */ + @PostMapping("/submitPublishApproval") + @SaCheckPermission("/api/v1/skill/save") + public Result submitPublishApproval(@JsonBody("id") BigInteger id) { + return buildApprovalActionResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布"); + } + + /** + * 提交下线审批。 + * + * @param id Skill ID + * @return 审批实例 ID + */ + @PostMapping("/submitOfflineApproval") + @SaCheckPermission("/api/v1/skill/save") + public Result submitOfflineApproval(@JsonBody("id") BigInteger id) { + return buildApprovalActionResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线"); + } + + /** + * 提交删除审批。 + * + * @param id Skill ID + * @return 审批实例 ID + */ + @PostMapping("/submitDeleteApproval") + @SaCheckPermission("/api/v1/skill/remove") + public Result submitDeleteApproval(@JsonBody("id") BigInteger id) { + return buildApprovalActionResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除"); + } + + @Override + protected Result onRemoveBefore(Collection ids) { + for (Serializable id : ids) { + Skill skill = service.getById(String.valueOf(id)); + if (skill != null) { + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill"); + } + } + return super.onRemoveBefore(ids); + } + + /** + * 查询 Skill 分页。 + * + * @param page 分页参数 + * @param queryWrapper 查询条件 + * @return Skill 分页 + */ + @Override + protected Page queryPage(Page page, QueryWrapper queryWrapper) { + if (!applyCategoryPermission(queryWrapper)) { + return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L); + } + applyPublishedOnlyFilter(queryWrapper); + Page result = super.queryPage(page, queryWrapper); + if (isPublishedOnlyRequest()) { + result.setRecords(result.getRecords().stream().map(skill -> service.fromSnapshot(skill.getPublishedSnapshotJson())).toList()); + } + skillApprovalStateService.fillSkillApprovalState(result.getRecords()); + aiResourceCreatorNameSupport.fillSkillCreatorNames(result.getRecords()); + return result; + } + + private boolean applyCategoryPermission(QueryWrapper queryWrapper) { + RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); + if (!access.isRestricted()) { + return true; + } + if (access.getCategoryIds().isEmpty()) { + queryWrapper.eq(Skill::getCreatedBy, access.getAccountId()); + return true; + } + queryWrapper.and(SKILL.CREATED_BY.eq(access.getAccountId()).or(SKILL.CATEGORY_ID.in(access.getCategoryIds()))); + return true; + } + + private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) { + if (isPublishedOnlyRequest()) { + queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode()); + } + } + + private boolean isPublishedOnlyRequest() { + HttpServletRequest request = currentRequest(); + if (request == null) { + return false; + } + return "true".equalsIgnoreCase(request.getParameter("publishedOnly")); + } + + private HttpServletRequest currentRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return null; + } + return attributes.getRequest(); + } + + private Result buildApprovalActionResult(ApprovalActionResult actionResult, + String approvalMessage, + String directMessage) { + return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId()); + } + + private String fileName(String path) { + if (path == null || path.isBlank()) { + return "asset"; + } + int index = path.lastIndexOf('/'); + return index < 0 ? path : path.substring(index + 1); + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java index 95def714..51d838ad 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; @@ -14,8 +15,6 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; @Component("local") @@ -116,6 +115,20 @@ public class LocalFileStorageServiceImpl implements FileStorageService { @Override public String save(MultipartFile file, String prePath) { - return save(file); + try { + String path = PathGeneratorUtil.generateUserPath(file.getOriginalFilename()); + if (StringUtils.hasText(prePath)) { + String normalized = prePath.replaceAll("^/+", "").replaceAll("/+$", ""); + path = normalized + "/" + path.replaceAll("^/+", ""); + } + File target = getLocalFile(path); + if (!target.getParentFile().exists() && !target.getParentFile().mkdirs()) { + LOG.error("创建文件失败: {} ", target.getParentFile()); + } + file.transferTo(target); + return prefix + path; + } catch (Exception e) { + throw new RuntimeException(e.getMessage(), e); + } } } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java index 0a64bd49..98851393 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/enums/ApprovalResourceType.java @@ -12,6 +12,7 @@ public enum ApprovalResourceType { BOT("BOT"), AGENT("AGENT"), + SKILL("SKILL"), WORKFLOW("WORKFLOW"), KNOWLEDGE("KNOWLEDGE"); diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 21910179..864731f5 100644 --- a/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/easyflow-modules/easyflow-module-autoconfig/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -7,4 +7,5 @@ tech.easyflow.chatlog.config.ChatlogModuleConfig tech.easyflow.datacenter.config.DatacenterModuleConfig tech.easyflow.job.config.JobModuleConfig tech.easyflow.log.config.LogModuleConfig +tech.easyflow.skill.config.SkillModuleConfig tech.easyflow.system.config.SysModuleConfig diff --git a/easyflow-modules/easyflow-module-skill/pom.xml b/easyflow-modules/easyflow-module-skill/pom.xml new file mode 100644 index 00000000..e2538dc2 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + tech.easyflow + easyflow-modules + ${revision} + + + easyflow-module-skill + easyflow-module-skill + + + + tech.easyflow + easyflow-module-ai + + + tech.easyflow + easyflow-module-approval + + + tech.easyflow + easyflow-module-system + + + tech.easyflow + easyflow-common-web + + + tech.easyflow + easyflow-common-satoken + + + tech.easyflow + easyflow-common-file-storage + + + com.mybatis-flex + mybatis-flex-spring-boot3-starter + + + com.easyagents + easy-agents-skill + + + org.springframework.boot + spring-boot-starter-web + + + junit + junit + ${junit.version} + test + + + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/config/SkillModuleConfig.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/config/SkillModuleConfig.java new file mode 100644 index 00000000..65b6429e --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/config/SkillModuleConfig.java @@ -0,0 +1,14 @@ +package tech.easyflow.skill.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.ComponentScan; + +/** + * Skill 模块自动配置。 + */ +@MapperScan("tech.easyflow.skill.mapper") +@ComponentScan("tech.easyflow.skill") +@AutoConfiguration +public class SkillModuleConfig { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java new file mode 100644 index 00000000..bf0b37b4 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java @@ -0,0 +1,135 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; +import tech.easyflow.common.entity.DateEntity; +import tech.easyflow.system.permission.resource.VisibilityResource; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Skill 主实体。 + */ +@Table("tb_skill") +public class Skill extends DateEntity implements VisibilityResource, Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger deptId; + private BigInteger categoryId; + private String name; + private String displayName; + private String description; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map metadataJson = new LinkedHashMap<>(); + private String skillContent; + private Boolean enabled; + private String visibilityScope; + private String sourceType; + private String packageHash; + private Integer referenceCount; + private Integer scriptCount; + private Integer assetCount; + private String publishStatus; + private BigInteger currentApprovalInstanceId; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map publishedSnapshotJson = new LinkedHashMap<>(); + private Date publishedAt; + private BigInteger publishedBy; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + + @Column(ignore = true) + private Boolean approvalPending; + @Column(ignore = true) + private String currentApprovalActionType; + @Column(ignore = true) + private String displayPublishStatus; + @Column(ignore = true) + private String createdByName; + @Column(ignore = true) + private List references; + @Column(ignore = true) + private List scripts; + @Column(ignore = true) + private List assets; + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getDeptId() { return deptId; } + public void setDeptId(BigInteger deptId) { this.deptId = deptId; } + public BigInteger getCategoryId() { return categoryId; } + public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Map getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } + public String getSkillContent() { return skillContent; } + public void setSkillContent(String skillContent) { this.skillContent = skillContent; } + public Boolean getEnabled() { return enabled; } + public void setEnabled(Boolean enabled) { this.enabled = enabled; } + public String getVisibilityScope() { return visibilityScope; } + public void setVisibilityScope(String visibilityScope) { this.visibilityScope = visibilityScope; } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public String getPackageHash() { return packageHash; } + public void setPackageHash(String packageHash) { this.packageHash = packageHash; } + public Integer getReferenceCount() { return referenceCount; } + public void setReferenceCount(Integer referenceCount) { this.referenceCount = referenceCount; } + public Integer getScriptCount() { return scriptCount; } + public void setScriptCount(Integer scriptCount) { this.scriptCount = scriptCount; } + public Integer getAssetCount() { return assetCount; } + public void setAssetCount(Integer assetCount) { this.assetCount = assetCount; } + public String getPublishStatus() { return publishStatus; } + public void setPublishStatus(String publishStatus) { this.publishStatus = publishStatus; } + public BigInteger getCurrentApprovalInstanceId() { return currentApprovalInstanceId; } + public void setCurrentApprovalInstanceId(BigInteger currentApprovalInstanceId) { this.currentApprovalInstanceId = currentApprovalInstanceId; } + public Map getPublishedSnapshotJson() { return publishedSnapshotJson; } + public void setPublishedSnapshotJson(Map publishedSnapshotJson) { this.publishedSnapshotJson = publishedSnapshotJson == null ? new LinkedHashMap<>() : publishedSnapshotJson; } + public Date getPublishedAt() { return publishedAt; } + public void setPublishedAt(Date publishedAt) { this.publishedAt = publishedAt; } + public BigInteger getPublishedBy() { return publishedBy; } + public void setPublishedBy(BigInteger publishedBy) { this.publishedBy = publishedBy; } + @Override public Date getCreated() { return created; } + @Override public void setCreated(Date created) { this.created = created; } + public BigInteger getCreatedBy() { return createdBy; } + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + @Override public Date getModified() { return modified; } + @Override public void setModified(Date modified) { this.modified = modified; } + public BigInteger getModifiedBy() { return modifiedBy; } + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + public Boolean getApprovalPending() { return approvalPending; } + public void setApprovalPending(Boolean approvalPending) { this.approvalPending = approvalPending; } + public String getCurrentApprovalActionType() { return currentApprovalActionType; } + public void setCurrentApprovalActionType(String currentApprovalActionType) { this.currentApprovalActionType = currentApprovalActionType; } + public String getDisplayPublishStatus() { return displayPublishStatus; } + public void setDisplayPublishStatus(String displayPublishStatus) { this.displayPublishStatus = displayPublishStatus; } + public String getCreatedByName() { return createdByName; } + public void setCreatedByName(String createdByName) { this.createdByName = createdByName; } + public List getReferences() { return references; } + public void setReferences(List references) { this.references = references; } + public List getScripts() { return scripts; } + public void setScripts(List scripts) { this.scripts = scripts; } + public List getAssets() { return assets; } + public void setAssets(List assets) { this.assets = assets; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java new file mode 100644 index 00000000..8b4c9192 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAsset.java @@ -0,0 +1,56 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill asset 实体。 + */ +@Table("tb_skill_asset") +public class SkillAsset implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger skillId; + private String path; + private String name; + private String mediaType; + private String contentRef; + private String contentHash; + private Long size; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map metadataJson = new LinkedHashMap<>(); + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public String getContentRef() { return contentRef; } + public void setContentRef(String contentRef) { this.contentRef = contentRef; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public Map getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java new file mode 100644 index 00000000..70b3f4b6 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillAssetContent.java @@ -0,0 +1,43 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.Table; + +import java.io.Serializable; +import java.util.Date; + +/** + * Skill asset 内容索引实体。 + */ +@Table("tb_skill_asset_content") +public class SkillAssetContent implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + private String contentRef; + private String contentHash; + private String filePath; + private String mediaType; + private Long size; + private Integer refCount; + private Date created; + private Date modified; + + public String getContentRef() { return contentRef; } + public void setContentRef(String contentRef) { this.contentRef = contentRef; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } + public String getFilePath() { return filePath; } + public void setFilePath(String filePath) { this.filePath = filePath; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public Integer getRefCount() { return refCount; } + public void setRefCount(Integer refCount) { this.refCount = refCount; } + public Date getCreated() { return created; } + public void setCreated(Date created) { this.created = created; } + public Date getModified() { return modified; } + public void setModified(Date modified) { this.modified = modified; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java new file mode 100644 index 00000000..b0702fb9 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillCategory.java @@ -0,0 +1,60 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import tech.easyflow.common.entity.DateEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; + +/** + * Skill 分类实体。 + */ +@Table("tb_skill_category") +public class SkillCategory extends DateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger parentId; + private String categoryName; + private Integer levelNo; + private String ancestors; + private Integer sortNo; + private Integer status; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getParentId() { return parentId; } + public void setParentId(BigInteger parentId) { this.parentId = parentId; } + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + public Integer getLevelNo() { return levelNo; } + public void setLevelNo(Integer levelNo) { this.levelNo = levelNo; } + public String getAncestors() { return ancestors; } + public void setAncestors(String ancestors) { this.ancestors = ancestors; } + public Integer getSortNo() { return sortNo; } + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + @Override public Date getCreated() { return created; } + @Override public void setCreated(Date created) { this.created = created; } + public BigInteger getCreatedBy() { return createdBy; } + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + @Override public Date getModified() { return modified; } + @Override public void setModified(Date modified) { this.modified = modified; } + public BigInteger getModifiedBy() { return modifiedBy; } + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java new file mode 100644 index 00000000..ac3ac37b --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillReference.java @@ -0,0 +1,53 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill reference 文档实体。 + */ +@Table("tb_skill_reference") +public class SkillReference implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger skillId; + private String path; + private String name; + private String content; + private String contentHash; + private Long size; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map metadataJson = new LinkedHashMap<>(); + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public Map getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java new file mode 100644 index 00000000..37c763df --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillScript.java @@ -0,0 +1,53 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill script 实体。 + */ +@Table("tb_skill_script") +public class SkillScript implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger skillId; + private String path; + private String language; + private String content; + private String contentHash; + private Long size; + @Column(typeHandler = FastjsonTypeHandler.class) + private Map metadataJson = new LinkedHashMap<>(); + + public BigInteger getId() { return id; } + public void setId(BigInteger id) { this.id = id; } + public BigInteger getTenantId() { return tenantId; } + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getLanguage() { return language; } + public void setLanguage(String language) { this.language = language; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public String getContentHash() { return contentHash; } + public void setContentHash(String contentHash) { this.contentHash = contentHash; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public Map getMetadataJson() { return metadataJson; } + public void setMetadataJson(Map metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java new file mode 100644 index 00000000..36f27e43 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileContent.java @@ -0,0 +1,31 @@ +package tech.easyflow.skill.file; + +/** + * Skill 逻辑文件内容。 + */ +public class SkillFileContent { + + private String path; + private String type; + private String content; + private String language; + private String mediaType; + private Long size; + private String downloadUrl; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public String getLanguage() { return language; } + public void setLanguage(String language) { this.language = language; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public String getDownloadUrl() { return downloadUrl; } + public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java new file mode 100644 index 00000000..61edc5fc --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileNode.java @@ -0,0 +1,37 @@ +package tech.easyflow.skill.file; + +import java.util.ArrayList; +import java.util.List; + +/** + * Skill 逻辑文件树节点。 + */ +public class SkillFileNode { + + private String key; + private String path; + private String name; + private String type; + private String language; + private String mediaType; + private Long size; + private List children = new ArrayList<>(); + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getLanguage() { return language; } + public void setLanguage(String language) { this.language = language; } + public String getMediaType() { return mediaType; } + public void setMediaType(String mediaType) { this.mediaType = mediaType; } + public Long getSize() { return size; } + public void setSize(Long size) { this.size = size; } + public List getChildren() { return children; } + public void setChildren(List children) { this.children = children == null ? new ArrayList<>() : children; } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java new file mode 100644 index 00000000..ff9957a7 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileSaveRequest.java @@ -0,0 +1,21 @@ +package tech.easyflow.skill.file; + +import java.math.BigInteger; + +/** + * Skill 文本文件保存请求。 + */ +public class SkillFileSaveRequest { + + private BigInteger skillId; + private String path; + private String content; + + public BigInteger getSkillId() { return skillId; } + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java new file mode 100644 index 00000000..3c15583e --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileService.java @@ -0,0 +1,66 @@ +package tech.easyflow.skill.file; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.math.BigInteger; +import java.util.List; + +/** + * Skill 逻辑文件服务。 + */ +public interface SkillFileService { + + /** + * 获取 Skill 文件树。 + * + * @param skillId Skill ID + * @return 文件树 + */ + List tree(BigInteger skillId); + + /** + * 获取逻辑文件内容。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + * @return 文件内容 + */ + SkillFileContent getContent(BigInteger skillId, String path); + + /** + * 保存文本文件内容。 + * + * @param request 保存请求 + * @return 保存后的文件内容 + */ + SkillFileContent saveContent(SkillFileSaveRequest request); + + /** + * 删除逻辑文件。 + * + * @param skillId Skill ID + * @param path 逻辑路径 + */ + void deleteFile(BigInteger skillId, String path); + + /** + * 上传 asset 文件。 + * + * @param skillId Skill ID + * @param path asset 逻辑路径 + * @param file 上传文件 + * @return asset 文件内容 + */ + SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file); + + /** + * 打开 asset 输入流。 + * + * @param skillId Skill ID + * @param path asset 逻辑路径 + * @return asset 输入流 + */ + InputStream openAsset(BigInteger skillId, String path); +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java new file mode 100644 index 00000000..014f3186 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileServiceImpl.java @@ -0,0 +1,460 @@ +package tech.easyflow.skill.file; + +import com.easyagents.skill.model.SkillScriptLanguage; +import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillPaths; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillAsset; +import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.entity.SkillScript; +import tech.easyflow.skill.service.SkillAssetService; +import tech.easyflow.skill.service.SkillReferenceService; +import tech.easyflow.skill.service.SkillScriptService; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Skill 逻辑文件服务实现。 + */ +@Service +public class SkillFileServiceImpl implements SkillFileService { + + private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + + private final SkillService skillService; + private final SkillReferenceService skillReferenceService; + private final SkillScriptService skillScriptService; + private final SkillAssetService skillAssetService; + private final DBSkillContentStore contentStore; + private final ResourceAccessService resourceAccessService; + + /** + * 创建 Skill 逻辑文件服务。 + * + * @param skillService Skill 服务 + * @param skillReferenceService reference 服务 + * @param skillScriptService script 服务 + * @param skillAssetService asset 服务 + * @param contentStore asset 内容存储 + * @param resourceAccessService 资源访问服务 + */ + public SkillFileServiceImpl(SkillService skillService, + SkillReferenceService skillReferenceService, + SkillScriptService skillScriptService, + SkillAssetService skillAssetService, + DBSkillContentStore contentStore, + ResourceAccessService resourceAccessService) { + this.skillService = skillService; + this.skillReferenceService = skillReferenceService; + this.skillScriptService = skillScriptService; + this.skillAssetService = skillAssetService; + this.contentStore = contentStore; + this.resourceAccessService = resourceAccessService; + } + + /** + * {@inheritDoc} + */ + @Override + public List tree(BigInteger skillId) { + Skill skill = requireReadableSkill(skillId); + List roots = new ArrayList<>(); + roots.add(fileNode(SkillPaths.SKILL_FILE, "SKILL.md", SkillFileType.SKILL.name())); + roots.add(directoryNode(SkillPaths.REFERENCES_DIR, skill.getReferences().stream() + .map(item -> fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.REFERENCE.name())) + .toList())); + roots.add(directoryNode(SkillPaths.SCRIPTS_DIR, skill.getScripts().stream() + .map(item -> { + SkillFileNode node = fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.SCRIPT.name()); + node.setLanguage(item.getLanguage()); + return node; + }) + .toList())); + roots.add(directoryNode(SkillPaths.ASSETS_DIR, skill.getAssets().stream() + .map(item -> { + SkillFileNode node = fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.ASSET.name()); + node.setMediaType(item.getMediaType()); + node.setSize(item.getSize()); + return node; + }) + .toList())); + return roots; + } + + /** + * {@inheritDoc} + */ + @Override + public SkillFileContent getContent(BigInteger skillId, String path) { + Skill skill = requireReadableSkill(skillId); + String normalizedPath = SkillPaths.normalize(path); + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + SkillFileContent content = new SkillFileContent(); + content.setPath(SkillPaths.SKILL_FILE); + content.setType(SkillFileType.SKILL.name()); + content.setContent(skill.getSkillContent()); + content.setSize((long) bytes(skill.getSkillContent()).length); + return content; + } + String topDir = SkillPaths.firstSegment(normalizedPath); + if (SkillPaths.REFERENCES_DIR.equals(topDir)) { + SkillReference reference = requireReference(skillId, normalizedPath); + SkillFileContent content = new SkillFileContent(); + content.setPath(reference.getPath()); + content.setType(SkillFileType.REFERENCE.name()); + content.setContent(reference.getContent()); + content.setSize(reference.getSize()); + return content; + } + if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { + SkillScript script = requireScript(skillId, normalizedPath); + SkillFileContent content = new SkillFileContent(); + content.setPath(script.getPath()); + content.setType(SkillFileType.SCRIPT.name()); + content.setContent(script.getContent()); + content.setLanguage(script.getLanguage()); + content.setSize(script.getSize()); + return content; + } + if (SkillPaths.ASSETS_DIR.equals(topDir)) { + SkillAsset asset = requireAsset(skillId, normalizedPath); + SkillFileContent content = new SkillFileContent(); + content.setPath(asset.getPath()); + content.setType(SkillFileType.ASSET.name()); + content.setMediaType(asset.getMediaType()); + content.setSize(asset.getSize()); + content.setDownloadUrl("/api/v1/skill/file/asset"); + return content; + } + throw new BusinessException("不支持的 Skill 文件路径"); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SkillFileContent saveContent(SkillFileSaveRequest request) { + if (request == null || request.getSkillId() == null) { + throw new BusinessException("Skill ID 不能为空"); + } + Skill skill = requireManageSkill(request.getSkillId()); + String normalizedPath = SkillPaths.normalize(request.getPath()); + String content = request.getContent() == null ? "" : request.getContent(); + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + skill.setSkillContent(content); + skillService.updateDraft(skill); + return getContent(skill.getId(), SkillPaths.SKILL_FILE); + } + String topDir = SkillPaths.firstSegment(normalizedPath); + if (SkillPaths.REFERENCES_DIR.equals(topDir)) { + saveReference(skill, normalizedPath, content); + refreshSkillCounts(skill.getId()); + return getContent(skill.getId(), normalizedPath); + } + if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { + saveScript(skill, normalizedPath, content); + refreshSkillCounts(skill.getId()); + return getContent(skill.getId(), normalizedPath); + } + throw new BusinessException("仅支持保存 SKILL.md、references/*.md 和 scripts/*.py|*.js|*.sh"); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFile(BigInteger skillId, String path) { + requireManageSkill(skillId); + String normalizedPath = SkillPaths.normalize(path); + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + throw new BusinessException("SKILL.md 不允许删除"); + } + String topDir = SkillPaths.firstSegment(normalizedPath); + if (SkillPaths.REFERENCES_DIR.equals(topDir)) { + skillReferenceService.remove(QueryWrapper.create().eq(SkillReference::getSkillId, skillId).eq(SkillReference::getPath, normalizedPath)); + } else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { + skillScriptService.remove(QueryWrapper.create().eq(SkillScript::getSkillId, skillId).eq(SkillScript::getPath, normalizedPath)); + } else if (SkillPaths.ASSETS_DIR.equals(topDir)) { + skillAssetService.remove(QueryWrapper.create().eq(SkillAsset::getSkillId, skillId).eq(SkillAsset::getPath, normalizedPath)); + } else { + throw new BusinessException("不支持的 Skill 文件路径"); + } + refreshSkillCounts(skillId); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file) { + Skill skill = requireManageSkill(skillId); + if (file == null || file.isEmpty()) { + throw new BusinessException("asset 文件不能为空"); + } + String normalizedPath = normalizeAssetPath(path, file.getOriginalFilename()); + try { + String contentRef = contentStore.put(file.getBytes()); + String hash = contentRef.substring("sha256:".length()); + SkillAsset asset = findAsset(skillId, normalizedPath); + if (asset == null) { + asset = new SkillAsset(); + asset.setTenantId(skill.getTenantId()); + asset.setSkillId(skillId); + asset.setPath(normalizedPath); + } + asset.setName(SkillPaths.fileName(normalizedPath)); + asset.setMediaType(detectMediaType(normalizedPath)); + asset.setContentRef(contentRef); + asset.setContentHash(hash); + asset.setSize(file.getSize()); + if (asset.getId() == null) { + skillAssetService.save(asset); + } else { + skillAssetService.updateById(asset); + } + refreshSkillCounts(skillId); + return getContent(skillId, normalizedPath); + } catch (IOException e) { + throw new BusinessException("读取 asset 文件失败"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream openAsset(BigInteger skillId, String path) { + requireReadableSkill(skillId); + SkillAsset asset = requireAsset(skillId, SkillPaths.normalize(path)); + return contentStore.open(asset.getContentRef()); + } + + private Skill requireReadableSkill(BigInteger skillId) { + Skill skill = requireSkill(skillId); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); + return skillService.getDetail(skillId); + } + + private Skill requireManageSkill(BigInteger skillId) { + Skill skill = skillService.getDetail(skillId); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理该 Skill"); + return skill; + } + + private Skill requireSkill(BigInteger skillId) { + if (skillId == null) { + throw new BusinessException("Skill ID 不能为空"); + } + Skill skill = skillService.getById(skillId); + if (skill == null) { + throw new BusinessException("Skill 不存在"); + } + return skill; + } + + private void saveReference(Skill skill, String path, String content) { + if (!SkillPaths.hasExtension(path, ".md")) { + throw new BusinessException("reference 仅支持 .md 文件"); + } + SkillReference reference = findReference(skill.getId(), path); + if (reference == null) { + reference = new SkillReference(); + reference.setTenantId(skill.getTenantId()); + reference.setSkillId(skill.getId()); + reference.setPath(path); + } + byte[] bytes = bytes(content); + reference.setName(SkillPaths.fileName(path)); + reference.setContent(content); + reference.setContentHash(SkillHashes.sha256Hex(bytes)); + reference.setSize((long) bytes.length); + if (reference.getId() == null) { + skillReferenceService.save(reference); + } else { + skillReferenceService.updateById(reference); + } + } + + private void saveScript(Skill skill, String path, String content) { + SkillScriptLanguage language = SkillScriptLanguage.fromPath(path); + if (language == SkillScriptLanguage.UNKNOWN) { + throw new BusinessException("script 仅支持 .py、.js、.sh 文件"); + } + SkillScript script = findScript(skill.getId(), path); + if (script == null) { + script = new SkillScript(); + script.setTenantId(skill.getTenantId()); + script.setSkillId(skill.getId()); + script.setPath(path); + } + byte[] bytes = bytes(content); + script.setLanguage(language.name()); + script.setContent(content); + script.setContentHash(SkillHashes.sha256Hex(bytes)); + script.setSize((long) bytes.length); + if (script.getId() == null) { + skillScriptService.save(script); + } else { + skillScriptService.updateById(script); + } + } + + private void refreshSkillCounts(BigInteger skillId) { + Skill skill = skillService.getDetail(skillId); + Skill update = new Skill(); + update.setId(skillId); + update.setReferenceCount(skill.getReferences() == null ? 0 : skill.getReferences().size()); + update.setScriptCount(skill.getScripts() == null ? 0 : skill.getScripts().size()); + update.setAssetCount(skill.getAssets() == null ? 0 : skill.getAssets().size()); + skillService.updateById(update); + } + + private SkillReference findReference(BigInteger skillId, String path) { + List records = skillReferenceService.list(QueryWrapper.create() + .eq(SkillReference::getSkillId, skillId) + .eq(SkillReference::getPath, path)); + return records.isEmpty() ? null : records.get(0); + } + + private SkillReference requireReference(BigInteger skillId, String path) { + SkillReference reference = findReference(skillId, path); + if (reference == null) { + throw new BusinessException("reference 文件不存在"); + } + return reference; + } + + private SkillScript findScript(BigInteger skillId, String path) { + List records = skillScriptService.list(QueryWrapper.create() + .eq(SkillScript::getSkillId, skillId) + .eq(SkillScript::getPath, path)); + return records.isEmpty() ? null : records.get(0); + } + + private SkillScript requireScript(BigInteger skillId, String path) { + SkillScript script = findScript(skillId, path); + if (script == null) { + throw new BusinessException("script 文件不存在"); + } + return script; + } + + private SkillAsset findAsset(BigInteger skillId, String path) { + List records = skillAssetService.list(QueryWrapper.create() + .eq(SkillAsset::getSkillId, skillId) + .eq(SkillAsset::getPath, path)); + return records.isEmpty() ? null : records.get(0); + } + + private SkillAsset requireAsset(BigInteger skillId, String path) { + SkillAsset asset = findAsset(skillId, path); + if (asset == null) { + throw new BusinessException("asset 文件不存在"); + } + return asset; + } + + private String normalizeAssetPath(String path, String originalFilename) { + String effectivePath = path; + if (effectivePath == null || effectivePath.isBlank()) { + effectivePath = SkillPaths.ASSETS_DIR + "/" + (originalFilename == null ? "asset.bin" : originalFilename); + } + String normalized = SkillPaths.normalize(effectivePath); + if (!SkillPaths.ASSETS_DIR.equals(SkillPaths.firstSegment(normalized))) { + throw new BusinessException("asset 必须位于 assets/ 目录下"); + } + return normalized; + } + + private SkillFileNode directoryNode(String name, List flatChildren) { + SkillFileNode node = new SkillFileNode(); + node.setKey(name); + node.setPath(name); + node.setName(name + "/"); + node.setType("DIRECTORY"); + node.setChildren(toNestedChildren(name, flatChildren)); + return node; + } + + private List toNestedChildren(String root, List flatChildren) { + Map nodes = new LinkedHashMap<>(); + for (SkillFileNode child : flatChildren) { + String[] segments = child.getPath().substring(root.length() + 1).split("/"); + String currentPath = root; + for (int i = 0; i < segments.length; i++) { + currentPath = currentPath + "/" + segments[i]; + boolean leaf = i == segments.length - 1; + if (leaf) { + nodes.put(currentPath, child); + } else { + String segmentName = segments[i]; + nodes.computeIfAbsent(currentPath, key -> { + SkillFileNode directory = new SkillFileNode(); + directory.setKey(key); + directory.setPath(key); + directory.setName(segmentName + "/"); + directory.setType("DIRECTORY"); + return directory; + }); + } + } + } + List roots = new ArrayList<>(); + for (SkillFileNode node : nodes.values()) { + String parentPath = parentPath(node.getPath()); + if (root.equals(parentPath)) { + roots.add(node); + } else { + SkillFileNode parent = nodes.get(parentPath); + if (parent != null) { + parent.getChildren().add(node); + } + } + } + return roots; + } + + private SkillFileNode fileNode(String path, String name, String type) { + SkillFileNode node = new SkillFileNode(); + node.setKey(path); + node.setPath(path); + node.setName(name); + node.setType(type); + return node; + } + + private String parentPath(String path) { + int index = path.lastIndexOf('/'); + return index < 0 ? "" : path.substring(0, index); + } + + private byte[] bytes(String content) { + return (content == null ? "" : content).getBytes(StandardCharsets.UTF_8); + } + + private String detectMediaType(String path) { + String mediaType = URLConnection.guessContentTypeFromName(path); + return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileType.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileType.java new file mode 100644 index 00000000..cf7eb815 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/file/SkillFileType.java @@ -0,0 +1,28 @@ +package tech.easyflow.skill.file; + +/** + * Skill 逻辑文件类型。 + */ +public enum SkillFileType { + + /** + * SKILL.md 主文件。 + */ + SKILL, + + /** + * references/ 下的 Markdown 文档。 + */ + REFERENCE, + + /** + * scripts/ 下的脚本文件。 + */ + SCRIPT, + + /** + * assets/ 下的静态资产。 + */ + ASSET +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java new file mode 100644 index 00000000..e51ee6b3 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportService.java @@ -0,0 +1,20 @@ +package tech.easyflow.skill.imports; + +import java.io.OutputStream; +import java.math.BigInteger; +import java.util.Collection; + +/** + * Skill zip 导出服务。 + */ +public interface SkillExportService { + + /** + * 导出一个或多个 Skill 为标准 zip 包。 + * + * @param skillIds Skill ID 集合 + * @param outputStream zip 输出流 + */ + void exportZip(Collection skillIds, OutputStream outputStream); +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java new file mode 100644 index 00000000..b5f0fa19 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillExportServiceImpl.java @@ -0,0 +1,106 @@ +package tech.easyflow.skill.imports; + +import com.easyagents.skill.util.SkillPaths; +import org.springframework.stereotype.Service; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillAsset; +import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.entity.SkillScript; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; + +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Skill zip 导出服务实现。 + */ +@Service +public class SkillExportServiceImpl implements SkillExportService { + + private final SkillService skillService; + private final DBSkillContentStore contentStore; + + /** + * 创建 Skill 导出服务。 + * + * @param skillService Skill 服务 + * @param contentStore Skill asset 内容存储 + */ + public SkillExportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) { + this.skillService = skillService; + this.contentStore = contentStore; + } + + /** + * {@inheritDoc} + */ + @Override + public void exportZip(Collection skillIds, OutputStream outputStream) { + if (skillIds == null || skillIds.isEmpty()) { + throw new BusinessException("请选择要导出的 Skill"); + } + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { + Set folderNames = new LinkedHashSet<>(); + for (BigInteger skillId : skillIds) { + Skill skill = skillService.getDetail(skillId); + writeSkill(zipOutputStream, folderNames, skill); + } + } catch (IOException e) { + throw new BusinessException("导出 Skill 失败"); + } + } + + private void writeSkill(ZipOutputStream zipOutputStream, Set folderNames, Skill skill) throws IOException { + String folder = uniqueFolderName(folderNames, skill.getName()); + writeText(zipOutputStream, folder + "/" + SkillPaths.SKILL_FILE, skill.getSkillContent()); + if (skill.getReferences() != null) { + for (SkillReference reference : skill.getReferences()) { + writeText(zipOutputStream, folder + "/" + reference.getPath(), reference.getContent()); + } + } + if (skill.getScripts() != null) { + for (SkillScript script : skill.getScripts()) { + writeText(zipOutputStream, folder + "/" + script.getPath(), script.getContent()); + } + } + if (skill.getAssets() != null) { + for (SkillAsset asset : skill.getAssets()) { + zipOutputStream.putNextEntry(new ZipEntry(folder + "/" + asset.getPath())); + zipOutputStream.write(contentStore.readAllBytes(asset.getContentRef())); + zipOutputStream.closeEntry(); + } + } + } + + private void writeText(ZipOutputStream zipOutputStream, String path, String content) throws IOException { + zipOutputStream.putNextEntry(new ZipEntry(path)); + zipOutputStream.write((content == null ? "" : content).getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + + private String uniqueFolderName(Set folderNames, String name) { + String base = sanitizeFolderName(name); + String candidate = base; + int index = 2; + while (!folderNames.add(candidate)) { + candidate = base + "-" + index++; + } + return candidate; + } + + private String sanitizeFolderName(String value) { + String sanitized = value == null ? "skill" : value.trim().replaceAll("[\\\\/:*?\"<>|\\s]+", "-"); + sanitized = sanitized.replaceAll("^-+", "").replaceAll("-+$", ""); + return sanitized.isBlank() ? "skill" : sanitized; + } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java new file mode 100644 index 00000000..5eae7885 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreview.java @@ -0,0 +1,31 @@ +package tech.easyflow.skill.imports; + +import java.util.ArrayList; +import java.util.List; + +/** + * Skill 导入预览结果。 + */ +public class SkillImportPreview { + + private List skills = new ArrayList<>(); + + /** + * 获取导入 Skill 预览项。 + * + * @return 预览项列表 + */ + public List getSkills() { + return skills; + } + + /** + * 设置导入 Skill 预览项。 + * + * @param skills 预览项列表 + */ + public void setSkills(List skills) { + this.skills = skills == null ? new ArrayList<>() : skills; + } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java new file mode 100644 index 00000000..5110eaf9 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportPreviewItem.java @@ -0,0 +1,47 @@ +package tech.easyflow.skill.imports; + +/** + * Skill 导入预览项。 + */ +public class SkillImportPreviewItem { + + private String packageId; + private String name; + private String description; + private int referenceCount; + private int scriptCount; + private int assetCount; + private boolean conflict; + + /** + * 获取包内 Skill ID。 + * + * @return 包内 Skill ID + */ + public String getPackageId() { + return packageId; + } + + /** + * 设置包内 Skill ID。 + * + * @param packageId 包内 Skill ID + */ + public void setPackageId(String packageId) { + this.packageId = packageId; + } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public int getReferenceCount() { return referenceCount; } + public void setReferenceCount(int referenceCount) { this.referenceCount = referenceCount; } + public int getScriptCount() { return scriptCount; } + public void setScriptCount(int scriptCount) { this.scriptCount = scriptCount; } + public int getAssetCount() { return assetCount; } + public void setAssetCount(int assetCount) { this.assetCount = assetCount; } + public boolean isConflict() { return conflict; } + public void setConflict(boolean conflict) { this.conflict = conflict; } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java new file mode 100644 index 00000000..d768aca7 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportService.java @@ -0,0 +1,32 @@ +package tech.easyflow.skill.imports; + +import tech.easyflow.skill.entity.Skill; + +import java.io.InputStream; +import java.math.BigInteger; +import java.util.List; + +/** + * Skill zip 导入服务。 + */ +public interface SkillImportService { + + /** + * 预览 zip 中的 Skill 包。 + * + * @param inputStream zip 输入流 + * @return 导入预览 + */ + SkillImportPreview preview(InputStream inputStream); + + /** + * 确认导入 zip 中的 Skill 包。 + * + * @param inputStream zip 输入流 + * @param categoryId 目标分类 ID,可为空 + * @param overwriteDraft 是否覆盖同名草稿 + * @return 已保存 Skill 列表 + */ + List importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft); +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java new file mode 100644 index 00000000..ade3b3cd --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/imports/SkillImportServiceImpl.java @@ -0,0 +1,102 @@ +package tech.easyflow.skill.imports; + +import com.easyagents.skill.codec.ZipSkillPackageCodec; +import com.easyagents.skill.util.SkillHashes; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.skill.support.SkillModelConverter; + +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Skill zip 导入服务实现。 + */ +@Service +public class SkillImportServiceImpl implements SkillImportService { + + private final SkillService skillService; + private final DBSkillContentStore contentStore; + + /** + * 创建 Skill 导入服务。 + * + * @param skillService Skill 服务 + * @param contentStore Skill asset 内容存储 + */ + public SkillImportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) { + this.skillService = skillService; + this.contentStore = contentStore; + } + + /** + * {@inheritDoc} + */ + @Override + public SkillImportPreview preview(InputStream inputStream) { + List importedSkills = new ZipSkillPackageCodec().importZip(inputStream); + SkillImportPreview preview = new SkillImportPreview(); + preview.setSkills(importedSkills.stream().map(this::toPreviewItem).toList()); + return preview; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public List importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft) { + List importedSkills = new ZipSkillPackageCodec(contentStore).importZip(inputStream); + List savedSkills = new ArrayList<>(); + for (com.easyagents.skill.model.Skill imported : importedSkills) { + Skill existing = findByName(imported.getName()); + if (existing != null) { + if (!overwriteDraft || !PublishStatus.DRAFT.getCode().equals(existing.getPublishStatus())) { + throw new BusinessException("Skill 已存在,且不允许覆盖:" + imported.getName()); + } + Skill replacement = SkillModelConverter.fromAgentSkill(imported); + replacement.setId(existing.getId()); + replacement.setCategoryId(categoryId); + replacement.setSourceType("ZIP"); + replacement.setPackageHash(SkillHashes.sha256Hex(imported.getSkillContent().getBytes(StandardCharsets.UTF_8))); + savedSkills.add(skillService.updateDraft(replacement)); + } else { + Skill skill = SkillModelConverter.fromAgentSkill(imported); + skill.setCategoryId(categoryId); + skill.setSourceType("ZIP"); + skill.setPackageHash(SkillHashes.sha256Hex(imported.getSkillContent().getBytes(StandardCharsets.UTF_8))); + savedSkills.add(skillService.saveDraft(skill)); + } + } + return savedSkills; + } + + private SkillImportPreviewItem toPreviewItem(com.easyagents.skill.model.Skill skill) { + SkillImportPreviewItem item = new SkillImportPreviewItem(); + item.setPackageId(skill.getId()); + item.setName(skill.getName()); + item.setDescription(skill.getDescription()); + item.setReferenceCount(skill.getReferences().size()); + item.setScriptCount(skill.getScripts().size()); + item.setAssetCount(skill.getAssets().size()); + item.setConflict(findByName(skill.getName()) != null); + return item; + } + + private Skill findByName(String name) { + if (name == null || name.isBlank()) { + return null; + } + List skills = skillService.list(QueryWrapper.create().eq(Skill::getName, name)); + return skills.isEmpty() ? null : skills.get(0); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java new file mode 100644 index 00000000..d4aecf97 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetContentMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillAssetContent; + +/** + * Skill asset 内容索引 Mapper。 + */ +public interface SkillAssetContentMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java new file mode 100644 index 00000000..7ea3e61f --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillAssetMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillAsset; + +/** + * Skill asset Mapper。 + */ +public interface SkillAssetMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java new file mode 100644 index 00000000..46d45651 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillCategoryMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillCategory; + +/** + * Skill 分类 Mapper。 + */ +public interface SkillCategoryMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java new file mode 100644 index 00000000..a9f3db64 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.Skill; + +/** + * Skill Mapper。 + */ +public interface SkillMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java new file mode 100644 index 00000000..c01d3ff8 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillReferenceMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillReference; + +/** + * Skill reference Mapper。 + */ +public interface SkillReferenceMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java new file mode 100644 index 00000000..d34e7611 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillScriptMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillScript; + +/** + * Skill script Mapper。 + */ +public interface SkillScriptMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java new file mode 100644 index 00000000..b0c7ceeb --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java @@ -0,0 +1,152 @@ +package tech.easyflow.skill.publish; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler; +import tech.easyflow.approval.enums.ApprovalResourceType; +import tech.easyflow.approval.service.ApprovalInstanceService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.Date; +import java.util.Map; + +/** + * Skill 审批资源处理器。 + */ +@Component +public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler { + + private final SkillService skillService; + private final ResourceAccessService resourceAccessService; + + /** + * 创建 Skill 审批资源处理器。 + * + * @param approvalInstanceService 审批实例服务 + * @param objectMapper JSON 映射器 + * @param skillService Skill 服务 + * @param resourceAccessService 资源访问服务 + */ + public SkillApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService, + ObjectMapper objectMapper, + SkillService skillService, + ResourceAccessService resourceAccessService) { + super(approvalInstanceService, objectMapper); + this.skillService = skillService; + this.resourceAccessService = resourceAccessService; + } + + /** + * {@inheritDoc} + */ + @Override + public String resourceType() { + return ApprovalResourceType.SKILL.getCode(); + } + + /** + * {@inheritDoc} + */ + @Override + public void assertPublishedAccess(Object identifier, String denyMessage) { + Skill skill = skillService.getById(String.valueOf(identifier)); + if (skill == null || !PublishStatus.from(skill.getPublishStatus()).isExternallyVisible() + || skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException(denyMessage); + } + } + + @Override + protected Skill requireResource(BigInteger resourceId) { + Skill skill = skillService.getById(resourceId); + if (skill == null) { + throw new BusinessException("Skill 不存在"); + } + return skill; + } + + @Override + protected void assertManagePermission(Skill resource) { + resourceAccessService.assertAccess(CategoryResourceType.SKILL, resource, ResourceAction.MANAGE, "无权限管理该 Skill"); + } + + @Override + protected BigInteger getCategoryId(Skill resource) { + return resource.getCategoryId(); + } + + @Override + protected BigInteger getDeptId(Skill resource) { + return resource.getDeptId(); + } + + @Override + protected String getTitle(Skill resource) { + return resource.getDisplayName() == null || resource.getDisplayName().isBlank() + ? resource.getName() + : resource.getDisplayName(); + } + + @Override + protected PublishStatus getCurrentStatus(Skill resource) { + return PublishStatus.from(resource.getPublishStatus()); + } + + @Override + protected Map getPublishedSnapshot(Skill resource) { + return resource.getPublishedSnapshotJson(); + } + + @Override + protected Map buildResourceSnapshot(Skill resource) { + return skillService.buildPublishSnapshot(resource); + } + + @Override + protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { + Skill skill = new Skill(); + skill.setId(resourceId); + skill.setPublishStatus(publishStatus.getCode()); + skill.setCurrentApprovalInstanceId(currentApprovalInstanceId); + skillService.updateById(skill); + } + + @Override + protected void publishResource(BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId) { + Skill skill = new Skill(); + skill.setId(resourceId); + skill.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + skill.setPublishedSnapshotJson(resourceSnapshot); + skill.setPublishedAt(new Date()); + skill.setPublishedBy(operatorId); + skill.setCurrentApprovalInstanceId(null); + skillService.updateById(skill); + } + + @Override + protected void markResourceOffline(BigInteger resourceId) { + Skill skill = new Skill(); + skill.setId(resourceId); + skill.setPublishStatus(PublishStatus.OFFLINE.getCode()); + skill.setCurrentApprovalInstanceId(null); + skillService.updateById(skill); + } + + @Override + protected void removeResource(BigInteger resourceId) { + skillService.removeAggregate(resourceId); + } + + @Override + protected String resourceLabel() { + return "Skill"; + } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java new file mode 100644 index 00000000..fb8816e5 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillPublishAppService.java @@ -0,0 +1,72 @@ +package tech.easyflow.skill.publish; + +import org.springframework.stereotype.Service; +import tech.easyflow.ai.publish.AiResourceLifecycleService; +import tech.easyflow.approval.entity.vo.ApprovalActionResult; +import tech.easyflow.approval.enums.ApprovalActionType; +import tech.easyflow.approval.enums.ApprovalResourceType; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; + +/** + * Skill 发布生命周期应用服务。 + */ +@Service +public class SkillPublishAppService { + + private final AiResourceLifecycleService aiResourceLifecycleService; + + /** + * 创建 Skill 发布应用服务。 + * + * @param aiResourceLifecycleService AI 资源生命周期服务 + */ + public SkillPublishAppService(AiResourceLifecycleService aiResourceLifecycleService) { + this.aiResourceLifecycleService = aiResourceLifecycleService; + } + + /** + * 提交 Skill 发布审批。 + * + * @param id Skill ID + * @return 审批动作结果 + */ + public ApprovalActionResult submitPublishApproval(BigInteger id) { + return submit(id, ApprovalActionType.PUBLISH); + } + + /** + * 提交 Skill 下线审批。 + * + * @param id Skill ID + * @return 审批动作结果 + */ + public ApprovalActionResult submitOfflineApproval(BigInteger id) { + return submit(id, ApprovalActionType.OFFLINE); + } + + /** + * 提交 Skill 删除审批。 + * + * @param id Skill ID + * @return 审批动作结果 + */ + public ApprovalActionResult submitDeleteApproval(BigInteger id) { + return submit(id, ApprovalActionType.DELETE); + } + + private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) { + if (id == null) { + throw new BusinessException("Skill 审批时资源ID不能为空"); + } + return aiResourceLifecycleService.submitAction( + ApprovalResourceType.SKILL.getCode(), + id, + actionType.getCode(), + SaTokenUtil.getLoginAccount().getId() + ); + } +} + diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java new file mode 100644 index 00000000..46d13743 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/repository/DBSkillRepository.java @@ -0,0 +1,117 @@ +package tech.easyflow.skill.repository; + +import com.easyagents.skill.model.SkillDescriptor; +import com.easyagents.skill.repository.SkillRepository; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Repository; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.support.SkillModelConverter; + +import java.math.BigInteger; +import java.util.List; +import java.util.Optional; + +/** + * 基于数据库的 easy-agents-skill 仓储适配器。 + */ +@Repository +public class DBSkillRepository implements SkillRepository { + + private final SkillService skillService; + + /** + * 创建数据库 Skill 仓储。 + * + * @param skillService Skill 服务 + */ + public DBSkillRepository(SkillService skillService) { + this.skillService = skillService; + } + + /** + * {@inheritDoc} + */ + @Override + public void save(com.easyagents.skill.model.Skill skill) { + Skill entity = SkillModelConverter.fromAgentSkill(skill); + BigInteger parsedId = tryParseId(skill.getId()); + if (parsedId != null && skillService.getById(parsedId) != null) { + entity.setId(parsedId); + skillService.updateDraft(entity); + return; + } + skillService.saveDraft(entity); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional get(String skillId) { + BigInteger id = parseId(skillId); + Skill skill = skillService.getDetail(id); + return Optional.of(SkillModelConverter.toAgentSkill(skill)); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional getDescriptor(String skillId) { + BigInteger id = parseId(skillId); + Skill skill = skillService.getById(id); + if (skill == null) { + return Optional.empty(); + } + return Optional.of(new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(), + new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson()))); + } + + /** + * {@inheritDoc} + */ + @Override + public List listDescriptors() { + return skillService.list().stream() + .map(skill -> new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(), + new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson()))) + .toList(); + } + + /** + * {@inheritDoc} + */ + @Override + public void delete(String skillId) { + skillService.removeAggregate(parseId(skillId)); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean exists(String skillId) { + BigInteger id = parseId(skillId); + return skillService.count(QueryWrapper.create().eq(Skill::getId, id)) > 0; + } + + private BigInteger parseId(String skillId) { + if (skillId == null || skillId.isBlank()) { + throw new BusinessException("Skill ID 不能为空"); + } + return new BigInteger(skillId); + } + + private BigInteger tryParseId(String skillId) { + if (skillId == null || skillId.isBlank()) { + return null; + } + try { + return new BigInteger(skillId); + } catch (NumberFormatException ignored) { + return null; + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillApprovalStateService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillApprovalStateService.java new file mode 100644 index 00000000..4a2f2be1 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillApprovalStateService.java @@ -0,0 +1,25 @@ +package tech.easyflow.skill.service; + +import tech.easyflow.skill.entity.Skill; + +import java.util.Collection; + +/** + * Skill 审批展示态派生服务。 + */ +public interface SkillApprovalStateService { + + /** + * 填充单个 Skill 审批展示态。 + * + * @param skill Skill + */ + void fillSkillApprovalState(Skill skill); + + /** + * 批量填充 Skill 审批展示态。 + * + * @param skills Skill 集合 + */ + void fillSkillApprovalState(Collection skills); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java new file mode 100644 index 00000000..7d4fdce2 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetContentService.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillAssetContent; + +/** + * Skill asset 内容索引服务。 + */ +public interface SkillAssetContentService extends IService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java new file mode 100644 index 00000000..b6fc8012 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillAssetService.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillAsset; + +/** + * Skill asset 服务。 + */ +public interface SkillAssetService extends IService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java new file mode 100644 index 00000000..5f69762a --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillCategoryService.java @@ -0,0 +1,19 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillCategory; + +import java.math.BigInteger; + +/** + * Skill 分类服务。 + */ +public interface SkillCategoryService extends IService { + + /** + * 校验目标分类可作为 Skill 分类。 + * + * @param categoryId 分类 ID,可为空 + */ + void validateUsableCategory(BigInteger categoryId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java new file mode 100644 index 00000000..a11c8f22 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceService.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillReference; + +/** + * Skill reference 服务。 + */ +public interface SkillReferenceService extends IService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java new file mode 100644 index 00000000..c0b974fb --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillScriptService.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.SkillScript; + +/** + * Skill script 服务。 + */ +public interface SkillScriptService extends IService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java new file mode 100644 index 00000000..d0292caa --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java @@ -0,0 +1,60 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.Skill; + +import java.math.BigInteger; +import java.util.Map; + +/** + * Skill 业务服务。 + */ +public interface SkillService extends IService { + + /** + * 获取 Skill 详情。 + * + * @param id Skill ID + * @return Skill 详情 + */ + Skill getDetail(BigInteger id); + + /** + * 保存 Skill 草稿。 + * + * @param skill Skill 草稿 + * @return 保存后的 Skill + */ + Skill saveDraft(Skill skill); + + /** + * 更新 Skill 草稿。 + * + * @param skill Skill 草稿 + * @return 更新后的 Skill + */ + Skill updateDraft(Skill skill); + + /** + * 构建发布快照。 + * + * @param skill Skill 草稿 + * @return 发布快照 + */ + Map buildPublishSnapshot(Skill skill); + + /** + * 从发布快照还原 Skill。 + * + * @param snapshot 发布快照 + * @return Skill + */ + Skill fromSnapshot(Map snapshot); + + /** + * 删除 Skill 聚合。 + * + * @param id Skill ID + */ + void removeAggregate(BigInteger id); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java new file mode 100644 index 00000000..b6eb0145 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillApprovalStateServiceImpl.java @@ -0,0 +1,115 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.approval.entity.ApprovalInstance; +import tech.easyflow.approval.enums.ApprovalActionType; +import tech.easyflow.approval.enums.ApprovalInstanceStatus; +import tech.easyflow.approval.enums.ApprovalResourceType; +import tech.easyflow.approval.mapper.ApprovalInstanceMapper; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillApprovalStateService; + +import java.math.BigInteger; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Skill 审批展示态派生服务实现。 + */ +@Service +public class SkillApprovalStateServiceImpl implements SkillApprovalStateService { + + private final ApprovalInstanceMapper approvalInstanceMapper; + + /** + * 创建 Skill 审批展示态派生服务。 + * + * @param approvalInstanceMapper 审批实例 Mapper + */ + public SkillApprovalStateServiceImpl(ApprovalInstanceMapper approvalInstanceMapper) { + this.approvalInstanceMapper = approvalInstanceMapper; + } + + /** + * {@inheritDoc} + */ + @Override + public void fillSkillApprovalState(Skill skill) { + fillSkillApprovalState(skill == null ? List.of() : List.of(skill)); + } + + /** + * {@inheritDoc} + */ + @Override + public void fillSkillApprovalState(Collection skills) { + if (CollectionUtils.isEmpty(skills)) { + return; + } + List validSkills = skills.stream().filter(Objects::nonNull).toList(); + if (validSkills.isEmpty()) { + return; + } + Map instanceMap = loadInstanceMap(validSkills); + for (Skill skill : validSkills) { + fillOne(skill, instanceMap.get(skill.getCurrentApprovalInstanceId())); + } + } + + private void fillOne(Skill skill, ApprovalInstance instance) { + PublishStatus currentStatus = PublishStatus.from(skill.getPublishStatus()); + if (!isValidCurrentInstance(instance)) { + skill.setApprovalPending(false); + skill.setCurrentApprovalActionType(null); + skill.setDisplayPublishStatus(currentStatus.getCode()); + return; + } + ApprovalInstanceStatus instanceStatus = ApprovalInstanceStatus.from(instance.getStatus()); + if (instanceStatus.isFinished()) { + skill.setApprovalPending(false); + skill.setCurrentApprovalActionType(null); + skill.setDisplayPublishStatus(currentStatus.getCode()); + return; + } + ApprovalActionType actionType = ApprovalActionType.from(instance.getActionType()); + skill.setApprovalPending(true); + skill.setCurrentApprovalActionType(actionType.getCode()); + skill.setDisplayPublishStatus(resolveDisplayStatusWithActiveInstance(currentStatus, actionType).getCode()); + } + + private Map loadInstanceMap(Collection skills) { + Set instanceIds = skills.stream() + .map(Skill::getCurrentApprovalInstanceId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (instanceIds.isEmpty()) { + return Collections.emptyMap(); + } + List instances = approvalInstanceMapper.selectListByQuery( + QueryWrapper.create().in(ApprovalInstance::getId, instanceIds) + ); + return instances.stream().collect(Collectors.toMap(ApprovalInstance::getId, Function.identity())); + } + + private boolean isValidCurrentInstance(ApprovalInstance instance) { + return instance != null && ApprovalResourceType.SKILL.getCode().equals(instance.getResourceType()); + } + + private PublishStatus resolveDisplayStatusWithActiveInstance(PublishStatus currentStatus, + ApprovalActionType actionType) { + if (currentStatus == PublishStatus.PUBLISH_PENDING + || currentStatus == PublishStatus.OFFLINE_PENDING + || currentStatus == PublishStatus.DELETE_PENDING) { + return currentStatus; + } + return switch (actionType) { + case PUBLISH -> PublishStatus.PUBLISH_PENDING; + case OFFLINE -> PublishStatus.OFFLINE_PENDING; + case DELETE -> PublishStatus.DELETE_PENDING; + }; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java new file mode 100644 index 00000000..d30c22ca --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetContentServiceImpl.java @@ -0,0 +1,14 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import tech.easyflow.skill.entity.SkillAssetContent; +import tech.easyflow.skill.mapper.SkillAssetContentMapper; +import tech.easyflow.skill.service.SkillAssetContentService; + +/** + * Skill asset 内容索引服务实现。 + */ +@Service +public class SkillAssetContentServiceImpl extends ServiceImpl implements SkillAssetContentService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java new file mode 100644 index 00000000..cb60c126 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillAssetServiceImpl.java @@ -0,0 +1,14 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import tech.easyflow.skill.entity.SkillAsset; +import tech.easyflow.skill.mapper.SkillAssetMapper; +import tech.easyflow.skill.service.SkillAssetService; + +/** + * Skill asset 服务实现。 + */ +@Service +public class SkillAssetServiceImpl extends ServiceImpl implements SkillAssetService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java new file mode 100644 index 00000000..6975ea3d --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillCategoryServiceImpl.java @@ -0,0 +1,120 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillCategory; +import tech.easyflow.skill.mapper.SkillCategoryMapper; +import tech.easyflow.skill.service.SkillCategoryService; + +import java.math.BigInteger; +import java.util.Date; + +/** + * Skill 分类服务实现。 + */ +@Service +public class SkillCategoryServiceImpl extends ServiceImpl implements SkillCategoryService { + + private static final int MAX_LEVEL = 3; + + /** + * {@inheritDoc} + */ + @Override + public void validateUsableCategory(BigInteger categoryId) { + if (categoryId == null) { + return; + } + SkillCategory category = getById(categoryId); + if (category == null) { + throw new BusinessException("Skill 分类不存在"); + } + if (category.getLevelNo() != null && category.getLevelNo() > MAX_LEVEL) { + throw new BusinessException("Skill 分类最多支持三级"); + } + if (category.getStatus() != null && category.getStatus() != 1) { + throw new BusinessException("Skill 分类不可用"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean save(SkillCategory entity) { + applyCategoryFields(entity); + return super.save(entity); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean updateById(SkillCategory entity) { + applyCategoryFields(entity); + return super.updateById(entity); + } + + /** + * 判断分类下是否存在子分类。 + * + * @param categoryId 分类 ID + * @return 存在子分类时返回 true + */ + public boolean hasChildren(BigInteger categoryId) { + if (categoryId == null) { + return false; + } + return count(QueryWrapper.create().eq(SkillCategory::getParentId, categoryId)) > 0; + } + + private void applyCategoryFields(SkillCategory category) { + if (category == null) { + throw new BusinessException("Skill 分类不能为空"); + } + if (category.getCategoryName() == null || category.getCategoryName().isBlank()) { + throw new BusinessException("Skill 分类名称不能为空"); + } + SkillCategory parent = null; + if (category.getParentId() != null) { + parent = getById(category.getParentId()); + if (parent == null) { + throw new BusinessException("父级 Skill 分类不存在"); + } + if (category.getId() != null && category.getId().equals(category.getParentId())) { + throw new BusinessException("父级分类不能是自身"); + } + } + int level = parent == null ? 1 : (parent.getLevelNo() == null ? 1 : parent.getLevelNo()) + 1; + if (level > MAX_LEVEL) { + throw new BusinessException("Skill 分类最多支持三级"); + } + LoginAccount account = SaTokenUtil.getLoginAccount(); + Date now = new Date(); + category.setLevelNo(level); + category.setAncestors(parent == null ? "" : appendAncestor(parent)); + category.setStatus(category.getStatus() == null ? 1 : category.getStatus()); + category.setSortNo(category.getSortNo() == null ? 0 : category.getSortNo()); + if (category.getId() == null) { + category.setTenantId(account.getTenantId()); + category.setCreated(now); + category.setCreatedBy(account.getId()); + } + category.setModified(now); + category.setModifiedBy(account.getId()); + } + + private String appendAncestor(SkillCategory parent) { + String ancestors = parent.getAncestors() == null || parent.getAncestors().isBlank() + ? String.valueOf(parent.getId()) + : parent.getAncestors() + "," + parent.getId(); + if (ancestors.length() > 512) { + throw new BusinessException("Skill 分类层级路径过长"); + } + return ancestors; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java new file mode 100644 index 00000000..32c60b69 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillReferenceServiceImpl.java @@ -0,0 +1,14 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.mapper.SkillReferenceMapper; +import tech.easyflow.skill.service.SkillReferenceService; + +/** + * Skill reference 服务实现。 + */ +@Service +public class SkillReferenceServiceImpl extends ServiceImpl implements SkillReferenceService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java new file mode 100644 index 00000000..83dda3e1 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillScriptServiceImpl.java @@ -0,0 +1,14 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import tech.easyflow.skill.entity.SkillScript; +import tech.easyflow.skill.mapper.SkillScriptMapper; +import tech.easyflow.skill.service.SkillScriptService; + +/** + * Skill script 服务实现。 + */ +@Service +public class SkillScriptServiceImpl extends ServiceImpl implements SkillScriptService { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java new file mode 100644 index 00000000..c1309846 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java @@ -0,0 +1,327 @@ +package tech.easyflow.skill.service.impl; + +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.factory.SkillFactory; +import com.easyagents.skill.validation.defaults.DefaultSkillValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillAsset; +import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.entity.SkillScript; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.*; +import tech.easyflow.skill.support.SkillModelConverter; +import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.enums.VisibilityScope; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import javax.annotation.Resource; +import java.math.BigInteger; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill 业务服务实现。 + */ +@Service +public class SkillServiceImpl extends ServiceImpl implements SkillService { + + private final DefaultSkillValidator skillValidator = new DefaultSkillValidator(); + + @Resource + private SkillCategoryService skillCategoryService; + @Resource + private SkillReferenceService skillReferenceService; + @Resource + private SkillScriptService skillScriptService; + @Resource + private SkillAssetService skillAssetService; + @Resource + private ResourceAccessService resourceAccessService; + @Resource + private CategoryPermissionService categoryPermissionService; + @Resource + private ObjectMapper objectMapper; + + /** + * {@inheritDoc} + */ + @Override + public Skill getDetail(BigInteger id) { + Skill skill = requireSkill(id); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); + fillResources(skill); + return skill; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Skill saveDraft(Skill skill) { + validateDraft(skill); + applyDraftDefaults(skill); + save(skill); + replaceResources(skill); + return getDetail(skill.getId()); + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Skill updateDraft(Skill skill) { + if (skill == null || skill.getId() == null) { + throw new BusinessException("Skill ID 不能为空"); + } + Skill existing = requireSkill(skill.getId()); + resourceAccessService.assertAccess(CategoryResourceType.SKILL, existing, ResourceAction.MANAGE, "无权限管理该 Skill"); + validateDraft(skill); + applyDraftUpdate(existing, skill); + updateById(existing); + replaceResources(existing); + return getDetail(existing.getId()); + } + + /** + * {@inheritDoc} + */ + @Override + public Map buildPublishSnapshot(Skill skill) { + Skill detail = getDetail(skill.getId()); + com.easyagents.skill.model.Skill agentSkill = SkillModelConverter.toAgentSkill(detail); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", detail.getId()); + snapshot.put("tenantId", detail.getTenantId()); + snapshot.put("deptId", detail.getDeptId()); + snapshot.put("createdBy", detail.getCreatedBy()); + snapshot.put("categoryId", detail.getCategoryId()); + snapshot.put("name", detail.getName()); + snapshot.put("displayName", detail.getDisplayName()); + snapshot.put("description", detail.getDescription()); + snapshot.put("metadataJson", detail.getMetadataJson()); + snapshot.put("skillContent", detail.getSkillContent()); + snapshot.put("enabled", detail.getEnabled()); + snapshot.put("visibilityScope", detail.getVisibilityScope()); + snapshot.put("sourceType", detail.getSourceType()); + snapshot.put("packageHash", detail.getPackageHash()); + snapshot.put("references", agentSkill.getReferences()); + snapshot.put("scripts", agentSkill.getScripts()); + snapshot.put("assets", agentSkill.getAssets()); + snapshot.put("snapshotAt", new Date()); + return snapshot; + } + + /** + * {@inheritDoc} + */ + @Override + public Skill fromSnapshot(Map snapshot) { + if (snapshot == null || snapshot.isEmpty()) { + throw new BusinessException("Skill 发布快照为空"); + } + Skill skill = objectMapper.convertValue(snapshot, Skill.class); + skill.setId(toBigInteger(snapshot.get("id"))); + skill.setTenantId(toBigInteger(snapshot.get("tenantId"))); + skill.setDeptId(toBigInteger(snapshot.get("deptId"))); + skill.setCreatedBy(toBigInteger(snapshot.get("createdBy"))); + skill.setCategoryId(toBigInteger(snapshot.get("categoryId"))); + skill.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + skill.setPublishedSnapshotJson(snapshot); + return skill; + } + + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void removeAggregate(BigInteger id) { + if (id == null) { + return; + } + removeResources(id); + removeById(id); + } + + private Skill requireSkill(BigInteger id) { + Skill skill = getById(id); + if (skill == null) { + throw new BusinessException("Skill 不存在"); + } + return skill; + } + + private void validateDraft(Skill skill) { + if (skill == null) { + throw new BusinessException("Skill 不能为空"); + } + normalizeFromSkillContent(skill); + if (skill.getName() == null || skill.getName().isBlank()) { + throw new BusinessException("Skill 名称不能为空"); + } + if (skill.getDescription() == null || skill.getDescription().isBlank()) { + throw new BusinessException("Skill 描述不能为空"); + } + if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) { + throw new BusinessException("SKILL.md 内容不能为空"); + } + skillCategoryService.validateUsableCategory(skill.getCategoryId()); + validateTargetCategoryVisible(skill.getCategoryId()); + skill.setVisibilityScope(VisibilityScope.fromOrDefault(skill.getVisibilityScope(), VisibilityScope.PRIVATE).name()); + validateSkillPackage(skill); + } + + private void validateTargetCategoryVisible(BigInteger categoryId) { + if (categoryId == null) { + return; + } + RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode()); + if (access.isRestricted() && !access.getCategoryIds().contains(categoryId)) { + throw new BusinessException("无权限使用该 Skill 分类"); + } + } + + private void applyDraftDefaults(Skill skill) { + LoginAccount account = requireCurrentLoginAccount(); + Date now = new Date(); + skill.setTenantId(account.getTenantId()); + skill.setDeptId(account.getDeptId()); + skill.setCreated(now); + skill.setCreatedBy(account.getId()); + skill.setModified(now); + skill.setModifiedBy(account.getId()); + skill.setEnabled(skill.getEnabled() == null || skill.getEnabled()); + skill.setSourceType(skill.getSourceType() == null ? "MANUAL" : skill.getSourceType()); + skill.setPublishStatus(PublishStatus.DRAFT.getCode()); + syncCounts(skill); + } + + private void applyDraftUpdate(Skill existing, Skill incoming) { + LoginAccount account = requireCurrentLoginAccount(); + existing.setCategoryId(incoming.getCategoryId()); + existing.setName(incoming.getName()); + existing.setDisplayName(incoming.getDisplayName()); + existing.setDescription(incoming.getDescription()); + existing.setMetadataJson(incoming.getMetadataJson()); + existing.setSkillContent(incoming.getSkillContent()); + existing.setEnabled(incoming.getEnabled() == null || incoming.getEnabled()); + existing.setVisibilityScope(incoming.getVisibilityScope()); + existing.setSourceType(incoming.getSourceType()); + existing.setPackageHash(incoming.getPackageHash()); + existing.setReferences(incoming.getReferences()); + existing.setScripts(incoming.getScripts()); + existing.setAssets(incoming.getAssets()); + syncCounts(existing); + existing.setModified(new Date()); + existing.setModifiedBy(account.getId()); + } + + private void syncCounts(Skill skill) { + skill.setReferenceCount(skill.getReferences() == null ? 0 : skill.getReferences().size()); + skill.setScriptCount(skill.getScripts() == null ? 0 : skill.getScripts().size()); + skill.setAssetCount(skill.getAssets() == null ? 0 : skill.getAssets().size()); + } + + private void fillResources(Skill skill) { + skill.setReferences(skillReferenceService.list(QueryWrapper.create().eq(SkillReference::getSkillId, skill.getId()).orderBy("path asc"))); + skill.setScripts(skillScriptService.list(QueryWrapper.create().eq(SkillScript::getSkillId, skill.getId()).orderBy("path asc"))); + skill.setAssets(skillAssetService.list(QueryWrapper.create().eq(SkillAsset::getSkillId, skill.getId()).orderBy("path asc"))); + } + + private void replaceResources(Skill skill) { + removeResources(skill.getId()); + BigInteger tenantId = skill.getTenantId(); + BigInteger skillId = skill.getId(); + if (skill.getReferences() != null) { + for (SkillReference reference : skill.getReferences()) { + reference.setTenantId(tenantId); + reference.setSkillId(skillId); + skillReferenceService.save(reference); + } + } + if (skill.getScripts() != null) { + for (SkillScript script : skill.getScripts()) { + script.setTenantId(tenantId); + script.setSkillId(skillId); + skillScriptService.save(script); + } + } + if (skill.getAssets() != null) { + for (SkillAsset asset : skill.getAssets()) { + asset.setTenantId(tenantId); + asset.setSkillId(skillId); + skillAssetService.save(asset); + } + } + } + + private void removeResources(BigInteger skillId) { + skillReferenceService.remove(QueryWrapper.create().eq(SkillReference::getSkillId, skillId)); + skillScriptService.remove(QueryWrapper.create().eq(SkillScript::getSkillId, skillId)); + skillAssetService.remove(QueryWrapper.create().eq(SkillAsset::getSkillId, skillId)); + } + + private void normalizeFromSkillContent(Skill skill) { + try { + com.easyagents.skill.model.Skill parsed = SkillFactory.create( + skill.getId() == null ? "draft" : String.valueOf(skill.getId()), + skill.getSkillContent(), + SkillModelConverter.toAgentReferences(skill.getReferences()), + SkillModelConverter.toAgentScripts(skill.getScripts()), + SkillModelConverter.toAgentAssets(skill.getAssets()) + ); + skill.setName(parsed.getName()); + if (skill.getDisplayName() == null || skill.getDisplayName().isBlank()) { + skill.setDisplayName(parsed.getName()); + } + skill.setDescription(parsed.getDescription()); + skill.setMetadataJson(parsed.getMetadata().getValues()); + } catch (SkillException e) { + throw new BusinessException("SKILL.md frontmatter 不合法:" + e.getMessage()); + } + } + + private void validateSkillPackage(Skill skill) { + try { + skillValidator.validate(SkillModelConverter.toAgentSkill(skill)); + } catch (SkillException e) { + throw new BusinessException("Skill 包校验失败:" + e.getMessage()); + } + } + + private LoginAccount requireCurrentLoginAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null) { + throw new BusinessException("未登录或登录态无效"); + } + return account; + } + + private BigInteger toBigInteger(Object value) { + if (value == null) { + return null; + } + if (value instanceof BigInteger bigInteger) { + return bigInteger; + } + if (value instanceof Number number) { + return BigInteger.valueOf(number.longValue()); + } + return new BigInteger(String.valueOf(value)); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java new file mode 100644 index 00000000..49aa3e9a --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/store/DBSkillContentStore.java @@ -0,0 +1,138 @@ +package tech.easyflow.skill.store; + +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.util.SkillHashes; +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.SkillAssetContent; +import tech.easyflow.skill.service.SkillAssetContentService; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; + +/** + * 基于数据库索引和现有文件存储的 Skill 内容存储。 + */ +@Component +public class DBSkillContentStore implements SkillContentStore { + + private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + + private final SkillAssetContentService skillAssetContentService; + private final FileStorageService fileStorageService; + + /** + * 创建数据库 Skill 内容存储。 + * + * @param skillAssetContentService asset 内容索引服务 + * @param fileStorageService 文件存储服务 + */ + public DBSkillContentStore(SkillAssetContentService skillAssetContentService, + @Qualifier("default") FileStorageService fileStorageService) { + this.skillAssetContentService = skillAssetContentService; + this.fileStorageService = fileStorageService; + } + + /** + * {@inheritDoc} + */ + @Override + public String put(byte[] bytes) { + String contentRef = SkillHashes.sha256Ref(bytes); + SkillAssetContent existing = skillAssetContentService.getById(contentRef); + if (existing != null) { + existing.setRefCount((existing.getRefCount() == null ? 0 : existing.getRefCount()) + 1); + existing.setModified(new Date()); + skillAssetContentService.updateById(existing); + return contentRef; + } + String hex = contentRef.substring("sha256:".length()); + String prePath = "skill-assets/" + hex.substring(0, Math.min(2, hex.length())); + MultipartFile file = new ByteArrayMultipartFile(bytes, contentRef + ".bin", DEFAULT_MEDIA_TYPE); + String filePath = fileStorageService.save(file, prePath); + SkillAssetContent content = new SkillAssetContent(); + content.setContentRef(contentRef); + content.setContentHash(hex); + content.setFilePath(filePath); + content.setMediaType(DEFAULT_MEDIA_TYPE); + content.setSize((long) (bytes == null ? 0 : bytes.length)); + content.setRefCount(1); + content.setCreated(new Date()); + content.setModified(new Date()); + skillAssetContentService.save(content); + return contentRef; + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream open(String contentRef) { + SkillAssetContent content = requireContent(contentRef); + try { + return fileStorageService.readStream(content.getFilePath()); + } catch (IOException e) { + throw new BusinessException("读取 Skill asset 失败"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public byte[] readAllBytes(String contentRef) { + try (InputStream inputStream = open(contentRef)) { + return inputStream.readAllBytes(); + } catch (IOException e) { + throw new BusinessException("读取 Skill asset 失败"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean exists(String contentRef) { + if (contentRef == null || contentRef.isBlank()) { + return false; + } + return skillAssetContentService.count(QueryWrapper.create().eq(SkillAssetContent::getContentRef, contentRef)) > 0; + } + + private SkillAssetContent requireContent(String contentRef) { + SkillAssetContent content = skillAssetContentService.getById(contentRef); + if (content == null) { + throw new BusinessException("Skill asset 内容不存在"); + } + return content; + } + + private static class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] bytes; + private final String filename; + private final String contentType; + + private ByteArrayMultipartFile(byte[] bytes, String filename, String contentType) { + this.bytes = bytes == null ? new byte[0] : bytes; + this.filename = filename; + this.contentType = contentType; + } + + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return filename; } + @Override public String getContentType() { return contentType; } + @Override public boolean isEmpty() { return bytes.length == 0; } + @Override public long getSize() { return bytes.length; } + @Override public byte[] getBytes() { return bytes; } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + @Override public void transferTo(File dest) throws IOException { org.springframework.util.FileCopyUtils.copy(bytes, dest); } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java new file mode 100644 index 00000000..d4e20189 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/support/SkillModelConverter.java @@ -0,0 +1,201 @@ +package tech.easyflow.skill.support; + +import com.easyagents.skill.factory.SkillFactory; +import com.easyagents.skill.model.SkillMetadata; +import com.easyagents.skill.model.SkillScriptLanguage; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillAsset; +import tech.easyflow.skill.entity.SkillReference; +import tech.easyflow.skill.entity.SkillScript; + +import java.math.BigInteger; +import java.util.List; + +/** + * EasyFlow Skill 实体与 easy-agents-skill 模型转换器。 + */ +public final class SkillModelConverter { + + private SkillModelConverter() { + } + + /** + * 转换为 easy-agents-skill 聚合。 + * + * @param skill Skill 主实体 + * @return easy-agents-skill 聚合 + */ + public static com.easyagents.skill.model.Skill toAgentSkill(Skill skill) { + return SkillFactory.create( + String.valueOf(skill.getId()), + skill.getSkillContent(), + toAgentReferences(skill.getReferences()), + toAgentScripts(skill.getScripts()), + toAgentAssets(skill.getAssets()) + ); + } + + /** + * 转换导入模型到主实体。 + * + * @param imported 导入 Skill + * @return Skill 主实体 + */ + public static Skill fromAgentSkill(com.easyagents.skill.model.Skill imported) { + Skill skill = new Skill(); + skill.setName(imported.getName()); + skill.setDisplayName(imported.getName()); + skill.setDescription(imported.getDescription()); + skill.setMetadataJson(imported.getMetadata().getValues()); + skill.setSkillContent(imported.getSkillContent()); + skill.setReferences(imported.getReferences().stream() + .map(item -> fromAgentReference(null, null, item)) + .toList()); + skill.setScripts(imported.getScripts().stream() + .map(item -> fromAgentScript(null, null, item)) + .toList()); + skill.setAssets(imported.getAssets().stream() + .map(item -> fromAgentAsset(null, null, item)) + .toList()); + skill.setReferenceCount(imported.getReferences().size()); + skill.setScriptCount(imported.getScripts().size()); + skill.setAssetCount(imported.getAssets().size()); + return skill; + } + + /** + * 转换 reference 列表。 + * + * @param references reference 实体 + * @return easy-agents-skill reference + */ + public static List toAgentReferences(List references) { + return references == null ? List.of() : references.stream().map(item -> { + com.easyagents.skill.model.SkillReference target = new com.easyagents.skill.model.SkillReference(); + target.setPath(item.getPath()); + target.setName(item.getName()); + target.setContent(item.getContent()); + target.setContentHash(item.getContentHash()); + target.setSize(item.getSize() == null ? 0L : item.getSize()); + target.setMetadata(new SkillMetadata(item.getMetadataJson())); + return target; + }).toList(); + } + + /** + * 转换 script 列表。 + * + * @param scripts script 实体 + * @return easy-agents-skill script + */ + public static List toAgentScripts(List scripts) { + return scripts == null ? List.of() : scripts.stream().map(item -> { + com.easyagents.skill.model.SkillScript target = new com.easyagents.skill.model.SkillScript(); + target.setPath(item.getPath()); + target.setLanguage(parseLanguage(item.getLanguage())); + target.setContent(item.getContent()); + target.setContentHash(item.getContentHash()); + target.setSize(item.getSize() == null ? 0L : item.getSize()); + target.setMetadata(new SkillMetadata(item.getMetadataJson())); + return target; + }).toList(); + } + + /** + * 转换 asset 列表。 + * + * @param assets asset 实体 + * @return easy-agents-skill asset + */ + public static List toAgentAssets(List assets) { + return assets == null ? List.of() : assets.stream().map(item -> { + com.easyagents.skill.model.SkillAsset target = new com.easyagents.skill.model.SkillAsset(); + target.setPath(item.getPath()); + target.setName(item.getName()); + target.setMediaType(item.getMediaType()); + target.setContentRef(item.getContentRef()); + target.setContentHash(item.getContentHash()); + target.setSize(item.getSize() == null ? 0L : item.getSize()); + target.setMetadata(new SkillMetadata(item.getMetadataJson())); + return target; + }).toList(); + } + + /** + * 转换导入 reference。 + * + * @param skillId Skill ID + * @param tenantId 租户 ID + * @param source 导入模型 + * @return reference 实体 + */ + public static SkillReference fromAgentReference(BigInteger skillId, BigInteger tenantId, + com.easyagents.skill.model.SkillReference source) { + SkillReference target = new SkillReference(); + target.setTenantId(tenantId); + target.setSkillId(skillId); + target.setPath(source.getPath()); + target.setName(source.getName()); + target.setContent(source.getContent()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadataJson(source.getMetadata().getValues()); + return target; + } + + /** + * 转换导入 script。 + * + * @param skillId Skill ID + * @param tenantId 租户 ID + * @param source 导入模型 + * @return script 实体 + */ + public static SkillScript fromAgentScript(BigInteger skillId, BigInteger tenantId, + com.easyagents.skill.model.SkillScript source) { + SkillScript target = new SkillScript(); + target.setTenantId(tenantId); + target.setSkillId(skillId); + target.setPath(source.getPath()); + target.setLanguage(source.getLanguage().name()); + target.setContent(source.getContent()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadataJson(source.getMetadata().getValues()); + return target; + } + + /** + * 转换导入 asset。 + * + * @param skillId Skill ID + * @param tenantId 租户 ID + * @param source 导入模型 + * @return asset 实体 + */ + public static SkillAsset fromAgentAsset(BigInteger skillId, BigInteger tenantId, + com.easyagents.skill.model.SkillAsset source) { + SkillAsset target = new SkillAsset(); + target.setTenantId(tenantId); + target.setSkillId(skillId); + target.setPath(source.getPath()); + target.setName(source.getName()); + target.setMediaType(source.getMediaType()); + target.setContentRef(source.getContentRef()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadataJson(source.getMetadata().getValues()); + return target; + } + + private static SkillScriptLanguage parseLanguage(String language) { + if (language == null || language.isBlank()) { + return SkillScriptLanguage.UNKNOWN; + } + try { + return SkillScriptLanguage.valueOf(language); + } catch (IllegalArgumentException ignored) { + return SkillScriptLanguage.UNKNOWN; + } + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java index de05a658..9839e287 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/enums/CategoryResourceType.java @@ -9,6 +9,7 @@ public enum CategoryResourceType { BOT("BOT"), AGENT("AGENT"), + SKILL("SKILL"), PLUGIN("PLUGIN"), WORKFLOW("WORKFLOW"), KNOWLEDGE("KNOWLEDGE"), diff --git a/easyflow-modules/pom.xml b/easyflow-modules/pom.xml index 6e6c50fd..8d170a39 100644 --- a/easyflow-modules/pom.xml +++ b/easyflow-modules/pom.xml @@ -19,6 +19,7 @@ easyflow-module-chatlog easyflow-module-ai easyflow-module-agent + easyflow-module-skill easyflow-module-job easyflow-module-datacenter diff --git a/easyflow-starter/easyflow-starter-all/pom.xml b/easyflow-starter/easyflow-starter-all/pom.xml index 0699a1b5..13e31fa7 100644 --- a/easyflow-starter/easyflow-starter-all/pom.xml +++ b/easyflow-starter/easyflow-starter-all/pom.xml @@ -56,6 +56,10 @@ tech.easyflow easyflow-module-agent + + tech.easyflow + easyflow-module-skill + tech.easyflow easyflow-module-auth diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V24__mysql_skill_schema.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V24__mysql_skill_schema.sql new file mode 100644 index 00000000..57dde1e6 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V24__mysql_skill_schema.sql @@ -0,0 +1,109 @@ +CREATE TABLE IF NOT EXISTS `tb_skill_category` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NULL COMMENT '租户ID', + `parent_id` BIGINT NULL COMMENT '父分类ID', + `category_name` VARCHAR(128) NOT NULL COMMENT '分类名称', + `level_no` INT DEFAULT 1 COMMENT '层级', + `ancestors` VARCHAR(512) NULL COMMENT '祖级路径', + `sort_no` INT DEFAULT 0 COMMENT '排序', + `status` INT DEFAULT 1 COMMENT '状态', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT NULL COMMENT '创建人', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT NULL COMMENT '修改人', + PRIMARY KEY (`id`), + KEY `idx_skill_category_tenant_parent` (`tenant_id`, `parent_id`, `status`, `sort_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 分类'; + +CREATE TABLE IF NOT EXISTS `tb_skill` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NULL COMMENT '租户ID', + `dept_id` BIGINT NULL COMMENT '部门ID', + `category_id` BIGINT NULL COMMENT '分类ID', + `name` VARCHAR(128) NOT NULL COMMENT 'Skill 名称', + `display_name` VARCHAR(128) NULL COMMENT '展示名称', + `description` VARCHAR(1024) NULL COMMENT '描述', + `metadata_json` JSON NULL COMMENT '元数据', + `skill_content` MEDIUMTEXT NULL COMMENT 'SKILL.md 内容', + `enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用', + `visibility_scope` VARCHAR(32) NULL COMMENT '可见范围', + `source_type` VARCHAR(32) NULL COMMENT '来源类型', + `package_hash` VARCHAR(128) NULL COMMENT '包 hash', + `reference_count` INT DEFAULT 0 COMMENT '引用文档数量', + `script_count` INT DEFAULT 0 COMMENT '脚本数量', + `asset_count` INT DEFAULT 0 COMMENT '资产数量', + `publish_status` VARCHAR(32) DEFAULT 'DRAFT' COMMENT '发布状态', + `current_approval_instance_id` BIGINT NULL COMMENT '当前审批实例ID', + `published_snapshot_json` JSON NULL COMMENT '已发布快照', + `published_at` DATETIME NULL COMMENT '发布时间', + `published_by` BIGINT NULL COMMENT '发布人', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT NULL COMMENT '创建人', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT NULL COMMENT '修改人', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_skill_tenant_name` (`tenant_id`, `name`), + KEY `idx_skill_tenant_category` (`tenant_id`, `category_id`, `enabled`), + KEY `idx_skill_publish_status` (`publish_status`), + KEY `idx_skill_created_by` (`created_by`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill'; + +CREATE TABLE IF NOT EXISTS `tb_skill_reference` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NULL COMMENT '租户ID', + `skill_id` BIGINT NOT NULL COMMENT 'Skill ID', + `path` VARCHAR(512) NOT NULL COMMENT '逻辑路径', + `name` VARCHAR(255) NULL COMMENT '文件名', + `content` MEDIUMTEXT NULL COMMENT '内容', + `content_hash` VARCHAR(128) NULL COMMENT '内容 hash', + `size` BIGINT DEFAULT 0 COMMENT '大小', + `metadata_json` JSON NULL COMMENT '元数据', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_skill_reference_path` (`skill_id`, `path`), + KEY `idx_skill_reference_skill` (`skill_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill reference'; + +CREATE TABLE IF NOT EXISTS `tb_skill_script` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NULL COMMENT '租户ID', + `skill_id` BIGINT NOT NULL COMMENT 'Skill ID', + `path` VARCHAR(512) NOT NULL COMMENT '逻辑路径', + `language` VARCHAR(32) NULL COMMENT '脚本语言', + `content` MEDIUMTEXT NULL COMMENT '内容', + `content_hash` VARCHAR(128) NULL COMMENT '内容 hash', + `size` BIGINT DEFAULT 0 COMMENT '大小', + `metadata_json` JSON NULL COMMENT '元数据', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_skill_script_path` (`skill_id`, `path`), + KEY `idx_skill_script_skill` (`skill_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill script'; + +CREATE TABLE IF NOT EXISTS `tb_skill_asset` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `tenant_id` BIGINT NULL COMMENT '租户ID', + `skill_id` BIGINT NOT NULL COMMENT 'Skill ID', + `path` VARCHAR(512) NOT NULL COMMENT '逻辑路径', + `name` VARCHAR(255) NULL COMMENT '文件名', + `media_type` VARCHAR(128) NULL COMMENT '媒体类型', + `content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用', + `content_hash` VARCHAR(128) NULL COMMENT '内容 hash', + `size` BIGINT DEFAULT 0 COMMENT '大小', + `metadata_json` JSON NULL COMMENT '元数据', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_skill_asset_path` (`skill_id`, `path`), + KEY `idx_skill_asset_skill` (`skill_id`), + KEY `idx_skill_asset_content_ref` (`content_ref`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill asset'; + +CREATE TABLE IF NOT EXISTS `tb_skill_asset_content` ( + `content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用', + `content_hash` VARCHAR(128) NOT NULL COMMENT '内容 hash', + `file_path` VARCHAR(1024) NOT NULL COMMENT '文件路径', + `media_type` VARCHAR(128) NULL COMMENT '媒体类型', + `size` BIGINT DEFAULT 0 COMMENT '大小', + `ref_count` INT DEFAULT 0 COMMENT '引用数', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`content_ref`), + KEY `idx_skill_asset_content_hash` (`content_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill asset 内容索引'; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V25__mysql_skill_menu.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V25__mysql_skill_menu.sql new file mode 100644 index 00000000..831622fd --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V25__mysql_skill_menu.sql @@ -0,0 +1,172 @@ +SET NAMES utf8mb4; + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000001, 0, 0, '技能管理', '/ai/skill', '/ai/skill/SkillList', 'lucide:badge-check', + 1, '', 3, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, '管理端 Skill 管理菜单' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000001 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000011, 367400000000000001, 1, '查询', '', '', '', + 0, '/api/v1/skill/query', 1, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-查询' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000011 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000012, 367400000000000001, 1, '详情', '', '', '', + 0, '/api/v1/skill/getDetail', 2, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-详情' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000012 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000013, 367400000000000001, 1, '保存', '', '', '', + 0, '/api/v1/skill/save', 3, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-保存' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000013 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000014, 367400000000000001, 1, '更新', '', '', '', + 0, '/api/v1/skill/update', 4, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-更新' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000014 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000015, 367400000000000001, 1, '删除', '', '', '', + 0, '/api/v1/skill/remove', 5, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-删除' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000015 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000016, 367400000000000001, 1, '发布', '', '', '', + 0, '/api/v1/skill/submitPublishApproval', 6, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-发布审批' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000016 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000017, 367400000000000001, 1, '下线', '', '', '', + 0, '/api/v1/skill/submitOfflineApproval', 7, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-下线审批' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000017 +); + +INSERT INTO `tb_sys_menu` ( + `id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, + `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark` +) +SELECT + 367400000000000018, 367400000000000001, 1, '删除审批', '', '', '', + 0, '/api/v1/skill/submitDeleteApproval', 8, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-删除审批' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000018 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000101, 1, 367400000000000001 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000001 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000111, 1, 367400000000000011 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000011 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000112, 1, 367400000000000012 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000012 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000113, 1, 367400000000000013 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000013 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000114, 1, 367400000000000014 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000014 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000115, 1, 367400000000000015 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000015 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000116, 1, 367400000000000016 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000016 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000117, 1, 367400000000000017 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000017 +); + +INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) +SELECT 367400000000000118, 1, 367400000000000018 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000018 +); diff --git a/easyflow-ui-admin/app/package.json b/easyflow-ui-admin/app/package.json index 200d647c..1fa9fd90 100644 --- a/easyflow-ui-admin/app/package.json +++ b/easyflow-ui-admin/app/package.json @@ -28,6 +28,13 @@ "@easyflow/styles": "workspace:*", "@easyflow/types": "workspace:*", "@easyflow/utils": "workspace:*", + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.2", + "@codemirror/legacy-modes": "^6.5.1", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.15", "@element-plus/icons-vue": "^2.3.2", "@tinyflow-ai/vue": "workspace:*", "@vueuse/core": "catalog:", diff --git a/easyflow-ui-admin/app/src/components/editor/CodeMirrorEditor.vue b/easyflow-ui-admin/app/src/components/editor/CodeMirrorEditor.vue new file mode 100644 index 00000000..087907ee --- /dev/null +++ b/easyflow-ui-admin/app/src/components/editor/CodeMirrorEditor.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/router/routes/modules/skill.ts b/easyflow-ui-admin/app/src/router/routes/modules/skill.ts new file mode 100644 index 00000000..777022ce --- /dev/null +++ b/easyflow-ui-admin/app/src/router/routes/modules/skill.ts @@ -0,0 +1,18 @@ +import type {RouteRecordRaw} from 'vue-router'; + +const routes: RouteRecordRaw[] = [ + { + name: 'SkillDetail', + path: '/ai/skill/detail/:id', + component: () => import('#/views/ai/skill/SkillDetail.vue'), + meta: { + title: '技能详情', + openInNewWindow: true, + hideInMenu: true, + activePath: '/ai/skill', + }, + }, +]; + +export default routes; + diff --git a/easyflow-ui-admin/app/src/types/codemirror-legacy-modes.d.ts b/easyflow-ui-admin/app/src/types/codemirror-legacy-modes.d.ts new file mode 100644 index 00000000..0941dd23 --- /dev/null +++ b/easyflow-ui-admin/app/src/types/codemirror-legacy-modes.d.ts @@ -0,0 +1,5 @@ +declare module '@codemirror/legacy-modes/mode/shell.js' { + import type {StreamParser} from '@codemirror/language'; + + export const shell: StreamParser; +} diff --git a/easyflow-ui-admin/app/src/types/markdown-it.d.ts b/easyflow-ui-admin/app/src/types/markdown-it.d.ts new file mode 100644 index 00000000..c6384a37 --- /dev/null +++ b/easyflow-ui-admin/app/src/types/markdown-it.d.ts @@ -0,0 +1,2 @@ +declare module 'markdown-it'; + diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue new file mode 100644 index 00000000..653645b3 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue new file mode 100644 index 00000000..8b4cc142 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue @@ -0,0 +1,335 @@ + + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/skill/api.ts b/easyflow-ui-admin/app/src/views/ai/skill/api.ts new file mode 100644 index 00000000..78b1893e --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/api.ts @@ -0,0 +1,106 @@ +import type { + RequestResult, + SkillFileContent, + SkillFileNode, + SkillImportPreview, + SkillInfo, +} from './types'; + +import {api} from '#/api/request'; + +export function getSkillDetail(id: number | string) { + return api.get>('/api/v1/skill/getDetail', { + params: { id }, + }); +} + +export function saveSkill(skill: SkillInfo) { + return api.post>('/api/v1/skill/save', skill); +} + +export function updateSkill(skill: SkillInfo) { + return api.post>('/api/v1/skill/update', skill); +} + +export function getSkillCategories() { + return api.get>('/api/v1/skillCategory/visibleList', { + params: { sortKey: 'sortNo', sortType: 'asc' }, + }); +} + +export function submitSkillPublishApproval(id: number | string) { + return api.post>( + '/api/v1/skill/submitPublishApproval', + { id }, + ); +} + +export function submitSkillOfflineApproval(id: number | string) { + return api.post>( + '/api/v1/skill/submitOfflineApproval', + { id }, + ); +} + +export function submitSkillDeleteApproval(id: number | string) { + return api.post>( + '/api/v1/skill/submitDeleteApproval', + { id }, + ); +} + +export function getSkillFileTree(skillId: number | string) { + return api.get>('/api/v1/skill/file/tree', { + params: { skillId }, + }); +} + +export function getSkillFileContent(skillId: number | string, path: string) { + return api.get>('/api/v1/skill/file/content', { + params: { skillId, path }, + }); +} + +export function saveSkillFile( + skillId: number | string, + path: string, + content: string, +) { + return api.post>('/api/v1/skill/file/save', { + skillId, + path, + content, + }); +} + +export function importSkillPreview(file: File) { + const formData = new FormData(); + formData.append('file', file); + return api.postFile>( + '/api/v1/skill/import/preview', + formData, + ); +} + +export function importSkillConfirm( + file: File, + categoryId?: number | string, + overwriteDraft = false, +) { + const formData = new FormData(); + formData.append('file', file); + if (categoryId) { + formData.append('categoryId', String(categoryId)); + } + formData.append('overwriteDraft', String(overwriteDraft)); + return api.postFile>( + '/api/v1/skill/import/confirm', + formData, + ); +} + +export function resolveSkillAssetUrl(skillId: number | string, path: string) { + return `/api/v1/skill/file/asset?skillId=${encodeURIComponent( + String(skillId), + )}&path=${encodeURIComponent(path)}`; +} diff --git a/easyflow-ui-admin/app/src/views/ai/skill/types.ts b/easyflow-ui-admin/app/src/views/ai/skill/types.ts new file mode 100644 index 00000000..bf242e8c --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/types.ts @@ -0,0 +1,99 @@ +export interface RequestResult { + data: T; + errorCode: number; + message?: string; +} + +export interface SkillInfo { + id?: number | string; + name?: string; + displayName?: string; + description?: string; + categoryId?: number | string; + metadataJson?: Record; + skillContent?: string; + enabled?: boolean; + visibilityScope?: string; + sourceType?: string; + referenceCount?: number; + scriptCount?: number; + assetCount?: number; + publishStatus?: string; + displayPublishStatus?: string; + approvalPending?: boolean; + currentApprovalActionType?: string; + currentApprovalInstanceId?: number | string; + created?: string; + createdByName?: string; + references?: SkillReference[]; + scripts?: SkillScript[]; + assets?: SkillAsset[]; + [key: string]: any; +} + +export interface SkillReference { + id?: number | string; + path: string; + name?: string; + content?: string; + contentHash?: string; + size?: number; + metadataJson?: Record; +} + +export interface SkillScript { + id?: number | string; + path: string; + language?: string; + content?: string; + contentHash?: string; + size?: number; + metadataJson?: Record; +} + +export interface SkillAsset { + id?: number | string; + path: string; + name?: string; + mediaType?: string; + contentRef?: string; + contentHash?: string; + size?: number; + metadataJson?: Record; +} + +export interface SkillFileNode { + key: string; + path: string; + name: string; + type: 'ASSET' | 'DIRECTORY' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string; + language?: string; + mediaType?: string; + size?: number; + children?: SkillFileNode[]; +} + +export interface SkillFileContent { + path: string; + type: 'ASSET' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string; + content?: string; + language?: string; + mediaType?: string; + size?: number; + downloadUrl?: string; +} + +export interface SkillImportPreviewItem { + packageId: string; + name: string; + description: string; + referenceCount: number; + scriptCount: number; + assetCount: number; + conflict: boolean; +} + +export interface SkillImportPreview { + skills: SkillImportPreviewItem[]; +} + diff --git a/easyflow-ui-admin/pnpm-lock.yaml b/easyflow-ui-admin/pnpm-lock.yaml index 83970dfa..aa72d343 100644 --- a/easyflow-ui-admin/pnpm-lock.yaml +++ b/easyflow-ui-admin/pnpm-lock.yaml @@ -531,10 +531,10 @@ importers: version: 24.10.1 '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) + version: 6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: 'catalog:' - version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) + version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3)) '@vue/test-utils': specifier: 'catalog:' version: 2.4.6 @@ -576,10 +576,10 @@ importers: version: 3.6.1(sass@1.94.0)(typescript@5.9.3)(vue-tsc@2.2.10(typescript@5.9.3))(vue@3.5.24(typescript@5.9.3)) vite: specifier: 'catalog:' - version: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) vitest: specifier: 'catalog:' - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) vue: specifier: ^3.5.17 version: 3.5.24(typescript@5.9.3) @@ -589,6 +589,27 @@ importers: app: dependencies: + '@codemirror/commands': + specifier: ^6.10.2 + version: 6.10.2 + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 + '@codemirror/lang-python': + specifier: ^6.2.1 + version: 6.2.1 + '@codemirror/language': + specifier: ^6.12.2 + version: 6.12.2 + '@codemirror/legacy-modes': + specifier: ^6.5.1 + version: 6.5.3 + '@codemirror/state': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/view': + specifier: ^6.39.15 + version: 6.39.15 '@easyflow-core/shadcn-ui': specifier: workspace:* version: link:../packages/@core/ui-kit/shadcn-ui @@ -2484,6 +2505,9 @@ packages: '@codemirror/language@6.12.2': resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + '@codemirror/lint@6.9.4': resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} @@ -12143,6 +12167,10 @@ snapshots: '@lezer/lr': 1.4.8 style-mod: 4.1.3 + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.2 + '@codemirror/lint@6.9.4': dependencies: '@codemirror/state': 6.5.4 @@ -14415,18 +14443,6 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.50 - '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) - vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - vue: 3.5.24(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-vue-jsx@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 @@ -14439,12 +14455,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-beta.29 - vite: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - vue: 3.5.24(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))(vue@3.5.24(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 @@ -14465,14 +14475,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -14480,7 +14482,6 @@ snapshots: magic-string: 0.30.21 optionalDependencies: vite: 7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - optional: true '@vitest/pretty-format@3.2.4': dependencies: @@ -21323,27 +21324,6 @@ snapshots: dependencies: vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - vite-node@3.2.4(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite-node@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -21364,7 +21344,6 @@ snapshots: - terser - tsx - yaml - optional: true vite-plugin-compression@0.5.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)): dependencies: @@ -21494,23 +21473,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): - dependencies: - esbuild: 0.25.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.2 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.1 - fsevents: 2.3.3 - jiti: 1.21.7 - less: 4.4.2 - sass: 1.94.0 - terser: 5.44.1 - yaml: 2.8.1 - vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: esbuild: 0.25.3 @@ -21545,23 +21507,6 @@ snapshots: terser: 5.44.1 yaml: 2.8.1 - vite@7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): - dependencies: - esbuild: 0.25.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.2 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.1 - fsevents: 2.3.3 - jiti: 1.21.7 - less: 4.4.2 - sass: 1.94.0 - terser: 5.44.1 - yaml: 2.8.1 - vite@7.3.1(@types/node@24.10.1)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: esbuild: 0.25.3 @@ -21578,55 +21523,11 @@ snapshots: sass: 1.94.0 terser: 5.44.1 yaml: 2.8.1 - optional: true vitefu@1.1.2(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)): optionalDependencies: vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.2.2 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.10.1)(jiti@1.21.7)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 24.10.1 - happy-dom: 17.6.3 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.94.0)(terser@5.44.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 @@ -21669,7 +21570,6 @@ snapshots: - terser - tsx - yaml - optional: true vscode-languageserver-textdocument@1.0.12: {} diff --git a/pom.xml b/pom.xml index 0ca162dd..12121f66 100644 --- a/pom.xml +++ b/pom.xml @@ -206,6 +206,11 @@ easy-agents-agent-runtime ${easy-agents.version} + + com.easyagents + easy-agents-skill + ${easy-agents.version} + com.squareup.okhttp3 @@ -451,6 +456,11 @@ easyflow-module-agent ${revision} + + tech.easyflow + easyflow-module-skill + ${revision} + tech.easyflow easyflow-module-auth