From 80b89fd2eadf73d54e973952229751f5ff8af723 Mon Sep 17 00:00:00 2001 From: Oscar Shaw Date: Tue, 16 Dec 2025 20:24:57 +0800 Subject: [PATCH] feat: implements command management and improve webui feature structure (#3904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit move mcp management to plugin managemanet page * feat: 新增命令配置数据库模型 * feat: 实现核心命令管理系统 * feat: 将命令管理集成到 Star 框架 * feat: 新增命令管理后台 API * feat: 新增命令管理界面页面 * feat: 新增命令管理国际化支持 * test: 新增命令管理相关测试 * refactor(command): 移除指令重命名时的别名功能 * fix(command): 修正指令冲突检测逻辑 * fix(command): 排除已禁用指令的冲突检测 - 只有 `effective_command` 存在且 `enabled` 为 `True` 的指令才会被纳入冲突检测范围。 * feat(command): 优化指令冲突显示与提示 - 【功能】新增指令冲突警告提示,当检测到冲突时显示详细信息及解决方案。 - 【优化】调整指令列表排序逻辑,将冲突指令优先显示并分组。 - 【样式】为冲突指令行添加专属高亮样式,提升视觉识别度。 - 【国际化】更新英文和中文多语言文件,增加指令冲突警告相关的翻译文本。 * chore(command-page): 禁用命令表格部分列的排序功能 * style(command-page): 调整命令页面表格样式和图标大小 * refactor(command): 优化指令页面布局并更新冲突警告 - 【布局优化】重新组织指令管理页面布局,将筛选器移至顶部独立行 - 【信息展示】将搜索栏与总指令数、已禁用指令数合并显示,提升页面空间利用率 - 【视觉更新】更新指令冲突警告样式 * style: UI 细节 * refactor(command): 调整指令管理中的成员权限显示与筛选 - 更新指令筛选逻辑,当选择“所有人”权限筛选时,将同时包含 `everyone` 和 `member` 权限的指令。 * feat(command-management): 新增指令层级管理与UI展示 - 【后端】 - `CommandDescriptor` 新增 `parent_group_handler` 和 `sub_commands` 字段,支持指令层级结构定义。 - `list_commands` 函数重构,实现指令的层级收集与构建,将子指令正确挂载到其父指令组下。 - 新增 `_collect_all_descriptors` 和 `_find_parent_group_handler` 辅助函数,用于全面收集指令并定位父指令组。 - `_build_descriptor` 优化指令类型判断逻辑,明确区分普通指令、指令组和子指令。 - `_descriptor_to_dict` 递归处理子指令,确保 API 返回完整的指令层级数据。 - 【前端】 - 指令管理页面 (`CommandPage.vue`) 增加指令类型筛选器,并支持指令组的展开/折叠功能。 - 表格展示优化,为指令组和子指令添加不同的样式和缩进,提升层级结构的视觉可读性。 - 指令详情对话框新增指令类型、所属指令组和子指令列表的展示。 - 更新 `CommandItem` 接口,以适配后端提供的层级数据结构。 - 【i18n】 - 新增指令类型(指令、指令组、子指令)的国际化文本。 - 更新指令管理相关 UI 文本,包括表格头部、详情对话框字段和筛选器选项。 * style(command): 优化指令组子指令数量显示UI * refactor(command): 修改指令列表排序逻辑 * style(command-page): 优化命令列表UI * feat(command): 添加系统插件指令过滤与冲突处理 * refactor(command): 更新指令数展示逻辑 * style(command): 更新空状态描述 * feat(extension): 添加插件指令冲突检测与提示 - 在插件安装或启用后,自动检测并提示指令冲突。 - 当检测到指令冲突时,显示警告对话框,告知用户冲突数量及可能的影响。 * refactor(command): 移除指令表格内部加载指示器 * style(extension): 文案修改 * refactor(command): 模块化指令管理面板前端代码 * refactor(commandPanel): 重命名指令模块目录为 commandPanel * style(commandPanel): 微调指令面板UI * fix(command): 确保新命令配置的事务提交 * fix(sidebar): 补全新增侧边栏项后的侧边栏位追加逻辑 * refactor(commands): 重构/help指令以动态显示实际命令并补充部分命令描述 * style(builtin_commands): 补充命令描述 * refactor(commandPanel): 移除未使用的 filterState 常量 * perf(dashboard): 删除多余的CommandPage.vue文件(已被模块化引用) * perf(command): 优化命令冲突计数逻辑 * perf(command): 优化指令管理辅助函数和配置绑定逻辑 * perf(db): 优化重构command相关数据库操作 * refactor(sidebar): 提取侧边栏项目解析逻辑到工具函数复用 * refactor: move mcp and command page to extension page * refactor: remove unused imports in component panel * fix: update terminology for handler management in extension localization --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/db/__init__.py | 72 +++ astrbot/core/db/po.py | 59 +++ astrbot/core/db/sqlite.py | 240 ++++++++++ astrbot/core/star/command_management.py | 449 ++++++++++++++++++ astrbot/core/star/filter/command.py | 1 + astrbot/core/star/filter/command_group.py | 1 + astrbot/core/star/star_handler.py | 4 + astrbot/core/star/star_manager.py | 2 + astrbot/dashboard/routes/__init__.py | 2 + astrbot/dashboard/routes/command.py | 82 ++++ astrbot/dashboard/routes/tools.py | 24 +- astrbot/dashboard/server.py | 1 + .../extension/McpServersSection.vue} | 366 ++------------ .../components/CommandFilters.vue | 155 ++++++ .../components/CommandTable.vue | 257 ++++++++++ .../components/DetailsDialog.vue | 143 ++++++ .../components/RenameDialog.vue | 53 +++ .../componentPanel/components/ToolTable.vue | 144 ++++++ .../composables/useCommandActions.ts | 177 +++++++ .../composables/useCommandFilters.ts | 187 ++++++++ .../composables/useComponentData.ts | 83 ++++ .../extension/componentPanel/index.vue | 307 ++++++++++++ .../extension/componentPanel/types.ts | 102 ++++ .../components/shared/SidebarCustomizer.vue | 38 +- .../src/i18n/locales/en-US/core/actions.json | 3 +- .../i18n/locales/en-US/core/navigation.json | 1 + .../i18n/locales/en-US/features/command.json | 91 ++++ .../locales/en-US/features/extension.json | 11 +- .../i18n/locales/en-US/features/tool-use.json | 5 +- .../src/i18n/locales/zh-CN/core/actions.json | 3 +- .../i18n/locales/zh-CN/core/navigation.json | 1 + .../i18n/locales/zh-CN/features/command.json | 91 ++++ .../locales/zh-CN/features/extension.json | 11 +- .../i18n/locales/zh-CN/features/tool-use.json | 5 +- dashboard/src/i18n/translations.ts | 8 +- .../full/vertical-sidebar/sidebarItem.ts | 5 - dashboard/src/router/MainRoutes.ts | 5 - dashboard/src/utils/sidebarCustomization.js | 140 ++++-- dashboard/src/views/ExtensionPage.vue | 99 +++- packages/builtin_commands/commands/admin.py | 1 + packages/builtin_commands/commands/help.py | 81 ++-- packages/builtin_commands/main.py | 5 +- packages/python_interpreter/main.py | 2 +- packages/reminder/main.py | 2 +- packages/web_searcher/main.py | 1 + tests/test_dashboard.py | 28 ++ 46 files changed, 3079 insertions(+), 469 deletions(-) create mode 100644 astrbot/core/star/command_management.py create mode 100644 astrbot/dashboard/routes/command.py rename dashboard/src/{views/ToolUsePage.vue => components/extension/McpServersSection.vue} (62%) create mode 100644 dashboard/src/components/extension/componentPanel/components/CommandFilters.vue create mode 100644 dashboard/src/components/extension/componentPanel/components/CommandTable.vue create mode 100644 dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue create mode 100644 dashboard/src/components/extension/componentPanel/components/RenameDialog.vue create mode 100644 dashboard/src/components/extension/componentPanel/components/ToolTable.vue create mode 100644 dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts create mode 100644 dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts create mode 100644 dashboard/src/components/extension/componentPanel/composables/useComponentData.ts create mode 100644 dashboard/src/components/extension/componentPanel/index.vue create mode 100644 dashboard/src/components/extension/componentPanel/types.ts create mode 100644 dashboard/src/i18n/locales/en-US/features/command.json create mode 100644 dashboard/src/i18n/locales/zh-CN/features/command.json 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') }} - - - {{ 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 @@ - -
@@ -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-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 @@ + + + + + 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 @@ + + + + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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()