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 001/143] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E8=AF=95=E9=AA=8C?= =?UTF-8?q?=E6=80=A7=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=AD=89=E5=BE=85=E4=BC=98?= =?UTF-8?q?=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 From e9ddeae33fe2aea712162201a9fc9d8caddd6f93 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 17:22:48 +0800 Subject: [PATCH 002/143] =?UTF-8?q?chore:=20=E8=B0=83=E6=95=B4=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E5=88=86=E6=94=AF=E4=B8=BA=E8=AF=95=E9=AA=8C=E6=80=A7?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 12121f66..fc8af520 100644 --- a/pom.xml +++ b/pom.xml @@ -15,14 +15,14 @@ + 1.1.0-RC + 1.1.0-RC 17 17 17 UTF-8 1.3.0 - 1.0.0 1.11.6 - 1.0.0 4.9.3 1.8.22 3.5.9 From 705e0faab64f3c45e5461d38462c4d10e9ba2011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Tue, 14 Jul 2026 21:21:57 +0800 Subject: [PATCH 003/143] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E5=AF=B9=E8=AF=9D=E4=BD=93=E9=AA=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持欢迎语、猜你想问和输入提示的编辑、草稿预览与发布态展示 - 补充配置校验、发布快照持久化和发布后回显修复 --- .../config/AgentInteractionConfigSupport.java | 115 ++++++ .../tech/easyflow/agent/entity/Agent.java | 15 + .../publish/AgentApprovalSubjectHandler.java | 38 +- .../agent/service/impl/AgentServiceImpl.java | 5 + .../AgentInteractionConfigSupportTest.java | 93 +++++ .../AgentApprovalSubjectHandlerTest.java | 120 +++++++ .../V30__mysql_agent_interaction_config.sql | 2 + .../src/components/ai-chat/AiChatPanel.vue | 8 +- .../components/AgentChatWelcomeState.vue | 30 -- .../app/src/views/ai/agent-chat/index.vue | 176 ++++++---- .../agents/components/AgentInspectorPanel.vue | 77 +++- .../components/AgentInteractionForm.vue | 328 ++++++++++++++++++ .../ai/agents/components/AgentTryoutPanel.vue | 91 +++-- .../agents/components/AgentWelcomeState.vue | 222 ++++++++++++ .../composables/useAgentDesignerState.ts | 19 +- .../ai/agents/interaction-config.test.ts | 45 +++ .../src/views/ai/agents/interaction-config.ts | 120 +++++++ .../app/src/views/ai/agents/types.ts | 7 + 18 files changed, 1351 insertions(+), 160 deletions(-) create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql delete mode 100644 easyflow-ui-admin/app/src/views/ai/agent-chat/components/AgentChatWelcomeState.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentInteractionForm.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentWelcomeState.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java new file mode 100644 index 00000000..a938e231 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentInteractionConfigSupport.java @@ -0,0 +1,115 @@ +package tech.easyflow.agent.config; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Agent 对话体验配置的规范化与校验工具。 + */ +public final class AgentInteractionConfigSupport { + + /** 欢迎语最大字符数。 */ + public static final int MAX_WELCOME_MESSAGE_LENGTH = 300; + /** 猜你想问最大数量。 */ + public static final int MAX_SUGGESTED_QUESTION_COUNT = 6; + /** 单条猜你想问最大字符数。 */ + public static final int MAX_SUGGESTED_QUESTION_LENGTH = 80; + /** 输入提示最大字符数。 */ + public static final int MAX_INPUT_PLACEHOLDER_LENGTH = 40; + + private AgentInteractionConfigSupport() { + } + + /** + * 规范化并校验对话体验配置。 + * + * @param source 原始配置 + * @return 仅包含受支持字段的稳定配置 + * @throws BusinessException 配置字段类型或内容不合法时抛出 + */ + public static Map normalize(Map source) { + Map config = source == null ? Map.of() : source; + String welcomeMessage = optionalString(config.get("welcomeMessage"), "欢迎语"); + if (welcomeMessage.length() > MAX_WELCOME_MESSAGE_LENGTH) { + throw new BusinessException("欢迎语不能超过 300 个字符"); + } + + String inputPlaceholder = optionalString(config.get("inputPlaceholder"), "输入提示"); + if (inputPlaceholder.contains("\n") || inputPlaceholder.contains("\r")) { + throw new BusinessException("输入提示仅支持单行文本"); + } + if (inputPlaceholder.length() > MAX_INPUT_PLACEHOLDER_LENGTH) { + throw new BusinessException("输入提示不能超过 40 个字符"); + } + + List suggestedQuestions = normalizeSuggestedQuestions(config.get("suggestedQuestions")); + Map normalized = new LinkedHashMap<>(); + normalized.put("welcomeMessage", welcomeMessage); + normalized.put("suggestedQuestions", suggestedQuestions); + normalized.put("inputPlaceholder", inputPlaceholder); + return normalized; + } + + /** + * 规范化猜你想问列表,过滤空项并保留原有顺序。 + * + * @param value 原始列表值 + * @return 规范化后的问题列表 + * @throws BusinessException 列表类型、数量、长度或重复性不合法时抛出 + */ + private static List normalizeSuggestedQuestions(Object value) { + if (value == null) { + return List.of(); + } + if (!(value instanceof Collection values)) { + throw new BusinessException("猜你想问格式不正确"); + } + List normalized = new ArrayList<>(); + Set uniqueQuestions = new LinkedHashSet<>(); + for (Object item : values) { + if (!(item instanceof String question)) { + throw new BusinessException("猜你想问仅支持文本内容"); + } + String trimmedQuestion = question.trim(); + if (trimmedQuestion.isEmpty()) { + continue; + } + if (trimmedQuestion.length() > MAX_SUGGESTED_QUESTION_LENGTH) { + throw new BusinessException("单条猜你想问不能超过 80 个字符"); + } + if (!uniqueQuestions.add(trimmedQuestion)) { + throw new BusinessException("猜你想问不能重复"); + } + normalized.add(trimmedQuestion); + } + if (normalized.size() > MAX_SUGGESTED_QUESTION_COUNT) { + throw new BusinessException("猜你想问最多配置 6 条"); + } + return normalized; + } + + /** + * 将可选字段转换为去除首尾空白的文本。 + * + * @param value 原始字段值 + * @param fieldName 字段名称 + * @return 规范化后的文本 + * @throws BusinessException 字段不是文本时抛出 + */ + private static String optionalString(Object value, String fieldName) { + if (value == null) { + return ""; + } + if (!(value instanceof String text)) { + throw new BusinessException(fieldName + "格式不正确"); + } + return text.trim(); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java index e4db2afe..a342e383 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java @@ -43,6 +43,9 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl private Map memoryConfigJson = new LinkedHashMap<>(); @Column(typeHandler = FastjsonTypeHandler.class) private Map executionConfigJson = new LinkedHashMap<>(); + /** 对话欢迎语、猜你想问和输入提示配置。 */ + @Column(typeHandler = FastjsonTypeHandler.class) + private Map interactionConfigJson = new LinkedHashMap<>(); private Integer status; private String visibilityScope; private String publishStatus; @@ -95,6 +98,18 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl public void setMemoryConfigJson(Map memoryConfigJson) { this.memoryConfigJson = memoryConfigJson == null ? new LinkedHashMap<>() : memoryConfigJson; } public Map getExecutionConfigJson() { return executionConfigJson; } public void setExecutionConfigJson(Map executionConfigJson) { this.executionConfigJson = executionConfigJson == null ? new LinkedHashMap<>() : executionConfigJson; } + /** + * 获取对话体验配置。 + * + * @return 对话体验配置 + */ + public Map getInteractionConfigJson() { return interactionConfigJson; } + /** + * 设置对话体验配置。 + * + * @param interactionConfigJson 对话体验配置 + */ + public void setInteractionConfigJson(Map interactionConfigJson) { this.interactionConfigJson = interactionConfigJson == null ? new LinkedHashMap<>() : interactionConfigJson; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status = status; } public String getVisibilityScope() { return visibilityScope; } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java index eb564326..62c6ad1c 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java @@ -2,6 +2,7 @@ package tech.easyflow.agent.publish; import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.update.UpdateChain; import org.springframework.stereotype.Component; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; @@ -122,32 +123,33 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand @Override protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) { - Agent agent = new Agent(); - agent.setId(resourceId); - agent.setPublishStatus(publishStatus.getCode()); - agent.setCurrentApprovalInstanceId(currentApprovalInstanceId); - agentService.updateById(agent); + // 生命周期操作仅更新状态字段,避免实体默认空配置覆盖 Agent 草稿配置。 + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, publishStatus.getCode()); + updateChain.set(Agent::getCurrentApprovalInstanceId, currentApprovalInstanceId); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); } @Override protected void publishResource(BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId) { - Agent agent = new Agent(); - agent.setId(resourceId); - agent.setPublishStatus(PublishStatus.PUBLISHED.getCode()); - agent.setPublishedSnapshotJson(resourceSnapshot); - agent.setPublishedAt(new Date()); - agent.setPublishedBy(operatorId); - agent.setCurrentApprovalInstanceId(null); - agentService.updateById(agent); + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, PublishStatus.PUBLISHED.getCode()); + updateChain.set(Agent::getPublishedSnapshotJson, resourceSnapshot); + updateChain.set(Agent::getPublishedAt, new Date()); + updateChain.set(Agent::getPublishedBy, operatorId); + updateChain.set(Agent::getCurrentApprovalInstanceId, null); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); } @Override protected void markResourceOffline(BigInteger resourceId) { - Agent agent = new Agent(); - agent.setId(resourceId); - agent.setPublishStatus(PublishStatus.OFFLINE.getCode()); - agent.setCurrentApprovalInstanceId(null); - agentService.updateById(agent); + UpdateChain updateChain = agentService.updateChain(); + updateChain.set(Agent::getPublishStatus, PublishStatus.OFFLINE.getCode()); + updateChain.set(Agent::getCurrentApprovalInstanceId, null); + updateChain.eq(Agent::getId, resourceId); + updateChain.update(); } @Override diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java index c6faf467..cb06502f 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.agent.config.AgentInteractionConfigSupport; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; @@ -133,6 +134,7 @@ public class AgentServiceImpl extends ServiceImpl implements snapshot.put("promptConfigJson", detail.getPromptConfigJson()); snapshot.put("memoryConfigJson", detail.getMemoryConfigJson()); snapshot.put("executionConfigJson", detail.getExecutionConfigJson()); + snapshot.put("interactionConfigJson", detail.getInteractionConfigJson()); snapshot.put("visibilityScope", detail.getVisibilityScope()); snapshot.put("toolBindings", snapshotToolBindings(detail.getToolBindings())); snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail.getKnowledgeBindings())); @@ -162,6 +164,7 @@ public class AgentServiceImpl extends ServiceImpl implements agent.setModelId(toBigInteger(snapshot.get("modelId"))); agent.setCategoryId(toBigInteger(snapshot.get("categoryId"))); agent.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); agent.setPublishedSnapshotJson(snapshot); agent.setToolBindings(objectMapper.convertValue(snapshot.get("toolBindings"), TOOL_BINDING_LIST_TYPE)); agent.setKnowledgeBindings(objectMapper.convertValue(snapshot.get("knowledgeBindings"), KNOWLEDGE_BINDING_LIST_TYPE)); @@ -191,6 +194,7 @@ public class AgentServiceImpl extends ServiceImpl implements throw new BusinessException("Agent 模型不存在"); } agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name()); + agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); } private void applyDraftDefaults(Agent agent) { @@ -231,6 +235,7 @@ public class AgentServiceImpl extends ServiceImpl implements existing.setPromptConfigJson(incoming.getPromptConfigJson()); existing.setMemoryConfigJson(incoming.getMemoryConfigJson()); existing.setExecutionConfigJson(incoming.getExecutionConfigJson()); + existing.setInteractionConfigJson(incoming.getInteractionConfigJson()); existing.setStatus(incoming.getStatus() == null ? 1 : incoming.getStatus()); existing.setVisibilityScope(incoming.getVisibilityScope()); existing.setModified(new Date()); diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java new file mode 100644 index 00000000..bc0c3083 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentInteractionConfigSupportTest.java @@ -0,0 +1,93 @@ +package tech.easyflow.agent.config; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * {@link AgentInteractionConfigSupport} 单元测试。 + */ +public class AgentInteractionConfigSupportTest { + + /** + * 验证空配置可以转换为稳定结构。 + */ + @Test + public void shouldNormalizeEmptyConfig() { + Map normalized = AgentInteractionConfigSupport.normalize(null); + + Assert.assertEquals("", normalized.get("welcomeMessage")); + Assert.assertEquals(List.of(), normalized.get("suggestedQuestions")); + Assert.assertEquals("", normalized.get("inputPlaceholder")); + } + + /** + * 验证空问题会被过滤,合法问题会去除首尾空白并保持顺序。 + */ + @Test + public void shouldNormalizeSuggestedQuestions() { + Map source = new LinkedHashMap<>(); + source.put("welcomeMessage", " 欢迎使用\n智能助手 "); + source.put("suggestedQuestions", List.of(" 第一个问题 ", "", " 第二个问题")); + source.put("inputPlaceholder", " 请输入问题 "); + + Map normalized = AgentInteractionConfigSupport.normalize(source); + + Assert.assertEquals("欢迎使用\n智能助手", normalized.get("welcomeMessage")); + Assert.assertEquals(List.of("第一个问题", "第二个问题"), normalized.get("suggestedQuestions")); + Assert.assertEquals("请输入问题", normalized.get("inputPlaceholder")); + } + + /** + * 验证重复问题会被拒绝。 + */ + @Test + public void shouldRejectDuplicateQuestionsAfterTrim() { + Map source = Map.of( + "suggestedQuestions", List.of("如何使用?", " 如何使用? ") + ); + + assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source)); + } + + /** + * 验证多行输入提示会被拒绝。 + */ + @Test + public void shouldRejectMultilinePlaceholder() { + Map source = Map.of("inputPlaceholder", "第一行\n第二行"); + + assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source)); + } + + /** + * 验证问题数量不能超过上限。 + */ + @Test + public void shouldRejectTooManyQuestions() { + Map source = Map.of( + "suggestedQuestions", + List.of("问题1", "问题2", "问题3", "问题4", "问题5", "问题6", "问题7") + ); + + assertBusinessException(() -> AgentInteractionConfigSupport.normalize(source)); + } + + /** + * 断言操作会抛出业务异常。 + * + * @param action 待执行操作 + */ + private void assertBusinessException(Runnable action) { + try { + action.run(); + Assert.fail("应抛出 BusinessException"); + } catch (BusinessException expected) { + Assert.assertNotNull(expected.getMessage()); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java index 27fc9b68..4b9f57d5 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java @@ -1,20 +1,86 @@ package tech.easyflow.agent.publish; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.update.UpdateChain; +import com.mybatisflex.core.util.LambdaGetter; import org.junit.Assert; import org.junit.Test; +import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.service.AgentKnowledgeBindingService; +import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.ai.enums.PublishStatus; import java.lang.reflect.Proxy; import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + /** * {@link AgentApprovalSubjectHandler} 单元测试。 */ public class AgentApprovalSubjectHandlerTest { + /** + * 审批状态变更必须使用字段级更新,避免覆盖 Agent 草稿配置。 + */ + @Test + public void persistResourceStateShouldUseSelectiveUpdate() { + AgentService agentService = mock(AgentService.class); + UpdateChain updateChain = prepareUpdateChain(agentService); + AgentApprovalSubjectHandler handler = handler(agentService); + + handler.persistResourceState( + BigInteger.valueOf(1001), + PublishStatus.PUBLISH_PENDING, + BigInteger.valueOf(2001) + ); + + verifySelectiveUpdate(updateChain, BigInteger.valueOf(1001), 2); + verify(agentService, never()).updateById(any(Agent.class)); + } + + /** + * 发布 Agent 必须使用字段级更新,确保发布后草稿配置仍可回显。 + */ + @Test + public void publishResourceShouldUseSelectiveUpdate() { + AgentService agentService = mock(AgentService.class); + UpdateChain updateChain = prepareUpdateChain(agentService); + AgentApprovalSubjectHandler handler = handler(agentService); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("interactionConfigJson", Map.of("welcomeMessage", "你好")); + + handler.publishResource(BigInteger.valueOf(1001), snapshot, BigInteger.valueOf(3001)); + + verifySelectiveUpdate(updateChain, BigInteger.valueOf(1001), 5); + verify(agentService, never()).updateById(any(Agent.class)); + } + + /** + * Agent 下线必须使用字段级更新,避免覆盖未发布配置。 + */ + @Test + public void markResourceOfflineShouldUseSelectiveUpdate() { + AgentService agentService = mock(AgentService.class); + UpdateChain updateChain = prepareUpdateChain(agentService); + AgentApprovalSubjectHandler handler = handler(agentService); + + handler.markResourceOffline(BigInteger.valueOf(1001)); + + verifySelectiveUpdate(updateChain, BigInteger.valueOf(1001), 2); + verify(agentService, never()).updateById(any(Agent.class)); + } + /** * 审批删除 Agent 前必须同步清理工具绑定和知识库绑定,避免留下孤儿数据。 */ @@ -39,6 +105,60 @@ public class AgentApprovalSubjectHandlerTest { Assert.assertEquals(1, knowledgeRemoveCalls.get()); } + /** + * 创建用于生命周期测试的处理器。 + * + * @param agentService Agent 服务 + * @return Agent 审批资源处理器 + */ + private static AgentApprovalSubjectHandler handler(AgentService agentService) { + return new AgentApprovalSubjectHandler( + null, + new ObjectMapper(), + agentService, + null, + null, + null + ); + } + + /** + * 准备字段更新对象。 + * + * @param agentService Agent 服务 + * @return 字段更新对象 + */ + @SuppressWarnings("unchecked") + private static UpdateChain prepareUpdateChain(AgentService agentService) { + UpdateChain updateChain = mock(UpdateChain.class); + when(agentService.updateChain()).thenReturn(updateChain); + return updateChain; + } + + /** + * 校验生命周期操作仅通过指定主键执行字段更新。 + * + * @param updateChain 字段更新对象 + * @param resourceId Agent 主键 + * @param fieldCount 更新字段数量 + */ + @SuppressWarnings("unchecked") + private static void verifySelectiveUpdate(UpdateChain updateChain, + BigInteger resourceId, + int fieldCount) { + verify(updateChain, times(fieldCount)).set(any(LambdaGetter.class), any()); + verify(updateChain).eq(any(LambdaGetter.class), eq(resourceId)); + verify(updateChain).update(); + } + + /** + * 创建只统计删除调用的服务代理。 + * + * @param type 服务接口类型 + * @param removeCalls 删除调用计数器 + * @param 服务接口类型 + * @return 服务代理 + */ @SuppressWarnings("unchecked") private static T proxy(Class type, AtomicInteger removeCalls) { return (T) Proxy.newProxyInstance( diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql new file mode 100644 index 00000000..8cad9ccd --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V30__mysql_agent_interaction_config.sql @@ -0,0 +1,2 @@ +ALTER TABLE `tb_agent` + ADD COLUMN `interaction_config_json` json NULL COMMENT 'Agent 对话体验配置' AFTER `execution_config_json`; diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue index 8f745d99..4cbdff3a 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiChatPanel.vue @@ -1,8 +1,8 @@ - - - - diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue index a6a85cf7..361b4e6a 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue @@ -4,32 +4,27 @@ import type { ChatTimelineMessageItem, ChatTimelineToolApprovalPayload, } from '@easyflow/common-ui'; -import {ChatTimeline, ChatTimelineBuilder} from '@easyflow/common-ui'; -import type {AgentInfo} from '../agents/types'; -import type {AgentChatSessionView} from './api'; -import { - approveAgentRun, - deleteAgentSession, - getAgentConversation, - getAgentSession, - getAgentSessions, - getPublishedAgents, - getPublishedKnowledges, - rejectAgentRun, - renameAgentSession, - saveAgentSessionExtraKnowledges, -} from './api'; +import type { AgentInfo } from '../agents/types'; +import type { AgentChatSessionView } from './api'; import type { ChatInputTriggerGroup, ChatInputTriggerItem, } from '#/components/chat-workspace/input-triggers/types'; -import {computed, onBeforeUnmount, onMounted, ref} from 'vue'; -import {useRoute, useRouter} from 'vue-router'; +import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; -import {Delete, EditPen, MoreFilled, Plus, Promotion,} from '@element-plus/icons-vue'; +import { ChatTimeline, ChatTimelineBuilder } from '@easyflow/common-ui'; + +import { + Delete, + EditPen, + MoreFilled, + Plus, + Promotion, +} from '@element-plus/icons-vue'; import { ElButton, ElDropdown, @@ -45,26 +40,28 @@ import { import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue'; import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue'; -import {useChatInputTrigger} from '#/components/chat-workspace/input-triggers/useChatInputTrigger'; +import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger'; -import {recordsToTimelineItems} from './adapters/agentTimelineAdapter'; -import {agentChatRuntimeManager} from './agentChatRuntimeManager'; -import AgentChatWelcomeState from './components/AgentChatWelcomeState.vue'; +import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue'; +import { resolveInteractionDisplay } from '../agents/interaction-config'; +import { recordsToTimelineItems } from './adapters/agentTimelineAdapter'; +import { agentChatRuntimeManager } from './agentChatRuntimeManager'; +import { + approveAgentRun, + deleteAgentSession, + getAgentConversation, + getAgentSession, + getAgentSessions, + getPublishedAgents, + getPublishedKnowledges, + rejectAgentRun, + renameAgentSession, + saveAgentSessionExtraKnowledges, +} from './api'; const route = useRoute(); const router = useRouter(); -const WELCOME_TITLES = [ - '我们应该做些什么', - '让协作发生', - '今天想推进什么', - '把想法变成行动', - '让智能体开始工作', - '从一个问题开始', - '一起把事情理清楚', - '把下一步交给协作', -]; - const agents = ref([]); const sessions = ref([]); const timelineItems = ref([]); @@ -73,6 +70,7 @@ const currentSessionId = ref(''); const promptText = ref(''); const promptInputRef = ref(); const loadingAgents = ref(false); +const agentLoadError = ref(''); const loadingSessions = ref(false); const loadingConversation = ref(false); const loadingKnowledges = ref(false); @@ -90,6 +88,9 @@ let runtimeUnsubscribe: (() => void) | undefined; const selectedAgent = computed(() => agents.value.find((agent) => String(agent.id) === selectedAgentId.value), ); +const interactionDisplay = computed(() => + resolveInteractionDisplay(selectedAgent.value), +); const currentSession = computed(() => sessions.value.find( (session) => String(session.sessionId) === currentSessionId.value, @@ -104,7 +105,9 @@ const canSend = computed( !runtimeRunning.value, ); const composerPlaceholder = computed(() => - selectedAgent.value ? '输入消息' : '请选择智能体', + selectedAgent.value + ? interactionDisplay.value.inputPlaceholder + : '请选择智能体', ); const selectedExtraKnowledges = computed(() => { const knowledges: { id: string; title: string }[] = []; @@ -125,18 +128,12 @@ const capabilityDisabled = computed( ); const isWelcomeState = computed( () => + Boolean(selectedAgent.value) && + !loadingAgents.value && !loadingConversation.value && !currentSessionId.value && timelineItems.value.length === 0, ); -const welcomeTitle = computed(() => { - const agentKey = selectedAgentId.value || selectedAgent.value?.name || ''; - const index = [...agentKey].reduce( - (total, char) => total + char.charCodeAt(0), - 0, - ); - return WELCOME_TITLES[index % WELCOME_TITLES.length] || '我们应该做些什么'; -}); const agentSelectWidth = computed(() => { const name = selectedAgent.value?.name || '选择智能体'; const textWidth = [...name].reduce( @@ -222,6 +219,7 @@ async function syncSessionRoute(sessionId?: string) { async function loadAgents() { loadingAgents.value = true; + agentLoadError.value = ''; try { const res = await getPublishedAgents(); if (res.errorCode !== 0) { @@ -232,7 +230,9 @@ async function loadAgents() { selectedAgentId.value = String(agents.value[0].id); } } catch (error) { - ElMessage.error(error instanceof Error ? error.message : '智能体加载失败'); + agentLoadError.value = + error instanceof Error ? error.message : '智能体加载失败'; + ElMessage.error(agentLoadError.value); } finally { loadingAgents.value = false; } @@ -571,8 +571,8 @@ function buildCapabilities() { ]; } -async function handleSend() { - const content = promptText.value.trim(); +async function sendContent(rawContent: string) { + const content = rawContent.trim(); if (!content || !selectedAgentId.value || sending.value) { return; } @@ -602,6 +602,14 @@ async function handleSend() { } } +async function handleSend() { + await sendContent(promptText.value); +} + +function handleSuggestedQuestion(question: string) { + void sendContent(question); +} + function handlePromptInput() { chatInputTrigger.sync(); } @@ -894,12 +902,36 @@ onBeforeUnmount(() => { class="agent-chat__timeline-wrap" :class="{ 'is-welcome': isWelcomeState }" > -
+
加载中
- + {{ agentLoadError }} +
+
+ 暂无已发布智能体 +
+ { /> -
+
{ diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue index cda921f7..927f3e94 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue @@ -1,18 +1,28 @@ + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts index 0dc55e3c..3ac18c55 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts @@ -9,6 +9,13 @@ import type { import { computed, reactive } from 'vue'; +import { + buildInteractionConfigPayload, + createEmptyInteractionConfig, + normalizeInteractionConfig, + validateInteractionConfig, +} from '../interaction-config'; + const BASE_NODE_ID = 'agent-base'; const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/; @@ -85,6 +92,7 @@ export function createEmptyAgent(): AgentInfo { minCompressionTokenThreshold: 6000, }, }, + interactionConfigJson: createEmptyInteractionConfig(), status: 1, }; } @@ -119,6 +127,9 @@ function normalizeAgent(agent?: AgentInfo): AgentInfo { defaultCompressionParameter.minCompressionTokenThreshold, }, }, + interactionConfigJson: normalizeInteractionConfig( + source.interactionConfigJson, + ), }; } @@ -160,7 +171,7 @@ function normalizeToolBinding( String( binding.id || createLocalId(String(binding.toolType || 'tool').toLowerCase()), - ), + ), toolName: normalizeBindingToolName(binding), optionsJson, sortNo: binding.sortNo ?? index + 1, @@ -302,6 +313,9 @@ export function useAgentDesignerState() { message: '请选择模型', }); } + issues.push( + ...validateInteractionConfig(state.agent.interactionConfigJson), + ); state.knowledgeBindings.forEach((binding) => { if (!binding.knowledgeId) { issues.push({ @@ -339,6 +353,9 @@ export function useAgentDesignerState() { memoryConfigJson; return { ...state.agent, + interactionConfigJson: buildInteractionConfigPayload( + state.agent.interactionConfigJson, + ), memoryConfigJson: { ...restMemoryConfigJson, compressionParameter: { diff --git a/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts new file mode 100644 index 00000000..5ab40853 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildInteractionConfigPayload, + resolveInteractionDisplay, + validateInteractionConfig, +} from './interaction-config'; + +describe('agent 对话体验配置', () => { + it('为空配置补充展示默认值', () => { + expect(resolveInteractionDisplay({ name: '数据助手' })).toEqual({ + inputPlaceholder: '输入消息', + suggestedQuestions: [], + welcomeMessage: '你好,我是 数据助手,有什么可以帮你?', + }); + }); + + it('保存前清理首尾空白并过滤空问题', () => { + expect( + buildInteractionConfigPayload({ + inputPlaceholder: ' 输入需求 ', + suggestedQuestions: [' 问题一 ', '', ' 问题二'], + welcomeMessage: ' 欢迎使用 ', + }), + ).toEqual({ + inputPlaceholder: '输入需求', + suggestedQuestions: ['问题一', '问题二'], + welcomeMessage: '欢迎使用', + }); + }); + + it('识别去除空白后的重复问题', () => { + const issues = validateInteractionConfig({ + suggestedQuestions: ['如何开始?', ' 如何开始? '], + }); + + expect(issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'interaction.suggestedQuestions.1', + }), + ]), + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts new file mode 100644 index 00000000..33696282 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/interaction-config.ts @@ -0,0 +1,120 @@ +import type { + AgentInfo, + AgentInteractionConfig, + AgentValidationIssue, +} from './types'; + +export const MAX_WELCOME_MESSAGE_LENGTH = 300; +export const MAX_SUGGESTED_QUESTION_COUNT = 6; +export const MAX_SUGGESTED_QUESTION_LENGTH = 80; +export const MAX_INPUT_PLACEHOLDER_LENGTH = 40; +export const DEFAULT_INPUT_PLACEHOLDER = '输入消息'; + +export function createEmptyInteractionConfig(): AgentInteractionConfig { + return { + inputPlaceholder: '', + suggestedQuestions: [], + welcomeMessage: '', + }; +} + +export function normalizeInteractionConfig( + source?: Partial | Record, +): AgentInteractionConfig { + return { + inputPlaceholder: + typeof source?.inputPlaceholder === 'string' + ? source.inputPlaceholder + : '', + suggestedQuestions: Array.isArray(source?.suggestedQuestions) + ? source.suggestedQuestions.filter( + (question): question is string => typeof question === 'string', + ) + : [], + welcomeMessage: + typeof source?.welcomeMessage === 'string' ? source.welcomeMessage : '', + }; +} + +export function buildInteractionConfigPayload( + source?: Partial | Record, +): AgentInteractionConfig { + const normalized = normalizeInteractionConfig(source); + return { + inputPlaceholder: normalized.inputPlaceholder.trim(), + suggestedQuestions: normalized.suggestedQuestions + .map((question) => question.trim()) + .filter(Boolean), + welcomeMessage: normalized.welcomeMessage.trim(), + }; +} + +export function resolveInteractionDisplay(agent?: AgentInfo) { + const config = buildInteractionConfigPayload(agent?.interactionConfigJson); + const agentName = String(agent?.name || '').trim() || '智能体'; + return { + inputPlaceholder: config.inputPlaceholder || DEFAULT_INPUT_PLACEHOLDER, + suggestedQuestions: config.suggestedQuestions, + welcomeMessage: + config.welcomeMessage || `你好,我是 ${agentName},有什么可以帮你?`, + }; +} + +export function validateInteractionConfig( + source?: Partial | Record, +): AgentValidationIssue[] { + const config = normalizeInteractionConfig(source); + const issues: AgentValidationIssue[] = []; + if (config.welcomeMessage.trim().length > MAX_WELCOME_MESSAGE_LENGTH) { + issues.push({ + field: 'interaction.welcomeMessage', + message: '欢迎语不能超过 300 个字符', + nodeId: 'agent-base', + }); + } + if (/\r|\n/.test(config.inputPlaceholder)) { + issues.push({ + field: 'interaction.inputPlaceholder', + message: '输入提示仅支持单行文本', + nodeId: 'agent-base', + }); + } else if ( + config.inputPlaceholder.trim().length > MAX_INPUT_PLACEHOLDER_LENGTH + ) { + issues.push({ + field: 'interaction.inputPlaceholder', + message: '输入提示不能超过 40 个字符', + nodeId: 'agent-base', + }); + } + + const questions = config.suggestedQuestions + .map((question, index) => ({ index, value: question.trim() })) + .filter((item) => item.value); + if (questions.length > MAX_SUGGESTED_QUESTION_COUNT) { + issues.push({ + field: 'interaction.suggestedQuestions', + message: '猜你想问最多配置 6 条', + nodeId: 'agent-base', + }); + } + const seenQuestions = new Set(); + for (const question of questions) { + if (question.value.length > MAX_SUGGESTED_QUESTION_LENGTH) { + issues.push({ + field: `interaction.suggestedQuestions.${question.index}`, + message: `第 ${question.index + 1} 条猜你想问不能超过 80 个字符`, + nodeId: 'agent-base', + }); + } + if (seenQuestions.has(question.value)) { + issues.push({ + field: `interaction.suggestedQuestions.${question.index}`, + message: `第 ${question.index + 1} 条猜你想问与已有内容重复`, + nodeId: 'agent-base', + }); + } + seenQuestions.add(question.value); + } + return issues; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/types.ts b/easyflow-ui-admin/app/src/views/ai/agents/types.ts index 9dc27b1c..79d7fe46 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/types.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/types.ts @@ -3,6 +3,12 @@ export type AgentPanelMode = 'base' | 'capability' | 'tryout'; export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp'; +export interface AgentInteractionConfig { + inputPlaceholder: string; + suggestedQuestions: string[]; + welcomeMessage: string; +} + export interface AgentInfo { id?: number | string; name?: string; @@ -15,6 +21,7 @@ export interface AgentInfo { promptConfigJson?: Record; memoryConfigJson?: Record; executionConfigJson?: Record; + interactionConfigJson?: AgentInteractionConfig; status?: number; visibilityScope?: string; publishStatus?: string; From 27e50a762423440e1b2168b1ffd3478e4b84d38b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Thu, 16 Jul 2026 14:50:06 +0800 Subject: [PATCH 004/143] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=20Chrome=2090?= =?UTF-8?q?=20=E5=89=8D=E7=AB=AF=E8=BF=90=E8=A1=8C=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为管理端与用户中心补齐旧浏览器运行时 API 和回归测试 - 增加现代 CSS 与选择器降级,保持现有界面效果 - 固定 Chrome 90 构建目标并补充兼容依赖 --- easyflow-ui-admin/.browserslistrc | 1 + easyflow-ui-admin/app/package.json | 5 +- .../app/src/components/ai-chat/AiMessage.vue | 1 + .../src/components/ai-chat/AiPromptInput.vue | 1 + .../app/src/components/tag/Tag.vue | 12 +- .../app/src/components/tree/Tree.vue | 3 + easyflow-ui-admin/app/src/main.ts | 1 + easyflow-ui-admin/app/src/polyfills.test.ts | 108 ++++++++ easyflow-ui-admin/app/src/polyfills.ts | 261 +++++++++++++++--- .../app/src/typings/wicg-inert.d.ts | 1 + .../agents/components/AgentInspectorPanel.vue | 1 + .../agents/components/AgentWelcomeState.vue | 26 +- .../agent-studio/AgentStudioCanvas.vue | 1 + .../agent-studio/AgentStudioEdgeLayer.vue | 1 + .../agent-studio/AgentStudioNode.vue | 16 ++ .../documentCollection/ChunkDocumentTable.vue | 21 ++ .../ai/documentCollection/SegmenterDoc.vue | 8 + .../documentCollection/SplitterDocPreview.vue | 1 + .../ui/number-field/NumberFieldContent.vue | 2 +- .../chat-timeline/ChatKnowledgeCard.vue | 28 +- .../effects/plugins/src/vxe-table/style.css | 1 + .../src/components/base/mixed-input.svelte | 4 +- .../src/components/base/select.svelte | 4 +- easyflow-ui-admin/pnpm-lock.yaml | 19 ++ easyflow-ui-usercenter/.browserslistrc | 1 + easyflow-ui-usercenter/app/package.json | 2 + .../app/src/components/tag/Tag.vue | 12 +- easyflow-ui-usercenter/app/src/main.ts | 3 + .../app/src/polyfills.test.ts | 82 ++++++ easyflow-ui-usercenter/app/src/polyfills.ts | 208 ++++++++++++++ .../vite-config/src/config/application.ts | 4 + .../ui/number-field/NumberFieldContent.vue | 2 +- .../effects/plugins/src/vxe-table/style.css | 1 + easyflow-ui-usercenter/pnpm-lock.yaml | 11 + 34 files changed, 793 insertions(+), 60 deletions(-) create mode 100644 easyflow-ui-admin/app/src/polyfills.test.ts create mode 100644 easyflow-ui-admin/app/src/typings/wicg-inert.d.ts create mode 100644 easyflow-ui-usercenter/app/src/polyfills.test.ts create mode 100644 easyflow-ui-usercenter/app/src/polyfills.ts diff --git a/easyflow-ui-admin/.browserslistrc b/easyflow-ui-admin/.browserslistrc index dc3bc09a..4ff9abc2 100644 --- a/easyflow-ui-admin/.browserslistrc +++ b/easyflow-ui-admin/.browserslistrc @@ -1,4 +1,5 @@ > 1% last 2 versions +Chrome >= 90 not dead not ie 11 diff --git a/easyflow-ui-admin/app/package.json b/easyflow-ui-admin/app/package.json index 7ef59f3f..91f278c7 100644 --- a/easyflow-ui-admin/app/package.json +++ b/easyflow-ui-admin/app/package.json @@ -37,6 +37,7 @@ "@codemirror/view": "^6.39.15", "@element-plus/icons-vue": "^2.3.2", "@tinyflow-ai/vue": "workspace:*", + "@ungap/structured-clone": "1.3.0", "@vueuse/core": "catalog:", "@wangeditor/editor": "^5.1.23", "@wangeditor/editor-for-vue": "^5.1.12", @@ -53,10 +54,12 @@ "vue-cropper": "^1.1.4", "vue-element-plus-x": "catalog:", "vue-router": "catalog:", - "vue3-json-viewer": "^2.4.1" + "vue3-json-viewer": "^2.4.1", + "wicg-inert": "3.1.3" }, "devDependencies": { "@types/node-forge": "^1.3.14", + "@types/ungap__structured-clone": "1.2.0", "cssnano": "catalog:", "unplugin-element-plus": "catalog:" } diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue index efe30ddb..857f7575 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue @@ -91,6 +91,7 @@ const emit = defineEmits<{ max-width: min(88%, 640px); padding: 12px 14px; color: var(--el-text-color-primary); + background: var(--el-bg-color); background: color-mix(in srgb, var(--el-bg-color) 88%, transparent); border: 1px solid var(--el-border-color-lighter); border-radius: 8px; diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue index 4dfcd2b7..f995c5d8 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiPromptInput.vue @@ -78,6 +78,7 @@ function handleKeydown(event: Event | KeyboardEvent) { align-items: flex-end; padding: 10px; margin: 0 16px 16px; + background: var(--el-bg-color); background: color-mix(in srgb, var(--el-bg-color) 92%, transparent); border: 1px solid var(--el-border-color-lighter); border-radius: 8px; diff --git a/easyflow-ui-admin/app/src/components/tag/Tag.vue b/easyflow-ui-admin/app/src/components/tag/Tag.vue index 85a6cf39..40a5a591 100644 --- a/easyflow-ui-admin/app/src/components/tag/Tag.vue +++ b/easyflow-ui-admin/app/src/components/tag/Tag.vue @@ -121,7 +121,11 @@ const sizeMap = {