|
@@ -2,6 +2,7 @@ package com.zsjz.ai.module.agent.skill;
|
|
|
|
|
|
|
|
import com.zsjz.ai.common.exception.ServerException;
|
|
import com.zsjz.ai.common.exception.ServerException;
|
|
|
import com.zsjz.ai.module.agent.vo.SkillDetailVO;
|
|
import com.zsjz.ai.module.agent.vo.SkillDetailVO;
|
|
|
|
|
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
|
|
|
import com.zsjz.ai.module.agent.vo.SkillVO;
|
|
import com.zsjz.ai.module.agent.vo.SkillVO;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.stereotype.Component;
|
|
import org.springframework.stereotype.Component;
|
|
@@ -9,10 +10,14 @@ import org.springframework.stereotype.Component;
|
|
|
import java.io.ByteArrayInputStream;
|
|
import java.io.ByteArrayInputStream;
|
|
|
import java.io.IOException;
|
|
import java.io.IOException;
|
|
|
import java.io.InputStream;
|
|
import java.io.InputStream;
|
|
|
|
|
+import java.nio.ByteBuffer;
|
|
|
|
|
+import java.nio.charset.CharacterCodingException;
|
|
|
|
|
+import java.nio.charset.CodingErrorAction;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Files;
|
|
|
import java.nio.file.Path;
|
|
import java.nio.file.Path;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
|
|
+import java.util.Base64;
|
|
|
import java.util.Comparator;
|
|
import java.util.Comparator;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
import java.util.Optional;
|
|
import java.util.Optional;
|
|
@@ -61,12 +66,24 @@ public class SkillStore {
|
|
|
/** frontmatter 解析的读取上限:技能定义是说明文档,超过这个体积的多半不是技能 */
|
|
/** frontmatter 解析的读取上限:技能定义是说明文档,超过这个体积的多半不是技能 */
|
|
|
private static final long MAX_SKILL_MD_BYTES = 1024 * 1024;
|
|
private static final long MAX_SKILL_MD_BYTES = 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
+ /** 单个附属文件的预览上限:超过只回体积,不下发内容(图片预览的 base64 也受此约束) */
|
|
|
|
|
+ private static final long MAX_PREVIEW_BYTES = 2L * 1024 * 1024;
|
|
|
|
|
+
|
|
|
/** 技能来源:用户技能目录(页面可管理) */
|
|
/** 技能来源:用户技能目录(页面可管理) */
|
|
|
public static final String SOURCE_USER = "user";
|
|
public static final String SOURCE_USER = "user";
|
|
|
|
|
|
|
|
/** 技能来源:agent 配置的技能仓库(只读) */
|
|
/** 技能来源:agent 配置的技能仓库(只读) */
|
|
|
public static final String SOURCE_REPO = "repo";
|
|
public static final String SOURCE_REPO = "repo";
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 附属文件写入条目:相对技能目录的路径 + 文本内容。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>只支持文本(scripts/references/testcases 里的脚本与文档,以及文本类 assets);
|
|
|
|
|
+ * 二进制资源请走 zip 安装,避免 JSON 里塞 base64 把请求体撑爆。
|
|
|
|
|
+ */
|
|
|
|
|
+ public record FileEntry(String path, String content) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 列出目录下的全部技能。
|
|
* 列出目录下的全部技能。
|
|
|
*
|
|
*
|
|
@@ -124,6 +141,50 @@ public class SkillStore {
|
|
|
return vo;
|
|
return vo;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 读取技能内单个文件的内容 —— 技能详情文件树点开节点时用。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>文本文件返回 {@code content};二进制(图片、压缩包等)返回 {@code base64} 供前端预览;
|
|
|
|
|
+ * 超过 {@value #MAX_PREVIEW_BYTES} 字节只回体积({@code truncated=true}),避免详情接口被大文件拖垮。
|
|
|
|
|
+ * 路径按不可信输入处理,挡住 {@code ..} 与绝对路径。
|
|
|
|
|
+ */
|
|
|
|
|
+ public SkillFileContentVO readFile(Path skillsRoot, String name, String relPath) {
|
|
|
|
|
+ Path dir = resolveDir(skillsRoot, name)
|
|
|
|
|
+ .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
|
|
|
|
|
+ if (relPath == null || relPath.isBlank()) {
|
|
|
|
|
+ throw new ServerException(400, "文件路径不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ Path file = resolveInside(dir, relPath);
|
|
|
|
|
+ if (!Files.isRegularFile(file)) {
|
|
|
|
|
+ throw new ServerException(404, "文件不存在: " + relPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ SkillFileContentVO vo = new SkillFileContentVO();
|
|
|
|
|
+ vo.setPath(dir.relativize(file).toString().replace('\\', '/'));
|
|
|
|
|
+ vo.setBinary(false);
|
|
|
|
|
+ vo.setTruncated(false);
|
|
|
|
|
+ try {
|
|
|
|
|
+ long size = Files.size(file);
|
|
|
|
|
+ vo.setSize(size);
|
|
|
|
|
+ if (size > MAX_PREVIEW_BYTES) {
|
|
|
|
|
+ vo.setBinary(true);
|
|
|
|
|
+ vo.setTruncated(true);
|
|
|
|
|
+ return vo;
|
|
|
|
|
+ }
|
|
|
|
|
+ byte[] bytes = Files.readAllBytes(file);
|
|
|
|
|
+ String text = decodeUtf8(bytes);
|
|
|
|
|
+ if (text != null) {
|
|
|
|
|
+ vo.setContent(text);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ vo.setBinary(true);
|
|
|
|
|
+ vo.setBase64(Base64.getEncoder().encodeToString(bytes));
|
|
|
|
|
+ }
|
|
|
|
|
+ return vo;
|
|
|
|
|
+ } catch (IOException e) {
|
|
|
|
|
+ throw new ServerException(500, "读取文件失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 安装一个技能包(zip 字节);返回安装后的技能名。
|
|
* 安装一个技能包(zip 字节);返回安装后的技能名。
|
|
|
*
|
|
*
|
|
@@ -189,6 +250,23 @@ public class SkillStore {
|
|
|
* @return 技能名
|
|
* @return 技能名
|
|
|
*/
|
|
*/
|
|
|
public String save(Path skillsRoot, String name, String description, String content, boolean overwrite) {
|
|
public String save(Path skillsRoot, String name, String description, String content, boolean overwrite) {
|
|
|
|
|
+ return save(skillsRoot, name, description, content, null, null, overwrite);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 新建/覆盖一个技能(含附属文件)—— 「插件与技能」页新增/编辑技能的落地方法。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>覆盖({@code overwrite=true})时<b>不删除技能目录</b>:只重写 SKILL.md、写入本次提交的
|
|
|
|
|
+ * 附属文件、删除 {@code deletePaths} 指定的文件。这样页面没动过的文件(例如 assets 里的图片,
|
|
|
|
|
+ * 二进制不便在前端往返)会被原样保留 —— 旧实现整目录删重建,编辑一次就会把附属文件全丢掉。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param files 要写入(或覆盖)的附属文件,可为空
|
|
|
|
|
+ * @param deletePaths 要删除的附属文件相对路径,可为空
|
|
|
|
|
+ * @param overwrite false = 同名已存在则报错(新建);true = 覆盖(编辑)
|
|
|
|
|
+ * @return 技能名
|
|
|
|
|
+ */
|
|
|
|
|
+ public String save(Path skillsRoot, String name, String description, String content,
|
|
|
|
|
+ List<FileEntry> files, List<String> deletePaths, boolean overwrite) {
|
|
|
validateName(name);
|
|
validateName(name);
|
|
|
if (content == null || content.isBlank()) {
|
|
if (content == null || content.isBlank()) {
|
|
|
throw new ServerException(400, "技能正文不能为空");
|
|
throw new ServerException(400, "技能正文不能为空");
|
|
@@ -199,16 +277,35 @@ public class SkillStore {
|
|
|
if (!dir.startsWith(skillsRoot.normalize())) {
|
|
if (!dir.startsWith(skillsRoot.normalize())) {
|
|
|
throw new ServerException(400, "技能名非法: " + name);
|
|
throw new ServerException(400, "技能名非法: " + name);
|
|
|
}
|
|
}
|
|
|
- if (Files.exists(dir)) {
|
|
|
|
|
- if (!overwrite) {
|
|
|
|
|
- throw new ServerException(400, "同名技能已存在: " + name);
|
|
|
|
|
- }
|
|
|
|
|
- deleteDirectory(dir);
|
|
|
|
|
|
|
+ if (Files.exists(dir) && !overwrite) {
|
|
|
|
|
+ throw new ServerException(400, "同名技能已存在: " + name);
|
|
|
}
|
|
}
|
|
|
Files.createDirectories(dir);
|
|
Files.createDirectories(dir);
|
|
|
Files.writeString(dir.resolve(SKILL_FILE),
|
|
Files.writeString(dir.resolve(SKILL_FILE),
|
|
|
buildSkillMd(name, description, content), StandardCharsets.UTF_8);
|
|
buildSkillMd(name, description, content), StandardCharsets.UTF_8);
|
|
|
- log.info("技能已{}: {} -> {}", overwrite ? "保存" : "创建", name, dir);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 先删后写:同一路径既在 deletePaths 又在 files 时以 files 为准
|
|
|
|
|
+ for (String rel : nullToEmpty(deletePaths)) {
|
|
|
|
|
+ if (rel == null || rel.isBlank() || SKILL_FILE.equals(normalizeRel(rel))) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ Files.deleteIfExists(resolveInside(dir, rel));
|
|
|
|
|
+ }
|
|
|
|
|
+ for (FileEntry entry : nullToEmpty(files)) {
|
|
|
|
|
+ if (entry == null || entry.path() == null || entry.path().isBlank()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (SKILL_FILE.equals(normalizeRel(entry.path()))) {
|
|
|
|
|
+ throw new ServerException(400, "SKILL.md 请用正文编辑,不能作为附属文件提交");
|
|
|
|
|
+ }
|
|
|
|
|
+ Path target = resolveInside(dir, entry.path());
|
|
|
|
|
+ Files.createDirectories(target.getParent());
|
|
|
|
|
+ Files.writeString(target, entry.content() == null ? "" : entry.content(),
|
|
|
|
|
+ StandardCharsets.UTF_8);
|
|
|
|
|
+ }
|
|
|
|
|
+ pruneEmptyDirs(dir);
|
|
|
|
|
+ log.info("技能已{}: {} -> {}(附属文件 {} 个)", overwrite ? "保存" : "创建", name, dir,
|
|
|
|
|
+ files == null ? 0 : files.size());
|
|
|
return name;
|
|
return name;
|
|
|
} catch (IOException e) {
|
|
} catch (IOException e) {
|
|
|
throw new ServerException(500, "保存技能失败: " + e.getMessage());
|
|
throw new ServerException(500, "保存技能失败: " + e.getMessage());
|
|
@@ -394,6 +491,81 @@ public class SkillStore {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 把「相对技能目录的路径」解析成绝对路径,并挡住路径穿越。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这些路径来自页面输入,必须当作不可信数据 —— 否则 {@code ../../x} 能写到技能目录之外。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static Path resolveInside(Path baseDir, String relPath) {
|
|
|
|
|
+ String rel = normalizeRel(relPath);
|
|
|
|
|
+ if (rel.isEmpty() || rel.startsWith("/") || rel.matches("^[A-Za-z]:.*")) {
|
|
|
|
|
+ throw new ServerException(400, "附属文件路径非法: " + relPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String seg : rel.split("/")) {
|
|
|
|
|
+ if ("..".equals(seg)) {
|
|
|
|
|
+ throw new ServerException(400, "附属文件路径非法: " + relPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Path target = baseDir.resolve(rel).normalize();
|
|
|
|
|
+ if (!target.startsWith(baseDir.normalize())) {
|
|
|
|
|
+ throw new ServerException(400, "附属文件路径非法: " + relPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ return target;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 相对路径统一成「正斜杠、无首尾空白」,便于与 SKILL.md 之类的常量比较 */
|
|
|
|
|
+ private static String normalizeRel(String relPath) {
|
|
|
|
|
+ return relPath == null ? "" : relPath.strip().replace('\\', '/');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static <T> List<T> nullToEmpty(List<T> list) {
|
|
|
|
|
+ return list == null ? List.of() : list;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 删除技能目录下的空目录(删掉附属文件后可能残留空壳,留着会误导「该目录已使用」) */
|
|
|
|
|
+ private void pruneEmptyDirs(Path skillDir) throws IOException {
|
|
|
|
|
+ List<Path> dirs;
|
|
|
|
|
+ try (Stream<Path> walk = Files.walk(skillDir)) {
|
|
|
|
|
+ dirs = walk.filter(Files::isDirectory)
|
|
|
|
|
+ .filter(p -> !p.equals(skillDir))
|
|
|
|
|
+ .sorted(Comparator.reverseOrder())
|
|
|
|
|
+ .toList();
|
|
|
|
|
+ }
|
|
|
|
|
+ for (Path dir : dirs) {
|
|
|
|
|
+ try (Stream<Path> children = Files.list(dir)) {
|
|
|
|
|
+ if (children.findAny().isEmpty()) {
|
|
|
|
|
+ Files.deleteIfExists(dir);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 按 UTF-8 严格解码;含 NUL 字节或非法序列即判定为二进制,返回 null。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>用「能不能解码」而不是扩展名白名单判断:技能包里的脚本没有固定后缀
|
|
|
|
|
+ * (.py/.js/.sh/.md/.csv…),扩展名白名单一定会漏。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String decodeUtf8(byte[] bytes) {
|
|
|
|
|
+ if (bytes.length == 0) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ for (byte b : bytes) {
|
|
|
|
|
+ if (b == 0) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return StandardCharsets.UTF_8.newDecoder()
|
|
|
|
|
+ .onMalformedInput(CodingErrorAction.REPORT)
|
|
|
|
|
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
|
|
|
+ .decode(ByteBuffer.wrap(bytes))
|
|
|
|
|
+ .toString();
|
|
|
|
|
+ } catch (CharacterCodingException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 读 SKILL.md 的 frontmatter(只取 name / description)。
|
|
* 读 SKILL.md 的 frontmatter(只取 name / description)。
|
|
|
*
|
|
*
|