Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] a2fe0ec5a1 Add webhook signature verification for security
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-12 14:27:51 +00:00
copilot-swe-agent[bot] 6957ec713d Clean up unused imports in tests
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-12 14:24:18 +00:00
copilot-swe-agent[bot] d97c8b5b2b Add tests for GitHub webhook platform adapter
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-12 14:23:22 +00:00
copilot-swe-agent[bot] d07a1ad5c9 Add GitHub webhook platform adapter with event handlers
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-12 14:20:33 +00:00
copilot-swe-agent[bot] d8e6dfbd6b Initial plan 2025-12-12 14:14:49 +00:00
66 changed files with 1287 additions and 3857 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.9.0" __version__ = "4.8.0"
+1 -1
View File
@@ -4,7 +4,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.9.0" VERSION = "4.8.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
-72
View File
@@ -9,8 +9,6 @@ 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,
@@ -316,76 +314,6 @@ 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,65 +234,6 @@ 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,7 +1,6 @@
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
@@ -11,8 +10,6 @@ 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,
@@ -29,7 +26,6 @@ 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):
@@ -674,242 +670,6 @@ 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
# ==== # ====
+1 -2
View File
@@ -24,7 +24,6 @@ import asyncio
import logging import logging
import os import os
import sys import sys
import time
from asyncio import Queue from asyncio import Queue
from collections import deque from collections import deque
@@ -149,7 +148,7 @@ class LogQueueHandler(logging.Handler):
self.log_broker.publish( self.log_broker.publish(
{ {
"level": record.levelname, "level": record.levelname,
"time": time.time(), "time": record.asctime,
"data": log_entry, "data": log_entry,
}, },
) )
+4
View File
@@ -112,6 +112,10 @@ class PlatformManager:
from .sources.satori.satori_adapter import ( from .sources.satori.satori_adapter import (
SatoriPlatformAdapter, # noqa: F401 SatoriPlatformAdapter, # noqa: F401
) )
case "github_webhook":
from .sources.github_webhook.github_webhook_adapter import (
GitHubWebhookPlatformAdapter, # noqa: F401
)
except (ImportError, ModuleNotFoundError) as e: except (ImportError, ModuleNotFoundError) as e:
logger.error( logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。", f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
@@ -0,0 +1,315 @@
import asyncio
import hashlib
import hmac
from typing import Any, cast
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Plain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.platform import PlatformStatus
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from .github_webhook_event import GitHubWebhookMessageEvent
@register_platform_adapter(
"github_webhook",
"GitHub Webhook 适配器",
support_streaming_message=False,
)
class GitHubWebhookPlatformAdapter(Platform):
"""GitHub Webhook 平台适配器
支持的事件:
- issues (created)
- issue_comment (created)
- pull_request (opened)
"""
def __init__(
self,
platform_config: dict,
platform_settings: dict,
event_queue: asyncio.Queue,
) -> None:
super().__init__(platform_config, event_queue)
self.unified_webhook_mode = platform_config.get("unified_webhook_mode", True)
self.webhook_secret = platform_config.get("webhook_secret", "")
self.shutdown_event = asyncio.Event()
async def send_by_session(
self,
session: MessageSesion,
message_chain: MessageChain,
):
"""GitHub Webhook 是单向接收,不支持主动发送消息"""
logger.warning("GitHub Webhook 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="github_webhook",
description="GitHub Webhook 适配器",
id=cast(str, self.config.get("id")),
)
async def run(self):
"""运行适配器"""
self.status = PlatformStatus.RUNNING
# 如果启用统一 webhook 模式
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(GitHub Webhook)", webhook_uuid)
# 保持运行状态,等待 shutdown
await self.shutdown_event.wait()
else:
logger.warning("GitHub Webhook 适配器需要启用统一 webhook 模式")
await self.shutdown_event.wait()
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口
处理 GitHub webhook 事件
Args:
request: Quart 请求对象
Returns:
响应数据
"""
try:
# 获取事件类型
event_type = request.headers.get("X-GitHub-Event", "")
# 获取请求数据
payload = await request.json
# 验证 webhook 签名(如果配置了 secret
if self.webhook_secret:
if not await self._verify_signature(request, payload):
logger.warning("GitHub webhook 签名验证失败")
return {"error": "Invalid signature"}, 401
logger.debug(f"收到 GitHub Webhook 事件: {event_type}")
# 处理不同类型的事件
if event_type == "issues":
await self._handle_issue_event(payload)
elif event_type == "issue_comment":
await self._handle_issue_comment_event(payload)
elif event_type == "pull_request":
await self._handle_pull_request_event(payload)
elif event_type == "ping":
# GitHub webhook 验证事件
return {"message": "pong"}
else:
logger.debug(f"忽略不支持的 GitHub 事件类型: {event_type}")
return {"status": "ok"}
except Exception as e:
logger.error(f"处理 GitHub webhook 回调时发生错误: {e}", exc_info=True)
return {"error": str(e)}, 500
async def _verify_signature(self, request: Any, payload: dict) -> bool:
"""验证 GitHub webhook 签名
Args:
request: Quart 请求对象
payload: 请求负载数据
Returns:
签名是否有效
"""
signature_header = request.headers.get("X-Hub-Signature-256", "")
if not signature_header:
# 如果没有签名头,检查是否有旧版本的签名
signature_header = request.headers.get("X-Hub-Signature", "")
if not signature_header:
return False
# 获取原始请求体
body = await request.get_data()
# 计算 HMAC
if signature_header.startswith("sha256="):
expected_signature = hmac.new(
self.webhook_secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
received_signature = signature_header.replace("sha256=", "")
elif signature_header.startswith("sha1="):
expected_signature = hmac.new(
self.webhook_secret.encode("utf-8"),
body,
hashlib.sha1,
).hexdigest()
received_signature = signature_header.replace("sha1=", "")
else:
return False
# 使用 hmac.compare_digest 防止时序攻击
return hmac.compare_digest(expected_signature, received_signature)
async def _handle_issue_event(self, payload: dict):
"""处理 issue 事件"""
action = payload.get("action", "")
# 只处理创建事件
if action != "created" and action != "opened":
return
issue = payload.get("issue", {})
repo = payload.get("repository", {})
sender = payload.get("sender", {})
# 构造消息文本
message_text = (
f"📝 新 Issue 创建\n"
f"仓库: {repo.get('full_name', 'unknown')}\n"
f"标题: {issue.get('title', 'No title')}\n"
f"作者: {sender.get('login', 'unknown')}\n"
f"链接: {issue.get('html_url', '')}\n"
f"内容:\n{issue.get('body', 'No description')[:200]}"
)
# 创建 AstrBotMessage
abm = self._create_message(
message_text,
sender.get("login", "unknown"),
sender.get("login", "unknown"),
repo.get("full_name", "unknown"),
)
# 提交事件
self.commit_event(
GitHubWebhookMessageEvent(
message_text,
abm,
self.meta(),
repo.get("full_name", "unknown"),
"issues",
payload,
)
)
async def _handle_issue_comment_event(self, payload: dict):
"""处理 issue 评论事件"""
action = payload.get("action", "")
# 只处理创建事件
if action != "created":
return
issue = payload.get("issue", {})
comment = payload.get("comment", {})
repo = payload.get("repository", {})
sender = payload.get("sender", {})
# 构造消息文本
message_text = (
f"💬 新 Issue 评论\n"
f"仓库: {repo.get('full_name', 'unknown')}\n"
f"Issue: {issue.get('title', 'No title')}\n"
f"评论者: {sender.get('login', 'unknown')}\n"
f"链接: {comment.get('html_url', '')}\n"
f"内容:\n{comment.get('body', 'No comment')[:200]}"
)
# 创建 AstrBotMessage
abm = self._create_message(
message_text,
sender.get("login", "unknown"),
sender.get("login", "unknown"),
repo.get("full_name", "unknown"),
)
# 提交事件
self.commit_event(
GitHubWebhookMessageEvent(
message_text,
abm,
self.meta(),
repo.get("full_name", "unknown"),
"issue_comment",
payload,
)
)
async def _handle_pull_request_event(self, payload: dict):
"""处理 pull request 事件"""
action = payload.get("action", "")
# 只处理打开事件
if action != "opened":
return
pr = payload.get("pull_request", {})
repo = payload.get("repository", {})
sender = payload.get("sender", {})
# 构造消息文本
message_text = (
f"🔀 新 Pull Request\n"
f"仓库: {repo.get('full_name', 'unknown')}\n"
f"标题: {pr.get('title', 'No title')}\n"
f"作者: {sender.get('login', 'unknown')}\n"
f"链接: {pr.get('html_url', '')}\n"
f"内容:\n{pr.get('body', 'No description')[:200]}"
)
# 创建 AstrBotMessage
abm = self._create_message(
message_text,
sender.get("login", "unknown"),
sender.get("login", "unknown"),
repo.get("full_name", "unknown"),
)
# 提交事件
self.commit_event(
GitHubWebhookMessageEvent(
message_text,
abm,
self.meta(),
repo.get("full_name", "unknown"),
"pull_request",
payload,
)
)
def _create_message(
self,
message_text: str,
user_id: str,
nickname: str,
session_id: str,
) -> AstrBotMessage:
"""创建 AstrBotMessage 对象"""
abm = AstrBotMessage()
abm.type = MessageType.GROUP_MESSAGE
abm.self_id = self.client_self_id
abm.session_id = session_id
abm.message_id = ""
abm.sender = MessageMember(user_id=user_id, nickname=nickname)
abm.message = [Plain(message_text)]
abm.message_str = message_text
abm.raw_message = message_text
return abm
async def terminate(self):
"""终止适配器运行"""
self.shutdown_event.set()
logger.info("GitHub Webhook 适配器已经被优雅地关闭")
@@ -0,0 +1,22 @@
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from ...astr_message_event import AstrMessageEvent
class GitHubWebhookMessageEvent(AstrMessageEvent):
"""GitHub Webhook 消息事件"""
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
event_type: str,
event_data: dict,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.event_type = event_type
"""GitHub 事件类型: issues, issue_comment, pull_request"""
self.event_data = event_data
"""原始事件数据"""
@@ -81,12 +81,7 @@ class LarkPlatformAdapter(Platform):
) )
self.lark_api = ( self.lark_api = (
lark.Client.builder() lark.Client.builder().app_id(self.appid).app_secret(self.appsecret).build()
.app_id(self.appid)
.app_secret(self.appsecret)
.log_level(lark.LogLevel.ERROR)
.domain(self.domain)
.build()
) )
self.webhook_server = None self.webhook_server = None
-449
View File
@@ -1,449 +0,0 @@
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,7 +40,6 @@ 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,7 +18,6 @@ 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,8 +118,6 @@ 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)
@@ -222,8 +220,6 @@ 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,7 +23,6 @@ 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
@@ -619,7 +618,6 @@ 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,6 +1,5 @@
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
@@ -18,7 +17,6 @@ from .update import UpdateRoute
__all__ = [ __all__ = [
"AuthRoute", "AuthRoute",
"ChatRoute", "ChatRoute",
"CommandRoute",
"ConfigRoute", "ConfigRoute",
"ConversationRoute", "ConversationRoute",
"FileRoute", "FileRoute",
-82
View File
@@ -1,82 +0,0 @@
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 {}
+1 -91
View File
@@ -1,9 +1,7 @@
import json import json
import traceback import traceback
from datetime import datetime
from io import BytesIO
from quart import request, send_file from quart import request
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -32,7 +30,6 @@ class ConversationRoute(Route):
"POST", "POST",
self.update_history, self.update_history,
), ),
"/conversation/export": ("POST", self.export_conversations),
} }
self.db_helper = db_helper self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager self.conv_mgr = core_lifecycle.conversation_manager
@@ -286,90 +283,3 @@ class ConversationRoute(Route):
except Exception as e: except Exception as e:
logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}") logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"更新对话历史失败: {e!s}").__dict__ 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__
+78 -253
View File
@@ -48,7 +48,6 @@ class KnowledgeBaseRoute(Route):
# 文档管理 # 文档管理
"/kb/document/list": ("GET", self.list_documents), "/kb/document/list": ("GET", self.list_documents),
"/kb/document/upload": ("POST", self.upload_document), "/kb/document/upload": ("POST", self.upload_document),
"/kb/document/import": ("POST", self.import_documents),
"/kb/document/upload/url": ("POST", self.upload_document_from_url), "/kb/document/upload/url": ("POST", self.upload_document_from_url),
"/kb/document/upload/progress": ("GET", self.get_upload_progress), "/kb/document/upload/progress": ("GET", self.get_upload_progress),
"/kb/document/get": ("GET", self.get_document), "/kb/document/get": ("GET", self.get_document),
@@ -67,65 +66,6 @@ class KnowledgeBaseRoute(Route):
def _get_kb_manager(self): def _get_kb_manager(self):
return self.core_lifecycle.kb_manager return self.core_lifecycle.kb_manager
def _init_task(self, task_id: str, status: str = "pending") -> None:
self.upload_tasks[task_id] = {
"status": status,
"result": None,
"error": None,
}
def _set_task_result(
self, task_id: str, status: str, result: any = None, error: str | None = None
) -> None:
self.upload_tasks[task_id] = {
"status": status,
"result": result,
"error": error,
}
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = status
def _update_progress(
self,
task_id: str,
*,
status: str | None = None,
file_index: int | None = None,
file_name: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
) -> None:
if task_id not in self.upload_progress:
return
p = self.upload_progress[task_id]
if status is not None:
p["status"] = status
if file_index is not None:
p["file_index"] = file_index
if file_name is not None:
p["file_name"] = file_name
if stage is not None:
p["stage"] = stage
if current is not None:
p["current"] = current
if total is not None:
p["total"] = total
def _make_progress_callback(self, task_id: str, file_idx: int, file_name: str):
async def _callback(stage: str, current: int, total: int):
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_name,
stage=stage,
current=current,
total=total,
)
return _callback
async def _background_upload_task( async def _background_upload_task(
self, self,
task_id: str, task_id: str,
@@ -140,7 +80,11 @@ class KnowledgeBaseRoute(Route):
"""后台上传任务""" """后台上传任务"""
try: try:
# 初始化任务状态 # 初始化任务状态
self._init_task(task_id, status="processing") self.upload_tasks[task_id] = {
"status": "processing",
"result": None,
"error": None,
}
self.upload_progress[task_id] = { self.upload_progress[task_id] = {
"status": "processing", "status": "processing",
"file_index": 0, "file_index": 0,
@@ -156,20 +100,30 @@ class KnowledgeBaseRoute(Route):
for file_idx, file_info in enumerate(files_to_upload): for file_idx, file_info in enumerate(files_to_upload):
try: try:
# 更新整体进度 # 更新整体进度
self._update_progress( self.upload_progress[task_id].update(
task_id, {
status="processing", "status": "processing",
file_index=file_idx, "file_index": file_idx,
file_name=file_info["file_name"], "file_name": file_info["file_name"],
stage="parsing", "stage": "parsing",
current=0, "current": 0,
total=100, "total": 100,
},
) )
# 创建进度回调函数 # 创建进度回调函数
progress_callback = self._make_progress_callback( async def progress_callback(stage, current, total):
task_id, file_idx, file_info["file_name"] if task_id in self.upload_progress:
) self.upload_progress[task_id].update(
{
"status": "processing",
"file_index": file_idx,
"file_name": file_info["file_name"],
"stage": stage,
"current": current,
"total": total,
},
)
doc = await kb_helper.upload_document( doc = await kb_helper.upload_document(
file_name=file_info["file_name"], file_name=file_info["file_name"],
@@ -200,99 +154,23 @@ class KnowledgeBaseRoute(Route):
"failed_count": len(failed_docs), "failed_count": len(failed_docs),
} }
self._set_task_result(task_id, "completed", result=result) self.upload_tasks[task_id] = {
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
except Exception as e: except Exception as e:
logger.error(f"后台上传任务 {task_id} 失败: {e}") logger.error(f"后台上传任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e)) self.upload_tasks[task_id] = {
"status": "failed",
async def _background_import_task( "result": None,
self, "error": str(e),
task_id: str,
kb_helper,
documents: list,
batch_size: int,
tasks_limit: int,
max_retries: int,
):
"""后台导入预切片文档任务"""
try:
# 初始化任务状态
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
"file_total": len(documents),
"stage": "waiting",
"current": 0,
"total": 100,
} }
if task_id in self.upload_progress:
uploaded_docs = [] self.upload_progress[task_id]["status"] = "failed"
failed_docs = []
for file_idx, doc_info in enumerate(documents):
file_name = doc_info.get("file_name", f"imported_doc_{file_idx}")
chunks = doc_info.get("chunks", [])
try:
# 更新整体进度
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_name,
stage="importing",
current=0,
total=100,
)
# 创建进度回调函数
progress_callback = self._make_progress_callback(
task_id, file_idx, file_name
)
# 调用 upload_document,传入 pre_chunked_text
doc = await kb_helper.upload_document(
file_name=file_name,
file_content=None, # 预切片模式下不需要原始内容
file_type=doc_info.get("file_type")
or (
file_name.rsplit(".", 1)[-1].lower()
if "." in file_name
else "txt"
),
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
pre_chunked_text=chunks,
)
uploaded_docs.append(doc.model_dump())
except Exception as e:
logger.error(f"导入文档 {file_name} 失败: {e}")
failed_docs.append(
{"file_name": file_name, "error": str(e)},
)
# 更新任务完成状态
result = {
"task_id": task_id,
"uploaded": uploaded_docs,
"failed": failed_docs,
"total": len(documents),
"success_count": len(uploaded_docs),
"failed_count": len(failed_docs),
}
self._set_task_result(task_id, "completed", result=result)
except Exception as e:
logger.error(f"后台导入任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))
async def list_kbs(self): async def list_kbs(self):
"""获取知识库列表 """获取知识库列表
@@ -736,7 +614,11 @@ class KnowledgeBaseRoute(Route):
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
# 初始化任务状态 # 初始化任务状态
self._init_task(task_id, status="pending") self.upload_tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
}
# 启动后台任务 # 启动后台任务
asyncio.create_task( asyncio.create_task(
@@ -771,93 +653,6 @@ class KnowledgeBaseRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"上传文档失败: {e!s}").__dict__ return Response().error(f"上传文档失败: {e!s}").__dict__
def _validate_import_request(self, data: dict):
kb_id = data.get("kb_id")
if not kb_id:
raise ValueError("缺少参数 kb_id")
documents = data.get("documents")
if not documents or not isinstance(documents, list):
raise ValueError("缺少参数 documents 或格式错误")
for doc in documents:
if "file_name" not in doc or "chunks" not in doc:
raise ValueError("文档格式错误,必须包含 file_name 和 chunks")
if not isinstance(doc["chunks"], list):
raise ValueError("chunks 必须是列表")
if not all(
isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"]
):
raise ValueError("chunks 必须是非空字符串列表")
batch_size = data.get("batch_size", 32)
tasks_limit = data.get("tasks_limit", 3)
max_retries = data.get("max_retries", 3)
return kb_id, documents, batch_size, tasks_limit, max_retries
async def import_documents(self):
"""导入预切片文档
Body:
- kb_id: 知识库 ID (必填)
- documents: 文档列表 (必填)
- file_name: 文件名 (必填)
- chunks: 切片列表 (必填, list[str])
- file_type: 文件类型 (可选, 默认从文件名推断或为 txt)
- batch_size: 批处理大小 (可选, 默认32)
- tasks_limit: 并发任务限制 (可选, 默认3)
- max_retries: 最大重试次数 (可选, 默认3)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id, documents, batch_size, tasks_limit, max_retries = (
self._validate_import_request(data)
)
# 获取知识库
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
self._background_import_task(
task_id=task_id,
kb_helper=kb_helper,
documents=documents,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
),
)
return (
Response()
.ok(
{
"task_id": task_id,
"doc_count": len(documents),
"message": "import task created, processing in background",
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"导入文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"导入文档失败: {e!s}").__dict__
async def get_upload_progress(self): async def get_upload_progress(self):
"""获取上传进度和结果 """获取上传进度和结果
@@ -1165,7 +960,11 @@ class KnowledgeBaseRoute(Route):
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
# 初始化任务状态 # 初始化任务状态
self._init_task(task_id, status="pending") self.upload_tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
}
# 启动后台任务 # 启动后台任务
asyncio.create_task( asyncio.create_task(
@@ -1218,7 +1017,11 @@ class KnowledgeBaseRoute(Route):
"""后台上传URL任务""" """后台上传URL任务"""
try: try:
# 初始化任务状态 # 初始化任务状态
self._init_task(task_id, status="processing") self.upload_tasks[task_id] = {
"status": "processing",
"result": None,
"error": None,
}
self.upload_progress[task_id] = { self.upload_progress[task_id] = {
"status": "processing", "status": "processing",
"file_index": 0, "file_index": 0,
@@ -1230,7 +1033,18 @@ class KnowledgeBaseRoute(Route):
} }
# 创建进度回调函数 # 创建进度回调函数
progress_callback = self._make_progress_callback(task_id, 0, f"URL: {url}") async def progress_callback(stage, current, total):
if task_id in self.upload_progress:
self.upload_progress[task_id].update(
{
"status": "processing",
"file_index": 0,
"file_name": f"URL: {url}",
"stage": stage,
"current": current,
"total": total,
},
)
# 上传文档 # 上传文档
doc = await kb_helper.upload_from_url( doc = await kb_helper.upload_from_url(
@@ -1255,9 +1069,20 @@ class KnowledgeBaseRoute(Route):
"failed_count": 0, "failed_count": 0,
} }
self._set_task_result(task_id, "completed", result=result) self.upload_tasks[task_id] = {
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
except Exception as e: except Exception as e:
logger.error(f"后台上传URL任务 {task_id} 失败: {e}") logger.error(f"后台上传URL任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e)) self.upload_tasks[task_id] = {
"status": "failed",
"result": None,
"error": str(e),
}
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = "failed"
+1 -5
View File
@@ -124,11 +124,7 @@ class PluginRoute(Route):
session.get(url) as response, session.get(url) as response,
): ):
if response.status == 200: if response.status == 200:
try: remote_data = await response.json()
remote_data = await response.json()
except aiohttp.ContentTypeError:
remote_text = await response.text()
remote_data = json.loads(remote_text)
# 检查远程数据是否为空 # 检查远程数据是否为空
if not remote_data or ( if not remote_data or (
+4 -20
View File
@@ -3,7 +3,6 @@ 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
@@ -297,30 +296,15 @@ 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,
} }
tools_dict.append(tool_info) for tool in tools
]
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,7 +67,6 @@ 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)
-19
View File
@@ -1,19 +0,0 @@
## What's Changed
### 新增
- 支持自定义插件源。
- 支持飞书(Lark)的 Webhook 模式(将事件推送至开发者服务器)。
- 支持 “禁用自带指令” 快捷配置项,启用后将禁用所有 AstrBot 自带指令。入口: WebUI -> 配置文件 -> 平台配置。
### 优化
- 从 WebUI 移除了开发版本渠道。
- 当试图测试"Agent Runner"时,提示前往配置文件页测试。
- WebUI 列表项支持批量粘贴、回车创建项目。
### 修复
- Gemini API 部分调用失败的问题。
- WebUI 插件安装加载 Dialog 关闭按钮在手机端下显示异常的问题。
- 部分情况下,WebUI 日志显示不全的问题。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,155 +0,0 @@
<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>
@@ -1,257 +0,0 @@
<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>
@@ -1,143 +0,0 @@
<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>
@@ -1,53 +0,0 @@
<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>
@@ -1,144 +0,0 @@
<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>
@@ -1,177 +0,0 @@
/**
* 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
};
}
@@ -1,187 +0,0 @@
/**
* 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
};
}
@@ -1,83 +0,0 @@
/**
* 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
};
}
@@ -1,307 +0,0 @@
<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>
@@ -1,102 +0,0 @@
/**
* -
*/
/** 指令项接口 */
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;
}
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { useCommonStore } from '@/stores/common'; import { useCommonStore } from '@/stores/common';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import axios from 'axios';
</script> </script>
<template> <template>
@@ -25,6 +24,8 @@ import axios from 'axios';
export default { export default {
name: 'ConsoleDisplayer', name: 'ConsoleDisplayer',
data() { data() {
const commonStore = useCommonStore();
const { log_cache } = storeToRefs(commonStore);
return { return {
autoScroll: true, // autoScroll: true, //
logColorAnsiMap: { logColorAnsiMap: {
@@ -37,6 +38,7 @@ export default {
'\u001b[32m': 'color: #00FF00;', // green '\u001b[32m': 'color: #00FF00;', // green
'default': 'color: #FFFFFF;' 'default': 'color: #FFFFFF;'
}, },
logCache: log_cache,
historyNum_: -1, historyNum_: -1,
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
selectedLevels: [0, 1, 2, 3, 4], // selectedLevels: [0, 1, 2, 3, 4], //
@@ -46,17 +48,7 @@ export default {
'WARNING': 'amber', 'WARNING': 'amber',
'ERROR': 'red', 'ERROR': 'red',
'CRITICAL': 'purple' 'CRITICAL': 'purple'
}, }
lastProcessedTime: 0, //
localLogCache: [], //
}
},
computed: {
commonStore() {
return useCommonStore();
},
logCache() {
return this.commonStore.log_cache;
} }
}, },
props: { props: {
@@ -71,39 +63,13 @@ export default {
}, },
watch: { watch: {
logCache: { logCache: {
handler(newVal) { handler(val) {
// timestamp const lastLog = val[this.logCache.length - 1];
if (newVal && newVal.length > 0) { if (lastLog && this.isLevelSelected(lastLog.level)) {
// DOM this.printLog(lastLog.data);
this.$nextTick(() => {
//
const newLogs = newVal.filter(log => log.time > this.lastProcessedTime);
if (newLogs.length > 0) {
this.localLogCache.push(...newLogs);
//
this.localLogCache.sort((a, b) => a.time - b.time);
// log_cache_max_len
if (this.localLogCache.length > this.commonStore.log_cache_max_len) {
this.localLogCache.splice(0, this.localLogCache.length - this.commonStore.log_cache_max_len);
}
//
newLogs.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
//
this.lastProcessedTime = Math.max(...newLogs.map(log => log.time));
}
});
} }
}, },
deep: true, deep: true
immediate: false
}, },
selectedLevels: { selectedLevels: {
handler() { handler() {
@@ -112,37 +78,14 @@ export default {
deep: true deep: true
} }
}, },
async mounted() { mounted() {
// if (this.logCache.length === 0) {
await this.fetchLogHistory(); this.delayInit()
} else {
// DOM this.init()
this.$nextTick(() => { }
if (this.localLogCache.length > 0) {
this.localLogCache.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
//
this.lastProcessedTime = Math.max(...this.localLogCache.map(log => log.time));
}
});
}, },
methods: { methods: {
async fetchLogHistory() {
try {
const res = await axios.get('/api/log-history');
if (res.data.data.logs && res.data.data.logs.length > 0) {
this.localLogCache = [...res.data.data.logs];
//
this.localLogCache.sort((a, b) => a.time - b.time);
}
} catch (err) {
console.error('Failed to fetch log history:', err);
}
},
getLevelColor(level) { getLevelColor(level) {
return this.levelColors[level] || 'grey'; return this.levelColors[level] || 'grey';
}, },
@@ -158,21 +101,40 @@ export default {
}, },
refreshDisplay() { refreshDisplay() {
//
const termElement = document.getElementById('term'); const termElement = document.getElementById('term');
if (termElement) { if (termElement) {
termElement.innerHTML = ''; termElement.innerHTML = '';
}
//
if (this.localLogCache && this.localLogCache.length > 0) { //
this.localLogCache.forEach(logItem => { this.init();
if (this.isLevelSelected(logItem.level)) { },
this.printLog(logItem.data);
} delayInit() {
}); if (this.logCache.length === 0) {
} setTimeout(() => {
this.delayInit()
}, 500)
} else {
this.init()
} }
}, },
init() {
this.historyNum_ = parseInt(this.historyNum)
let i = 0
for (let log of this.logCache) {
if (this.isLevelSelected(log.level)) { //
if (this.historyNum_ != -1 && i >= this.logCache.length - this.historyNum_) {
this.printLog(log.data)
++i
} else if (this.historyNum_ == -1) {
this.printLog(log.data)
}
}
}
},
toggleAutoScroll() { toggleAutoScroll() {
this.autoScroll = !this.autoScroll; this.autoScroll = !this.autoScroll;
@@ -181,11 +143,6 @@ export default {
printLog(log) { printLog(log) {
// append span termblock // append span termblock
let ele = document.getElementById('term') let ele = document.getElementById('term')
if (!ele) {
console.warn('term element not found, skipping log print');
return;
}
let span = document.createElement('pre') let span = document.createElement('pre')
let style = this.logColorAnsiMap['default'] let style = this.logColorAnsiMap['default']
for (let key in this.logColorAnsiMap) { for (let key in this.logColorAnsiMap) {
@@ -121,8 +121,7 @@ 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();
@@ -134,12 +133,35 @@ const draggedItem = ref(null);
function initializeItems() { function initializeItems() {
const customization = getSidebarCustomization(); const customization = getSidebarCustomization();
const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
sidebarItems, if (customization) {
customization // Load from customization
); const allItemsMap = new Map();
mainItems.value = resolvedMain;
moreItems.value = resolvedMore; sidebarItems.forEach(item => {
if (item.children) {
item.children.forEach(child => {
allItemsMap.set(child.title, child);
});
} else {
allItemsMap.set(item.title, item);
}
});
mainItems.value = customization.mainItems
.map(title => allItemsMap.get(title))
.filter(item => item);
moreItems.value = customization.moreItems
.map(title => allItemsMap.get(title))
.filter(item => item);
} else {
// Load default structure
mainItems.value = sidebarItems.filter(item => !item.children);
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
moreItems.value = moreGroup ? [...moreGroup.children] : [];
}
} }
function openDialog() { function openDialog() {
@@ -19,6 +19,5 @@
"submit": "Submit", "submit": "Submit",
"reset": "Reset", "reset": "Reset",
"clear": "Clear", "clear": "Clear",
"save": "Save", "save": "Save"
"close": "Close"
} }
@@ -2,7 +2,6 @@
"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",
@@ -1,91 +0,0 @@
{
"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."
}
}
@@ -13,8 +13,7 @@
"refresh": "Refresh" "refresh": "Refresh"
}, },
"batch": { "batch": {
"deleteSelected": "Delete Selected ({count})", "deleteSelected": "Delete Selected ({count})"
"exportSelected": "Export Selected ({count})"
}, },
"pagination": { "pagination": {
"itemsPerPage": "Items per page", "itemsPerPage": "Items per page",
@@ -77,8 +76,7 @@
"message": "Are you sure you want to delete the selected {count} conversations? This action cannot be undone, please proceed with caution!", "message": "Are you sure you want to delete the selected {count} conversations? This action cannot be undone, please proceed with caution!",
"andMore": "and {count} more", "andMore": "and {count} more",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Batch Delete", "confirm": "Batch Delete"
"warning": "Warning: This action cannot be undone!"
} }
}, },
"messages": { "messages": {
@@ -94,9 +92,6 @@
"noItemSelected": "Please select conversations to delete first", "noItemSelected": "Please select conversations to delete first",
"batchDeleteSuccess": "Successfully deleted {count} conversations", "batchDeleteSuccess": "Successfully deleted {count} conversations",
"batchDeleteError": "Batch delete failed", "batchDeleteError": "Batch delete failed",
"batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed", "batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed"
"exportSuccess": "Export successful",
"exportError": "Export failed",
"noItemSelectedForExport": "Please select conversations to export first"
} }
} }
@@ -2,9 +2,7 @@
"title": "Extension Management", "title": "Extension Management",
"subtitle": "Manage and configure system extensions", "subtitle": "Manage and configure system extensions",
"tabs": { "tabs": {
"installedPlugins": "Installed Plugins", "installed": "Installed",
"installedMcpServers": "Installed MCP Servers",
"handlersOperation": "Manage Components",
"market": "Extension Market" "market": "Extension Market"
}, },
"search": { "search": {
@@ -199,12 +197,5 @@
"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,10 +42,7 @@
"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,6 +19,5 @@
"submit": "提交", "submit": "提交",
"reset": "重置", "reset": "重置",
"clear": "清空", "clear": "清空",
"save": "保存", "save": "保存"
"close": "关闭"
} }
@@ -2,7 +2,6 @@
"dashboard": "数据统计", "dashboard": "数据统计",
"platforms": "机器人", "platforms": "机器人",
"providers": "模型提供商", "providers": "模型提供商",
"commands": "指令管理",
"persona": "人格设定", "persona": "人格设定",
"toolUse": "MCP", "toolUse": "MCP",
"extension": "插件", "extension": "插件",
@@ -1,91 +0,0 @@
{
"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": "存在涉及系统插件的冲突,需解决冲突后才能隐藏"
}
}
@@ -13,8 +13,7 @@
"refresh": "刷新" "refresh": "刷新"
}, },
"batch": { "batch": {
"deleteSelected": "删除选中 ({count})", "deleteSelected": "删除选中 ({count})"
"exportSelected": "导出选中 ({count})"
}, },
"pagination": { "pagination": {
"itemsPerPage": "每页", "itemsPerPage": "每页",
@@ -77,8 +76,7 @@
"message": "确定要删除选中的 {count} 个对话吗?此操作不可恢复,请谨慎操作!", "message": "确定要删除选中的 {count} 个对话吗?此操作不可恢复,请谨慎操作!",
"andMore": "等 {count} 个", "andMore": "等 {count} 个",
"cancel": "取消", "cancel": "取消",
"confirm": "批量删除", "confirm": "批量删除"
"warning": "警告:此操作不可撤销!"
} }
}, },
"messages": { "messages": {
@@ -94,9 +92,6 @@
"noItemSelected": "请先选择要删除的对话", "noItemSelected": "请先选择要删除的对话",
"batchDeleteSuccess": "成功删除 {count} 个对话", "batchDeleteSuccess": "成功删除 {count} 个对话",
"batchDeleteError": "批量删除失败", "batchDeleteError": "批量删除失败",
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个", "batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个"
"exportSuccess": "导出成功",
"exportError": "导出失败",
"noItemSelectedForExport": "请先选择要导出的对话"
} }
} }
@@ -2,9 +2,7 @@
"title": "插件管理", "title": "插件管理",
"subtitle": "管理和配置系统插件", "subtitle": "管理和配置系统插件",
"tabs": { "tabs": {
"installedPlugins": "已安装的插件", "installed": "已安装",
"installedMcpServers": "已安装的 MCP 服务器",
"handlersOperation": "管理组件",
"market": "插件市场" "market": "插件市场"
}, },
"search": { "search": {
@@ -199,12 +197,5 @@
"errors": { "errors": {
"confirmNotRegistered": "$confirm 未正确注册" "confirmNotRegistered": "$confirm 未正确注册"
} }
},
"conflicts": {
"title": "检测到指令冲突",
"message": "这会导致部分指令工作异常,建议前往【指令管理】面板进行处理。",
"pairs": "对指令冲突",
"goToManage": "前往处理",
"later": "稍后处理"
} }
} }
@@ -42,10 +42,7 @@
"paramName": "参数名", "paramName": "参数名",
"type": "类型", "type": "类型",
"description": "描述", "description": "描述",
"required": "必填", "required": "必填"
"origin": "来源",
"originName": "来源名称",
"actions": "操作"
} }
}, },
"marketplace": { "marketplace": {
+2 -6
View File
@@ -32,7 +32,6 @@ 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';
@@ -69,7 +68,6 @@ 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';
@@ -113,8 +111,7 @@ export const translations = {
document: zhCNKnowledgeBaseDocument document: zhCNKnowledgeBaseDocument
}, },
persona: zhCNPersona, persona: zhCNPersona,
migration: zhCNMigration, migration: zhCNMigration
command: zhCNCommand
}, },
messages: { messages: {
errors: zhCNErrors, errors: zhCNErrors,
@@ -158,8 +155,7 @@ export const translations = {
document: enUSKnowledgeBaseDocument document: enUSKnowledgeBaseDocument
}, },
persona: enUSPersona, persona: enUSPersona,
migration: enUSMigration, migration: enUSMigration
command: enUSCommand
}, },
messages: { messages: {
errors: enUSErrors, errors: enUSErrors,
@@ -33,6 +33,11 @@ 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,6 +31,11 @@ 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',
+64 -30
View File
@@ -16,6 +16,21 @@ export const useCommonStore = defineStore({
}), }),
actions: { actions: {
async createEventSource() { async createEventSource() {
const fetchLogHistory = async () => {
try {
const res = await axios.get('/api/log-history');
if (res.data.data.logs) {
this.log_cache.push(...res.data.data.logs);
} else {
this.log_cache = [];
}
} catch (err) {
console.error('Failed to fetch log history:', err);
}
};
await fetchLogHistory();
if (this.eventSource) { if (this.eventSource) {
return return
} }
@@ -39,9 +54,25 @@ export const useCommonStore = defineStore({
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let bufferedText = '';
let incompleteLine = ""; // 用于存储不完整的行
const handleIncompleteLine = (line) => {
incompleteLine += line;
// if can parse as JSON, return it
try {
const data_json = JSON.parse(incompleteLine);
incompleteLine = ""; // 清空不完整行
return data_json;
} catch (e) {
return null;
}
}
const processStream = ({ done, value }) => { const processStream = ({ done, value }) => {
// get bytes length
const bytesLength = value ? value.byteLength : 0;
console.log(`Received ${bytesLength} bytes from live log`);
if (done) { if (done) {
console.log('SSE stream closed'); console.log('SSE stream closed');
setTimeout(() => { setTimeout(() => {
@@ -51,41 +82,44 @@ export const useCommonStore = defineStore({
return; return;
} }
// Accumulate partial chunks; SSE data may split JSON across reads. const text = decoder.decode(value);
const text = decoder.decode(value, { stream: true }); const lines = text.split('\n\n');
bufferedText += text; lines.forEach(line => {
if (!line.trim()) {
// Split completed events; keep the trailing partial in buffer.
const segments = bufferedText.split('\n\n');
bufferedText = segments.pop() || '';
segments.forEach(segment => {
const line = segment.trim();
if (!line.startsWith('data: ')) {
return; return;
} }
if (line.startsWith('data:')) {
const logLine = line.replace('data: ', '').trim(); const data = line.substring(5).trim();
if (!logLine) { // {"type":"log","data":"[2021-08-01 00:00:00] INFO: Hello, world!"}
return; let data_json = {}
} try {
data_json = JSON.parse(data);
try { } catch (e) {
const logObject = JSON.parse(logLine); console.warn('Invalid JSON:', data);
// give a uuid if not exists // 尝试处理不完整的行
if (!logObject.uuid) { const parsedData = handleIncompleteLine(data);
logObject.uuid = crypto.randomUUID(); if (parsedData) {
data_json = parsedData;
} else {
return; // 如果无法解析,跳过当前行
}
} }
this.log_cache.push(logObject); if (data_json.type === 'log') {
// Limit log cache size this.log_cache.push(data_json);
if (this.log_cache.length > this.log_cache_max_len) { if (this.log_cache.length > this.log_cache_max_len) {
this.log_cache.splice(0, this.log_cache.length - this.log_cache_max_len); this.log_cache.shift();
}
}
} else {
const parsedData = handleIncompleteLine(line);
if (parsedData && parsedData.type === 'log') {
this.log_cache.push(parsedData);
if (this.log_cache.length > this.log_cache_max_len) {
this.log_cache.shift();
}
} }
} catch (err) {
console.warn('Failed to parse SSE log line, skipping:', err, logLine);
} }
}); });
return reader.read().then(processStream); return reader.read().then(processStream);
}; };
+51 -89
View File
@@ -41,97 +41,59 @@ export function clearSidebarCustomization() {
} }
/** /**
* 解析侧边栏默认项与用户定制返回主区/更多区及可选的合并结果 * Apply customization to sidebar items
* @param {Array} defaultItems - 默认侧边栏结构 * @param {Array} defaultItems - Default sidebar items array
* @param {Object|null} customization - 用户定制mainItems/moreItems * @returns {Array} Customized sidebar items array (new array, doesn't mutate input)
* @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();
const { merged } = resolveSidebarItems(defaultItems, customization, { if (!customization) {
cloneItems: true, return defaultItems;
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;
} }
-58
View File
@@ -40,17 +40,6 @@
:loading="loading" size="small" class="mr-2"> :loading="loading" size="small" class="mr-2">
{{ tm('history.refresh') }} {{ tm('history.refresh') }}
</v-btn> </v-btn>
<v-btn
v-if="selectedItems.length > 0"
color="success"
prepend-icon="mdi-download"
variant="tonal"
@click="exportConversations"
:disabled="loading"
size="small"
class="mr-2">
{{ tm('batch.exportSelected', { count: selectedItems.length }) }}
</v-btn>
<v-btn <v-btn
v-if="selectedItems.length > 0" v-if="selectedItems.length > 0"
color="error" color="error"
@@ -921,53 +910,6 @@ export default {
} }
}, },
//
async exportConversations() {
if (this.selectedItems.length === 0) {
this.showErrorMessage(this.tm('messages.noItemSelectedForExport'));
return;
}
this.loading = true;
try {
//
const conversations = this.selectedItems.map(item => ({
user_id: item.user_id,
cid: item.cid
}));
const response = await axios.post('/api/conversation/export', {
conversations: conversations
}, {
responseType: 'blob' // axios blob
});
//
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
// 使
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const filename = `conversations_export_${timestamp}.jsonl`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
//
link.remove();
window.URL.revokeObjectURL(url);
this.showSuccessMessage(this.tm('messages.exportSuccess'));
} catch (error) {
console.error(this.tm('messages.exportError'), error);
this.showErrorMessage(error.response?.data?.message || error.message || this.tm('messages.exportError'));
} finally {
this.loading = false;
}
},
// //
formatTimestamp(timestamp) { formatTimestamp(timestamp) {
if (!timestamp) return this.tm('status.unknown'); if (!timestamp) return this.tm('status.unknown');
+6 -93
View File
@@ -5,45 +5,18 @@ 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, watch } from 'vue'; import { ref, computed, onMounted, reactive, inject, 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({
@@ -475,9 +448,7 @@ const pluginOn = async (extension) => {
return; return;
} }
toast(res.data.message, "success"); toast(res.data.message, "success");
await getExtensions(); getExtensions();
await checkAndPromptConflicts();
} catch (err) { } catch (err) {
toast(err, "error"); toast(err, "error");
} }
@@ -811,8 +782,6 @@ 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);
@@ -839,8 +808,6 @@ 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");
@@ -933,29 +900,21 @@ 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.installedPlugins') }} {{ tm('tabs.installed') }}
</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-if="activeTab === 'installed'" v-model="pluginSearch" density="compact" :label="tm('search.placeholder')" <v-text-field v-else 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>
@@ -1159,24 +1118,6 @@ 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'">
@@ -1603,34 +1544,6 @@ 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>
@@ -4,18 +4,42 @@
<!-- 页面标题 --> <!-- 页面标题 -->
<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" > @click="showMcpServerDialog = true" rounded="xl" size="x-large">
{{ 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>
@@ -33,6 +57,7 @@
</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">
@@ -42,7 +67,8 @@
<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 }) }} ({{ item.tools.length }}) {{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{
item.tools.length }})
</span> </span>
</template> </template>
<template v-slot:default="{ isActive }"> <template v-slot:default="{ isActive }">
@@ -52,7 +78,10 @@
</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;">{{ tool }}</li> <li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{
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">
@@ -62,6 +91,8 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</template> </template>
</v-dialog> </v-dialog>
</div> </div>
</div> </div>
@@ -74,6 +105,8 @@
<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>
@@ -150,7 +183,8 @@
</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">
@@ -206,8 +240,115 @@
</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" location="top"> <v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }} {{ save_message }}
</v-snackbar> </v-snackbar>
</div> </div>
@@ -215,13 +356,15 @@
<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: 'McpServersSection', name: 'ToolUsePage',
components: { components: {
AstrBotConfig,
VueMonacoEditor, VueMonacoEditor,
ItemCard ItemCard
}, },
@@ -234,15 +377,20 @@ export default {
return { return {
refreshInterval: null, refreshInterval: null,
mcpServers: [], mcpServers: [],
tools: [],
showMcpServerDialog: false, showMcpServerDialog: false,
selectedMcpServerProvider: 'modelscope',
mcpServerProviderList: ['modelscope'], selectedMcpServerProvider: "modelscope",
mcpServerProviderList: ["modelscope"],
mcpProviderToken: '', mcpProviderToken: '',
showSyncMcpServerDialog: false, showSyncMcpServerDialog: false,
addServerDialogMessage: '', addServerDialogMessage: "",
showToolsDialog: false,
showTools: true,
loading: false, loading: false,
loadingGettingServers: false, loadingGettingServers: false,
mcpServerUpdateLoaders: {}, mcpServerUpdateLoaders: {}, // record loading state for each server update
isEditMode: false, isEditMode: false,
serverConfigJson: '', serverConfigJson: '',
jsonError: null, jsonError: null,
@@ -252,50 +400,87 @@ 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;
} }
@@ -307,12 +492,24 @@ 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;
@@ -321,51 +518,61 @@ 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();
}) })
@@ -378,12 +585,14 @@ 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) {
const serverName = server.name || server; let 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 => {
@@ -391,22 +600,37 @@ export default {
}); });
} }
}, },
editServer(server) { editServer(server) {
//
const configCopy = { ...server }; const configCopy = { ...server };
delete configCopy.name;
delete configCopy.active; //
delete configCopy.tools; try {
delete configCopy.errlogs; delete configCopy.name;
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)
@@ -422,16 +646,20 @@ 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);
@@ -440,8 +668,9 @@ 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;
@@ -452,6 +681,7 @@ 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: '',
@@ -462,26 +692,58 @@ 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'));
@@ -490,33 +752,61 @@ 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) {
this.showError(this.tm('syncProvider.messages.syncError', { console.error('同步 MCP 服务器失败:', error);
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题' this.showError(this.tm('syncProvider.messages.syncError', {
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: 0px; padding: 20px;
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;
@@ -524,4 +814,4 @@ export default {
margin-top: 4px; margin-top: 4px;
overflow: hidden; overflow: hidden;
} }
</style> </style>
@@ -71,7 +71,6 @@ 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("管理面板更新完成。"))
+28 -53
View File
@@ -3,7 +3,6 @@ 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
@@ -22,46 +21,6 @@ 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 = ""
@@ -71,18 +30,34 @@ 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_parts = [ msg = f"""AstrBot v{VERSION}(WebUI: {dashboard_version})
f"AstrBot v{VERSION}(WebUI: {dashboard_version})", 内置指令:
"内置指令:", [System]
commands_section, /plugin: 查看插件插件帮助
] /t2i: 开关文本转图片
if notice: /tts: 开关文本转语音
msg_parts.append(notice) /sid: 获取会话 ID
msg = "\n".join(msg_parts) /op: 管理员
/wl: 白名单
/dashboard_update: 更新管理面板(op)
/alter_cmd: 设置指令权限(op)
[大模型]
/llm: 开启/关闭 LLM
/provider: 大模型提供商
/model: 模型列表
/ls: 对话列表
/new: 创建新对话
/groupnew 群号: 为群聊创建新对话(op)
/switch 序号: 切换对话
/rename 新名字: 重命名当前对话
/del: 删除当前会话对话(op)
/reset: 重置 LLM 会话
/history: 当前对话的对话记录
/persona: 人格情景(op)
/key: API Key(op)
/websearch: 网页搜索
{notice}"""
event.set_result(MessageEventResult().message(msg).use_t2i(False)) event.set_result(MessageEventResult().message(msg).use_t2i(False))
+2 -3
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,7 +219,6 @@ 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,7 +185,6 @@ 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 中开启或关闭网页搜索功能。",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.9.0" version = "4.8.0"
description = "Easy-to-use multi-platform LLM chatbot and development framework" description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
-28
View File
@@ -160,34 +160,6 @@ 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()
+279
View File
@@ -0,0 +1,279 @@
"""Test GitHub webhook platform adapter"""
import asyncio
import hashlib
import hmac
from unittest.mock import MagicMock
import pytest
from astrbot.core.platform.sources.github_webhook.github_webhook_adapter import (
GitHubWebhookPlatformAdapter,
)
@pytest.fixture
def event_queue():
"""Create a test event queue"""
return asyncio.Queue()
@pytest.fixture
def platform_config():
"""Create test platform configuration"""
return {
"type": "github_webhook",
"enable": True,
"id": "test_github_webhook",
"unified_webhook_mode": True,
"webhook_uuid": "test-uuid-123",
"webhook_secret": "", # No secret by default for easier testing
}
@pytest.fixture
def platform_settings():
"""Create test platform settings"""
return {"unique_session": False}
@pytest.fixture
def adapter(platform_config, platform_settings, event_queue):
"""Create test adapter instance"""
return GitHubWebhookPlatformAdapter(platform_config, platform_settings, event_queue)
class TestGitHubWebhookAdapter:
"""Test cases for GitHub webhook adapter"""
def test_adapter_initialization(self, adapter):
"""Test adapter is initialized correctly"""
assert adapter.unified_webhook_mode is True
assert adapter.webhook_secret == ""
assert adapter.meta().name == "github_webhook"
assert adapter.meta().description == "GitHub Webhook 适配器"
@pytest.mark.asyncio
async def test_ping_event(self, adapter):
"""Test GitHub ping event"""
# Mock request
request = MagicMock()
request.headers.get.return_value = "ping"
async def mock_json():
return {}
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"message": "pong"}
@pytest.mark.asyncio
async def test_issue_created_event(self, adapter, event_queue):
"""Test GitHub issue created event"""
# Mock request with issue created payload
request = MagicMock()
request.headers.get.return_value = "issues"
payload = {
"action": "opened",
"issue": {
"title": "Test Issue",
"body": "This is a test issue",
"html_url": "https://github.com/test/repo/issues/1",
},
"repository": {"full_name": "test/repo"},
"sender": {"login": "testuser"},
}
async def mock_json():
return payload
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"status": "ok"}
# Verify event was queued
assert not event_queue.empty()
event = event_queue.get_nowait()
assert event.event_type == "issues"
assert "新 Issue 创建" in event.message_str
assert "Test Issue" in event.message_str
@pytest.mark.asyncio
async def test_issue_comment_event(self, adapter, event_queue):
"""Test GitHub issue comment event"""
request = MagicMock()
request.headers.get.return_value = "issue_comment"
payload = {
"action": "created",
"issue": {"title": "Test Issue"},
"comment": {
"body": "Test comment",
"html_url": "https://github.com/test/repo/issues/1#comment",
},
"repository": {"full_name": "test/repo"},
"sender": {"login": "commenter"},
}
async def mock_json():
return payload
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"status": "ok"}
# Verify event was queued
assert not event_queue.empty()
event = event_queue.get_nowait()
assert event.event_type == "issue_comment"
assert "新 Issue 评论" in event.message_str
assert "Test comment" in event.message_str
@pytest.mark.asyncio
async def test_pull_request_event(self, adapter, event_queue):
"""Test GitHub pull request opened event"""
request = MagicMock()
request.headers.get.return_value = "pull_request"
payload = {
"action": "opened",
"pull_request": {
"title": "Test PR",
"body": "This is a test PR",
"html_url": "https://github.com/test/repo/pull/1",
},
"repository": {"full_name": "test/repo"},
"sender": {"login": "prauthor"},
}
async def mock_json():
return payload
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"status": "ok"}
# Verify event was queued
assert not event_queue.empty()
event = event_queue.get_nowait()
assert event.event_type == "pull_request"
assert "新 Pull Request" in event.message_str
assert "Test PR" in event.message_str
@pytest.mark.asyncio
async def test_unsupported_event(self, adapter, event_queue):
"""Test unsupported GitHub event type"""
request = MagicMock()
request.headers.get.return_value = "push"
async def mock_json():
return {"action": "created"}
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"status": "ok"}
# Verify no event was queued for unsupported events
assert event_queue.empty()
@pytest.mark.asyncio
async def test_issue_closed_ignored(self, adapter, event_queue):
"""Test that issue closed action is ignored"""
request = MagicMock()
request.headers.get.return_value = "issues"
payload = {
"action": "closed", # Should be ignored
"issue": {"title": "Test Issue"},
"repository": {"full_name": "test/repo"},
"sender": {"login": "testuser"},
}
async def mock_json():
return payload
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"status": "ok"}
# Verify no event was queued
assert event_queue.empty()
@pytest.mark.asyncio
async def test_signature_verification(self, platform_settings, event_queue):
"""Test webhook signature verification"""
# Create adapter with webhook secret
config_with_secret = {
"type": "github_webhook",
"enable": True,
"id": "test_github_webhook",
"unified_webhook_mode": True,
"webhook_uuid": "test-uuid-123",
"webhook_secret": "test-secret",
}
adapter = GitHubWebhookPlatformAdapter(
config_with_secret, platform_settings, event_queue
)
# Create a valid signature
body = b'{"action": "opened"}'
signature = hmac.new(b"test-secret", body, hashlib.sha256).hexdigest()
# Mock request with valid signature
request = MagicMock()
request.headers.get = lambda key, default="": {
"X-GitHub-Event": "ping",
"X-Hub-Signature-256": f"sha256={signature}",
}.get(key, default)
async def mock_get_data():
return body
request.get_data = mock_get_data
async def mock_json():
return {"action": "opened"}
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == {"message": "pong"}
@pytest.mark.asyncio
async def test_invalid_signature(self, platform_settings, event_queue):
"""Test webhook with invalid signature is rejected"""
# Create adapter with webhook secret
config_with_secret = {
"type": "github_webhook",
"enable": True,
"id": "test_github_webhook",
"unified_webhook_mode": True,
"webhook_uuid": "test-uuid-123",
"webhook_secret": "test-secret",
}
adapter = GitHubWebhookPlatformAdapter(
config_with_secret, platform_settings, event_queue
)
# Mock request with invalid signature
request = MagicMock()
request.headers.get = lambda key, default="": {
"X-GitHub-Event": "ping",
"X-Hub-Signature-256": "sha256=invalidsignature",
}.get(key, default)
async def mock_get_data():
return b'{"action": "opened"}'
request.get_data = mock_get_data
async def mock_json():
return {"action": "opened"}
request.json = mock_json()
response = await adapter.webhook_callback(request)
assert response == ({"error": "Invalid signature"}, 401)
-209
View File
@@ -1,209 +0,0 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from quart import Quart
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.models import KBDocument
from astrbot.dashboard.server import AstrBotDashboard
@pytest_asyncio.fixture(scope="module")
async def core_lifecycle_td(tmp_path_factory):
"""Creates and initializes a core lifecycle instance with a temporary database."""
tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_kb.db"
db = SQLiteDatabase(str(tmp_db_path))
log_broker = LogBroker()
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
await core_lifecycle.initialize()
# Mock kb_manager and kb_helper
kb_manager = MagicMock()
kb_helper = AsyncMock(spec=KBHelper)
# Configure get_kb to be an async mock that returns kb_helper
kb_manager.get_kb = AsyncMock(return_value=kb_helper)
# Mock upload_document return value
mock_doc = KBDocument(
doc_id="test_doc_id",
kb_id="test_kb_id",
doc_name="test_file.txt",
file_type="txt",
file_size=100,
file_path="",
chunk_count=2,
media_count=0,
)
kb_helper.upload_document.return_value = mock_doc
# kb_manager.get_kb.return_value = kb_helper # Removed this line as it's handled above
core_lifecycle.kb_manager = kb_manager
try:
yield core_lifecycle
finally:
try:
_stop_res = core_lifecycle.stop()
if asyncio.iscoroutine(_stop_res):
await _stop_res
except Exception:
pass
@pytest.fixture(scope="module")
def app(core_lifecycle_td: AstrBotCoreLifecycle):
"""Creates a Quart app instance for testing."""
shutdown_event = asyncio.Event()
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
return server.app
@pytest_asyncio.fixture(scope="module")
async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
"""Handles login and returns an authenticated header."""
test_client = app.test_client()
response = await test_client.post(
"/api/auth/login",
json={
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
"password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
},
)
data = await response.get_json()
assert data["status"] == "ok"
token = data["data"]["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_import_documents(
app: Quart, authenticated_header: dict, core_lifecycle_td: AstrBotCoreLifecycle
):
"""Tests the import documents functionality."""
test_client = app.test_client()
# Test data
import_data = {
"kb_id": "test_kb_id",
"documents": [
{"file_name": "test_file_1.txt", "chunks": ["chunk1", "chunk2"]},
{"file_name": "test_file_2.md", "chunks": ["chunk3", "chunk4", "chunk5"]},
],
}
# Send request
response = await test_client.post(
"/api/kb/document/import", json=import_data, headers=authenticated_header
)
# Verify response
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert "task_id" in data["data"]
assert data["data"]["doc_count"] == 2
task_id = data["data"]["task_id"]
# Wait for background task to complete (mocked)
# Since we mocked upload_document, it should be fast, but we might need to poll progress
for _ in range(10):
progress_response = await test_client.get(
f"/api/kb/document/upload/progress?task_id={task_id}",
headers=authenticated_header,
)
progress_data = await progress_response.get_json()
if progress_data["data"]["status"] == "completed":
break
await asyncio.sleep(0.1)
assert progress_data["data"]["status"] == "completed"
result = progress_data["data"]["result"]
assert result["success_count"] == 2
assert result["failed_count"] == 0
# Verify kb_helper.upload_document was called correctly
kb_helper = await core_lifecycle_td.kb_manager.get_kb("test_kb_id")
assert kb_helper.upload_document.call_count == 2
# Check first call arguments
call_args_list = kb_helper.upload_document.call_args_list
# First document
args1, kwargs1 = call_args_list[0]
assert kwargs1["file_name"] == "test_file_1.txt"
assert kwargs1["pre_chunked_text"] == ["chunk1", "chunk2"]
# Second document
args2, kwargs2 = call_args_list[1]
assert kwargs2["file_name"] == "test_file_2.md"
assert kwargs2["pre_chunked_text"] == ["chunk3", "chunk4", "chunk5"]
@pytest.mark.asyncio
async def test_import_documents_invalid_input(app: Quart, authenticated_header: dict):
"""Tests import documents with invalid input."""
test_client = app.test_client()
# Missing kb_id
response = await test_client.post(
"/api/kb/document/import", json={"documents": []}, headers=authenticated_header
)
data = await response.get_json()
assert data["status"] == "error"
assert "缺少参数 kb_id" in data["message"]
# Missing documents
response = await test_client.post(
"/api/kb/document/import",
json={"kb_id": "test_kb"},
headers=authenticated_header,
)
data = await response.get_json()
assert data["status"] == "error"
assert "缺少参数 documents" in data["message"]
# Invalid document format
response = await test_client.post(
"/api/kb/document/import",
json={
"kb_id": "test_kb",
"documents": [{"file_name": "test"}], # Missing chunks
},
headers=authenticated_header,
)
data = await response.get_json()
assert data["status"] == "error"
assert "文档格式错误" in data["message"]
# Invalid chunks type
response = await test_client.post(
"/api/kb/document/import",
json={
"kb_id": "test_kb",
"documents": [{"file_name": "test", "chunks": "not-a-list"}],
},
headers=authenticated_header,
)
data = await response.get_json()
assert data["status"] == "error"
assert "chunks 必须是列表" in data["message"]
# Invalid chunks content
response = await test_client.post(
"/api/kb/document/import",
json={
"kb_id": "test_kb",
"documents": [{"file_name": "test", "chunks": ["valid", ""]}],
},
headers=authenticated_header,
)
data = await response.get_json()
assert data["status"] == "error"
assert "chunks 必须是非空字符串列表" in data["message"]