From c1de265baf58af995d341afbfc310aef1b9c3b0d Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Fri, 27 Feb 2026 15:22:07 +0800 Subject: [PATCH] feat(skills): mark sandbox preset skills readonly expose skill source metadata and sandbox cache status in the skills API response so the dashboard can distinguish local, sandbox-only, and synced skills. prevent enabling, disabling, or deleting sandbox-only preset skills in both backend guards and UI actions to avoid invalid local operations. add source badges, discovery-pending hinting for sandbox runtime, and new i18n strings for source labels and readonly warnings. --- astrbot/core/skills/skill_manager.py | 90 +++++++++--- astrbot/dashboard/routes/skills.py | 61 +++++++- .../components/extension/SkillsSection.vue | 135 ++++++++++++++++-- dashboard/src/components/shared/ItemCard.vue | 13 +- .../locales/en-US/features/extension.json | 10 +- .../locales/zh-CN/features/extension.json | 10 +- 6 files changed, 286 insertions(+), 33 deletions(-) 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 @@ -