发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
68 changed files with 4901 additions and 135 deletions
Showing only changes of commit 950148b3f7 - Show all commits

View File

@@ -24,6 +24,10 @@
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId> <artifactId>easyflow-module-agent</artifactId>
</dependency> </dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-skill</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId> <artifactId>easyflow-module-chatlog</artifactId>

View File

@@ -6,6 +6,7 @@ import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountService;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -76,6 +77,15 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName); fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName);
} }
/**
* 批量填充 Skill 创建人名称。
*
* @param skills Skill 集合
*/
public void fillSkillCreatorNames(Collection<Skill> skills) {
fillCreatorNames(skills, Skill::getCreatedBy, Skill::setCreatedByName);
}
/** /**
* 通用的创建人名称填充逻辑。 * 通用的创建人名称填充逻辑。
* *

View File

@@ -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<SkillCategoryService, SkillCategory> {
@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<List<SkillCategory>> 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<Serializable> ids) {
for (Serializable id : ids) {
List<Skill> skills = skillMapper.selectListByQuery(QueryWrapper.create().eq(Skill::getCategoryId, id));
if (skills != null && !skills.isEmpty()) {
throw new BusinessException("请先迁移或删除该分类下的 Skill");
}
List<SkillCategory> children = service.list(QueryWrapper.create().eq(SkillCategory::getParentId, id));
if (children != null && !children.isEmpty()) {
throw new BusinessException("请先删除子分类");
}
}
return super.onRemoveBefore(ids);
}
}

View File

@@ -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<SkillService, Skill> {
@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<Skill> 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<SkillImportPreview> 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<List<Skill>> 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<BigInteger> 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<List<SkillFileNode>> fileTree(BigInteger skillId) {
return Result.ok(skillFileService.tree(skillId));
}
/**
* 获取 Skill 文件内容。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @return 文件内容
*/
@GetMapping("/file/content")
public Result<SkillFileContent> 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<SkillFileContent> 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<Void> 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<SkillFileContent> 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<BigInteger> 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<BigInteger> 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<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> 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<Skill> queryPage(Page<Skill> page, QueryWrapper queryWrapper) {
if (!applyCategoryPermission(queryWrapper)) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
}
applyPublishedOnlyFilter(queryWrapper);
Page<Skill> 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<BigInteger> 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);
}
}

View File

@@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
@@ -14,8 +15,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Component("local") @Component("local")
@@ -116,6 +115,20 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
@Override @Override
public String save(MultipartFile file, String prePath) { 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);
}
} }
} }

View File

@@ -12,6 +12,7 @@ public enum ApprovalResourceType {
BOT("BOT"), BOT("BOT"),
AGENT("AGENT"), AGENT("AGENT"),
SKILL("SKILL"),
WORKFLOW("WORKFLOW"), WORKFLOW("WORKFLOW"),
KNOWLEDGE("KNOWLEDGE"); KNOWLEDGE("KNOWLEDGE");

View File

@@ -7,4 +7,5 @@ tech.easyflow.chatlog.config.ChatlogModuleConfig
tech.easyflow.datacenter.config.DatacenterModuleConfig tech.easyflow.datacenter.config.DatacenterModuleConfig
tech.easyflow.job.config.JobModuleConfig tech.easyflow.job.config.JobModuleConfig
tech.easyflow.log.config.LogModuleConfig tech.easyflow.log.config.LogModuleConfig
tech.easyflow.skill.config.SkillModuleConfig
tech.easyflow.system.config.SysModuleConfig tech.easyflow.system.config.SysModuleConfig

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-modules</artifactId>
<version>${revision}</version>
</parent>
<name>easyflow-module-skill</name>
<artifactId>easyflow-module-skill</artifactId>
<dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-approval</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-system</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-web</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-satoken</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-file-storage</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-skill</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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 {
}

View File

@@ -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<String, Object> 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<String, Object> 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<SkillReference> references;
@Column(ignore = true)
private List<SkillScript> scripts;
@Column(ignore = true)
private List<SkillAsset> 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<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> 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<String, Object> getPublishedSnapshotJson() { return publishedSnapshotJson; }
public void setPublishedSnapshotJson(Map<String, Object> 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<SkillReference> getReferences() { return references; }
public void setReferences(List<SkillReference> references) { this.references = references; }
public List<SkillScript> getScripts() { return scripts; }
public void setScripts(List<SkillScript> scripts) { this.scripts = scripts; }
public List<SkillAsset> getAssets() { return assets; }
public void setAssets(List<SkillAsset> assets) { this.assets = assets; }
}

View File

@@ -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<String, Object> 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<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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<String, Object> 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<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
}

View File

@@ -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<String, Object> 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<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
}

View File

@@ -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; }
}

View File

@@ -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<SkillFileNode> 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<SkillFileNode> getChildren() { return children; }
public void setChildren(List<SkillFileNode> children) { this.children = children == null ? new ArrayList<>() : children; }
}

View File

@@ -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; }
}

View File

