mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): add ZIP file import for skill installation
This commit is contained in:
parent
d3b72929c7
commit
b3c1f8403d
@ -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) {
|
||||
|
||||
@ -235,6 +235,90 @@ public class SkillInstaller {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步安装 SkillBundle(用于 ZIP 上传等本地解析场景,无需异步任务)
|
||||
*
|
||||
* @return 安装结果 Map(skillId, 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) {
|
||||
|
||||
@ -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", "📦")) : "📦"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
@ -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">×</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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user