From 7dc446581b279337acd894c7e7f29f3a899a48a3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 1 May 2026 20:44:13 +0800 Subject: [PATCH 01/16] feat: supports plugin to add skills --- astrbot/core/computer/computer_client.py | 38 ++++++- astrbot/core/skills/skill_manager.py | 107 +++++++++++++++++- astrbot/dashboard/routes/plugin.py | 16 +++ astrbot/dashboard/routes/skills.py | 36 +++++- .../components/extension/SkillsSection.vue | 31 +++-- .../locales/en-US/features/extension.json | 2 + .../locales/ru-RU/features/extension.json | 2 + .../locales/zh-CN/features/extension.json | 2 + docs/en/dev/star/plugin-new.md | 27 +++++ docs/zh/dev/star/plugin-new.md | 27 +++++ docs/zh/dev/star/plugin.md | 27 +++++ tests/test_computer_skill_sync.py | 51 +++++++++ tests/test_skill_metadata_enrichment.py | 33 ++++++ 13 files changed, 379 insertions(+), 20 deletions(-) diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 648c771235..2de3199311 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -31,6 +31,24 @@ 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.""" + skill_manager = SkillManager(skills_root=str(Path(get_astrbot_skills_path()))) + 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 _discover_bay_credentials(endpoint: str) -> str: """Try to auto-discover Bay API key from credentials.json. @@ -382,21 +400,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 +441,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/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index a4f73b7a83..ea16d8602d 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -14,6 +14,7 @@ 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.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter @@ -89,6 +90,12 @@ def __init__( self._logo_cache = {} + async def _sync_skills_after_plugin_change(self) -> None: + try: + await sync_skills_to_active_sandboxes() + except Exception: + logger.warning("Failed to sync plugin-provided skills to active sandboxes.") + async def check_plugin_compatibility(self): try: data = await request.get_json() @@ -129,6 +136,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 +159,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()}") @@ -520,6 +529,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: @@ -561,6 +571,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: @@ -595,6 +606,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: @@ -623,6 +635,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: @@ -645,6 +658,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: @@ -696,6 +710,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 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..ed4d56d1b3 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -356,8 +356,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..5da5c71203 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -355,8 +355,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..0eb7d92999 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -356,8 +356,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/docs/en/dev/star/plugin-new.md b/docs/en/dev/star/plugin-new.md index 41dac43d01..b3e6845dbb 100644 --- a/docs/en/dev/star/plugin-new.md +++ b/docs/en/dev/star/plugin-new.md @@ -61,6 +61,33 @@ You can add a `short_desc` field to `metadata.yaml` as the short description sho short_desc: A one-line summary of your plugin. ``` +### Bundle Skills with a Plugin (Optional) + +Plugins can provide a `skills/` directory. After AstrBot loads the plugin, valid Skills inside that directory are automatically included in the Skill Manager, with their source shown as the plugin. + +For multiple Skills, use this structure: + +```text +your_plugin/ + metadata.yaml + main.py + skills/ + web-search-helper/ + SKILL.md + report-writer/ + SKILL.md +``` + +If `skills/` itself is one Skill, you can also place `SKILL.md` directly under it: + +```text +your_plugin/ + skills/ + SKILL.md +``` + +In that case, the Skill name uses the plugin directory name. Plugin-provided Skills are managed by the plugin and appear as read-only sources in the WebUI Skills page. They can be enabled or disabled, but cannot be deleted or edited from Local Skills. When the plugin is uninstalled or updated, its bundled Skills change with the plugin files. + ### Declare Supported Platforms (Optional) You can add a `support_platforms` field (`list[str]`) to `metadata.yaml` to declare which platform adapters your plugin supports. The WebUI plugin page will display this field. diff --git a/docs/zh/dev/star/plugin-new.md b/docs/zh/dev/star/plugin-new.md index 86262fe963..04d61deae6 100644 --- a/docs/zh/dev/star/plugin-new.md +++ b/docs/zh/dev/star/plugin-new.md @@ -63,6 +63,33 @@ git clone 插件仓库地址 short_desc: 一句话介绍你的插件。 ``` +### 随插件提供 Skills(可选) + +插件可以在自己的目录下提供 `skills/` 文件夹。AstrBot 加载插件后会自动把其中合法的 Skill 纳入 Skill Manager,来源会显示为对应插件。 + +推荐一个插件包含多个 Skill 时使用以下结构: + +```text +your_plugin/ + metadata.yaml + main.py + skills/ + web-search-helper/ + SKILL.md + report-writer/ + SKILL.md +``` + +如果 `skills/` 本身就是一个 Skill,也可以直接放置: + +```text +your_plugin/ + skills/ + SKILL.md +``` + +这种情况下 Skill 名称会使用插件目录名。插件提供的 Skill 由插件管理,在 WebUI 的 Skills 页面中作为只读来源展示;可以启用或禁用,但不能从本地 Skills 页面删除或编辑。插件卸载或更新后,对应 Skill 会随插件文件变化。 + ### 声明支持平台(Optional) 你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`),声明插件支持的平台适配器。WebUI 插件页会展示该字段。 diff --git a/docs/zh/dev/star/plugin.md b/docs/zh/dev/star/plugin.md index fc60a1b22b..84ac03ff46 100644 --- a/docs/zh/dev/star/plugin.md +++ b/docs/zh/dev/star/plugin.md @@ -234,6 +234,33 @@ async def on_message(self, event: AstrMessageEvent): short_desc: 一句话介绍你的插件。 ``` +### 随插件提供 Skills + +插件可以在自己的目录下提供 `skills/` 文件夹。AstrBot 加载插件后会自动把其中合法的 Skill 纳入 Skill Manager,来源会显示为对应插件。 + +推荐一个插件包含多个 Skill 时使用以下结构: + +```text +your_plugin/ + metadata.yaml + main.py + skills/ + web-search-helper/ + SKILL.md + report-writer/ + SKILL.md +``` + +如果 `skills/` 本身就是一个 Skill,也可以直接放置: + +```text +your_plugin/ + skills/ + SKILL.md +``` + +这种情况下 Skill 名称会使用插件目录名。插件提供的 Skill 由插件管理,在 WebUI 的 Skills 页面中作为只读来源展示;可以启用或禁用,但不能从本地 Skills 页面删除或编辑。插件卸载或更新后,对应 Skill 会随插件文件变化。 + ### 声明支持平台(Optional) 你可以在 `metadata.yaml` 中新增 `support_platforms` 字段(`list[str]`),声明插件支持的平台适配器。WebUI 插件页会展示该字段。 diff --git a/tests/test_computer_skill_sync.py b/tests/test_computer_skill_sync.py index 37715bb74b..81cf62cff4 100644 --- a/tests/test_computer_skill_sync.py +++ b/tests/test_computer_skill_sync.py @@ -137,6 +137,57 @@ def _fake_set_cache(self, skills): ] +def test_sync_skills_includes_plugin_provided_skills( + monkeypatch, + tmp_path: Path, +): + skills_root = tmp_path / "skills" + plugins_root = tmp_path / "plugins" + temp_root = tmp_path / "temp" + skills_root.mkdir(parents=True, exist_ok=True) + temp_root.mkdir(parents=True, exist_ok=True) + plugin_skill_dir = plugins_root / "astrbot_plugin_demo" / "skills" / "demo-skill" + plugin_skill_dir.mkdir(parents=True) + plugin_skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8") + + captured = {"skills": None} + + def _fake_set_cache(self, skills): + captured["skills"] = skills + + monkeypatch.setattr( + "astrbot.core.computer.computer_client.get_astrbot_skills_path", + lambda: str(skills_root), + ) + monkeypatch.setattr( + "astrbot.core.skills.skill_manager.get_astrbot_plugin_path", + lambda: str(plugins_root), + ) + monkeypatch.setattr( + "astrbot.core.computer.computer_client.get_astrbot_temp_path", + lambda: str(temp_root), + ) + monkeypatch.setattr( + "astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache", + _fake_set_cache, + ) + + booter = _FakeBooter( + '{"skills":[{"name":"demo-skill","description":"","path":"skills/demo-skill/SKILL.md"}]}' + ) + asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter))) + + assert len(booter.uploads) == 1 + assert booter.uploads[0][1] == "skills/skills.zip" + assert captured["skills"] == [ + { + "name": "demo-skill", + "description": "", + "path": "skills/demo-skill/SKILL.md", + } + ] + + def test_build_scan_command_frontmatter_newline_is_escaped_literal(): command = computer_client._build_scan_command() script = _extract_embedded_python(command) diff --git a/tests/test_skill_metadata_enrichment.py b/tests/test_skill_metadata_enrichment.py index 78d84104f9..3719331b09 100644 --- a/tests/test_skill_metadata_enrichment.py +++ b/tests/test_skill_metadata_enrichment.py @@ -441,6 +441,39 @@ def test_list_skills_parses_description_from_local(monkeypatch, tmp_path: Path): assert not hasattr(s, "output") +def test_list_skills_includes_plugin_provided_skills(monkeypatch, tmp_path: Path): + data_dir = tmp_path / "data" + skills_root = tmp_path / "skills" + plugins_root = tmp_path / "plugins" + data_dir.mkdir(parents=True, exist_ok=True) + skills_root.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr( + "astrbot.core.skills.skill_manager.get_astrbot_data_path", + lambda: str(data_dir), + ) + + plugin_skill_dir = plugins_root / "astrbot_plugin_demo" / "skills" / "demo-skill" + plugin_skill_dir.mkdir(parents=True) + plugin_skill_dir.joinpath("SKILL.md").write_text( + "---\nname: demo-skill\ndescription: Plugin bundled skill.\n---\n# Demo\n", + encoding="utf-8", + ) + + mgr = SkillManager(skills_root=str(skills_root), plugins_root=str(plugins_root)) + skills = mgr.list_skills() + + assert len(skills) == 1 + skill = skills[0] + assert skill.name == "demo-skill" + assert skill.description == "Plugin bundled skill." + assert skill.source_type == "plugin" + assert skill.source_label == "astrbot_plugin_demo" + assert skill.plugin_name == "astrbot_plugin_demo" + assert skill.readonly is True + assert skill.path.endswith("plugins/astrbot_plugin_demo/skills/demo-skill/SKILL.md") + + def test_list_skills_description_from_sandbox_cache(monkeypatch, tmp_path: Path): data_dir = tmp_path / "data" temp_dir = tmp_path / "temp" From 50c31c67b1fb3c832ed2f3e2a5e4b3bd393d9be0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 3 May 2026 14:47:25 +0800 Subject: [PATCH 02/16] fix tests --- astrbot/core/computer/computer_client.py | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 2de3199311..a50549fba7 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -33,7 +33,16 @@ def _list_local_skill_dirs(skills_root: Path) -> list[Path]: def _collect_sync_skill_dirs() -> list[tuple[str, Path]]: """Collect local and plugin-provided skills that should be synced.""" - skill_manager = SkillManager(skills_root=str(Path(get_astrbot_skills_path()))) + 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, @@ -49,6 +58,12 @@ def _collect_sync_skill_dirs() -> list[tuple[str, Path]]: 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. @@ -369,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) @@ -380,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) From c847aea3bdffd624fb0651d100fb73bcbc9b8edc Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 3 May 2026 15:01:41 +0800 Subject: [PATCH 03/16] fix: fs tools --- astrbot/core/tools/computer_tools/fs.py | 85 ++++++++++++++++++++----- docs/en/use/computer.md | 3 +- docs/zh/use/computer.md | 3 +- tests/test_computer_fs_tools.py | 64 ++++++++++++++++++- 4 files changed, 137 insertions(+), 18 deletions(-) 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/docs/en/use/computer.md b/docs/en/use/computer.md index 09fcd87ad0..e217e6f073 100644 --- a/docs/en/use/computer.md +++ b/docs/en/use/computer.md @@ -76,11 +76,12 @@ When enabled: - Admin users can use Shell, Python, file read, file write, file edit, and Grep search in `local` mode. - Non-admin users cannot use Shell or Python. -- Non-admin users can only use file read, write, edit, and search inside restricted directories. +- Non-admin users can only use file read, write, edit, and search inside restricted directories. Plugin-provided Skills are read/search-only and cannot be written or edited. Allowed directories for non-admin users in `local` mode include: - `data/skills` +- `data/plugins/*/skills` (read-only, for plugin-provided Skills) - Current session's `data/workspaces/{normalized_umo}` - AstrBot temporary directories - `.astrbot` under the system temporary directory diff --git a/docs/zh/use/computer.md b/docs/zh/use/computer.md index 2b98420813..7da8dd5d17 100644 --- a/docs/zh/use/computer.md +++ b/docs/zh/use/computer.md @@ -74,11 +74,12 @@ data/workspaces/{normalized_umo}/notes/todo.txt - 管理员可以使用 `local` 模式下的 Shell、Python、文件读取、文件写入、文件编辑和 Grep 搜索。 - 非管理员不能使用 Shell 和 Python。 -- 非管理员只能在受限目录内使用文件读取、写入、编辑和搜索。 +- 非管理员只能在受限目录内使用文件读取、写入、编辑和搜索。插件内置 Skills 只允许读取和搜索,不允许写入或编辑。 非管理员在 `local` 模式下允许访问的目录包括: - `data/skills` +- `data/plugins/*/skills`(只读,用于插件内置 Skills) - 当前会话的 `data/workspaces/{normalized_umo}` - AstrBot 的临时目录 - 系统临时目录中的 `.astrbot` diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py index a9f6fa16d1..67bfd44f05 100644 --- a/tests/test_computer_fs_tools.py +++ b/tests/test_computer_fs_tools.py @@ -49,9 +49,11 @@ def _setup_local_fs_tools( ) -> Any: workspaces_root = tmp_path / "workspaces" skills_root = tmp_path / "skills" + plugins_root = tmp_path / "plugins" temp_root = tmp_path / "temp" workspaces_root.mkdir() skills_root.mkdir() + plugins_root.mkdir() temp_root.mkdir() monkeypatch.setattr( @@ -64,6 +66,11 @@ def _setup_local_fs_tools( "get_astrbot_skills_path", lambda: str(skills_root), ) + monkeypatch.setattr( + fs_tools, + "get_astrbot_plugin_path", + lambda: str(plugins_root), + ) monkeypatch.setattr( fs_tools, "get_astrbot_temp_path", @@ -123,7 +130,9 @@ def _make_epub_bytes(*, chapter_count: int = 1) -> bytes: 'media-type="application/xhtml+xml"/>' ) spine_items.append(f'') - nav_links.append(f'
  • Chapter {index}
  • ') + nav_links.append( + f'
  • Chapter {index}
  • ' + ) archive.writestr( f"OEBPS/chapter{index}.xhtml", f""" @@ -181,6 +190,59 @@ def _make_epub_bytes(*, chapter_count: int = 1) -> bytes: return buffer.getvalue() +@pytest.mark.asyncio +async def test_restricted_local_member_can_read_plugin_provided_skill( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + _setup_local_fs_tools(monkeypatch, tmp_path) + plugin_skill = ( + tmp_path + / "plugins" + / "astrbot_plugin_demo" + / "skills" + / "demo-skill" + / "SKILL.md" + ) + plugin_skill.parent.mkdir(parents=True) + plugin_skill.write_text("# Demo Skill\n\nRead plugin docs.", encoding="utf-8") + + result = await fs_tools.FileReadTool().call( + _make_context(role="member"), + path=str(plugin_skill), + ) + + assert result == "# Demo Skill\n\nRead plugin docs." + + +@pytest.mark.asyncio +async def test_restricted_local_member_cannot_write_plugin_provided_skill( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + _setup_local_fs_tools(monkeypatch, tmp_path) + plugin_skill = ( + tmp_path + / "plugins" + / "astrbot_plugin_demo" + / "skills" + / "demo-skill" + / "SKILL.md" + ) + plugin_skill.parent.mkdir(parents=True) + plugin_skill.write_text("# Demo Skill\n", encoding="utf-8") + + result = await fs_tools.FileWriteTool().call( + _make_context(role="member"), + path=str(plugin_skill), + content="# Changed\n", + ) + + assert "Write access is restricted for this user." in result + assert "data/plugins/*/skills" not in result + assert plugin_skill.read_text(encoding="utf-8") == "# Demo Skill\n" + + def test_detect_text_encoding_allows_utf8_probe_cut_mid_character(): sample = '{"results": ["中文内容"]}'.encode()[:-1] From b9bc3f3d40be169d25b2d42467e6de61ef215fba Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 3 May 2026 16:27:00 +0800 Subject: [PATCH 04/16] Add tests for plugin skills handling and improve skill management - Implement test for restricted local member reading plugin skill inventory even if the plugin is inactive. - Ensure that the skill synchronization process retains built-in skills when local skills are empty, including proper handling of plugin paths. - Update dashboard tests to verify that plugin details include components when requested. - Enhance skill metadata enrichment tests to include inactive plugin-provided skills for inventory. - Add filtering tests for plugin skills based on current configuration, ensuring only allowed plugins are considered and inactive plugins are skipped. Co-authored-by: Copilot --- astrbot/core/astr_main_agent.py | 40 +- astrbot/dashboard/routes/plugin.py | 347 +++++- .../locales/en-US/features/extension.json | 21 +- .../locales/ru-RU/features/extension.json | 5 + .../locales/zh-CN/features/extension.json | 21 +- dashboard/src/layouts/full/FullLayout.vue | 101 +- .../full/vertical-header/VerticalHeader.vue | 1028 +++++++++++------ dashboard/src/stores/common.js | 282 +++-- dashboard/src/views/ExtensionPage.vue | 368 +++--- .../views/extension/InstalledPluginsTab.vue | 376 +++--- .../src/views/extension/MarketPluginsTab.vue | 399 +++---- .../src/views/extension/PluginDetailPage.vue | 433 ++++--- .../src/views/extension/useExtensionPage.js | 578 +++++---- tests/test_computer_fs_tools.py | 25 + tests/test_computer_skill_sync.py | 6 + tests/test_dashboard.py | 13 + tests/test_skill_metadata_enrichment.py | 51 +- tests/unit/test_astr_main_agent.py | 80 ++ 18 files changed, 2602 insertions(+), 1572 deletions(-) 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/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index ea16d8602d..399f17b4e7 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -16,6 +16,7 @@ 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 @@ -35,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 @@ -54,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), @@ -96,27 +104,6 @@ async def _sync_skills_after_plugin_change(self) -> None: except Exception: logger.warning("Failed to sync plugin-provided skills to active sandboxes.") - async def check_plugin_compatibility(self): - 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__ - async def reload_failed_plugins(self): if DEMO_MODE: return ( @@ -410,9 +397,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, @@ -438,13 +422,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 = {} @@ -460,48 +497,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 [] + + 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 [] + + 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 - if not info["desc"]: - info["desc"] = "无描述" + def _get_command_filter_display_name(self, command_filter: CommandFilter) -> str: + return command_filter.get_complete_command_names()[0].strip() - handlers.append(info) + 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) - return handlers + 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: @@ -732,6 +975,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: @@ -750,6 +994,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/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index ed4d56d1b3..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" } }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index 5da5c71203..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": "Функциональные инструменты", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 0eb7d92999..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": "仓库" } }, 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 @@