diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 266093194..d15876526 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -32,6 +32,10 @@ class SkillInfo: description: str path: str active: bool + source_type: str = "local_only" + source_label: str = "local" + local_exists: bool = True + sandbox_exists: bool = False def _parse_frontmatter_description(text: str) -> str: @@ -164,6 +168,7 @@ class SkillManager: return { "version": int(data.get("version", _SANDBOX_SKILLS_CACHE_VERSION)), "skills": skills, + "updated_at": data.get("updated_at"), } except Exception: return {"version": _SANDBOX_SKILLS_CACHE_VERSION, "skills": []} @@ -198,6 +203,17 @@ class SkillManager: } self._save_sandbox_skills_cache(cache) + def get_sandbox_skills_cache_status(self) -> dict[str, object]: + cache = self._load_sandbox_skills_cache() + skills = cache.get("skills", []) + count = len(skills) if isinstance(skills, list) else 0 + return { + "exists": os.path.exists(self.sandbox_skills_cache_path), + "ready": count > 0, + "count": count, + "updated_at": cache.get("updated_at"), + } + def list_skills( self, *, @@ -217,15 +233,18 @@ class SkillManager: skills_by_name: dict[str, SkillInfo] = {} sandbox_cached_paths: dict[str, str] = {} - if runtime == "sandbox": - cache_for_paths = self._load_sandbox_skills_cache() - for item in cache_for_paths.get("skills", []): - if not isinstance(item, dict): - continue - name = str(item.get("name", "") or "").strip() - path = str(item.get("path", "") or "").strip().replace("\\", "/") - if name and path and _SKILL_NAME_RE.match(name): - sandbox_cached_paths[name] = path + sandbox_cached_descriptions: dict[str, str] = {} + cache_for_paths = self._load_sandbox_skills_cache() + for item in cache_for_paths.get("skills", []): + if not isinstance(item, dict): + continue + name = str(item.get("name", "") or "").strip() + path = str(item.get("path", "") or "").strip().replace("\\", "/") + if not name or not _SKILL_NAME_RE.match(name): + continue + sandbox_cached_descriptions[name] = str(item.get("description", "") or "") + if path: + sandbox_cached_paths[name] = path for entry in sorted(Path(self.skills_root).iterdir()): if not entry.is_dir(): @@ -246,6 +265,11 @@ class SkillManager: description = _parse_frontmatter_description(content) except Exception: description = "" + sandbox_exists = ( + runtime == "sandbox" and skill_name in sandbox_cached_descriptions + ) + source_type = "both" if sandbox_exists else "local_only" + source_label = "synced" if sandbox_exists else "local" if runtime == "sandbox" and show_sandbox_path: path_str = sandbox_cached_paths.get(skill_name) or ( f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" @@ -258,6 +282,10 @@ class SkillManager: description=description, path=path_str, active=active, + source_type=source_type, + source_label=source_label, + local_exists=True, + sandbox_exists=sandbox_exists, ) if runtime == "sandbox": @@ -278,22 +306,22 @@ class SkillManager: modified = True if active_only and not active: continue - description = str(item.get("description", "") or "") + description = sandbox_cached_descriptions.get(skill_name, "") if show_sandbox_path: - path_str = ( - f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - ) + path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" else: - path_str = str(item.get("path", "") or "") + path_str = sandbox_cached_paths.get(skill_name, "") if not path_str: - path_str = ( - f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - ) + path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" skills_by_name[skill_name] = SkillInfo( name=skill_name, description=description, path=path_str.replace("\\", "/"), active=active, + source_type="sandbox_only", + source_label="sandbox_preset", + local_exists=False, + sandbox_exists=True, ) if modified: @@ -302,7 +330,27 @@ class SkillManager: return [skills_by_name[name] for name in sorted(skills_by_name)] + def is_sandbox_only_skill(self, name: str) -> bool: + skill_dir = Path(self.skills_root) / name + skill_md_exists = (skill_dir / "SKILL.md").exists() + if skill_md_exists: + return False + cache = self._load_sandbox_skills_cache() + skills = cache.get("skills", []) + if not isinstance(skills, list): + return False + for item in skills: + if not isinstance(item, dict): + continue + if str(item.get("name", "")).strip() == name: + return True + return False + def set_skill_active(self, name: str, active: bool) -> None: + if self.is_sandbox_only_skill(name): + raise PermissionError( + "Sandbox preset skill cannot be enabled/disabled from local skill management." + ) config = self._load_config() config.setdefault("skills", {}) config["skills"][name] = {"active": bool(active)} @@ -318,8 +366,7 @@ class SkillManager: item for item in skills if not ( - isinstance(item, dict) - and str(item.get("name", "")).strip() == name + isinstance(item, dict) and str(item.get("name", "")).strip() == name ) ] @@ -328,6 +375,11 @@ class SkillManager: self._save_sandbox_skills_cache(cache) def delete_skill(self, name: str) -> None: + if self.is_sandbox_only_skill(name): + raise PermissionError( + "Sandbox preset skill cannot be deleted from local skill management." + ) + skill_dir = Path(self.skills_root) / name if skill_dir.exists(): shutil.rmtree(skill_dir) diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index 65fdaef50..adad49615 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -1,9 +1,12 @@ import os +import re +import shutil import traceback from collections.abc import Awaitable, Callable +from pathlib import Path from typing import Any -from quart import request +from quart import request, send_file from astrbot.core import DEMO_MODE, logger from astrbot.core.computer.computer_client import ( @@ -37,6 +40,9 @@ def _to_bool(value: Any, default: bool = False) -> bool: return bool(value) +_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") + + class SkillsRoute(Route): def __init__(self, context: RouteContext, core_lifecycle) -> None: super().__init__(context) @@ -44,6 +50,7 @@ class SkillsRoute(Route): self.routes = { "/skills": ("GET", self.get_skills), "/skills/upload": ("POST", self.upload_skill), + "/skills/download": ("GET", self.download_skill), "/skills/update": ("POST", self.update_skill), "/skills/delete": ("POST", self.delete_skill), "/skills/neo/candidates": ("GET", self.get_neo_candidates), @@ -116,7 +123,8 @@ class SkillsRoute(Route): "provider_settings", {} ) runtime = provider_settings.get("computer_use_runtime", "local") - skills = SkillManager().list_skills( + skill_mgr = SkillManager() + skills = skill_mgr.list_skills( active_only=False, runtime=runtime, show_sandbox_path=False ) return ( @@ -124,6 +132,8 @@ class SkillsRoute(Route): .ok( { "skills": [skill.__dict__ for skill in skills], + "runtime": runtime, + "sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(), } ) .__dict__ @@ -178,6 +188,53 @@ class SkillsRoute(Route): except Exception: logger.warning(f"Failed to remove temp skill file: {temp_path}") + async def download_skill(self): + try: + name = str(request.args.get("name") or "").strip() + if not name: + return Response().error("Missing skill name").__dict__ + if not _SKILL_NAME_RE.match(name): + return Response().error("Invalid skill name").__dict__ + + skill_mgr = SkillManager() + if skill_mgr.is_sandbox_only_skill(name): + return ( + Response() + .error( + "Sandbox preset skill cannot be downloaded from local skill files." + ) + .__dict__ + ) + + skill_dir = Path(skill_mgr.skills_root) / name + skill_md = skill_dir / "SKILL.md" + if not skill_dir.is_dir() or not skill_md.exists(): + return Response().error("Local skill not found").__dict__ + + export_dir = Path(get_astrbot_temp_path()) / "skill_exports" + export_dir.mkdir(parents=True, exist_ok=True) + zip_base = export_dir / name + zip_path = zip_base.with_suffix(".zip") + if zip_path.exists(): + zip_path.unlink() + + shutil.make_archive( + str(zip_base), + "zip", + root_dir=str(skill_mgr.skills_root), + base_dir=name, + ) + + return await send_file( + str(zip_path), + as_attachment=True, + attachment_filename=f"{name}.zip", + conditional=True, + ) + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(str(e)).__dict__ + async def update_skill(self): if DEMO_MODE: return ( diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index 54a38a248..c26f37abe 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -23,8 +23,17 @@ -
+
{{ tm("skills.runtimeHint") }} + + {{ tm("skills.sandboxDiscoveryPending") }} +
@@ -42,27 +51,58 @@ {{ tm("skills.emptyHint") }}
- - + + + @@ -288,6 +328,8 @@ export default { const mode = ref("local"); const skills = ref([]); const loading = ref(false); + const runtime = ref("local"); + const sandboxCache = reactive({ ready: false, count: 0, updated_at: null }); const uploading = ref(false); const uploadDialog = ref(false); const uploadFile = ref(null); @@ -357,10 +399,35 @@ export default { const normalizeSkillsPayload = (res) => { const payload = res?.data?.data || []; - if (Array.isArray(payload)) return payload; + if (Array.isArray(payload)) { + runtime.value = "local"; + sandboxCache.ready = false; + sandboxCache.count = 0; + sandboxCache.updated_at = null; + return payload; + } + runtime.value = payload.runtime || "local"; + const cache = payload.sandbox_cache || {}; + sandboxCache.ready = !!cache.ready; + sandboxCache.count = Number(cache.count || 0); + sandboxCache.updated_at = cache.updated_at || null; return payload.skills || []; }; + const sourceTypeLabel = (sourceType) => { + if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly"); + if (sourceType === "both") return tm("skills.sourceBoth"); + return tm("skills.sourceLocalOnly"); + }; + + const sourceTypeColor = (sourceType) => { + if (sourceType === "sandbox_only") return "indigo"; + if (sourceType === "both") return "success"; + return "primary"; + }; + + const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only"; + const normalizeNeoItemsPayload = (res) => { const payload = res?.data?.data || []; if (Array.isArray(payload)) return payload; @@ -417,6 +484,10 @@ export default { }; const toggleSkill = async (skill) => { + if (isSandboxPresetSkill(skill)) { + showMessage(tm("skills.sandboxPresetReadonly"), "warning"); + return; + } const nextActive = !skill.active; itemLoading[skill.name] = true; try { @@ -435,6 +506,10 @@ export default { }; const confirmDelete = (skill) => { + if (isSandboxPresetSkill(skill)) { + showMessage(tm("skills.sandboxPresetReadonly"), "warning"); + return; + } skillToDelete.value = skill; deleteDialog.value = true; }; @@ -457,6 +532,34 @@ export default { } }; + const downloadSkill = async (skill) => { + if (isSandboxPresetSkill(skill)) { + showMessage(tm("skills.sandboxPresetReadonly"), "warning"); + return; + } + itemLoading[skill.name] = true; + try { + const res = await axios.get("/api/skills/download", { + params: { name: skill.name }, + responseType: "blob", + }); + const blob = new Blob([res.data], { type: "application/zip" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${skill.name}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + showMessage(tm("skills.downloadSuccess"), "success"); + } catch (_err) { + showMessage(tm("skills.downloadFailed"), "error"); + } finally { + itemLoading[skill.name] = false; + } + }; + const fetchNeoCandidates = async () => { const params = { skill_key: neoFilters.skill_key || undefined, @@ -685,6 +788,8 @@ export default { mode, skills, loading, + runtime, + sandboxCache, uploadDialog, uploadFile, uploading, @@ -707,6 +812,7 @@ export default { refreshCurrentMode, fetchNeoData, uploadSkill, + downloadSkill, toggleSkill, confirmDelete, deleteSkill, @@ -719,6 +825,9 @@ export default { viewPayload, deleteCandidate, deleteRelease, + sourceTypeLabel, + sourceTypeColor, + isSandboxPresetSkill, }; }, }; @@ -730,6 +839,16 @@ export default { -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; + min-height: 20px; +} + +.skill-path { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 40px; + word-break: break-all; } .payload-preview { diff --git a/dashboard/src/components/shared/ItemCard.vue b/dashboard/src/components/shared/ItemCard.vue index 976258cac..37745c991 100644 --- a/dashboard/src/components/shared/ItemCard.vue +++ b/dashboard/src/components/shared/ItemCard.vue @@ -10,7 +10,7 @@ density="compact" :model-value="getItemEnabled()" :loading="loading" - :disabled="loading" + :disabled="loading || disableToggle" v-bind="props" @update:model-value="toggleEnabled" > @@ -29,7 +29,7 @@ color="error" size="small" rounded="xl" - :disabled="loading" + :disabled="loading || disableDelete" @click="$emit('delete', item)" > {{ t('core.common.itemCard.delete') }} @@ -108,6 +108,14 @@ export default { showEditButton: { type: Boolean, default: true + }, + disableToggle: { + type: Boolean, + default: false + }, + disableDelete: { + type: Boolean, + default: false } }, emits: ['toggle-enabled', 'delete', 'edit', 'copy'], @@ -132,6 +140,7 @@ export default { transition: all 0.3s ease; overflow: hidden; min-height: 220px; + height: 100%; display: flex; flex-direction: column; justify-content: space-between; diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index a8e2eed0a..85ab70b8b 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -220,6 +220,9 @@ "path": "Path", "uploadSuccess": "Upload succeeded", "uploadFailed": "Upload failed", + "download": "Download", + "downloadSuccess": "Download succeeded", + "downloadFailed": "Download failed", "loadFailed": "Failed to load Skills", "updateSuccess": "Updated successfully", "updateFailed": "Update failed", @@ -253,7 +256,12 @@ "neoPayloadFailed": "Failed to load payload", "runtimeNoneWarning": "Computer Use runtime is set to None; Skills may not run correctly because no runtime is enabled.", "runtimeHint": "Set the Computer Use runtime to Local or Sandbox in settings so AstrBot can use your Skills.", - "neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo." + "neoRuntimeRequired": "Neo Skills are available only when runtime is sandbox and sandbox booter is shipyard_neo.", + "sourceLocalOnly": "Local Skill", + "sourceSandboxOnly": "Sandbox Preset Skill", + "sourceBoth": "Local + Sandbox", + "sandboxDiscoveryPending": "Sandbox preset skills have not been discovered yet. Start at least one sandbox session to populate this list.", + "sandboxPresetReadonly": "Sandbox preset skills are read-only here. You cannot delete or enable/disable them from Local Skills." }, "card": { "actions": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 017699d18..d2ab828f4 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -220,6 +220,9 @@ "path": "路径", "uploadSuccess": "上传成功", "uploadFailed": "上传失败", + "download": "下载", + "downloadSuccess": "下载成功", + "downloadFailed": "下载失败", "loadFailed": "加载 Skills 失败", "updateSuccess": "更新成功", "updateFailed": "更新失败", @@ -256,7 +259,12 @@ "neoPayloadFailed": "读取 Payload 失败", "runtimeNoneWarning": "Computer Use 运行环境为无,Skills 可能无法正确被 Agent 运行,因为没有启用运行环境。", "runtimeHint": "需要在配置的 “使用电脑能力” 中将运行环境设置为 “local” 或 “sandbox” 才能让 AstrBot 正常使用你提供的 Skills。", - "neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。" + "neoRuntimeRequired": "Neo Skills 仅在运行环境为 sandbox 且沙箱驱动为 shipyard_neo 时可用。", + "sourceLocalOnly": "本地 Skill", + "sourceSandboxOnly": "Sandbox 预置 Skill", + "sourceBoth": "本地 + Sandbox", + "sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。", + "sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。" }, "card": { "actions": {