Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 191 additions & 96 deletions astrbot/core/skills/skill_manager.py

Large diffs are not rendered by default.

63 changes: 48 additions & 15 deletions astrbot/dashboard/services/skills_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,42 @@ def resolve_local_skill_dir(self, name: str) -> Path:
"Sandbox preset skill cannot be opened from local skill files."
)

skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
local_skill_dir = skills_root / skill_name
if local_skill_dir.exists():
skill_dir = local_skill_dir.resolve(strict=True)
if not skill_dir.is_relative_to(skills_root):
raise PermissionError("Invalid skill path")
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
return skill_dir

global_skill_dir = skill_mgr._get_global_skill_dir(skill_name)
if global_skill_dir is not None:
return global_skill_dir.resolve(strict=True)

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):
raise PermissionError("Invalid skill path")
if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists():
raise FileNotFoundError("Local skill not found")
return skill_dir
raise FileNotFoundError("Local skill not found")

@staticmethod
def is_skill_dir_readonly(skill_mgr: SkillManager, skill_dir: Path) -> bool:
"""Return whether a resolved skill directory is externally managed.

Args:
skill_mgr: Skill manager carrying the editable AstrBot skills root.
skill_dir: Resolved skill directory.

Returns:
True when the skill is outside the editable AstrBot skills root.
"""
try:
skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
resolved_skill_dir = skill_dir.resolve(strict=True)
except OSError:
return True
return not resolved_skill_dir.is_relative_to(skills_root)