@@ -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<SkillFileNode> 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);
}

View File

@@ -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<SkillFileNode> tree(BigInteger skillId) {
Skill skill = requireReadableSkill(skillId);
List<SkillFileNode> 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<SkillReference> 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<SkillScript> 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<SkillAsset> 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<SkillFileNode> 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<SkillFileNode> toNestedChildren(String root, List<SkillFileNode> flatChildren) {
Map<String, SkillFileNode> 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<SkillFileNode> 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;
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.skill.file;
/**
* Skill 逻辑文件类型。
*/
public enum SkillFileType {
/**
* SKILL.md 主文件。
*/
SKILL,
/**
* references/ 下的 Markdown 文档。
*/
REFERENCE,
/**
* scripts/ 下的脚本文件。
*/
SCRIPT,
/**
* assets/ 下的静态资产。
*/
ASSET
}

View File

@@ -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<BigInteger> skillIds, OutputStream outputStream);
}

View File

@@ -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<BigInteger> skillIds, OutputStream outputStream) {
if (skillIds == null || skillIds.isEmpty()) {
throw new BusinessException("请选择要导出的 Skill");
}
try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) {
Set<String> 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<String> 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<String> 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;
}
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.skill.imports;
import java.util.ArrayList;
import java.util.List;
/**
* Skill 导入预览结果。
*/
public class SkillImportPreview {
private List<SkillImportPreviewItem> skills = new ArrayList<>();
/**
* 获取导入 Skill 预览项。
*
* @return 预览项列表
*/
public List<SkillImportPreviewItem> getSkills() {
return skills;
}
/**
* 设置导入 Skill 预览项。
*
* @param skills 预览项列表
*/
public void setSkills(List<SkillImportPreviewItem> skills) {
this.skills = skills == null ? new ArrayList<>() : skills;
}
}

View File

@@ -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; }
}

View File

@@ -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<Skill> importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft);
}

View File

@@ -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<com.easyagents.skill.model.Skill> 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<Skill> importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft) {
List<com.easyagents.skill.model.Skill> importedSkills = new ZipSkillPackageCodec(contentStore).importZip(inputStream);
List<Skill> 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<Skill> skills = skillService.list(QueryWrapper.create().eq(Skill::getName, name));
return skills.isEmpty() ? null : skills.get(0);
}
}

View File

@@ -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<SkillAssetContent> {
}

View File

@@ -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<SkillAsset> {
}

View File

@@ -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<SkillCategory> {
}

View File

@@ -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<Skill> {
}

View File

@@ -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<SkillReference> {
}

View File

@@ -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<SkillScript> {
}

View File

@@ -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<Skill> {
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<String, Object> getPublishedSnapshot(Skill resource) {
return resource.getPublishedSnapshotJson();
}
@Override
protected Map<String, Object> 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<String, Object> 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";
}
}

View File

@@ -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()
);
}
}

View File

@@ -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<com.easyagents.skill.model.Skill> get(String skillId) {
BigInteger id = parseId(skillId);
Skill skill = skillService.getDetail(id);
return Optional.of(SkillModelConverter.toAgentSkill(skill));
}
/**
* {@inheritDoc}
*/
@Override
public Optional<SkillDescriptor> 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<SkillDescriptor> 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;
}
}
}

View File

@@ -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<Skill> skills);
}

View File

@@ -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<SkillAssetContent> {
}

View File

@@ -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<SkillAsset> {
}

View File

@@ -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<SkillCategory> {
/**
* 校验目标分类可作为 Skill 分类。
*
* @param categoryId 分类 ID可为空
*/
void validateUsableCategory(BigInteger categoryId);
}

View File

@@ -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<SkillReference> {
}

View File

@@ -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<SkillScript> {
}

View File

@@ -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> {
/**
* 获取 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<String, Object> buildPublishSnapshot(Skill skill);
/**
* 从发布快照还原 Skill。
*
* @param snapshot 发布快照
* @return Skill
*/
Skill fromSnapshot(Map<String, Object> snapshot);
/**
* 删除 Skill 聚合。
*
* @param id Skill ID
*/
void removeAggregate(BigInteger id);
}

View File

@@ -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<Skill> skills) {
if (CollectionUtils.isEmpty(skills)) {
return;
}
List<Skill> validSkills = skills.stream().filter(Objects::nonNull).toList();
if (validSkills.isEmpty()) {
return;
}
Map<BigInteger, ApprovalInstance> 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<BigInteger, ApprovalInstance> loadInstanceMap(Collection<Skill> skills) {
Set<BigInteger> instanceIds = skills.stream()
.map(Skill::getCurrentApprovalInstanceId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (instanceIds.isEmpty()) {
return Collections.emptyMap();
}
List<ApprovalInstance> 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;
};
}
}

View File

@@ -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<SkillAssetContentMapper, SkillAssetContent> implements SkillAssetContentService {
}

View File

@@ -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<SkillAssetMapper, SkillAsset> implements SkillAssetService {
}

View File

