diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py
index 44c69b209..192c7b263 100644
--- a/astrbot/core/db/__init__.py
+++ b/astrbot/core/db/__init__.py
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from astrbot.core.db.po import (
Attachment,
+ CommandConfig,
+ CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -314,6 +316,76 @@ class BaseDatabase(abc.ABC):
"""Clear all preferences for a specific scope ID."""
...
+ @abc.abstractmethod
+ async def get_command_configs(self) -> list[CommandConfig]:
+ """Get all stored command configurations."""
+ ...
+
+ @abc.abstractmethod
+ async def get_command_config(self, handler_full_name: str) -> CommandConfig | None:
+ """Fetch a single command configuration by handler."""
+ ...
+
+ @abc.abstractmethod
+ async def upsert_command_config(
+ self,
+ handler_full_name: str,
+ plugin_name: str,
+ module_path: str,
+ original_command: str,
+ *,
+ resolved_command: str | None = None,
+ enabled: bool | None = None,
+ keep_original_alias: bool | None = None,
+ conflict_key: str | None = None,
+ resolution_strategy: str | None = None,
+ note: str | None = None,
+ extra_data: dict | None = None,
+ auto_managed: bool | None = None,
+ ) -> CommandConfig:
+ """Create or update a command configuration."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_command_config(self, handler_full_name: str) -> None:
+ """Delete a single command configuration."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_command_configs(self, handler_full_names: list[str]) -> None:
+ """Bulk delete command configurations."""
+ ...
+
+ @abc.abstractmethod
+ async def list_command_conflicts(
+ self,
+ status: str | None = None,
+ ) -> list[CommandConflict]:
+ """List recorded command conflict entries."""
+ ...
+
+ @abc.abstractmethod
+ async def upsert_command_conflict(
+ self,
+ conflict_key: str,
+ handler_full_name: str,
+ plugin_name: str,
+ *,
+ status: str | None = None,
+ resolution: str | None = None,
+ resolved_command: str | None = None,
+ note: str | None = None,
+ extra_data: dict | None = None,
+ auto_generated: bool | None = None,
+ ) -> CommandConflict:
+ """Create or update a conflict record."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_command_conflicts(self, ids: list[int]) -> None:
+ """Delete conflict records."""
+ ...
+
# @abc.abstractmethod
# async def insert_llm_message(
# self,
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index 34b301c92..64bcf4ce3 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -234,6 +234,65 @@ class Attachment(SQLModel, table=True):
)
+class CommandConfig(SQLModel, table=True):
+ """Per-command configuration overrides for dashboard management."""
+
+ __tablename__ = "command_configs" # type: ignore
+
+ handler_full_name: str = Field(
+ primary_key=True,
+ max_length=512,
+ )
+ plugin_name: str = Field(nullable=False, max_length=255)
+ module_path: str = Field(nullable=False, max_length=255)
+ original_command: str = Field(nullable=False, max_length=255)
+ resolved_command: str | None = Field(default=None, max_length=255)
+ enabled: bool = Field(default=True, nullable=False)
+ keep_original_alias: bool = Field(default=False, nullable=False)
+ conflict_key: str | None = Field(default=None, max_length=255)
+ resolution_strategy: str | None = Field(default=None, max_length=64)
+ note: str | None = Field(default=None, sa_type=Text)
+ extra_data: dict | None = Field(default=None, sa_type=JSON)
+ auto_managed: bool = Field(default=False, nullable=False)
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(
+ default_factory=lambda: datetime.now(timezone.utc),
+ sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
+ )
+
+
+class CommandConflict(SQLModel, table=True):
+ """Conflict tracking for duplicated command names."""
+
+ __tablename__ = "command_conflicts" # type: ignore
+
+ id: int | None = Field(
+ default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
+ )
+ conflict_key: str = Field(nullable=False, max_length=255)
+ handler_full_name: str = Field(nullable=False, max_length=512)
+ plugin_name: str = Field(nullable=False, max_length=255)
+ status: str = Field(default="pending", max_length=32)
+ resolution: str | None = Field(default=None, max_length=64)
+ resolved_command: str | None = Field(default=None, max_length=255)
+ note: str | None = Field(default=None, sa_type=Text)
+ extra_data: dict | None = Field(default=None, sa_type=JSON)
+ auto_generated: bool = Field(default=False, nullable=False)
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(
+ default_factory=lambda: datetime.now(timezone.utc),
+ sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
+ )
+
+ __table_args__ = (
+ UniqueConstraint(
+ "conflict_key",
+ "handler_full_name",
+ name="uix_conflict_handler",
+ ),
+ )
+
+
@dataclass
class Conversation:
"""LLM 对话类
diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py
index 033d076c8..fa3ca9a76 100644
--- a/astrbot/core/db/sqlite.py
+++ b/astrbot/core/db/sqlite.py
@@ -1,6 +1,7 @@
import asyncio
import threading
import typing as T
+from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult
@@ -10,6 +11,8 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import (
Attachment,
+ CommandConfig,
+ CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
)
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
+TxResult = T.TypeVar("TxResult")
class SQLiteDatabase(BaseDatabase):
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
)
await session.commit()
+ # ====
+ # Command Configuration & Conflict Tracking
+ # ====
+
+ async def _run_in_tx(
+ self,
+ fn: Callable[[AsyncSession], Awaitable[TxResult]],
+ ) -> TxResult:
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ return await fn(session)
+
+ @staticmethod
+ def _apply_updates(model, **updates) -> None:
+ for field, value in updates.items():
+ if value is not None:
+ setattr(model, field, value)
+
+ @staticmethod
+ def _new_command_config(
+ handler_full_name: str,
+ plugin_name: str,
+ module_path: str,
+ original_command: str,
+ *,
+ resolved_command: str | None = None,
+ enabled: bool | None = None,
+ keep_original_alias: bool | None = None,
+ conflict_key: str | None = None,
+ resolution_strategy: str | None = None,
+ note: str | None = None,
+ extra_data: dict | None = None,
+ auto_managed: bool | None = None,
+ ) -> CommandConfig:
+ return CommandConfig(
+ handler_full_name=handler_full_name,
+ plugin_name=plugin_name,
+ module_path=module_path,
+ original_command=original_command,
+ resolved_command=resolved_command,
+ enabled=True if enabled is None else enabled,
+ keep_original_alias=False
+ if keep_original_alias is None
+ else keep_original_alias,
+ conflict_key=conflict_key or original_command,
+ resolution_strategy=resolution_strategy,
+ note=note,
+ extra_data=extra_data,
+ auto_managed=bool(auto_managed),
+ )
+
+ @staticmethod
+ def _new_command_conflict(
+ conflict_key: str,
+ handler_full_name: str,
+ plugin_name: str,
+ *,
+ status: str | None = None,
+ resolution: str | None = None,
+ resolved_command: str | None = None,
+ note: str | None = None,
+ extra_data: dict | None = None,
+ auto_generated: bool | None = None,
+ ) -> CommandConflict:
+ return CommandConflict(
+ conflict_key=conflict_key,
+ handler_full_name=handler_full_name,
+ plugin_name=plugin_name,
+ status=status or "pending",
+ resolution=resolution,
+ resolved_command=resolved_command,
+ note=note,
+ extra_data=extra_data,
+ auto_generated=bool(auto_generated),
+ )
+
+ async def get_command_configs(self) -> list[CommandConfig]:
+ async with self.get_db() as session:
+ session: AsyncSession
+ result = await session.execute(select(CommandConfig))
+ return list(result.scalars().all())
+
+ async def get_command_config(
+ self,
+ handler_full_name: str,
+ ) -> CommandConfig | None:
+ async with self.get_db() as session:
+ session: AsyncSession
+ return await session.get(CommandConfig, handler_full_name)
+
+ async def upsert_command_config(
+ self,
+ handler_full_name: str,
+ plugin_name: str,
+ module_path: str,
+ original_command: str,
+ *,
+ resolved_command: str | None = None,
+ enabled: bool | None = None,
+ keep_original_alias: bool | None = None,
+ conflict_key: str | None = None,
+ resolution_strategy: str | None = None,
+ note: str | None = None,
+ extra_data: dict | None = None,
+ auto_managed: bool | None = None,
+ ) -> CommandConfig:
+ async def _op(session: AsyncSession) -> CommandConfig:
+ config = await session.get(CommandConfig, handler_full_name)
+ if not config:
+ config = self._new_command_config(
+ handler_full_name,
+ plugin_name,
+ module_path,
+ original_command,
+ resolved_command=resolved_command,
+ enabled=enabled,
+ keep_original_alias=keep_original_alias,
+ conflict_key=conflict_key,
+ resolution_strategy=resolution_strategy,
+ note=note,
+ extra_data=extra_data,
+ auto_managed=auto_managed,
+ )
+ session.add(config)
+ else:
+ self._apply_updates(
+ config,
+ plugin_name=plugin_name,
+ module_path=module_path,
+ original_command=original_command,
+ resolved_command=resolved_command,
+ enabled=enabled,
+ keep_original_alias=keep_original_alias,
+ conflict_key=conflict_key,
+ resolution_strategy=resolution_strategy,
+ note=note,
+ extra_data=extra_data,
+ auto_managed=auto_managed,
+ )
+ await session.flush()
+ await session.refresh(config)
+ return config
+
+ return await self._run_in_tx(_op)
+
+ async def delete_command_config(self, handler_full_name: str) -> None:
+ await self.delete_command_configs([handler_full_name])
+
+ async def delete_command_configs(self, handler_full_names: list[str]) -> None:
+ if not handler_full_names:
+ return
+
+ async def _op(session: AsyncSession) -> None:
+ await session.execute(
+ delete(CommandConfig).where(
+ col(CommandConfig.handler_full_name).in_(handler_full_names),
+ ),
+ )
+
+ await self._run_in_tx(_op)
+
+ async def list_command_conflicts(
+ self,
+ status: str | None = None,
+ ) -> list[CommandConflict]:
+ async with self.get_db() as session:
+ session: AsyncSession
+ query = select(CommandConflict)
+ if status:
+ query = query.where(CommandConflict.status == status)
+ result = await session.execute(query)
+ return list(result.scalars().all())
+
+ async def upsert_command_conflict(
+ self,
+ conflict_key: str,
+ handler_full_name: str,
+ plugin_name: str,
+ *,
+ status: str | None = None,
+ resolution: str | None = None,
+ resolved_command: str | None = None,
+ note: str | None = None,
+ extra_data: dict | None = None,
+ auto_generated: bool | None = None,
+ ) -> CommandConflict:
+ async def _op(session: AsyncSession) -> CommandConflict:
+ result = await session.execute(
+ select(CommandConflict).where(
+ CommandConflict.conflict_key == conflict_key,
+ CommandConflict.handler_full_name == handler_full_name,
+ ),
+ )
+ record = result.scalar_one_or_none()
+ if not record:
+ record = self._new_command_conflict(
+ conflict_key,
+ handler_full_name,
+ plugin_name,
+ status=status,
+ resolution=resolution,
+ resolved_command=resolved_command,
+ note=note,
+ extra_data=extra_data,
+ auto_generated=auto_generated,
+ )
+ session.add(record)
+ else:
+ self._apply_updates(
+ record,
+ plugin_name=plugin_name,
+ status=status,
+ resolution=resolution,
+ resolved_command=resolved_command,
+ note=note,
+ extra_data=extra_data,
+ auto_generated=auto_generated,
+ )
+ await session.flush()
+ await session.refresh(record)
+ return record
+
+ return await self._run_in_tx(_op)
+
+ async def delete_command_conflicts(self, ids: list[int]) -> None:
+ if not ids:
+ return
+
+ async def _op(session: AsyncSession) -> None:
+ await session.execute(
+ delete(CommandConflict).where(col(CommandConflict.id).in_(ids)),
+ )
+
+ await self._run_in_tx(_op)
+
# ====
# Deprecated Methods
# ====
diff --git a/astrbot/core/star/command_management.py b/astrbot/core/star/command_management.py
new file mode 100644
index 000000000..a0b125d33
--- /dev/null
+++ b/astrbot/core/star/command_management.py
@@ -0,0 +1,449 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from dataclasses import dataclass, field
+from typing import Any
+
+from astrbot.core import db_helper
+from astrbot.core.db.po import CommandConfig
+from astrbot.core.star.filter.command import CommandFilter
+from astrbot.core.star.filter.command_group import CommandGroupFilter
+from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
+from astrbot.core.star.star import star_map
+from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
+
+
+@dataclass
+class CommandDescriptor:
+ handler: StarHandlerMetadata = field(repr=False)
+ filter_ref: CommandFilter | CommandGroupFilter | None = field(
+ default=None,
+ repr=False,
+ )
+ handler_full_name: str = ""
+ handler_name: str = ""
+ plugin_name: str = ""
+ plugin_display_name: str | None = None
+ module_path: str = ""
+ description: str = ""
+ command_type: str = "command" # "command" | "group" | "sub_command"
+ raw_command_name: str | None = None
+ current_fragment: str | None = None
+ parent_signature: str = ""
+ parent_group_handler: str = ""
+ original_command: str | None = None
+ effective_command: str | None = None
+ aliases: list[str] = field(default_factory=list)
+ permission: str = "everyone"
+ enabled: bool = True
+ is_group: bool = False
+ is_sub_command: bool = False
+ reserved: bool = False
+ config: CommandConfig | None = None
+ has_conflict: bool = False
+ sub_commands: list[CommandDescriptor] = field(default_factory=list)
+
+
+async def sync_command_configs() -> None:
+ """同步指令配置,清理过期配置。"""
+ descriptors = _collect_descriptors(include_sub_commands=False)
+ config_records = await db_helper.get_command_configs()
+ config_map = _bind_configs_to_descriptors(descriptors, config_records)
+ live_handlers = {desc.handler_full_name for desc in descriptors}
+
+ stale_configs = [key for key in config_map if key not in live_handlers]
+ if stale_configs:
+ await db_helper.delete_command_configs(stale_configs)
+
+
+async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor:
+ descriptor = _build_descriptor_by_full_name(handler_full_name)
+ if not descriptor:
+ raise ValueError("指定的处理函数不存在或不是指令。")
+
+ existing_cfg = await db_helper.get_command_config(handler_full_name)
+ config = await db_helper.upsert_command_config(
+ handler_full_name=handler_full_name,
+ plugin_name=descriptor.plugin_name or "",
+ module_path=descriptor.module_path,
+ original_command=descriptor.original_command or descriptor.handler_name,
+ resolved_command=(
+ existing_cfg.resolved_command
+ if existing_cfg
+ else descriptor.current_fragment
+ ),
+ enabled=enabled,
+ keep_original_alias=False,
+ conflict_key=existing_cfg.conflict_key
+ if existing_cfg and existing_cfg.conflict_key
+ else descriptor.original_command,
+ resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None,
+ note=existing_cfg.note if existing_cfg else None,
+ extra_data=existing_cfg.extra_data if existing_cfg else None,
+ auto_managed=False,
+ )
+ _bind_descriptor_with_config(descriptor, config)
+ await sync_command_configs()
+ return descriptor
+
+
+async def rename_command(
+ handler_full_name: str,
+ new_fragment: str,
+) -> CommandDescriptor:
+ descriptor = _build_descriptor_by_full_name(handler_full_name)
+ if not descriptor:
+ raise ValueError("指定的处理函数不存在或不是指令。")
+
+ new_fragment = new_fragment.strip()
+ if not new_fragment:
+ raise ValueError("指令名不能为空。")
+
+ candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
+ if _is_command_in_use(handler_full_name, candidate_full):
+ raise ValueError("新的指令名已被其他指令占用,请换一个名称。")
+
+ config = await db_helper.upsert_command_config(
+ handler_full_name=handler_full_name,
+ plugin_name=descriptor.plugin_name or "",
+ module_path=descriptor.module_path,
+ original_command=descriptor.original_command or descriptor.handler_name,
+ resolved_command=new_fragment,
+ enabled=True if descriptor.enabled else False,
+ keep_original_alias=False,
+ conflict_key=descriptor.original_command,
+ resolution_strategy="manual_rename",
+ note=None,
+ extra_data=None,
+ auto_managed=False,
+ )
+ _bind_descriptor_with_config(descriptor, config)
+
+ await sync_command_configs()
+ return descriptor
+
+
+async def list_commands() -> list[dict[str, Any]]:
+ descriptors = _collect_descriptors(include_sub_commands=True)
+ config_records = await db_helper.get_command_configs()
+ _bind_configs_to_descriptors(descriptors, config_records)
+
+ conflict_groups = _group_conflicts(descriptors)
+ conflict_handler_names: set[str] = {
+ d.handler_full_name for group in conflict_groups.values() for d in group
+ }
+
+ # 分类,设置冲突标志,将子指令挂载到父指令组
+ group_map: dict[str, CommandDescriptor] = {}
+ sub_commands: list[CommandDescriptor] = []
+ root_commands: list[CommandDescriptor] = []
+
+ for desc in descriptors:
+ desc.has_conflict = desc.handler_full_name in conflict_handler_names
+ if desc.is_group:
+ group_map[desc.handler_full_name] = desc
+ elif desc.is_sub_command:
+ sub_commands.append(desc)
+ else:
+ root_commands.append(desc)
+
+ for sub in sub_commands:
+ if sub.parent_group_handler and sub.parent_group_handler in group_map:
+ group_map[sub.parent_group_handler].sub_commands.append(sub)
+ else:
+ root_commands.append(sub)
+
+ # 指令组 + 普通指令,按 effective_command 字母排序
+ all_commands = list(group_map.values()) + root_commands
+ all_commands.sort(key=lambda d: (d.effective_command or "").lower())
+
+ result = [_descriptor_to_dict(desc) for desc in all_commands]
+ return result
+
+
+async def list_command_conflicts() -> list[dict[str, Any]]:
+ """列出所有冲突的指令组。"""
+ descriptors = _collect_descriptors(include_sub_commands=False)
+ config_records = await db_helper.get_command_configs()
+ _bind_configs_to_descriptors(descriptors, config_records)
+
+ conflict_groups = _group_conflicts(descriptors)
+ details = [
+ {
+ "conflict_key": key,
+ "handlers": [
+ {
+ "handler_full_name": item.handler_full_name,
+ "plugin": item.plugin_name,
+ "current_name": item.effective_command,
+ }
+ for item in group
+ ],
+ }
+ for key, group in conflict_groups.items()
+ ]
+ return details
+
+
+# Internal helpers ----------------------------------------------------------
+
+
+def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
+ """收集指令,按需包含子指令。"""
+ descriptors: list[CommandDescriptor] = []
+ for handler in star_handlers_registry:
+ desc = _build_descriptor(handler)
+ if not desc:
+ continue
+ if not include_sub_commands and desc.is_sub_command:
+ continue
+ descriptors.append(desc)
+ return descriptors
+
+
+def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None:
+ filter_ref = _locate_primary_filter(handler)
+ if filter_ref is None:
+ return None
+
+ plugin_meta = star_map.get(handler.handler_module_path)
+ plugin_name = (
+ plugin_meta.name if plugin_meta else None
+ ) or handler.handler_module_path
+ plugin_display = plugin_meta.display_name if plugin_meta else None
+
+ is_sub_command = bool(handler.extras_configs.get("sub_command"))
+ parent_group_handler = ""
+
+ if isinstance(filter_ref, CommandFilter):
+ raw_fragment = getattr(
+ filter_ref, "_original_command_name", filter_ref.command_name
+ )
+ current_fragment = filter_ref.command_name
+ parent_signature = (filter_ref.parent_command_names or [""])[0].strip()
+ # 如果是子指令,尝试找到父指令组的 handler_full_name
+ if is_sub_command and parent_signature:
+ parent_group_handler = _find_parent_group_handler(
+ handler.handler_module_path, parent_signature
+ )
+ else:
+ raw_fragment = getattr(
+ filter_ref, "_original_group_name", filter_ref.group_name
+ )
+ current_fragment = filter_ref.group_name
+ parent_signature = _resolve_group_parent_signature(filter_ref)
+
+ original_command = _compose_command(parent_signature, raw_fragment)
+ effective_command = _compose_command(parent_signature, current_fragment)
+
+ # 确定 command_type
+ if isinstance(filter_ref, CommandGroupFilter):
+ command_type = "group"
+ elif is_sub_command:
+ command_type = "sub_command"
+ else:
+ command_type = "command"
+
+ descriptor = CommandDescriptor(
+ handler=handler,
+ filter_ref=filter_ref,
+ handler_full_name=handler.handler_full_name,
+ handler_name=handler.handler_name,
+ plugin_name=plugin_name,
+ plugin_display_name=plugin_display,
+ module_path=handler.handler_module_path,
+ description=handler.desc or "",
+ command_type=command_type,
+ raw_command_name=raw_fragment,
+ current_fragment=current_fragment,
+ parent_signature=parent_signature,
+ parent_group_handler=parent_group_handler,
+ original_command=original_command,
+ effective_command=effective_command,
+ aliases=sorted(getattr(filter_ref, "alias", set())),
+ permission=_determine_permission(handler),
+ enabled=handler.enabled,
+ is_group=isinstance(filter_ref, CommandGroupFilter),
+ is_sub_command=is_sub_command,
+ reserved=plugin_meta.reserved if plugin_meta else False,
+ )
+ return descriptor
+
+
+def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None:
+ handler = star_handlers_registry.get_handler_by_full_name(full_name)
+ if not handler:
+ return None
+ return _build_descriptor(handler)
+
+
+def _locate_primary_filter(
+ handler: StarHandlerMetadata,
+) -> CommandFilter | CommandGroupFilter | None:
+ for filter_ref in handler.event_filters:
+ if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
+ return filter_ref
+ return None
+
+
+def _determine_permission(handler: StarHandlerMetadata) -> str:
+ for filter_ref in handler.event_filters:
+ if isinstance(filter_ref, PermissionTypeFilter):
+ return (
+ "admin"
+ if filter_ref.permission_type == PermissionType.ADMIN
+ else "member"
+ )
+ return "everyone"
+
+
+def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str:
+ signatures: list[str] = []
+ parent = group_filter.parent_group
+ while parent:
+ signatures.append(getattr(parent, "_original_group_name", parent.group_name))
+ parent = parent.parent_group
+ return " ".join(reversed(signatures)).strip()
+
+
+def _find_parent_group_handler(module_path: str, parent_signature: str) -> str:
+ """根据模块路径和父级签名,找到对应的指令组 handler_full_name。"""
+ parent_sig_normalized = parent_signature.strip()
+ for handler in star_handlers_registry:
+ if handler.handler_module_path != module_path:
+ continue
+ filter_ref = _locate_primary_filter(handler)
+ if not isinstance(filter_ref, CommandGroupFilter):
+ continue
+ # 检查该指令组的完整指令名是否匹配 parent_signature
+ group_names = filter_ref.get_complete_command_names()
+ if parent_sig_normalized in group_names:
+ return handler.handler_full_name
+ return ""
+
+
+def _compose_command(parent_signature: str, fragment: str | None) -> str:
+ fragment = (fragment or "").strip()
+ parent_signature = parent_signature.strip()
+ if not parent_signature:
+ return fragment
+ if not fragment:
+ return parent_signature
+ return f"{parent_signature} {fragment}"
+
+
+def _bind_descriptor_with_config(
+ descriptor: CommandDescriptor,
+ config: CommandConfig,
+) -> None:
+ _apply_config_to_descriptor(descriptor, config)
+ _apply_config_to_runtime(descriptor, config)
+
+
+def _apply_config_to_descriptor(
+ descriptor: CommandDescriptor,
+ config: CommandConfig,
+) -> None:
+ descriptor.config = config
+ descriptor.enabled = config.enabled
+
+ if config.original_command:
+ descriptor.original_command = config.original_command
+
+ new_fragment = config.resolved_command or descriptor.current_fragment
+ descriptor.current_fragment = new_fragment
+ descriptor.effective_command = _compose_command(
+ descriptor.parent_signature,
+ new_fragment,
+ )
+
+
+def _apply_config_to_runtime(
+ descriptor: CommandDescriptor,
+ config: CommandConfig,
+) -> None:
+ descriptor.handler.enabled = config.enabled
+ if descriptor.filter_ref and descriptor.current_fragment:
+ _set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
+
+
+def _bind_configs_to_descriptors(
+ descriptors: list[CommandDescriptor],
+ config_records: list[CommandConfig],
+) -> dict[str, CommandConfig]:
+ config_map = {cfg.handler_full_name: cfg for cfg in config_records}
+ for desc in descriptors:
+ if cfg := config_map.get(desc.handler_full_name):
+ _bind_descriptor_with_config(desc, cfg)
+ return config_map
+
+
+def _group_conflicts(
+ descriptors: list[CommandDescriptor],
+) -> dict[str, list[CommandDescriptor]]:
+ conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list)
+ for desc in descriptors:
+ if desc.effective_command and desc.enabled:
+ conflicts[desc.effective_command].append(desc)
+ return {k: v for k, v in conflicts.items() if len(v) > 1}
+
+
+def _set_filter_fragment(
+ filter_ref: CommandFilter | CommandGroupFilter,
+ fragment: str,
+) -> None:
+ attr = (
+ "group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name"
+ )
+ current_value = getattr(filter_ref, attr)
+ if fragment == current_value:
+ return
+ setattr(filter_ref, attr, fragment)
+ if hasattr(filter_ref, "_cmpl_cmd_names"):
+ filter_ref._cmpl_cmd_names = None
+
+
+def _is_command_in_use(
+ target_handler_full_name: str,
+ candidate_full_command: str,
+) -> bool:
+ candidate = candidate_full_command.strip()
+ for handler in star_handlers_registry:
+ if handler.handler_full_name == target_handler_full_name:
+ continue
+ filter_ref = _locate_primary_filter(handler)
+ if not filter_ref:
+ continue
+ names = {name.strip() for name in filter_ref.get_complete_command_names()}
+ if candidate in names:
+ return True
+ return False
+
+
+def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]:
+ result = {
+ "handler_full_name": desc.handler_full_name,
+ "handler_name": desc.handler_name,
+ "plugin": desc.plugin_name,
+ "plugin_display_name": desc.plugin_display_name,
+ "module_path": desc.module_path,
+ "description": desc.description,
+ "type": desc.command_type,
+ "parent_signature": desc.parent_signature,
+ "parent_group_handler": desc.parent_group_handler,
+ "original_command": desc.original_command,
+ "current_fragment": desc.current_fragment,
+ "effective_command": desc.effective_command,
+ "aliases": desc.aliases,
+ "permission": desc.permission,
+ "enabled": desc.enabled,
+ "is_group": desc.is_group,
+ "has_conflict": desc.has_conflict,
+ "reserved": desc.reserved,
+ }
+ # 如果是指令组,包含子指令列表
+ if desc.is_group and desc.sub_commands:
+ result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands]
+ else:
+ result["sub_commands"] = []
+ return result
diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py
index 2a9868fdc..51ad5f089 100755
--- a/astrbot/core/star/filter/command.py
+++ b/astrbot/core/star/filter/command.py
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
):
self.command_name = command_name
self.alias = alias if alias else set()
+ self._original_command_name = command_name
self.parent_command_names = (
parent_command_names if parent_command_names is not None else [""]
)
diff --git a/astrbot/core/star/filter/command_group.py b/astrbot/core/star/filter/command_group.py
index e1c2efb22..4cbd2c007 100755
--- a/astrbot/core/star/filter/command_group.py
+++ b/astrbot/core/star/filter/command_group.py
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
):
self.group_name = group_name
self.alias = alias if alias else set()
+ self._original_group_name = group_name
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
self.custom_filter_list: list[CustomFilter] = []
self.parent_group = parent_group
diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py
index da59cd291..be5b4679f 100644
--- a/astrbot/core/star/star_handler.py
+++ b/astrbot/core/star/star_handler.py
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
# 过滤事件类型
if handler.event_type != event_type:
continue
+ if not handler.enabled:
+ continue
# 过滤启用状态
if only_activated:
plugin = star_map.get(handler.handler_module_path)
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
extras_configs: dict = field(default_factory=dict)
"""插件注册的一些其他的信息, 如 priority 等"""
+ enabled: bool = True
+
def __lt__(self, other: StarHandlerMetadata):
"""定义小于运算符以支持优先队列"""
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index 15dc91b48..1f9f95ae5 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
from astrbot.core.utils.io import remove_dir
from . import StarMetadata
+from .command_management import sync_command_configs
from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
@@ -630,6 +631,7 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
+ await sync_command_configs()
if not fail_rec:
return True, None
diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py
index 514e6d6ed..951db956c 100644
--- a/astrbot/dashboard/routes/__init__.py
+++ b/astrbot/dashboard/routes/__init__.py
@@ -1,5 +1,6 @@
from .auth import AuthRoute
from .chat import ChatRoute
+from .command import CommandRoute
from .config import ConfigRoute
from .conversation import ConversationRoute
from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
__all__ = [
"AuthRoute",
"ChatRoute",
+ "CommandRoute",
"ConfigRoute",
"ConversationRoute",
"FileRoute",
diff --git a/astrbot/dashboard/routes/command.py b/astrbot/dashboard/routes/command.py
new file mode 100644
index 000000000..5cb267169
--- /dev/null
+++ b/astrbot/dashboard/routes/command.py
@@ -0,0 +1,82 @@
+from quart import request
+
+from astrbot.core.star.command_management import (
+ list_command_conflicts,
+ list_commands,
+)
+from astrbot.core.star.command_management import (
+ rename_command as rename_command_service,
+)
+from astrbot.core.star.command_management import (
+ toggle_command as toggle_command_service,
+)
+
+from .route import Response, Route, RouteContext
+
+
+class CommandRoute(Route):
+ def __init__(self, context: RouteContext) -> None:
+ super().__init__(context)
+ self.routes = {
+ "/commands": ("GET", self.get_commands),
+ "/commands/conflicts": ("GET", self.get_conflicts),
+ "/commands/toggle": ("POST", self.toggle_command),
+ "/commands/rename": ("POST", self.rename_command),
+ }
+ self.register_routes()
+
+ async def get_commands(self):
+ commands = await list_commands()
+ summary = {
+ "total": len(commands),
+ "disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
+ "conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
+ }
+ return Response().ok({"items": commands, "summary": summary}).__dict__
+
+ async def get_conflicts(self):
+ conflicts = await list_command_conflicts()
+ return Response().ok(conflicts).__dict__
+
+ async def toggle_command(self):
+ data = await request.get_json()
+ handler_full_name = data.get("handler_full_name")
+ enabled = data.get("enabled")
+
+ if handler_full_name is None or enabled is None:
+ return Response().error("handler_full_name 与 enabled 均为必填。").__dict__
+
+ if isinstance(enabled, str):
+ enabled = enabled.lower() in ("1", "true", "yes", "on")
+
+ try:
+ await toggle_command_service(handler_full_name, bool(enabled))
+ except ValueError as exc:
+ return Response().error(str(exc)).__dict__
+
+ payload = await _get_command_payload(handler_full_name)
+ return Response().ok(payload).__dict__
+
+ async def rename_command(self):
+ data = await request.get_json()
+ handler_full_name = data.get("handler_full_name")
+ new_name = data.get("new_name")
+
+ if not handler_full_name or not new_name:
+ return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
+
+ try:
+ await rename_command_service(handler_full_name, new_name)
+ except ValueError as exc:
+ return Response().error(str(exc)).__dict__
+
+ payload = await _get_command_payload(handler_full_name)
+ return Response().ok(payload).__dict__
+
+
+async def _get_command_payload(handler_full_name: str):
+ commands = await list_commands()
+ for cmd in commands:
+ if cmd["handler_full_name"] == handler_full_name:
+ return cmd
+ return {}
diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py
index 64cd78caa..d7b082000 100644
--- a/astrbot/dashboard/routes/tools.py
+++ b/astrbot/dashboard/routes/tools.py
@@ -3,6 +3,7 @@ import traceback
from quart import request
from astrbot.core import logger
+from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map
@@ -296,15 +297,30 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表"""
try:
tools = self.tool_mgr.func_list
- tools_dict = [
- {
+ tools_dict = []
+ for tool in tools:
+ if isinstance(tool, MCPTool):
+ origin = "mcp"
+ origin_name = tool.mcp_server_name
+ elif tool.handler_module_path and star_map.get(
+ tool.handler_module_path
+ ):
+ star = star_map[tool.handler_module_path]
+ origin = "plugin"
+ origin_name = star.name
+ else:
+ origin = "unknown"
+ origin_name = "unknown"
+
+ tool_info = {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
"active": tool.active,
+ "origin": origin,
+ "origin_name": origin_name,
}
- for tool in tools
- ]
+ tools_dict.append(tool_info)
return Response().ok(data=tools_dict).__dict__
except Exception as e:
logger.error(traceback.format_exc())
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index 09ec76b52..6d6530c90 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -67,6 +67,7 @@ class AstrBotDashboard:
core_lifecycle,
core_lifecycle.plugin_manager,
)
+ self.command_route = CommandRoute(self.context)
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
diff --git a/dashboard/src/views/ToolUsePage.vue b/dashboard/src/components/extension/McpServersSection.vue
similarity index 62%
rename from dashboard/src/views/ToolUsePage.vue
rename to dashboard/src/components/extension/McpServersSection.vue
index db8fee905..fe20497f8 100644
--- a/dashboard/src/views/ToolUsePage.vue
+++ b/dashboard/src/components/extension/McpServersSection.vue
@@ -4,42 +4,18 @@
-
- mdi-function-variant{{ tm('title') }}
-
-
- {{ tm('subtitle') }}
-
-
-
- mdi-information
-
-
- {{ tm('tooltip.info') }}
-
-
-
-
-
- {{ tm('functionTools.buttons.view') }}({{ tools.length }})
-
+ @click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }}
+ >
{{ tm('mcpServers.buttons.sync') }}
-
-
-
mdi-server-off
{{ tm('mcpServers.empty') }}
@@ -57,7 +33,6 @@
-
@@ -67,8 +42,7 @@
- {{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{
- item.tools.length }})
+ {{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
@@ -78,10 +52,7 @@
- - {{
- tool
- }}
-
+ - {{ tool }}
@@ -91,8 +62,6 @@
-
-
@@ -105,8 +74,6 @@
-
-
@@ -183,8 +150,7 @@
-
-
+
@@ -240,115 +206,8 @@
-
-
-
-
- {{ tm('functionTools.title') }}
- {{ tools.length }}
-
-
-
-
-
-
mdi-api-off
-
{{ tm('functionTools.empty') }}
-
-
-
-
-
-
复选框代表该工具是否被启用。
-
-
-
-
-
-
-
-
-
-
-
- {{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
-
-
- {{ formatToolName(tool.name) }}
-
-
-
-
- {{ tool.description }}
-
-
-
-
-
-
-
-
- mdi-information
- {{ tm('functionTools.description') }}
-
- {{ tool.description }}
-
-
-
- mdi-code-json
- {{ tm('functionTools.parameters') }}
-
-
-
-
-
- | {{ tm('functionTools.table.paramName') }} |
- {{ tm('functionTools.table.type') }} |
- {{ tm('functionTools.table.description') }} |
-
-
-
-
- | {{ paramName }} |
-
-
- {{ param.type }}
-
- |
- {{ param.description }} |
-
-
-
-
-
-
mdi-code-brackets
-
{{ tm('functionTools.noParameters') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ tm('dialogs.serverDetail.buttons.close') }}
-
-
-
-
-
-
+
{{ save_message }}
@@ -356,15 +215,13 @@
\ No newline at end of file
+
diff --git a/dashboard/src/components/extension/componentPanel/components/CommandFilters.vue b/dashboard/src/components/extension/componentPanel/components/CommandFilters.vue
new file mode 100644
index 000000000..c4b212803
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/components/CommandFilters.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('filters.showSystemPlugins') }}
+
+
+ mdi-alert-circle
+
+ {{ tm('filters.systemPluginConflictHint') }}
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/extension/componentPanel/components/CommandTable.vue b/dashboard/src/components/extension/componentPanel/components/CommandTable.vue
new file mode 100644
index 000000000..f8bb6fa82
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/components/CommandTable.vue
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
+ {{ isGroupExpanded(item) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
+
+
+
+
+
+ {{ item.effective_command }}
+
+
+
+
+
+
+
+ {{ getTypeInfo(item.type).icon }}
+ {{ getTypeInfo(item.type).text }}{{ item.is_group && item.sub_commands?.length > 0 ? `(${item.sub_commands.length})` : '' }}
+
+
+
+
+ {{ item.plugin_display_name || item.plugin }}
+
+
+
+
+ {{ item.description || '-' }}
+
+
+
+
+
+ {{ getPermissionLabel(item.permission) }}
+
+
+
+
+
+ {{ getStatusInfo(item).text }}
+
+
+
+
+
+
+
+ mdi-play
+ {{ tm('tooltips.enable') }}
+
+
+ mdi-pause
+ {{ tm('tooltips.disable') }}
+
+
+
+ mdi-pencil
+ {{ tm('tooltips.rename') }}
+
+
+
+ mdi-information
+ {{ tm('tooltips.viewDetails') }}
+
+
+
+
+
+
+
+
mdi-console-line
+
{{ tm('empty.noCommands') }}
+
{{ tm('empty.noCommandsDesc') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue b/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue
new file mode 100644
index 000000000..6d9188374
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+ {{ tm('dialogs.details.title') }}
+
+
+
+ {{ tm('dialogs.details.type') }}
+
+
+ {{ getTypeInfo(command.type).icon }}
+ {{ getTypeInfo(command.type).text }}
+
+
+
+
+ {{ tm('dialogs.details.handler') }}
+ {{ command.handler_name }}
+
+
+ {{ tm('dialogs.details.module') }}
+ {{ command.module_path }}
+
+
+ {{ tm('dialogs.details.originalCommand') }}
+ {{ command.original_command }}
+
+
+ {{ tm('dialogs.details.effectiveCommand') }}
+ {{ command.effective_command }}
+
+
+ {{ tm('dialogs.details.parentGroup') }}
+ {{ command.parent_signature }}
+
+
+ {{ tm('dialogs.details.aliases') }}
+
+
+ {{ alias }}
+
+
+
+
+ {{ tm('dialogs.details.subCommands') }}
+
+
+
+ {{ sub.current_fragment }}
+
+
+
+
+
+ {{ tm('dialogs.details.permission') }}
+
+
+ {{ getPermissionLabel(command.permission) }}
+
+
+
+
+ {{ tm('dialogs.details.conflictStatus') }}
+
+ {{ tm('status.conflict') }}
+
+
+
+
+
+
+
+ {{ t('core.actions.close') }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue b/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue
new file mode 100644
index 000000000..ffdc5a826
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+ {{ tm('dialogs.rename.title') }}
+
+
+
+
+
+
+ {{ tm('dialogs.rename.cancel') }}
+
+
+ {{ tm('dialogs.rename.confirm') }}
+
+
+
+
+
diff --git a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue
new file mode 100644
index 000000000..1b6fecfc1
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+ {{ item.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
+
+
+
+
+
+
+
+ {{ item.description || '-' }}
+
+
+
+
+
+ {{ item.origin || '-' }}
+
+
+
+
+
+ {{ item.origin_name || '-' }}
+
+
+
+
+
+ {{ item.active ? tmCommand('status.enabled') : tmCommand('status.disabled') }}
+
+
+
+
+
+
+
+
+
+
mdi-function-variant
+
{{ tmTool('functionTools.empty') }}
+
+
+
+
+
+
+ mdi-code-json
+
+ {{ tmTool('functionTools.parameters') }}
+
+ {{ tmTool('functionTools.noParameters') }}
+
+
+
+
+ | {{ tmTool('functionTools.table.paramName') }} |
+ {{ tmTool('functionTools.table.type') }} |
+ {{ tmTool('functionTools.table.description') }} |
+
+
+
+
+ | {{ paramName }} |
+
+
+ {{ param?.type || '-' }}
+
+ |
+ {{ param?.description || '-' }} |
+
+
+
+
+
+ |
+
+
+
+
+
+
diff --git a/dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts b/dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts
new file mode 100644
index 000000000..a285c473f
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts
@@ -0,0 +1,177 @@
+/**
+ * 指令操作方法 Composable
+ */
+import { reactive } from 'vue';
+import axios from 'axios';
+import type { CommandItem, RenameDialogState, DetailsDialogState, TypeInfo, StatusInfo } from '../types';
+
+export function useCommandActions(
+ toast: (message: string, color?: string) => void,
+ fetchCommands: () => Promise
+) {
+ // 重命名对话框状态
+ const renameDialog = reactive({
+ show: false,
+ command: null,
+ newName: '',
+ loading: false
+ });
+
+ // 详情对话框状态
+ const detailsDialog = reactive({
+ show: false,
+ command: null
+ });
+
+ /**
+ * 切换指令启用/禁用状态
+ */
+ const toggleCommand = async (
+ cmd: CommandItem,
+ successMessage: string,
+ errorMessage: string
+ ) => {
+ try {
+ const res = await axios.post('/api/commands/toggle', {
+ handler_full_name: cmd.handler_full_name,
+ enabled: !cmd.enabled
+ });
+ if (res.data.status === 'ok') {
+ toast(successMessage, 'success');
+ await fetchCommands();
+ } else {
+ toast(res.data.message || errorMessage, 'error');
+ }
+ } catch (err: any) {
+ toast(err?.message || errorMessage, 'error');
+ }
+ };
+
+ /**
+ * 打开重命名对话框
+ */
+ const openRenameDialog = (cmd: CommandItem) => {
+ renameDialog.command = cmd;
+ renameDialog.newName = cmd.current_fragment || '';
+ renameDialog.show = true;
+ };
+
+ /**
+ * 确认重命名
+ */
+ const confirmRename = async (successMessage: string, errorMessage: string) => {
+ if (!renameDialog.command || !renameDialog.newName.trim()) return;
+
+ renameDialog.loading = true;
+ try {
+ const res = await axios.post('/api/commands/rename', {
+ handler_full_name: renameDialog.command.handler_full_name,
+ new_name: renameDialog.newName.trim()
+ });
+ if (res.data.status === 'ok') {
+ toast(successMessage, 'success');
+ renameDialog.show = false;
+ await fetchCommands();
+ } else {
+ toast(res.data.message || errorMessage, 'error');
+ }
+ } catch (err: any) {
+ toast(err?.message || errorMessage, 'error');
+ } finally {
+ renameDialog.loading = false;
+ }
+ };
+
+ /**
+ * 打开详情对话框
+ */
+ const openDetailsDialog = (cmd: CommandItem) => {
+ detailsDialog.command = cmd;
+ detailsDialog.show = true;
+ };
+
+ /**
+ * 获取类型显示信息
+ */
+ const getTypeInfo = (type: string, translations: { group: string; subCommand: string; command: string }): TypeInfo => {
+ switch (type) {
+ case 'group':
+ return { text: translations.group, color: 'info', icon: 'mdi-folder-outline' };
+ case 'sub_command':
+ return { text: translations.subCommand, color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
+ default:
+ return { text: translations.command, color: 'primary', icon: 'mdi-console-line' };
+ }
+ };
+
+ /**
+ * 获取权限颜色
+ */
+ const getPermissionColor = (permission: string): string => {
+ switch (permission) {
+ case 'admin': return 'error';
+ default: return 'success';
+ }
+ };
+
+ /**
+ * 获取权限标签
+ */
+ const getPermissionLabel = (permission: string, translations: { admin: string; everyone: string }): string => {
+ switch (permission) {
+ case 'admin': return translations.admin;
+ default: return translations.everyone;
+ }
+ };
+
+ /**
+ * 获取状态显示信息
+ */
+ const getStatusInfo = (
+ cmd: CommandItem,
+ translations: { conflict: string; enabled: string; disabled: string }
+ ): StatusInfo => {
+ if (cmd.has_conflict) {
+ return { text: translations.conflict, color: 'warning', variant: 'flat' };
+ }
+ if (cmd.enabled) {
+ return { text: translations.enabled, color: 'success', variant: 'flat' };
+ }
+ return { text: translations.disabled, color: 'error', variant: 'outlined' };
+ };
+
+ /**
+ * 获取表格行属性(用于冲突高亮和子指令样式)
+ */
+ const getRowProps = ({ item }: { item: CommandItem }) => {
+ const classes: string[] = [];
+ if (item.has_conflict) {
+ classes.push('conflict-row');
+ }
+ if (item.type === 'sub_command') {
+ classes.push('sub-command-row');
+ }
+ if (item.is_group) {
+ classes.push('group-row');
+ }
+ return classes.length > 0 ? { class: classes.join(' ') } : {};
+ };
+
+ return {
+ // 状态
+ renameDialog,
+ detailsDialog,
+
+ // 方法
+ toggleCommand,
+ openRenameDialog,
+ confirmRename,
+ openDetailsDialog,
+ getTypeInfo,
+ getPermissionColor,
+ getPermissionLabel,
+ getStatusInfo,
+ getRowProps
+ };
+}
+
diff --git a/dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts b/dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts
new file mode 100644
index 000000000..f7d5bbc0e
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts
@@ -0,0 +1,187 @@
+/**
+ * 指令过滤逻辑 Composable
+ */
+import { ref, computed, type Ref } from 'vue';
+import type { CommandItem, FilterState } from '../types';
+
+export function useCommandFilters(commands: Ref) {
+ // 过滤状态
+ const searchQuery = ref('');
+ const pluginFilter = ref('all');
+ const permissionFilter = ref('all');
+ const statusFilter = ref('all');
+ const typeFilter = ref('all');
+ const showSystemPlugins = ref(false);
+
+ // 展开的指令组
+ const expandedGroups = ref>(new Set());
+
+ /**
+ * 检查是否有涉及系统插件的冲突
+ */
+ const hasSystemPluginConflict = computed(() => {
+ return commands.value.some(cmd => cmd.has_conflict && cmd.reserved);
+ });
+
+ /**
+ * 实际是否显示系统插件(如果有系统插件冲突则强制显示)
+ */
+ const effectiveShowSystemPlugins = computed(() => {
+ return showSystemPlugins.value || hasSystemPluginConflict.value;
+ });
+
+ /**
+ * 获取可用的插件列表(用于过滤下拉框)
+ */
+ const availablePlugins = computed(() => {
+ const plugins = new Set(
+ commands.value
+ .filter(cmd => effectiveShowSystemPlugins.value || !cmd.reserved)
+ .map(cmd => cmd.plugin)
+ );
+ return Array.from(plugins).sort();
+ });
+
+ /**
+ * 检查指令是否匹配过滤条件
+ */
+ const matchesFilters = (cmd: CommandItem, query: string): boolean => {
+ // 系统插件过滤(除非显示系统插件)
+ if (!effectiveShowSystemPlugins.value && cmd.reserved) {
+ return false;
+ }
+
+ // 搜索过滤
+ if (query) {
+ const matchesSearch =
+ cmd.effective_command?.toLowerCase().includes(query) ||
+ cmd.description?.toLowerCase().includes(query) ||
+ cmd.plugin?.toLowerCase().includes(query);
+ if (!matchesSearch) return false;
+ }
+
+ // 插件过滤
+ if (pluginFilter.value !== 'all' && cmd.plugin !== pluginFilter.value) {
+ return false;
+ }
+
+ // 权限过滤
+ if (permissionFilter.value !== 'all') {
+ if (permissionFilter.value === 'everyone') {
+ if (cmd.permission !== 'everyone' && cmd.permission !== 'member') return false;
+ } else if (cmd.permission !== permissionFilter.value) {
+ return false;
+ }
+ }
+
+ // 状态过滤
+ if (statusFilter.value !== 'all') {
+ if (statusFilter.value === 'enabled' && !cmd.enabled) return false;
+ if (statusFilter.value === 'disabled' && cmd.enabled) return false;
+ if (statusFilter.value === 'conflict' && !cmd.has_conflict) return false;
+ }
+
+ // 类型过滤
+ if (typeFilter.value !== 'all') {
+ if (typeFilter.value === 'group' && cmd.type !== 'group') return false;
+ if (typeFilter.value === 'command' && cmd.type !== 'command') return false;
+ if (typeFilter.value === 'sub_command' && cmd.type !== 'sub_command') return false;
+ }
+
+ return true;
+ };
+
+ /**
+ * 过滤后的指令列表(支持层级结构)
+ */
+ const filteredCommands = computed(() => {
+ const query = searchQuery.value.toLowerCase();
+ const conflictCmds: CommandItem[] = [];
+ const normalCmds: CommandItem[] = [];
+
+ for (const cmd of commands.value) {
+ // 对于指令组,检查组本身或子指令是否匹配
+ if (cmd.is_group) {
+ const groupMatches = matchesFilters(cmd, query);
+ const matchingSubCmds = (cmd.sub_commands || []).filter(sub => matchesFilters(sub, query));
+
+ // 如果组匹配或有匹配的子指令,则包含它
+ if (groupMatches || matchingSubCmds.length > 0) {
+ if (cmd.has_conflict) {
+ conflictCmds.push(cmd);
+ } else {
+ normalCmds.push(cmd);
+ }
+
+ // 如果组已展开,添加匹配的子指令
+ if (expandedGroups.value.has(cmd.handler_full_name)) {
+ const subsToShow = query ? matchingSubCmds : (cmd.sub_commands || []);
+ for (const sub of subsToShow) {
+ if (sub.has_conflict) {
+ conflictCmds.push(sub);
+ } else {
+ normalCmds.push(sub);
+ }
+ }
+ }
+ }
+ } else if (cmd.type !== 'sub_command') {
+ // 普通指令(子指令通过组处理)
+ if (matchesFilters(cmd, query)) {
+ if (cmd.has_conflict) {
+ conflictCmds.push(cmd);
+ } else {
+ normalCmds.push(cmd);
+ }
+ }
+ }
+ }
+
+ // 按 effective_command 排序冲突指令,使其分组在一起
+ conflictCmds.sort((a, b) => (a.effective_command || '').localeCompare(b.effective_command || ''));
+
+ return [...conflictCmds, ...normalCmds];
+ });
+
+ /**
+ * 切换指令组的展开/折叠状态
+ */
+ const toggleGroupExpand = (cmd: CommandItem) => {
+ if (!cmd.is_group) return;
+ if (expandedGroups.value.has(cmd.handler_full_name)) {
+ expandedGroups.value.delete(cmd.handler_full_name);
+ } else {
+ expandedGroups.value.add(cmd.handler_full_name);
+ }
+ };
+
+ /**
+ * 检查指令组是否已展开
+ */
+ const isGroupExpanded = (cmd: CommandItem): boolean => {
+ return expandedGroups.value.has(cmd.handler_full_name);
+ };
+
+ return {
+ // 状态
+ searchQuery,
+ pluginFilter,
+ permissionFilter,
+ statusFilter,
+ typeFilter,
+ showSystemPlugins,
+ expandedGroups,
+
+ // 计算属性
+ hasSystemPluginConflict,
+ effectiveShowSystemPlugins,
+ availablePlugins,
+ filteredCommands,
+
+ // 方法
+ matchesFilters,
+ toggleGroupExpand,
+ isGroupExpanded
+ };
+}
+
diff --git a/dashboard/src/components/extension/componentPanel/composables/useComponentData.ts b/dashboard/src/components/extension/componentPanel/composables/useComponentData.ts
new file mode 100644
index 000000000..291ba53c4
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/composables/useComponentData.ts
@@ -0,0 +1,83 @@
+/**
+ * 指令数据管理 Composable
+ */
+import { ref, reactive } from 'vue';
+import axios from 'axios';
+import type { CommandItem, CommandSummary, SnackbarState, ToolItem } from '../types';
+
+export function useComponentData() {
+ const loading = ref(false);
+ const commands = ref([]);
+ const tools = ref([]);
+ const toolsLoading = ref(false);
+ const summary = reactive({
+ disabled: 0,
+ conflicts: 0
+ });
+
+ const snackbar = reactive({
+ show: false,
+ message: '',
+ color: 'success'
+ });
+
+ /**
+ * 显示 Toast 消息
+ */
+ const toast = (message: string, color: string = 'success') => {
+ snackbar.message = message;
+ snackbar.color = color;
+ snackbar.show = true;
+ };
+
+ /**
+ * 获取指令列表
+ */
+ const fetchCommands = async (errorMessage: string) => {
+ loading.value = true;
+ try {
+ const res = await axios.get('/api/commands');
+ if (res.data.status === 'ok') {
+ commands.value = res.data.data.items || [];
+ const s = res.data.data.summary || {};
+ summary.disabled = s.disabled || 0;
+ summary.conflicts = s.conflicts || 0;
+ } else {
+ toast(res.data.message || errorMessage, 'error');
+ }
+ } catch (err: any) {
+ toast(err?.message || errorMessage, 'error');
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ const fetchTools = async (errorMessage: string) => {
+ toolsLoading.value = true;
+ try {
+ const res = await axios.get('/api/tools/list');
+ if (res.data.status === 'ok') {
+ tools.value = res.data.data || [];
+ } else {
+ toast(res.data.message || errorMessage, 'error');
+ }
+ } catch (err: any) {
+ toast(err?.message || errorMessage, 'error');
+ } finally {
+ toolsLoading.value = false;
+ }
+ };
+
+ return {
+ loading,
+ commands,
+ tools,
+ toolsLoading,
+ summary,
+ snackbar,
+ toast,
+ fetchCommands,
+ fetchTools
+ };
+}
+
diff --git a/dashboard/src/components/extension/componentPanel/index.vue b/dashboard/src/components/extension/componentPanel/index.vue
new file mode 100644
index 000000000..912af9156
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/index.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
+
+
+
+ mdi-console-line
+ {{ tm('type.command') }}
+
+
+ mdi-function-variant
+ {{ tmTool('functionTools.title') }}
+
+
+
+
+
+
+
+
+
+
+ mdi-console-line
+ {{ tm('summary.total') }}:
+ {{ filteredCommands.length }}
+
+
+
+ mdi-close-circle-outline
+ {{ tm('summary.disabled') }}:
+ {{ summary.disabled }}
+
+
+
+
+
+
+ mdi-alert-circle
+
+
+ {{ tm('conflictAlert.title') }}
+
+
+ {{ tm('conflictAlert.description', { count: summary.conflicts }) }}
+
+
+ mdi-lightbulb-outline
+ {{ tm('conflictAlert.hint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-function-variant
+ {{ tm('summary.total') }}:
+ {{ filteredTools.length }}
+
+
+
+ mdi-check-circle-outline
+ {{ tm('status.enabled') }}:
+ {{ filteredTools.filter(t => t.active).length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+
diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts
new file mode 100644
index 000000000..d2b388ec9
--- /dev/null
+++ b/dashboard/src/components/extension/componentPanel/types.ts
@@ -0,0 +1,102 @@
+/**
+ * 指令管理模块 - 类型定义
+ */
+
+/** 指令项接口 */
+export interface CommandItem {
+ handler_full_name: string;
+ handler_name: string;
+ plugin: string;
+ plugin_display_name: string | null;
+ module_path: string;
+ description: string;
+ type: CommandType;
+ parent_signature: string;
+ parent_group_handler: string;
+ original_command: string;
+ current_fragment: string;
+ effective_command: string;
+ aliases: string[];
+ permission: PermissionType;
+ enabled: boolean;
+ is_group: boolean;
+ has_conflict: boolean;
+ reserved: boolean;
+ sub_commands: CommandItem[];
+}
+
+/** 指令类型 */
+export type CommandType = 'command' | 'group' | 'sub_command';
+
+/** 权限类型 */
+export type PermissionType = 'admin' | 'everyone' | 'member';
+
+/** 指令摘要统计 */
+export interface CommandSummary {
+ disabled: number;
+ conflicts: number;
+}
+
+/** 过滤器状态 */
+export interface FilterState {
+ searchQuery: string;
+ pluginFilter: string;
+ permissionFilter: string;
+ statusFilter: string;
+ typeFilter: string;
+ showSystemPlugins: boolean;
+}
+
+/** 重命名对话框状态 */
+export interface RenameDialogState {
+ show: boolean;
+ command: CommandItem | null;
+ newName: string;
+ loading: boolean;
+}
+
+/** 详情对话框状态 */
+export interface DetailsDialogState {
+ show: boolean;
+ command: CommandItem | null;
+}
+
+/** Toast 消息状态 */
+export interface SnackbarState {
+ show: boolean;
+ message: string;
+ color: string;
+}
+
+/** 类型信息展示 */
+export interface TypeInfo {
+ text: string;
+ color: string;
+ icon: string;
+}
+
+/** 状态信息展示 */
+export interface StatusInfo {
+ text: string;
+ color: string;
+ variant: 'flat' | 'outlined' | 'text' | 'elevated' | 'tonal' | 'plain';
+}
+
+/** MCP/函数工具参数定义 */
+export interface ToolParameter {
+ type?: string;
+ description?: string;
+}
+
+/** MCP/函数工具对象 */
+export interface ToolItem {
+ name: string;
+ description: string;
+ active: boolean;
+ parameters?: {
+ properties?: Record;
+ };
+ origin?: string;
+ origin_name?: string;
+}
+
diff --git a/dashboard/src/components/shared/SidebarCustomizer.vue b/dashboard/src/components/shared/SidebarCustomizer.vue
index 625320d0d..098f32f46 100644
--- a/dashboard/src/components/shared/SidebarCustomizer.vue
+++ b/dashboard/src/components/shared/SidebarCustomizer.vue
@@ -121,7 +121,8 @@ import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import {
getSidebarCustomization,
setSidebarCustomization,
- clearSidebarCustomization
+ clearSidebarCustomization,
+ resolveSidebarItems
} from '@/utils/sidebarCustomization';
const { t } = useI18n();
@@ -133,35 +134,12 @@ const draggedItem = ref(null);
function initializeItems() {
const customization = getSidebarCustomization();
-
- if (customization) {
- // Load from customization
- const allItemsMap = new Map();
-
- sidebarItems.forEach(item => {
- if (item.children) {
- item.children.forEach(child => {
- allItemsMap.set(child.title, child);
- });
- } else {
- allItemsMap.set(item.title, item);
- }
- });
-
- mainItems.value = customization.mainItems
- .map(title => allItemsMap.get(title))
- .filter(item => item);
-
- moreItems.value = customization.moreItems
- .map(title => allItemsMap.get(title))
- .filter(item => item);
- } else {
- // Load default structure
- mainItems.value = sidebarItems.filter(item => !item.children);
-
- const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
- moreItems.value = moreGroup ? [...moreGroup.children] : [];
- }
+ const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
+ sidebarItems,
+ customization
+ );
+ mainItems.value = resolvedMain;
+ moreItems.value = resolvedMore;
}
function openDialog() {
diff --git a/dashboard/src/i18n/locales/en-US/core/actions.json b/dashboard/src/i18n/locales/en-US/core/actions.json
index a1ba76e08..d53a0cac3 100644
--- a/dashboard/src/i18n/locales/en-US/core/actions.json
+++ b/dashboard/src/i18n/locales/en-US/core/actions.json
@@ -19,5 +19,6 @@
"submit": "Submit",
"reset": "Reset",
"clear": "Clear",
- "save": "Save"
+ "save": "Save",
+ "close": "Close"
}
\ No newline at end of file
diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json
index ba18041e2..e1aaf9fd8 100644
--- a/dashboard/src/i18n/locales/en-US/core/navigation.json
+++ b/dashboard/src/i18n/locales/en-US/core/navigation.json
@@ -2,6 +2,7 @@
"dashboard": "Dashboard",
"platforms": "Platforms",
"providers": "Providers",
+ "commands": "Commands",
"persona": "Persona",
"toolUse": "MCP Tools",
"config": "Config",
diff --git a/dashboard/src/i18n/locales/en-US/features/command.json b/dashboard/src/i18n/locales/en-US/features/command.json
new file mode 100644
index 000000000..ab17d7bb0
--- /dev/null
+++ b/dashboard/src/i18n/locales/en-US/features/command.json
@@ -0,0 +1,91 @@
+{
+ "title": "Command Management",
+ "summary": {
+ "total": "Displayed commands",
+ "disabled": "Disabled",
+ "conflicts": "Conflicts"
+ },
+ "conflictAlert": {
+ "title": "Command Conflicts Detected",
+ "description": "There are {count} conflicting commands. Conflicting commands will trigger multiple plugins simultaneously, which may cause unexpected behavior.",
+ "hint": "Click the \"Rename\" button to rename conflicting commands and resolve conflicts."
+ },
+ "table": {
+ "headers": {
+ "command": "Command",
+ "type": "Type",
+ "plugin": "Plugin",
+ "description": "Description",
+ "permission": "Permission",
+ "status": "Status",
+ "actions": "Actions"
+ }
+ },
+ "type": {
+ "command": "Command",
+ "group": "Group",
+ "subCommand": "Sub-command"
+ },
+ "status": {
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "conflict": "Conflict"
+ },
+ "permission": {
+ "everyone": "Everyone",
+ "admin": "Admin"
+ },
+ "tooltips": {
+ "enable": "Enable command",
+ "disable": "Disable command",
+ "rename": "Rename command",
+ "viewDetails": "View details"
+ },
+ "dialogs": {
+ "rename": {
+ "title": "Rename Command",
+ "newName": "New command name",
+ "cancel": "Cancel",
+ "confirm": "Confirm"
+ },
+ "details": {
+ "title": "Command Details",
+ "type": "Command Type",
+ "handler": "Handler",
+ "module": "Module Path",
+ "originalCommand": "Original Command",
+ "effectiveCommand": "Effective Command",
+ "parentGroup": "Parent Group",
+ "subCommands": "Sub-commands",
+ "aliases": "Aliases",
+ "permission": "Permission",
+ "conflictStatus": "Conflict Status"
+ }
+ },
+ "messages": {
+ "toggleSuccess": "Command status updated",
+ "toggleFailed": "Failed to update command status",
+ "renameSuccess": "Command renamed",
+ "renameFailed": "Rename failed",
+ "loadFailed": "Failed to load commands"
+ },
+ "search": {
+ "placeholder": "Search commands..."
+ },
+ "empty": {
+ "noCommands": "No Commands",
+ "noCommandsDesc": "No commands found"
+ },
+ "filters": {
+ "all": "All",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "conflict": "Conflict",
+ "byPlugin": "Filter by plugin",
+ "byType": "Filter by type",
+ "byPermission": "Filter by permission",
+ "byStatus": "Filter by status",
+ "showSystemPlugins": "Show system plugins commands",
+ "systemPluginConflictHint": "System plugin conflicts detected. Resolve conflicts to hide."
+ }
+}
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index ab8d7b855..c313452bc 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -2,7 +2,9 @@
"title": "Extension Management",
"subtitle": "Manage and configure system extensions",
"tabs": {
- "installed": "Installed",
+ "installedPlugins": "Installed Plugins",
+ "installedMcpServers": "Installed MCP Servers",
+ "handlersOperation": "Manage Handlers",
"market": "Extension Market"
},
"search": {
@@ -197,5 +199,12 @@
"errors": {
"confirmNotRegistered": "$confirm not properly registered"
}
+ },
+ "conflicts": {
+ "title": "Command Conflicts Detected",
+ "message": "This will cause some commands to work abnormally. It is recommended to go to the [Command Management] panel to handle it.",
+ "pairs": "command conflicts",
+ "goToManage": "Go to Manage",
+ "later": "Later"
}
}
diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json
index 8a6ccd492..2c68b8243 100644
--- a/dashboard/src/i18n/locales/en-US/features/tool-use.json
+++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json
@@ -42,7 +42,10 @@
"paramName": "Parameter Name",
"type": "Type",
"description": "Description",
- "required": "Required"
+ "required": "Required",
+ "origin": "Origin",
+ "originName": "Origin Name",
+ "actions": "Actions"
}
},
"marketplace": {
diff --git a/dashboard/src/i18n/locales/zh-CN/core/actions.json b/dashboard/src/i18n/locales/zh-CN/core/actions.json
index 69b29598b..a0a8db1bf 100644
--- a/dashboard/src/i18n/locales/zh-CN/core/actions.json
+++ b/dashboard/src/i18n/locales/zh-CN/core/actions.json
@@ -19,5 +19,6 @@
"submit": "提交",
"reset": "重置",
"clear": "清空",
- "save": "保存"
+ "save": "保存",
+ "close": "关闭"
}
\ No newline at end of file
diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json
index b361b7b08..b7d2d174c 100644
--- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json
+++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json
@@ -2,6 +2,7 @@
"dashboard": "数据统计",
"platforms": "机器人",
"providers": "模型提供商",
+ "commands": "指令管理",
"persona": "人格设定",
"toolUse": "MCP",
"extension": "插件",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/command.json b/dashboard/src/i18n/locales/zh-CN/features/command.json
new file mode 100644
index 000000000..514a9837a
--- /dev/null
+++ b/dashboard/src/i18n/locales/zh-CN/features/command.json
@@ -0,0 +1,91 @@
+{
+ "title": "指令管理",
+ "summary": {
+ "total": "展示的指令数",
+ "disabled": "已禁用",
+ "conflicts": "有冲突"
+ },
+ "conflictAlert": {
+ "title": "检测到指令冲突",
+ "description": "当前有 {count} 对指令存在冲突,冲突的指令会同时触发多个插件响应,可能导致意外行为。",
+ "hint": "请点击「重命名」按钮修改冲突指令的名称以解决冲突。"
+ },
+ "table": {
+ "headers": {
+ "command": "指令",
+ "type": "类型",
+ "plugin": "所属插件",
+ "description": "描述",
+ "permission": "权限",
+ "status": "状态",
+ "actions": "操作"
+ }
+ },
+ "type": {
+ "command": "指令",
+ "group": "指令组",
+ "subCommand": "子指令"
+ },
+ "status": {
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "conflict": "有冲突"
+ },
+ "permission": {
+ "everyone": "所有人",
+ "admin": "管理员"
+ },
+ "tooltips": {
+ "enable": "启用指令",
+ "disable": "禁用指令",
+ "rename": "重命名指令",
+ "viewDetails": "查看详情"
+ },
+ "dialogs": {
+ "rename": {
+ "title": "重命名指令",
+ "newName": "新指令名",
+ "cancel": "取消",
+ "confirm": "确认"
+ },
+ "details": {
+ "title": "指令详情",
+ "type": "指令类型",
+ "handler": "处理函数",
+ "module": "模块路径",
+ "originalCommand": "原始指令",
+ "effectiveCommand": "生效指令",
+ "parentGroup": "所属指令组",
+ "subCommands": "子指令列表",
+ "aliases": "别名",
+ "permission": "权限要求",
+ "conflictStatus": "冲突状态"
+ }
+ },
+ "messages": {
+ "toggleSuccess": "指令状态已更新",
+ "toggleFailed": "更新指令状态失败",
+ "renameSuccess": "指令已重命名",
+ "renameFailed": "重命名失败",
+ "loadFailed": "加载指令列表失败"
+ },
+ "search": {
+ "placeholder": "搜索指令..."
+ },
+ "empty": {
+ "noCommands": "暂无指令",
+ "noCommandsDesc": "当前筛选条件下没有找到任何指令"
+ },
+ "filters": {
+ "all": "全部",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "conflict": "有冲突",
+ "byPlugin": "按插件筛选",
+ "byType": "按类型筛选",
+ "byPermission": "按权限筛选",
+ "byStatus": "按状态筛选",
+ "showSystemPlugins": "显示系统插件指令",
+ "systemPluginConflictHint": "存在涉及系统插件的冲突,需解决冲突后才能隐藏"
+ }
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index e31057fd1..37ca08d56 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -2,7 +2,9 @@
"title": "插件管理",
"subtitle": "管理和配置系统插件",
"tabs": {
- "installed": "已安装",
+ "installedPlugins": "已安装的插件",
+ "installedMcpServers": "已安装的 MCP 服务器",
+ "handlersOperation": "管理行为",
"market": "插件市场"
},
"search": {
@@ -197,5 +199,12 @@
"errors": {
"confirmNotRegistered": "$confirm 未正确注册"
}
+ },
+ "conflicts": {
+ "title": "检测到指令冲突",
+ "message": "这会导致部分指令工作异常,建议前往【指令管理】面板进行处理。",
+ "pairs": "对指令冲突",
+ "goToManage": "前往处理",
+ "later": "稍后处理"
}
}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
index c9b902c02..f6e6c4407 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
@@ -42,7 +42,10 @@
"paramName": "参数名",
"type": "类型",
"description": "描述",
- "required": "必填"
+ "required": "必填",
+ "origin": "来源",
+ "originName": "来源名称",
+ "actions": "操作"
}
},
"marketplace": {
diff --git a/dashboard/src/i18n/translations.ts b/dashboard/src/i18n/translations.ts
index d4444225b..8cff882be 100644
--- a/dashboard/src/i18n/translations.ts
+++ b/dashboard/src/i18n/translations.ts
@@ -32,6 +32,7 @@ import zhCNKnowledgeBaseDetail from './locales/zh-CN/features/knowledge-base/det
import zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/document.json';
import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
+import zhCNCommand from './locales/zh-CN/features/command.json';
import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
@@ -68,6 +69,7 @@ import enUSKnowledgeBaseDetail from './locales/en-US/features/knowledge-base/det
import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/document.json';
import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
+import enUSCommand from './locales/en-US/features/command.json';
import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
@@ -111,7 +113,8 @@ export const translations = {
document: zhCNKnowledgeBaseDocument
},
persona: zhCNPersona,
- migration: zhCNMigration
+ migration: zhCNMigration,
+ command: zhCNCommand
},
messages: {
errors: zhCNErrors,
@@ -155,7 +158,8 @@ export const translations = {
document: enUSKnowledgeBaseDocument
},
persona: enUSPersona,
- migration: enUSMigration
+ migration: enUSMigration,
+ command: enUSCommand
},
messages: {
errors: enUSErrors,
diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
index 42f763402..e5628ce51 100644
--- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
+++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
@@ -33,11 +33,6 @@ const sidebarItem: menu[] = [
icon: 'mdi-cog',
to: '/config',
},
- {
- title: 'core.navigation.toolUse',
- icon: 'mdi-function-variant',
- to: '/tool-use'
- },
{
title: 'core.navigation.extension',
icon: 'mdi-puzzle',
diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts
index 276d37444..0a8617426 100644
--- a/dashboard/src/router/MainRoutes.ts
+++ b/dashboard/src/router/MainRoutes.ts
@@ -31,11 +31,6 @@ const MainRoutes = {
path: '/providers',
component: () => import('@/views/ProviderPage.vue')
},
- {
- name: 'ToolUsePage',
- path: '/tool-use',
- component: () => import('@/views/ToolUsePage.vue')
- },
{
name: 'Configs',
path: '/config',
diff --git a/dashboard/src/utils/sidebarCustomization.js b/dashboard/src/utils/sidebarCustomization.js
index 75cc5896f..10004322f 100644
--- a/dashboard/src/utils/sidebarCustomization.js
+++ b/dashboard/src/utils/sidebarCustomization.js
@@ -41,59 +41,97 @@ export function clearSidebarCustomization() {
}
/**
- * Apply customization to sidebar items
- * @param {Array} defaultItems - Default sidebar items array
- * @returns {Array} Customized sidebar items array (new array, doesn't mutate input)
+ * 解析侧边栏默认项与用户定制,返回主区/更多区及可选的合并结果
+ * @param {Array} defaultItems - 默认侧边栏结构
+ * @param {Object|null} customization - 用户定制(mainItems/moreItems)
+ * @param {Object} options
+ * @param {boolean} [options.cloneItems=false] - 是否克隆条目以避免外部引用被修改
+ * @param {boolean} [options.assembleMoreGroup=false] - 是否组装带更多分组的整体数组
+ * @returns {{ mainItems: Array, moreItems: Array, merged?: Array }}
+ */
+export function resolveSidebarItems(defaultItems, customization, options = {}) {
+ const { cloneItems = false, assembleMoreGroup = false } = options;
+
+ const all = new Map();
+ const defaultMain = [];
+ const defaultMore = [];
+
+ // 收集所有条目,按 title 建索引
+ defaultItems.forEach(item => {
+ if (item.children) {
+ item.children.forEach(child => {
+ all.set(child.title, cloneItems ? { ...child } : child);
+ defaultMore.push(child.title);
+ });
+ } else {
+ all.set(item.title, cloneItems ? { ...item } : item);
+ defaultMain.push(item.title);
+ }
+ });
+
+ const hasCustomization = Boolean(customization);
+ const mainKeys = hasCustomization ? customization.mainItems || [] : defaultMain;
+ const moreKeys = hasCustomization ? customization.moreItems || [] : defaultMore;
+ const used = hasCustomization ? new Set([...mainKeys, ...moreKeys]) : new Set(defaultMain.concat(defaultMore));
+
+ const mainItems = mainKeys
+ .map(title => all.get(title))
+ .filter(Boolean);
+
+ if (hasCustomization) {
+ // 补充新增默认主区项
+ defaultMain.forEach(title => {
+ if (!used.has(title)) {
+ const item = all.get(title);
+ if (item) mainItems.push(item);
+ }
+ });
+ }
+
+ const moreItems = moreKeys
+ .map(title => all.get(title))
+ .filter(Boolean);
+
+ if (hasCustomization) {
+ // 补充新增默认更多区项
+ defaultMore.forEach(title => {
+ if (!used.has(title)) {
+ const item = all.get(title);
+ if (item) moreItems.push(item);
+ }
+ });
+ }
+
+ let merged;
+ if (assembleMoreGroup) {
+ const children = cloneItems ? moreItems.map(item => ({ ...item })) : [...moreItems];
+ if (children.length > 0) {
+ merged = [
+ ...mainItems,
+ {
+ title: 'core.navigation.groups.more',
+ icon: 'mdi-dots-horizontal',
+ children
+ }
+ ];
+ } else {
+ merged = [...mainItems];
+ }
+ }
+
+ return { mainItems, moreItems, merged };
+}
+
+/**
+ * 应用侧边栏定制,返回包含更多分组的完整结构
+ * @param {Array} defaultItems - 默认侧边栏结构
+ * @returns {Array} 自定义后的结构(新数组,不修改入参)
*/
export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization();
- if (!customization) {
- return defaultItems;
- }
-
- const { mainItems, moreItems } = customization;
-
- // Create a map of all items by title for quick lookup
- // Deep clone items to avoid mutating originals
- const allItemsMap = new Map();
- defaultItems.forEach(item => {
- if (item.children) {
- // If it's the "More" group, add children to map
- item.children.forEach(child => {
- allItemsMap.set(child.title, { ...child });
- });
- } else {
- allItemsMap.set(item.title, { ...item });
- }
+ const { merged } = resolveSidebarItems(defaultItems, customization, {
+ cloneItems: true,
+ assembleMoreGroup: true
});
-
- const customizedItems = [];
-
- // Add main items in custom order
- mainItems.forEach(title => {
- const item = allItemsMap.get(title);
- if (item) {
- customizedItems.push(item);
- }
- });
-
- // If there are items in moreItems, create the "More Features" group
- if (moreItems && moreItems.length > 0) {
- const moreGroup = {
- title: 'core.navigation.groups.more',
- icon: 'mdi-dots-horizontal',
- children: []
- };
-
- moreItems.forEach(title => {
- const item = allItemsMap.get(title);
- if (item) {
- moreGroup.children.push(item);
- }
- });
-
- customizedItems.push(moreGroup);
- }
-
- return customizedItems;
+ return merged || defaultItems;
}
diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue
index 802664443..5a5037efb 100644
--- a/dashboard/src/views/ExtensionPage.vue
+++ b/dashboard/src/views/ExtensionPage.vue
@@ -5,18 +5,45 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.vue';
+import McpServersSection from '@/components/extension/McpServersSection.vue';
+import ComponentPanel from '@/components/extension/componentPanel/index.vue';
import axios from 'axios';
import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import defaultPluginIcon from '@/assets/images/plugin_icon.png';
-import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
-
+import { ref, computed, onMounted, reactive, watch } from 'vue';
+import { useRouter } from 'vue-router';
const commonStore = useCommonStore();
const { t } = useI18n();
const { tm } = useModuleI18n('features/extension');
+const router = useRouter();
+
+// 检查指令冲突并提示
+const conflictDialog = reactive({
+ show: false,
+ count: 0
+});
+const checkAndPromptConflicts = async () => {
+ try {
+ const res = await axios.get('/api/commands');
+ if (res.data.status === 'ok') {
+ const conflicts = res.data.data.summary?.conflicts || 0;
+ if (conflicts > 0) {
+ conflictDialog.count = conflicts;
+ conflictDialog.show = true;
+ }
+ }
+ } catch (err) {
+ console.debug('Failed to check command conflicts:', err);
+ }
+};
+const handleConflictConfirm = () => {
+ activeTab.value = 'commands';
+};
+
const fileInput = ref(null);
const activeTab = ref('installed');
const extension_data = reactive({
@@ -448,7 +475,9 @@ const pluginOn = async (extension) => {
return;
}
toast(res.data.message, "success");
- getExtensions();
+ await getExtensions();
+
+ await checkAndPromptConflicts();
} catch (err) {
toast(err, "error");
}
@@ -782,6 +811,8 @@ const newExtension = async () => {
name: res.data.data.name,
repo: res.data.data.repo || null
});
+
+ await checkAndPromptConflicts();
}).catch((err) => {
loading_.value = false;
onLoadingDialogResult(2, err, -1);
@@ -808,6 +839,8 @@ const newExtension = async () => {
name: res.data.data.name,
repo: res.data.data.repo || null
});
+
+ await checkAndPromptConflicts();
}).catch((err) => {
loading_.value = false;
toast(tm('messages.installFailed') + " " + err, "error");
@@ -900,21 +933,29 @@ watch(marketSearch, (newVal) => {
mdi-puzzle
- {{ tm('tabs.installed') }}
+ {{ tm('tabs.installedPlugins') }}
+
+
+ mdi-server-network
+ {{ tm('tabs.installedMcpServers') }}
mdi-store
{{ tm('tabs.market') }}
+
+ mdi-wrench
+ {{ tm('tabs.handlersOperation') }}
+
-
-
@@ -1118,6 +1159,24 @@ watch(marketSearch, (newVal) => {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1544,6 +1603,34 @@ watch(marketSearch, (newVal) => {
+
+
+
+
+ mdi-alert-circle
+ {{ tm('conflicts.title') }}
+
+
+
+
+ {{ conflictDialog.count }}
+
+ {{ tm('conflicts.pairs') }}
+
+
+ {{ tm('conflicts.message') }}
+
+
+
+
+ {{ tm('conflicts.later') }}
+
+ {{ tm('conflicts.goToManage') }}
+
+
+
+
+
diff --git a/packages/builtin_commands/commands/admin.py b/packages/builtin_commands/commands/admin.py
index 2073f45a2..83d4b5974 100644
--- a/packages/builtin_commands/commands/admin.py
+++ b/packages/builtin_commands/commands/admin.py
@@ -71,6 +71,7 @@ class AdminCommands:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
async def update_dashboard(self, event: AstrMessageEvent):
+ """更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板..."))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成。"))
diff --git a/packages/builtin_commands/commands/help.py b/packages/builtin_commands/commands/help.py
index 7f5b6c170..092fc59ec 100644
--- a/packages/builtin_commands/commands/help.py
+++ b/packages/builtin_commands/commands/help.py
@@ -3,6 +3,7 @@ import aiohttp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.config.default import VERSION
+from astrbot.core.star import command_management
from astrbot.core.utils.io import get_dashboard_version
@@ -21,6 +22,46 @@ class HelpCommand:
except BaseException:
return ""
+ async def _build_reserved_command_lines(self) -> list[str]:
+ """
+ 使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
+ """
+ try:
+ commands = await command_management.list_commands()
+ except BaseException:
+ return []
+
+ lines: list[str] = []
+ hidden_commands = {"set", "unset", "websearch"}
+
+ def walk(items: list[dict], indent: int = 0):
+ for item in items:
+ if not item.get("reserved") or not item.get("enabled"):
+ continue
+ # 仅展示顶级指令或指令组
+ if item.get("type") == "sub_command":
+ continue
+ if item.get("parent_signature"):
+ continue
+
+ effective = (
+ item.get("effective_command")
+ or item.get("original_command")
+ or item.get("handler_name")
+ )
+ if not effective:
+ continue
+ if effective in hidden_commands:
+ continue
+
+ description = item.get("description") or ""
+ desc_text = f" - {description}" if description else ""
+ indent_prefix = " " * indent
+ lines.append(f"{indent_prefix}/{effective}{desc_text}")
+
+ walk(commands)
+ return lines
+
async def help(self, event: AstrMessageEvent):
"""查看帮助"""
notice = ""
@@ -30,34 +71,18 @@ class HelpCommand:
pass
dashboard_version = await get_dashboard_version()
+ command_lines = await self._build_reserved_command_lines()
+ commands_section = (
+ "\n".join(command_lines) if command_lines else "暂无启用的内置指令"
+ )
- msg = f"""AstrBot v{VERSION}(WebUI: {dashboard_version})
-内置指令:
-[System]
-/plugin: 查看插件、插件帮助
-/t2i: 开关文本转图片
-/tts: 开关文本转语音
-/sid: 获取会话 ID
-/op: 管理员
-/wl: 白名单
-/dashboard_update: 更新管理面板(op)
-/alter_cmd: 设置指令权限(op)
-
-[大模型]
-/llm: 开启/关闭 LLM
-/provider: 大模型提供商
-/model: 模型列表
-/ls: 对话列表
-/new: 创建新对话
-/groupnew 群号: 为群聊创建新对话(op)
-/switch 序号: 切换对话
-/rename 新名字: 重命名当前对话
-/del: 删除当前会话对话(op)
-/reset: 重置 LLM 会话
-/history: 当前对话的对话记录
-/persona: 人格情景(op)
-/key: API Key(op)
-/websearch: 网页搜索
-{notice}"""
+ msg_parts = [
+ f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
+ "内置指令:",
+ commands_section,
+ ]
+ if notice:
+ msg_parts.append(notice)
+ msg = "\n".join(msg_parts)
event.set_result(MessageEventResult().message(msg).use_t2i(False))
diff --git a/packages/builtin_commands/main.py b/packages/builtin_commands/main.py
index 291bed456..7809c4359 100644
--- a/packages/builtin_commands/main.py
+++ b/packages/builtin_commands/main.py
@@ -49,7 +49,7 @@ class Main(star.Star):
@filter.command_group("tool")
def tool(self):
- pass
+ """函数工具管理"""
@tool.command("ls")
async def tool_ls(self, event: AstrMessageEvent):
@@ -73,7 +73,7 @@ class Main(star.Star):
@filter.command_group("plugin")
def plugin(self):
- pass
+ """插件管理"""
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent):
@@ -219,6 +219,7 @@ class Main(star.Star):
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent):
+ """更新管理面板"""
await self.admin_c.update_dashboard(event)
@filter.command("set")
diff --git a/packages/python_interpreter/main.py b/packages/python_interpreter/main.py
index 98496157a..afbef7560 100644
--- a/packages/python_interpreter/main.py
+++ b/packages/python_interpreter/main.py
@@ -249,7 +249,7 @@ class Main(star.Star):
@filter.command_group("pi")
def pi(self):
- pass
+ """代码执行器配置"""
@pi.command("absdir")
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
diff --git a/packages/reminder/main.py b/packages/reminder/main.py
index 8f61e02fe..62af7ae56 100644
--- a/packages/reminder/main.py
+++ b/packages/reminder/main.py
@@ -179,7 +179,7 @@ class Main(star.Star):
@filter.command_group("reminder")
def reminder(self):
- """The command group of the reminder."""
+ """待办提醒"""
async def get_upcoming_reminders(self, unified_msg_origin: str):
"""Get upcoming reminders."""
diff --git a/packages/web_searcher/main.py b/packages/web_searcher/main.py
index 118ef2483..4745cd0c0 100644
--- a/packages/web_searcher/main.py
+++ b/packages/web_searcher/main.py
@@ -185,6 +185,7 @@ class Main(star.Star):
@filter.command("websearch")
async def websearch(self, event: AstrMessageEvent, oper: str | None = None):
+ """网页搜索指令(已废弃)"""
event.set_result(
MessageEventResult().message(
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index f5439e9d5..969f0da6d 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -160,6 +160,34 @@ async def test_plugins(app: Quart, authenticated_header: dict):
assert exists is False, "插件 astrbot_plugin_essential 未成功卸载"
+@pytest.mark.asyncio
+async def test_commands_api(app: Quart, authenticated_header: dict):
+ """Tests the command management API endpoints."""
+ test_client = app.test_client()
+
+ # GET /api/commands - list commands
+ response = await test_client.get("/api/commands", headers=authenticated_header)
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data["status"] == "ok"
+ assert "items" in data["data"]
+ assert "summary" in data["data"]
+ summary = data["data"]["summary"]
+ assert "total" in summary
+ assert "disabled" in summary
+ assert "conflicts" in summary
+
+ # GET /api/commands/conflicts - list conflicts
+ response = await test_client.get(
+ "/api/commands/conflicts", headers=authenticated_header
+ )
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data["status"] == "ok"
+ # conflicts is a list
+ assert isinstance(data["data"], list)
+
+
@pytest.mark.asyncio
async def test_check_update(app: Quart, authenticated_header: dict):
test_client = app.test_client()