feat(skill): add ZIP file import for skill installation

This commit is contained in:
matevip 2026-04-11 19:29:01 +08:00
parent d3b72929c7
commit b3c1f8403d
7 changed files with 408 additions and 1 deletions

View File

@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, String>> uninstall(@PathVariable String skillName) {

View File

@ -235,6 +235,90 @@ public class SkillInstaller {
return CompletableFuture.completedFuture(null);
}
/**
* 同步安装 SkillBundle用于 ZIP 上传等本地解析场景无需异步任务
*
* @return 安装结果 MapskillId, name, version, filesCount
*/
public Map<String, Object> 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) {

View File

@ -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 包解析器
* <p>
* 从上传的 ZIP 文件中解析 SKILL.md + references/ + scripts/
* 构建统一的 {@link SkillBundle} 供安装流程使用
* <p>
* 安全防护
* <ul>
* <li>Zip Slip 路径穿越检测</li>
* <li>单文件 1MB总解压 50MB</li>
* <li>仅接受 SKILL.md / references/ / scripts/ 下的文件</li>
* </ul>
*
* @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<String, String> references = new HashMap<>();
Map<String, String> 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", "📦")) : "📦"
);
}
}

View File

@ -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 ====================

View File

@ -19,6 +19,9 @@
<button class="import-tab" :class="{ active: activeTab === 'search' }" @click="activeTab = 'search'">
{{ t('skills.import.searchTab') }}
</button>
<button class="import-tab" :class="{ active: activeTab === 'zip' }" @click="activeTab = 'zip'">
{{ t('skills.import.zipTab') }}
</button>
</div>
<!-- URL 安装 -->
@ -85,6 +88,42 @@
</div>
</div>
<!-- ZIP 上传 -->
<div v-if="activeTab === 'zip'" class="tab-content">
<div class="upload-zone"
:class="{ 'drag-over': dragOver, 'has-file': zipFile }"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="handleDrop"
@click="triggerFileInput">
<input ref="zipInputRef" type="file" accept=".zip" class="hidden-input" @change="handleFileSelect" />
<svg v-if="!zipFile" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.4; margin-bottom: 8px;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p v-if="!zipFile" class="upload-hint">{{ t('skills.import.dropHint') }}</p>
<p v-if="!zipFile" class="upload-sub">{{ t('skills.import.zipRequirement') }}</p>
<div v-if="zipFile" class="file-preview">
<span class="file-icon">📦</span>
<span class="file-name">{{ zipFile.name }}</span>
<span class="file-size">{{ formatSize(zipFile.size) }}</span>
<button class="file-remove" @click.stop="zipFile = null">&times;</button>
</div>
</div>
<div class="form-options">
<label class="checkbox-label">
<input type="checkbox" v-model="enableAfterInstall" />
{{ t('skills.import.enableAfterInstall') }}
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="overwriteExisting" />
{{ t('skills.import.overwrite') }}
</label>
</div>
<button class="btn-primary install-btn" @click="uploadZip" :disabled="!zipFile || installing">
{{ installing ? t('skills.import.uploading') : t('skills.import.install') }}
</button>
</div>
<!-- 安装进度 -->
<div v-if="currentTask" class="install-progress">
<div class="progress-header">
@ -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<HubSkillInfo[]>([])
@ -132,6 +171,9 @@ const installing = ref(false)
const enableAfterInstall = ref(true)
const overwriteExisting = ref(false)
const currentTask = ref<InstallTask | null>(null)
const zipFile = ref<File | null>(null)
const zipInputRef = ref<HTMLInputElement | null>(null)
const dragOver = ref(false)
let pollTimer: ReturnType<typeof setInterval> | 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); }
</style>

View File

@ -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: {

View File

@ -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: {