mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-30 17:33:24 +08:00
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:
@@ -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,
|
||||
"",
|
||||
|
||||
@@ -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": "读取技能文件失败",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user