diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index d1d7ca339..f403da773 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -36,7 +36,7 @@ jobs: zip -r dist.zip dist - name: Archive production artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: dist-without-markdown path: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e261bfa3..47404d563 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,20 @@ - 请使用英文描述您的 PR。 - 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`。 +#### 代码规范 + +##### Core + +我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范: + +```bash +ruff format . +ruff check . +``` + +如果您使用 VSCode,可以安装 `Ruff` 插件。 + + ## Contributing Guide First off, thanks for taking the time to contribute! ❤️ @@ -62,4 +76,15 @@ We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features. #### PR Description - Please use English to describe your PR. -- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`. \ No newline at end of file +- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`. + +#### Code Style + +##### Core + +We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines: + +```bash +ruff format . +ruff check . +``` diff --git a/README.md b/README.md index a45fa1fc5..46254b2b4 100644 --- a/README.md +++ b/README.md @@ -243,4 +243,10 @@ pre-commit install +
+ _私は、高性能ですから!_ + + +
list[CommandConfig]: + """Get all stored command configurations.""" + ... + + @abc.abstractmethod + async def get_command_config(self, handler_full_name: str) -> CommandConfig | None: + """Fetch a single command configuration by handler.""" + ... + + @abc.abstractmethod + async def upsert_command_config( + self, + handler_full_name: str, + plugin_name: str, + module_path: str, + original_command: str, + *, + resolved_command: str | None = None, + enabled: bool | None = None, + keep_original_alias: bool | None = None, + conflict_key: str | None = None, + resolution_strategy: str | None = None, + note: str | None = None, + extra_data: dict | None = None, + auto_managed: bool | None = None, + ) -> CommandConfig: + """Create or update a command configuration.""" + ... + + @abc.abstractmethod + async def delete_command_config(self, handler_full_name: str) -> None: + """Delete a single command configuration.""" + ... + + @abc.abstractmethod + async def delete_command_configs(self, handler_full_names: list[str]) -> None: + """Bulk delete command configurations.""" + ... + + @abc.abstractmethod + async def list_command_conflicts( + self, + status: str | None = None, + ) -> list[CommandConflict]: + """List recorded command conflict entries.""" + ... + + @abc.abstractmethod + async def upsert_command_conflict( + self, + conflict_key: str, + handler_full_name: str, + plugin_name: str, + *, + status: str | None = None, + resolution: str | None = None, + resolved_command: str | None = None, + note: str | None = None, + extra_data: dict | None = None, + auto_generated: bool | None = None, + ) -> CommandConflict: + """Create or update a conflict record.""" + ... + + @abc.abstractmethod + async def delete_command_conflicts(self, ids: list[int]) -> None: + """Delete conflict records.""" + ... + # @abc.abstractmethod # async def insert_llm_message( # self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 34b301c92..64bcf4ce3 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -234,6 +234,65 @@ class Attachment(SQLModel, table=True): ) +class CommandConfig(SQLModel, table=True): + """Per-command configuration overrides for dashboard management.""" + + __tablename__ = "command_configs" # type: ignore + + handler_full_name: str = Field( + primary_key=True, + max_length=512, + ) + plugin_name: str = Field(nullable=False, max_length=255) + module_path: str = Field(nullable=False, max_length=255) + original_command: str = Field(nullable=False, max_length=255) + resolved_command: str | None = Field(default=None, max_length=255) + enabled: bool = Field(default=True, nullable=False) + keep_original_alias: bool = Field(default=False, nullable=False) + conflict_key: str | None = Field(default=None, max_length=255) + resolution_strategy: str | None = Field(default=None, max_length=64) + note: str | None = Field(default=None, sa_type=Text) + extra_data: dict | None = Field(default=None, sa_type=JSON) + auto_managed: bool = Field(default=False, nullable=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + +class CommandConflict(SQLModel, table=True): + """Conflict tracking for duplicated command names.""" + + __tablename__ = "command_conflicts" # type: ignore + + id: int | None = Field( + default=None, primary_key=True, sa_column_kwargs={"autoincrement": True} + ) + conflict_key: str = Field(nullable=False, max_length=255) + handler_full_name: str = Field(nullable=False, max_length=512) + plugin_name: str = Field(nullable=False, max_length=255) + status: str = Field(default="pending", max_length=32) + resolution: str | None = Field(default=None, max_length=64) + resolved_command: str | None = Field(default=None, max_length=255) + note: str | None = Field(default=None, sa_type=Text) + extra_data: dict | None = Field(default=None, sa_type=JSON) + auto_generated: bool = Field(default=False, nullable=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "conflict_key", + "handler_full_name", + name="uix_conflict_handler", + ), + ) + + @dataclass class Conversation: """LLM 对话类 diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 033d076c8..fa3ca9a76 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -1,6 +1,7 @@ import asyncio import threading import typing as T +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone from sqlalchemy import CursorResult @@ -10,6 +11,8 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update from astrbot.core.db import BaseDatabase from astrbot.core.db.po import ( Attachment, + CommandConfig, + CommandConflict, ConversationV2, Persona, PlatformMessageHistory, @@ -26,6 +29,7 @@ from astrbot.core.db.po import ( ) NOT_GIVEN = T.TypeVar("NOT_GIVEN") +TxResult = T.TypeVar("TxResult") class SQLiteDatabase(BaseDatabase): @@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase): ) await session.commit() + # ==== + # Command Configuration & Conflict Tracking + # ==== + + async def _run_in_tx( + self, + fn: Callable[[AsyncSession], Awaitable[TxResult]], + ) -> TxResult: + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + return await fn(session) + + @staticmethod + def _apply_updates(model, **updates) -> None: + for field, value in updates.items(): + if value is not None: + setattr(model, field, value) + + @staticmethod + def _new_command_config( + handler_full_name: str, + plugin_name: str, + module_path: str, + original_command: str, + *, + resolved_command: str | None = None, + enabled: bool | None = None, + keep_original_alias: bool | None = None, + conflict_key: str | None = None, + resolution_strategy: str | None = None, + note: str | None = None, + extra_data: dict | None = None, + auto_managed: bool | None = None, + ) -> CommandConfig: + return CommandConfig( + handler_full_name=handler_full_name, + plugin_name=plugin_name, + module_path=module_path, + original_command=original_command, + resolved_command=resolved_command, + enabled=True if enabled is None else enabled, + keep_original_alias=False + if keep_original_alias is None + else keep_original_alias, + conflict_key=conflict_key or original_command, + resolution_strategy=resolution_strategy, + note=note, + extra_data=extra_data, + auto_managed=bool(auto_managed), + ) + + @staticmethod + def _new_command_conflict( + conflict_key: str, + handler_full_name: str, + plugin_name: str, + *, + status: str | None = None, + resolution: str | None = None, + resolved_command: str | None = None, + note: str | None = None, + extra_data: dict | None = None, + auto_generated: bool | None = None, + ) -> CommandConflict: + return CommandConflict( + conflict_key=conflict_key, + handler_full_name=handler_full_name, + plugin_name=plugin_name, + status=status or "pending", + resolution=resolution, + resolved_command=resolved_command, + note=note, + extra_data=extra_data, + auto_generated=bool(auto_generated), + ) + + async def get_command_configs(self) -> list[CommandConfig]: + async with self.get_db() as session: + session: AsyncSession + result = await session.execute(select(CommandConfig)) + return list(result.scalars().all()) + + async def get_command_config( + self, + handler_full_name: str, + ) -> CommandConfig | None: + async with self.get_db() as session: + session: AsyncSession + return await session.get(CommandConfig, handler_full_name) + + async def upsert_command_config( + self, + handler_full_name: str, + plugin_name: str, + module_path: str, + original_command: str, + *, + resolved_command: str | None = None, + enabled: bool | None = None, + keep_original_alias: bool | None = None, + conflict_key: str | None = None, + resolution_strategy: str | None = None, + note: str | None = None, + extra_data: dict | None = None, + auto_managed: bool | None = None, + ) -> CommandConfig: + async def _op(session: AsyncSession) -> CommandConfig: + config = await session.get(CommandConfig, handler_full_name) + if not config: + config = self._new_command_config( + handler_full_name, + plugin_name, + module_path, + original_command, + resolved_command=resolved_command, + enabled=enabled, + keep_original_alias=keep_original_alias, + conflict_key=conflict_key, + resolution_strategy=resolution_strategy, + note=note, + extra_data=extra_data, + auto_managed=auto_managed, + ) + session.add(config) + else: + self._apply_updates( + config, + plugin_name=plugin_name, + module_path=module_path, + original_command=original_command, + resolved_command=resolved_command, + enabled=enabled, + keep_original_alias=keep_original_alias, + conflict_key=conflict_key, + resolution_strategy=resolution_strategy, + note=note, + extra_data=extra_data, + auto_managed=auto_managed, + ) + await session.flush() + await session.refresh(config) + return config + + return await self._run_in_tx(_op) + + async def delete_command_config(self, handler_full_name: str) -> None: + await self.delete_command_configs([handler_full_name]) + + async def delete_command_configs(self, handler_full_names: list[str]) -> None: + if not handler_full_names: + return + + async def _op(session: AsyncSession) -> None: + await session.execute( + delete(CommandConfig).where( + col(CommandConfig.handler_full_name).in_(handler_full_names), + ), + ) + + await self._run_in_tx(_op) + + async def list_command_conflicts( + self, + status: str | None = None, + ) -> list[CommandConflict]: + async with self.get_db() as session: + session: AsyncSession + query = select(CommandConflict) + if status: + query = query.where(CommandConflict.status == status) + result = await session.execute(query) + return list(result.scalars().all()) + + async def upsert_command_conflict( + self, + conflict_key: str, + handler_full_name: str, + plugin_name: str, + *, + status: str | None = None, + resolution: str | None = None, + resolved_command: str | None = None, + note: str | None = None, + extra_data: dict | None = None, + auto_generated: bool | None = None, + ) -> CommandConflict: + async def _op(session: AsyncSession) -> CommandConflict: + result = await session.execute( + select(CommandConflict).where( + CommandConflict.conflict_key == conflict_key, + CommandConflict.handler_full_name == handler_full_name, + ), + ) + record = result.scalar_one_or_none() + if not record: + record = self._new_command_conflict( + conflict_key, + handler_full_name, + plugin_name, + status=status, + resolution=resolution, + resolved_command=resolved_command, + note=note, + extra_data=extra_data, + auto_generated=auto_generated, + ) + session.add(record) + else: + self._apply_updates( + record, + plugin_name=plugin_name, + status=status, + resolution=resolution, + resolved_command=resolved_command, + note=note, + extra_data=extra_data, + auto_generated=auto_generated, + ) + await session.flush() + await session.refresh(record) + return record + + return await self._run_in_tx(_op) + + async def delete_command_conflicts(self, ids: list[int]) -> None: + if not ids: + return + + async def _op(session: AsyncSession) -> None: + await session.execute( + delete(CommandConflict).where(col(CommandConflict.id).in_(ids)), + ) + + await self._run_in_tx(_op) + # ==== # Deprecated Methods # ==== diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 8f1b87efc..60ab168b3 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -119,7 +119,7 @@ class RespondStage(Stage): if (result := event.get_result()) is None: return False - if self.only_llm_result and result.is_llm_result(): + if self.only_llm_result and not result.is_llm_result(): return False if event.get_platform_name() in [ @@ -158,7 +158,11 @@ class RespondStage(Stage): result = event.get_result() if result is None: return + if event.get_extra("_streaming_finished", False): + # prevent some plugin make result content type to LLM_RESULT after streaming finished, lead to send again + return if result.result_content_type == ResultContentType.STREAMING_FINISH: + event.set_extra("_streaming_finished", True) return logger.info( diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 208f3a9f2..7647ef022 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -1,3 +1,4 @@ +import random import re import time import traceback @@ -42,6 +43,18 @@ class ResultDecorateStage(Stage): "forward_threshold" ] + trigger_probability = ctx.astrbot_config["provider_tts_settings"].get( + "trigger_probability", + 1, + ) + try: + self.tts_trigger_probability = max( + 0.0, + min(float(trigger_probability), 1.0), + ) + except (TypeError, ValueError): + self.tts_trigger_probability = 1.0 + # 分段回复 self.words_count_threshold = int( ctx.astrbot_config["platform_settings"]["segmented_reply"][ @@ -246,7 +259,14 @@ class ResultDecorateStage(Stage): and result.is_llm_result() and SessionServiceManager.should_process_tts_request(event) ): - if not tts_provider: + should_tts = self.tts_trigger_probability >= 1.0 or ( + self.tts_trigger_probability > 0.0 + and random.random() <= self.tts_trigger_probability + ) + + if not should_tts: + logger.debug("跳过 TTS:触发概率未命中。") + elif not tts_provider: logger.warning( f"会话 {event.unified_msg_origin} 未配置文本转语音模型。", ) diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index 883d81217..08df1f359 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -81,7 +81,12 @@ class LarkPlatformAdapter(Platform): ) self.lark_api = ( - lark.Client.builder().app_id(self.appid).app_secret(self.appsecret).build() + lark.Client.builder() + .app_id(self.appid) + .app_secret(self.appsecret) + .log_level(lark.LogLevel.ERROR) + .domain(self.domain) + .build() ) self.webhook_server = None diff --git a/astrbot/core/star/__init__.py b/astrbot/core/star/__init__.py index e27db7405..c474962c5 100644 --- a/astrbot/core/star/__init__.py +++ b/astrbot/core/star/__init__.py @@ -2,15 +2,19 @@ from astrbot.core import html_renderer from astrbot.core.provider import Provider from astrbot.core.star.star_tools import StarTools from astrbot.core.utils.command_parser import CommandParserMixin +from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin from .context import Context from .star import StarMetadata, star_map, star_registry from .star_manager import PluginManager -class Star(CommandParserMixin): +class Star(CommandParserMixin, PluginKVStoreMixin): """所有插件(Star)的父类,所有插件都应该继承于这个类""" + author: str + name: str + def __init__(self, context: Context, config: dict | None = None): StarTools.initialize(context) self.context = context diff --git a/astrbot/core/star/command_management.py b/astrbot/core/star/command_management.py new file mode 100644 index 000000000..a0b125d33 --- /dev/null +++ b/astrbot/core/star/command_management.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Any + +from astrbot.core import db_helper +from astrbot.core.db.po import CommandConfig +from astrbot.core.star.filter.command import CommandFilter +from astrbot.core.star.filter.command_group import CommandGroupFilter +from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter +from astrbot.core.star.star import star_map +from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry + + +@dataclass +class CommandDescriptor: + handler: StarHandlerMetadata = field(repr=False) + filter_ref: CommandFilter | CommandGroupFilter | None = field( + default=None, + repr=False, + ) + handler_full_name: str = "" + handler_name: str = "" + plugin_name: str = "" + plugin_display_name: str | None = None + module_path: str = "" + description: str = "" + command_type: str = "command" # "command" | "group" | "sub_command" + raw_command_name: str | None = None + current_fragment: str | None = None + parent_signature: str = "" + parent_group_handler: str = "" + original_command: str | None = None + effective_command: str | None = None + aliases: list[str] = field(default_factory=list) + permission: str = "everyone" + enabled: bool = True + is_group: bool = False + is_sub_command: bool = False + reserved: bool = False + config: CommandConfig | None = None + has_conflict: bool = False + sub_commands: list[CommandDescriptor] = field(default_factory=list) + + +async def sync_command_configs() -> None: + """同步指令配置,清理过期配置。""" + descriptors = _collect_descriptors(include_sub_commands=False) + config_records = await db_helper.get_command_configs() + config_map = _bind_configs_to_descriptors(descriptors, config_records) + live_handlers = {desc.handler_full_name for desc in descriptors} + + stale_configs = [key for key in config_map if key not in live_handlers] + if stale_configs: + await db_helper.delete_command_configs(stale_configs) + + +async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor: + descriptor = _build_descriptor_by_full_name(handler_full_name) + if not descriptor: + raise ValueError("指定的处理函数不存在或不是指令。") + + existing_cfg = await db_helper.get_command_config(handler_full_name) + config = await db_helper.upsert_command_config( + handler_full_name=handler_full_name, + plugin_name=descriptor.plugin_name or "", + module_path=descriptor.module_path, + original_command=descriptor.original_command or descriptor.handler_name, + resolved_command=( + existing_cfg.resolved_command + if existing_cfg + else descriptor.current_fragment + ), + enabled=enabled, + keep_original_alias=False, + conflict_key=existing_cfg.conflict_key + if existing_cfg and existing_cfg.conflict_key + else descriptor.original_command, + resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None, + note=existing_cfg.note if existing_cfg else None, + extra_data=existing_cfg.extra_data if existing_cfg else None, + auto_managed=False, + ) + _bind_descriptor_with_config(descriptor, config) + await sync_command_configs() + return descriptor + + +async def rename_command( + handler_full_name: str, + new_fragment: str, +) -> CommandDescriptor: + descriptor = _build_descriptor_by_full_name(handler_full_name) + if not descriptor: + raise ValueError("指定的处理函数不存在或不是指令。") + + new_fragment = new_fragment.strip() + if not new_fragment: + raise ValueError("指令名不能为空。") + + candidate_full = _compose_command(descriptor.parent_signature, new_fragment) + if _is_command_in_use(handler_full_name, candidate_full): + raise ValueError("新的指令名已被其他指令占用,请换一个名称。") + + config = await db_helper.upsert_command_config( + handler_full_name=handler_full_name, + plugin_name=descriptor.plugin_name or "", + module_path=descriptor.module_path, + original_command=descriptor.original_command or descriptor.handler_name, + resolved_command=new_fragment, + enabled=True if descriptor.enabled else False, + keep_original_alias=False, + conflict_key=descriptor.original_command, + resolution_strategy="manual_rename", + note=None, + extra_data=None, + auto_managed=False, + ) + _bind_descriptor_with_config(descriptor, config) + + await sync_command_configs() + return descriptor + + +async def list_commands() -> list[dict[str, Any]]: + descriptors = _collect_descriptors(include_sub_commands=True) + config_records = await db_helper.get_command_configs() + _bind_configs_to_descriptors(descriptors, config_records) + + conflict_groups = _group_conflicts(descriptors) + conflict_handler_names: set[str] = { + d.handler_full_name for group in conflict_groups.values() for d in group + } + + # 分类,设置冲突标志,将子指令挂载到父指令组 + group_map: dict[str, CommandDescriptor] = {} + sub_commands: list[CommandDescriptor] = [] + root_commands: list[CommandDescriptor] = [] + + for desc in descriptors: + desc.has_conflict = desc.handler_full_name in conflict_handler_names + if desc.is_group: + group_map[desc.handler_full_name] = desc + elif desc.is_sub_command: + sub_commands.append(desc) + else: + root_commands.append(desc) + + for sub in sub_commands: + if sub.parent_group_handler and sub.parent_group_handler in group_map: + group_map[sub.parent_group_handler].sub_commands.append(sub) + else: + root_commands.append(sub) + + # 指令组 + 普通指令,按 effective_command 字母排序 + all_commands = list(group_map.values()) + root_commands + all_commands.sort(key=lambda d: (d.effective_command or "").lower()) + + result = [_descriptor_to_dict(desc) for desc in all_commands] + return result + + +async def list_command_conflicts() -> list[dict[str, Any]]: + """列出所有冲突的指令组。""" + descriptors = _collect_descriptors(include_sub_commands=False) + config_records = await db_helper.get_command_configs() + _bind_configs_to_descriptors(descriptors, config_records) + + conflict_groups = _group_conflicts(descriptors) + details = [ + { + "conflict_key": key, + "handlers": [ + { + "handler_full_name": item.handler_full_name, + "plugin": item.plugin_name, + "current_name": item.effective_command, + } + for item in group + ], + } + for key, group in conflict_groups.items() + ] + return details + + +# Internal helpers ---------------------------------------------------------- + + +def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]: + """收集指令,按需包含子指令。""" + descriptors: list[CommandDescriptor] = [] + for handler in star_handlers_registry: + desc = _build_descriptor(handler) + if not desc: + continue + if not include_sub_commands and desc.is_sub_command: + continue + descriptors.append(desc) + return descriptors + + +def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None: + filter_ref = _locate_primary_filter(handler) + if filter_ref is None: + return None + + plugin_meta = star_map.get(handler.handler_module_path) + plugin_name = ( + plugin_meta.name if plugin_meta else None + ) or handler.handler_module_path + plugin_display = plugin_meta.display_name if plugin_meta else None + + is_sub_command = bool(handler.extras_configs.get("sub_command")) + parent_group_handler = "" + + if isinstance(filter_ref, CommandFilter): + raw_fragment = getattr( + filter_ref, "_original_command_name", filter_ref.command_name + ) + current_fragment = filter_ref.command_name + parent_signature = (filter_ref.parent_command_names or [""])[0].strip() + # 如果是子指令,尝试找到父指令组的 handler_full_name + if is_sub_command and parent_signature: + parent_group_handler = _find_parent_group_handler( + handler.handler_module_path, parent_signature + ) + else: + raw_fragment = getattr( + filter_ref, "_original_group_name", filter_ref.group_name + ) + current_fragment = filter_ref.group_name + parent_signature = _resolve_group_parent_signature(filter_ref) + + original_command = _compose_command(parent_signature, raw_fragment) + effective_command = _compose_command(parent_signature, current_fragment) + + # 确定 command_type + if isinstance(filter_ref, CommandGroupFilter): + command_type = "group" + elif is_sub_command: + command_type = "sub_command" + else: + command_type = "command" + + descriptor = CommandDescriptor( + handler=handler, + filter_ref=filter_ref, + handler_full_name=handler.handler_full_name, + handler_name=handler.handler_name, + plugin_name=plugin_name, + plugin_display_name=plugin_display, + module_path=handler.handler_module_path, + description=handler.desc or "", + command_type=command_type, + raw_command_name=raw_fragment, + current_fragment=current_fragment, + parent_signature=parent_signature, + parent_group_handler=parent_group_handler, + original_command=original_command, + effective_command=effective_command, + aliases=sorted(getattr(filter_ref, "alias", set())), + permission=_determine_permission(handler), + enabled=handler.enabled, + is_group=isinstance(filter_ref, CommandGroupFilter), + is_sub_command=is_sub_command, + reserved=plugin_meta.reserved if plugin_meta else False, + ) + return descriptor + + +def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None: + handler = star_handlers_registry.get_handler_by_full_name(full_name) + if not handler: + return None + return _build_descriptor(handler) + + +def _locate_primary_filter( + handler: StarHandlerMetadata, +) -> CommandFilter | CommandGroupFilter | None: + for filter_ref in handler.event_filters: + if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)): + return filter_ref + return None + + +def _determine_permission(handler: StarHandlerMetadata) -> str: + for filter_ref in handler.event_filters: + if isinstance(filter_ref, PermissionTypeFilter): + return ( + "admin" + if filter_ref.permission_type == PermissionType.ADMIN + else "member" + ) + return "everyone" + + +def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str: + signatures: list[str] = [] + parent = group_filter.parent_group + while parent: + signatures.append(getattr(parent, "_original_group_name", parent.group_name)) + parent = parent.parent_group + return " ".join(reversed(signatures)).strip() + + +def _find_parent_group_handler(module_path: str, parent_signature: str) -> str: + """根据模块路径和父级签名,找到对应的指令组 handler_full_name。""" + parent_sig_normalized = parent_signature.strip() + for handler in star_handlers_registry: + if handler.handler_module_path != module_path: + continue + filter_ref = _locate_primary_filter(handler) + if not isinstance(filter_ref, CommandGroupFilter): + continue + # 检查该指令组的完整指令名是否匹配 parent_signature + group_names = filter_ref.get_complete_command_names() + if parent_sig_normalized in group_names: + return handler.handler_full_name + return "" + + +def _compose_command(parent_signature: str, fragment: str | None) -> str: + fragment = (fragment or "").strip() + parent_signature = parent_signature.strip() + if not parent_signature: + return fragment + if not fragment: + return parent_signature + return f"{parent_signature} {fragment}" + + +def _bind_descriptor_with_config( + descriptor: CommandDescriptor, + config: CommandConfig, +) -> None: + _apply_config_to_descriptor(descriptor, config) + _apply_config_to_runtime(descriptor, config) + + +def _apply_config_to_descriptor( + descriptor: CommandDescriptor, + config: CommandConfig, +) -> None: + descriptor.config = config + descriptor.enabled = config.enabled + + if config.original_command: + descriptor.original_command = config.original_command + + new_fragment = config.resolved_command or descriptor.current_fragment + descriptor.current_fragment = new_fragment + descriptor.effective_command = _compose_command( + descriptor.parent_signature, + new_fragment, + ) + + +def _apply_config_to_runtime( + descriptor: CommandDescriptor, + config: CommandConfig, +) -> None: + descriptor.handler.enabled = config.enabled + if descriptor.filter_ref and descriptor.current_fragment: + _set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment) + + +def _bind_configs_to_descriptors( + descriptors: list[CommandDescriptor], + config_records: list[CommandConfig], +) -> dict[str, CommandConfig]: + config_map = {cfg.handler_full_name: cfg for cfg in config_records} + for desc in descriptors: + if cfg := config_map.get(desc.handler_full_name): + _bind_descriptor_with_config(desc, cfg) + return config_map + + +def _group_conflicts( + descriptors: list[CommandDescriptor], +) -> dict[str, list[CommandDescriptor]]: + conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list) + for desc in descriptors: + if desc.effective_command and desc.enabled: + conflicts[desc.effective_command].append(desc) + return {k: v for k, v in conflicts.items() if len(v) > 1} + + +def _set_filter_fragment( + filter_ref: CommandFilter | CommandGroupFilter, + fragment: str, +) -> None: + attr = ( + "group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name" + ) + current_value = getattr(filter_ref, attr) + if fragment == current_value: + return + setattr(filter_ref, attr, fragment) + if hasattr(filter_ref, "_cmpl_cmd_names"): + filter_ref._cmpl_cmd_names = None + + +def _is_command_in_use( + target_handler_full_name: str, + candidate_full_command: str, +) -> bool: + candidate = candidate_full_command.strip() + for handler in star_handlers_registry: + if handler.handler_full_name == target_handler_full_name: + continue + filter_ref = _locate_primary_filter(handler) + if not filter_ref: + continue + names = {name.strip() for name in filter_ref.get_complete_command_names()} + if candidate in names: + return True + return False + + +def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]: + result = { + "handler_full_name": desc.handler_full_name, + "handler_name": desc.handler_name, + "plugin": desc.plugin_name, + "plugin_display_name": desc.plugin_display_name, + "module_path": desc.module_path, + "description": desc.description, + "type": desc.command_type, + "parent_signature": desc.parent_signature, + "parent_group_handler": desc.parent_group_handler, + "original_command": desc.original_command, + "current_fragment": desc.current_fragment, + "effective_command": desc.effective_command, + "aliases": desc.aliases, + "permission": desc.permission, + "enabled": desc.enabled, + "is_group": desc.is_group, + "has_conflict": desc.has_conflict, + "reserved": desc.reserved, + } + # 如果是指令组,包含子指令列表 + if desc.is_group and desc.sub_commands: + result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands] + else: + result["sub_commands"] = [] + return result diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py index 2a9868fdc..51ad5f089 100755 --- a/astrbot/core/star/filter/command.py +++ b/astrbot/core/star/filter/command.py @@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter): ): self.command_name = command_name self.alias = alias if alias else set() + self._original_command_name = command_name self.parent_command_names = ( parent_command_names if parent_command_names is not None else [""] ) diff --git a/astrbot/core/star/filter/command_group.py b/astrbot/core/star/filter/command_group.py index e1c2efb22..4cbd2c007 100755 --- a/astrbot/core/star/filter/command_group.py +++ b/astrbot/core/star/filter/command_group.py @@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter): ): self.group_name = group_name self.alias = alias if alias else set() + self._original_group_name = group_name self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = [] self.custom_filter_list: list[CustomFilter] = [] self.parent_group = parent_group diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index da59cd291..be5b4679f 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]): # 过滤事件类型 if handler.event_type != event_type: continue + if not handler.enabled: + continue # 过滤启用状态 if only_activated: plugin = star_map.get(handler.handler_module_path) @@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]): extras_configs: dict = field(default_factory=dict) """插件注册的一些其他的信息, 如 priority 等""" + enabled: bool = True + def __lt__(self, other: StarHandlerMetadata): """定义小于运算符以支持优先队列""" return self.extras_configs.get("priority", 0) < other.extras_configs.get( diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index abdedc249..1f9f95ae5 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import ( from astrbot.core.utils.io import remove_dir from . import StarMetadata +from .command_management import sync_command_configs from .context import Context from .filter.permission import PermissionType, PermissionTypeFilter from .star import star_map, star_registry @@ -467,6 +468,18 @@ class PluginManager: metadata.star_cls = metadata.star_cls_type( context=self.context, ) + + p_name = (metadata.name or "unknown").lower().replace("/", "_") + p_author = ( + (metadata.author or "unknown").lower().replace("/", "_") + ) + setattr(metadata.star_cls, "name", p_name) + setattr(metadata.star_cls, "author", p_author) + setattr( + metadata.star_cls, + "plugin_id", + f"{p_author}/{p_name}", + ) else: logger.info(f"插件 {metadata.name} 已被禁用。") @@ -618,6 +631,7 @@ class PluginManager: # 清除 pip.main 导致的多余的 logging handlers for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) + await sync_command_configs() if not fail_rec: return True, None diff --git a/astrbot/core/utils/plugin_kv_store.py b/astrbot/core/utils/plugin_kv_store.py new file mode 100644 index 000000000..88460c8e1 --- /dev/null +++ b/astrbot/core/utils/plugin_kv_store.py @@ -0,0 +1,28 @@ +from typing import TypeVar + +from astrbot.core import sp + +SUPPORTED_VALUE_TYPES = int | float | str | bytes | bool | dict | list | None +_VT = TypeVar("_VT") + + +class PluginKVStoreMixin: + """为插件提供键值存储功能的 Mixin 类""" + + plugin_id: str + + async def put_kv_data( + self, + key: str, + value: SUPPORTED_VALUE_TYPES, + ) -> None: + """为指定插件存储一个键值对""" + await sp.put_async("plugin", self.plugin_id, key, value) + + async def get_kv_data(self, key: str, default: _VT) -> _VT | None: + """获取指定插件存储的键值对""" + return await sp.get_async("plugin", self.plugin_id, key, default) + + async def delete_kv_data(self, key: str) -> None: + """删除指定插件存储的键值对""" + await sp.remove_async("plugin", self.plugin_id, key) diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index 514e6d6ed..951db956c 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -1,5 +1,6 @@ from .auth import AuthRoute from .chat import ChatRoute +from .command import CommandRoute from .config import ConfigRoute from .conversation import ConversationRoute from .file import FileRoute @@ -17,6 +18,7 @@ from .update import UpdateRoute __all__ = [ "AuthRoute", "ChatRoute", + "CommandRoute", "ConfigRoute", "ConversationRoute", "FileRoute", diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index cfb750803..f2439c058 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -338,8 +338,10 @@ class ChatRoute(Route): chain_type = result.get("chain_type", "normal") if chain_type == "reasoning": accumulated_reasoning += result_text - else: + elif streaming: accumulated_text += result_text + else: + accumulated_text = result_text elif msg_type == "image": filename = result_text.replace("[IMAGE]", "") part = await self._create_attachment_from_file( diff --git a/astrbot/dashboard/routes/command.py b/astrbot/dashboard/routes/command.py new file mode 100644 index 000000000..5cb267169 --- /dev/null +++ b/astrbot/dashboard/routes/command.py @@ -0,0 +1,82 @@ +from quart import request + +from astrbot.core.star.command_management import ( + list_command_conflicts, + list_commands, +) +from astrbot.core.star.command_management import ( + rename_command as rename_command_service, +) +from astrbot.core.star.command_management import ( + toggle_command as toggle_command_service, +) + +from .route import Response, Route, RouteContext + + +class CommandRoute(Route): + def __init__(self, context: RouteContext) -> None: + super().__init__(context) + self.routes = { + "/commands": ("GET", self.get_commands), + "/commands/conflicts": ("GET", self.get_conflicts), + "/commands/toggle": ("POST", self.toggle_command), + "/commands/rename": ("POST", self.rename_command), + } + self.register_routes() + + async def get_commands(self): + commands = await list_commands() + summary = { + "total": len(commands), + "disabled": len([cmd for cmd in commands if not cmd["enabled"]]), + "conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]), + } + return Response().ok({"items": commands, "summary": summary}).__dict__ + + async def get_conflicts(self): + conflicts = await list_command_conflicts() + return Response().ok(conflicts).__dict__ + + async def toggle_command(self): + data = await request.get_json() + handler_full_name = data.get("handler_full_name") + enabled = data.get("enabled") + + if handler_full_name is None or enabled is None: + return Response().error("handler_full_name 与 enabled 均为必填。").__dict__ + + if isinstance(enabled, str): + enabled = enabled.lower() in ("1", "true", "yes", "on") + + try: + await toggle_command_service(handler_full_name, bool(enabled)) + except ValueError as exc: + return Response().error(str(exc)).__dict__ + + payload = await _get_command_payload(handler_full_name) + return Response().ok(payload).__dict__ + + async def rename_command(self): + data = await request.get_json() + handler_full_name = data.get("handler_full_name") + new_name = data.get("new_name") + + if not handler_full_name or not new_name: + return Response().error("handler_full_name 与 new_name 均为必填。").__dict__ + + try: + await rename_command_service(handler_full_name, new_name) + except ValueError as exc: + return Response().error(str(exc)).__dict__ + + payload = await _get_command_payload(handler_full_name) + return Response().ok(payload).__dict__ + + +async def _get_command_payload(handler_full_name: str): + commands = await list_commands() + for cmd in commands: + if cmd["handler_full_name"] == handler_full_name: + return cmd + return {} diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index d19fdf793..513d3603f 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -1,7 +1,9 @@ import json import traceback +from datetime import datetime +from io import BytesIO -from quart import request +from quart import request, send_file from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle @@ -30,6 +32,7 @@ class ConversationRoute(Route): "POST", self.update_history, ), + "/conversation/export": ("POST", self.export_conversations), } self.db_helper = db_helper self.conv_mgr = core_lifecycle.conversation_manager @@ -283,3 +286,90 @@ class ConversationRoute(Route): except Exception as e: logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}") return Response().error(f"更新对话历史失败: {e!s}").__dict__ + + async def export_conversations(self): + """批量导出对话为 JSONL 格式""" + try: + data = await request.get_json() + conversations_to_export = data.get("conversations", []) + + if not conversations_to_export: + return Response().error("导出列表不能为空").__dict__ + + # 收集所有对话的内容 + jsonl_lines = [] + exported_count = 0 + failed_items = [] + + for conv_info in conversations_to_export: + user_id = conv_info.get("user_id") + cid = conv_info.get("cid") + + if not user_id or not cid: + failed_items.append( + f"user_id:{user_id}, cid:{cid} - 缺少必要参数", + ) + continue + + try: + conversation = await self.conv_mgr.get_conversation( + unified_msg_origin=user_id, + conversation_id=cid, + ) + + if not conversation: + failed_items.append( + f"user_id:{user_id}, cid:{cid} - 对话不存在" + ) + continue + + # 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1) + content = json.loads(conversation.history) + + # 创建导出记录 + export_record = { + "cid": cid, + "user_id": user_id, + "platform_id": conversation.platform_id, + "title": conversation.title, + "persona_id": conversation.persona_id, + "created_at": conversation.created_at, + "updated_at": conversation.updated_at, + "content": content, + } + + # 将记录转换为 JSON 字符串并添加到 JSONL + jsonl_lines.append(json.dumps(export_record, ensure_ascii=False)) + exported_count += 1 + + except Exception as e: + failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}") + logger.error( + f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}" + ) + + if exported_count == 0: + return Response().error("没有成功导出任何对话").__dict__ + + # 创建 JSONL 内容 + jsonl_content = "\n".join(jsonl_lines) + + # 创建一个内存文件对象 + file_obj = BytesIO(jsonl_content.encode("utf-8")) + file_obj.seek(0) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"astrbot_conversations_export_{timestamp}.jsonl" + + # 返回文件流 + return await send_file( + file_obj, + mimetype="application/jsonl", + as_attachment=True, + attachment_filename=filename, + ) + + except Exception as e: + logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"批量导出对话失败: {e!s}").__dict__ diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index c249b07b7..fd808c6c9 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -124,7 +124,11 @@ class PluginRoute(Route): session.get(url) as response, ): if response.status == 200: - remote_data = await response.json() + try: + remote_data = await response.json() + except aiohttp.ContentTypeError: + remote_text = await response.text() + remote_data = json.loads(remote_text) # 检查远程数据是否为空 if not remote_data or ( diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 64cd78caa..d7b082000 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -3,6 +3,7 @@ import traceback from quart import request from astrbot.core import logger +from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.star import star_map @@ -296,15 +297,30 @@ class ToolsRoute(Route): """获取所有注册的工具列表""" try: tools = self.tool_mgr.func_list - tools_dict = [ - { + tools_dict = [] + for tool in tools: + if isinstance(tool, MCPTool): + origin = "mcp" + origin_name = tool.mcp_server_name + elif tool.handler_module_path and star_map.get( + tool.handler_module_path + ): + star = star_map[tool.handler_module_path] + origin = "plugin" + origin_name = star.name + else: + origin = "unknown" + origin_name = "unknown" + + tool_info = { "name": tool.name, "description": tool.description, "parameters": tool.parameters, "active": tool.active, + "origin": origin, + "origin_name": origin_name, } - for tool in tools - ] + tools_dict.append(tool_info) return Response().ok(data=tools_dict).__dict__ except Exception as e: logger.error(traceback.format_exc()) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 09ec76b52..6d6530c90 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -67,6 +67,7 @@ class AstrBotDashboard: core_lifecycle, core_lifecycle.plugin_manager, ) + self.command_route = CommandRoute(self.context) self.cr = ConfigRoute(self.context, core_lifecycle) self.lr = LogRoute(self.context, core_lifecycle.log_broker) self.sfr = StaticFileRoute(self.context) diff --git a/changelogs/v4.9.1.md b/changelogs/v4.9.1.md new file mode 100644 index 000000000..f7e4c2e5c --- /dev/null +++ b/changelogs/v4.9.1.md @@ -0,0 +1,3 @@ +## What's Changed + +- \ No newline at end of file diff --git a/changelogs/v4.9.2.md b/changelogs/v4.9.2.md new file mode 100644 index 000000000..87538c6ca --- /dev/null +++ b/changelogs/v4.9.2.md @@ -0,0 +1,17 @@ +## What's Changed + +### 修复 + +- 企业自部署飞书(自定义 domain)可以接收消息但无法发送消息的问题。 +- 安装插件 Dialog 的深色样式问题。 + +### 优化 + +- 避免某些插件在流式响应结束后重d复发送消息的问题。 + +### 新增 + +- 支持在对话管理批量导出对话轨迹数据为 `jsonl` 格式文件。入口:WebUI -> 对话管理 -> 批量选中 -> 导出。 +- 支持对 TTS(文本转语音)设置概率触发。 +- (插件开发)支持在 schema 中对 float 和 int 类型设置 `slider` 滑块控件。例如 `slider: {min: 0, max: 1, step: 0.1}`。 +- (插件开发)支持 key-value 存储功能。例如使用 `await self.put_kv_data("key", value)`, `await self.get_kv_data("key", default_value)` 和 `await self.delete_kv_data("key")`。 \ No newline at end of file diff --git a/dashboard/src/components/chat/ProviderModelSelector.vue b/dashboard/src/components/chat/ProviderModelSelector.vue index 00a53adf7..5e4ad11aa 100644 --- a/dashboard/src/components/chat/ProviderModelSelector.vue +++ b/dashboard/src/components/chat/ProviderModelSelector.vue @@ -11,7 +11,7 @@ - + 选择提供商和模型 diff --git a/dashboard/src/views/ToolUsePage.vue b/dashboard/src/components/extension/McpServersSection.vue similarity index 62% rename from dashboard/src/views/ToolUsePage.vue rename to dashboard/src/components/extension/McpServersSection.vue index db8fee905..fe20497f8 100644 --- a/dashboard/src/views/ToolUsePage.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -4,42 +4,18 @@
-

- mdi-function-variant{{ tm('title') }} -

-

- {{ tm('subtitle') }} - - - {{ tm('tooltip.info') }} - -

-
-
- - {{ tm('functionTools.buttons.view') }}({{ tools.length }}) - + @click="showMcpServerDialog = true" > {{ tm('mcpServers.buttons.add') }} + > {{ tm('mcpServers.buttons.sync') }}
- - -
mdi-server-off

{{ tm('mcpServers.empty') }}

@@ -57,7 +33,6 @@
-
@@ -67,8 +42,7 @@ - -
@@ -105,8 +74,6 @@
- - @@ -183,8 +150,7 @@
- - + @@ -240,115 +206,8 @@ - - - - - {{ tm('functionTools.title') }} - {{ tools.length }} - - - -
-
- mdi-api-off -

{{ tm('functionTools.empty') }}

-
- -
- - - 复选框代表该工具是否被启用。 - - - - - - - - - -
- - {{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }} - - - {{ formatToolName(tool.name) }} - -
-
- - {{ tool.description }} - -
-
- - - - -

- mdi-information - {{ tm('functionTools.description') }} -

-

{{ tool.description }}

- - -
- mdi-code-brackets -

{{ tm('functionTools.noParameters') }}

-
-
-
-
-
-
-
-
-
-
- - - - - {{ tm('dialogs.serverDetail.buttons.close') }} - - -
-
- - + {{ save_message }} @@ -356,15 +215,13 @@ \ No newline at end of file + diff --git a/dashboard/src/components/extension/componentPanel/components/CommandFilters.vue b/dashboard/src/components/extension/componentPanel/components/CommandFilters.vue new file mode 100644 index 000000000..c4b212803 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/components/CommandFilters.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/dashboard/src/components/extension/componentPanel/components/CommandTable.vue b/dashboard/src/components/extension/componentPanel/components/CommandTable.vue new file mode 100644 index 000000000..f8bb6fa82 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/components/CommandTable.vue @@ -0,0 +1,257 @@ + + + + + + + + diff --git a/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue b/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue new file mode 100644 index 000000000..6d9188374 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue b/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue new file mode 100644 index 000000000..ffdc5a826 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/components/RenameDialog.vue @@ -0,0 +1,53 @@ + + + diff --git a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue new file mode 100644 index 000000000..1b6fecfc1 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts b/dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts new file mode 100644 index 000000000..a285c473f --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/composables/useCommandActions.ts @@ -0,0 +1,177 @@ +/** + * 指令操作方法 Composable + */ +import { reactive } from 'vue'; +import axios from 'axios'; +import type { CommandItem, RenameDialogState, DetailsDialogState, TypeInfo, StatusInfo } from '../types'; + +export function useCommandActions( + toast: (message: string, color?: string) => void, + fetchCommands: () => Promise +) { + // 重命名对话框状态 + const renameDialog = reactive({ + show: false, + command: null, + newName: '', + loading: false + }); + + // 详情对话框状态 + const detailsDialog = reactive({ + show: false, + command: null + }); + + /** + * 切换指令启用/禁用状态 + */ + const toggleCommand = async ( + cmd: CommandItem, + successMessage: string, + errorMessage: string + ) => { + try { + const res = await axios.post('/api/commands/toggle', { + handler_full_name: cmd.handler_full_name, + enabled: !cmd.enabled + }); + if (res.data.status === 'ok') { + toast(successMessage, 'success'); + await fetchCommands(); + } else { + toast(res.data.message || errorMessage, 'error'); + } + } catch (err: any) { + toast(err?.message || errorMessage, 'error'); + } + }; + + /** + * 打开重命名对话框 + */ + const openRenameDialog = (cmd: CommandItem) => { + renameDialog.command = cmd; + renameDialog.newName = cmd.current_fragment || ''; + renameDialog.show = true; + }; + + /** + * 确认重命名 + */ + const confirmRename = async (successMessage: string, errorMessage: string) => { + if (!renameDialog.command || !renameDialog.newName.trim()) return; + + renameDialog.loading = true; + try { + const res = await axios.post('/api/commands/rename', { + handler_full_name: renameDialog.command.handler_full_name, + new_name: renameDialog.newName.trim() + }); + if (res.data.status === 'ok') { + toast(successMessage, 'success'); + renameDialog.show = false; + await fetchCommands(); + } else { + toast(res.data.message || errorMessage, 'error'); + } + } catch (err: any) { + toast(err?.message || errorMessage, 'error'); + } finally { + renameDialog.loading = false; + } + }; + + /** + * 打开详情对话框 + */ + const openDetailsDialog = (cmd: CommandItem) => { + detailsDialog.command = cmd; + detailsDialog.show = true; + }; + + /** + * 获取类型显示信息 + */ + const getTypeInfo = (type: string, translations: { group: string; subCommand: string; command: string }): TypeInfo => { + switch (type) { + case 'group': + return { text: translations.group, color: 'info', icon: 'mdi-folder-outline' }; + case 'sub_command': + return { text: translations.subCommand, color: 'secondary', icon: 'mdi-subdirectory-arrow-right' }; + default: + return { text: translations.command, color: 'primary', icon: 'mdi-console-line' }; + } + }; + + /** + * 获取权限颜色 + */ + const getPermissionColor = (permission: string): string => { + switch (permission) { + case 'admin': return 'error'; + default: return 'success'; + } + }; + + /** + * 获取权限标签 + */ + const getPermissionLabel = (permission: string, translations: { admin: string; everyone: string }): string => { + switch (permission) { + case 'admin': return translations.admin; + default: return translations.everyone; + } + }; + + /** + * 获取状态显示信息 + */ + const getStatusInfo = ( + cmd: CommandItem, + translations: { conflict: string; enabled: string; disabled: string } + ): StatusInfo => { + if (cmd.has_conflict) { + return { text: translations.conflict, color: 'warning', variant: 'flat' }; + } + if (cmd.enabled) { + return { text: translations.enabled, color: 'success', variant: 'flat' }; + } + return { text: translations.disabled, color: 'error', variant: 'outlined' }; + }; + + /** + * 获取表格行属性(用于冲突高亮和子指令样式) + */ + const getRowProps = ({ item }: { item: CommandItem }) => { + const classes: string[] = []; + if (item.has_conflict) { + classes.push('conflict-row'); + } + if (item.type === 'sub_command') { + classes.push('sub-command-row'); + } + if (item.is_group) { + classes.push('group-row'); + } + return classes.length > 0 ? { class: classes.join(' ') } : {}; + }; + + return { + // 状态 + renameDialog, + detailsDialog, + + // 方法 + toggleCommand, + openRenameDialog, + confirmRename, + openDetailsDialog, + getTypeInfo, + getPermissionColor, + getPermissionLabel, + getStatusInfo, + getRowProps + }; +} + diff --git a/dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts b/dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts new file mode 100644 index 000000000..f7d5bbc0e --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/composables/useCommandFilters.ts @@ -0,0 +1,187 @@ +/** + * 指令过滤逻辑 Composable + */ +import { ref, computed, type Ref } from 'vue'; +import type { CommandItem, FilterState } from '../types'; + +export function useCommandFilters(commands: Ref) { + // 过滤状态 + const searchQuery = ref(''); + const pluginFilter = ref('all'); + const permissionFilter = ref('all'); + const statusFilter = ref('all'); + const typeFilter = ref('all'); + const showSystemPlugins = ref(false); + + // 展开的指令组 + const expandedGroups = ref>(new Set()); + + /** + * 检查是否有涉及系统插件的冲突 + */ + const hasSystemPluginConflict = computed(() => { + return commands.value.some(cmd => cmd.has_conflict && cmd.reserved); + }); + + /** + * 实际是否显示系统插件(如果有系统插件冲突则强制显示) + */ + const effectiveShowSystemPlugins = computed(() => { + return showSystemPlugins.value || hasSystemPluginConflict.value; + }); + + /** + * 获取可用的插件列表(用于过滤下拉框) + */ + const availablePlugins = computed(() => { + const plugins = new Set( + commands.value + .filter(cmd => effectiveShowSystemPlugins.value || !cmd.reserved) + .map(cmd => cmd.plugin) + ); + return Array.from(plugins).sort(); + }); + + /** + * 检查指令是否匹配过滤条件 + */ + const matchesFilters = (cmd: CommandItem, query: string): boolean => { + // 系统插件过滤(除非显示系统插件) + if (!effectiveShowSystemPlugins.value && cmd.reserved) { + return false; + } + + // 搜索过滤 + if (query) { + const matchesSearch = + cmd.effective_command?.toLowerCase().includes(query) || + cmd.description?.toLowerCase().includes(query) || + cmd.plugin?.toLowerCase().includes(query); + if (!matchesSearch) return false; + } + + // 插件过滤 + if (pluginFilter.value !== 'all' && cmd.plugin !== pluginFilter.value) { + return false; + } + + // 权限过滤 + if (permissionFilter.value !== 'all') { + if (permissionFilter.value === 'everyone') { + if (cmd.permission !== 'everyone' && cmd.permission !== 'member') return false; + } else if (cmd.permission !== permissionFilter.value) { + return false; + } + } + + // 状态过滤 + if (statusFilter.value !== 'all') { + if (statusFilter.value === 'enabled' && !cmd.enabled) return false; + if (statusFilter.value === 'disabled' && cmd.enabled) return false; + if (statusFilter.value === 'conflict' && !cmd.has_conflict) return false; + } + + // 类型过滤 + if (typeFilter.value !== 'all') { + if (typeFilter.value === 'group' && cmd.type !== 'group') return false; + if (typeFilter.value === 'command' && cmd.type !== 'command') return false; + if (typeFilter.value === 'sub_command' && cmd.type !== 'sub_command') return false; + } + + return true; + }; + + /** + * 过滤后的指令列表(支持层级结构) + */ + const filteredCommands = computed(() => { + const query = searchQuery.value.toLowerCase(); + const conflictCmds: CommandItem[] = []; + const normalCmds: CommandItem[] = []; + + for (const cmd of commands.value) { + // 对于指令组,检查组本身或子指令是否匹配 + if (cmd.is_group) { + const groupMatches = matchesFilters(cmd, query); + const matchingSubCmds = (cmd.sub_commands || []).filter(sub => matchesFilters(sub, query)); + + // 如果组匹配或有匹配的子指令,则包含它 + if (groupMatches || matchingSubCmds.length > 0) { + if (cmd.has_conflict) { + conflictCmds.push(cmd); + } else { + normalCmds.push(cmd); + } + + // 如果组已展开,添加匹配的子指令 + if (expandedGroups.value.has(cmd.handler_full_name)) { + const subsToShow = query ? matchingSubCmds : (cmd.sub_commands || []); + for (const sub of subsToShow) { + if (sub.has_conflict) { + conflictCmds.push(sub); + } else { + normalCmds.push(sub); + } + } + } + } + } else if (cmd.type !== 'sub_command') { + // 普通指令(子指令通过组处理) + if (matchesFilters(cmd, query)) { + if (cmd.has_conflict) { + conflictCmds.push(cmd); + } else { + normalCmds.push(cmd); + } + } + } + } + + // 按 effective_command 排序冲突指令,使其分组在一起 + conflictCmds.sort((a, b) => (a.effective_command || '').localeCompare(b.effective_command || '')); + + return [...conflictCmds, ...normalCmds]; + }); + + /** + * 切换指令组的展开/折叠状态 + */ + const toggleGroupExpand = (cmd: CommandItem) => { + if (!cmd.is_group) return; + if (expandedGroups.value.has(cmd.handler_full_name)) { + expandedGroups.value.delete(cmd.handler_full_name); + } else { + expandedGroups.value.add(cmd.handler_full_name); + } + }; + + /** + * 检查指令组是否已展开 + */ + const isGroupExpanded = (cmd: CommandItem): boolean => { + return expandedGroups.value.has(cmd.handler_full_name); + }; + + return { + // 状态 + searchQuery, + pluginFilter, + permissionFilter, + statusFilter, + typeFilter, + showSystemPlugins, + expandedGroups, + + // 计算属性 + hasSystemPluginConflict, + effectiveShowSystemPlugins, + availablePlugins, + filteredCommands, + + // 方法 + matchesFilters, + toggleGroupExpand, + isGroupExpanded + }; +} + diff --git a/dashboard/src/components/extension/componentPanel/composables/useComponentData.ts b/dashboard/src/components/extension/componentPanel/composables/useComponentData.ts new file mode 100644 index 000000000..291ba53c4 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/composables/useComponentData.ts @@ -0,0 +1,83 @@ +/** + * 指令数据管理 Composable + */ +import { ref, reactive } from 'vue'; +import axios from 'axios'; +import type { CommandItem, CommandSummary, SnackbarState, ToolItem } from '../types'; + +export function useComponentData() { + const loading = ref(false); + const commands = ref([]); + const tools = ref([]); + const toolsLoading = ref(false); + const summary = reactive({ + disabled: 0, + conflicts: 0 + }); + + const snackbar = reactive({ + show: false, + message: '', + color: 'success' + }); + + /** + * 显示 Toast 消息 + */ + const toast = (message: string, color: string = 'success') => { + snackbar.message = message; + snackbar.color = color; + snackbar.show = true; + }; + + /** + * 获取指令列表 + */ + const fetchCommands = async (errorMessage: string) => { + loading.value = true; + try { + const res = await axios.get('/api/commands'); + if (res.data.status === 'ok') { + commands.value = res.data.data.items || []; + const s = res.data.data.summary || {}; + summary.disabled = s.disabled || 0; + summary.conflicts = s.conflicts || 0; + } else { + toast(res.data.message || errorMessage, 'error'); + } + } catch (err: any) { + toast(err?.message || errorMessage, 'error'); + } finally { + loading.value = false; + } + }; + + const fetchTools = async (errorMessage: string) => { + toolsLoading.value = true; + try { + const res = await axios.get('/api/tools/list'); + if (res.data.status === 'ok') { + tools.value = res.data.data || []; + } else { + toast(res.data.message || errorMessage, 'error'); + } + } catch (err: any) { + toast(err?.message || errorMessage, 'error'); + } finally { + toolsLoading.value = false; + } + }; + + return { + loading, + commands, + tools, + toolsLoading, + summary, + snackbar, + toast, + fetchCommands, + fetchTools + }; +} + diff --git a/dashboard/src/components/extension/componentPanel/index.vue b/dashboard/src/components/extension/componentPanel/index.vue new file mode 100644 index 000000000..912af9156 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/index.vue @@ -0,0 +1,307 @@ + + + diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts new file mode 100644 index 000000000..d2b388ec9 --- /dev/null +++ b/dashboard/src/components/extension/componentPanel/types.ts @@ -0,0 +1,102 @@ +/** + * 指令管理模块 - 类型定义 + */ + +/** 指令项接口 */ +export interface CommandItem { + handler_full_name: string; + handler_name: string; + plugin: string; + plugin_display_name: string | null; + module_path: string; + description: string; + type: CommandType; + parent_signature: string; + parent_group_handler: string; + original_command: string; + current_fragment: string; + effective_command: string; + aliases: string[]; + permission: PermissionType; + enabled: boolean; + is_group: boolean; + has_conflict: boolean; + reserved: boolean; + sub_commands: CommandItem[]; +} + +/** 指令类型 */ +export type CommandType = 'command' | 'group' | 'sub_command'; + +/** 权限类型 */ +export type PermissionType = 'admin' | 'everyone' | 'member'; + +/** 指令摘要统计 */ +export interface CommandSummary { + disabled: number; + conflicts: number; +} + +/** 过滤器状态 */ +export interface FilterState { + searchQuery: string; + pluginFilter: string; + permissionFilter: string; + statusFilter: string; + typeFilter: string; + showSystemPlugins: boolean; +} + +/** 重命名对话框状态 */ +export interface RenameDialogState { + show: boolean; + command: CommandItem | null; + newName: string; + loading: boolean; +} + +/** 详情对话框状态 */ +export interface DetailsDialogState { + show: boolean; + command: CommandItem | null; +} + +/** Toast 消息状态 */ +export interface SnackbarState { + show: boolean; + message: string; + color: string; +} + +/** 类型信息展示 */ +export interface TypeInfo { + text: string; + color: string; + icon: string; +} + +/** 状态信息展示 */ +export interface StatusInfo { + text: string; + color: string; + variant: 'flat' | 'outlined' | 'text' | 'elevated' | 'tonal' | 'plain'; +} + +/** MCP/函数工具参数定义 */ +export interface ToolParameter { + type?: string; + description?: string; +} + +/** MCP/函数工具对象 */ +export interface ToolItem { + name: string; + description: string; + active: boolean; + parameters?: { + properties?: Record; + }; + origin?: string; + origin_name?: string; +} + diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index 5e2fa63d4..045506b63 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -304,16 +304,32 @@ function hasVisibleItemsAfter(items, currentIndex) { hide-details > - - +
+ class="d-flex align-center gap-3" + > + + +
- - +
+ class="d-flex align-center gap-3" + > + + +
- - + +
+ + +