@@ -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<SkillCategoryMapper, SkillCategory> 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;
}
}

View File

@@ -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<SkillReferenceMapper, SkillReference> implements SkillReferenceService {
}

View File

@@ -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<SkillScriptMapper, SkillScript> implements SkillScriptService {
}

View File

@@ -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<SkillMapper, Skill> 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<String, Object> buildPublishSnapshot(Skill skill) {
Skill detail = getDetail(skill.getId());
com.easyagents.skill.model.Skill agentSkill = SkillModelConverter.toAgentSkill(detail);
Map<String, Object> 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<String, Object> 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));
}
}

View File

@@ -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); }
}
}

View File

@@ -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<com.easyagents.skill.model.SkillReference> toAgentReferences(List<SkillReference> 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<com.easyagents.skill.model.SkillScript> toAgentScripts(List<SkillScript> 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<com.easyagents.skill.model.SkillAsset> toAgentAssets(List<SkillAsset> 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;
}
}
}

View File

@@ -9,6 +9,7 @@ public enum CategoryResourceType {
BOT("BOT"), BOT("BOT"),
AGENT("AGENT"), AGENT("AGENT"),
SKILL("SKILL"),
PLUGIN("PLUGIN"), PLUGIN("PLUGIN"),
WORKFLOW("WORKFLOW"), WORKFLOW("WORKFLOW"),
KNOWLEDGE("KNOWLEDGE"), KNOWLEDGE("KNOWLEDGE"),

View File

@@ -19,6 +19,7 @@
<module>easyflow-module-chatlog</module> <module>easyflow-module-chatlog</module>
<module>easyflow-module-ai</module> <module>easyflow-module-ai</module>
<module>easyflow-module-agent</module> <module>easyflow-module-agent</module>
<module>easyflow-module-skill</module>
<module>easyflow-module-job</module> <module>easyflow-module-job</module>
<module>easyflow-module-datacenter</module> <module>easyflow-module-datacenter</module>
</modules> </modules>

View File

@@ -56,6 +56,10 @@
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId> <artifactId>easyflow-module-agent</artifactId>
</dependency> </dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-skill</artifactId>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-auth</artifactId> <artifactId>easyflow-module-auth</artifactId>

View File

@@ -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 内容索引';

View File

@@ -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
);

View File

