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/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py
index 648c771235..a50549fba7 100644
--- a/astrbot/core/computer/computer_client.py
+++ b/astrbot/core/computer/computer_client.py
@@ -31,6 +31,39 @@ 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."""
+ 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,
+ 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 _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.
@@ -351,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)
@@ -362,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)
@@ -382,21 +419,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 +460,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/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/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py
index f835a2ad63..95479fbdab 100644
--- a/astrbot/dashboard/routes/plugin.py
+++ b/astrbot/dashboard/routes/plugin.py
@@ -14,7 +14,9 @@
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.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
@@ -34,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
@@ -53,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),
@@ -89,26 +98,11 @@ def __init__(
self._logo_cache = {}
- async def check_plugin_compatibility(self):
+ async def _sync_skills_after_plugin_change(self) -> None:
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__
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync plugin-provided skills to active sandboxes.")
async def reload_failed_plugins(self):
if DEMO_MODE:
@@ -129,6 +123,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 +146,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()}")
@@ -403,9 +399,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,
@@ -431,13 +424,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 = {}
@@ -453,48 +499,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 []
- if not info["desc"]:
- info["desc"] = "无描述"
+ 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 []
- handlers.append(info)
+ 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
- return handlers
+ def _get_command_filter_display_name(self, command_filter: CommandFilter) -> str:
+ return command_filter.get_complete_command_names()[0].strip()
+
+ 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)
+
+ 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:
@@ -522,6 +774,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:
@@ -563,6 +816,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:
@@ -597,6 +851,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:
@@ -625,6 +880,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:
@@ -647,6 +903,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:
@@ -698,6 +955,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
@@ -718,6 +977,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:
@@ -736,6 +996,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/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..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"
}
},
@@ -356,8 +361,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..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": "Функциональные инструменты",
@@ -355,8 +360,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..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": "仓库"
}
},
@@ -356,8 +361,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/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 @@