mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-29 17:24:47 +08:00
feat(skills): add batch upload functionality for multiple skill ZIP files (#5804)
* feat(skills): add batch upload functionality for multiple skill ZIP files - Implemented a new endpoint for batch uploading skills. - Enhanced the SkillsSection component to support multiple file selection and drag-and-drop functionality. - Updated localization files for new upload features and messages. - Added tests to validate batch upload behavior and error handling. * feat(skills): improve batch upload handling and enhance accessibility for dropzone * feat(skills): enhance batch upload process and improve UI for better user experience * feat(skills): enhance skills upload dialog layout and styling for improved usability * feat(skills): update upload dialog description styling for better visibility and usability * feat(skills): improve upload dialog button styling and layout for enhanced usability * feat(skills): refine upload dialog text for clarity and consistency * feat(skills): enhance batch upload functionality by ignoring __MACOSX entries and improving upload dialog styling * feat(skills): refactor upload dialog and button styles for improved consistency and usability --------- Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
This commit is contained in:
co-authored by
whatevertogo
parent
51ff4f6e46
commit
812834bc9f
+227
-5
@@ -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"}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user