@@ -28,6 +28,13 @@
"@easyflow/styles": "workspace:*", "@easyflow/styles": "workspace:*",
"@easyflow/types": "workspace:*", "@easyflow/types": "workspace:*",
"@easyflow/utils": "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", "@element-plus/icons-vue": "^2.3.2",
"@tinyflow-ai/vue": "workspace:*", "@tinyflow-ai/vue": "workspace:*",
"@vueuse/core": "catalog:", "@vueuse/core": "catalog:",

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import {onBeforeUnmount, onMounted, ref, shallowRef, watch} from 'vue';
import {defaultKeymap, history, historyKeymap, indentWithTab} from '@codemirror/commands';
import {javascript} from '@codemirror/lang-javascript';
import {python} from '@codemirror/lang-python';
import {
bracketMatching,
defaultHighlightStyle,
indentOnInput,
StreamLanguage,
syntaxHighlighting
} from '@codemirror/language';
import {shell} from '@codemirror/legacy-modes/mode/shell';
import {EditorState, type Extension} from '@codemirror/state';
import {
drawSelection,
EditorView,
highlightActiveLine,
keymap,
lineNumbers
} from '@codemirror/view';
type EditorLanguage = 'javascript' | 'markdown' | 'python' | 'shell' | 'text';
const props = withDefaults(
defineProps<{
modelValue: string;
language?: EditorLanguage;
readonly?: boolean;
}>(),
{
language: 'text',
readonly: false,
},
);
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void;
}>();
const hostRef = ref<HTMLElement>();
const viewRef = shallowRef<EditorView>();
let syncingFromOutside = false;
function languageExtension(): Extension[] {
if (props.language === 'python') {
return [python()];
}
if (props.language === 'javascript') {
return [javascript({ jsx: true, typescript: true })];
}
if (props.language === 'shell') {
return [StreamLanguage.define(shell)];
}
return [];
}
function createState(doc: string) {
return EditorState.create({
doc,
extensions: [
lineNumbers(),
history(),
drawSelection(),
highlightActiveLine(),
indentOnInput(),
bracketMatching(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.editable.of(!props.readonly),
EditorState.readOnly.of(props.readonly),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (!update.docChanged || syncingFromOutside) return;
emit('update:modelValue', update.state.doc.toString());
}),
EditorView.theme({
'&': {
height: '100%',
fontSize: '13px',
backgroundColor: 'var(--el-bg-color)',
color: 'var(--el-text-color-primary)',
},
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
},
'.cm-gutters': {
backgroundColor: 'var(--el-fill-color-lighter)',
borderRight: '1px solid var(--el-border-color-lighter)',
color: 'var(--el-text-color-secondary)',
},
'.cm-activeLine': {
backgroundColor: 'var(--el-fill-color-light)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--el-fill-color-light)',
},
}),
...languageExtension(),
],
});
}
function mountEditor() {
if (!hostRef.value) return;
viewRef.value = new EditorView({
state: createState(props.modelValue || ''),
parent: hostRef.value,
});
}
function recreateEditor() {
const currentDoc = viewRef.value?.state.doc.toString() ?? props.modelValue ?? '';
viewRef.value?.destroy();
viewRef.value = undefined;
if (!hostRef.value) return;
viewRef.value = new EditorView({
state: createState(currentDoc),
parent: hostRef.value,
});
}
watch(
() => props.modelValue,
(value) => {
const view = viewRef.value;
if (!view) return;
const nextValue = value || '';
const currentValue = view.state.doc.toString();
if (nextValue === currentValue) return;
syncingFromOutside = true;
view.dispatch({
changes: { from: 0, to: currentValue.length, insert: nextValue },
});
syncingFromOutside = false;
},
);
watch(
() => [props.language, props.readonly],
() => recreateEditor(),
);
onMounted(mountEditor);
onBeforeUnmount(() => {
viewRef.value?.destroy();
});
</script>
<template>
<div ref="hostRef" class="code-mirror-editor"></div>
</template>
<style scoped>
.code-mirror-editor {
height: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
</style>

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
declare module '@codemirror/legacy-modes/mode/shell.js' {
import type {StreamParser} from '@codemirror/language';
export const shell: StreamParser<unknown>;
}

View File

@@ -0,0 +1,2 @@
declare module 'markdown-it';

View File

@@ -0,0 +1,398 @@
<script setup lang="ts">
import type {SkillFileContent, SkillFileNode, SkillInfo} from './types';
import {computed, onMounted, ref, watch} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import DOMPurify from 'dompurify';
import {ArrowLeft, Check, Download, Promotion} from '@element-plus/icons-vue';
import {ElButton, ElEmpty, ElImage, ElMessage, ElTag, ElTree,} from 'element-plus';
import MarkdownIt from 'markdown-it';
import CodeMirrorEditor from '#/components/editor/CodeMirrorEditor.vue';
import {
getSkillDetail,
getSkillFileContent,
getSkillFileTree,
resolveSkillAssetUrl,
saveSkill,
saveSkillFile,
submitSkillPublishApproval,
} from './api';
const route = useRoute();
const router = useRouter();
const skillId = computed(() => String(route.params.id || 'new'));
const isNew = computed(() => skillId.value === 'new');
const skill = ref<SkillInfo>({
displayName: '新建技能',
skillContent: '---\nname: new-skill\ndescription: 新技能\n---\n\n# 新技能\n',
visibilityScope: 'PRIVATE',
enabled: true,
});
const fileTree = ref<SkillFileNode[]>([]);
const selectedFile = ref<SkillFileContent>();
const editorContent = ref('');
const loading = ref(false);
const saving = ref(false);
const markdown = new MarkdownIt({
breaks: true,
html: false,
linkify: true,
});
const renderedMarkdown = computed(() => {
if (!isMarkdownFile.value) return '';
return DOMPurify.sanitize(markdown.render(editorContent.value || ''));
});
const isMarkdownFile = computed(() =>
selectedFile.value?.type === 'SKILL' || selectedFile.value?.type === 'REFERENCE',
);
const isScriptFile = computed(() => selectedFile.value?.type === 'SCRIPT');
const isAssetFile = computed(() => selectedFile.value?.type === 'ASSET');
const assetUrl = computed(() =>
selectedFile.value?.path && !isNew.value
? resolveSkillAssetUrl(skillId.value, selectedFile.value.path)
: '',
);
const isImageAsset = computed(() =>
String(selectedFile.value?.mediaType || '').startsWith('image/') &&
selectedFile.value?.mediaType !== 'image/svg+xml',
);
const isPdfAsset = computed(() => selectedFile.value?.mediaType === 'application/pdf');
onMounted(initPage);
watch(skillId, initPage);
async function initPage() {
if (isNew.value) {
const defaultContent = '---\nname: new-skill\ndescription: 新技能\n---\n\n# 新技能\n';
skill.value = {
displayName: '新建技能',
skillContent: defaultContent,
visibilityScope: 'PRIVATE',
enabled: true,
};
selectedFile.value = { path: 'SKILL.md', type: 'SKILL', content: defaultContent };
editorContent.value = defaultContent;
fileTree.value = [
{ key: 'SKILL.md', path: 'SKILL.md', name: 'SKILL.md', type: 'SKILL' },
{ key: 'references', path: 'references', name: 'references/', type: 'DIRECTORY', children: [] },
{ key: 'scripts', path: 'scripts', name: 'scripts/', type: 'DIRECTORY', children: [] },
{ key: 'assets', path: 'assets', name: 'assets/', type: 'DIRECTORY', children: [] },
];
return;
}
await loadDetail();
}
async function loadDetail() {
loading.value = true;
try {
const detailRes = await getSkillDetail(skillId.value);
if (detailRes.errorCode === 0) {
skill.value = detailRes.data;
}
await loadTree();
await selectFile('SKILL.md');
} finally {
loading.value = false;
}
}
async function loadTree() {
const res = await getSkillFileTree(skillId.value);
if (res.errorCode === 0) {
fileTree.value = res.data || [];
}
}
async function selectFile(path: string) {
if (isNew.value) return;
const res = await getSkillFileContent(skillId.value, path);
if (res.errorCode === 0) {
selectedFile.value = res.data;
editorContent.value = res.data.content || '';
}
}
function handleTreeNodeClick(node: SkillFileNode) {
if (node.type === 'DIRECTORY') return;
selectFile(node.path);
}
async function handleSave() {
saving.value = true;
try {
if (isNew.value) {
skill.value.skillContent = editorContent.value;
const res = await saveSkill(skill.value);
if (res.errorCode === 0) {
ElMessage.success('保存成功');
await router.replace(`/ai/skill/detail/${res.data.id}`);
}
return;
}
if (!selectedFile.value) return;
if (isMarkdownFile.value || isScriptFile.value) {
const res = await saveSkillFile(skillId.value, selectedFile.value.path, editorContent.value);
if (res.errorCode === 0) {
selectedFile.value = res.data;
ElMessage.success('保存成功');
await loadTree();
if (selectedFile.value.path === 'SKILL.md') {
await loadDetail();
}
}
}
} finally {
saving.value = false;
}
}
async function handlePublish() {
if (isNew.value) {
ElMessage.warning('请先保存技能');
return;
}
const res = await submitSkillPublishApproval(skillId.value);
if (res.errorCode === 0) {
ElMessage.success(res.message || '操作成功');
await loadDetail();
}
}
function editorLanguage() {
const language = selectedFile.value?.language;
if (language === 'PYTHON') return 'python';
if (language === 'JAVASCRIPT') return 'javascript';
if (language === 'SHELL') return 'shell';
if (isMarkdownFile.value) return 'markdown';
return 'text';
}
</script>
<template>
<div class="skill-detail-page" v-loading="loading">
<header class="skill-detail-page__header">
<div class="skill-detail-page__title">
<ElButton :icon="ArrowLeft" text @click="router.push('/ai/skill')" />
<div>
<h1>{{ skill.displayName || skill.name || '技能详情' }}</h1>
<p>{{ skill.description || '未填写描述' }}</p>
</div>
<ElTag v-if="skill.displayPublishStatus || skill.publishStatus" size="small" effect="plain">
{{ skill.displayPublishStatus || skill.publishStatus }}
</ElTag>
</div>
<div class="skill-detail-page__actions">
<ElButton :icon="Check" type="primary" :loading="saving" @click="handleSave">
保存
</ElButton>
<ElButton :icon="Promotion" @click="handlePublish">发布</ElButton>
</div>
</header>
<main class="skill-detail-page__body">
<aside class="skill-detail-page__tree">
<ElTree
node-key="path"
:data="fileTree"
:props="{ label: 'name', children: 'children' }"
default-expand-all
highlight-current
@node-click="handleTreeNodeClick"
/>
</aside>
<section class="skill-detail-page__workspace">
<template v-if="selectedFile">
<div class="skill-detail-page__filebar">
<strong>{{ selectedFile.path }}</strong>
<span v-if="selectedFile.size">{{ selectedFile.size }} bytes</span>
</div>
<div v-if="isMarkdownFile" class="skill-detail-page__split">
<CodeMirrorEditor
v-model="editorContent"
class="skill-detail-page__editor"
:language="editorLanguage()"
/>
<div class="skill-detail-page__preview markdown-body" v-html="renderedMarkdown"></div>
</div>
<CodeMirrorEditor
v-else-if="isScriptFile"
v-model="editorContent"
class="skill-detail-page__editor"
:language="editorLanguage()"
/>
<div v-else-if="isAssetFile" class="skill-detail-page__asset">
<ElImage v-if="isImageAsset" :src="assetUrl" fit="contain" />
<iframe v-else-if="isPdfAsset" :src="assetUrl"></iframe>
<div v-else class="skill-detail-page__asset-info">
<p>{{ selectedFile.mediaType || 'application/octet-stream' }}</p>
<p>{{ selectedFile.size || 0 }} bytes</p>
</div>
<ElButton :icon="Download" tag="a" :href="assetUrl" target="_blank">
下载
</ElButton>
</div>
</template>
<ElEmpty v-else />
</section>
</main>
</div>
</template>
<style scoped>
.skill-detail-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 24px;
}
.skill-detail-page__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.skill-detail-page__title {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.skill-detail-page__title h1 {
margin: 0;
font-size: 20px;
font-weight: 650;
line-height: 28px;
}
.skill-detail-page__title p {
margin: 2px 0 0;
color: var(--el-text-color-secondary);
}
.skill-detail-page__actions {
display: flex;
gap: 8px;
}
.skill-detail-page__body {
display: flex;
flex: 1;
gap: 16px;
min-height: 0;
}
.skill-detail-page__tree {
width: 280px;
min-width: 240px;
padding: 12px;
overflow: auto;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.skill-detail-page__workspace {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.skill-detail-page__filebar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.skill-detail-page__filebar span {
color: var(--el-text-color-secondary);
}
.skill-detail-page__split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
min-height: 0;
padding: 16px;
flex: 1;
}
.skill-detail-page__editor {
flex: 1;
min-height: 0;
margin: 16px;
}
.skill-detail-page__split .skill-detail-page__editor {
margin: 0;
}
.skill-detail-page__preview {
min-height: 0;
padding: 16px;
overflow: auto;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.skill-detail-page__asset {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 0;
padding: 24px;
}
.skill-detail-page__asset :deep(.el-image) {
width: 100%;
max-height: 60vh;
}
.skill-detail-page__asset iframe {
width: 100%;
height: 60vh;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.skill-detail-page__asset-info {
color: var(--el-text-color-secondary);
text-align: center;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin: 0 0 12px;
}
.markdown-body :deep(p) {
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import type {SkillInfo} from './types';
import type {ActionButton, CardPrimaryAction} from '#/components/page/CardList.vue';
import CardList from '#/components/page/CardList.vue';
import {markRaw, onMounted, ref} from 'vue';
import {useRouter} from 'vue-router';
import {Delete, Edit, Plus, Promotion, Upload} from '@element-plus/icons-vue';
import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
import {tryit} from 'radash';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {
canAiResourceDelete,
canAiResourceOffline,
canAiResourcePublish,
canAiResourceRepublish,
isAiResourceApprovalPending,
resolveAiResourceDisplayStatus,
} from '#/views/ai/shared/publish-status';
import {
getSkillCategories,
importSkillConfirm,
importSkillPreview,
submitSkillDeleteApproval,
submitSkillOfflineApproval,
submitSkillPublishApproval,
} from './api';
const router = useRouter();
const pageDataRef = ref();
const sideList = ref<any[]>([]);
const importInputRef = ref<HTMLInputElement>();
const selectedCategoryId = ref<string>('');
const SKILL_TAB_PAGE_KEY = '/ai/skill';
const headerButtons = [
{
key: 'create',
text: '新建技能',
icon: markRaw(Plus),
type: 'primary',
data: { action: 'create' },
permission: '/api/v1/skill/save',
},
{
key: 'import',
text: '导入',
icon: markRaw(Upload),
type: 'default',
data: { action: 'import' },
permission: '/api/v1/skill/save',
},
];
const primaryAction: CardPrimaryAction = {
icon: Edit,
text: '编辑',
permission: '/api/v1/skill/update',
onClick(row: SkillInfo) {
router.push({
path: `/ai/skill/detail/${row.id}`,
query: {
pageKey: SKILL_TAB_PAGE_KEY,
navTitle: row.displayName || row.name || '技能详情',
},
});
},
};
const actions: ActionButton[] = [
{
icon: Promotion,
text: (row: SkillInfo) =>
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? '重新发布'
: '发布',
permission: '/api/v1/skill/save',
placement: 'inline',
visible: (row: SkillInfo) =>
canAiResourcePublish(row.displayPublishStatus, row.publishStatus) ||
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus),
onClick: handlePublishAction,
},
{
icon: Promotion,
text: '下线',
permission: '/api/v1/skill/save',
placement: 'menu',
visible: (row: SkillInfo) =>
canAiResourceOffline(row.displayPublishStatus, row.publishStatus),
onClick: handleOfflineAction,
},
{
icon: Delete,
text: '删除',
permission: '/api/v1/skill/remove',
placement: 'menu',
tone: 'danger',
visible: (row: SkillInfo) =>
canAiResourceDelete(row.displayPublishStatus, row.publishStatus),
onClick: handleDeleteAction,
},
];
onMounted(loadCategories);
function handleSearch(keyword: string) {
pageDataRef.value?.setQuery({
isQueryOr: true,
name: keyword,
displayName: keyword,
description: keyword,
});
}
function handleButtonClick(payload: any) {
if (payload?.key === 'create' || payload?.data?.action === 'create') {
router.push({
path: '/ai/skill/detail/new',
query: { pageKey: SKILL_TAB_PAGE_KEY, navTitle: '新建技能' },
});
return;
}
if (payload?.key === 'import' || payload?.data?.action === 'import') {
importInputRef.value?.click();
}
}
function changeCategory(category: any) {
selectedCategoryId.value = category.id || '';
pageDataRef.value?.setQuery({ categoryId: category.id });
}
async function loadCategories() {
const [, res] = await tryit(getSkillCategories)();
if (res?.errorCode === 0) {
sideList.value = [
{ id: '', categoryName: '全部分类' },
...(res.data || []),
];
}
}
async function handleImportFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const preview = await importSkillPreview(file);
if (preview.errorCode !== 0) return;
const conflictCount = preview.data?.skills?.filter((item) => item.conflict).length || 0;
const ok = await confirmAction(
conflictCount > 0
? `检测到 ${conflictCount} 个同名草稿,确认覆盖草稿并导入?`
: '确认导入该 Skill zip',
conflictCount > 0 ? 'warning' : 'info',
);
if (!ok) return;
const res = await importSkillConfirm(
file,
selectedCategoryId.value || undefined,
conflictCount > 0,
);
if (res.errorCode === 0) {
ElMessage.success('导入完成');
pageDataRef.value?.reload?.();
}
}
function resolvePublishStatusMeta(displayPublishStatus?: string, publishStatus?: string) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING':
return { label: '删除中', type: 'danger' as const };
case 'OFFLINE':
return { label: '已下线', type: 'info' as const };
case 'OFFLINE_PENDING':
return { label: '下线中', type: 'warning' as const };
case 'PUBLISH_PENDING':
return { label: '发布中', type: 'warning' as const };
case 'PUBLISHED':
return { label: '已发布', type: 'success' as const };
default:
return { label: '草稿', type: 'info' as const };
}
}
async function confirmAction(message: string, type: 'info' | 'warning' = 'info') {
try {
await ElMessageBox.confirm(message, '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type,
});
return true;
} catch {
return false;
}
}
async function handlePublishAction(row: SkillInfo) {
if (isAiResourceApprovalPending(row.displayPublishStatus, row.publishStatus)) {
ElMessage.warning('当前技能正在审批中');
return;
}
const ok = await confirmAction(
canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)
? '确认提交重新发布审批?'
: '确认提交发布审批?',
);
if (!ok) return;
const res = await submitSkillPublishApproval(String(row.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '操作成功');
pageDataRef.value?.reload?.();
}
}
async function handleOfflineAction(row: SkillInfo) {
const ok = await confirmAction('确认提交下线审批?', 'warning');
if (!ok) return;
const res = await submitSkillOfflineApproval(String(row.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '操作成功');
pageDataRef.value?.reload?.();
}
}
async function handleDeleteAction(row: SkillInfo) {
const ok = await confirmAction('确认提交删除审批?', 'warning');
if (!ok) return;
const res = await submitSkillDeleteApproval(String(row.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '操作成功');
pageDataRef.value?.reload?.();
}
}
</script>
<template>
<div class="skill-list-page">
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="handleButtonClick"
/>
<input
ref="importInputRef"
class="skill-list-page__file"
type="file"
accept=".zip,application/zip"
@change="handleImportFile"
/>
<div class="skill-list-page__body">
<PageSide
label-key="categoryName"
value-key="id"
:menus="sideList"
@change="changeCategory"
/>
<div class="skill-list-page__content">
<PageData
ref="pageDataRef"
page-url="/api/v1/skill/page"
:page-sizes="[12, 18, 24]"
:page-size="12"
>
<template #default="{ pageList }">
<CardList
title-field="displayName"
desc-field="description"
:data="pageList"
:default-icon="''"
:primary-action="primaryAction"
:actions="actions"
>
<template #corner="{ item }">
<ElTag
size="small"
effect="plain"
round
:type="
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).type
"
>
{{
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).label
}}
</ElTag>
</template>
</CardList>
</template>
</PageData>
</div>
</div>
</div>
</template>
<style scoped>
.skill-list-page {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
padding: 24px;
}
.skill-list-page__file {
display: none;
}
.skill-list-page__body {
display: flex;
flex: 1;
gap: 24px;
min-height: 0;
}
.skill-list-page__content {
flex: 1;
min-width: 0;
height: calc(100vh - 192px);
overflow: auto;
}
</style>

