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
This commit is contained in:
Soulter
2026-08-08 12:05:14 +08:00
committed by GitHub
parent 516c22ede9
commit 2418c6a0c7
11 changed files with 211 additions and 14 deletions
+12
View File
@@ -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
@@ -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,
"",
+6 -1
View File
@@ -161,6 +161,11 @@ export interface ToolListParams {
enabled?: boolean;
}
export interface SkillListParams extends Record<string, unknown> {
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<any>(openApiV1.listSkills({ query: params }));
},
uploadBatch(files: File[]) {
@@ -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") }}
</v-chip>
<v-chip
v-if="isInactivePluginSkill(skill)"
size="x-small"
variant="tonal"
color="warning"
>
{{ tm("skills.pluginDisabled") }}
</v-chip>
</div>
</template>
@@ -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)"
/>
</template>
<span>{{
skill.active ? tm("skills.disable") : tm("skills.enable")
isInactivePluginSkill(skill)
? tm("skills.pluginDisabled")
: skill.active
? tm("skills.disable")
: tm("skills.enable")
}}</span>
</v-tooltip>
</template>
@@ -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;
}
@@ -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),
};
});
});
@@ -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",
@@ -413,6 +413,7 @@
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
"pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.",
"pluginDisabled": "Плагин отключён",
"openEditor": "Просмотр/правка",
"editorTitle": "Редактировать навык",
"editorLoadFailed": "Не удалось открыть файл навыка",
@@ -418,6 +418,7 @@
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置技能。请至少启动一次 Sandbox 会话后再查看。",
"sandboxPresetReadonly": "Sandbox 预置技能在此处为只读,无法在本地技能页面删除或启用/停用。",
"pluginReadonly": "插件提供的技能由插件管理,无法在本地技能页面删除或下载。",
"pluginDisabled": "插件已禁用",
"openEditor": "查看/编辑",
"editorTitle": "编辑技能",
"editorLoadFailed": "读取技能文件失败",
+61
View File
@@ -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)
+61
View File
@@ -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"
+4 -1
View File
@@ -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"