|
|
@@ -1,687 +0,0 @@
|
|
|
-package com.zsjz.ai.module.agent.skill;
|
|
|
-
|
|
|
-import com.zsjz.ai.common.exception.ServerException;
|
|
|
-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 lombok.extern.slf4j.Slf4j;
|
|
|
-import org.springframework.stereotype.Component;
|
|
|
-
|
|
|
-import java.io.ByteArrayInputStream;
|
|
|
-import java.io.IOException;
|
|
|
-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.file.Files;
|
|
|
-import java.nio.file.Path;
|
|
|
-import java.util.ArrayList;
|
|
|
-import java.util.Base64;
|
|
|
-import java.util.Comparator;
|
|
|
-import java.util.List;
|
|
|
-import java.util.Optional;
|
|
|
-import java.util.stream.Stream;
|
|
|
-import java.util.zip.ZipEntry;
|
|
|
-import java.util.zip.ZipInputStream;
|
|
|
-
|
|
|
-/**
|
|
|
- * 技能文件仓库:技能在磁盘上的读写实现(<b>纯文件系统操作,不碰数据库</b>)。
|
|
|
- *
|
|
|
- * <h3>目录约定</h3>
|
|
|
- * <pre>
|
|
|
- * {skillsRoot}/
|
|
|
- * ├── 技能名/SKILL.md 必需:YAML frontmatter(name + description)+ 正文
|
|
|
- * ├── 技能名/references/… 可选:附属文件
|
|
|
- * ├── 技能名/.disabled 存在 = 该技能已停用(由本类维护)
|
|
|
- * └── 其他目录/ 没有 SKILL.md 的目录一律不算技能
|
|
|
- * </pre>
|
|
|
- *
|
|
|
- * <p>与 AgentScope 的 {@code FileSystemSkillRepository} 同构(同样是「一层子目录 + SKILL.md」),
|
|
|
- * 因此把技能直接写进目录即可被 agent 加载,无需注册、无需落库。
|
|
|
- *
|
|
|
- * <h3>为什么单独成类</h3>
|
|
|
- * 这些方法只依赖一个 {@code skillsRoot} 路径,不依赖登录态、数据库与 Spring 上下文,
|
|
|
- * 因此可以直接用临时目录做单元测试(见 {@code SkillStoreTest})。
|
|
|
- */
|
|
|
-@Slf4j
|
|
|
-@Component
|
|
|
-public class SkillStore {
|
|
|
-
|
|
|
- /** 技能定义文件名(与 AgentScope 一致) */
|
|
|
- public static final String SKILL_FILE = "SKILL.md";
|
|
|
-
|
|
|
- /** 停用标记文件名:存在即停用 */
|
|
|
- public static final String DISABLED_MARKER = ".disabled";
|
|
|
-
|
|
|
- /** 技能名允许的字符:中英文、数字、下划线、短横线、点(不允许路径分隔符,避免越界) */
|
|
|
- private static final String NAME_PATTERN = "[\\p{IsHan}A-Za-z0-9._-]{1,64}";
|
|
|
-
|
|
|
- /** 单个技能包解压后的体积上限(防 zip 炸弹) */
|
|
|
- private static final long MAX_UNZIP_BYTES = 50L * 1024 * 1024;
|
|
|
-
|
|
|
- /** 单个技能包的条目数上限 */
|
|
|
- private static final int MAX_UNZIP_ENTRIES = 2000;
|
|
|
-
|
|
|
- /** frontmatter 解析的读取上限:技能定义是说明文档,超过这个体积的多半不是技能 */
|
|
|
- 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";
|
|
|
-
|
|
|
- /** 技能来源:agent 配置的技能仓库(只读) */
|
|
|
- public static final String SOURCE_REPO = "repo";
|
|
|
-
|
|
|
- /**
|
|
|
- * 附属文件写入条目:相对技能目录的路径 + 文本内容。
|
|
|
- *
|
|
|
- * <p>只支持文本(scripts/references/testcases 里的脚本与文档,以及文本类 assets);
|
|
|
- * 二进制资源请走 zip 安装,避免 JSON 里塞 base64 把请求体撑爆。
|
|
|
- */
|
|
|
- public record FileEntry(String path, String content) {
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 列出目录下的全部技能。
|
|
|
- *
|
|
|
- * @param skillsRoot 技能根目录(不存在时返回空列表,不抛异常)
|
|
|
- * @param source 来源标签,写入返回的 VO
|
|
|
- * @param managed 是否可管理(可卸载 / 可启停)
|
|
|
- * @param markerIsOff 停用标记是否生效(配置仓库只读,标记不由我们维护)
|
|
|
- */
|
|
|
- public List<SkillVO> list(Path skillsRoot, String source, boolean managed, boolean markerIsOff) {
|
|
|
- List<SkillVO> result = new ArrayList<>();
|
|
|
- if (skillsRoot == null || !Files.isDirectory(skillsRoot)) {
|
|
|
- return result;
|
|
|
- }
|
|
|
- try (Stream<Path> dirs = Files.list(skillsRoot)) {
|
|
|
- for (Path dir : (Iterable<Path>) dirs.sorted()::iterator) {
|
|
|
- Path skillMd = dir.resolve(SKILL_FILE);
|
|
|
- if (!Files.isDirectory(dir) || !Files.isRegularFile(skillMd)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- Meta meta = readMeta(skillMd);
|
|
|
- SkillVO vo = new SkillVO();
|
|
|
- vo.setName(meta.name != null ? meta.name : dir.getFileName().toString());
|
|
|
- vo.setDescription(meta.description);
|
|
|
- vo.setSource(source);
|
|
|
- vo.setManaged(managed);
|
|
|
- vo.setEnabled(!(markerIsOff && Files.exists(dir.resolve(DISABLED_MARKER))));
|
|
|
- vo.setPath(dir.toAbsolutePath().toString());
|
|
|
- result.add(vo);
|
|
|
- }
|
|
|
- } catch (IOException e) {
|
|
|
- throw new ServerException(500, "读取技能目录失败: " + e.getMessage());
|
|
|
- }
|
|
|
- result.sort(Comparator.comparing(SkillVO::getName, Comparator.nullsLast(String::compareTo)));
|
|
|
- return result;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 读取技能详情(SKILL.md 正文 + 附属文件清单)。
|
|
|
- */
|
|
|
- public SkillDetailVO read(Path skillsRoot, String name, String source, boolean managed, boolean markerIsOff) {
|
|
|
- Path dir = resolveDir(skillsRoot, name)
|
|
|
- .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
|
|
|
- Path skillMd = dir.resolve(SKILL_FILE);
|
|
|
- Meta meta = readMeta(skillMd);
|
|
|
-
|
|
|
- SkillDetailVO vo = new SkillDetailVO();
|
|
|
- vo.setName(meta.name != null ? meta.name : dir.getFileName().toString());
|
|
|
- vo.setDescription(meta.description);
|
|
|
- vo.setSource(source);
|
|
|
- vo.setManaged(managed);
|
|
|
- vo.setEnabled(!(markerIsOff && Files.exists(dir.resolve(DISABLED_MARKER))));
|
|
|
- vo.setPath(dir.toAbsolutePath().toString());
|
|
|
- vo.setContent(readText(skillMd));
|
|
|
- vo.setFiles(listFiles(dir));
|
|
|
- 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 字节);返回安装后的技能名。
|
|
|
- *
|
|
|
- * <p>校验顺序:解压到临时目录 → 定位唯一技能根(含 SKILL.md 的目录)→ 校验技能名
|
|
|
- * → 落到 {@code {skillsRoot}/{技能名}}。同名技能存在时,只有 {@code overwrite=true} 才覆盖。
|
|
|
- *
|
|
|
- * @param zipBytes zip 包内容(由调用方从上传流读出,避免本类依赖 web 层)
|
|
|
- * @param skillsRoot 用户技能根目录(按需创建)
|
|
|
- * @param overwrite 同名技能是否覆盖
|
|
|
- */
|
|
|
- public String install(byte[] zipBytes, Path skillsRoot, boolean overwrite) {
|
|
|
- if (zipBytes == null || zipBytes.length == 0) {
|
|
|
- throw new ServerException(400, "上传的技能包为空");
|
|
|
- }
|
|
|
- Path tempDir;
|
|
|
- try {
|
|
|
- tempDir = Files.createTempDirectory("skill-install-");
|
|
|
- } catch (IOException e) {
|
|
|
- throw new ServerException(500, "创建临时目录失败: " + e.getMessage());
|
|
|
- }
|
|
|
- try {
|
|
|
- unzip(zipBytes, tempDir);
|
|
|
- List<Path> roots = findSkillRoots(tempDir);
|
|
|
- if (roots.isEmpty()) {
|
|
|
- throw new ServerException(400, "压缩包里没有 " + SKILL_FILE + ",不是有效的技能包");
|
|
|
- }
|
|
|
- if (roots.size() > 1) {
|
|
|
- throw new ServerException(400,
|
|
|
- "一个压缩包只能包含一个技能(发现 " + roots.size() + " 个 " + SKILL_FILE + ")");
|
|
|
- }
|
|
|
- Path skillRoot = roots.get(0);
|
|
|
- Path skillMd = skillRoot.resolve(SKILL_FILE);
|
|
|
- Meta meta = readMeta(skillMd);
|
|
|
- String name = meta.name != null ? meta.name : skillRoot.getFileName().toString();
|
|
|
- validateName(name);
|
|
|
-
|
|
|
- Files.createDirectories(skillsRoot);
|
|
|
- Path target = skillsRoot.resolve(name).normalize();
|
|
|
- if (!target.startsWith(skillsRoot.normalize())) {
|
|
|
- throw new ServerException(400, "技能名非法: " + name);
|
|
|
- }
|
|
|
- if (Files.exists(target)) {
|
|
|
- if (!overwrite) {
|
|
|
- throw new ServerException(400, "同名技能已存在: " + name + "(如需替换请勾选覆盖)");
|
|
|
- }
|
|
|
- deleteDirectory(target);
|
|
|
- }
|
|
|
- copyDirectory(skillRoot, target);
|
|
|
- log.info("技能已安装: {} -> {}", name, target);
|
|
|
- return name;
|
|
|
- } catch (IOException e) {
|
|
|
- throw new ServerException(500, "安装技能失败: " + e.getMessage());
|
|
|
- } finally {
|
|
|
- deleteQuietly(tempDir);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 新建/覆盖一个技能:直接写 {@code SKILL.md}(frontmatter name/description + 正文),
|
|
|
- * 免去打 zip 包。供「专家弹窗 → 添加/修改技能」使用。
|
|
|
- *
|
|
|
- * @param overwrite false = 同名已存在则报错(新建);true = 覆盖(修改)
|
|
|
- * @return 技能名
|
|
|
- */
|
|
|
- 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);
|
|
|
- if (content == null || content.isBlank()) {
|
|
|
- throw new ServerException(400, "技能正文不能为空");
|
|
|
- }
|
|
|
- try {
|
|
|
- Files.createDirectories(skillsRoot);
|
|
|
- Path dir = skillsRoot.resolve(name).normalize();
|
|
|
- if (!dir.startsWith(skillsRoot.normalize())) {
|
|
|
- throw new ServerException(400, "技能名非法: " + name);
|
|
|
- }
|
|
|
- if (Files.exists(dir) && !overwrite) {
|
|
|
- throw new ServerException(400, "同名技能已存在: " + name);
|
|
|
- }
|
|
|
- Files.createDirectories(dir);
|
|
|
- Files.writeString(dir.resolve(SKILL_FILE),
|
|
|
- buildSkillMd(name, description, content), StandardCharsets.UTF_8);
|
|
|
-
|
|
|
- // 先删后写:同一路径既在 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;
|
|
|
- } catch (IOException e) {
|
|
|
- throw new ServerException(500, "保存技能失败: " + e.getMessage());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 组装 SKILL.md:frontmatter(name + description)+ 正文。
|
|
|
- * 正文若自带 frontmatter(用户粘贴完整 SKILL.md)则原样保留。
|
|
|
- */
|
|
|
- private static String buildSkillMd(String name, String description, String content) {
|
|
|
- String body = content.strip();
|
|
|
- if (body.startsWith("---")) {
|
|
|
- return body;
|
|
|
- }
|
|
|
- String desc = description == null ? ""
|
|
|
- : description.strip().replace("\r", " ").replace("\n", " ");
|
|
|
- // description 统一用双引号包裹并转义,避免 YAML 特殊字符(: # 等)破坏 frontmatter
|
|
|
- String quoted = "\"" + desc.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
|
|
- return "---\nname: " + name + "\ndescription: " + quoted
|
|
|
- + "\n---\n\n" + body + "\n";
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 卸载技能(删除技能目录)。
|
|
|
- */
|
|
|
- public void delete(Path skillsRoot, String name) {
|
|
|
- Path dir = resolveDir(skillsRoot, name)
|
|
|
- .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
|
|
|
- try {
|
|
|
- deleteDirectory(dir);
|
|
|
- log.info("技能已卸载: {} ({})", name, dir);
|
|
|
- } catch (IOException e) {
|
|
|
- throw new ServerException(500, "卸载技能失败: " + e.getMessage());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 启停技能:启用 = 删除 {@value #DISABLED_MARKER} 标记,停用 = 创建标记。
|
|
|
- */
|
|
|
- public void toggle(Path skillsRoot, String name, boolean enabled) {
|
|
|
- Path dir = resolveDir(skillsRoot, name)
|
|
|
- .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
|
|
|
- Path marker = dir.resolve(DISABLED_MARKER);
|
|
|
- try {
|
|
|
- if (enabled) {
|
|
|
- Files.deleteIfExists(marker);
|
|
|
- } else if (!Files.exists(marker)) {
|
|
|
- Files.createFile(marker);
|
|
|
- }
|
|
|
- } catch (IOException e) {
|
|
|
- throw new ServerException(500, "更新技能状态失败: " + e.getMessage());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 已停用的技能名(取 frontmatter 的 name,缺失时退回目录名)——供对话链路挂技能过滤器。
|
|
|
- */
|
|
|
- public List<String> disabledNames(Path skillsRoot) {
|
|
|
- List<String> names = new ArrayList<>();
|
|
|
- if (skillsRoot == null || !Files.isDirectory(skillsRoot)) {
|
|
|
- return names;
|
|
|
- }
|
|
|
- try (Stream<Path> dirs = Files.list(skillsRoot)) {
|
|
|
- for (Path dir : (Iterable<Path>) dirs::iterator) {
|
|
|
- Path skillMd = dir.resolve(SKILL_FILE);
|
|
|
- if (!Files.isDirectory(dir) || !Files.isRegularFile(skillMd)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- if (!Files.exists(dir.resolve(DISABLED_MARKER))) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- Meta meta = readMeta(skillMd);
|
|
|
- names.add(meta.name != null ? meta.name : dir.getFileName().toString());
|
|
|
- }
|
|
|
- } catch (IOException e) {
|
|
|
- log.error("读取停用技能失败: {}", e.getMessage());
|
|
|
- }
|
|
|
- return names;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 按技能名定位技能目录:先比目录名,再比 SKILL.md frontmatter 里的 name。
|
|
|
- */
|
|
|
- public Optional<Path> resolveDir(Path skillsRoot, String name) {
|
|
|
- if (skillsRoot == null || name == null || name.isBlank() || !Files.isDirectory(skillsRoot)) {
|
|
|
- return Optional.empty();
|
|
|
- }
|
|
|
- Path direct = skillsRoot.resolve(name).normalize();
|
|
|
- if (direct.startsWith(skillsRoot.normalize())
|
|
|
- && Files.isRegularFile(direct.resolve(SKILL_FILE))) {
|
|
|
- return Optional.of(direct);
|
|
|
- }
|
|
|
- try (Stream<Path> dirs = Files.list(skillsRoot)) {
|
|
|
- return dirs.filter(Files::isDirectory)
|
|
|
- .filter(dir -> Files.isRegularFile(dir.resolve(SKILL_FILE)))
|
|
|
- .filter(dir -> {
|
|
|
- Meta meta = readMeta(dir.resolve(SKILL_FILE));
|
|
|
- return name.equals(meta.name);
|
|
|
- })
|
|
|
- .findFirst();
|
|
|
- } catch (IOException e) {
|
|
|
- return Optional.empty();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /* ------------------------------------------------------------------ *
|
|
|
- * 内部实现
|
|
|
- * ------------------------------------------------------------------ */
|
|
|
-
|
|
|
- /**
|
|
|
- * 解压 zip 到目标目录。
|
|
|
- *
|
|
|
- * <p>自己实现而不是用工具类,是因为**路径穿越必须在这里挡住**:条目名里的 {@code ../}
|
|
|
- * 或绝对路径会让文件落到技能目录之外。另外顺手挡一下 zip 炸弹(体积 / 条目数上限)。
|
|
|
- */
|
|
|
- private void unzip(byte[] zipBytes, Path targetDir) throws IOException {
|
|
|
- long total = 0;
|
|
|
- int entries = 0;
|
|
|
- byte[] buffer = new byte[8192];
|
|
|
- try (InputStream in = new ByteArrayInputStream(zipBytes);
|
|
|
- ZipInputStream zis = new ZipInputStream(in, StandardCharsets.UTF_8)) {
|
|
|
- ZipEntry entry;
|
|
|
- while ((entry = zis.getNextEntry()) != null) {
|
|
|
- if (entry.isDirectory()) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- entries++;
|
|
|
- if (entries > MAX_UNZIP_ENTRIES) {
|
|
|
- throw new ServerException(400, "压缩包条目过多,已中止");
|
|
|
- }
|
|
|
- Path out = targetDir.resolve(entry.getName()).normalize();
|
|
|
- if (!out.startsWith(targetDir.normalize())) {
|
|
|
- throw new ServerException(400, "压缩包包含非法路径: " + entry.getName());
|
|
|
- }
|
|
|
- Files.createDirectories(out.getParent());
|
|
|
- long written = 0;
|
|
|
- try (var os = Files.newOutputStream(out)) {
|
|
|
- int len;
|
|
|
- while ((len = zis.read(buffer)) > 0) {
|
|
|
- written += len;
|
|
|
- total += len;
|
|
|
- if (total > MAX_UNZIP_BYTES) {
|
|
|
- throw new ServerException(400, "压缩包解压后体积过大,已中止");
|
|
|
- }
|
|
|
- os.write(buffer, 0, len);
|
|
|
- }
|
|
|
- }
|
|
|
- if (written == 0) {
|
|
|
- Files.write(out, new byte[0]);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 找出压缩包里含 SKILL.md 的目录(最多留意两层:包内直接是技能目录,或多套了一层包装目录)。
|
|
|
- */
|
|
|
- private List<Path> findSkillRoots(Path tempDir) throws IOException {
|
|
|
- List<Path> roots = new ArrayList<>();
|
|
|
- try (Stream<Path> walk = Files.walk(tempDir, 3)) {
|
|
|
- for (Path p : (Iterable<Path>) walk::iterator) {
|
|
|
- if (Files.isDirectory(p) && Files.isRegularFile(p.resolve(SKILL_FILE))) {
|
|
|
- roots.add(p);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- // 嵌套情况(技能目录里还有技能)只保留最外层,避免被当成多个技能
|
|
|
- List<Path> outer = new ArrayList<>();
|
|
|
- for (Path p : roots) {
|
|
|
- boolean nested = outer.stream().anyMatch(parent -> p.startsWith(parent));
|
|
|
- if (!nested) {
|
|
|
- outer.add(p);
|
|
|
- }
|
|
|
- }
|
|
|
- return outer;
|
|
|
- }
|
|
|
-
|
|
|
- private void validateName(String name) {
|
|
|
- if (name == null || name.isBlank() || !name.matches(NAME_PATTERN)) {
|
|
|
- throw new ServerException(400,
|
|
|
- "技能名非法: " + name + "(仅允许中英文、数字、下划线、短横线与点,长度 1~64)");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 把「相对技能目录的路径」解析成绝对路径,并挡住路径穿越。
|
|
|
- *
|
|
|
- * <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)。
|
|
|
- *
|
|
|
- * <p>技能定义的 frontmatter 就这两个必需字段(AgentScope 也是这么约定),
|
|
|
- * 因此这里手写解析,不引入 YAML 依赖去做通用解析。
|
|
|
- */
|
|
|
- private Meta readMeta(Path skillMd) {
|
|
|
- Meta meta = new Meta();
|
|
|
- String text = readText(skillMd);
|
|
|
- if (text == null || !text.startsWith("---")) {
|
|
|
- return meta;
|
|
|
- }
|
|
|
- int end = text.indexOf("\n---", 3);
|
|
|
- String frontmatter = end > 0 ? text.substring(3, end) : "";
|
|
|
- for (String rawLine : frontmatter.split("\n")) {
|
|
|
- String line = rawLine.strip();
|
|
|
- if (line.startsWith("name:")) {
|
|
|
- meta.name = unquote(line.substring("name:".length()).strip());
|
|
|
- } else if (line.startsWith("description:")) {
|
|
|
- meta.description = unquote(line.substring("description:".length()).strip());
|
|
|
- }
|
|
|
- }
|
|
|
- return meta;
|
|
|
- }
|
|
|
-
|
|
|
- private static String unquote(String value) {
|
|
|
- if (value == null || value.isEmpty()) {
|
|
|
- return value;
|
|
|
- }
|
|
|
- if (value.startsWith("\"") && value.endsWith("\"") && value.length() > 1) {
|
|
|
- // 双引号写入时做过转义(buildSkillMd),读回要还原,保证编辑往返一致
|
|
|
- return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\");
|
|
|
- }
|
|
|
- if (value.startsWith("'") && value.endsWith("'") && value.length() > 1) {
|
|
|
- return value.substring(1, value.length() - 1);
|
|
|
- }
|
|
|
- return value;
|
|
|
- }
|
|
|
-
|
|
|
- private String readText(Path file) {
|
|
|
- try {
|
|
|
- if (!Files.isRegularFile(file) || Files.size(file) > MAX_SKILL_MD_BYTES) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- return Files.readString(file, StandardCharsets.UTF_8);
|
|
|
- } catch (IOException e) {
|
|
|
- log.error("读取技能定义失败: {} ({})", file, e.getMessage());
|
|
|
- return null;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private List<SkillDetailVO.FileItem> listFiles(Path skillDir) {
|
|
|
- List<SkillDetailVO.FileItem> files = new ArrayList<>();
|
|
|
- try (Stream<Path> walk = Files.walk(skillDir)) {
|
|
|
- for (Path p : (Iterable<Path>) walk::iterator) {
|
|
|
- if (!Files.isRegularFile(p)) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- String relative = skillDir.relativize(p).toString().replace('\\', '/');
|
|
|
- if (SKILL_FILE.equals(relative) || relative.startsWith(".") || relative.contains("/.")) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- SkillDetailVO.FileItem item = new SkillDetailVO.FileItem();
|
|
|
- item.setPath(relative);
|
|
|
- try {
|
|
|
- item.setSize(Files.size(p));
|
|
|
- } catch (IOException ignored) {
|
|
|
- item.setSize(null);
|
|
|
- }
|
|
|
- files.add(item);
|
|
|
- }
|
|
|
- } catch (IOException e) {
|
|
|
- log.error("读取技能附属文件失败: {} ({})", skillDir, e.getMessage());
|
|
|
- }
|
|
|
- files.sort(Comparator.comparing(SkillDetailVO.FileItem::getPath,
|
|
|
- Comparator.nullsLast(String::compareTo)));
|
|
|
- return files;
|
|
|
- }
|
|
|
-
|
|
|
- private void copyDirectory(Path source, Path target) throws IOException {
|
|
|
- try (Stream<Path> walk = Files.walk(source)) {
|
|
|
- for (Path p : (Iterable<Path>) walk::iterator) {
|
|
|
- Path dest = target.resolve(source.relativize(p).toString());
|
|
|
- if (Files.isDirectory(p)) {
|
|
|
- Files.createDirectories(dest);
|
|
|
- } else {
|
|
|
- Files.createDirectories(dest.getParent());
|
|
|
- Files.copy(p, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private void deleteDirectory(Path dir) throws IOException {
|
|
|
- if (!Files.exists(dir)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- try (Stream<Path> walk = Files.walk(dir)) {
|
|
|
- List<Path> paths = walk.sorted(Comparator.reverseOrder()).toList();
|
|
|
- for (Path p : paths) {
|
|
|
- Files.deleteIfExists(p);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private void deleteQuietly(Path dir) {
|
|
|
- try {
|
|
|
- deleteDirectory(dir);
|
|
|
- } catch (IOException e) {
|
|
|
- log.error("清理临时目录失败: {} ({})", dir, e.getMessage());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /** SKILL.md frontmatter 里的关键字段 */
|
|
|
- private static final class Meta {
|
|
|
- private String name;
|
|
|
- private String description;
|
|
|
- }
|
|
|
-}
|