View File

@@ -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<RequestResult<SkillInfo>>('/api/v1/skill/getDetail', {
params: { id },
});
}
export function saveSkill(skill: SkillInfo) {
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/save', skill);
}
export function updateSkill(skill: SkillInfo) {
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/update', skill);
}
export function getSkillCategories() {
return api.get<RequestResult<any[]>>('/api/v1/skillCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
});
}
export function submitSkillPublishApproval(id: number | string) {
return api.post<RequestResult<number | string>>(
'/api/v1/skill/submitPublishApproval',
{ id },
);
}
export function submitSkillOfflineApproval(id: number | string) {
return api.post<RequestResult<number | string>>(
'/api/v1/skill/submitOfflineApproval',
{ id },
);
}
export function submitSkillDeleteApproval(id: number | string) {
return api.post<RequestResult<number | string>>(
'/api/v1/skill/submitDeleteApproval',
{ id },
);
}
export function getSkillFileTree(skillId: number | string) {
return api.get<RequestResult<SkillFileNode[]>>('/api/v1/skill/file/tree', {
params: { skillId },
});
}
export function getSkillFileContent(skillId: number | string, path: string) {
return api.get<RequestResult<SkillFileContent>>('/api/v1/skill/file/content', {
params: { skillId, path },
});
}
export function saveSkillFile(
skillId: number | string,
path: string,
content: string,
) {
return api.post<RequestResult<SkillFileContent>>('/api/v1/skill/file/save', {
skillId,
path,
content,
});
}
export function importSkillPreview(file: File) {
const formData = new FormData();
formData.append('file', file);
return api.postFile<RequestResult<SkillImportPreview>>(
'/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<RequestResult<SkillInfo[]>>(
'/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)}`;
}

View File

@@ -0,0 +1,99 @@
export interface RequestResult<T = any> {
data: T;
errorCode: number;
message?: string;
}
export interface SkillInfo {
id?: number | string;
name?: string;
displayName?: string;
description?: string;
categoryId?: number | string;
metadataJson?: Record<string, any>;
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<string, any>;
}
export interface SkillScript {
id?: number | string;
path: string;
language?: string;
content?: string;
contentHash?: string;
size?: number;
metadataJson?: Record<string, any>;
}
export interface SkillAsset {
id?: number | string;
path: string;
name?: string;
mediaType?: string;
contentRef?: string;
contentHash?: string;
size?: number;
metadataJson?: Record<string, any>;
}
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[];
}

