From 2418c6a0c75a59beafddb451f444b5e3c4db8373 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:05:14 +0800 Subject: [PATCH] fix: sync plugin skill state across the dashboard (#9596) * fix: hide skills from disabled plugins * fix: show disabled plugin skills in personas * fix: align skill list query types * fix: show disabled plugin skills in skills page * style: dim inactive skills * docs: regenerate public OpenAPI spec * refactor: always include plugin skills --- astrbot/core/computer/computer_client.py | 12 ++++ astrbot/dashboard/services/skills_service.py | 15 +++++ dashboard/src/api/v1.ts | 7 ++- .../components/extension/SkillsSection.vue | 41 +++++++++++-- .../shared/PersonaCapabilitiesEditor.vue | 20 +++--- .../locales/en-US/features/extension.json | 1 + .../locales/ru-RU/features/extension.json | 1 + .../locales/zh-CN/features/extension.json | 1 + tests/test_computer_skill_sync.py | 61 +++++++++++++++++++ tests/test_dashboard.py | 61 +++++++++++++++++++ tests/test_fastapi_v1_dashboard.py | 5 +- 11 files changed, 211 insertions(+), 14 deletions(-) diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index fe4502541..a901b3a2d 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -100,6 +100,8 @@ 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.""" + from astrbot.core.star.star import star_registry + skills_root = Path(get_astrbot_skills_path()) try: skill_manager = SkillManager(skills_root=str(skills_root)) @@ -107,6 +109,11 @@ def _collect_sync_skill_dirs() -> list[tuple[str, Path]]: logger.warning("[Computer] Failed to initialize skill manager: %s", exc) return [] + active_plugin_root_names = { + plugin.root_dir_name + for plugin in star_registry + if plugin.activated and plugin.root_dir_name + } sync_dirs: list[tuple[str, Path]] = [] for skill in skill_manager.list_skills( active_only=False, @@ -115,6 +122,11 @@ def _collect_sync_skill_dirs() -> list[tuple[str, Path]]: ): if skill.source_type == "sandbox_only": continue + if ( + skill.source_type == "plugin" + and skill.plugin_name not in active_plugin_root_names + ): + continue skill_md = Path(skill.path) if not skill_md.is_file(): continue diff --git a/astrbot/dashboard/services/skills_service.py b/astrbot/dashboard/services/skills_service.py index b084d53f3..044472bb9 100644 --- a/astrbot/dashboard/services/skills_service.py +++ b/astrbot/dashboard/services/skills_service.py @@ -244,6 +244,11 @@ class SkillsService: return SkillsOperationResult(ok=False, message=str(exc)) def get_skills(self) -> dict: + """Return the Skill inventory for Dashboard consumers. + + Returns: + The serialized Skill inventory and current runtime metadata. + """ provider_settings = self.core_lifecycle.astrbot_config.get( "provider_settings", {} ) @@ -255,16 +260,26 @@ class SkillsService: show_sandbox_path=False, ) plugin_display_names = {} + plugin_activation_by_root_name = {} for plugin in self.core_lifecycle.plugin_manager.context.get_all_stars(): display_name = str(plugin.display_name or plugin.name or "").strip() for plugin_name in (plugin.name, plugin.root_dir_name): if plugin_name: plugin_display_names[str(plugin_name)] = display_name + if plugin.root_dir_name: + plugin_activation_by_root_name[str(plugin.root_dir_name)] = bool( + plugin.activated + ) serialized_skills = [] for skill in skills: skill_data = dict(skill.__dict__) if skill.source_type == "plugin": + plugin_active = plugin_activation_by_root_name.get( + skill.plugin_name, + False, + ) + skill_data["plugin_active"] = plugin_active skill_data["plugin_display_name"] = plugin_display_names.get( skill.plugin_name, "", diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 4d75881c8..f136e08c2 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -161,6 +161,11 @@ export interface ToolListParams { enabled?: boolean; } +export interface SkillListParams extends Record { + enabled?: boolean; + source?: string; +} + export interface BackupListParams { page?: number; page_size?: number; @@ -1535,7 +1540,7 @@ export const knowledgeApi = { }; export const skillApi = { - list(params?: { enabled?: boolean; source?: string }) { + list(params?: SkillListParams) { return typed(openApiV1.listSkills({ query: params })); }, uploadBatch(files: File[]) { diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index 5a43abc3c..9b675500e 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -58,6 +58,10 @@ :key="skill.name" :title="skill.name" class="skill-list-item" + :class="{ + 'skill-list-item--inactive': + skill.active === false || isInactivePluginSkill(skill), + }" clickable @click="openSkillEditor(skill)" > @@ -71,6 +75,14 @@ > {{ tm("status.preset") }} + + {{ tm("skills.pluginDisabled") }} + @@ -129,22 +141,32 @@ density="compact" hide-details inset - :model-value="skill.active" + :model-value=" + skill.active && !isInactivePluginSkill(skill) + " :aria-label=" - skill.active + isInactivePluginSkill(skill) + ? tm('skills.pluginDisabled') + : skill.active ? tm('skills.disable') : tm('skills.enable') " :loading="itemLoading[skill.name] || false" :disabled=" - itemLoading[skill.name] || isSandboxPresetSkill(skill) + itemLoading[skill.name] || + isSandboxPresetSkill(skill) || + isInactivePluginSkill(skill) " @click.stop @update:model-value="toggleSkill(skill)" /> {{ - skill.active ? tm("skills.disable") : tm("skills.enable") + isInactivePluginSkill(skill) + ? tm("skills.pluginDisabled") + : skill.active + ? tm("skills.disable") + : tm("skills.enable") }} @@ -982,6 +1004,8 @@ export default { const isSandboxPresetSkill = (skill) => skill?.source_type === "sandbox_only"; const isPluginProvidedSkill = (skill) => skill?.source_type === "plugin"; + const isInactivePluginSkill = (skill) => + isPluginProvidedSkill(skill) && skill?.plugin_active === false; const isReadOnlySourceSkill = (skill) => isSandboxPresetSkill(skill) || isPluginProvidedSkill(skill); @@ -1251,6 +1275,10 @@ export default { }; const toggleSkill = async (skill) => { + if (isInactivePluginSkill(skill)) { + showMessage(tm("skills.pluginDisabled"), "warning"); + return; + } if (isSandboxPresetSkill(skill)) { showMessage(tm("skills.sandboxPresetReadonly"), "warning"); return; @@ -1837,6 +1865,7 @@ export default { deleteRelease, isSandboxPresetSkill, isPluginProvidedSkill, + isInactivePluginSkill, isReadOnlySourceSkill, }; }, @@ -1858,6 +1887,10 @@ export default { gap: 0; } +.skill-list-item--inactive { + opacity: 0.58; +} + .skill-list-item :deep(.outlined-action-list-item__content) { flex: 1 1 auto; } diff --git a/dashboard/src/components/shared/PersonaCapabilitiesEditor.vue b/dashboard/src/components/shared/PersonaCapabilitiesEditor.vue index e0eb80151..fb530456e 100644 --- a/dashboard/src/components/shared/PersonaCapabilitiesEditor.vue +++ b/dashboard/src/components/shared/PersonaCapabilitiesEditor.vue @@ -72,7 +72,7 @@ const selectableToolNames = computed(() => ); const selectableSkillNames = computed(() => props.availableSkills - .filter((skill) => skill.active !== false) + .filter((skill) => skill.active !== false && skill.plugin_active !== false) .map((skill) => skill.name), ); @@ -182,7 +182,10 @@ const skillItems = computed(() => { if (!item.skills) { return item; } - const selectedCount = item.skills.filter((skill) => + const activeSkills = item.skills.filter( + (skill) => skill.plugin_active !== false, + ); + const selectedCount = activeSkills.filter((skill) => isCapabilitySelected("skills", skill.name), ).length; return { @@ -191,13 +194,14 @@ const skillItems = computed(() => { badgeTone: "plugin", meta: tm("personaQuickPreview.skillCount", { selected: selectedCount, - total: item.skills.length, + total: activeSkills.length, }), - selected: item.skills.length > 0 && selectedCount === item.skills.length, - indeterminate: selectedCount > 0 && selectedCount < item.skills.length, - disabled: item.skills.length === 0, - configurable: true, - skillNames: item.skills.map((skill) => skill.name), + selected: + activeSkills.length > 0 && selectedCount === activeSkills.length, + indeterminate: selectedCount > 0 && selectedCount < activeSkills.length, + disabled: activeSkills.length === 0, + configurable: activeSkills.length > 0, + skillNames: activeSkills.map((skill) => skill.name), }; }); }); diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 8184dcce4..cf1c55375 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -418,6 +418,7 @@ "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.", + "pluginDisabled": "Plugin disabled", "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 b4ed54f3e..942adcf2a 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -413,6 +413,7 @@ "sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.", "sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.", "pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.", + "pluginDisabled": "Плагин отключён", "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 d7afc3971..64e7dfc72 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -418,6 +418,7 @@ "sandboxDiscoveryPending": "尚未发现 Sandbox 预置技能。请至少启动一次 Sandbox 会话后再查看。", "sandboxPresetReadonly": "Sandbox 预置技能在此处为只读,无法在本地技能页面删除或启用/停用。", "pluginReadonly": "插件提供的技能由插件管理,无法在本地技能页面删除或下载。", + "pluginDisabled": "插件已禁用", "openEditor": "查看/编辑", "editorTitle": "编辑技能", "editorLoadFailed": "读取技能文件失败", diff --git a/tests/test_computer_skill_sync.py b/tests/test_computer_skill_sync.py index 422aa35f9..a1977c9c7 100644 --- a/tests/test_computer_skill_sync.py +++ b/tests/test_computer_skill_sync.py @@ -159,6 +159,9 @@ def test_sync_skills_includes_plugin_provided_skills( monkeypatch, tmp_path: Path, ): + import astrbot.core.star.star as star_module + from astrbot.core.star.star import StarMetadata + skills_root = tmp_path / "skills" plugins_root = tmp_path / "plugins" temp_root = tmp_path / "temp" @@ -189,6 +192,17 @@ def test_sync_skills_includes_plugin_provided_skills( "astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache", _fake_set_cache, ) + monkeypatch.setattr( + star_module, + "star_registry", + [ + StarMetadata( + name="demo", + root_dir_name="astrbot_plugin_demo", + activated=True, + ) + ], + ) booter = _FakeBooter( '{"skills":[{"name":"demo-skill","description":"","path":"skills/demo-skill/SKILL.md"}]}' @@ -206,6 +220,53 @@ def test_sync_skills_includes_plugin_provided_skills( ] +def test_sync_skills_skips_inactive_plugin_provided_skills( + monkeypatch, + tmp_path: Path, +): + import astrbot.core.star.star as star_module + from astrbot.core.star.star import StarMetadata + + 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") + + 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( + star_module, + "star_registry", + [ + StarMetadata( + name="demo", + root_dir_name="astrbot_plugin_demo", + activated=False, + ) + ], + ) + + booter = _FakeBooter('{"skills":[]}') + asyncio.run(computer_client._sync_skills_to_sandbox(cast(ComputerBooter, booter))) + + assert booter.uploads == [] + assert any(cmd == "rm -f skills/skills.zip" for cmd in booter.shell.commands) + + 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_dashboard.py b/tests/test_dashboard.py index 8d5598803..24ebac1d8 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -46,6 +46,7 @@ from astrbot.dashboard.server import AstrBotDashboard from astrbot.dashboard.services.auth_service import DASHBOARD_JWT_COOKIE_NAME from astrbot.dashboard.services.plugin_page_service import PluginPageService from astrbot.dashboard.services.plugin_service import PluginService +from astrbot.dashboard.services.skills_service import SkillsService from tests.fixtures.helpers import ( MockPluginBuilder, create_mock_updater_install, @@ -57,6 +58,66 @@ PLUGIN_PAGE_DEMO_NAME = "astrbot_plugin_page_demo" PLUGIN_PAGE_DEMO_PAGE_NAME = "bridge-demo" +def test_skills_service_marks_inactive_plugin_skills(monkeypatch): + skills = [ + SimpleNamespace( + name="local-skill", + source_type="local_only", + plugin_name="", + ), + SimpleNamespace( + name="active-plugin-skill", + source_type="plugin", + plugin_name="astrbot_plugin_active", + ), + SimpleNamespace( + name="inactive-plugin-skill", + source_type="plugin", + plugin_name="astrbot_plugin_inactive", + ), + ] + skill_manager = SimpleNamespace( + list_skills=lambda **_kwargs: skills, + get_sandbox_skills_cache_status=lambda: {}, + ) + plugins = [ + StarMetadata( + name="active", + display_name="Active Plugin", + root_dir_name="astrbot_plugin_active", + activated=True, + ), + StarMetadata( + name="inactive", + display_name="Inactive Plugin", + root_dir_name="astrbot_plugin_inactive", + activated=False, + ), + ] + core_lifecycle = SimpleNamespace( + astrbot_config={"provider_settings": {}}, + plugin_manager=SimpleNamespace( + context=SimpleNamespace(get_all_stars=lambda: plugins) + ), + ) + monkeypatch.setattr( + "astrbot.dashboard.services.skills_service.SkillManager", + lambda: skill_manager, + ) + + result = SkillsService(core_lifecycle).get_skills() + + assert [skill["name"] for skill in result["skills"]] == [ + "local-skill", + "active-plugin-skill", + "inactive-plugin-skill", + ] + assert result["skills"][1]["plugin_display_name"] == "Active Plugin" + assert result["skills"][1]["plugin_active"] is True + assert result["skills"][2]["plugin_display_name"] == "Inactive Plugin" + assert result["skills"][2]["plugin_active"] is False + + def _removed_md5_hint_alias_key() -> str: return "le" + "gacy_pwd_hint" diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index bbc90fa79..4988db834 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -3331,10 +3331,13 @@ async def test_v1_skill_scope_accepts_api_key_and_rejects_plural_scope( fake_db: FakeDb, monkeypatch: pytest.MonkeyPatch, ): + def fake_get_skills(): + return {"skills": [{"name": "demo_skill"}]} + monkeypatch.setattr( asgi_app.state.services.skills, "get_skills", - lambda: {"skills": [{"name": "demo_skill"}]}, + fake_get_skills, ) plural_key = "abk_fastapi_v1_skills"