Compare commits

...

43 Commits

Author SHA1 Message Date
Soulter 8b9abc093a refactor: remove unused imports in component panel 2025-12-15 00:50:06 +08:00
Soulter eeec6bcc48 refactor: move mcp and command page to extension page 2025-12-15 00:44:37 +08:00
Soulter bd1c1c7e4f Merge remote-tracking branch 'origin/master' into feature/command-panel 2025-12-14 22:06:13 +08:00
Oscar 2387abe570 refactor(sidebar): 提取侧边栏项目解析逻辑到工具函数复用 2025-12-11 17:14:02 +08:00
Oscar 7e43cca134 perf(db): 优化重构command相关数据库操作 2025-12-11 17:00:15 +08:00
Oscar 89fdb18936 perf(command): 优化指令管理辅助函数和配置绑定逻辑 2025-12-11 16:51:10 +08:00
Oscar e710454d18 perf(command): 优化命令冲突计数逻辑 2025-12-11 15:45:14 +08:00
Oscar 20024cfec9 perf(dashboard): 删除多余的CommandPage.vue文件(已被模块化引用) 2025-12-11 15:36:22 +08:00
Oscar 042c507127 refactor(commandPanel): 移除未使用的 filterState 常量 2025-12-11 15:28:55 +08:00
Oscar 5e83a19ac5 style(builtin_commands): 补充命令描述 2025-12-11 14:48:28 +08:00
Oscar 1f87984133 Merge branch 'master' of https://github.com/ocetars/AstrBot-OscarDev into feature/command-panel 2025-12-11 14:45:01 +08:00
Oscar c6739105c4 refactor(commands): 重构/help指令以动态显示实际命令并补充部分命令描述 2025-12-11 14:34:46 +08:00
Oscar 6fd86eda13 fix(sidebar): 补全新增侧边栏项后的侧边栏位追加逻辑 2025-12-08 17:28:58 +08:00
Ocetars 693f2988be fix(command): 确保新命令配置的事务提交 2025-12-04 16:38:27 +08:00
Ocetars adcffcc466 style(commandPanel): 微调指令面板UI 2025-12-04 16:17:12 +08:00
Ocetars 238aa30331 refactor(commandPanel): 重命名指令模块目录为 commandPanel 2025-12-04 16:05:49 +08:00
Ocetars 26a27776ab refactor(command): 模块化指令管理面板前端代码 2025-12-04 15:58:11 +08:00
Ocetars eb2c88f802 style(extension): 文案修改 2025-12-04 15:23:53 +08:00
Ocetars 81a0e0f28e refactor(command): 移除指令表格内部加载指示器 2025-12-04 15:12:10 +08:00
Ocetars aa61815fcd feat(extension): 添加插件指令冲突检测与提示
- 在插件安装或启用后,自动检测并提示指令冲突。
- 当检测到指令冲突时,显示警告对话框,告知用户冲突数量及可能的影响。
2025-12-03 20:58:53 +08:00
Ocetars f34902574f style(command): 更新空状态描述 2025-12-03 19:55:42 +08:00
Ocetars b1b031077c refactor(command): 更新指令数展示逻辑 2025-12-03 19:48:22 +08:00
Ocetars 7f0e011126 feat(command): 添加系统插件指令过滤与冲突处理 2025-12-03 19:41:27 +08:00
Ocetars 7b7d9f1b8c style(command-page): 优化命令列表UI 2025-12-03 19:19:56 +08:00
Ocetars fe040da7a4 refactor(command): 修改指令列表排序逻辑 2025-12-03 19:10:20 +08:00
Ocetars b98cd1bd72 style(command): 优化指令组子指令数量显示UI 2025-12-03 18:09:12 +08:00
Ocetars 7fa71c538e 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 文本,包括表格头部、详情对话框字段和筛选器选项。
2025-12-03 17:58:52 +08:00
Ocetars 97c0be85e4 refactor(command): 调整指令管理中的成员权限显示与筛选
- 更新指令筛选逻辑,当选择“所有人”权限筛选时,将同时包含 `everyone` 和 `member` 权限的指令。
2025-12-03 17:12:09 +08:00
Ocetars b1273ff997 style: UI 细节 2025-12-03 15:55:11 +08:00
Ocetars e560f396c5 refactor(command): 优化指令页面布局并更新冲突警告
- 【布局优化】重新组织指令管理页面布局,将筛选器移至顶部独立行
- 【信息展示】将搜索栏与总指令数、已禁用指令数合并显示,提升页面空间利用率
- 【视觉更新】更新指令冲突警告样式
2025-12-03 15:45:12 +08:00
Ocetars 9c842ecd03 style(command-page): 调整命令页面表格样式和图标大小 2025-12-03 15:24:53 +08:00
Ocetars 281ac6dcfe chore(command-page): 禁用命令表格部分列的排序功能 2025-12-03 15:11:36 +08:00
Ocetars 7aa44ba3d8 feat(command): 优化指令冲突显示与提示
- 【功能】新增指令冲突警告提示,当检测到冲突时显示详细信息及解决方案。
- 【优化】调整指令列表排序逻辑,将冲突指令优先显示并分组。
- 【样式】为冲突指令行添加专属高亮样式,提升视觉识别度。
- 【国际化】更新英文和中文多语言文件,增加指令冲突警告相关的翻译文本。
2025-12-03 15:04:30 +08:00
Ocetars 8144b61ae0 fix(command): 排除已禁用指令的冲突检测
- 只有 `effective_command` 存在且 `enabled` 为 `True` 的指令才会被纳入冲突检测范围。
2025-12-03 14:43:18 +08:00
Ocetars 3da0c77e87 fix(command): 修正指令冲突检测逻辑 2025-12-03 14:34:25 +08:00
Ocetars 5e7a0591d9 refactor(command): 移除指令重命名时的别名功能 2025-12-03 14:25:49 +08:00
Ocetars 09d6b715f0 test: 新增命令管理相关测试 2025-12-02 20:57:03 +08:00
Ocetars f0770c5c4d feat: 新增命令管理国际化支持 2025-12-02 20:56:33 +08:00
Ocetars 0858ec4cba feat: 新增命令管理界面页面 2025-12-02 20:56:21 +08:00
Ocetars ae07835da7 feat: 新增命令管理后台 API 2025-12-02 20:56:05 +08:00
Ocetars 6ba1c51cd2 feat: 将命令管理集成到 Star 框架 2025-12-02 20:55:24 +08:00
Ocetars 2dc28eff89 feat: 实现核心命令管理系统 2025-12-02 20:55:14 +08:00
Ocetars 68c1e4ecf9 feat: 新增命令配置数据库模型 2025-12-02 20:53:53 +08:00
46 changed files with 3079 additions and 469 deletions
+72
View File
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from astrbot.core.db.po import ( from astrbot.core.db.po import (
Attachment, Attachment,
CommandConfig,
CommandConflict,
ConversationV2, ConversationV2,
Persona, Persona,
PlatformMessageHistory, PlatformMessageHistory,
@@ -314,6 +316,76 @@ class BaseDatabase(abc.ABC):
"""Clear all preferences for a specific scope ID.""" """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 # @abc.abstractmethod
# async def insert_llm_message( # async def insert_llm_message(
# self, # self,
+59
View File
@@ -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 @dataclass
class Conversation: class Conversation:
"""LLM 对话类 """LLM 对话类
+240
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import threading import threading
import typing as T import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult 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 import BaseDatabase
from astrbot.core.db.po import ( from astrbot.core.db.po import (
Attachment, Attachment,
CommandConfig,
CommandConflict,
ConversationV2, ConversationV2,
Persona, Persona,
PlatformMessageHistory, PlatformMessageHistory,
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
) )
NOT_GIVEN = T.TypeVar("NOT_GIVEN") NOT_GIVEN = T.TypeVar("NOT_GIVEN")
TxResult = T.TypeVar("TxResult")
class SQLiteDatabase(BaseDatabase): class SQLiteDatabase(BaseDatabase):
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
) )
await session.commit() 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 # Deprecated Methods
# ==== # ====
+449
View File
@@ -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
+1
View File
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
): ):
self.command_name = command_name self.command_name = command_name
self.alias = alias if alias else set() self.alias = alias if alias else set()
self._original_command_name = command_name
self.parent_command_names = ( self.parent_command_names = (
parent_command_names if parent_command_names is not None else [""] parent_command_names if parent_command_names is not None else [""]
) )
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
): ):
self.group_name = group_name self.group_name = group_name
self.alias = alias if alias else set() self.alias = alias if alias else set()
self._original_group_name = group_name
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = [] self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
self.custom_filter_list: list[CustomFilter] = [] self.custom_filter_list: list[CustomFilter] = []
self.parent_group = parent_group self.parent_group = parent_group
+4
View File
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
# 过滤事件类型 # 过滤事件类型
if handler.event_type != event_type: if handler.event_type != event_type:
continue continue
if not handler.enabled:
continue
# 过滤启用状态 # 过滤启用状态
if only_activated: if only_activated:
plugin = star_map.get(handler.handler_module_path) plugin = star_map.get(handler.handler_module_path)
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
extras_configs: dict = field(default_factory=dict) extras_configs: dict = field(default_factory=dict)
"""插件注册的一些其他的信息, 如 priority 等""" """插件注册的一些其他的信息, 如 priority 等"""
enabled: bool = True
def __lt__(self, other: StarHandlerMetadata): def __lt__(self, other: StarHandlerMetadata):
"""定义小于运算符以支持优先队列""" """定义小于运算符以支持优先队列"""
return self.extras_configs.get("priority", 0) < other.extras_configs.get( return self.extras_configs.get("priority", 0) < other.extras_configs.get(
+2
View File
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
from astrbot.core.utils.io import remove_dir from astrbot.core.utils.io import remove_dir
from . import StarMetadata from . import StarMetadata
from .command_management import sync_command_configs
from .context import Context from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry from .star import star_map, star_registry
@@ -618,6 +619,7 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers # 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]: for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler) logging.root.removeHandler(handler)
await sync_command_configs()
if not fail_rec: if not fail_rec:
return True, None return True, None
+2
View File
@@ -1,5 +1,6 @@
from .auth import AuthRoute from .auth import AuthRoute
from .chat import ChatRoute from .chat import ChatRoute
from .command import CommandRoute
from .config import ConfigRoute from .config import ConfigRoute
from .conversation import ConversationRoute from .conversation import ConversationRoute
from .file import FileRoute from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
__all__ = [ __all__ = [
"AuthRoute", "AuthRoute",
"ChatRoute", "ChatRoute",
"CommandRoute",
"ConfigRoute", "ConfigRoute",
"ConversationRoute", "ConversationRoute",
"FileRoute", "FileRoute",
+82
View File
@@ -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 {}
+20 -4
View File
@@ -3,6 +3,7 @@ import traceback
from quart import request from quart import request
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map from astrbot.core.star import star_map
@@ -296,15 +297,30 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表""" """获取所有注册的工具列表"""
try: try:
tools = self.tool_mgr.func_list 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, "name": tool.name,
"description": tool.description, "description": tool.description,
"parameters": tool.parameters, "parameters": tool.parameters,
"active": tool.active, "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__ return Response().ok(data=tools_dict).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
+1
View File
@@ -67,6 +67,7 @@ class AstrBotDashboard:
core_lifecycle, core_lifecycle,
core_lifecycle.plugin_manager, core_lifecycle.plugin_manager,
) )
self.command_route = CommandRoute(self.context)
self.cr = ConfigRoute(self.context, core_lifecycle) self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker) self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context) self.sfr = StaticFileRoute(self.context)
@@ -4,42 +4,18 @@
<!-- 页面标题 --> <!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8"> <v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div> <div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-function-variant</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
{{ tm('subtitle') }}
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="primary" class="ms-1 cursor-pointer"
@click="openurl('https://astrbot.app/use/function-calling.html')">
mdi-information
</v-icon>
</template>
<span>{{ tm('tooltip.info') }}</span>
</v-tooltip>
</p>
</div>
<div>
<v-btn color="primary" prepend-icon="mdi-tools" class="me-2" variant="tonal" @click="showToolsDialog = true"
rounded="xl" size="x-large">
{{ tm('functionTools.buttons.view') }}({{ tools.length }})
</v-btn>
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal" <v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
@click="showMcpServerDialog = true" rounded="xl" size="x-large"> @click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }} {{ tm('mcpServers.buttons.add') }}
</v-btn> </v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true" <v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
rounded="xl" size="x-large"> >
{{ tm('mcpServers.buttons.sync') }} {{ tm('mcpServers.buttons.sync') }}
</v-btn> </v-btn>
</div> </div>
</v-row> </v-row>
<!-- 本地服务器列表 -->
<!-- MCP 服务器部分 --> <!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8"> <div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon> <v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p> <p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
@@ -57,7 +33,6 @@
</span> </span>
</div> </div>
<div class="d-flex" style="gap: 8px;"> <div class="d-flex" style="gap: 8px;">
<div> <div>
<div v-if="item.tools && item.tools.length > 0"> <div v-if="item.tools && item.tools.length > 0">
@@ -67,8 +42,7 @@
<template v-slot:activator="{ props: listToolsProps }"> <template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps" <span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;"> style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ {{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
item.tools.length }})
</span> </span>
</template> </template>
<template v-slot:default="{ isActive }"> <template v-slot:default="{ isActive }">
@@ -78,10 +52,7 @@
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<ul> <ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ <li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
tool
}}
</li>
</ul> </ul>
</v-card-text> </v-card-text>
<v-card-actions class="d-flex justify-end"> <v-card-actions class="d-flex justify-end">
@@ -91,8 +62,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</template> </template>
</v-dialog> </v-dialog>
</div> </div>
</div> </div>
@@ -105,8 +74,6 @@
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular> <v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div> </div>
</div> </div>
</template> </template>
</item-card> </item-card>
</v-col> </v-col>
@@ -183,8 +150,7 @@
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 同步 MCP 服务器对话框 -->
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent> <v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent>
<v-card> <v-card>
<v-card-title class="bg-primary text-white py-3"> <v-card-title class="bg-primary text-white py-3">
@@ -240,115 +206,8 @@
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 函数工具对话框 -->
<v-dialog v-model="showToolsDialog" max-width="800px">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
{{ tm('functionTools.title') }}
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
</v-card-title>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showTools">
<div class="pa-4">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ tm('functionTools.empty') }}</p>
</div>
<div v-else>
<v-text-field v-model="toolSearch" prepend-inner-icon="mdi-magnify" :label="tm('functionTools.search')"
variant="outlined" density="compact" class="mb-4" hide-details clearable></v-text-field>
<small>复选框代表该工具是否被启用</small>
<v-expansion-panels v-model="openedPanel" multiple style="max-height: 500px; overflow-y: auto;">
<v-expansion-panel v-for="(tool, index) in filteredTools" :key="index" :value="index"
class="mb-2 tool-panel" rounded="lg">
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="1">
<v-checkbox v-model="tool.active" color="primary" hide-details density="compact" @click.stop
@change="toggleToolStatus(tool)"></v-checkbox>
</v-col>
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.name">
{{ formatToolName(tool.name) }}
</span>
</div>
</v-col>
<v-col cols="8" class="text-grey">
{{ tool.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
{{ tm('functionTools.description') }}
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.description }}</p>
<template v-if="tool.parameters && tool.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
{{ tm('functionTools.parameters') }}
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>{{ tm('functionTools.table.paramName') }}</th>
<th>{{ tm('functionTools.table.type') }}</th>
<th>{{ tm('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>{{ tm('functionTools.noParameters') }}</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
</v-card-text>
</v-expand-transition>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showToolsDialog = false">
{{ tm('dialogs.serverDetail.buttons.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 --> <!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" <v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
location="top">
{{ save_message }} {{ save_message }}
</v-snackbar> </v-snackbar>
</div> </div>
@@ -356,15 +215,13 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'; import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue'; import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
export default { export default {
name: 'ToolUsePage', name: 'McpServersSection',
components: { components: {
AstrBotConfig,
VueMonacoEditor, VueMonacoEditor,
ItemCard ItemCard
}, },
@@ -377,20 +234,15 @@ export default {
return { return {
refreshInterval: null, refreshInterval: null,
mcpServers: [], mcpServers: [],
tools: [],
showMcpServerDialog: false, showMcpServerDialog: false,
selectedMcpServerProvider: 'modelscope',
selectedMcpServerProvider: "modelscope", mcpServerProviderList: ['modelscope'],
mcpServerProviderList: ["modelscope"],
mcpProviderToken: '', mcpProviderToken: '',
showSyncMcpServerDialog: false, showSyncMcpServerDialog: false,
addServerDialogMessage: "", addServerDialogMessage: '',
showToolsDialog: false,
showTools: true,
loading: false, loading: false,
loadingGettingServers: false, loadingGettingServers: false,
mcpServerUpdateLoaders: {}, // record loading state for each server update mcpServerUpdateLoaders: {},
isEditMode: false, isEditMode: false,
serverConfigJson: '', serverConfigJson: '',
jsonError: null, jsonError: null,
@@ -400,87 +252,50 @@ export default {
tools: [] tools: []
}, },
save_message_snack: false, save_message_snack: false,
save_message: "", save_message: '',
save_message_success: "success", save_message_success: 'success'
toolSearch: '', };
openedPanel: [], //
}
}, },
computed: { computed: {
filteredTools() {
if (!this.toolSearch) return this.tools;
const searchTerm = this.toolSearch.toLowerCase();
return this.tools.filter(tool =>
tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm)
);
},
isServerFormValid() { isServerFormValid() {
return !!this.currentServer.name && !this.jsonError; return !!this.currentServer.name && !this.jsonError;
}, },
//
getServerConfigSummary() { getServerConfigSummary() {
return (server) => { return (server) => {
if (server.command) { if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`; return `${server.command} ${(server.args || []).join(' ')}`;
} }
// command
const configKeys = Object.keys(server).filter(key => const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key) !['name', 'active', 'tools'].includes(key)
); );
if (configKeys.length > 0) { if (configKeys.length > 0) {
return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') }); return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') });
} }
return this.tm('mcpServers.status.noConfig'); return this.tm('mcpServers.status.noConfig');
} };
}, }
}, },
mounted() { mounted() {
this.getServers(); this.getServers();
this.getTools();
this.refreshInterval = setInterval(() => { this.refreshInterval = setInterval(() => {
this.getServers(); this.getServers();
this.getTools();
}, 5000); }, 5000);
}, },
unmounted() { unmounted() {
// if it exists
if (this.refreshInterval) { if (this.refreshInterval) {
clearInterval(this.refreshInterval); clearInterval(this.refreshInterval);
} }
}, },
methods: { methods: {
openurl(url) { openurl(url) {
window.open(url, '_blank'); window.open(url, '_blank');
}, },
formatToolName(name) {
if (name.includes(':')) {
// MCP mcp:server:tool
const parts = name.split(':');
return parts[parts.length - 1]; //
}
return name;
},
getServers() { getServers() {
this.loadingGettingServers = true; this.loadingGettingServers = true;
axios.get('/api/tools/mcp/servers') axios.get('/api/tools/mcp/servers')
.then(response => { .then(response => {
this.mcpServers = response.data.data || []; this.mcpServers = response.data.data || [];
this.mcpServers.forEach(server => { this.mcpServers.forEach(server => {
// Ensure each server has a loader state
if (!this.mcpServerUpdateLoaders[server.name]) { if (!this.mcpServerUpdateLoaders[server.name]) {
this.mcpServerUpdateLoaders[server.name] = false; this.mcpServerUpdateLoaders[server.name] = false;
} }
@@ -492,24 +307,12 @@ export default {
this.loadingGettingServers = false; this.loadingGettingServers = false;
}); });
}, },
getTools() {
axios.get('/api/tools/list')
.then(response => {
this.tools = response.data.data || [];
})
.catch(error => {
this.showError(this.tm('messages.getToolsError', { error: error.message }));
});
},
validateJson() { validateJson() {
try { try {
if (!this.serverConfigJson.trim()) { if (!this.serverConfigJson.trim()) {
this.jsonError = this.tm('dialogs.addServer.errors.configEmpty'); this.jsonError = this.tm('dialogs.addServer.errors.configEmpty');
return false; return false;
} }
JSON.parse(this.serverConfigJson); JSON.parse(this.serverConfigJson);
this.jsonError = null; this.jsonError = null;
return true; return true;
@@ -518,61 +321,51 @@ export default {
return false; return false;
} }
}, },
setConfigTemplate(type = 'stdio') { setConfigTemplate(type = 'stdio') {
let template = {}; let template = {};
if (type === 'streamable_http') { if (type === 'streamable_http') {
template = { template = {
transport: "streamable_http", transport: 'streamable_http',
url: "your mcp server url", url: 'your mcp server url',
headers: {}, headers: {},
timeout: 5, timeout: 5,
sse_read_timeout: 300, sse_read_timeout: 300
}; };
} else if (type === 'sse') { } else if (type === 'sse') {
template = { template = {
transport: "sse", transport: 'sse',
url: "your mcp server url", url: 'your mcp server url',
headers: {}, headers: {},
timeout: 5, timeout: 5,
sse_read_timeout: 300, sse_read_timeout: 300
}; };
} else { } else {
template = { template = {
command: "python", command: 'python',
args: ["-m", "your_module"], args: ['-m', 'your_module']
}; };
} }
this.serverConfigJson = JSON.stringify(template, null, 2); this.serverConfigJson = JSON.stringify(template, null, 2);
}, },
saveServer() { saveServer() {
if (!this.validateJson()) { if (!this.validateJson()) {
return; return;
} }
this.loading = true; this.loading = true;
// JSON
try { try {
const configObj = JSON.parse(this.serverConfigJson); const configObj = JSON.parse(this.serverConfigJson);
//
const serverData = { const serverData = {
name: this.currentServer.name, name: this.currentServer.name,
active: this.currentServer.active, active: this.currentServer.active,
...configObj ...configObj
}; };
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add'; const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData) axios.post(endpoint, serverData)
.then(response => { .then(response => {
this.loading = false; this.loading = false;
this.showMcpServerDialog = false; this.showMcpServerDialog = false;
this.addServerDialogMessage = ""; this.addServerDialogMessage = '';
this.getServers(); this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.saveSuccess')); this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
this.resetForm(); this.resetForm();
}) })
@@ -585,14 +378,12 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message })); this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
} }
}, },
deleteServer(server) { deleteServer(server) {
let serverName = server.name || server; const serverName = server.name || server;
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) { if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
axios.post('/api/tools/mcp/delete', { name: serverName }) axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => { .then(response => {
this.getServers(); this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess')); this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
}) })
.catch(error => { .catch(error => {
@@ -600,37 +391,22 @@ export default {
}); });
} }
}, },
editServer(server) { editServer(server) {
//
const configCopy = { ...server }; const configCopy = { ...server };
delete configCopy.name;
// delete configCopy.active;
try { delete configCopy.tools;
delete configCopy.name; delete configCopy.errlogs;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
} catch (e) {
console.error("Error removing basic fields: ", e);
}
//
this.currentServer = { this.currentServer = {
name: server.name, name: server.name,
active: server.active, active: server.active,
tools: server.tools || [] tools: server.tools || []
}; };
// JSON
this.serverConfigJson = JSON.stringify(configCopy, null, 2); this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true; this.isEditMode = true;
this.showMcpServerDialog = true; this.showMcpServerDialog = true;
}, },
updateServerStatus(server) { updateServerStatus(server) {
//
this.mcpServerUpdateLoaders[server.name] = true; this.mcpServerUpdateLoaders[server.name] = true;
server.active = !server.active; server.active = !server.active;
axios.post('/api/tools/mcp/update', server) axios.post('/api/tools/mcp/update', server)
@@ -646,20 +422,16 @@ export default {
this.mcpServerUpdateLoaders[server.name] = false; this.mcpServerUpdateLoaders[server.name] = false;
}); });
}, },
closeServerDialog() { closeServerDialog() {
this.showMcpServerDialog = false; this.showMcpServerDialog = false;
this.addServerDialogMessage = ''; this.addServerDialogMessage = '';
this.resetForm(); this.resetForm();
}, },
testServerConnection() { testServerConnection() {
if (!this.validateJson()) { if (!this.validateJson()) {
return; return;
} }
this.loading = true; this.loading = true;
let configObj; let configObj;
try { try {
configObj = JSON.parse(this.serverConfigJson); configObj = JSON.parse(this.serverConfigJson);
@@ -668,9 +440,8 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message })); this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
return; return;
} }
axios.post('/api/tools/mcp/test', { axios.post('/api/tools/mcp/test', {
"mcp_server_config": configObj, mcp_server_config: configObj
}) })
.then(response => { .then(response => {
this.loading = false; this.loading = false;
@@ -681,7 +452,6 @@ export default {
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message })); this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
}); });
}, },
resetForm() { resetForm() {
this.currentServer = { this.currentServer = {
name: '', name: '',
@@ -692,58 +462,26 @@ export default {
this.jsonError = null; this.jsonError = null;
this.isEditMode = false; this.isEditMode = false;
}, },
showSuccess(message) { showSuccess(message) {
this.save_message = message; this.save_message = message;
this.save_message_success = "success"; this.save_message_success = 'success';
this.save_message_snack = true; this.save_message_snack = true;
}, },
showError(message) { showError(message) {
this.save_message = message; this.save_message = message;
this.save_message_success = "error"; this.save_message_success = 'error';
this.save_message_snack = true; this.save_message_snack = true;
}, },
// MCP
//
async toggleToolStatus(tool) {
try {
const response = await axios.post('/api/tools/toggle-tool', {
name: tool.name,
activate: tool.active
});
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('messages.toggleToolSuccess'));
} else {
//
tool.active = !tool.active;
this.showError(response.data.message || this.tm('messages.toggleToolError'));
}
} catch (error) {
//
tool.active = !tool.active;
this.showError(this.tm('messages.toggleToolError', { error: error.response?.data?.message || error.message }));
}
},
// MCP
async syncMcpServers() { async syncMcpServers() {
if (!this.selectedMcpServerProvider) { if (!this.selectedMcpServerProvider) {
this.showError(this.tm('syncProvider.status.selectProvider')); this.showError(this.tm('syncProvider.status.selectProvider'));
return; return;
} }
this.loading = true; this.loading = true;
try { try {
const requestData = { const requestData = {
name: this.selectedMcpServerProvider name: this.selectedMcpServerProvider
}; };
//
if (this.selectedMcpServerProvider === 'modelscope') { if (this.selectedMcpServerProvider === 'modelscope') {
if (!this.mcpProviderToken.trim()) { if (!this.mcpProviderToken.trim()) {
this.showError(this.tm('syncProvider.status.enterToken')); this.showError(this.tm('syncProvider.status.enterToken'));
@@ -752,61 +490,33 @@ export default {
} }
requestData.access_token = this.mcpProviderToken.trim(); requestData.access_token = this.mcpProviderToken.trim();
} }
const response = await axios.post('/api/tools/mcp/sync-provider', requestData); const response = await axios.post('/api/tools/mcp/sync-provider', requestData);
if (response.data.status === 'ok') { if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess')); this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess'));
this.showSyncMcpServerDialog = false; this.showSyncMcpServerDialog = false;
this.mcpProviderToken = ''; this.mcpProviderToken = '';
//
this.getServers(); this.getServers();
this.getTools();
} else { } else {
this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' })); this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' }));
} }
} catch (error) { } catch (error) {
console.error('同步 MCP 服务器失败:', error); this.showError(this.tm('syncProvider.messages.syncError', {
this.showError(this.tm('syncProvider.messages.syncError', { error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
})); }));
} finally { } finally {
this.loading = false; this.loading = false;
} }
} }
} }
} };
</script> </script>
<style scoped> <style scoped>
.tools-page { .tools-page {
padding: 20px; padding: 0px;
padding-top: 8px; padding-top: 8px;
} }
.tool-chips {
max-height: 60px;
overflow-y: auto;
}
.tool-panel {
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.tool-panel:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.params-table {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.params-table th {
background-color: rgba(0, 0, 0, 0.02);
}
.monaco-container { .monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px; border-radius: 8px;
@@ -814,4 +524,4 @@ export default {
margin-top: 4px; margin-top: 4px;
overflow: hidden; overflow: hidden;
} }
</style> </style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
availablePlugins: string[];
hasSystemPluginConflict: boolean;
effectiveShowSystemPlugins: boolean;
pluginFilter: string;
typeFilter: string;
permissionFilter: string;
statusFilter: string;
showSystemPlugins: boolean;
searchQuery: string;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:pluginFilter', value: string): void;
(e: 'update:typeFilter', value: string): void;
(e: 'update:permissionFilter', value: string): void;
(e: 'update:statusFilter', value: string): void;
(e: 'update:showSystemPlugins', value: boolean): void;
(e: 'update:searchQuery', value: string): void;
}>();
// Computed items for selects
const pluginItems = computed(() => [
{ title: tm('filters.all'), value: 'all' },
...props.availablePlugins.map(p => ({ title: p, value: p }))
]);
const typeItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('type.group'), value: 'group' },
{ title: tm('type.command'), value: 'command' },
{ title: tm('type.subCommand'), value: 'sub_command' }
];
const permissionItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('permission.everyone'), value: 'everyone' },
{ title: tm('permission.admin'), value: 'admin' }
];
const statusItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('filters.enabled'), value: 'enabled' },
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
<!-- 过滤器行 -->
<v-row class="mb-4" align="center">
<v-col cols="12" sm="6" md="3">
<v-select
:model-value="pluginFilter"
@update:model-value="emit('update:pluginFilter', $event)"
:items="pluginItems"
:label="tm('filters.byPlugin')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="typeFilter"
@update:model-value="emit('update:typeFilter', $event)"
:items="typeItems"
:label="tm('filters.byType')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="permissionFilter"
@update:model-value="emit('update:permissionFilter', $event)"
:items="permissionItems"
:label="tm('filters.byPermission')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="statusFilter"
@update:model-value="emit('update:statusFilter', $event)"
:items="statusItems"
:label="tm('filters.byStatus')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
</v-row>
<!-- 搜索栏 + 统计信息行 -->
<div class="mb-4 d-flex flex-wrap align-center ga-4">
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', $event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
flat
hide-details
single-line
/>
</div>
<div class="d-flex align-center ga-4">
<slot name="stats"></slot>
<v-divider vertical class="mx-1" style="height: 20px;" />
<v-checkbox
:model-value="effectiveShowSystemPlugins"
@update:model-value="emit('update:showSystemPlugins', !!$event)"
:label="tm('filters.showSystemPlugins')"
density="compact"
hide-details
:disabled="hasSystemPluginConflict"
class="system-plugin-checkbox"
>
<template v-slot:label>
<span class="text-body-2">{{ tm('filters.showSystemPlugins') }}</span>
<v-tooltip v-if="hasSystemPluginConflict" location="top">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" size="16" color="warning" class="ml-1">mdi-alert-circle</v-icon>
</template>
{{ tm('filters.systemPluginConflictHint') }}
</v-tooltip>
</template>
</v-checkbox>
</div>
</div>
</template>
<style scoped>
.system-plugin-checkbox {
flex: none;
}
.system-plugin-checkbox :deep(.v-selection-control) {
min-height: auto;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem, TypeInfo, StatusInfo } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
items: CommandItem[];
expandedGroups: Set<string>;
loading?: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'toggle-expand', cmd: CommandItem): void;
(e: 'toggle-command', cmd: CommandItem): void;
(e: 'rename', cmd: CommandItem): void;
(e: 'view-details', cmd: CommandItem): void;
}>();
//
const commandHeaders = computed(() => [
{ title: tm('table.headers.command'), key: 'effective_command', minWidth: '100px' },
{ title: tm('table.headers.type'), key: 'type', sortable: false, width: '100px' },
{ title: tm('table.headers.plugin'), key: 'plugin', width: '140px' },
{ title: tm('table.headers.description'), key: 'description', sortable: false },
{ title: tm('table.headers.permission'), key: 'permission', sortable: false, width: '100px' },
{ title: tm('table.headers.status'), key: 'enabled', sortable: false, width: '100px' },
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '140px' }
]);
//
const isGroupExpanded = (cmd: CommandItem): boolean => {
return props.expandedGroups.has(cmd.handler_full_name);
};
//
const getTypeInfo = (type: string): TypeInfo => {
switch (type) {
case 'group':
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: tm('type.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): string => {
switch (permission) {
case 'admin': return tm('permission.admin');
default: return tm('permission.everyone');
}
};
//
const getStatusInfo = (cmd: CommandItem): StatusInfo => {
if (cmd.has_conflict) {
return { text: tm('status.conflict'), color: 'warning', variant: 'flat' };
}
if (cmd.enabled) {
return { text: tm('status.enabled'), color: 'success', variant: 'flat' };
}
return { text: tm('status.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(' ') } : {};
};
</script>
<template>
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="commandHeaders"
:items="items"
item-key="handler_full_name"
hover
:row-props="getRowProps"
:loading="props.loading"
>
<template v-slot:item.effective_command="{ item }">
<div class="d-flex align-center py-2">
<!-- 展开/折叠按钮针对指令组 -->
<v-btn
v-if="item.is_group && item.sub_commands?.length > 0"
icon
variant="text"
size="x-small"
class="mr-1"
@click.stop="emit('toggle-expand', item)"
>
<v-icon size="18">{{ isGroupExpanded(item) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</v-btn>
<!-- 子指令缩进 -->
<div v-else-if="item.type === 'sub_command'" class="ml-6"></div>
<div>
<div class="text-subtitle-1 font-weight-medium">
<code :class="{ 'sub-command-code': item.type === 'sub_command' }">{{ item.effective_command }}</code>
</div>
</div>
</div>
</template>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeInfo(item.type).color"
size="small"
variant="tonal"
>
<v-icon start size="14">{{ getTypeInfo(item.type).icon }}</v-icon>
{{ getTypeInfo(item.type).text }}{{ item.is_group && item.sub_commands?.length > 0 ? `(${item.sub_commands.length})` : '' }}
</v-chip>
</template>
<template v-slot:item.plugin="{ item }">
<div class="text-body-2">{{ item.plugin_display_name || item.plugin }}</div>
</template>
<template v-slot:item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.description || '-' }}
</div>
</template>
<template v-slot:item.permission="{ item }">
<v-chip :color="getPermissionColor(item.permission)" size="small" class="font-weight-medium">
{{ getPermissionLabel(item.permission) }}
</v-chip>
</template>
<template v-slot:item.enabled="{ item }">
<v-chip
:color="getStatusInfo(item).color"
size="small"
class="font-weight-medium"
:variant="getStatusInfo(item).variant"
>
{{ getStatusInfo(item).text }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center">
<v-btn-group density="default" variant="text" color="primary">
<v-btn
v-if="!item.enabled"
icon
size="small"
color="success"
@click="emit('toggle-command', item)"
>
<v-icon size="22">mdi-play</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.enable') }}</v-tooltip>
</v-btn>
<v-btn
v-else
icon
size="small"
color="error"
@click="emit('toggle-command', item)"
>
<v-icon size="22">mdi-pause</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.disable') }}</v-tooltip>
</v-btn>
<v-btn icon size="small" color="warning" @click="emit('rename', item)">
<v-icon size="22">mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.rename') }}</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="emit('view-details', item)">
<v-icon size="22">mdi-information</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.viewDetails') }}</v-tooltip>
</v-btn>
</v-btn-group>
</div>
</template>
<template v-slot:no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-console-line</v-icon>
<div class="text-h5 mb-2">{{ tm('empty.noCommands') }}</div>
<div class="text-body-1 mb-4">{{ tm('empty.noCommandsDesc') }}</div>
</div>
</template>
</v-data-table>
</v-card>
</template>
<style scoped>
code {
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
white-space: nowrap;
}
code.sub-command-code {
background-color: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
</style>
<style>
/* 冲突行高亮 */
.v-data-table .conflict-row {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.15) 0%, rgba(var(--v-theme-warning), 0.05) 100%) !important;
border-left: 3px solid rgb(var(--v-theme-warning)) !important;
}
.v-data-table .conflict-row:hover {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.25) 0%, rgba(var(--v-theme-warning), 0.1) 100%) !important;
}
/* 指令组行样式 */
.v-data-table .group-row {
background-color: rgba(var(--v-theme-info), 0.05);
}
.v-data-table .group-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
/* 子指令行样式 */
.v-data-table .sub-command-row {
background-color: rgba(var(--v-theme-info), 0.05);
}
.v-data-table .sub-command-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { CommandItem, TypeInfo } from '../types';
const { t } = useI18n();
const { tm } = useModuleI18n('features/command');
// Props
defineProps<{
show: boolean;
command: CommandItem | null;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
}>();
//
const getTypeInfo = (type: string): TypeInfo => {
switch (type) {
case 'group':
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: tm('type.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): string => {
switch (permission) {
case 'admin': return tm('permission.admin');
default: return tm('permission.everyone');
}
};
</script>
<template>
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
<v-card v-if="command">
<v-card-title class="text-h5">{{ tm('dialogs.details.title') }}</v-card-title>
<v-card-text>
<v-list density="compact">
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.type') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getTypeInfo(command.type).color"
size="small"
variant="tonal"
>
<v-icon start size="14">{{ getTypeInfo(command.type).icon }}</v-icon>
{{ getTypeInfo(command.type).text }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.handler') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.handler_name }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.module') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.module_path }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.originalCommand') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.original_command }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.effectiveCommand') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.effective_command }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.parent_signature">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.parentGroup') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.parent_signature }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.aliases.length > 0">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.aliases') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip v-for="alias in command.aliases" :key="alias" size="small" class="mr-1">
{{ alias }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.is_group && command.sub_commands?.length > 0">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.subCommands') }}</v-list-item-title>
<v-list-item-subtitle>
<div class="d-flex flex-wrap ga-1 mt-1">
<v-chip
v-for="sub in command.sub_commands"
:key="sub.handler_full_name"
size="small"
variant="outlined"
>
{{ sub.current_fragment }}
</v-chip>
</div>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.permission') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip :color="getPermissionColor(command.permission)" size="small">
{{ getPermissionLabel(command.permission) }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.has_conflict">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.conflictStatus') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip color="warning" size="small">{{ tm('status.conflict') }}</v-chip>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="emit('update:show', false)">
{{ t('core.actions.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
code {
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
defineProps<{
show: boolean;
command: CommandItem | null;
newName: string;
loading: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'update:newName', value: string): void;
(e: 'confirm'): void;
}>();
</script>
<template>
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
<v-card>
<v-card-title class="text-h5">{{ tm('dialogs.rename.title') }}</v-card-title>
<v-card-text>
<v-text-field
:model-value="newName"
@update:model-value="emit('update:newName', $event)"
:label="tm('dialogs.rename.newName')"
variant="outlined"
density="compact"
autofocus
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="grey" variant="text" @click="emit('update:show', false)">
{{ tm('dialogs.rename.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="text"
:loading="loading"
@click="emit('confirm')"
>
{{ tm('dialogs.rename.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { ToolItem } from '../types';
const { tm: tmTool } = useModuleI18n('features/tooluse');
const { tm: tmCommand } = useModuleI18n('features/command');
const props = defineProps<{
items: ToolItem[];
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'toggle-tool', tool: ToolItem): void;
}>();
const toolHeaders = computed(() => [
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '160px' },
{ title: tmTool('functionTools.description'), key: 'description' },
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' },
{ title: tmCommand('status.enabled'), key: 'active', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' }
]);
const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {});
</script>
<template>
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="toolHeaders"
:items="items"
item-key="name"
hover
show-expand
class="tool-table"
:loading="props.loading"
>
<template #item.name="{ item }">
<div class="d-flex align-center py-2">
<v-icon color="primary" class="mr-2" size="18">
{{ item.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<div>
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
</div>
</div>
</template>
<template #item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.description || '-' }}
</div>
</template>
<template #item.origin="{ item }">
<v-chip size="small" variant="tonal" color="info" class="text-caption font-weight-medium">
{{ item.origin || '-' }}
</v-chip>
</template>
<template #item.origin_name="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.origin_name || '-' }}
</div>
</template>
<template #item.active="{ item }">
<v-chip :color="item.active ? 'success' : 'error'" size="small" class="font-weight-medium" :variant="item.active ? 'flat' : 'outlined'">
{{ item.active ? tmCommand('status.enabled') : tmCommand('status.disabled') }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-switch
:model-value="item.active"
color="primary"
density="compact"
hide-details
inset
@update:model-value="emit('toggle-tool', item)"
/>
</template>
<template #no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-function-variant</v-icon>
<div class="text-h5 mb-2">{{ tmTool('functionTools.empty') }}</div>
</div>
</template>
<template #expanded-row="{ item }">
<td :colspan="toolHeaders.length + 1" class="pa-4">
<div class="d-flex align-start ga-4">
<v-icon size="20" color="primary">mdi-code-json</v-icon>
<div class="flex-1">
<div class="text-subtitle-2 font-weight-medium mb-2">{{ tmTool('functionTools.parameters') }}</div>
<div v-if="parameterEntries(item).length === 0" class="text-caption text-medium-emphasis">
{{ tmTool('functionTools.noParameters') }}
</div>
<v-table
v-else
density="compact"
class="param-table"
>
<thead>
<tr>
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.paramName') }}</th>
<th class="text-left text-caption text-medium-emphasis" style="width: 140px;">{{ tmTool('functionTools.table.type') }}</th>
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="([paramName, param]) in parameterEntries(item)" :key="paramName">
<td class="font-weight-medium text-body-2">{{ paramName }}</td>
<td class="text-body-2">
<v-chip size="x-small" color="primary" class="text-caption">
{{ param?.type || '-' }}
</v-chip>
</td>
<td class="text-body-2 text-medium-emphasis">{{ param?.description || '-' }}</td>
</tr>
</tbody>
</v-table>
</div>
</div>
</td>
</template>
</v-data-table>
</v-card>
</template>
<style scoped>
.param-table {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
}
.tool-table :deep(.v-data-table__td) {
vertical-align: middle;
}
</style>
@@ -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<void>
) {
// 重命名对话框状态
const renameDialog = reactive<RenameDialogState>({
show: false,
command: null,
newName: '',
loading: false
});
// 详情对话框状态
const detailsDialog = reactive<DetailsDialogState>({
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
};
}
@@ -0,0 +1,187 @@
/**
* Composable
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
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<Set<string>>(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
};
}
@@ -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<CommandItem[]>([]);
const tools = ref<ToolItem[]>([]);
const toolsLoading = ref(false);
const summary = reactive<CommandSummary>({
disabled: 0,
conflicts: 0
});
const snackbar = reactive<SnackbarState>({
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
};
}
@@ -0,0 +1,307 @@
<script setup lang="ts">
/**
* 组件管理页面 - 主入口
*
* 模块化结构
* - types.ts: 类型定义
* - composables/useComponentData.ts: 数据获取和状态管理
* - composables/useCommandFilters.ts: 过滤逻辑
* - composables/useCommandActions.ts: 操作方法
* - components/CommandFilters.vue: 过滤器组件
* - components/CommandTable.vue: 表格组件
* - components/RenameDialog.vue: 重命名对话框
* - components/DetailsDialog.vue: 详情对话框
*/
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
// Composables
import { useComponentData } from './composables/useComponentData';
import { useCommandFilters } from './composables/useCommandFilters';
import { useCommandActions } from './composables/useCommandActions';
// Components
import CommandFilters from './components/CommandFilters.vue';
import CommandTable from './components/CommandTable.vue';
import ToolTable from './components/ToolTable.vue';
import RenameDialog from './components/RenameDialog.vue';
import DetailsDialog from './components/DetailsDialog.vue';
// Types
import type { CommandItem, ToolItem } from './types';
defineOptions({ name: 'ComponentPanel' });
const props = withDefaults(defineProps<{ active?: boolean }>(), {
active: true
});
const { tm } = useModuleI18n('features/command');
const { tm: tmTool } = useModuleI18n('features/tooluse');
const viewMode = ref<'commands' | 'tools'>('commands');
const toolSearch = ref('');
//
const {
loading,
commands,
tools,
toolsLoading,
summary,
snackbar,
toast,
fetchCommands,
fetchTools
} = useComponentData();
//
const {
searchQuery,
pluginFilter,
permissionFilter,
statusFilter,
typeFilter,
showSystemPlugins,
expandedGroups,
hasSystemPluginConflict,
effectiveShowSystemPlugins,
availablePlugins,
filteredCommands,
toggleGroupExpand
} = useCommandFilters(commands);
//
const {
renameDialog,
detailsDialog,
toggleCommand,
openRenameDialog,
confirmRename,
openDetailsDialog
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = toolSearch.value.trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
);
});
//
const handleToggleCommand = async (cmd: CommandItem) => {
await toggleCommand(cmd, tm('messages.toggleSuccess'), tm('messages.toggleFailed'));
};
const handleToggleTool = async (tool: ToolItem) => {
const previous = tool.active;
tool.active = !tool.active;
try {
const res = await axios.post('/api/tools/toggle-tool', {
name: tool.name,
activate: tool.active
});
if (res.data.status === 'ok') {
toast(res.data.message || tmTool('messages.toggleToolSuccess'));
} else {
tool.active = previous;
toast(res.data.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
}
} catch (error: any) {
tool.active = previous;
toast(error?.response?.data?.message || error?.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
}
};
//
const handleConfirmRename = async () => {
await confirmRename(tm('messages.renameSuccess'), tm('messages.renameFailed'));
};
//
onMounted(async () => {
await Promise.all([
fetchCommands(tm('messages.loadFailed')),
fetchTools(tmTool('messages.getToolsError', { error: '' }))
]);
});
watch(() => props.active, async (isActive) => {
if (!isActive) return;
if (viewMode.value === 'commands') {
await fetchCommands(tm('messages.loadFailed'));
} else {
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
}
});
watch(viewMode, async (mode) => {
if (mode === 'commands') {
await fetchCommands(tm('messages.loadFailed'));
} else {
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
}
});
</script>
<template>
<v-row>
<v-col cols="12">
<v-card variant="flat" style="background-color: transparent">
<v-card-text style="padding: 20px 12px; padding-top: 0px;">
<div class="d-flex justify-space-between align-center mb-6 flex-wrap ga-3">
<v-btn-toggle v-model="viewMode" color="primary" variant="outlined" density="comfortable" mandatory>
<v-btn value="commands">
<v-icon size="18" class="mr-1">mdi-console-line</v-icon>
{{ tm('type.command') }}
</v-btn>
<v-btn value="tools">
<v-icon size="18" class="mr-1">mdi-function-variant</v-icon>
{{ tmTool('functionTools.title') }}
</v-btn>
</v-btn-toggle>
<v-progress-linear
v-if="viewMode === 'commands' && loading"
indeterminate
color="primary"
style="max-width: 220px; flex: 1;"
/>
<v-progress-linear
v-else-if="viewMode === 'tools' && toolsLoading"
indeterminate
color="primary"
style="max-width: 220px; flex: 1;"
/>
</div>
<div v-if="viewMode === 'commands'">
<CommandFilters
:plugin-filter="pluginFilter"
@update:plugin-filter="pluginFilter = $event"
:type-filter="typeFilter"
@update:type-filter="typeFilter = $event"
:permission-filter="permissionFilter"
@update:permission-filter="permissionFilter = $event"
:status-filter="statusFilter"
@update:status-filter="statusFilter = $event"
:show-system-plugins="showSystemPlugins"
@update:show-system-plugins="showSystemPlugins = $event"
:search-query="searchQuery"
@update:search-query="searchQuery = $event"
:available-plugins="availablePlugins"
:has-system-plugin-conflict="hasSystemPluginConflict"
:effective-show-system-plugins="effectiveShowSystemPlugins"
>
<template #stats>
<div class="d-flex align-center">
<v-icon size="18" color="primary" class="mr-1">mdi-console-line</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
<span class="text-body-1 font-weight-bold text-primary">{{ filteredCommands.length }}</span>
</div>
<v-divider vertical class="mx-1" style="height: 20px;" />
<div class="d-flex align-center">
<v-icon size="18" color="error" class="mr-1">mdi-close-circle-outline</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.disabled') }}:</span>
<span class="text-body-1 font-weight-bold text-error">{{ summary.disabled }}</span>
</div>
</template>
</CommandFilters>
<v-alert
v-if="summary.conflicts > 0"
type="error"
variant="tonal"
class="mb-4"
prominent
border="start"
>
<template v-slot:prepend>
<v-icon size="28">mdi-alert-circle</v-icon>
</template>
<v-alert-title class="text-subtitle-1 font-weight-bold">
{{ tm('conflictAlert.title') }}
</v-alert-title>
<div class="text-body-2 mt-1">
{{ tm('conflictAlert.description', { count: summary.conflicts }) }}
</div>
<div class="text-body-2 mt-2">
<v-icon size="16" class="mr-1">mdi-lightbulb-outline</v-icon>
{{ tm('conflictAlert.hint') }}
</div>
</v-alert>
<CommandTable
:items="filteredCommands"
:expanded-groups="expandedGroups"
:loading="loading"
@toggle-expand="toggleGroupExpand"
@toggle-command="handleToggleCommand"
@rename="openRenameDialog"
@view-details="openDetailsDialog"
/>
</div>
<div v-else>
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
density="compact"
hide-details
clearable
/>
</div>
<div class="d-flex align-center ga-2">
<div class="d-flex align-center">
<v-icon size="18" color="primary" class="mr-1">mdi-function-variant</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
<span class="text-body-1 font-weight-bold text-primary">{{ filteredTools.length }}</span>
</div>
<v-divider vertical class="mx-1" style="height: 20px;" />
<div class="d-flex align-center">
<v-icon size="18" color="success" class="mr-1">mdi-check-circle-outline</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('status.enabled') }}:</span>
<span class="text-body-1 font-weight-bold text-success">{{ filteredTools.filter(t => t.active).length }}</span>
</div>
</div>
</div>
<ToolTable
:items="filteredTools"
:loading="toolsLoading"
@toggle-tool="handleToggleTool"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 重命名对话框 -->
<RenameDialog
:show="renameDialog.show"
@update:show="renameDialog.show = $event"
:new-name="renameDialog.newName"
@update:new-name="renameDialog.newName = $event"
:command="renameDialog.command"
:loading="renameDialog.loading"
@confirm="handleConfirmRename"
/>
<!-- 详情对话框 -->
<DetailsDialog
:show="detailsDialog.show"
@update:show="detailsDialog.show = $event"
:command="detailsDialog.command"
/>
<!-- Snackbar -->
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
{{ snackbar.message }}
</v-snackbar>
</template>
@@ -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<string, ToolParameter>;
};
origin?: string;
origin_name?: string;
}
@@ -121,7 +121,8 @@ import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import { import {
getSidebarCustomization, getSidebarCustomization,
setSidebarCustomization, setSidebarCustomization,
clearSidebarCustomization clearSidebarCustomization,
resolveSidebarItems
} from '@/utils/sidebarCustomization'; } from '@/utils/sidebarCustomization';
const { t } = useI18n(); const { t } = useI18n();
@@ -133,35 +134,12 @@ const draggedItem = ref(null);
function initializeItems() { function initializeItems() {
const customization = getSidebarCustomization(); const customization = getSidebarCustomization();
const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
if (customization) { sidebarItems,
// Load from customization customization
const allItemsMap = new Map(); );
mainItems.value = resolvedMain;
sidebarItems.forEach(item => { moreItems.value = resolvedMore;
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] : [];
}
} }
function openDialog() { function openDialog() {
@@ -19,5 +19,6 @@
"submit": "Submit", "submit": "Submit",
"reset": "Reset", "reset": "Reset",
"clear": "Clear", "clear": "Clear",
"save": "Save" "save": "Save",
"close": "Close"
} }
@@ -2,6 +2,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"platforms": "Platforms", "platforms": "Platforms",
"providers": "Providers", "providers": "Providers",
"commands": "Commands",
"persona": "Persona", "persona": "Persona",
"toolUse": "MCP Tools", "toolUse": "MCP Tools",
"config": "Config", "config": "Config",
@@ -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."
}
}
@@ -2,7 +2,9 @@
"title": "Extension Management", "title": "Extension Management",
"subtitle": "Manage and configure system extensions", "subtitle": "Manage and configure system extensions",
"tabs": { "tabs": {
"installed": "Installed", "installedPlugins": "Installed Plugins",
"installedMcpServers": "Installed MCP Servers",
"handlersOperation": "Manage Components",
"market": "Extension Market" "market": "Extension Market"
}, },
"search": { "search": {
@@ -197,5 +199,12 @@
"errors": { "errors": {
"confirmNotRegistered": "$confirm not properly registered" "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"
} }
} }
@@ -42,7 +42,10 @@
"paramName": "Parameter Name", "paramName": "Parameter Name",
"type": "Type", "type": "Type",
"description": "Description", "description": "Description",
"required": "Required" "required": "Required",
"origin": "Origin",
"originName": "Origin Name",
"actions": "Actions"
} }
}, },
"marketplace": { "marketplace": {
@@ -19,5 +19,6 @@
"submit": "提交", "submit": "提交",
"reset": "重置", "reset": "重置",
"clear": "清空", "clear": "清空",
"save": "保存" "save": "保存",
"close": "关闭"
} }
@@ -2,6 +2,7 @@
"dashboard": "数据统计", "dashboard": "数据统计",
"platforms": "机器人", "platforms": "机器人",
"providers": "模型提供商", "providers": "模型提供商",
"commands": "指令管理",
"persona": "人格设定", "persona": "人格设定",
"toolUse": "MCP", "toolUse": "MCP",
"extension": "插件", "extension": "插件",
@@ -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": "存在涉及系统插件的冲突,需解决冲突后才能隐藏"
}
}
@@ -2,7 +2,9 @@
"title": "插件管理", "title": "插件管理",
"subtitle": "管理和配置系统插件", "subtitle": "管理和配置系统插件",
"tabs": { "tabs": {
"installed": "已安装", "installedPlugins": "已安装的插件",
"installedMcpServers": "已安装的 MCP 服务器",
"handlersOperation": "管理组件",
"market": "插件市场" "market": "插件市场"
}, },
"search": { "search": {
@@ -197,5 +199,12 @@
"errors": { "errors": {
"confirmNotRegistered": "$confirm 未正确注册" "confirmNotRegistered": "$confirm 未正确注册"
} }
},
"conflicts": {
"title": "检测到指令冲突",
"message": "这会导致部分指令工作异常,建议前往【指令管理】面板进行处理。",
"pairs": "对指令冲突",
"goToManage": "前往处理",
"later": "稍后处理"
} }
} }
@@ -42,7 +42,10 @@
"paramName": "参数名", "paramName": "参数名",
"type": "类型", "type": "类型",
"description": "描述", "description": "描述",
"required": "必填" "required": "必填",
"origin": "来源",
"originName": "来源名称",
"actions": "操作"
} }
}, },
"marketplace": { "marketplace": {
+6 -2
View File
@@ -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 zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/document.json';
import zhCNPersona from './locales/zh-CN/features/persona.json'; import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.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 zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.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 enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/document.json';
import enUSPersona from './locales/en-US/features/persona.json'; import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.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 enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json'; import enUSSuccess from './locales/en-US/messages/success.json';
@@ -111,7 +113,8 @@ export const translations = {
document: zhCNKnowledgeBaseDocument document: zhCNKnowledgeBaseDocument
}, },
persona: zhCNPersona, persona: zhCNPersona,
migration: zhCNMigration migration: zhCNMigration,
command: zhCNCommand
}, },
messages: { messages: {
errors: zhCNErrors, errors: zhCNErrors,
@@ -155,7 +158,8 @@ export const translations = {
document: enUSKnowledgeBaseDocument document: enUSKnowledgeBaseDocument
}, },
persona: enUSPersona, persona: enUSPersona,
migration: enUSMigration migration: enUSMigration,
command: enUSCommand
}, },
messages: { messages: {
errors: enUSErrors, errors: enUSErrors,
@@ -33,11 +33,6 @@ const sidebarItem: menu[] = [
icon: 'mdi-cog', icon: 'mdi-cog',
to: '/config', to: '/config',
}, },
{
title: 'core.navigation.toolUse',
icon: 'mdi-function-variant',
to: '/tool-use'
},
{ {
title: 'core.navigation.extension', title: 'core.navigation.extension',
icon: 'mdi-puzzle', icon: 'mdi-puzzle',
-5
View File
@@ -31,11 +31,6 @@ const MainRoutes = {
path: '/providers', path: '/providers',
component: () => import('@/views/ProviderPage.vue') component: () => import('@/views/ProviderPage.vue')
}, },
{
name: 'ToolUsePage',
path: '/tool-use',
component: () => import('@/views/ToolUsePage.vue')
},
{ {
name: 'Configs', name: 'Configs',
path: '/config', path: '/config',
+89 -51
View File
@@ -41,59 +41,97 @@ export function clearSidebarCustomization() {
} }
/** /**
* Apply customization to sidebar items * 解析侧边栏默认项与用户定制返回主区/更多区及可选的合并结果
* @param {Array} defaultItems - Default sidebar items array * @param {Array} defaultItems - 默认侧边栏结构
* @returns {Array} Customized sidebar items array (new array, doesn't mutate input) * @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) { export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization(); const customization = getSidebarCustomization();
if (!customization) { const { merged } = resolveSidebarItems(defaultItems, customization, {
return defaultItems; cloneItems: true,
} assembleMoreGroup: true
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 });
}
}); });
return merged || defaultItems;
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;
} }
+93 -6
View File
@@ -5,18 +5,45 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue'; import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue'; import ProxySelector from '@/components/shared/ProxySelector.vue';
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.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 axios from 'axios';
import { pinyin } from 'pinyin-pro'; import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common'; import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import defaultPluginIcon from '@/assets/images/plugin_icon.png'; 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 commonStore = useCommonStore();
const { t } = useI18n(); const { t } = useI18n();
const { tm } = useModuleI18n('features/extension'); 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 fileInput = ref(null);
const activeTab = ref('installed'); const activeTab = ref('installed');
const extension_data = reactive({ const extension_data = reactive({
@@ -448,7 +475,9 @@ const pluginOn = async (extension) => {
return; return;
} }
toast(res.data.message, "success"); toast(res.data.message, "success");
getExtensions(); await getExtensions();
await checkAndPromptConflicts();
} catch (err) { } catch (err) {
toast(err, "error"); toast(err, "error");
} }
@@ -782,6 +811,8 @@ const newExtension = async () => {
name: res.data.data.name, name: res.data.data.name,
repo: res.data.data.repo || null repo: res.data.data.repo || null
}); });
await checkAndPromptConflicts();
}).catch((err) => { }).catch((err) => {
loading_.value = false; loading_.value = false;
onLoadingDialogResult(2, err, -1); onLoadingDialogResult(2, err, -1);
@@ -808,6 +839,8 @@ const newExtension = async () => {
name: res.data.data.name, name: res.data.data.name,
repo: res.data.data.repo || null repo: res.data.data.repo || null
}); });
await checkAndPromptConflicts();
}).catch((err) => { }).catch((err) => {
loading_.value = false; loading_.value = false;
toast(tm('messages.installFailed') + " " + err, "error"); toast(tm('messages.installFailed') + " " + err, "error");
@@ -900,21 +933,29 @@ watch(marketSearch, (newVal) => {
<v-tabs v-model="activeTab" color="primary"> <v-tabs v-model="activeTab" color="primary">
<v-tab value="installed"> <v-tab value="installed">
<v-icon class="mr-2">mdi-puzzle</v-icon> <v-icon class="mr-2">mdi-puzzle</v-icon>
{{ tm('tabs.installed') }} {{ tm('tabs.installedPlugins') }}
</v-tab>
<v-tab value="mcp">
<v-icon class="mr-2">mdi-server-network</v-icon>
{{ tm('tabs.installedMcpServers') }}
</v-tab> </v-tab>
<v-tab value="market"> <v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon> <v-icon class="mr-2">mdi-store</v-icon>
{{ tm('tabs.market') }} {{ tm('tabs.market') }}
</v-tab> </v-tab>
<v-tab value="components">
<v-icon class="mr-2">mdi-wrench</v-icon>
{{ tm('tabs.handlersOperation') }}
</v-tab>
</v-tabs> </v-tabs>
<!-- 搜索栏 - 在移动端时独占一行 --> <!-- 搜索栏 - 在移动端时独占一行 -->
<div style="flex-grow: 1; min-width: 250px; max-width: 400px; margin-left: auto; margin-top: 8px;"> <div style="flex-grow: 1; min-width: 250px; max-width: 400px; margin-left: auto; margin-top: 8px;">
<v-text-field v-if="activeTab == 'market'" v-model="marketSearch" density="compact" <v-text-field v-if="activeTab === 'market'" v-model="marketSearch" density="compact"
:label="tm('search.marketPlaceholder')" prepend-inner-icon="mdi-magnify" variant="solo-filled" flat :label="tm('search.marketPlaceholder')" prepend-inner-icon="mdi-magnify" variant="solo-filled" flat
hide-details single-line> hide-details single-line>
</v-text-field> </v-text-field>
<v-text-field v-else v-model="pluginSearch" density="compact" :label="tm('search.placeholder')" <v-text-field v-else-if="activeTab === 'installed'" v-model="pluginSearch" density="compact" :label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details single-line> prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details single-line>
</v-text-field> </v-text-field>
</div> </div>
@@ -1118,6 +1159,24 @@ watch(marketSearch, (newVal) => {
</v-fade-transition> </v-fade-transition>
</v-tab-item> </v-tab-item>
<!-- 指令面板标签页内容 -->
<v-tab-item v-show="activeTab === 'components'">
<v-card class="rounded-lg" variant="flat" style="background-color: transparent;">
<v-card-text class="pa-0">
<ComponentPanel :active="activeTab === 'components'" />
</v-card-text>
</v-card>
</v-tab-item>
<!-- 已安装的 MCP 服务器标签页内容 -->
<v-tab-item v-show="activeTab === 'mcp'">
<v-card class="rounded-lg" variant="flat" style="background-color: transparent;">
<v-card-text class="pa-0">
<McpServersSection />
</v-card-text>
</v-card>
</v-tab-item>
<!-- 插件市场标签页内容 --> <!-- 插件市场标签页内容 -->
<v-tab-item v-show="activeTab === 'market'"> <v-tab-item v-show="activeTab === 'market'">
@@ -1544,6 +1603,34 @@ watch(marketSearch, (newVal) => {
<!-- 卸载插件确认对话框列表模式用 --> <!-- 卸载插件确认对话框列表模式用 -->
<UninstallConfirmDialog v-model="showUninstallDialog" @confirm="handleUninstallConfirm" /> <UninstallConfirmDialog v-model="showUninstallDialog" @confirm="handleUninstallConfirm" />
<!-- 指令冲突提示对话框 -->
<v-dialog v-model="conflictDialog.show" max-width="420">
<v-card class="rounded-lg">
<v-card-title class="d-flex align-center pa-4">
<v-icon color="warning" class="mr-2">mdi-alert-circle</v-icon>
{{ tm('conflicts.title') }}
</v-card-title>
<v-card-text class="px-4 pb-2">
<div class="d-flex align-center mb-3">
<v-chip color="warning" variant="tonal" size="large" class="font-weight-bold">
{{ conflictDialog.count }}
</v-chip>
<span class="ml-2 text-body-1">{{ tm('conflicts.pairs') }}</span>
</div>
<p class="text-body-2" style="color: rgba(var(--v-theme-on-surface), 0.7);">
{{ tm('conflicts.message') }}
</p>
</v-card-text>
<v-card-actions class="pa-4 pt-2">
<v-spacer></v-spacer>
<v-btn variant="text" @click="conflictDialog.show = false">{{ tm('conflicts.later') }}</v-btn>
<v-btn color="warning" variant="flat" @click="handleConflictConfirm">
{{ tm('conflicts.goToManage') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 危险插件确认对话框 --> <!-- 危险插件确认对话框 -->
<v-dialog v-model="dangerConfirmDialog" width="500" persistent> <v-dialog v-model="dangerConfirmDialog" width="500" persistent>
<v-card> <v-card>
@@ -71,6 +71,7 @@ class AdminCommands:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。")) event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
async def update_dashboard(self, event: AstrMessageEvent): async def update_dashboard(self, event: AstrMessageEvent):
"""更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板...")) await event.send(MessageChain().message("正在尝试更新管理面板..."))
await download_dashboard(version=f"v{VERSION}", latest=False) await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成。")) await event.send(MessageChain().message("管理面板更新完成。"))
+53 -28
View File
@@ -3,6 +3,7 @@ import aiohttp
from astrbot.api import star from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
from astrbot.core.star import command_management
from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.io import get_dashboard_version
@@ -21,6 +22,46 @@ class HelpCommand:
except BaseException: except BaseException:
return "" 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): async def help(self, event: AstrMessageEvent):
"""查看帮助""" """查看帮助"""
notice = "" notice = ""
@@ -30,34 +71,18 @@ class HelpCommand:
pass pass
dashboard_version = await get_dashboard_version() 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}) msg_parts = [
内置指令: f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
[System] "内置指令:",
/plugin: 查看插件插件帮助 commands_section,
/t2i: 开关文本转图片 ]
/tts: 开关文本转语音 if notice:
/sid: 获取会话 ID msg_parts.append(notice)
/op: 管理员 msg = "\n".join(msg_parts)
/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}"""
event.set_result(MessageEventResult().message(msg).use_t2i(False)) event.set_result(MessageEventResult().message(msg).use_t2i(False))
+3 -2
View File
@@ -49,7 +49,7 @@ class Main(star.Star):
@filter.command_group("tool") @filter.command_group("tool")
def tool(self): def tool(self):
pass """函数工具管理"""
@tool.command("ls") @tool.command("ls")
async def tool_ls(self, event: AstrMessageEvent): async def tool_ls(self, event: AstrMessageEvent):
@@ -73,7 +73,7 @@ class Main(star.Star):
@filter.command_group("plugin") @filter.command_group("plugin")
def plugin(self): def plugin(self):
pass """插件管理"""
@plugin.command("ls") @plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent): async def plugin_ls(self, event: AstrMessageEvent):
@@ -219,6 +219,7 @@ class Main(star.Star):
@filter.permission_type(filter.PermissionType.ADMIN) @filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update") @filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent): async def update_dashboard(self, event: AstrMessageEvent):
"""更新管理面板"""
await self.admin_c.update_dashboard(event) await self.admin_c.update_dashboard(event)
@filter.command("set") @filter.command("set")
+1 -1
View File
@@ -249,7 +249,7 @@ class Main(star.Star):
@filter.command_group("pi") @filter.command_group("pi")
def pi(self): def pi(self):
pass """代码执行器配置"""
@pi.command("absdir") @pi.command("absdir")
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""): async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
+1 -1
View File
@@ -179,7 +179,7 @@ class Main(star.Star):
@filter.command_group("reminder") @filter.command_group("reminder")
def reminder(self): def reminder(self):
"""The command group of the reminder.""" """待办提醒"""
async def get_upcoming_reminders(self, unified_msg_origin: str): async def get_upcoming_reminders(self, unified_msg_origin: str):
"""Get upcoming reminders.""" """Get upcoming reminders."""
+1
View File
@@ -185,6 +185,7 @@ class Main(star.Star):
@filter.command("websearch") @filter.command("websearch")
async def websearch(self, event: AstrMessageEvent, oper: str | None = None): async def websearch(self, event: AstrMessageEvent, oper: str | None = None):
"""网页搜索指令(已废弃)"""
event.set_result( event.set_result(
MessageEventResult().message( MessageEventResult().message(
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。", "此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
+28
View File
@@ -160,6 +160,34 @@ async def test_plugins(app: Quart, authenticated_header: dict):
assert exists is False, "插件 astrbot_plugin_essential 未成功卸载" 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 @pytest.mark.asyncio
async def test_check_update(app: Quart, authenticated_header: dict): async def test_check_update(app: Quart, authenticated_header: dict):
test_client = app.test_client() test_client = app.test_client()