diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 792970cc3b..3916215e5b 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -38,8 +38,13 @@ from astrbot.core.provider import Provider from astrbot.core.provider.entities import ProviderRequest from astrbot.core.provider.register import llm_tools -from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt +from astrbot.core.skills.skill_manager import ( + SkillInfo, + SkillManager, + build_skills_prompt, +) from astrbot.core.star.context import Context +from astrbot.core.star.star import star_registry from astrbot.core.star.star_handler import star_map from astrbot.core.tools.computer_tools import ( AnnotateExecutionTool, @@ -375,6 +380,38 @@ def _build_local_mode_prompt() -> str: ) +def _filter_skills_for_current_config( + skills: list[SkillInfo], + cfg: dict, +) -> list[SkillInfo]: + plugin_set = cfg.get("plugin_set", ["*"]) + allowed_plugins = ( + None + if not isinstance(plugin_set, list) or "*" in plugin_set + else {str(name) for name in plugin_set} + ) + plugin_by_root_dir = { + metadata.root_dir_name: metadata + for metadata in star_registry + if metadata.root_dir_name + } + filtered: list[SkillInfo] = [] + for skill in skills: + if skill.source_type != "plugin": + filtered.append(skill) + continue + + plugin = plugin_by_root_dir.get(skill.plugin_name) + if not plugin or not plugin.activated: + continue + if plugin.reserved or allowed_plugins is None: + filtered.append(skill) + continue + if plugin.name is not None and plugin.name in allowed_plugins: + filtered.append(skill) + return filtered + + async def _ensure_persona_and_skills( req: ProviderRequest, cfg: dict, @@ -417,6 +454,7 @@ async def _ensure_persona_and_skills( runtime = cfg.get("computer_use_runtime", "local") skill_manager = SkillManager() skills = skill_manager.list_skills(active_only=True, runtime=runtime) + skills = _filter_skills_for_current_config(skills, cfg) if skills: if persona and persona.get("skills") is not None: diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 648c771235..a50549fba7 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -31,6 +31,39 @@ def _list_local_skill_dirs(skills_root: Path) -> list[Path]: return skills +def _collect_sync_skill_dirs() -> list[tuple[str, Path]]: + """Collect local and plugin-provided skills that should be synced.""" + skills_root = Path(get_astrbot_skills_path()) + if not skills_root.is_dir(): + return [] + + try: + skill_manager = SkillManager(skills_root=str(skills_root)) + except OSError as exc: + logger.warning("[Computer] Failed to initialize skill manager: %s", exc) + return [] + + sync_dirs: list[tuple[str, Path]] = [] + for skill in skill_manager.list_skills( + active_only=False, + runtime="local", + show_sandbox_path=False, + ): + if skill.source_type == "sandbox_only": + continue + skill_md = Path(skill.path) + if not skill_md.is_file(): + continue + sync_dirs.append((skill.name, skill_md.parent)) + return sync_dirs + + +def _normalize_shell_exec_result(result: object) -> dict: + if isinstance(result, dict): + return result + return {"exit_code": 0, "stdout": "", "stderr": ""} + + def _discover_bay_credentials(endpoint: str) -> str: """Try to auto-discover Bay API key from credentials.json. @@ -351,7 +384,9 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None: executed in a separate phase to keep failure domains clear. """ logger.info("[Computer] Skill sync phase=apply start") - apply_result = await booter.shell.exec(_build_apply_sync_command()) + apply_result = _normalize_shell_exec_result( + await booter.shell.exec(_build_apply_sync_command()) + ) if not _shell_exec_succeeded(apply_result): detail = _format_exec_error_detail(apply_result) logger.error("[Computer] Skill sync phase=apply failed: %s", detail) @@ -362,7 +397,9 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None: async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None: """Scan sandbox skills and return normalized payload for cache update.""" logger.info("[Computer] Skill sync phase=scan start") - scan_result = await booter.shell.exec(_build_scan_command()) + scan_result = _normalize_shell_exec_result( + await booter.shell.exec(_build_scan_command()) + ) if not _shell_exec_succeeded(scan_result): detail = _format_exec_error_detail(scan_result) logger.error("[Computer] Skill sync phase=scan failed: %s", detail) @@ -382,21 +419,24 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: Backward-compatible orchestrator: keep historical behavior while internally splitting into `apply` and `scan` phases. """ - skills_root = Path(get_astrbot_skills_path()) - if not skills_root.is_dir(): - return - local_skill_dirs = _list_local_skill_dirs(skills_root) + sync_skill_dirs = _collect_sync_skill_dirs() temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) zip_base = temp_dir / "skills_bundle" zip_path = zip_base.with_suffix(".zip") + bundle_root = temp_dir / f"skills_bundle_{uuid.uuid4().hex}" try: - if local_skill_dirs: + if sync_skill_dirs: if zip_path.exists(): zip_path.unlink() - shutil.make_archive(str(zip_base), "zip", str(skills_root)) + if bundle_root.exists(): + shutil.rmtree(bundle_root) + bundle_root.mkdir(parents=True) + for skill_name, skill_dir in sync_skill_dirs: + shutil.copytree(skill_dir, bundle_root / skill_name) + shutil.make_archive(str(zip_base), "zip", str(bundle_root)) remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip" logger.info("Uploading skills bundle to sandbox...") await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}") @@ -420,6 +460,11 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: len(managed), ) finally: + if bundle_root.exists(): + try: + shutil.rmtree(bundle_root) + except Exception: + logger.warning(f"Failed to remove temp skills bundle: {bundle_root}") if zip_path.exists(): try: zip_path.unlink() diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index a8121c42a4..838301c044 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -16,6 +16,7 @@ from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, + get_astrbot_plugin_path, get_astrbot_skills_path, get_astrbot_temp_path, ) @@ -64,7 +65,11 @@ def _is_ignored_zip_entry(name: str) -> bool: return parts[0] == "__MACOSX" -def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None: +def _normalize_skill_markdown_path( + skill_dir: Path, + *, + rename_legacy: bool = True, +) -> Path | None: """Return the canonical `SKILL.md` path for a skill directory. If only legacy `skill.md` exists, it is renamed to `SKILL.md` in-place. @@ -79,6 +84,8 @@ def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None: if "skill.md" not in entries: return None try: + if not rename_legacy: + return legacy tmp = skill_dir / f".{uuid.uuid4().hex}.tmp_skill_md" legacy.rename(tmp) tmp.rename(canonical) @@ -97,6 +104,8 @@ class SkillInfo: source_label: str = "local" local_exists: bool = True sandbox_exists: bool = False + plugin_name: str = "" + readonly: bool = False def _parse_frontmatter_description(text: str) -> str: @@ -274,13 +283,60 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str: class SkillManager: - def __init__(self, skills_root: str | None = None) -> None: + def __init__( + self, + skills_root: str | None = None, + plugins_root: str | None = None, + ) -> None: self.skills_root = skills_root or get_astrbot_skills_path() + self.plugins_root = plugins_root or get_astrbot_plugin_path() data_path = Path(get_astrbot_data_path()) self.config_path = str(data_path / SKILLS_CONFIG_FILENAME) self.sandbox_skills_cache_path = str(data_path / SANDBOX_SKILLS_CACHE_FILENAME) os.makedirs(self.skills_root, exist_ok=True) + def _iter_plugin_skill_dirs(self) -> list[tuple[str, str, Path]]: + """Return plugin-provided skill directories as (skill, plugin, dir).""" + plugins_root = Path(self.plugins_root) + if not plugins_root.is_dir(): + return [] + + result: list[tuple[str, str, Path]] = [] + for plugin_dir in sorted(plugins_root.iterdir(), key=lambda item: item.name): + if not plugin_dir.is_dir(): + continue + plugin_name = plugin_dir.name + skills_dir = plugin_dir / "skills" + if not skills_dir.is_dir(): + continue + + direct_skill_md = _normalize_skill_markdown_path( + skills_dir, + rename_legacy=False, + ) + if direct_skill_md is not None and _SKILL_NAME_RE.match(plugin_name): + result.append((plugin_name, plugin_name, skills_dir)) + + for skill_dir in sorted(skills_dir.iterdir(), key=lambda item: item.name): + if not skill_dir.is_dir(): + continue + skill_name = skill_dir.name + if not _SKILL_NAME_RE.match(skill_name): + continue + if ( + _normalize_skill_markdown_path(skill_dir, rename_legacy=False) + is None + ): + continue + result.append((skill_name, plugin_name, skill_dir)) + return result + + def _get_plugin_skill_dir(self, name: str) -> Path | None: + for skill_name, _plugin_name, skill_dir in self._iter_plugin_skill_dirs(): + if skill_name == name: + return skill_dir + return None + def _load_config(self) -> dict: if not os.path.exists(self.config_path): self._save_config(DEFAULT_SKILLS_CONFIG.copy()) @@ -430,6 +486,46 @@ def list_skills( sandbox_exists=sandbox_exists, ) + for skill_name, plugin_name, skill_dir in self._iter_plugin_skill_dirs(): + if skill_name in skills_by_name: + continue + skill_md = _normalize_skill_markdown_path(skill_dir, rename_legacy=False) + if skill_md is None: + continue + active = skill_configs.get(skill_name, {}).get("active", True) + if skill_name not in skill_configs: + skill_configs[skill_name] = {"active": active} + modified = True + if active_only and not active: + continue + description = "" + try: + content = skill_md.read_text(encoding="utf-8") + description = _parse_frontmatter_description(content) + except Exception: + description = "" + sandbox_exists = ( + runtime == "sandbox" and skill_name in sandbox_cached_descriptions + ) + if runtime == "sandbox" and show_sandbox_path: + path_str = sandbox_cached_paths.get( + skill_name + ) or _default_sandbox_skill_path(skill_name) + else: + path_str = str(skill_md) + skills_by_name[skill_name] = SkillInfo( + name=skill_name, + description=description, + path=path_str.replace("\\", "/"), + active=active, + source_type="plugin", + source_label=plugin_name, + local_exists=True, + sandbox_exists=sandbox_exists, + plugin_name=plugin_name, + readonly=True, + ) + if runtime == "sandbox": cache = self._load_sandbox_skills_cache() for item in cache.get("skills", []): @@ -488,6 +584,9 @@ def is_sandbox_only_skill(self, name: str) -> bool: return True return False + def is_plugin_skill(self, name: str) -> bool: + return self._get_plugin_skill_dir(name) is not None + def set_skill_active(self, name: str, active: bool) -> None: if self.is_sandbox_only_skill(name): raise PermissionError( @@ -521,6 +620,10 @@ def delete_skill(self, name: str) -> None: raise PermissionError( "Sandbox preset skill cannot be deleted from local skill management." ) + if self.is_plugin_skill(name): + raise PermissionError( + "Plugin-provided skill cannot be deleted from local skill management." + ) skill_dir = Path(self.skills_root) / name if skill_dir.exists(): diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index f15f28dec9..5660022fd0 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -12,9 +12,11 @@ access depends on the local runtime implementation and host OS permissions. Upload and download tools are defined here, but `LocalBooter` does not implement them and the main agent does not expose them in local mode. -- Member + local: read/write/edit/grep are restricted to `data/skills`, - `data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`. Upload/download are - denied by `check_admin_permission` if invoked. +- Member + local: read/grep are restricted to `data/skills`, + plugin-provided `data/plugins/*/skills`, + `data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`; write/edit are + restricted to the same local roots except plugin-provided Skills, which are + read-only. Upload/download are denied by `check_admin_permission` if invoked. - Admin + sandbox: read/write/edit/grep are not path-restricted by this module; sandbox filesystem boundaries are enforced by the sandbox runtime. Upload and @@ -45,6 +47,7 @@ from astrbot.core.computer.file_read_utils import read_file_tool_result from astrbot.core.message.components import File, Image from astrbot.core.utils.astrbot_path import ( + get_astrbot_plugin_path, get_astrbot_skills_path, get_astrbot_system_tmp_path, get_astrbot_temp_path, @@ -67,15 +70,22 @@ _IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"} -def _restricted_env_path_labels(umo: str) -> list[str]: +def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]: """Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment""" normalized_umo = normalize_umo_for_workspace(umo) - return [ + labels = [ "data/skills", - f"data/workspaces/{normalized_umo}", - get_astrbot_system_tmp_path(), - get_astrbot_temp_path(), ] + if include_plugin_skills: + labels.append("data/plugins/*/skills") + labels.extend( + [ + f"data/workspaces/{normalized_umo}", + get_astrbot_system_tmp_path(), + get_astrbot_temp_path(), + ] + ) + return labels def get_astrbot_workspaces_path() -> str: @@ -89,8 +99,30 @@ def _workspace_root(umo: str) -> Path: return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False) +def _plugin_skill_roots() -> tuple[Path, ...]: + plugins_root = Path(get_astrbot_plugin_path()) + if not plugins_root.exists(): + return () + return tuple( + (plugin_dir / "skills").resolve(strict=False) + for plugin_dir in plugins_root.iterdir() + if plugin_dir.is_dir() and (plugin_dir / "skills").is_dir() + ) + + def _read_allowed_roots(umo: str) -> tuple[Path, ...]: """Non-admin users can only read files within these directories (and their subdirectories)""" + return ( + Path(get_astrbot_skills_path()).resolve(strict=False), + *_plugin_skill_roots(), + _workspace_root(umo), + Path(get_astrbot_system_tmp_path()).resolve(strict=False), + Path(get_astrbot_temp_path()).resolve(strict=False), + ) + + +def _write_allowed_roots(umo: str) -> tuple[Path, ...]: + """Non-admin users cannot modify plugin-provided Skills.""" return ( Path(get_astrbot_skills_path()).resolve(strict=False), _workspace_root(umo), @@ -131,11 +163,16 @@ def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path: return (Path.cwd() / candidate).resolve(strict=False) -def _is_path_within_allowed_roots(path: str, umo: str) -> bool: +def _is_path_within_allowed_roots( + path: str, + *, + umo: str, + allowed_roots: tuple[Path, ...], +) -> bool: resolved = _resolve_user_path(path, local_env=True, umo=umo) return any( resolved == allowed_root or resolved.is_relative_to(allowed_root) - for allowed_root in _read_allowed_roots(umo) + for allowed_root in allowed_roots ) @@ -145,14 +182,24 @@ def _normalize_rw_path( restricted: bool, local_env: bool, umo: str, + write: bool = False, ) -> str: normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo) if not normalized_path: raise ValueError("`path` must be a non-empty string.") - if restricted and not _is_path_within_allowed_roots(normalized_path, umo): - allowed = ", ".join(_restricted_env_path_labels(umo)) + if restricted: + allowed_roots = _write_allowed_roots(umo) if write else _read_allowed_roots(umo) + if restricted and not _is_path_within_allowed_roots( + normalized_path, + umo=umo, + allowed_roots=allowed_roots, + ): + allowed = ", ".join( + _restricted_env_path_labels(umo, include_plugin_skills=not write) + ) + access = "Write" if write else "Read" raise PermissionError( - "Read access is restricted for this user. " + f"{access} access is restricted for this user. " f"Allowed directories: {allowed}. Blocked path: {normalized_path}." ) return normalized_path @@ -290,6 +337,7 @@ async def call( restricted=restricted, local_env=local_env, umo=context.context.event.unified_msg_origin, + write=True, ) if local_env else path.strip() @@ -368,6 +416,7 @@ async def call( restricted=restricted, local_env=local_env, umo=umo, + write=True, ) if local_env else path.strip() @@ -532,10 +581,16 @@ def _normalize_search_paths( disallowed = [ path for path in normalized - if not _is_path_within_allowed_roots(path, umo) + if not _is_path_within_allowed_roots( + path, + umo=umo, + allowed_roots=_read_allowed_roots(umo), + ) ] if disallowed: - allowed = ", ".join(_restricted_env_path_labels(umo)) + allowed = ", ".join( + _restricted_env_path_labels(umo, include_plugin_skills=True) + ) blocked = ", ".join(disallowed) raise PermissionError( "Read access is restricted for this user. " diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index f835a2ad63..95479fbdab 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -14,7 +14,9 @@ from astrbot.api import sp from astrbot.core import DEMO_MODE, file_token_service, logger +from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.skills.skill_manager import SkillManager from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.permission import PermissionTypeFilter @@ -34,6 +36,13 @@ PLUGIN_UPDATE_CONCURRENCY = ( 3 # limit concurrent updates to avoid overwhelming plugin sources ) +PLUGIN_COMPONENT_TYPE_ORDER = { + "skill": 0, + "command": 1, + "llm_tool": 2, + "listener": 3, + "hook": 4, +} @dataclass @@ -53,7 +62,7 @@ def __init__( super().__init__(context) self.routes = { "/plugin/get": ("GET", self.get_plugins), - "/plugin/check-compat": ("POST", self.check_plugin_compatibility), + "/plugin/detail": ("GET", self.get_plugin_detail), "/plugin/install": ("POST", self.install_plugin), "/plugin/install-upload": ("POST", self.install_plugin_upload), "/plugin/update": ("POST", self.update_plugin), @@ -89,26 +98,11 @@ def __init__( self._logo_cache = {} - async def check_plugin_compatibility(self): + async def _sync_skills_after_plugin_change(self) -> None: try: - data = await request.get_json() - version_spec = data.get("astrbot_version", "") - is_valid, message = self.plugin_manager._validate_astrbot_version_specifier( - version_spec - ) - return ( - Response() - .ok( - { - "compatible": is_valid, - "message": message, - "astrbot_version": version_spec, - } - ) - .__dict__ - ) - except Exception as e: - return Response().error(str(e)).__dict__ + await sync_skills_to_active_sandboxes() + except Exception: + logger.warning("Failed to sync plugin-provided skills to active sandboxes.") async def reload_failed_plugins(self): if DEMO_MODE: @@ -129,6 +123,7 @@ async def reload_failed_plugins(self): success, err = await self.plugin_manager.reload_failed_plugin(dir_name) if success: + await self._sync_skills_after_plugin_change() return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__ else: return Response().error(f"重载失败: {err}").__dict__ @@ -151,6 +146,7 @@ async def reload_plugins(self): success, message = await self.plugin_manager.reload(plugin_name) if not success: return Response().error(message or "插件重载失败").__dict__ + await self._sync_skills_after_plugin_change() return Response().ok(None, "重载成功。").__dict__ except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") @@ -403,9 +399,6 @@ async def get_plugins(self): "reserved": plugin.reserved, "activated": plugin.activated, "online_vesion": "", - "handlers": await self.get_plugin_handlers_info( - plugin.star_handler_full_names, - ), "display_name": plugin.display_name, "logo": f"/api/file/{logo_url}" if logo_url else None, "support_platforms": plugin.support_platforms, @@ -431,13 +424,66 @@ async def get_plugins(self): .__dict__ ) + async def get_plugin_detail(self): + plugin_name = request.args.get("name") + if not plugin_name: + return Response().error("缺少插件名").__dict__ + + for plugin in self.plugin_manager.context.get_all_stars(): + if plugin.name != plugin_name: + continue + + logo_url = None + if plugin.logo_path: + logo_url = await self.get_plugin_logo_token(plugin.logo_path) + + return ( + Response() + .ok( + { + "name": plugin.name, + "repo": "" if plugin.repo is None else plugin.repo, + "author": plugin.author, + "desc": plugin.desc, + "version": plugin.version, + "reserved": plugin.reserved, + "activated": plugin.activated, + "online_vesion": "", + "components": await self.get_plugin_components_info(plugin), + "display_name": plugin.display_name, + "logo": f"/api/file/{logo_url}" if logo_url else None, + "support_platforms": plugin.support_platforms, + "astrbot_version": plugin.astrbot_version, + "installed_at": self._get_plugin_installed_at(plugin), + "i18n": plugin.i18n, + } + ) + .__dict__ + ) + + return Response().error("插件不存在").__dict__ + async def get_failed_plugins(self): """专门获取加载失败的插件列表(字典格式)""" return Response().ok(self.plugin_manager.failed_plugin_dict).__dict__ - async def get_plugin_handlers_info(self, handler_full_names: list[str]): - """解析插件行为""" - handlers = [] + async def get_plugin_components_info(self, plugin): + """Build plugin components for the dashboard.""" + handler_components = await self.get_plugin_handler_components( + plugin.star_handler_full_names, + ) + components = [ + *self.get_plugin_skill_components(plugin), + *handler_components, + ] + return sorted( + components, + key=lambda item: PLUGIN_COMPONENT_TYPE_ORDER.get(item["type"], 99), + ) + + async def get_plugin_handler_components(self, handler_full_names: list[str]): + """Build behavior components from registered handlers.""" + components = [] for handler_full_name in handler_full_names: info = {} @@ -453,48 +499,254 @@ async def get_plugin_handlers_info(self, handler_full_names: list[str]): handler.event_type.name, ) info["handler_full_name"] = handler.handler_full_name - info["desc"] = handler.desc + info["description"] = handler.desc or "无描述" info["handler_name"] = handler.handler_name + component_type = "hook" + component = None if handler.event_type == EventType.AdapterMessageEvent: # 处理平台适配器消息事件 has_admin = False - for filter in ( + for event_filter in ( handler.event_filters ): # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高 - if isinstance(filter, CommandFilter): - info["type"] = "指令" - info["cmd"] = ( - f"{filter.parent_command_names[0]} {filter.command_name}" + if isinstance(event_filter, CommandFilter): + component_type = "command" + info["display_type"] = "指令" + info["cmd"] = self._get_command_filter_display_name( + event_filter ) + component = self._build_command_filter_component( + event_filter, + handler.desc, + ) + elif isinstance(event_filter, CommandGroupFilter): + component_type = "command" + info["display_type"] = "指令组" + info["cmd"] = event_filter.get_complete_command_names()[0] info["cmd"] = info["cmd"].strip() - elif isinstance(filter, CommandGroupFilter): - info["type"] = "指令组" - info["cmd"] = filter.get_complete_command_names()[0] - info["cmd"] = info["cmd"].strip() - info["sub_command"] = filter.print_cmd_tree( - filter.sub_command_filters, + component = self._build_command_group_component( + event_filter, + handler.desc, ) - elif isinstance(filter, RegexFilter): - info["type"] = "正则匹配" - info["cmd"] = filter.regex_str - elif isinstance(filter, PermissionTypeFilter): + elif isinstance(event_filter, RegexFilter): + component_type = "command" + info["display_type"] = "正则匹配" + info["cmd"] = event_filter.regex_str + component = { + "type": "command", + "name": event_filter.regex_str, + "description": handler.desc or "无描述", + "match": "regex", + } + elif isinstance(event_filter, PermissionTypeFilter): has_admin = True info["has_admin"] = has_admin if "cmd" not in info: info["cmd"] = "未知" - if "type" not in info: - info["type"] = "事件监听器" + if "display_type" not in info: + info["display_type"] = "事件监听器" + component_type = "listener" else: info["cmd"] = "自动触发" - info["type"] = "无" + info["display_type"] = "无" + if handler.event_type == EventType.OnCallingFuncToolEvent: + component_type = "llm_tool" + + if component is None: + component = { + "type": component_type, + "name": handler.handler_name or handler.event_type.name, + "description": handler.desc or "无描述", + } + else: + component["type"] = component_type + + if component_type == "command": + component["event_type"] = info["event_type"] + component["event_type_h"] = info["event_type_h"] + component["handler_name"] = info["handler_name"] + component["has_admin"] = info.get("has_admin", False) + if "display_type" in info: + component["display_type"] = info["display_type"] + if "cmd" in info: + component["command"] = info["cmd"] + else: + component.update(info) + components.append(component) + + return self._merge_command_components(components) + + def get_plugin_skill_components(self, plugin): + """Build skill components provided by this plugin.""" + plugin_names = { + str(name) + for name in (plugin.root_dir_name, plugin.name) + if str(name or "").strip() + } + if not plugin_names: + return [] - if not info["desc"]: - info["desc"] = "无描述" + try: + skills = SkillManager().list_skills( + active_only=False, + runtime="local", + show_sandbox_path=False, + ) + except Exception as exc: + logger.warning(f"获取插件 Skills 失败 {plugin.name}: {exc!s}") + return [] - handlers.append(info) + components = [] + for skill in skills: + if skill.source_type != "plugin" or skill.plugin_name not in plugin_names: + continue + components.append( + { + "type": "skill", + "name": skill.name, + "description": skill.description or "无描述", + "path": skill.path, + } + ) + return components - return handlers + def _get_command_filter_display_name(self, command_filter: CommandFilter) -> str: + return command_filter.get_complete_command_names()[0].strip() + + def _get_command_description( + self, + command_filter: CommandFilter | CommandGroupFilter, + fallback: str = "", + ) -> str: + handler_md = getattr(command_filter, "handler_md", None) + desc = getattr(handler_md, "desc", "") if handler_md else "" + return desc or fallback or "无描述" + + def _build_command_filter_component( + self, + command_filter: CommandFilter, + fallback_desc: str = "", + ) -> dict: + parts = self._get_command_filter_display_name(command_filter).split() + if not parts: + parts = [command_filter.command_name] + component = { + "type": "command", + "name": parts[-1], + "description": self._get_command_description( + command_filter, + fallback_desc, + ), + } + return self._wrap_command_component(parts[:-1], component) + + def _build_command_group_component( + self, + command_group_filter: CommandGroupFilter, + fallback_desc: str = "", + ) -> dict: + parts = command_group_filter.get_complete_command_names()[0].strip().split() + if not parts: + parts = [command_group_filter.group_name] + subcommands = [ + self._build_command_group_child(sub_filter) + for sub_filter in command_group_filter.sub_command_filters + ] + component = { + "type": "command", + "name": parts[-1], + "description": self._get_command_description( + command_group_filter, + fallback_desc, + ), + } + if subcommands: + component["subcommands"] = subcommands + return self._wrap_command_component(parts[:-1], component) + + def _build_command_group_child( + self, + command_filter: CommandFilter | CommandGroupFilter, + ) -> dict: + if isinstance(command_filter, CommandGroupFilter): + component = { + "name": command_filter.group_name, + "description": self._get_command_description(command_filter), + } + subcommands = [ + self._build_command_group_child(sub_filter) + for sub_filter in command_filter.sub_command_filters + ] + if subcommands: + component["subcommands"] = subcommands + return component + + return { + "name": command_filter.command_name, + "description": self._get_command_description(command_filter), + } + + def _wrap_command_component(self, parent_names: list[str], component: dict) -> dict: + for parent_name in reversed(parent_names): + component = { + "type": "command", + "name": parent_name, + "description": "无描述", + "subcommands": [component], + } + return component + + def _merge_command_components(self, components: list[dict]) -> list[dict]: + merged: list[dict] = [] + for component in components: + if component.get("type") != "command": + merged.append(component) + continue + existing = next( + ( + item + for item in merged + if item.get("type") == "command" + and item.get("name") == component.get("name") + and item.get("match") == component.get("match") + ), + None, + ) + if existing is None: + merged.append(component) + continue + self._merge_command_component(existing, component) + return merged + + def _merge_command_component(self, target: dict, source: dict) -> None: + if target.get("description") == "无描述" and source.get("description"): + target["description"] = source["description"] + for key, value in source.items(): + if key in {"subcommands", "description"}: + continue + target.setdefault(key, value) + + source_subcommands = source.get("subcommands") + if not isinstance(source_subcommands, list): + return + target_subcommands = target.setdefault("subcommands", []) + for source_subcommand in source_subcommands: + if not isinstance(source_subcommand, dict): + continue + existing = next( + ( + item + for item in target_subcommands + if isinstance(item, dict) + and item.get("name") == source_subcommand.get("name") + ), + None, + ) + if existing is None: + target_subcommands.append(source_subcommand) + continue + self._merge_command_component(existing, source_subcommand) async def install_plugin(self): if DEMO_MODE: @@ -522,6 +774,7 @@ async def install_plugin(self): download_url=download_url, ) # self.core_lifecycle.restart() + await self._sync_skills_after_plugin_change() logger.info(f"安装插件 {repo_url} 成功。") return Response().ok(plugin_info, "安装成功。").__dict__ except PluginVersionIncompatibleError as e: @@ -563,6 +816,7 @@ async def install_plugin_upload(self): ignore_version_check=ignore_version_check, ) # self.core_lifecycle.restart() + await self._sync_skills_after_plugin_change() logger.info(f"安装插件 {file.filename} 成功") return Response().ok(plugin_info, "安装成功。").__dict__ except PluginVersionIncompatibleError as e: @@ -597,6 +851,7 @@ async def uninstall_plugin(self): delete_config=delete_config, delete_data=delete_data, ) + await self._sync_skills_after_plugin_change() logger.info(f"卸载插件 {plugin_name} 成功") return Response().ok(None, "卸载成功").__dict__ except Exception as e: @@ -625,6 +880,7 @@ async def uninstall_failed_plugin(self): delete_config=delete_config, delete_data=delete_data, ) + await self._sync_skills_after_plugin_change() logger.info(f"卸载失败插件 {dir_name} 成功") return Response().ok(None, "卸载成功").__dict__ except Exception as e: @@ -647,6 +903,7 @@ async def update_plugin(self): await self.plugin_manager.update_plugin(plugin_name, proxy) # self.core_lifecycle.restart() await self.plugin_manager.reload(plugin_name) + await self._sync_skills_after_plugin_change() logger.info(f"更新插件 {plugin_name} 成功。") return Response().ok(None, "更新成功。").__dict__ except Exception as e: @@ -698,6 +955,8 @@ async def _update_one(name: str): results.append(result) failed = [r for r in results if r["status"] == "error"] + if len(failed) < len(results): + await self._sync_skills_after_plugin_change() message = ( "批量更新完成,全部成功。" if not failed @@ -718,6 +977,7 @@ async def off_plugin(self): plugin_name = post_data["name"] try: await self.plugin_manager.turn_off_plugin(plugin_name) + await self._sync_skills_after_plugin_change() logger.info(f"停用插件 {plugin_name} 。") return Response().ok(None, "停用成功。").__dict__ except Exception as e: @@ -736,6 +996,7 @@ async def on_plugin(self): plugin_name = post_data["name"] try: await self.plugin_manager.turn_on_plugin(plugin_name) + await self._sync_skills_after_plugin_change() logger.info(f"启用插件 {plugin_name} 。") return Response().ok(None, "启用成功。").__dict__ except Exception as e: diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index d7676987d2..c86598212e 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -112,6 +112,10 @@ def _resolve_local_skill_dir(self, name: str) -> Path: "Sandbox preset skill cannot be opened from local skill files." ) + plugin_skill_dir = skill_mgr._get_plugin_skill_dir(skill_name) + if plugin_skill_dir is not None: + return plugin_skill_dir.resolve(strict=True) + skills_root = Path(skill_mgr.skills_root).resolve(strict=True) skill_dir = (skills_root / skill_name).resolve(strict=True) if not skill_dir.is_relative_to(skills_root): @@ -153,7 +157,13 @@ def _is_editable_skill_file(path: Path) -> bool: or path.suffix.lower() in _EDITABLE_SKILL_FILE_SUFFIXES ) - def _serialize_skill_file_entry(self, skill_dir: Path, path: Path) -> dict: + def _serialize_skill_file_entry( + self, + skill_dir: Path, + path: Path, + *, + readonly: bool = False, + ) -> dict: stat = path.stat() is_dir = path.is_dir() return { @@ -162,7 +172,8 @@ def _serialize_skill_file_entry(self, skill_dir: Path, path: Path) -> dict: "type": "directory" if is_dir else "file", "size": 0 if is_dir else stat.st_size, "editable": ( - (not is_dir) + not readonly + and (not is_dir) and self._is_editable_skill_file(path) and stat.st_size <= _SKILL_FILE_MAX_BYTES ), @@ -478,6 +489,14 @@ async def download_skill(self): ) .__dict__ ) + if skill_mgr.is_plugin_skill(name): + return ( + Response() + .error( + "Plugin-provided skill cannot be downloaded from local skill files." + ) + .__dict__ + ) skill_dir = Path(skill_mgr.skills_root) / name skill_md = skill_dir / "SKILL.md" @@ -512,6 +531,7 @@ async def list_skill_files(self): try: name = str(request.args.get("name") or "").strip() relative_path = request.args.get("path", "") + readonly = SkillManager().is_plugin_skill(name) skill_dir = self._resolve_local_skill_dir(name) target_dir = self._resolve_skill_relative_path( skill_dir, @@ -532,7 +552,13 @@ async def list_skill_files(self): continue if not resolved.is_dir() and not resolved.is_file(): continue - entries.append(self._serialize_skill_file_entry(skill_dir, resolved)) + entries.append( + self._serialize_skill_file_entry( + skill_dir, + resolved, + readonly=readonly, + ) + ) return ( Response() @@ -579,7 +605,7 @@ async def get_skill_file(self): "path": self._skill_relative_path(skill_dir, target_file), "content": content, "size": size, - "editable": True, + "editable": not SkillManager().is_plugin_skill(name), } ) .__dict__ @@ -609,6 +635,8 @@ async def update_skill_file(self): return Response().error("File content is too large").__dict__ skill_dir = self._resolve_local_skill_dir(name) + if SkillManager().is_plugin_skill(name): + return Response().error("Plugin-provided skill is read-only.").__dict__ target_file = self._resolve_skill_relative_path( skill_dir, relative_path, diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index b263ef7519..2ad1bddffe 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -66,7 +66,7 @@ variant="tonal" :color="sourceTypeColor(skill.source_type)" > - {{ sourceTypeLabel(skill.source_type) }} + {{ sourceTypeLabel(skill.source_type, skill) }} @@ -88,11 +88,7 @@ variant="text" size="small" class="list-action-icon-btn" - :disabled=" - itemLoading[skill.name] || - false || - isSandboxPresetSkill(skill) - " + :disabled="itemLoading[skill.name] || isReadOnlySourceSkill(skill)" @click.stop="downloadSkill(skill)" /> @@ -106,7 +102,7 @@ variant="text" size="small" class="list-action-icon-btn" - :disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)" + :disabled="itemLoading[skill.name] || isReadOnlySourceSkill(skill)" @click.stop="confirmDelete(skill)" /> @@ -948,7 +944,12 @@ export default { return payload.skills || []; }; - const sourceTypeLabel = (sourceType) => { + const sourceTypeLabel = (sourceType, skill = null) => { + if (sourceType === "plugin") { + return tm("skills.sourcePlugin", { + plugin: skill?.source_label || skill?.plugin_name || "", + }); + } if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly"); if (sourceType === "both") return tm("skills.sourceBoth"); return tm("skills.sourceLocalOnly"); @@ -956,12 +957,16 @@ export default { const sourceTypeColor = (sourceType) => { if (sourceType === "sandbox_only") return "indigo"; + if (sourceType === "plugin") return "secondary"; if (sourceType === "both") return "success"; return "primary"; }; const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only"; + const isPluginProvidedSkill = (skill) => skill?.source_type === "plugin"; + const isReadOnlySourceSkill = (skill) => + isSandboxPresetSkill(skill) || isPluginProvidedSkill(skill); const normalizeNeoItemsPayload = (res) => { const payload = res?.data?.data || []; @@ -1265,6 +1270,10 @@ export default { showMessage(tm("skills.sandboxPresetReadonly"), "warning"); return; } + if (isPluginProvidedSkill(skill)) { + showMessage(tm("skills.pluginReadonly"), "warning"); + return; + } skillToDelete.value = skill; deleteDialog.value = true; }; @@ -1297,6 +1306,10 @@ export default { showMessage(tm("skills.sandboxPresetReadonly"), "warning"); return; } + if (isPluginProvidedSkill(skill)) { + showMessage(tm("skills.pluginReadonly"), "warning"); + return; + } itemLoading[skill.name] = true; try { const res = await axios.get("/api/skills/download", { @@ -1827,6 +1840,8 @@ export default { sourceTypeLabel, sourceTypeColor, isSandboxPresetSkill, + isPluginProvidedSkill, + isReadOnlySourceSkill, }; }, }; diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 7ae60532dc..56822be22d 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -61,10 +61,10 @@ "enabled": "Enabled", "disabled": "Disabled", "system": "System", - "loading": "Loading...", - "installed": "Installed", - "unknown": "Unknown", - "incompatible": "Incompatible" + "loading": "Loading...", + "installed": "Installed", + "unknown": "Unknown", + "incompatible": "Incompatible" }, "tooltips": { "enable": "Click to Enable", @@ -103,6 +103,11 @@ "docsTitle": "Documentation", "docsEmpty": "No documentation", "handlerGroups": { + "skill": "Skills", + "command": "Commands / Command Groups", + "llm_tool": "LLM Tools", + "listener": "Event Listeners", + "hook": "Hooks", "commands": "Commands / Command Groups", "hooks": "Hooks", "functionTools": "Function Tools", @@ -114,10 +119,10 @@ "author": "Author", "category": "Category", "stars": "Stars", - "tags": "Tags", - "astrbotVersion": "AstrBot Version Requirement", - "supportPlatforms": "Supported Platforms", - "authorWebsite": "Author Website", + "tags": "Tags", + "astrbotVersion": "AstrBot Version Requirement", + "supportPlatforms": "Supported Platforms", + "authorWebsite": "Author Website", "repository": "Repository" } }, @@ -356,8 +361,10 @@ "sourceLocalOnly": "Local Skill", "sourceSandboxOnly": "Sandbox Preset Skill", "sourceBoth": "Local + Sandbox", + "sourcePlugin": "Plugin: {plugin}", "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.", + "pluginReadonly": "Plugin-provided skills are managed by their plugin. They cannot be deleted or downloaded from Local Skills.", "openEditor": "View/Edit", "editorTitle": "Edit Skill", "editorLoadFailed": "Failed to load Skill file", diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index 194736e023..826c601eff 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -103,6 +103,11 @@ "docsTitle": "Документация", "docsEmpty": "Документация отсутствует", "handlerGroups": { + "skill": "Skills", + "command": "Команды / группы команд", + "llm_tool": "LLM-инструменты", + "listener": "Слушатели событий", + "hook": "Хуки", "commands": "Команды / группы команд", "hooks": "Хуки", "functionTools": "Функциональные инструменты", @@ -355,8 +360,10 @@ "sourceLocalOnly": "Локальный навык", "sourceSandboxOnly": "Предустановленный Sandbox навык", "sourceBoth": "Локальный + Sandbox", + "sourcePlugin": "Плагин: {plugin}", "sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.", "sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.", + "pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.", "openEditor": "Просмотр/правка", "editorTitle": "Редактировать навык", "editorLoadFailed": "Не удалось открыть файл навыка", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index f615f8e3fd..5468dda92b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -61,10 +61,10 @@ "enabled": "启用", "disabled": "禁用", "system": "系统", - "loading": "加载中...", - "installed": "已安装", - "unknown": "未知", - "incompatible": "不兼容" + "loading": "加载中...", + "installed": "已安装", + "unknown": "未知", + "incompatible": "不兼容" }, "tooltips": { "enable": "点击启用", @@ -103,6 +103,11 @@ "docsTitle": "文档", "docsEmpty": "暂无文档", "handlerGroups": { + "skill": "Skills", + "command": "指令/指令组", + "llm_tool": "LLM 工具", + "listener": "事件监听器", + "hook": "钩子", "commands": "指令/指令组", "hooks": "钩子", "functionTools": "函数工具", @@ -114,10 +119,10 @@ "author": "作者", "category": "类别", "stars": "Star数", - "tags": "标签", - "astrbotVersion": "AstrBot 版本要求", - "supportPlatforms": "支持平台", - "authorWebsite": "作者网站", + "tags": "标签", + "astrbotVersion": "AstrBot 版本要求", + "supportPlatforms": "支持平台", + "authorWebsite": "作者网站", "repository": "仓库" } }, @@ -356,8 +361,10 @@ "sourceLocalOnly": "本地 Skill", "sourceSandboxOnly": "Sandbox 预置 Skill", "sourceBoth": "本地 + Sandbox", + "sourcePlugin": "插件:{plugin}", "sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。", "sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。", + "pluginReadonly": "插件提供的 Skill 由插件管理,无法在本地 Skills 页面删除或下载。", "openEditor": "查看/编辑", "editorTitle": "编辑 Skill", "editorLoadFailed": "读取 Skill 文件失败", diff --git a/dashboard/src/layouts/full/FullLayout.vue b/dashboard/src/layouts/full/FullLayout.vue index 19772046d0..e409661440 100644 --- a/dashboard/src/layouts/full/FullLayout.vue +++ b/dashboard/src/layouts/full/FullLayout.vue @@ -1,26 +1,30 @@