From b3c1f8403d827e59ec247e24cb07229af7ce36af Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 11 Apr 2026 19:29:01 +0800 Subject: [PATCH] feat(skill): add ZIP file import for skill installation --- .../controller/SkillInstallController.java | 28 +++ .../mate/skill/installer/SkillInstaller.java | 84 +++++++++ .../mate/skill/installer/ZipSkillFetcher.java | 161 ++++++++++++++++++ mateclaw-ui/src/api/index.ts | 8 + .../src/components/skill/ImportHubDialog.vue | 112 +++++++++++- mateclaw-ui/src/i18n/locales/en-US.ts | 8 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 8 + 7 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java index 892f0903..91dea8a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java @@ -3,10 +3,14 @@ package vip.mate.skill.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import vip.mate.common.result.R; import vip.mate.skill.installer.SkillInstaller; +import vip.mate.skill.installer.ZipSkillFetcher; import vip.mate.skill.installer.model.*; +import vip.mate.skill.runtime.SkillFrontmatterParser; import java.util.List; import java.util.Map; @@ -26,6 +30,7 @@ import java.util.Map; public class SkillInstallController { private final SkillInstaller skillInstaller; + private final SkillFrontmatterParser frontmatterParser; @Operation(summary = "搜索 ClawHub 市场") @GetMapping("/hub/search") @@ -61,6 +66,29 @@ public class SkillInstallController { return R.ok(); } + @Operation(summary = "上传 ZIP 安装 skill") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R> uploadZip( + @RequestPart("file") MultipartFile zipFile, + @RequestParam(defaultValue = "true") Boolean enable, + @RequestParam(defaultValue = "false") Boolean overwrite, + @RequestParam(required = false) String targetName) { + // 校验文件类型 + String filename = zipFile.getOriginalFilename(); + if (filename == null || !filename.toLowerCase().endsWith(".zip")) { + return R.fail(400, "Only .zip files are accepted"); + } + try { + SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser); + Map result = skillInstaller.installFromBundle(bundle, enable, overwrite, targetName); + return R.ok(result); + } catch (IllegalArgumentException e) { + return R.fail(400, e.getMessage()); + } catch (Exception e) { + return R.fail("ZIP install failed: " + e.getMessage()); + } + } + @Operation(summary = "卸载 skill") @DeleteMapping("/{skillName}") public R> uninstall(@PathVariable String skillName) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java index f1faf79e..b40d49df 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java @@ -235,6 +235,90 @@ public class SkillInstaller { return CompletableFuture.completedFuture(null); } + /** + * 同步安装 SkillBundle(用于 ZIP 上传等本地解析场景,无需异步任务) + * + * @return 安装结果 Map(skillId, name, version, filesCount) + */ + public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) { + String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name(); + if (skillName == null || skillName.isBlank()) { + throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle"); + } + + boolean exists = skillService.listSkills().stream() + .anyMatch(s -> s.getName().equals(skillName)); + if (exists && !overwrite) { + throw new vip.mate.exception.MateClawException("err.skill.name_exists", + "Skill '" + skillName + "' already exists. Enable overwrite to replace."); + } + + // 写入 workspace + if (exists) { + workspaceManager.cleanWorkspaceDataDirs(skillName); + } + workspaceManager.initWorkspace(skillName, bundle.content()); + + if (bundle.references() != null) { + for (var entry : bundle.references().entrySet()) { + String key = entry.getKey(); + if (!key.startsWith("references/")) key = "references/" + key; + workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue()); + } + } + if (bundle.scripts() != null) { + for (var entry : bundle.scripts().entrySet()) { + String key = entry.getKey(); + if (!key.startsWith("scripts/")) key = "scripts/" + key; + workspaceManager.writeWorkspaceFile(skillName, key, entry.getValue()); + } + } + + // 注册/更新 DB + SkillEntity skillEntity; + if (exists) { + skillEntity = skillService.listSkills().stream() + .filter(s -> s.getName().equals(skillName)) + .findFirst().orElseThrow(); + skillEntity.setSkillContent(bundle.content()); + skillEntity.setDescription(bundle.description()); + skillEntity.setVersion(bundle.version()); + skillEntity.setAuthor(bundle.author()); + skillEntity.setIcon(bundle.icon()); + skillEntity.setConfigJson(buildConfigJson(bundle)); + if (enable) skillEntity.setEnabled(true); + skillService.updateSkill(skillEntity); + } else { + skillEntity = new SkillEntity(); + skillEntity.setName(skillName); + skillEntity.setDescription(bundle.description()); + skillEntity.setSkillType("dynamic"); + skillEntity.setVersion(bundle.version()); + skillEntity.setAuthor(bundle.author()); + skillEntity.setIcon(bundle.icon()); + skillEntity.setSkillContent(bundle.content()); + skillEntity.setConfigJson(buildConfigJson(bundle)); + skillEntity.setEnabled(enable); + skillService.createSkill(skillEntity); + } + + eventPublisher.publishEvent(new SkillWorkspaceEvent( + skillName, SkillWorkspaceEvent.Type.INSTALLED, + workspaceManager.resolveConventionPath(skillName))); + + int filesCount = (bundle.references() != null ? bundle.references().size() : 0) + + (bundle.scripts() != null ? bundle.scripts().size() : 0) + 1; + + log.info("Skill '{}' installed from ZIP (v{}, {} files)", skillName, bundle.version(), filesCount); + + return Map.of( + "skillId", skillEntity.getId(), + "name", skillName, + "version", bundle.version() != null ? bundle.version() : "", + "filesCount", filesCount + ); + } + // ==================== 工具方法 ==================== private String buildConfigJson(SkillBundle bundle) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java new file mode 100644 index 00000000..b9be2663 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java @@ -0,0 +1,161 @@ +package vip.mate.skill.installer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; +import vip.mate.skill.installer.model.SkillBundle; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * ZIP 格式 Skill 包解析器 + *

+ * 从上传的 ZIP 文件中解析 SKILL.md + references/ + scripts/, + * 构建统一的 {@link SkillBundle} 供安装流程使用。 + *

+ * 安全防护: + *

    + *
  • Zip Slip 路径穿越检测
  • + *
  • 单文件 ≤1MB,总解压 ≤50MB
  • + *
  • 仅接受 SKILL.md / references/ / scripts/ 下的文件
  • + *
+ * + * @author MateClaw Team + */ +@Slf4j +public class ZipSkillFetcher { + + private static final long MAX_FILE_SIZE = 1_000_000; // 1MB per file + private static final long MAX_TOTAL_SIZE = 50_000_000; // 50MB total + private static final String SKILL_MD = "SKILL.md"; + private static final String SKILL_MD_LOWER = "skill.md"; + + /** + * 解析 ZIP 文件为 SkillBundle + * + * @param zipFile 上传的 ZIP 文件 + * @param parser frontmatter 解析器 + * @return 解析后的 SkillBundle + * @throws IOException 解析失败 + */ + public static SkillBundle parse(MultipartFile zipFile, SkillFrontmatterParser parser) throws IOException { + if (zipFile == null || zipFile.isEmpty()) { + throw new IllegalArgumentException("ZIP file is empty"); + } + if (zipFile.getSize() > MAX_TOTAL_SIZE) { + throw new IllegalArgumentException("ZIP file too large (max 50MB)"); + } + + String skillMdContent = null; + String skillMdPrefix = ""; // 如果 SKILL.md 在子目录中,记录前缀 + Map references = new HashMap<>(); + Map scripts = new HashMap<>(); + long totalSize = 0; + + try (InputStream is = zipFile.getInputStream(); + ZipInputStream zis = new ZipInputStream(is, StandardCharsets.UTF_8)) { + + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + zis.closeEntry(); + continue; + } + + String entryName = entry.getName(); + + // Zip Slip 防护:normalize 后检查是否逃逸 + Path entryPath = Path.of(entryName).normalize(); + if (entryPath.isAbsolute() || entryName.contains("..")) { + log.warn("[ZipSkillFetcher] Skipping suspicious entry: {}", entryName); + zis.closeEntry(); + continue; + } + + // 文件大小检查 + long size = entry.getSize(); + if (size > MAX_FILE_SIZE) { + log.warn("[ZipSkillFetcher] Skipping oversized entry: {} ({}bytes)", entryName, size); + zis.closeEntry(); + continue; + } + + // 读取内容 + byte[] bytes = zis.readAllBytes(); + totalSize += bytes.length; + if (totalSize > MAX_TOTAL_SIZE) { + throw new IOException("Total extracted size exceeds 50MB limit"); + } + + String content = new String(bytes, StandardCharsets.UTF_8); + String fileName = entryPath.getFileName().toString(); + + // 定位 SKILL.md(根目录或一级子目录) + if (skillMdContent == null && (SKILL_MD.equals(fileName) || SKILL_MD_LOWER.equals(fileName))) { + skillMdContent = content; + // 确定子目录前缀(如 "my-skill/SKILL.md" → prefix = "my-skill/") + int slashIdx = entryName.lastIndexOf('/'); + skillMdPrefix = slashIdx > 0 ? entryName.substring(0, slashIdx + 1) : ""; + log.info("[ZipSkillFetcher] Found SKILL.md at: {}", entryName); + } + + zis.closeEntry(); + + // 先收集所有文件,后面按前缀过滤 + String normalizedName = entryPath.toString().replace('\\', '/'); + + // 收集 references/ 和 scripts/ 文件 + String relativeName = normalizedName; + if (!skillMdPrefix.isEmpty() && normalizedName.startsWith(skillMdPrefix)) { + relativeName = normalizedName.substring(skillMdPrefix.length()); + } + + if (relativeName.startsWith("references/")) { + references.put(relativeName, content); + } else if (relativeName.startsWith("scripts/")) { + scripts.put(relativeName, content); + } + } + } + + if (skillMdContent == null) { + throw new IllegalArgumentException("ZIP does not contain SKILL.md"); + } + + // 解析 frontmatter + var parsed = parser.parse(skillMdContent); + String name = parsed.getName(); + if (name == null || name.isBlank()) { + // 从 ZIP 文件名推断 + String zipName = zipFile.getOriginalFilename(); + if (zipName != null) { + name = zipName.replaceAll("\\.zip$", "").replaceAll("[^a-zA-Z0-9_-]", "-"); + } else { + name = "imported-skill"; + } + } + + log.info("[ZipSkillFetcher] Parsed: name={}, references={}, scripts={}, totalSize={}", + name, references.size(), scripts.size(), totalSize); + + return new SkillBundle( + name, + skillMdContent, + references, + scripts, + "zip", + zipFile.getOriginalFilename(), + parsed.getFrontmatter() != null ? String.valueOf(parsed.getFrontmatter().getOrDefault("version", "1.0.0")) : "1.0.0", + parsed.getDescription(), + parsed.getFrontmatter() != null ? String.valueOf(parsed.getFrontmatter().getOrDefault("author", "")) : "", + parsed.getFrontmatter() != null ? String.valueOf(parsed.getFrontmatter().getOrDefault("icon", "📦")) : "📦" + ); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 815c2108..fe0840bf 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -169,6 +169,14 @@ export const skillInstallApi = { http.post(`/skills/install/cancel/${taskId}`), uninstall: (skillName: string) => http.delete(`/skills/install/${skillName}`), + uploadZip: (file: File, options?: { enable?: boolean; overwrite?: boolean; targetName?: string }) => { + const formData = new FormData() + formData.append('file', file) + return http.post('/skills/install/upload', formData, { + params: options, + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, } // ==================== Datasource ==================== diff --git a/mateclaw-ui/src/components/skill/ImportHubDialog.vue b/mateclaw-ui/src/components/skill/ImportHubDialog.vue index 8121664e..c5c2b6a6 100644 --- a/mateclaw-ui/src/components/skill/ImportHubDialog.vue +++ b/mateclaw-ui/src/components/skill/ImportHubDialog.vue @@ -19,6 +19,9 @@ + @@ -85,6 +88,42 @@ + +
+
+ + + + +

{{ t('skills.import.dropHint') }}

+

{{ t('skills.import.zipRequirement') }}

+
+ 📦 + {{ zipFile.name }} + {{ formatSize(zipFile.size) }} + +
+
+
+ + +
+ +
+
@@ -122,7 +161,7 @@ const emit = defineEmits<{ const { t } = useI18n() -const activeTab = ref<'url' | 'search'>('url') +const activeTab = ref<'url' | 'search' | 'zip'>('url') const urlInput = ref('') const searchQuery = ref('') const searchResults = ref([]) @@ -132,6 +171,9 @@ const installing = ref(false) const enableAfterInstall = ref(true) const overwriteExisting = ref(false) const currentTask = ref(null) +const zipFile = ref(null) +const zipInputRef = ref(null) +const dragOver = ref(false) let pollTimer: ReturnType | null = null watch(() => props.visible, (val) => { @@ -213,6 +255,59 @@ async function cancelInstall() { } } +function triggerFileInput() { + zipInputRef.value?.click() +} + +function handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file && file.name.endsWith('.zip')) { + zipFile.value = file + } else if (file) { + ElMessage.warning(t('skills.import.invalidZip')) + } + input.value = '' +} + +function handleDrop(e: DragEvent) { + dragOver.value = false + const file = e.dataTransfer?.files?.[0] + if (file && file.name.endsWith('.zip')) { + zipFile.value = file + } else { + ElMessage.warning(t('skills.import.invalidZip')) + } +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} + +async function uploadZip() { + if (!zipFile.value) return + if (zipFile.value.size > 50 * 1024 * 1024) { + ElMessage.error(t('skills.import.tooLarge')) + return + } + installing.value = true + try { + const res: any = await skillInstallApi.uploadZip(zipFile.value, { + enable: enableAfterInstall.value, + overwrite: overwriteExisting.value, + }) + ElMessage.success(t('skills.import.uploadSuccess')) + zipFile.value = null + emit('installed') + } catch (e: any) { + ElMessage.error(e?.response?.data?.msg || e?.message || t('skills.import.uploadFailed')) + } finally { + installing.value = false + } +} + async function doSearch() { const q = searchQuery.value.trim() if (!q) return @@ -305,4 +400,19 @@ function getStatusLabel(status: string): string { .progress-url { font-size: 11px; color: var(--mc-text-tertiary); font-family: monospace; word-break: break-all; margin-bottom: 4px; } .progress-error { font-size: 12px; color: var(--mc-danger); margin-top: 4px; } .progress-result { font-size: 13px; color: var(--mc-text-primary); margin-top: 4px; } + +/* ZIP upload zone */ +.upload-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 140px; border: 2px dashed var(--mc-border); border-radius: 12px; padding: 24px; cursor: pointer; transition: all 0.2s; text-align: center; } +.upload-zone:hover { border-color: var(--mc-primary); background: var(--mc-bg-muted); } +.upload-zone.drag-over { border-color: var(--mc-primary); background: var(--mc-primary-light, rgba(217,119,87,0.06)); } +.upload-zone.has-file { border-style: solid; border-color: var(--mc-primary); } +.upload-hint { font-size: 14px; color: var(--mc-text-secondary); margin: 0; } +.upload-sub { font-size: 12px; color: var(--mc-text-tertiary); margin: 4px 0 0; } +.hidden-input { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; } +.file-preview { display: flex; align-items: center; gap: 8px; font-size: 14px; } +.file-icon { font-size: 20px; } +.file-name { font-weight: 500; color: var(--mc-text-primary); } +.file-size { font-size: 12px; color: var(--mc-text-tertiary); } +.file-remove { border: none; background: none; color: var(--mc-text-tertiary); font-size: 18px; cursor: pointer; padding: 0 4px; } +.file-remove:hover { color: var(--mc-danger); } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 666e9526..88110e46 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1554,6 +1554,14 @@ export default { searchFailed: 'Search failed', noResults: 'No matching skills found', statusPending: 'Pending', + zipTab: 'ZIP Import', + dropHint: 'Drop .zip file here, or click to select', + zipRequirement: 'ZIP must contain a SKILL.md file', + uploading: 'Installing...', + uploadSuccess: 'Skill installed successfully', + uploadFailed: 'Installation failed', + invalidZip: 'Please select a .zip file', + tooLarge: 'File too large (max 50MB)', }, }, onboarding: { diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 405acccf..341f7c5f 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1564,6 +1564,14 @@ export default { searchFailed: '搜索失败', noResults: '未找到匹配的技能', statusPending: '等待中', + zipTab: 'ZIP 导入', + dropHint: '拖拽 .zip 文件到此处,或点击选择', + zipRequirement: 'ZIP 包需包含 SKILL.md 文件', + uploading: '正在安装...', + uploadSuccess: '技能安装成功', + uploadFailed: '安装失败', + invalidZip: '请选择 .zip 格式的文件', + tooLarge: '文件过大(最大 50MB)', }, }, onboarding: {