@staticmethod
def resolve_skill_relative_path(
Expand Down Expand Up @@ -430,14 +455,18 @@ def prepare_skill_archive(self, name: str) -> SkillArchive:
raise SkillsServiceError(
"Sandbox preset skill cannot be downloaded from local skill files."
)
if skill_mgr.is_plugin_skill(skill_name):
raise SkillsServiceError(
"Plugin-provided skill cannot be downloaded from local skill files."
)

skill_dir = Path(skill_mgr.skills_root) / skill_name
skill_md = skill_dir / "SKILL.md"
if not skill_dir.is_dir() or not skill_md.exists():
if skill_mgr.is_global_skill(skill_name):
raise SkillsServiceError(
"Global Agent skill cannot be downloaded from local skill files."
)
if skill_mgr.is_plugin_skill(skill_name):
raise SkillsServiceError(
"Plugin-provided skill cannot be downloaded from local skill files."
)
raise SkillsServiceError("Local skill not found", status_code=404)

export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
Expand All @@ -462,8 +491,9 @@ def prepare_skill_archive_from_dashboard_query(

def list_skill_files(self, name: str, relative_path: str | None = "") -> dict:
skill_name = str(name or "").strip()
readonly = SkillManager().is_plugin_skill(skill_name)
skill_mgr = SkillManager()
skill_dir = self.resolve_local_skill_dir(skill_name)
readonly = self.is_skill_dir_readonly(skill_mgr, skill_dir)
target_dir = self.resolve_skill_relative_path(
skill_dir,
relative_path,
Expand Down Expand Up @@ -507,7 +537,9 @@ def list_skill_files_from_dashboard_query(

def get_skill_file(self, name: str, relative_path: str | None = "SKILL.md") -> dict:
skill_name = str(name or "").strip()
skill_mgr = SkillManager()
skill_dir = self.resolve_local_skill_dir(skill_name)
readonly = self.is_skill_dir_readonly(skill_mgr, skill_dir)
target_file = self.resolve_skill_relative_path(
skill_dir,
relative_path,
Expand All @@ -530,7 +562,7 @@ def get_skill_file(self, name: str, relative_path: str | None = "SKILL.md") -> d
"path": self.skill_relative_path(skill_dir, target_file),
"content": content,
"size": size,
"editable": not SkillManager().is_plugin_skill(skill_name),
"editable": not readonly,
}

def get_skill_file_from_dashboard_query(
Expand All @@ -555,8 +587,9 @@ async def update_skill_file(self, data: object) -> dict:
raise SkillsServiceError("File content is too large")

skill_dir = self.resolve_local_skill_dir(skill_name)
if SkillManager().is_plugin_skill(skill_name):
raise SkillsServiceError("Plugin-provided skill is read-only.")
skill_mgr = SkillManager()
if self.is_skill_dir_readonly(skill_mgr, skill_dir):
raise SkillsServiceError("This skill is read-only.")
target_file = self.resolve_skill_relative_path(
skill_dir,
relative_path,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* Auto-generated MDI subset – 273 icons */
/* Auto-generated MDI subset – 272 icons */
/* Do not edit manually. Run: pnpm run subset-icons */

@font-face {
Expand Down Expand Up @@ -404,10 +404,6 @@
content: "\F0209";
}

.mdi-eye-outline::before {
content: "\F06D0";
}

.mdi-eyedropper::before {
content: "\F020A";
}
Expand Down
Binary file not shown.
Binary file not shown.
16 changes: 14 additions & 2 deletions dashboard/src/components/extension/SkillsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@ export default {
plugin: skill?.source_label || skill?.plugin_name || "",
});
}
if (sourceType === "global") return tm("skills.sourceGlobal");
if (sourceType === "sandbox_only") return tm("skills.sourceSandboxOnly");
if (sourceType === "both") return tm("skills.sourceBoth");
return tm("skills.sourceLocalOnly");
Expand All @@ -958,6 +959,7 @@ export default {
const sourceTypeColor = (sourceType) => {
if (sourceType === "sandbox_only") return "indigo";
if (sourceType === "plugin") return "secondary";
if (sourceType === "global") return "teal";
if (sourceType === "both") return "success";
return "primary";
};
Expand All @@ -966,7 +968,9 @@ export default {
skill?.source_type === "sandbox_only";
const isPluginProvidedSkill = (skill) => skill?.source_type === "plugin";
const isReadOnlySourceSkill = (skill) =>
isSandboxPresetSkill(skill) || isPluginProvidedSkill(skill);
!!skill?.readonly ||
isSandboxPresetSkill(skill) ||
isPluginProvidedSkill(skill);

const normalizeNeoItemsPayload = (res) => {
const payload = res?.data?.data || [];
Expand Down Expand Up @@ -1266,6 +1270,10 @@ export default {
showMessage(tm("skills.pluginReadonly"), "warning");
return;
}
if (isReadOnlySourceSkill(skill)) {
showMessage(tm("skills.readonlySource"), "warning");
return;
}
skillToDelete.value = skill;
deleteDialog.value = true;
};
Expand Down Expand Up @@ -1300,6 +1308,10 @@ export default {
showMessage(tm("skills.pluginReadonly"), "warning");
return;
}
if (isReadOnlySourceSkill(skill)) {
showMessage(tm("skills.readonlySource"), "warning");
return;
}
itemLoading[skill.name] = true;
try {
const res = await skillApi.download(skill.name);
Expand Down Expand Up @@ -1399,7 +1411,7 @@ export default {
editorDialog.show = true;
const entries = await loadSkillDir("");
const skillMd = entries.find((entry) => entry.path === "SKILL.md");
if (skillMd?.editable) {
if (skillMd) {
await loadSkillFile(skillMd.path);
}
};
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,11 @@
"sourceSandboxOnly": "Sandbox Preset Skill",
"sourceBoth": "Local + Sandbox",
"sourcePlugin": "Plugin: {plugin}",
"sourceGlobal": "Global Skill",
"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.",
"readonlySource": "This Skill is read-only here. It cannot be deleted or downloaded from Local Skills.",
"openEditor": "View/Edit",
"editorTitle": "Edit Skill",
"editorLoadFailed": "Failed to load Skill file",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,11 @@
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
"sourcePlugin": "Плагин: {plugin}",
"sourceGlobal": "Глобальный навык",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь.",
"pluginReadonly": "Навыки из плагинов управляются плагином и не могут быть удалены или скачаны здесь.",
"readonlySource": "Этот навык доступен здесь только для чтения и не может быть удален или скачан.",
"openEditor": "Просмотр/правка",
"editorTitle": "Редактировать навык",
"editorLoadFailed": "Не удалось открыть файл навыка",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,11 @@
"sourceSandboxOnly": "Sandbox 预置 Skill",
"sourceBoth": "本地 + Sandbox",
"sourcePlugin": "插件:{plugin}",
"sourceGlobal": "全局 Skill",
"sandboxDiscoveryPending": "尚未发现 Sandbox 预置 Skill。请至少启动一次 Sandbox 会话后再查看。",
"sandboxPresetReadonly": "Sandbox 预置 Skill 在此处为只读,无法在本地 Skills 页面删除或启用/禁用。",
"pluginReadonly": "插件提供的 Skill 由插件管理,无法在本地 Skills 页面删除或下载。",
"readonlySource": "该 Skill 在此处为只读,无法在本地 Skills 页面删除或下载。",
"openEditor": "查看/编辑",
"editorTitle": "编辑 Skill",
"editorLoadFailed": "读取 Skill 文件失败",
Expand Down
Loading
Loading