From a5631caab3ddb228a80a20edb402c84f862b17c7 Mon Sep 17 00:00:00 2001 From: wangzhiwei Date: Fri, 10 Apr 2026 15:47:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(controller):=20=E6=B7=BB=E5=8A=A0=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=9C=AC=E5=9C=B0=E6=8A=80=E8=83=BD=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E5=8C=85V2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在SkillGenController中新增uploadSkillV4方法,支持上传zip或rar格式技能包 - 新增CmsContent uploadSkillV4接口实现技能包解析和内容生成 - 集成SevenZipJBinding库支持rar格式解压 - 实现压缩包目录树结构解析功能 - 添加YAML内容生成和技能信息提取功能 - 完善异常处理和错误信息返回机制 --- .../skills/controller/SkillGenController.java | 20 + .../entity/dto/SkillPackageInfoDto.java | 53 + .../entity/dto/SkillStructureNodeDto.java | 53 + .../kexue/skills/service/SkillGenService.java | 2 + .../service/impl/SkillGenServiceImpl.java | 917 ++++++++++++++---- .../java/com/kexue/skills/utils/YamlUtil.java | 18 + 6 files changed, 900 insertions(+), 163 deletions(-) create mode 100644 src/main/java/com/kexue/skills/entity/dto/SkillPackageInfoDto.java create mode 100644 src/main/java/com/kexue/skills/entity/dto/SkillStructureNodeDto.java create mode 100644 src/main/java/com/kexue/skills/utils/YamlUtil.java diff --git a/src/main/java/com/kexue/skills/controller/SkillGenController.java b/src/main/java/com/kexue/skills/controller/SkillGenController.java index 67d3d14..ebdabce 100644 --- a/src/main/java/com/kexue/skills/controller/SkillGenController.java +++ b/src/main/java/com/kexue/skills/controller/SkillGenController.java @@ -128,4 +128,24 @@ public class SkillGenController { return CommonResult.success(cmsContent); } + /** + * 上传本地技能压缩包V2 + * + * @param file 技能压缩包文件 + * @return 生成的技能内容 + */ + @PostMapping("/uploadSkillV4") + @Operation(summary = "上传本地技能压缩包V2", description = "上传本地zip或rar文件并生成技能") + public CommonResult uploadSkillV4( + @RequestParam("file") MultipartFile file) { + try { + byte[] fileBytes = file.getBytes(); + String fileName = file.getOriginalFilename(); + CmsContent cmsContent = skillGenService.uploadSkillV4(fileBytes, fileName); + return CommonResult.success(cmsContent); + } catch (Exception e) { + return CommonResult.failed("上传失败:" + e.getMessage()); + } + } + } diff --git a/src/main/java/com/kexue/skills/entity/dto/SkillPackageInfoDto.java b/src/main/java/com/kexue/skills/entity/dto/SkillPackageInfoDto.java new file mode 100644 index 0000000..d7f5f6e --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/dto/SkillPackageInfoDto.java @@ -0,0 +1,53 @@ +package com.kexue.skills.entity.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 技能包信息DTO + * + * @author AI技能生成助手 + * @since 2026-04-10 + */ +@Data +public class SkillPackageInfoDto implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 技能名称 + */ + private String name; + + /** + * 版本号 + */ + private String version; + + /** + * 技能描述 + */ + private String description; + + /** + * 作者 + */ + private String author; + + /** + * 创建日期 + */ + private String created; + + /** + * 标签列表 + */ + private List tags; + + /** + * 目录结构 + */ + private List structure; +} diff --git a/src/main/java/com/kexue/skills/entity/dto/SkillStructureNodeDto.java b/src/main/java/com/kexue/skills/entity/dto/SkillStructureNodeDto.java new file mode 100644 index 0000000..ce54669 --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/dto/SkillStructureNodeDto.java @@ -0,0 +1,53 @@ +package com.kexue.skills.entity.dto; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 技能包目录结构节点DTO + * + * @author AI技能生成助手 + * @since 2026-04-10 + */ +@Data +public class SkillStructureNodeDto implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 节点名称(文件或目录名) + */ + private String name; + + /** + * 节点类型:directory 或 file + */ + private String type; + + /** + * 父级路径 + */ + private String path; + + /** + * 格式:dir、markdown、python + */ + private String format; + + /** + * 节点描述 + */ + private String description; + + /** + * 子节点列表(目录类型使用) + */ + private List children; + + /** + * 文件内容(文件类型使用) + */ + private String content; +} diff --git a/src/main/java/com/kexue/skills/service/SkillGenService.java b/src/main/java/com/kexue/skills/service/SkillGenService.java index ef16dd3..c4b0688 100644 --- a/src/main/java/com/kexue/skills/service/SkillGenService.java +++ b/src/main/java/com/kexue/skills/service/SkillGenService.java @@ -69,5 +69,7 @@ public interface SkillGenService { */ CmsContent uploadSkillV2(byte[] fileBytes, String fileName); + CmsContent uploadSkillV4(byte[] fileBytes, String fileName); + CmsContent uploadSkillV3(String yamlContent); } diff --git a/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java index 40d0fca..ff1b06c 100644 --- a/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java @@ -11,6 +11,8 @@ import com.kexue.skills.config.GlmConfig; import com.kexue.skills.entity.CmsContent; import com.kexue.skills.entity.CmsTag; import com.kexue.skills.entity.dto.CmsTagDto; +import com.kexue.skills.entity.dto.SkillPackageInfoDto; +import com.kexue.skills.entity.dto.SkillStructureNodeDto; import com.kexue.skills.entity.request.SkillAnalyzeRequest; import com.kexue.skills.entity.request.SkillGenRequest; import com.kexue.skills.entity.request.SkillPreGenRequest; @@ -22,20 +24,42 @@ import com.kexue.skills.service.CmsTagService; import com.kexue.skills.service.SkillGenService; import com.kexue.skills.utils.EscapeCharacterUtils; import com.kexue.skills.utils.YamlToMapUtil; +import com.kexue.skills.utils.YamlUtil; import jodd.util.StringUtil; import lombok.extern.slf4j.Slf4j; +import net.sf.sevenzipjbinding.IInArchive; +import net.sf.sevenzipjbinding.SevenZip; +import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.yaml.snakeyaml.error.YAMLException; import javax.annotation.Resource; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.RandomAccessFile; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; /** * 技能生成服务实现 @@ -49,10 +73,10 @@ public class SkillGenServiceImpl implements SkillGenService { @Autowired private DeepSeekConfig deepSeekConfig; - + @Autowired private GlmConfig glmConfig; - + @Autowired private CmsTagService cmsTagService; @@ -69,25 +93,25 @@ public class SkillGenServiceImpl implements SkillGenService { public SkillResponse preGenerate(SkillPreGenRequest request) { log.info("生成技能请求: {}", request); String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions"; - + // 从数据库中读取cms_tag表的标签信息 CmsTagDto tagDto = new CmsTagDto(); tagDto.setDeleteFlag(0); tagDto.setStatus(1); List tags = cmsTagService.getList(tagDto); - + // 将标签名称拼接成逗号分隔的字符串 StringBuilder tagsList = new StringBuilder(); for (int i = 0; i < tags.size(); i++) { CmsTag tag = tags.get(i); - tagsList.append(tag.getTagId()+"."+tag.getTagName()); + tagsList.append(tag.getTagId() + "." + tag.getTagName()); if (i < tags.size() - 1) { tagsList.append(","); } } - - SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), - deepSeekConfig.getChat().getTemperature(), deepSeekConfig.getChat().getMaxTokens(), + + SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), + deepSeekConfig.getChat().getTemperature(), deepSeekConfig.getChat().getMaxTokens(), request.getPrompt(), tagsList.toString()); String deepseekResponse = ""; @@ -99,26 +123,26 @@ public class SkillGenServiceImpl implements SkillGenService { // 解析deepseek返回结果 JSONObject responseJson = JSON.parseObject(deepseekResponse); List choices = responseJson.getJSONArray("choices").toJavaList(JSONObject.class); - + if (choices != null && !choices.isEmpty()) { // 获取最新的choice(这里是第一个,因为只有一个) JSONObject latestChoice = choices.get(0); JSONObject message = latestChoice.getJSONObject("message"); String content = message.getString("content"); - + // 解析content中的JSON JSONObject skillJson = JSON.parseObject(content); SkillResponse skillResponse = new SkillResponse(); skillResponse.setName(skillJson.getString("name")); skillResponse.setDescription(skillJson.getString("description")); skillResponse.setTags(skillJson.getJSONArray("tags").toJavaList(String.class)); - + log.info("解析技能响应: {}", skillResponse); return skillResponse; } } catch (Exception e) { log.error("调用Deepseek API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse); } return null; @@ -132,26 +156,26 @@ public class SkillGenServiceImpl implements SkillGenService { String model = glmConfig.getChat().getModel(); double temperature = glmConfig.getChat().getTemperature(); int maxTokens = glmConfig.getChat().getMaxTokens(); - + // 从数据库中读取cms_tag表的标签信息 CmsTagDto tagDto = new CmsTagDto(); tagDto.setDeleteFlag(0); tagDto.setStatus(1); List tags = cmsTagService.getList(tagDto); - + // 将标签名称拼接成逗号分隔的字符串 StringBuilder tagsList = new StringBuilder(); for (int i = 0; i < tags.size(); i++) { CmsTag tag = tags.get(i); - tagsList.append(tag.getTagId()+"."+tag.getTagName()); + tagsList.append(tag.getTagId() + "." + tag.getTagName()); if (i < tags.size() - 1) { tagsList.append(","); } } - + // 构建系统消息内容 String systemContent = "你是一个专业的AI技能设计助手。请根据agent skills撰写规范,按照用户提出的主题描述及参考文件,生成这个skill的名称、描述,并从以下标签列表中选择至少3个标签:\"" + tagsList.toString() + "\",tags只需要返回序号数组,并简述这个skill的具体价值点。输出json格式,仅输出以上所提到的名称、描述、标签、价值点,节点名称分别为name、description、tags、value_point,节点内容以中文形式返回。请严格按照指定的JSON格式输出,仅包含要求的字段,以中文形式返回。"; - + // 准备文件URL列表 List fileUrls = new ArrayList<>(); if (request.getFileUrl() != null && !request.getFileUrl().isEmpty()) { @@ -160,7 +184,7 @@ public class SkillGenServiceImpl implements SkillGenService { if (request.getFileUrls() != null && !request.getFileUrls().isEmpty()) { fileUrls.addAll(request.getFileUrls()); } - + // 创建技能请求 SkillRequest skillRequest = new SkillRequest(model, systemContent, request.getPrompt(), fileUrls, temperature, maxTokens); @@ -173,13 +197,13 @@ public class SkillGenServiceImpl implements SkillGenService { // 解析返回结果 JSONObject responseJson = JSON.parseObject(response); List choices = responseJson.getJSONArray("choices").toJavaList(JSONObject.class); - + if (choices != null && !choices.isEmpty()) { // 获取最新的choice(这里是第一个,因为只有一个) JSONObject latestChoice = choices.get(0); JSONObject message = latestChoice.getJSONObject("message"); String content = message.getString("content"); - + // 解析content中的JSON JSONObject skillJson = JSON.parseObject(content); SkillResponse skillResponse = new SkillResponse(); @@ -187,13 +211,13 @@ public class SkillGenServiceImpl implements SkillGenService { skillResponse.setDescription(skillJson.getString("description")); skillResponse.setTags(skillJson.getJSONArray("tags").toJavaList(String.class)); skillResponse.setSummary(skillJson.getString("value_point")); - + log.info("解析技能响应: {}", skillResponse); return skillResponse; } } catch (Exception e) { log.error("调用API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ response); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + response); } return null; } @@ -221,8 +245,8 @@ public class SkillGenServiceImpl implements SkillGenService { String defaultIcon = ""; for (int i = 0; i < tags.size(); i++) { CmsTag tag = tags.get(i); - if (tags1.contains(tag.getTagId()+"")) { - if (StringUtil.isEmpty(defaultIcon)){ + if (tags1.contains(tag.getTagId() + "")) { + if (StringUtil.isEmpty(defaultIcon)) { defaultIcon = tag.getIcon(); } tagsList.append(tag.getTagName()); @@ -232,99 +256,99 @@ public class SkillGenServiceImpl implements SkillGenService { } } String systemContent = """ - 你是AI技能包设计专家,仅输出【完整的纯YAML文本】,输出内容是完整可解析的技能YAML描述文件,绝非片段,不包含任何多余文字(无解释、无注释、无引言、无结尾)。 - - ### 一、YAML顶层强制规则(仅一个节点:package) - 1. 顶层只能有 package 一个节点,所有信息(名称、版本、目录结构等)均嵌套在 package 下 - 2. package 节点必含子字段:name、version、description、author、created、tags、structure(缺一不可) - 3. 最终必须输出完整闭合的YAML结构,禁止输出残缺片段、部分节点 - - ### 二、package子字段规范(固定格式) - 1. name:技能名称(与用户提供的Skill名称完全一致,不修改) - 2. version:固定为 "1.0.0" - 3. description:用户提供的Skill描述(完整复制,不增删任何内容) - 4. author:固定为 "AI技能生成助手" - 5. created:格式为 "YYYY-MM-DD"(使用当前日期,如2026-04-01) - 6. tags:数组格式,值为用户提供的Skill标签(中文,自动去重,逗号后加空格) - 7. structure:技能包目录树,根目录固定为 /,structure下直接编写根目录的一级子文件/子文件夹,禁止重复描述根目录本身 - - ### 三、structure目录树规则(仅Python脚本,无其他语言) - #### 1. 基础必含文件(所有技能通用) - - 根目录 / 下必生成:skills.md 文件,归属父路径 /,文件必须保留后缀名 .md - - #### 2. structure核心编写规则 - 1. structure节点下不写根目录/的描述,直接从一级子文件/子文件夹开始编写 - 2. 【path路径强制规则】path只写父级目录路径,不拼接文件名称/目录名称 - - 目录自身名称使用 name 字段体现,目录一律无后缀名 - - 文件自身名称使用 name 字段体现,文件必须保留标准后缀名 - - 示例:scripts目录在根目录下 → path: /,name: scripts - - 示例:skills.md在根目录下 → path: /,name: skills.md - - 示例:main.py在scripts目录下 → path: /scripts,name: main.py - 3. 所有directory/file节点均为根目录/的直接/间接子节点 - - #### 3. Python脚本目录/文件判断逻辑 - - 若用户提供的“Skill描述”“Skill摘要”中包含“脚本”“代码”“执行”“运行”“处理”“计算”等需执行逻辑的关键词: - 1. 必须新增 scripts 目录,该目录为文件夹,无任何后缀名,path: /,name: scripts - 2. 必须在该目录下固定生成 main.py 文件,文件必须带 .py 后缀,不允许省略或修改文件名 - - 若用户需求中无任何执行逻辑相关描述(如纯文档、纯说明类技能):不生成 scripts 目录,避免冗余 - - #### 4. 节点必含字段 - - directory类型:name、type、path(仅父级路径)、format(固定"dir")、description、children(空目录写 children: []) - - file类型:name、type、path(仅父级路径)、format(仅markdown/python两种)、description、content(非空,有实际可用内容) - - ### 四、文件content内容规范(Python脚本必实用) - #### 1. 根目录下 skills.md - - # 技能名称(不加多余符号,居中可加空格但不强制) - - ## 技能描述(整合用户“描述+摘要”,补充逻辑连贯性) - - ## 标签(格式:- 标签1\n- 标签2) - - ## 使用说明(分点写:适用场景、操作步骤,需脚本则写“运行scripts/main.py脚本”,无需则写“直接参考文档使用”) - - ## 目录结构(用代码块 ``` 列出所有文件/目录路径) - - #### 2. scripts目录下 main.py - - 必含依赖导入(如 import pandas as pd,无依赖则不写) - - 必含入口函数 def execute(params: dict) -> dict:(参数为dict,返回dict结果) - - 函数内必含: - 1. 参数校验(判断必填键是否存在,缺失返回错误) - 2. 核心逻辑(匹配技能需求,如数据处理、文本分析) - 3. 结果返回(成功:{"status": "success", "data": 结果};失败:{"status": "fail", "error": 信息}) - - 必含注释:函数说明、参数/返回值说明、示例调用(if __name__ == "__main__": 块) - - 禁止空函数、语法错误,确保复制后可直接运行 - - ### 五、YAML语法死规定(100%无解析错误) - 1. 缩进:统一2个空格(禁止Tab,禁止1/3/4空格,嵌套层级严格对齐) - - package 下子字段缩进2空格 - - structure 下目录/文件节点缩进4空格(package→structure→children,每层+2空格) - 2. 路径:全部遵循path只写父目录规则,Unix风格 /,禁止 \\ 或 ./ - 3. 字符串:特殊字符(:、#、空格)无需转义,直接书写 - 4. 数组:tags格式严格为 tags: [标签1, 标签2](逗号后加空格,无多余逗号) - 5. content:多行内容用 | 开头,内容行首顶格,内部遵循对应格式缩进(Python用4空格) - - ### 六、错误规避红线(绝对不能触碰) - 1. 禁止顶层出现除 package 外的任何节点 - 2. 禁止在YAML前后加任何多余文字 - 3. 禁止生成非Python脚本,禁止生成无后缀的脚本文件 - 4. 禁止字段缺失 - 5. 禁止输出YAML片段,必须输出完整可解析文件 - 6. structure禁止描述根目录/,直接从一级子节点开始 - 7. 严禁path中携带文件/目录名称,必须只写父级路径 - 8. 严禁将scripts目录错误添加后缀名,严禁修改Python脚本名为非main.py - - 最终输出仅完整纯YAML,直接可复制存储、解析使用,无任何冗余或格式问题! - """; + 你是AI技能包设计专家,仅输出【完整的纯YAML文本】,输出内容是完整可解析的技能YAML描述文件,绝非片段,不包含任何多余文字(无解释、无注释、无引言、无结尾)。 + + ### 一、YAML顶层强制规则(仅一个节点:package) + 1. 顶层只能有 package 一个节点,所有信息(名称、版本、目录结构等)均嵌套在 package 下 + 2. package 节点必含子字段:name、version、description、author、created、tags、structure(缺一不可) + 3. 最终必须输出完整闭合的YAML结构,禁止输出残缺片段、部分节点 + + ### 二、package子字段规范(固定格式) + 1. name:技能名称(与用户提供的Skill名称完全一致,不修改) + 2. version:固定为 "1.0.0" + 3. description:用户提供的Skill描述(完整复制,不增删任何内容) + 4. author:固定为 "AI技能生成助手" + 5. created:格式为 "YYYY-MM-DD"(使用当前日期,如2026-04-01) + 6. tags:数组格式,值为用户提供的Skill标签(中文,自动去重,逗号后加空格) + 7. structure:技能包目录树,根目录固定为 /,structure下直接编写根目录的一级子文件/子文件夹,禁止重复描述根目录本身 + + ### 三、structure目录树规则(仅Python脚本,无其他语言) + #### 1. 基础必含文件(所有技能通用) + - 根目录 / 下必生成:skills.md 文件,归属父路径 /,文件必须保留后缀名 .md + + #### 2. structure核心编写规则 + 1. structure节点下不写根目录/的描述,直接从一级子文件/子文件夹开始编写 + 2. 【path路径强制规则】path只写父级目录路径,不拼接文件名称/目录名称 + - 目录自身名称使用 name 字段体现,目录一律无后缀名 + - 文件自身名称使用 name 字段体现,文件必须保留标准后缀名 + - 示例:scripts目录在根目录下 → path: /,name: scripts + - 示例:skills.md在根目录下 → path: /,name: skills.md + - 示例:main.py在scripts目录下 → path: /scripts,name: main.py + 3. 所有directory/file节点均为根目录/的直接/间接子节点 + + #### 3. Python脚本目录/文件判断逻辑 + - 若用户提供的“Skill描述”“Skill摘要”中包含“脚本”“代码”“执行”“运行”“处理”“计算”等需执行逻辑的关键词: + 1. 必须新增 scripts 目录,该目录为文件夹,无任何后缀名,path: /,name: scripts + 2. 必须在该目录下固定生成 main.py 文件,文件必须带 .py 后缀,不允许省略或修改文件名 + - 若用户需求中无任何执行逻辑相关描述(如纯文档、纯说明类技能):不生成 scripts 目录,避免冗余 + + #### 4. 节点必含字段 + - directory类型:name、type、path(仅父级路径)、format(固定"dir")、description、children(空目录写 children: []) + - file类型:name、type、path(仅父级路径)、format(仅markdown/python两种)、description、content(非空,有实际可用内容) + + ### 四、文件content内容规范(Python脚本必实用) + #### 1. 根目录下 skills.md + - # 技能名称(不加多余符号,居中可加空格但不强制) + - ## 技能描述(整合用户“描述+摘要”,补充逻辑连贯性) + - ## 标签(格式:- 标签1\n- 标签2) + - ## 使用说明(分点写:适用场景、操作步骤,需脚本则写“运行scripts/main.py脚本”,无需则写“直接参考文档使用”) + - ## 目录结构(用代码块 ``` 列出所有文件/目录路径) + + #### 2. scripts目录下 main.py + - 必含依赖导入(如 import pandas as pd,无依赖则不写) + - 必含入口函数 def execute(params: dict) -> dict:(参数为dict,返回dict结果) + - 函数内必含: + 1. 参数校验(判断必填键是否存在,缺失返回错误) + 2. 核心逻辑(匹配技能需求,如数据处理、文本分析) + 3. 结果返回(成功:{"status": "success", "data": 结果};失败:{"status": "fail", "error": 信息}) + - 必含注释:函数说明、参数/返回值说明、示例调用(if __name__ == "__main__": 块) + - 禁止空函数、语法错误,确保复制后可直接运行 + + ### 五、YAML语法死规定(100%无解析错误) + 1. 缩进:统一2个空格(禁止Tab,禁止1/3/4空格,嵌套层级严格对齐) + - package 下子字段缩进2空格 + - structure 下目录/文件节点缩进4空格(package→structure→children,每层+2空格) + 2. 路径:全部遵循path只写父目录规则,Unix风格 /,禁止 \\ 或 ./ + 3. 字符串:特殊字符(:、#、空格)无需转义,直接书写 + 4. 数组:tags格式严格为 tags: [标签1, 标签2](逗号后加空格,无多余逗号) + 5. content:多行内容用 | 开头,内容行首顶格,内部遵循对应格式缩进(Python用4空格) + + ### 六、错误规避红线(绝对不能触碰) + 1. 禁止顶层出现除 package 外的任何节点 + 2. 禁止在YAML前后加任何多余文字 + 3. 禁止生成非Python脚本,禁止生成无后缀的脚本文件 + 4. 禁止字段缺失 + 5. 禁止输出YAML片段,必须输出完整可解析文件 + 6. structure禁止描述根目录/,直接从一级子节点开始 + 7. 严禁path中携带文件/目录名称,必须只写父级路径 + 8. 严禁将scripts目录错误添加后缀名,严禁修改Python脚本名为非main.py + + 最终输出仅完整纯YAML,直接可复制存储、解析使用,无任何冗余或格式问题! + """; String userContent = """ - 基于以下信息生成技能包YAML,严格遵守system指令(仅Python脚本,无其他语言): - 1. Skill名称:%s - 2. Skill描述:%s - 3. Skill标签:%s(中文,直接使用,不修改) - 4. Skill摘要/需求:%s(用于完善skills.md的“使用说明”章节) - """.formatted( + 基于以下信息生成技能包YAML,严格遵守system指令(仅Python脚本,无其他语言): + 1. Skill名称:%s + 2. Skill描述:%s + 3. Skill标签:%s(中文,直接使用,不修改) + 4. Skill摘要/需求:%s(用于完善skills.md的“使用说明”章节) + """.formatted( request.getName(), request.getDescription(), tagsList.toString(), request.getRequirement() ); - SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(),systemContent,userContent,deepSeekConfig.getChat().getTemperature(), 8192,"text"); + SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, userContent, deepSeekConfig.getChat().getTemperature(), 8192, "text"); String deepseekResponse = ""; try { // 发送HTTP请求到deepseek API @@ -332,10 +356,10 @@ public class SkillGenServiceImpl implements SkillGenService { log.info("Deepseek API响应: {}", deepseekResponse); JSONObject responseJson = JSON.parseObject(deepseekResponse); String content = responseJson.getJSONArray("choices").toJavaList(JSONObject.class).get(0).getJSONObject("message").getString("content"); - content = EscapeCharacterUtils.removeEscapeCharacters( content);//去除转义字符 - CmsContent cmsContent = getCmsContent(request, content, StpUtil.getLoginIdAsLong(),defaultIcon); + content = EscapeCharacterUtils.removeEscapeCharacters(content);//去除转义字符 + CmsContent cmsContent = getCmsContent(request, content, StpUtil.getLoginIdAsLong(), defaultIcon); List list = tags.stream().filter(tag -> tag.getTagId() == Long.parseLong(cmsContent.getTags().split(",")[0])).toList(); - if (CollectionUtil.isNotEmpty( list)){ + if (CollectionUtil.isNotEmpty(list)) { cmsContent.setIcon(list.get(0).getIcon()); } // 保存到数据库 @@ -343,11 +367,11 @@ public class SkillGenServiceImpl implements SkillGenService { return cmsContent; } catch (Exception e) { log.error("调用Deepseek API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse); } } - private CmsContent getCmsContent(SkillGenRequest request, String CmsContent,Long userId,String defaultIcon){ + private CmsContent getCmsContent(SkillGenRequest request, String CmsContent, Long userId, String defaultIcon) { CmsContent cmsContent = new CmsContent(); cmsContent.setTitle(request.getName()); cmsContent.setDescription(request.getDescription()); @@ -369,8 +393,8 @@ public class SkillGenServiceImpl implements SkillGenService { cmsContent.setCreateTime(new Date()); cmsContent.setUpdateTime(new Date()); cmsContent.setContentType(1); - cmsContent.setCreateBy(userId+""); - cmsContent.setUpdateBy(userId+""); + cmsContent.setCreateBy(userId + ""); + cmsContent.setUpdateBy(userId + ""); cmsContent.setDeleteFlag(0); cmsContent.setIcon(defaultIcon); cmsContent.setRequirement(request.getRequirement()); @@ -397,7 +421,7 @@ public class SkillGenServiceImpl implements SkillGenService { public String genIntroduce(String content) { log.info("生成技能介绍请求: {}", content); String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions"; - + String systemContent = "你是一个专业的AI技能设计助手。我会给你提供一个完整的skill的内容,请你帮我总结出skill的作用,能够解决的问题,输出一段描述文本"; SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, content, 0.3, 500, "text"); String deepseekResponse = ""; @@ -405,29 +429,29 @@ public class SkillGenServiceImpl implements SkillGenService { // 发送HTTP请求到deepseek API deepseekResponse = HttpUtil.sendPostRequest(url, skillRequest, deepSeekConfig.getApiKey(), null); log.info("Deepseek API响应: {}", deepseekResponse); - + // 解析返回结果 JSONObject responseJson = JSON.parseObject(deepseekResponse); List choices = responseJson.getJSONArray("choices").toJavaList(JSONObject.class); - + if (choices != null && !choices.isEmpty()) { JSONObject latestChoice = choices.get(0); JSONObject message = latestChoice.getJSONObject("message"); - return EscapeCharacterUtils.removeEscapeCharacters( message.getString("content"));//去除转义字符 + return EscapeCharacterUtils.removeEscapeCharacters(message.getString("content"));//去除转义字符 } } catch (Exception e) { log.error("调用Deepseek API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse); } - + return null; } - + @Override public String genIntroduceByDescription(String description) { log.info("根据技能描述生成技能介绍请求: {}", description); String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions"; - + String systemContent = "你是一个专业的AI技能设计助手。我会给你提供一个skill的描述,请你基于这个描述,生成一段详细的技能介绍,包括技能的作用、能够解决的问题、使用场景等,输出一段完整的描述文本"; SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, description, 0.3, 500, "text"); String deepseekResponse = ""; @@ -435,21 +459,21 @@ public class SkillGenServiceImpl implements SkillGenService { // 发送HTTP请求到deepseek API deepseekResponse = HttpUtil.sendPostRequest(url, skillRequest, deepSeekConfig.getApiKey(), null); log.info("Deepseek API响应: {}", deepseekResponse); - + // 解析返回结果 JSONObject responseJson = JSON.parseObject(deepseekResponse); List choices = responseJson.getJSONArray("choices").toJavaList(JSONObject.class); - + if (choices != null && !choices.isEmpty()) { JSONObject latestChoice = choices.get(0); JSONObject message = latestChoice.getJSONObject("message"); - return EscapeCharacterUtils.removeEscapeCharacters( message.getString("content"));//去除转义字符 + return EscapeCharacterUtils.removeEscapeCharacters(message.getString("content"));//去除转义字符 } } catch (Exception e) { log.error("调用Deepseek API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse); } - + return null; } @@ -468,7 +492,7 @@ public class SkillGenServiceImpl implements SkillGenService { StringBuilder tagsList = new StringBuilder(); for (int i = 0; i < tags.size(); i++) { CmsTag tag = tags.get(i); - tagsList.append(tag.getTagId()+"."+tag.getTagName()); + tagsList.append(tag.getTagId() + "." + tag.getTagName()); if (i < tags.size() - 1) { tagsList.append(","); } @@ -495,10 +519,10 @@ public class SkillGenServiceImpl implements SkillGenService { 请将最终结果以JSON格式输出,JSON结构必须包含:name(技能名称),description(技能描述)、introduce(功能介绍)、tagList(标签列表)、content(完整YAML格式技能包内容),无需额外说明,仅输出符合要求的JSON内容。 """; systemContent = systemContent.replace("TAG_LIST", tagsList.toString()); - + // 构建用户消息内容 String userContent = skillUrl; - + // 创建技能请求 SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, userContent, 0.5, 8192, "json_object"); @@ -507,31 +531,31 @@ public class SkillGenServiceImpl implements SkillGenService { // 发送HTTP请求到DeepSeek API deepseekResponse = HttpUtil.sendPostRequest(url, skillRequest, deepSeekConfig.getApiKey(), null); log.info("Deepseek API响应: {}", deepseekResponse); - + // 解析返回结果 JSONObject responseJson = JSON.parseObject(deepseekResponse); List choices = responseJson.getJSONArray("choices").toJavaList(JSONObject.class); - + if (choices != null && !choices.isEmpty()) { // 获取最新的choice JSONObject latestChoice = choices.get(0); JSONObject message = latestChoice.getJSONObject("message"); String content = message.getString("content"); - + // 解析content中的JSON JSONObject skillJson = JSON.parseObject(content); - + // 构建技能生成请求 SkillGenRequest request = new SkillGenRequest(); request.setName(skillJson.getString("name")); request.setDescription(skillJson.getString("description")); request.setIntroduce(skillJson.getString("introduce")); request.setTags(skillJson.getJSONArray("tagList").toJavaList(String.class)); - + // 生成CmsContent对象 CmsContent cmsContent = getCmsContent(request, skillJson.getString("content"), StpUtil.getLoginIdAsLong(), ""); List list = tags.stream().filter(tag -> tag.getTagId() == Long.parseLong(cmsContent.getTags().split(",")[0])).toList(); - if (CollectionUtil.isNotEmpty( list)){ + if (CollectionUtil.isNotEmpty(list)) { cmsContent.setIcon(list.get(0).getIcon()); } // 保存到数据库 @@ -540,16 +564,16 @@ public class SkillGenServiceImpl implements SkillGenService { } } catch (Exception e) { log.error("调用Deepseek API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse); } - + return null; } - + @Override public CmsContent uploadSkillV2(byte[] fileBytes, String fileName) { log.info("上传本地技能压缩包请求: {}", fileName); - + try { // 创建临时文件 String fileExtension = ""; @@ -563,7 +587,7 @@ public class SkillGenServiceImpl implements SkillGenService { try (FileOutputStream fos = new FileOutputStream(tempFile)) { fos.write(fileBytes); } - + // 生成默认参数 String author = StpUtil.getLoginIdAsString(); // 从文件名中提取技能名称 @@ -574,11 +598,11 @@ public class SkillGenServiceImpl implements SkillGenService { defaultSkillName = fileName.substring(0, dotIndex); } } - + // 1. 提取压缩包中的skillMdText Map skillInfo = com.kexue.skills.common.util.SkillZipParser.extractSkillMdText(tempFile.getAbsolutePath(), defaultSkillName); String skillMdText = (String) skillInfo.get("skillMdText"); - + // 从数据库中读取cms_tag表的标签信息 CmsTagDto tagDto = new CmsTagDto(); tagDto.setDeleteFlag(0); @@ -600,11 +624,11 @@ public class SkillGenServiceImpl implements SkillGenService { // 3. 使用SkillGenRequest中的信息生成yaml String yamlContent = com.kexue.skills.common.util.SkillZipParser.generateYamlFromSkillInfo( - tempFile.getAbsolutePath(), - author, - request.getName(), - request.getDescription(), - request.getTags() + tempFile.getAbsolutePath(), + author, + request.getName(), + request.getDescription(), + request.getTags() ); log.info("解析出skill 压缩包内容:{}", yamlContent); @@ -620,7 +644,7 @@ public class SkillGenServiceImpl implements SkillGenService { } long finalL = l; List list = tags.stream().filter(tag -> tag.getTagId() == finalL).toList(); - if (CollectionUtil.isNotEmpty(list)){ + if (CollectionUtil.isNotEmpty(list)) { cmsContent.setIcon(list.get(0).getIcon()); } } @@ -628,7 +652,7 @@ public class SkillGenServiceImpl implements SkillGenService { // cmsContentMapper.insert(cmsContent); // 删除临时文件 tempFile.delete(); - + return cmsContent; } catch (Exception e) { log.error("上传本地技能压缩包失败: {}", e.getMessage(), e); @@ -642,7 +666,7 @@ public class SkillGenServiceImpl implements SkillGenService { try { Map yamlMap = YamlToMapUtil.yamlTextToMap(yamlContent); - Map packageMap = (Map)yamlMap.get("package"); + Map packageMap = (Map) yamlMap.get("package"); String skillName = (String) packageMap.get("name"); String skillDescription = (String) packageMap.get("description"); List tagList = (List) packageMap.get("tags"); @@ -655,11 +679,11 @@ public class SkillGenServiceImpl implements SkillGenService { request.setIntroduce(genIntroduceByDescription(skillDescription)); return getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), skillIcon); } catch (YAMLException e) { - throw new BizException("yaml解析失败:"+ e.getMessage()); + throw new BizException("yaml解析失败:" + e.getMessage()); } } - private String getDefaultIcon(String tagId){ + private String getDefaultIcon(String tagId) { CmsTagDto tagDto = new CmsTagDto(); tagDto.setDeleteFlag(0); tagDto.setStatus(1); @@ -668,8 +692,8 @@ public class SkillGenServiceImpl implements SkillGenService { String defaultIcon = ""; for (int i = 0; i < tagList.size(); i++) { CmsTag tag = tagList.get(i); - if (tagId.contains(tag.getTagId()+"")) { - if (StringUtil.isEmpty(defaultIcon)){ + if (tagId.contains(tag.getTagId() + "")) { + if (StringUtil.isEmpty(defaultIcon)) { defaultIcon = tag.getIcon(); break; } @@ -678,14 +702,14 @@ public class SkillGenServiceImpl implements SkillGenService { return defaultIcon; } - public SkillGenRequest parseSkillMdText(String skillMdText,List tags) { + public SkillGenRequest parseSkillMdText(String skillMdText, List tags) { String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions"; // 将标签名称拼接成逗号分隔的字符串 StringBuilder tagsList = new StringBuilder(); for (int i = 0; i < tags.size(); i++) { CmsTag tag = tags.get(i); - tagsList.append(tag.getTagId()+"."+tag.getTagName()); + tagsList.append(tag.getTagId() + "." + tag.getTagName()); if (i < tags.size() - 1) { tagsList.append(","); } @@ -742,9 +766,576 @@ public class SkillGenServiceImpl implements SkillGenService { } } catch (Exception e) { log.error("调用Deepseek API失败: {}", e.getMessage(), e); - throw new BizException("调用Deepseek API失败:"+ e.getMessage()+" Deepseek API响应:"+ deepseekResponse); + throw new BizException("调用Deepseek API失败:" + e.getMessage() + " Deepseek API响应:" + deepseekResponse); } return null; } -} + + + @Override + public CmsContent uploadSkillV4(byte[] fileBytes, String fileName) { + log.info("上传技能压缩包V4请求: {}", fileName); + + try { + // 1. 从文件名提取技能名称 + String skillName = extractSkillName(fileName); + + // 2. 解析压缩包,构建目录树结构 + List structureNodes = parseArchiveToStructure(fileBytes, fileName); + + // 3. 从SKILL.md或README.md中提取描述信息(如果存在) + String description = extractDescriptionFromStructure(structureNodes); + if (description == null || description.isEmpty()) { + description = "AI生成的技能包"; + } + + // 4. 提取标签(如果没有则使用默认标签) + List tags = extractTagsFromStructure(structureNodes); + if (tags == null || tags.isEmpty()) { + tags = Arrays.asList("通用"); + } + + // 5. 构建packageInfo + SkillPackageInfoDto packageInfo = new SkillPackageInfoDto(); + packageInfo.setName(skillName); + packageInfo.setVersion("1.0.0"); + packageInfo.setDescription(description); + packageInfo.setAuthor("AI技能生成助手"); + packageInfo.setCreated(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + packageInfo.setTags(tags); + packageInfo.setStructure(structureNodes); + + // 6. 生成YAML内容 + String yamlContent = generateYamlContent(packageInfo); + log.info("生成的YAML内容:\n{}", yamlContent); + + // 7. 构建SkillGenRequest + SkillGenRequest request = new SkillGenRequest(); + request.setName(skillName); + request.setDescription(description); + // request.setIntroduce(genIntroduceByDescription(description)); + request.setTags(tags); + + // 8. 生成CmsContent + CmsContent cmsContent = getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), ""); + + // 9. 设置图标 + setSkillIcon(cmsContent, tags); + + cmsContentMapper.insert(cmsContent); + + return cmsContent; + } catch (Exception e) { + log.error("上传技能压缩包V4失败: {}", e.getMessage(), e); + throw new BizException("上传技能压缩包V4失败:" + e.getMessage()); + } + } + + /** + * 解析压缩包为目录树结构 + */ + private List parseArchiveToStructure(byte[] fileBytes, String fileName) { + Map fileMap = new HashMap<>(); + + // 解压文件 + if (fileName.toLowerCase().endsWith(".zip")) { + extractZipFiles(fileBytes, fileMap); + } else if (fileName.toLowerCase().endsWith(".rar")) { + extractRarFiles(fileBytes, fileMap); + } else { + throw new BizException("不支持的压缩包格式,仅支持 zip 或 rar"); + } + + // 构建目录树 + return buildDirectoryTree(fileMap); + } + + /** + * 构建目录树结构 + */ + private List buildDirectoryTree(Map fileMap) { + log.info("开始构建目录树,文件数量: {}", fileMap.size()); + + // 收集所有唯一的目录路径 + Set directorySet = new TreeSet<>(); + for (String filePath : fileMap.keySet()) { + String normalizedPath = filePath.replace("\\", "/"); + // 从文件路径提取所有父目录 + int lastSlashIndex = normalizedPath.lastIndexOf("/"); + while (lastSlashIndex > 0) { + String parentDir = normalizedPath.substring(0, lastSlashIndex); + directorySet.add(parentDir); + lastSlashIndex = parentDir.lastIndexOf("/"); + } + } + + log.info("检测到目录: {}", directorySet); + + // 创建节点映射:path -> node + Map pathToNodeMap = new HashMap<>(); + + // 先创建所有目录节点 + for (String dirPath : directorySet) { + String[] parts = dirPath.split("/"); + String dirName = parts[parts.length - 1]; + String parentPath = dirPath.contains("/") ? dirPath.substring(0, dirPath.lastIndexOf("/")) : ""; + + SkillStructureNodeDto dirNode = new SkillStructureNodeDto(); + dirNode.setName(dirName); + dirNode.setType("directory"); + dirNode.setPath(parentPath.isEmpty() ? "/" : parentPath); + dirNode.setFormat("dir"); + dirNode.setDescription(dirName + " 目录"); + dirNode.setChildren(new ArrayList<>()); + + pathToNodeMap.put(dirPath, dirNode); + log.debug("创建目录节点: name={}, path={}", dirName, dirNode.getPath()); + } + + // 将目录节点添加到父节点 + List rootChildren = new ArrayList<>(); + for (Map.Entry entry : pathToNodeMap.entrySet()) { + String dirPath = entry.getKey(); + SkillStructureNodeDto node = entry.getValue(); + + String parentPath = dirPath.contains("/") ? dirPath.substring(0, dirPath.lastIndexOf("/")) : ""; + if (parentPath.isEmpty()) { + // 一级目录,直接添加到根节点 + rootChildren.add(node); + } else { + // 找到父目录并添加 + SkillStructureNodeDto parentNode = pathToNodeMap.get(parentPath); + if (parentNode != null && parentNode.getChildren() != null) { + parentNode.getChildren().add(node); + } else { + log.warn("找不到父目录: {}, 将{}添加到根节点", parentPath, dirPath); + rootChildren.add(node); + } + } + } + + // 再处理所有文件 + for (Map.Entry entry : fileMap.entrySet()) { + String filePath = entry.getKey().replace("\\", "/"); + if (filePath.endsWith("/")) { + continue; // 跳过目录条目 + } + + int lastSlashIndex = filePath.lastIndexOf("/"); + String fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath; + String parentPath = lastSlashIndex >= 0 ? filePath.substring(0, lastSlashIndex) : ""; + + SkillStructureNodeDto fileNode = new SkillStructureNodeDto(); + fileNode.setName(fileName); + fileNode.setType("file"); + fileNode.setPath(parentPath.isEmpty() ? "/" : parentPath); + fileNode.setFormat(getFileFormat(fileName)); + fileNode.setDescription(fileName + " 文件"); + + // 读取文件内容 + try { + String content = new String(entry.getValue(), StandardCharsets.UTF_8); + fileNode.setContent(content); + } catch (Exception e) { + log.warn("读取文件内容失败: {}", filePath, e); + fileNode.setContent(""); + } + + // 添加到父节点 + if (parentPath.isEmpty()) { + // 根目录下的文件 + rootChildren.add(fileNode); + } else { + SkillStructureNodeDto parentNode = pathToNodeMap.get(parentPath); + if (parentNode != null && parentNode.getChildren() != null) { + parentNode.getChildren().add(fileNode); + } else { + log.warn("找不到文件的父目录: {}, 将{}添加到根节点", parentPath, filePath); + rootChildren.add(fileNode); + } + } + + log.debug("创建文件节点: name={}, path={}", fileName, fileNode.getPath()); + } + + // 删除:不再强制生成skills.md,保持压缩包原有结构 + // 之前的代码:ensureSkillsMdExists(rootChildren); + + // 如果只有一个根目录节点,将其children提升到根层级(去掉最外层技能包目录) + if (rootChildren.size() == 1 && "directory".equals(rootChildren.get(0).getType())) { + SkillStructureNodeDto rootDir = rootChildren.get(0); + String removedDirName = rootDir.getName(); + log.info("检测到单个根目录节点: {},将其children提升到根层级", removedDirName); + + // 获取其children作为新的根节点 + List newRootChildren = rootDir.getChildren(); + if (newRootChildren != null && !newRootChildren.isEmpty()) { + rootChildren = newRootChildren; + + // 递归更新所有节点的path,去除被移除的目录名前缀 + updatePathsAfterFlattening(rootChildren, removedDirName); + } + } + + log.info("目录树构建完成,根节点数量: {}", rootChildren.size()); + for (SkillStructureNodeDto node : rootChildren) { + log.info(" 根节点: {} ({})", node.getName(), node.getType()); + } + return rootChildren; + } + + /** + * 扁平化后递归更新所有节点的path + */ + private void updatePathsAfterFlattening(List nodes, String removedDirPrefix) { + if (nodes == null || nodes.isEmpty()) { + return; + } + + for (SkillStructureNodeDto node : nodes) { + String oldPath = node.getPath(); + String newPath; + + // 如果path是被移除的目录名,改为 "/" + if (removedDirPrefix.equals(oldPath)) { + newPath = "/"; + } else if (oldPath.startsWith(removedDirPrefix + "/")) { + // 如果被移除目录名是前缀,去掉它 + newPath = "/" + oldPath.substring(removedDirPrefix.length() + 1); + } else { + // 其他情况保持不变 + newPath = oldPath; + } + + node.setPath(newPath); + + // 递归处理子节点 + if ("directory".equals(node.getType()) && node.getChildren() != null) { + updatePathsAfterFlattening(node.getChildren(), removedDirPrefix); + } + } + } + + /** + * 确保根目录下有skills.md文件 + */ + private void ensureSkillsMdExists(List rootChildren) { + boolean hasSkillsMd = false; + for (SkillStructureNodeDto node : rootChildren) { + if ("skills.md".equals(node.getName())) { + hasSkillsMd = true; + break; + } + } + + if (!hasSkillsMd) { + SkillStructureNodeDto skillsMd = new SkillStructureNodeDto(); + skillsMd.setName("skills.md"); + skillsMd.setType("file"); + skillsMd.setPath("/"); + skillsMd.setFormat("markdown"); + skillsMd.setDescription("技能说明文档"); + skillsMd.setContent("# 技能说明\n\n请在此处填写技能的详细说明。\n"); + rootChildren.add(skillsMd); + } + } + + /** + * 从目录结构中提取描述 + */ + private String extractDescriptionFromStructure(List structureNodes) { + // 查找SKILL.md或README.md文件 + for (SkillStructureNodeDto node : structureNodes) { + if ("file".equals(node.getType())) { + String lowerName = node.getName().toLowerCase(); + if ((lowerName.equals("skill.md") || lowerName.equals("readme.md")) && node.getContent() != null) { + // 提取前200个字符作为描述 + String content = node.getContent(); + if (content.length() > 200) { + return content.substring(0, 200); + } + return content; + } + } else if ("directory".equals(node.getType()) && node.getChildren() != null) { + // 递归查找子目录 + String desc = extractDescriptionFromStructure(node.getChildren()); + if (desc != null && !desc.isEmpty()) { + return desc; + } + } + } + return null; + } + + /** + * 从目录结构中提取标签 + */ + private List extractTagsFromStructure(List structureNodes) { + // 这里可以解析SKILL.md中的front matter提取标签 + // 暂时返回空列表,由调用方设置默认值 + return new ArrayList<>(); + } + + /** + * 生成YAML内容 + */ + private String generateYamlContent(SkillPackageInfoDto packageInfo) { + StringBuilder yaml = new StringBuilder(); + yaml.append("package:\n"); + yaml.append(" name: ").append(quoteIfNecessary(packageInfo.getName())).append("\n"); + yaml.append(" version: ").append(quoteIfNecessary(packageInfo.getVersion())).append("\n"); + + // description可能包含特殊字符,使用块标量 + String description = packageInfo.getDescription(); + if (description != null && !description.isEmpty()) { + yaml.append(" description: |\n"); + String[] lines = description.split("\n"); + for (String line : lines) { + yaml.append(" ").append(line).append("\n"); + } + } else { + yaml.append(" description: \"\"\n"); + } + + yaml.append(" author: ").append(quoteIfNecessary(packageInfo.getAuthor())).append("\n"); + yaml.append(" created: ").append(quoteIfNecessary(packageInfo.getCreated())).append("\n"); + + // tags数组 + yaml.append(" tags:\n"); + if (packageInfo.getTags() != null) { + for (String tag : packageInfo.getTags()) { + yaml.append(" - ").append(quoteIfNecessary(tag)).append("\n"); + } + } + + // structure目录树 + yaml.append(" structure:\n"); + if (packageInfo.getStructure() != null) { + appendStructureNodes(yaml, packageInfo.getStructure(), 2); + } + + return yaml.toString(); + } + + /** + * 判断是否需要引号,需要则加双引号 + */ + private String quoteIfNecessary(String value) { + if (value == null) { + return "\"\""; + } + // 如果包含特殊字符或可能产生歧义,加双引号 + if (value.contains(":") || value.contains("#") || value.contains("{") || + value.contains("}") || value.contains("[") || value.contains("]") || + value.contains(",") || value.contains("&") || value.contains("*") || + value.contains("?") || value.contains("|") || value.contains("-") || + value.contains("<") || value.contains(">") || value.contains("=") || + value.contains("!") || value.contains("%") || value.contains("@") || + value.contains("`") || value.startsWith("'") || value.startsWith(" ") || + value.endsWith(" ")) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + return value; + } + + /** + * 递归追加structure节点到YAML + */ + private void appendStructureNodes(StringBuilder yaml, List nodes, int indentLevel) { + if (nodes == null || nodes.isEmpty()) { + return; + } + + String indent = " ".repeat(indentLevel); + + for (SkillStructureNodeDto node : nodes) { + yaml.append(indent).append("- name: ").append(node.getName()).append("\n"); + yaml.append(indent).append(" type: ").append(node.getType()).append("\n"); + yaml.append(indent).append(" path: ").append(node.getPath()).append("\n"); + yaml.append(indent).append(" format: ").append(node.getFormat()).append("\n"); + + if ("file".equals(node.getType())) { + // 文件类型,添加content + if (node.getContent() != null && !node.getContent().isEmpty()) { + String rawContent = node.getContent(); + // 将Tab替换为4个空格(YAML不允许Tab) + rawContent = rawContent.replace("\t", " "); + + // 分割成行 + String[] lines = rawContent.split("\n", -1); + + // 去除末尾的空行 + int lastNonEmpty = lines.length - 1; + while (lastNonEmpty >= 0 && lines[lastNonEmpty].trim().isEmpty()) { + lastNonEmpty--; + } + + if (lastNonEmpty >= 0) { + yaml.append(indent).append(" content: |"); + // 如果原内容有尾随换行,保留一个 + if (lastNonEmpty < lines.length - 1) { + yaml.append("\n"); + } else { + yaml.append("-\n"); // |- 表示去掉末尾换行 + } + + // 输出内容行,每行添加统一的缩进 + for (int i = 0; i <= lastNonEmpty; i++) { + yaml.append(indent).append(" ").append(lines[i]).append("\n"); + } + } + } + } else if ("directory".equals(node.getType())) { + // 目录类型,添加children + if (node.getChildren() != null && !node.getChildren().isEmpty()) { + yaml.append(indent).append(" children:\n"); + appendStructureNodes(yaml, node.getChildren(), indentLevel + 1); + } else { + yaml.append(indent).append(" children: []\n"); + } + } + } + } + + /** + * 转义YAML字符串中的特殊字符 + */ + private String escapeYamlString(String str) { + if (str == null) { + return ""; + } + // 转义双引号、反斜杠等特殊字符 + return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"); + } + + /** + * 获取文件格式 + */ + private String getFileFormat(String fileName) { + if (fileName == null) { + return "unknown"; + } + String lowerName = fileName.toLowerCase(); + if (lowerName.endsWith(".md")) { + return "markdown"; + } else if (lowerName.endsWith(".py")) { + return "python"; + } else if (lowerName.endsWith(".txt")) { + return "text"; + } else if (lowerName.endsWith(".json")) { + return "json"; + } else if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) { + return "yaml"; + } + return "unknown"; + } + + /** + * 设置技能图标 + */ + private void setSkillIcon(CmsContent cmsContent, List tags) { + if (tags == null || tags.isEmpty()) { + return; + } + + try { + String firstTagId = tags.get(0); + CmsTagDto tagDto = new CmsTagDto(); + tagDto.setDeleteFlag(0); + tagDto.setStatus(1); + List tagList = cmsTagService.getList(tagDto); + + for (CmsTag tag : tagList) { + if (firstTagId.contains(tag.getTagId() + "")) { + cmsContent.setIcon(tag.getIcon()); + break; + } + } + } catch (Exception e) { + log.warn("设置技能图标失败: {}", e.getMessage()); + } + } + + /** + * 解压 ZIP 文件 + */ + private void extractZipFiles(byte[] fileBytes, Map fileMap) { + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (!entry.isDirectory()) { + String entryName = entry.getName(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = zis.read(buffer)) > 0) { + baos.write(buffer, 0, len); + } + fileMap.put(entryName, baos.toByteArray()); + } + zis.closeEntry(); + } + } catch (Exception e) { + log.error("解压 ZIP 文件失败: {}", e.getMessage(), e); + throw new BizException("解压 ZIP 文件失败:" + e.getMessage()); + } + } + + /** + * 解压 RAR 文件 + */ + private void extractRarFiles(byte[] fileBytes, Map fileMap) { + File tempFile = null; + try { + // 创建临时文件 + tempFile = File.createTempFile("skill_rar_", ".rar"); + try (FileOutputStream fos = new FileOutputStream(tempFile)) { + fos.write(fileBytes); + } + + // 使用 SevenZipJBinding 解压 RAR + RandomAccessFile randomAccessFile = new RandomAccessFile(tempFile, "r"); + IInArchive archive = SevenZip.openInArchive(null, new RandomAccessFileInStream(randomAccessFile)); + + int itemCount = archive.getNumberOfItems(); + for (int i = 0; i < itemCount; i++) { + Object isFolder = archive.getProperty(i, net.sf.sevenzipjbinding.PropID.IS_FOLDER); + if (!(isFolder instanceof Boolean) || !((Boolean) isFolder)) { + String path = (String) archive.getProperty(i, net.sf.sevenzipjbinding.PropID.PATH); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + archive.extractSlow(i, data -> { + try { + baos.write(data); + return data.length; + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + fileMap.put(path, baos.toByteArray()); + } + } + + archive.close(); + randomAccessFile.close(); + } catch (Exception e) { + log.error("解压 RAR 文件失败: {}", e.getMessage(), e); + throw new BizException("解压 RAR 文件失败:" + e.getMessage()); + } finally { + if (tempFile != null && tempFile.exists()) { + tempFile.delete(); + } + } + } + + /** + * 从文件名提取技能名称 + */ + private String extractSkillName(String fileName) { + if (fileName.contains(".")) { + int dotIndex = fileName.lastIndexOf("."); + return fileName.substring(0, dotIndex); + } + return fileName; + } +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/utils/YamlUtil.java b/src/main/java/com/kexue/skills/utils/YamlUtil.java new file mode 100644 index 0000000..34193f9 --- /dev/null +++ b/src/main/java/com/kexue/skills/utils/YamlUtil.java @@ -0,0 +1,18 @@ +package com.kexue.skills.utils; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +public class YamlUtil { + + /** + * 对象转 YAML 字符串 + */ + public static String toYaml(Object obj) { + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + Yaml yaml = new Yaml(options); + return yaml.dump(obj); + } +} \ No newline at end of file