View File

@@ -531,10 +531,10 @@ importers:
version: 24.10.1 version: 24.10.1
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: 'catalog:' 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': '@vitejs/plugin-vue-jsx':
specifier: 'catalog:' 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': '@vue/test-utils':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.4.6 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)) 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: vite:
specifier: 'catalog:' 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: vitest:
specifier: 'catalog:' 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: vue:
specifier: ^3.5.17 specifier: ^3.5.17
version: 3.5.24(typescript@5.9.3) version: 3.5.24(typescript@5.9.3)
@@ -589,6 +589,27 @@ importers:
app: app:
dependencies: 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': '@easyflow-core/shadcn-ui':
specifier: workspace:* specifier: workspace:*
version: link:../packages/@core/ui-kit/shadcn-ui version: link:../packages/@core/ui-kit/shadcn-ui
@@ -2484,6 +2505,9 @@ packages:
'@codemirror/language@6.12.2': '@codemirror/language@6.12.2':
resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==} resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==}
'@codemirror/legacy-modes@6.5.3':
resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.4':
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
@@ -12143,6 +12167,10 @@ snapshots:
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
style-mod: 4.1.3 style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.3':
dependencies:
'@codemirror/language': 6.12.2
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.4':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
@@ -14415,18 +14443,6 @@ snapshots:
- rollup - rollup
- supports-color - 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))': '@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: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
@@ -14439,12 +14455,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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))': '@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: dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29 '@rolldown/pluginutils': 1.0.0-beta.29
@@ -14465,14 +14475,6 @@ snapshots:
chai: 5.3.3 chai: 5.3.3
tinyrainbow: 2.0.0 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))': '@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: dependencies:
'@vitest/spy': 3.2.4 '@vitest/spy': 3.2.4
@@ -14480,7 +14482,6 @@ snapshots:
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: 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) 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': '@vitest/pretty-format@3.2.4':
dependencies: dependencies:
@@ -21323,27 +21324,6 @@ snapshots:
dependencies: 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: 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): 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: dependencies:
cac: 6.7.14 cac: 6.7.14
@@ -21364,7 +21344,6 @@ snapshots:
- terser - terser
- tsx - tsx
- yaml - 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)): 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: dependencies:
@@ -21494,23 +21473,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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): 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: dependencies:
esbuild: 0.25.3 esbuild: 0.25.3
@@ -21545,23 +21507,6 @@ snapshots:
terser: 5.44.1 terser: 5.44.1
yaml: 2.8.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): 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: dependencies:
esbuild: 0.25.3 esbuild: 0.25.3
@@ -21578,55 +21523,11 @@ snapshots:
sass: 1.94.0 sass: 1.94.0
terser: 5.44.1 terser: 5.44.1
yaml: 2.8.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)): 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: 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) 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): 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: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
@@ -21669,7 +21570,6 @@ snapshots:
- terser - terser
- tsx - tsx
- yaml - yaml
optional: true
vscode-languageserver-textdocument@1.0.12: {} vscode-languageserver-textdocument@1.0.12: {}

10
pom.xml
View File

@@ -206,6 +206,11 @@
<artifactId>easy-agents-agent-runtime</artifactId> <artifactId>easy-agents-agent-runtime</artifactId>
<version>${easy-agents.version}</version> <version>${easy-agents.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-skill</artifactId>
<version>${easy-agents.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.squareup.okhttp3</groupId> <groupId>com.squareup.okhttp3</groupId>
@@ -451,6 +456,11 @@
<artifactId>easyflow-module-agent</artifactId> <artifactId>easyflow-module-agent</artifactId>
<version>${revision}</version> <version>${revision}</version>
</dependency> </dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-skill</artifactId>
<version>${revision}</version>
</dependency>
<dependency> <dependency>
<groupId>tech.easyflow</groupId> <groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-auth</artifactId> <artifactId>easyflow-module-auth</artifactId>