diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py
index d15876526..626e2752f 100644
--- a/astrbot/core/skills/skill_manager.py
+++ b/astrbot/core/skills/skill_manager.py
@@ -26,6 +26,13 @@ _SANDBOX_SKILLS_CACHE_VERSION = 1
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
+def _is_ignored_zip_entry(name: str) -> bool:
+ parts = PurePosixPath(name).parts
+ if not parts:
+ return True
+ return parts[0] == "__MACOSX"
+
+
@dataclass
class SkillInfo:
name: str
@@ -401,7 +408,11 @@ class SkillManager:
raise ValueError("Uploaded file is not a valid zip archive.")
with zipfile.ZipFile(zip_path) as zf:
- names = [name.replace("\\", "/") for name in zf.namelist()]
+ names = [
+ name
+ for name in (entry.replace("\\", "/") for entry in zf.namelist())
+ if name and not _is_ignored_zip_entry(name)
+ ]
file_names = [name for name in names if name and not name.endswith("/")]
if not file_names:
raise ValueError("Zip archive is empty.")
@@ -436,7 +447,11 @@ class SkillManager:
raise ValueError("SKILL.md not found in the skill folder.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
- zf.extractall(tmp_dir)
+ for member in zf.infolist():
+ member_name = member.filename.replace("\\", "/")
+ if not member_name or _is_ignored_zip_entry(member_name):
+ continue
+ zf.extract(member, tmp_dir)
src_dir = Path(tmp_dir) / skill_name
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")
diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py
index adad49615..42ba7fd80 100644
--- a/astrbot/dashboard/routes/skills.py
+++ b/astrbot/dashboard/routes/skills.py
@@ -2,6 +2,7 @@ import os
import re
import shutil
import traceback
+import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -50,6 +51,7 @@ class SkillsRoute(Route):
self.routes = {
"/skills": ("GET", self.get_skills),
"/skills/upload": ("POST", self.upload_skill),
+ "/skills/batch-upload": ("POST", self.batch_upload_skills),
"/skills/download": ("GET", self.download_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
@@ -188,6 +190,114 @@ class SkillsRoute(Route):
except Exception:
logger.warning(f"Failed to remove temp skill file: {temp_path}")
+ async def batch_upload_skills(self):
+ """批量上传多个 skill ZIP 文件"""
+ if DEMO_MODE:
+ return (
+ Response()
+ .error("You are not permitted to do this operation in demo mode")
+ .__dict__
+ )
+
+ try:
+ files = await request.files
+ file_list = files.getlist("files")
+
+ if not file_list:
+ return Response().error("No files provided").__dict__
+
+ succeeded = []
+ failed = []
+ skill_mgr = SkillManager()
+ temp_dir = get_astrbot_temp_path()
+ os.makedirs(temp_dir, exist_ok=True)
+
+ for file in file_list:
+ filename = os.path.basename(file.filename or "unknown.zip")
+ temp_path = None
+
+ try:
+ if not filename.lower().endswith(".zip"):
+ failed.append(
+ {
+ "filename": filename,
+ "error": "Only .zip files are supported",
+ }
+ )
+ continue
+
+ temp_path = os.path.join(
+ temp_dir, f"batch_{uuid.uuid4().hex}_{filename}"
+ )
+ await file.save(temp_path)
+
+ skill_name = skill_mgr.install_skill_from_zip(
+ temp_path, overwrite=True
+ )
+ succeeded.append({"filename": filename, "name": skill_name})
+
+ except Exception as e:
+ failed.append({"filename": filename, "error": str(e)})
+ finally:
+ if temp_path and os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except Exception:
+ pass
+
+ if succeeded:
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning(
+ "Failed to sync uploaded skills to active sandboxes."
+ )
+
+ total = len(file_list)
+ success_count = len(succeeded)
+
+ if success_count == total:
+ message = f"All {total} skill(s) uploaded successfully."
+ return (
+ Response()
+ .ok(
+ {
+ "total": total,
+ "succeeded": succeeded,
+ "failed": failed,
+ },
+ message,
+ )
+ .__dict__
+ )
+ if success_count == 0:
+ message = f"Upload failed for all {total} file(s)."
+ resp = Response().error(message)
+ resp.data = {
+ "total": total,
+ "succeeded": succeeded,
+ "failed": failed,
+ }
+ return resp.__dict__
+
+ message = f"Partial success: {success_count}/{total} skill(s) uploaded."
+ return (
+ Response()
+ .ok(
+ {
+ "total": total,
+ "succeeded": succeeded,
+ "failed": failed,
+ },
+ message,
+ )
+ .__dict__
+ )
+
+ except Exception as e:
+ logger.error(traceback.format_exc())
+ return Response().error(str(e)).__dict__
+
async def download_skill(self):
try:
name = str(request.args.get("name") or "").strip()
diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue
index d8ec137e0..b46b4e296 100644
--- a/dashboard/src/components/extension/SkillsSection.vue
+++ b/dashboard/src/components/extension/SkillsSection.vue
@@ -5,26 +5,33 @@
{{ tm("skills.upload") }}
-
+
{{ tm("skills.refresh") }}
{{ tm("skills.modeLocal") }}
- {{ tm("skills.modeNeo") }}
+ {{
+ tm("skills.modeNeo")
+ }}
-
{{ tm("skills.runtimeHint") }}
+
{{ tm("skills.runtimeHint") }}
-
+
{{ neoUnavailableMessage }}
-
+
mdi-folder-open
@@ -81,7 +97,9 @@
>
{{ sourceTypeLabel(item.source_type) }}
-
+
mdi-text
{{ item.description || tm("skills.noDescription") }}
@@ -97,7 +115,11 @@
color="primary"
size="small"
rounded="xl"
- :disabled="itemLoading[item.name] || false || isSandboxPresetSkill(item)"
+ :disabled="
+ itemLoading[item.name] ||
+ false ||
+ isSandboxPresetSkill(item)
+ "
@click="downloadSkill(item)"
>
{{ tm("skills.download") }}
@@ -110,12 +132,21 @@
-
+
Neo Skills
-
{{ tm("skills.neoFilterHint") }}
+
+ {{ tm("skills.neoFilterHint") }}
+
-
+
{{ tm("skills.refresh") }}
@@ -160,16 +191,28 @@
-
+
- Candidates: {{ neoCandidates.length }}
- Releases: {{ neoReleases.length }}
- Active: {{ activeReleaseCount }}
+ Candidates: {{ neoCandidates.length }}
+ Releases: {{ neoReleases.length }}
+ Active: {{ activeReleaseCount }}
- {{ tm("skills.neoCandidates") }}
+ {{
+ tm("skills.neoCandidates")
+ }}
-
+
{{ tm("skills.neoPass") }}
-
+
{{ tm("skills.neoReject") }}
Payload
@@ -230,7 +283,9 @@
- {{ tm("skills.neoReleases") }}
+ {{
+ tm("skills.neoReleases")
+ }}
-
+
{{ item.is_active ? "active" : "inactive" }}
@@ -251,9 +310,18 @@
variant="tonal"
@click="handleReleaseLifecycleAction(item)"
>
- {{ item.is_active ? tm("skills.neoDeactivate") : tm("skills.neoRollback") }}
+ {{
+ item.is_active
+ ? tm("skills.neoDeactivate")
+ : tm("skills.neoRollback")
+ }}
-
+
{{ tm("skills.neoSync") }}
-
-
- {{ tm("skills.uploadDialogTitle") }}
-
- {{ tm("skills.uploadHint") }}
-
+
+
+
+
+
+ {{ tm("skills.uploadHint") }}
+
+
+
+ mdi-information-outline
+ {{ tm("skills.structureRequirement") }}
+
+
+
+
+
+ mdi-layers-outline
+
+
{{ tm("skills.abilityMultiple") }}
+
+
+
+ mdi-shield-check-outline
+
+
{{ tm("skills.abilityValidate") }}
+
+
+
+ mdi-skip-next-circle-outline
+
+
{{ tm("skills.abilitySkip") }}
+
+
+
+
+
+ mdi-folder-zip-outline
+
+
+ {{ tm("skills.dropzoneTitle") }}
+
+
+ {{ tm("skills.dropzoneAction") }}
+
+
+ {{ tm("skills.dropzoneHint") }}
+
+
+
+
+
+
+ {{
+ tm("skills.summaryTotal", { count: uploadStateCounts.total })
+ }}
+
+
+ {{
+ tm("skills.summaryReady", {
+ count:
+ uploadStateCounts.waiting + uploadStateCounts.uploading,
+ })
+ }}
+
+
+ {{
+ tm("skills.summarySuccess", {
+ count: uploadStateCounts.success,
+ })
+ }}
+
+
+ {{
+ tm("skills.summaryFailed", { count: uploadStateCounts.error })
+ }}
+
+
+ {{
+ tm("skills.summarySkipped", {
+ count: uploadStateCounts.skipped,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ uploadStatusLabel(item.status) }}
+
+
+
+
+
+
+ {{ tm("skills.fileListEmpty") }}
+
-
- {{ tm("skills.cancel") }}
-
+
+
+
+ {{ tm("skills.cancel") }}
+
+
{{ tm("skills.confirmUpload") }}
@@ -300,7 +551,9 @@
{{ tm("skills.deleteTitle") }}
{{ tm("skills.deleteMessage") }}
- {{ tm("skills.cancel") }}
+ {{
+ tm("skills.cancel")
+ }}
{{ t("core.common.itemCard.delete") }}
@@ -315,12 +568,19 @@
{{ payloadDialog.content }}
- {{ tm("skills.cancel") }}
+ {{
+ tm("skills.cancel")
+ }}
-
+
{{ snackbar.message }}
@@ -332,6 +592,12 @@ import { computed, onMounted, reactive, ref, watch } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
+const STATUS_WAITING = "waiting";
+const STATUS_UPLOADING = "uploading";
+const STATUS_SUCCESS = "success";
+const STATUS_ERROR = "error";
+const STATUS_SKIPPED = "skipped";
+
export default {
name: "SkillsSection",
components: { ItemCard },
@@ -346,7 +612,9 @@ export default {
const sandboxCache = reactive({ ready: false, count: 0, updated_at: null });
const uploading = ref(false);
const uploadDialog = ref(false);
- const uploadFile = ref(null);
+ const uploadInput = ref(null);
+ const uploadItems = ref([]);
+ const isUploadDragging = ref(false);
const itemLoading = reactive({});
const deleteDialog = ref(false);
const deleting = ref(false);
@@ -369,6 +637,7 @@ export default {
const neoEnabled = ref(false);
const neoUnavailableMessage = ref("");
+ let nextUploadItemId = 0;
const candidateStatusItems = computed(() => [
{ title: tm("skills.neoAll"), value: "" },
@@ -387,14 +656,44 @@ export default {
{ title: "stable", value: "stable" },
]);
- const activeReleaseCount = computed(() => neoReleases.value.filter((item) => item?.is_active).length);
+ const activeReleaseCount = computed(
+ () => neoReleases.value.filter((item) => item?.is_active).length,
+ );
+ const uploadStateCounts = computed(() =>
+ uploadItems.value.reduce(
+ (counts, item) => {
+ counts.total += 1;
+ counts[item.status] += 1;
+ return counts;
+ },
+ {
+ total: 0,
+ [STATUS_WAITING]: 0,
+ [STATUS_UPLOADING]: 0,
+ [STATUS_SUCCESS]: 0,
+ [STATUS_ERROR]: 0,
+ [STATUS_SKIPPED]: 0,
+ },
+ ),
+ );
+ const hasUploadableItems = computed(() =>
+ uploadItems.value.some(
+ (item) =>
+ item.status === STATUS_WAITING || item.status === STATUS_ERROR,
+ ),
+ );
const candidateHeaders = computed(() => [
{ title: "ID", key: "id", width: "180px" },
{ title: "skill_key", key: "skill_key" },
{ title: "status", key: "status", width: "130px" },
{ title: "score", key: "latest_score", width: "90px" },
- { title: tm("skills.actions"), key: "actions", sortable: false, width: "420px" },
+ {
+ title: tm("skills.actions"),
+ key: "actions",
+ sortable: false,
+ width: "420px",
+ },
]);
const releaseHeaders = computed(() => [
@@ -403,7 +702,12 @@ export default {
{ title: "stage", key: "stage", width: "100px" },
{ title: "version", key: "version", width: "90px" },
{ title: "active", key: "is_active", width: "110px" },
- { title: tm("skills.actions"), key: "actions", sortable: false, width: "220px" },
+ {
+ title: tm("skills.actions"),
+ key: "actions",
+ sortable: false,
+ width: "220px",
+ },
]);
const showMessage = (message, color = "success") => {
@@ -441,7 +745,8 @@ export default {
return "primary";
};
- const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only";
+ const isSandboxPresetSkill = (skill) =>
+ skill?.source_type === "sandbox_only";
const normalizeNeoItemsPayload = (res) => {
const payload = res?.data?.data || [];
@@ -450,6 +755,175 @@ export default {
return [];
};
+ const formatFileSize = (size) => {
+ if (!Number.isFinite(size) || size <= 0) return "0 B";
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
+ };
+
+ const normalizeUploadName = (name) =>
+ String(name || "")
+ .trim()
+ .toLowerCase();
+
+ const buildUploadItem = (file, status, validationMessage) => ({
+ id: `upload-${nextUploadItemId++}`,
+ file,
+ name: file.name,
+ size: file.size,
+ status,
+ validationMessage,
+ filenameKey: normalizeUploadName(file.name),
+ });
+
+ const uploadStatusLabel = (status) => {
+ if (status === STATUS_UPLOADING) return tm("skills.statusUploading");
+ if (status === STATUS_SUCCESS) return tm("skills.statusSuccess");
+ if (status === STATUS_ERROR) return tm("skills.statusError");
+ if (status === STATUS_SKIPPED) return tm("skills.statusSkipped");
+ return tm("skills.statusWaiting");
+ };
+
+ const statusChipClass = (status) =>
+ `skills-status-chip skills-status-chip--${status}`;
+
+ const resetUploadState = () => {
+ uploadItems.value = [];
+ isUploadDragging.value = false;
+ if (uploadInput.value) {
+ uploadInput.value.value = "";
+ }
+ };
+
+ const openUploadDialog = () => {
+ uploadDialog.value = true;
+ };
+
+ const closeUploadDialog = () => {
+ if (uploading.value) return;
+ uploadDialog.value = false;
+ };
+
+ const openUploadPicker = () => {
+ if (uploading.value) return;
+ uploadInput.value?.click();
+ };
+
+ const addUploadFiles = (filesToAdd) => {
+ const existingNames = new Set(
+ uploadItems.value.map((item) => item.filenameKey),
+ );
+ const nextItems = [];
+
+ for (const file of filesToAdd) {
+ if (!file?.name) continue;
+ const filenameKey = normalizeUploadName(file.name);
+
+ if (existingNames.has(filenameKey)) {
+ nextItems.push(
+ buildUploadItem(
+ file,
+ STATUS_SKIPPED,
+ tm("skills.validationDuplicate"),
+ ),
+ );
+ continue;
+ }
+
+ existingNames.add(filenameKey);
+ if (!/\.zip$/i.test(file.name)) {
+ nextItems.push(
+ buildUploadItem(
+ file,
+ STATUS_SKIPPED,
+ tm("skills.validationZipOnly"),
+ ),
+ );
+ continue;
+ }
+
+ nextItems.push(
+ buildUploadItem(file, STATUS_WAITING, tm("skills.validationReady")),
+ );
+ }
+
+ if (nextItems.length > 0) {
+ uploadItems.value = [...uploadItems.value, ...nextItems];
+ }
+ };
+
+ const handleUploadSelection = (event) => {
+ const selected = Array.from(event?.target?.files || []);
+ addUploadFiles(selected);
+ if (uploadInput.value) {
+ uploadInput.value.value = "";
+ }
+ };
+
+ const handleUploadDrop = (event) => {
+ isUploadDragging.value = false;
+ if (uploading.value) {
+ return;
+ }
+ addUploadFiles(Array.from(event?.dataTransfer?.files || []));
+ };
+
+ const removeUploadItem = (itemId) => {
+ uploadItems.value = uploadItems.value.filter(
+ (item) => item.id !== itemId,
+ );
+ };
+
+ const takeFirstMatch = (matchMap, filenameKey) => {
+ const matches = matchMap.get(filenameKey) || [];
+ const entry = matches.shift() || null;
+ if (matches.length === 0) {
+ matchMap.delete(filenameKey);
+ }
+ return entry;
+ };
+
+ const buildResultMap = (items = []) => {
+ const resultMap = new Map();
+ for (const item of items) {
+ const filenameKey = normalizeUploadName(item?.filename);
+ if (!filenameKey) continue;
+ if (!resultMap.has(filenameKey)) {
+ resultMap.set(filenameKey, []);
+ }
+ resultMap.get(filenameKey).push(item);
+ }
+ return resultMap;
+ };
+
+ const applyUploadResults = (attemptedItems, payload) => {
+ const succeededMap = buildResultMap(payload?.succeeded);
+ const failedMap = buildResultMap(payload?.failed);
+
+ for (const item of attemptedItems) {
+ const successEntry = takeFirstMatch(succeededMap, item.filenameKey);
+ if (successEntry) {
+ item.status = STATUS_SUCCESS;
+ item.validationMessage = tm("skills.validationUploadedAs", {
+ name: successEntry.name || item.name,
+ });
+ continue;
+ }
+
+ const failedEntry = takeFirstMatch(failedMap, item.filenameKey);
+ if (failedEntry) {
+ item.status = STATUS_ERROR;
+ item.validationMessage =
+ failedEntry.error || tm("skills.validationUploadFailed");
+ continue;
+ }
+
+ item.status = STATUS_ERROR;
+ item.validationMessage = tm("skills.validationNoResult");
+ }
+ };
+
const fetchSkills = async () => {
loading.value = true;
try {
@@ -462,36 +936,73 @@ export default {
}
};
- const handleApiResponse = (res, successMessage, failureMessageDefault, onSuccess) => {
+ const handleApiResponse = (
+ res,
+ successMessage,
+ failureMessageDefault,
+ onSuccess,
+ ) => {
if (res && res.data && res.data.status === "ok") {
showMessage(successMessage, "success");
if (onSuccess) onSuccess();
} else {
- const msg = (res && res.data && res.data.message) || failureMessageDefault;
+ const msg =
+ (res && res.data && res.data.message) || failureMessageDefault;
showMessage(msg, "error");
}
};
- const uploadSkill = async () => {
- if (!uploadFile.value) return;
+ const uploadSkillBatch = async () => {
+ const attemptedItems = uploadItems.value.filter(
+ (item) =>
+ item.status === STATUS_WAITING || item.status === STATUS_ERROR,
+ );
+ if (attemptedItems.length === 0) return;
+
uploading.value = true;
+ for (const item of attemptedItems) {
+ item.status = STATUS_UPLOADING;
+ item.validationMessage = tm("skills.validationUploading");
+ }
+
try {
const formData = new FormData();
- const file = Array.isArray(uploadFile.value) ? uploadFile.value[0] : uploadFile.value;
- if (!file) {
- uploading.value = false;
- return;
+ for (const item of attemptedItems) {
+ formData.append("files", item.file);
}
- formData.append("file", file);
- const res = await axios.post("/api/skills/upload", formData, {
+
+ const res = await axios.post("/api/skills/batch-upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
- handleApiResponse(res, tm("skills.uploadSuccess"), tm("skills.uploadFailed"), async () => {
- uploadDialog.value = false;
- uploadFile.value = null;
+
+ const payload = res?.data?.data || {};
+ applyUploadResults(attemptedItems, payload);
+
+ const succeededCount = Array.isArray(payload.succeeded)
+ ? payload.succeeded.length
+ : 0;
+ const failedCount = Array.isArray(payload.failed)
+ ? payload.failed.length
+ : 0;
+ const responseColor =
+ res?.data?.status === "error"
+ ? "error"
+ : failedCount > 0
+ ? "warning"
+ : "success";
+ showMessage(
+ res?.data?.message || tm("skills.uploadSuccess"),
+ responseColor,
+ );
+
+ if (succeededCount > 0) {
await fetchSkills();
- });
+ }
} catch (_err) {
+ for (const item of attemptedItems) {
+ item.status = STATUS_ERROR;
+ item.validationMessage = tm("skills.validationUploadFailed");
+ }
showMessage(tm("skills.uploadFailed"), "error");
} finally {
uploading.value = false;
@@ -510,9 +1021,14 @@ export default {
name: skill.name,
active: nextActive,
});
- handleApiResponse(res, tm("skills.updateSuccess"), tm("skills.updateFailed"), () => {
- skill.active = nextActive;
- });
+ handleApiResponse(
+ res,
+ tm("skills.updateSuccess"),
+ tm("skills.updateFailed"),
+ () => {
+ skill.active = nextActive;
+ },
+ );
} catch (_err) {
showMessage(tm("skills.updateFailed"), "error");
} finally {
@@ -536,10 +1052,15 @@ export default {
const res = await axios.post("/api/skills/delete", {
name: skillToDelete.value.name,
});
- handleApiResponse(res, tm("skills.deleteSuccess"), tm("skills.deleteFailed"), async () => {
- deleteDialog.value = false;
- await fetchSkills();
- });
+ handleApiResponse(
+ res,
+ tm("skills.deleteSuccess"),
+ tm("skills.deleteFailed"),
+ async () => {
+ deleteDialog.value = false;
+ await fetchSkills();
+ },
+ );
} catch (_err) {
showMessage(tm("skills.deleteFailed"), "error");
} finally {
@@ -606,9 +1127,11 @@ export default {
const res = await axios.get("/api/config/get");
const config = res?.data?.data?.config || {};
const providerSettings = config?.provider_settings || {};
- const runtime = providerSettings?.computer_use_runtime || "local";
+ const currentRuntime =
+ providerSettings?.computer_use_runtime || "local";
const booter = providerSettings?.sandbox?.booter || "";
- neoEnabled.value = runtime === "sandbox" && booter === "shipyard_neo";
+ neoEnabled.value =
+ currentRuntime === "sandbox" && booter === "shipyard_neo";
} catch (_err) {
neoEnabled.value = false;
}
@@ -638,19 +1161,26 @@ export default {
score: passed ? 1.0 : 0.0,
report: passed ? "approved_from_webui" : "rejected_from_webui",
});
- handleApiResponse(res, tm("skills.neoEvaluateSuccess"), tm("skills.neoEvaluateFailed"), async () => {
- await fetchNeoCandidates();
- });
+ handleApiResponse(
+ res,
+ tm("skills.neoEvaluateSuccess"),
+ tm("skills.neoEvaluateFailed"),
+ async () => {
+ await fetchNeoCandidates();
+ },
+ );
} catch (_err) {
showMessage(tm("skills.neoEvaluateFailed"), "error");
}
};
- const candidatePromoteLoadingKey = (candidateId, stage) => `${candidateId}:${stage}`;
+ const candidatePromoteLoadingKey = (candidateId, stage) =>
+ `${candidateId}:${stage}`;
const isCandidatePromoteLoading = (candidateId, stage) =>
!!candidatePromoteLoading[candidatePromoteLoadingKey(candidateId, stage)];
const isCandidatePromoting = (candidateId) =>
- isCandidatePromoteLoading(candidateId, "canary") || isCandidatePromoteLoading(candidateId, "stable");
+ isCandidatePromoteLoading(candidateId, "canary") ||
+ isCandidatePromoteLoading(candidateId, "stable");
const promoteCandidate = async (candidate, stage) => {
const candidateId = candidate?.id;
@@ -666,7 +1196,10 @@ export default {
});
const ok = res?.data?.status === "ok";
if (!ok) {
- showMessage(res?.data?.message || tm("skills.neoPromoteFailed"), "error");
+ showMessage(
+ res?.data?.message || tm("skills.neoPromoteFailed"),
+ "error",
+ );
} else {
showMessage(tm("skills.neoPromoteSuccess"), "success");
}
@@ -686,9 +1219,14 @@ export default {
const res = await axios.post("/api/skills/neo/rollback", {
release_id: release.id,
});
- handleApiResponse(res, tm("skills.neoRollbackSuccess"), tm("skills.neoRollbackFailed"), async () => {
- await fetchNeoData();
- });
+ handleApiResponse(
+ res,
+ tm("skills.neoRollbackSuccess"),
+ tm("skills.neoRollbackFailed"),
+ async () => {
+ await fetchNeoData();
+ },
+ );
} catch (_err) {
showMessage(tm("skills.neoRollbackFailed"), "error");
}
@@ -725,9 +1263,14 @@ export default {
const res = await axios.post("/api/skills/neo/sync", {
release_id: release.id,
});
- handleApiResponse(res, tm("skills.neoSyncSuccess"), tm("skills.neoSyncFailed"), async () => {
- await fetchSkills();
- });
+ handleApiResponse(
+ res,
+ tm("skills.neoSyncSuccess"),
+ tm("skills.neoSyncFailed"),
+ async () => {
+ await fetchSkills();
+ },
+ );
} catch (_err) {
showMessage(tm("skills.neoSyncFailed"), "error");
}
@@ -740,7 +1283,10 @@ export default {
params: { payload_ref: payloadRef },
});
if (res?.data?.status !== "ok") {
- showMessage(res?.data?.message || tm("skills.neoPayloadFailed"), "error");
+ showMessage(
+ res?.data?.message || tm("skills.neoPayloadFailed"),
+ "error",
+ );
return;
}
const payload = res?.data?.data || {};
@@ -757,9 +1303,14 @@ export default {
candidate_id: candidate.id,
reason: "deleted_from_webui",
});
- handleApiResponse(res, tm("skills.neoDeleteSuccess"), tm("skills.neoDeleteFailed"), async () => {
- await fetchNeoData();
- });
+ handleApiResponse(
+ res,
+ tm("skills.neoDeleteSuccess"),
+ tm("skills.neoDeleteFailed"),
+ async () => {
+ await fetchNeoData();
+ },
+ );
} catch (_err) {
showMessage(tm("skills.neoDeleteFailed"), "error");
}
@@ -771,9 +1322,14 @@ export default {
release_id: release.id,
reason: "deleted_from_webui",
});
- handleApiResponse(res, tm("skills.neoDeleteSuccess"), tm("skills.neoDeleteFailed"), async () => {
- await fetchNeoData();
- });
+ handleApiResponse(
+ res,
+ tm("skills.neoDeleteSuccess"),
+ tm("skills.neoDeleteFailed"),
+ async () => {
+ await fetchNeoData();
+ },
+ );
} catch (_err) {
showMessage(tm("skills.neoDeleteFailed"), "error");
}
@@ -803,6 +1359,12 @@ export default {
}
});
+ watch(uploadDialog, (isOpen) => {
+ if (!isOpen && !uploading.value) {
+ resetUploadState();
+ }
+ });
+
onMounted(async () => {
await Promise.all([fetchSkills(), loadNeoAvailability()]);
if (neoEnabled.value) {
@@ -819,7 +1381,11 @@ export default {
runtime,
sandboxCache,
uploadDialog,
- uploadFile,
+ uploadInput,
+ uploadItems,
+ uploadStateCounts,
+ hasUploadableItems,
+ isUploadDragging,
uploading,
itemLoading,
deleteDialog,
@@ -837,9 +1403,18 @@ export default {
candidateHeaders,
releaseHeaders,
payloadDialog,
+ formatFileSize,
+ uploadStatusLabel,
+ statusChipClass,
+ openUploadDialog,
+ closeUploadDialog,
+ openUploadPicker,
+ handleUploadSelection,
+ handleUploadDrop,
+ removeUploadItem,
refreshCurrentMode,
fetchNeoData,
- uploadSkill,
+ uploadSkillBatch,
downloadSkill,
toggleSkill,
confirmDelete,
@@ -881,6 +1456,288 @@ export default {
word-break: break-all;
}
+.skills-upload-dialog {
+ display: flex;
+ flex-direction: column;
+ max-height: min(88vh, 960px);
+ border-radius: 24px;
+ background: rgb(var(--v-theme-surface));
+ border: 1px solid var(--v-theme-border);
+ outline: 1px solid rgba(var(--v-theme-primary), 0.1);
+ outline-offset: -1px;
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
+ overflow: hidden;
+}
+
+.skills-upload-dialog__header {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: flex-start;
+ gap: 16px;
+ white-space: normal;
+ overflow: visible;
+}
+
+.skills-upload-dialog__heading {
+ min-width: 0;
+ padding-right: 0;
+ white-space: normal;
+}
+
+.skills-upload-dialog__description {
+ max-width: 100%;
+ color: var(--v-theme-secondaryText);
+ line-height: 1.7;
+ word-break: break-word;
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+
+.skills-upload-dialog__description--body {
+ margin: 0 0 14px;
+ font-size: 15px;
+ line-height: 1.6;
+}
+
+.skills-upload-dialog__close {
+ align-self: flex-start;
+}
+
+.skills-upload-dialog__body {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.skills-upload-dialog__actions {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-top: 1px solid var(--v-theme-border);
+ background: rgba(var(--v-theme-surface), 0.98);
+}
+
+.skills-upload-dialog__action-btn {
+ min-width: 96px;
+ height: 38px;
+ border-radius: 10px;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.skills-upload-structure-note {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 18px;
+ padding: 12px 14px;
+ border-radius: 16px;
+ border: 1px solid rgba(var(--v-theme-primary), 0.18);
+ background: rgba(var(--v-theme-surface), 0.96);
+ color: var(--v-theme-secondaryText);
+ line-height: 1.6;
+}
+
+.skills-upload-capabilities {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 18px;
+}
+
+.skills-upload-capability {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 52px;
+ padding: 0 14px;
+ border-radius: 16px;
+ border: 1px solid rgba(var(--v-theme-primary), 0.16);
+ background: rgba(var(--v-theme-surface), 0.96);
+ color: var(--v-theme-secondaryText);
+}
+
+.skills-upload-capability__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 999px;
+ background: rgba(var(--v-theme-primary), 0.16);
+ color: rgba(var(--v-theme-primary), 0.95);
+}
+
+.skills-dropzone {
+ padding: 36px 24px;
+ border-radius: 22px;
+ border: 1.5px dashed rgba(var(--v-theme-primary), 0.24);
+ background: rgba(var(--v-theme-surface), 0.94);
+ text-align: center;
+ cursor: pointer;
+ transition:
+ border-color 0.2s ease,
+ transform 0.2s ease,
+ background-color 0.2s ease;
+}
+
+.skills-dropzone:hover,
+.skills-dropzone--dragover {
+ border-color: rgba(var(--v-theme-primary), 0.52);
+ background: rgba(var(--v-theme-primary), 0.05);
+ transform: translateY(-1px);
+}
+
+.skills-dropzone__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 66px;
+ height: 66px;
+ margin: 0 auto 18px;
+ border-radius: 20px;
+ background: rgba(var(--v-theme-primary), 0.15);
+ color: rgba(var(--v-theme-primary), 0.96);
+}
+
+.skills-dropzone__subtitle {
+ margin-top: 10px;
+ color: var(--v-theme-secondaryText);
+}
+
+.skills-dropzone__hint {
+ margin-top: 8px;
+ font-size: 13px;
+ color: var(--v-theme-secondaryText);
+ opacity: 0.82;
+}
+
+.skills-upload-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 18px;
+}
+
+.skills-upload-summary__chip {
+ background: rgba(var(--v-theme-surface), 0.96);
+ border: 1px solid rgba(var(--v-theme-primary), 0.16);
+ color: var(--v-theme-secondaryText);
+}
+
+.skills-upload-summary__chip--success {
+ background: rgba(var(--v-theme-primary), 0.18);
+ color: var(--v-theme-primaryText);
+}
+
+.skills-upload-summary__chip--error {
+ background: #f2e6e2;
+ color: #8b5d54;
+}
+
+.skills-upload-list {
+ margin-top: 16px;
+ border-radius: 20px;
+ border: 1px solid rgba(var(--v-theme-primary), 0.2);
+ background: rgba(var(--v-theme-surface), 0.94);
+ overflow: hidden;
+}
+
+.skills-upload-list__header {
+ padding: 14px 18px;
+ border-bottom: 1px solid rgba(var(--v-theme-primary), 0.14);
+ color: var(--v-theme-primaryText);
+ font-weight: 600;
+}
+
+.skills-upload-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px 18px;
+}
+
+.skills-upload-row + .skills-upload-row {
+ border-top: 1px solid rgba(var(--v-theme-primary), 0.12);
+}
+
+.skills-upload-row__meta {
+ min-width: 0;
+ flex: 1;
+}
+
+.skills-upload-row__name {
+ font-weight: 600;
+ color: var(--v-theme-primaryText);
+ word-break: break-all;
+}
+
+.skills-upload-row__size {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--v-theme-secondaryText);
+ opacity: 0.82;
+}
+
+.skills-upload-row__message {
+ margin-top: 8px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--v-theme-secondaryText);
+}
+
+.skills-upload-row__actions {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+
+.skills-status-chip {
+ min-width: 74px;
+ justify-content: center;
+ font-weight: 600;
+}
+
+.skills-status-chip--waiting {
+ background: rgba(var(--v-theme-surface), 0.96);
+ border: 1px solid rgba(var(--v-theme-primary), 0.16);
+ color: var(--v-theme-secondaryText);
+}
+
+.skills-status-chip--uploading {
+ background: rgba(var(--v-theme-primary), 0.14);
+ color: var(--v-theme-primaryText);
+}
+
+.skills-status-chip--success {
+ background: rgba(var(--v-theme-primary), 0.2);
+ color: var(--v-theme-primaryText);
+}
+
+.skills-status-chip--error {
+ background: #f2e6e2;
+ color: #8a5a50;
+}
+
+.skills-status-chip--skipped {
+ background: rgba(var(--v-theme-surface), 0.96);
+ border: 1px solid rgba(var(--v-theme-primary), 0.16);
+ color: var(--v-theme-secondaryText);
+}
+
+.skills-upload-empty {
+ margin-top: 16px;
+ padding: 20px 18px;
+ border-radius: 20px;
+ border: 1px dashed rgba(var(--v-theme-primary), 0.24);
+ background: rgba(var(--v-theme-surface), 0.94);
+ text-align: center;
+ color: var(--v-theme-secondaryText);
+}
+
.payload-preview {
max-height: 480px;
overflow: auto;
@@ -890,10 +1747,15 @@ export default {
border-radius: 8px;
font-size: 12px;
}
+
.neo-filter-card {
border-radius: 14px;
border-color: rgba(var(--v-theme-primary), 0.25);
- background: linear-gradient(180deg, rgba(var(--v-theme-primary), 0.03), rgba(var(--v-theme-surface), 1));
+ background: linear-gradient(
+ 180deg,
+ rgba(var(--v-theme-primary), 0.03),
+ rgba(var(--v-theme-surface), 1)
+ );
}
.neo-table-card {
@@ -907,4 +1769,37 @@ export default {
.neo-data-table :deep(tbody tr:hover) {
background: rgba(var(--v-theme-primary), 0.04);
}
+
+@media (max-width: 860px) {
+ .skills-upload-capabilities {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 640px) {
+ .skills-upload-dialog {
+ max-height: 92vh;
+ }
+
+ .skills-upload-dialog__header {
+ gap: 12px;
+ }
+
+ .skills-upload-dialog__heading {
+ padding-right: 0;
+ }
+
+ .skills-upload-row {
+ flex-direction: column;
+ }
+
+ .skills-upload-row__actions {
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .skills-upload-dialog__description--body {
+ font-size: 14px;
+ }
+}
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 24c32bcd9..c97deaf49 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -224,10 +224,43 @@
"empty": "No Skills found",
"emptyHint": "Upload a Skills zip to get started",
"uploadDialogTitle": "Upload Skills",
- "uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
+ "uploadHint": "Upload multiple zip skill packages or drag them in. The system validates the structure automatically and shows a result for each file.",
+ "structureRequirement": "The most common failure is an invalid archive structure. Each zip must contain exactly one top-level folder such as `skillname/`, and that folder must include `SKILL.md`.",
+ "abilityMultiple": "Upload multiple zip files at once",
+ "abilityValidate": "Validate `SKILL.md` automatically",
+ "abilitySkip": "Automatically skip duplicate files.",
"selectFile": "Select file",
- "confirmUpload": "Upload",
+ "selectFiles": "Select files (multiple allowed)",
+ "dropzoneTitle": "Drag multiple zip files here",
+ "dropzoneAction": "or click to pick multiple files from a folder",
+ "dropzoneHint": "Batch upload is supported and the structure will be validated automatically",
+ "fileListTitle": "Files in queue",
+ "fileListEmpty": "Selected files will appear here with validation feedback and upload status",
+ "uploading": "Uploading...",
+ "batchResultTitle": "Batch Upload Results",
+ "batchResultSummary": "{success} of {total} files uploaded successfully",
+ "batchSuccessList": "Successfully uploaded",
+ "batchFailedList": "Failed to upload",
+ "confirm": "OK",
+ "confirmUpload": "Start Upload",
"cancel": "Cancel",
+ "statusWaiting": "Waiting",
+ "statusUploading": "Uploading",
+ "statusSuccess": "Uploaded",
+ "statusError": "Failed",
+ "statusSkipped": "Skipped",
+ "summaryTotal": "{count} file(s)",
+ "summaryReady": "Pending {count}",
+ "summarySuccess": "Success {count}",
+ "summaryFailed": "Failed {count}",
+ "summarySkipped": "Skipped {count}",
+ "validationReady": "Ready to upload. The archive structure will be checked during upload.",
+ "validationZipOnly": "Only zip skill packages are supported",
+ "validationDuplicate": "A file with the same name is already in the queue and has been skipped",
+ "validationUploading": "Validating and uploading...",
+ "validationUploadFailed": "Upload failed. Please try again.",
+ "validationUploadedAs": "Installed as {name}",
+ "validationNoResult": "No validation result was returned. Check the platform logs.",
"noDescription": "No description",
"path": "Path",
"uploadSuccess": "Upload succeeded",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index d132a2641..a67fef728 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -224,10 +224,43 @@
"empty": "暂无 Skills",
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
- "uploadHint": "请上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
+ "uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
+ "structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
+ "abilityMultiple": "支持一次上传多个zip文件",
+ "abilityValidate": "自动校验 `SKILL.md`",
+ "abilitySkip": "自动跳过重复文件",
"selectFile": "选择文件",
- "confirmUpload": "上传",
+ "selectFiles": "选择文件(可多选)",
+ "dropzoneTitle": "拖拽多个 zip 文件到这里",
+ "dropzoneAction": "或者点击之后在文件夹中选择多个文件",
+ "dropzoneHint": "支持批量上传,系统会自动校验目录结构",
+ "fileListTitle": "待处理文件",
+ "fileListEmpty": "选择文件后会在这里显示校验结果与上传状态",
+ "uploading": "正在上传...",
+ "batchResultTitle": "批量上传结果",
+ "batchResultSummary": "共 {total} 个文件,成功 {success} 个",
+ "batchSuccessList": "上传成功",
+ "batchFailedList": "上传失败",
+ "confirm": "确定",
+ "confirmUpload": "开始上传",
"cancel": "取消",
+ "statusWaiting": "待上传",
+ "statusUploading": "上传中",
+ "statusSuccess": "已上传",
+ "statusError": "校验失败",
+ "statusSkipped": "已跳过",
+ "summaryTotal": "共 {count} 个文件",
+ "summaryReady": "待处理 {count}",
+ "summarySuccess": "成功 {count}",
+ "summaryFailed": "失败 {count}",
+ "summarySkipped": "跳过 {count}",
+ "validationReady": "等待上传,上传时会自动校验目录结构",
+ "validationZipOnly": "仅支持 zip 技能包",
+ "validationDuplicate": "同名文件已在列表中,已跳过",
+ "validationUploading": "正在校验并上传...",
+ "validationUploadFailed": "上传失败,请重试",
+ "validationUploadedAs": "已安装为 {name}",
+ "validationNoResult": "未收到校验结果,请检查平台日志",
"noDescription": "无描述",
"path": "路径",
"uploadSuccess": "上传成功",
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index cbaec66c7..9ba5beab8 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -1,11 +1,14 @@
import asyncio
+import io
import os
import sys
+import zipfile
from types import SimpleNamespace
import pytest
import pytest_asyncio
from quart import Quart
+from werkzeug.datastructures import FileStorage
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -15,7 +18,6 @@ from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.dashboard.server import AstrBotDashboard
from tests.fixtures.helpers import (
MockPluginBuilder,
- MockPluginConfig,
create_mock_updater_install,
create_mock_updater_update,
)
@@ -145,9 +147,7 @@ async def test_plugins(
monkeypatch.setattr(
core_lifecycle_td.plugin_manager.updator, "install", mock_install
)
- monkeypatch.setattr(
- core_lifecycle_td.plugin_manager.updator, "update", mock_update
- )
+ monkeypatch.setattr(core_lifecycle_td.plugin_manager.updator, "update", mock_update)
try:
# 插件安装
@@ -158,7 +158,9 @@ async def test_plugins(
)
assert response.status_code == 200
data = await response.get_json()
- assert data["status"] == "ok", f"安装失败: {data.get('message', 'unknown error')}"
+ assert data["status"] == "ok", (
+ f"安装失败: {data.get('message', 'unknown error')}"
+ )
# 验证插件已注册
exists = any(md.name == test_plugin_name for md in star_registry)
@@ -493,3 +495,223 @@ async def test_neo_skills_routes(
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["skill_key"] == "neo.demo"
+
+
+@pytest.mark.asyncio
+async def test_batch_upload_skills_returns_error_when_all_files_invalid(
+ app: Quart,
+ authenticated_header: dict,
+):
+ test_client = app.test_client()
+
+ response = await test_client.post(
+ "/api/skills/batch-upload",
+ headers=authenticated_header,
+ files={
+ "files": FileStorage(
+ stream=io.BytesIO(b"not-a-zip"),
+ filename="invalid.txt",
+ content_type="text/plain",
+ ),
+ },
+ )
+
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data["status"] == "error"
+ assert data["message"] == "Upload failed for all 1 file(s)."
+
+
+@pytest.mark.asyncio
+async def test_batch_upload_skills_accepts_zip_files(
+ app: Quart,
+ authenticated_header: dict,
+ monkeypatch,
+):
+ async def _fake_sync_skills_to_active_sandboxes():
+ return
+
+ def _fake_install_skill_from_zip(
+ self,
+ zip_path: str,
+ *,
+ overwrite: bool = True,
+ ):
+ _ = self, overwrite
+ assert zip_path.endswith(".zip")
+ return "demo_skill"
+
+ monkeypatch.setattr(
+ "astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
+ _fake_sync_skills_to_active_sandboxes,
+ )
+ monkeypatch.setattr(
+ "astrbot.dashboard.routes.skills.SkillManager.install_skill_from_zip",
+ _fake_install_skill_from_zip,
+ )
+
+ test_client = app.test_client()
+
+ response = await test_client.post(
+ "/api/skills/batch-upload",
+ headers=authenticated_header,
+ files={
+ "files": FileStorage(
+ stream=io.BytesIO(b"fake-zip"),
+ filename="demo_skill.zip",
+ content_type="application/zip",
+ ),
+ },
+ )
+
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data["status"] == "ok"
+ assert data["message"] == "All 1 skill(s) uploaded successfully."
+ assert data["data"]["total"] == 1
+ assert data["data"]["succeeded"] == [
+ {"filename": "demo_skill.zip", "name": "demo_skill"}
+ ]
+ assert data["data"]["failed"] == []
+
+
+@pytest.mark.asyncio
+async def test_batch_upload_skills_accepts_valid_skill_archive(
+ app: Quart,
+ authenticated_header: dict,
+ monkeypatch,
+ tmp_path,
+):
+ data_dir = tmp_path / "data"
+ skills_dir = tmp_path / "skills"
+ temp_dir = tmp_path / "temp"
+ data_dir.mkdir()
+ skills_dir.mkdir()
+ temp_dir.mkdir()
+
+ async def _fake_sync_skills_to_active_sandboxes():
+ return
+
+ monkeypatch.setattr(
+ "astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
+ _fake_sync_skills_to_active_sandboxes,
+ )
+ monkeypatch.setattr(
+ "astrbot.core.skills.skill_manager.get_astrbot_data_path",
+ lambda: str(data_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.skills.skill_manager.get_astrbot_skills_path",
+ lambda: str(skills_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.core.skills.skill_manager.get_astrbot_temp_path",
+ lambda: str(temp_dir),
+ )
+ monkeypatch.setattr(
+ "astrbot.dashboard.routes.skills.get_astrbot_temp_path",
+ lambda: str(temp_dir),
+ )
+
+ archive = io.BytesIO()
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr(
+ "demo_skill/SKILL.md",
+ "---\nname: demo-skill\ndescription: Demo skill\n---\n",
+ )
+ zf.writestr("demo_skill/notes.txt", "hello")
+ zf.writestr("__MACOSX/demo_skill/._SKILL.md", "")
+ zf.writestr("__MACOSX/._demo_skill", "")
+ archive.seek(0)
+
+ test_client = app.test_client()
+
+ response = await test_client.post(
+ "/api/skills/batch-upload",
+ headers=authenticated_header,
+ files={
+ "files": FileStorage(
+ stream=archive,
+ filename="demo_skill.zip",
+ content_type="application/zip",
+ ),
+ },
+ )
+
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data["status"] == "ok"
+ assert data["data"]["succeeded"] == [
+ {"filename": "demo_skill.zip", "name": "demo_skill"}
+ ]
+ assert data["data"]["failed"] == []
+ assert (skills_dir / "demo_skill" / "SKILL.md").exists()
+
+
+@pytest.mark.asyncio
+async def test_batch_upload_skills_partial_success(
+ app: Quart,
+ authenticated_header: dict,
+ monkeypatch,
+):
+ async def _fake_sync_skills_to_active_sandboxes():
+ return
+
+ def _fake_install_skill_from_zip(
+ self,
+ zip_path: str,
+ *,
+ overwrite: bool = True,
+ ):
+ _ = self, overwrite
+ if "ok_skill" in zip_path:
+ return "ok_skill"
+ raise RuntimeError("install failed")
+
+ monkeypatch.setattr(
+ "astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
+ _fake_sync_skills_to_active_sandboxes,
+ )
+ monkeypatch.setattr(
+ "astrbot.dashboard.routes.skills.SkillManager.install_skill_from_zip",
+ _fake_install_skill_from_zip,
+ )
+
+ test_client = app.test_client()
+
+ boundary = "----AstrBotBatchBoundary"
+ body = (
+ (
+ f"--{boundary}\r\n"
+ 'Content-Disposition: form-data; name="files"; filename="ok_skill.zip"\r\n'
+ "Content-Type: application/zip\r\n\r\n"
+ ).encode()
+ + b"fake-zip-1\r\n"
+ + (
+ f"--{boundary}\r\n"
+ 'Content-Disposition: form-data; name="files"; filename="bad_skill.zip"\r\n'
+ "Content-Type: application/zip\r\n\r\n"
+ ).encode()
+ + b"fake-zip-2\r\n"
+ + f"--{boundary}--\r\n".encode()
+ )
+ headers = dict(authenticated_header)
+ headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
+
+ response = await test_client.post(
+ "/api/skills/batch-upload",
+ headers=headers,
+ data=body,
+ )
+
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data["status"] == "ok"
+ assert data["message"] == "Partial success: 1/2 skill(s) uploaded."
+ assert data["data"]["total"] == 2
+ assert data["data"]["succeeded"] == [
+ {"filename": "ok_skill.zip", "name": "ok_skill"}
+ ]
+ assert data["data"]["failed"] == [
+ {"filename": "bad_skill.zip", "error": "install failed"}
+ ]