|
|
@@ -0,0 +1,461 @@
|
|
|
+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.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.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.util.ArrayList;
|
|
|
+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;
|
|
|
+
|
|
|
+ /** 技能来源:用户技能目录(页面可管理) */
|
|
|
+ public static final String SOURCE_USER = "user";
|
|
|
+
|
|
|
+ /** 技能来源:agent 配置的技能仓库(只读) */
|
|
|
+ public static final String SOURCE_REPO = "repo";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 列出目录下的全部技能。
|
|
|
+ *
|
|
|
+ * @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;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 安装一个技能包(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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 卸载技能(删除技能目录)。
|
|
|
+ */
|
|
|
+ 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.warn("读取停用技能失败: {}", 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)");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 读 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)
|
|
|
+ || (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.warn("读取技能定义失败: {} ({})", 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.warn("读取技能附属文件失败: {} ({})", 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.warn("清理临时目录失败: {} ({})", dir, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** SKILL.md frontmatter 里的关键字段 */
|
|
|
+ private static final class Meta {
|
|
|
+ private String name;
|
|
|
+ private String description;
|
|
|
+ }
|
|
|
+}
|