diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index d72e5e4d..05a63b3f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -114,11 +114,19 @@ public class SkillController { return R.ok(skillRuntimeService.resolveAllSkillsStatus()); } - @Operation(summary = "刷新 active skills 缓存") + @Operation(summary = "刷新 active skills 缓存,resync=true 时同步内置技能到 workspace") @PostMapping("/runtime/refresh") - public R> refreshRuntime() { + public R> refreshRuntime( + @RequestParam(defaultValue = "false") boolean resync) { + List resynced = List.of(); + if (resync) { + resynced = workspaceManager.syncBundledSkills(); + } List skills = skillRuntimeService.refreshActiveSkills(); - return R.ok(Map.of("count", skills.size(), "message", "Active skills refreshed")); + return R.ok(Map.of( + "count", skills.size(), + "message", resync ? "Active skills refreshed with workspace resync" : "Active skills refreshed", + "resynced", resynced)); } // ==================== Workspace API ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 5af9136e..3149c630 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -153,6 +153,9 @@ public class SkillService { skillMapper.updateById(existing); log.info("Updated builtin skill (limited): {}", existing.getName()); + // builtin skill 也同步 workspace SKILL.md + syncSkillContentToWorkspace(existing); + // 刷新 runtime cache if (runtimeService != null) { runtimeService.refreshActiveSkills(); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java index 03b6dea6..90d517c0 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceBootstrapRunner.java @@ -13,7 +13,10 @@ import java.util.List; * Skill 工作区启动初始化 *

* 1. 确保 workspace root 目录存在 - * 2. 将 classpath 下预置技能同步到 workspace(仅首次,不覆盖) + * 2. 将 classpath 下预置技能同步到 workspace + * - 首次:创建并同步 + * - 后续:比对 SKILL.md frontmatter 中的 version 字段, + * bundled version 更高时归档旧版本并覆盖升级 *

* Order(195) — 在 DatabaseBootstrapRunner(200) 之前执行。 * diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index a30c3772..6ccf3173 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -346,11 +346,28 @@ public class SkillWorkspaceManager { Path targetDir = resolveConventionPath(skillName); if (Files.exists(targetDir)) { - log.debug("Bundled skill '{}' already exists at {}, skipping", skillName, targetDir); + // 版本比对:classpath version > workspace version 时覆盖升级 + String bundledVersion = parseFrontmatterVersion(skillMdResource); + Path workspaceMd = targetDir.resolve("SKILL.md"); + String workspaceVersion = Files.exists(workspaceMd) + ? parseFrontmatterVersionFromPath(workspaceMd) : null; + + if (bundledVersion != null && isNewerVersion(bundledVersion, workspaceVersion)) { + log.info("Bundled skill '{}' version {} > workspace version {}, upgrading", + skillName, bundledVersion, workspaceVersion); + archiveWorkspace(skillName); + syncSingleBundledSkill(resolver, bundledPath, skillName, targetDir); + synced.add(skillName); + eventPublisher.publishEvent( + new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, targetDir)); + } else { + log.debug("Bundled skill '{}' workspace version is current (bundled={}, workspace={}), skipping", + skillName, bundledVersion, workspaceVersion); + } continue; } - // 同步该技能目录下的所有文件 + // 目录不存在:首次同步 syncSingleBundledSkill(resolver, bundledPath, skillName, targetDir); synced.add(skillName); log.info("Synced bundled skill '{}' → {}", skillName, targetDir); @@ -433,6 +450,74 @@ public class SkillWorkspaceManager { } } + // ==================== 版本比对 ==================== + + private static final java.util.regex.Pattern VERSION_PATTERN = + java.util.regex.Pattern.compile("^version:\\s*[\"']?([^\"'\\s]+)[\"']?", java.util.regex.Pattern.MULTILINE); + + /** + * 从 classpath Resource (SKILL.md) 的 frontmatter 中提取 version 字段 + */ + private String parseFrontmatterVersion(Resource resource) { + try (InputStream is = resource.getInputStream()) { + String content = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + return extractVersionFromContent(content); + } catch (IOException e) { + return null; + } + } + + /** + * 从文件路径 (SKILL.md) 的 frontmatter 中提取 version 字段 + */ + private String parseFrontmatterVersionFromPath(Path path) { + try { + String content = Files.readString(path); + return extractVersionFromContent(content); + } catch (IOException e) { + return null; + } + } + + private String extractVersionFromContent(String content) { + // 只读 frontmatter 部分(--- 之间) + if (!content.startsWith("---")) return null; + int endIdx = content.indexOf("---", 3); + if (endIdx < 0) return null; + String frontmatter = content.substring(0, endIdx); + var matcher = VERSION_PATTERN.matcher(frontmatter); + return matcher.find() ? matcher.group(1) : null; + } + + /** + * 语义版本比较:newer > older 返回 true + *

+ * 例:1.1.0 > 1.0.0, 2.0.0 > 1.9.9, older=null 视为 0.0.0 + */ + static boolean isNewerVersion(String newer, String older) { + if (newer == null) return false; + if (older == null) return true; + + String[] nParts = newer.split("\\."); + String[] oParts = older.split("\\."); + int len = Math.max(nParts.length, oParts.length); + for (int i = 0; i < len; i++) { + int n = i < nParts.length ? parseSegment(nParts[i]) : 0; + int o = i < oParts.length ? parseSegment(oParts[i]) : 0; + if (n > o) return true; + if (n < o) return false; + } + return false; // equal + } + + private static int parseSegment(String s) { + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + // ==================== 工具方法 ==================== private String sanitizeName(String name) { diff --git a/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md b/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md index 58331865..777163c6 100644 --- a/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/sql_query/SKILL.md @@ -1,5 +1,6 @@ --- name: sql_query +version: "1.1.0" description: "当用户提出数据查询需求(如'查数'、'查一下订单量'、'有多少用户'、'帮我跑个SQL'等),使用数据源工具发现表结构,生成并执行只读 SQL 查询。" dependencies: tools: