Compare commits

...

49 Commits

Author SHA1 Message Date
Soulter 8b9abc093a refactor: remove unused imports in component panel 2025-12-15 00:50:06 +08:00
Soulter eeec6bcc48 refactor: move mcp and command page to extension page 2025-12-15 00:44:37 +08:00
Soulter bd1c1c7e4f Merge remote-tracking branch 'origin/master' into feature/command-panel 2025-12-14 22:06:13 +08:00
Copilot 65da469deb feat: add conversation export feature to JSONL for AI training (#4037)
* Initial plan

* Add conversation export functionality (backend and frontend)

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Address code review feedback: move imports, simplify logic, improve i18n

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Simplify frontend download logic: remove redundant Blob wrapper and complex filename parsing

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: update conversation export filename format for consistency

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2025-12-14 21:44:12 +08:00
Soulter 16df64c405 fix: lark domain and log_level of Lark API client (#4038)
fixes: #4035
2025-12-14 21:31:17 +08:00
i0cLiceao 6b73b19e54 fix: support using GitHub Raw content as plugin source (#3975)
* Update plugin.py

* Update plugin.py

* Update plugin.py

* Update plugin.py
2025-12-14 18:23:29 +08:00
Soulter e7e97730af chore: bump version to 4.9.0 2025-12-13 18:49:07 +08:00
Soulter 467ca1eb5c fix: webui log output incompletely (#4029)
* fix: webui log output incompletely

* fix: improve SSE log parsing to handle partial data chunks

* fix: enhance log handling by implementing local cache and fetching history

* fix: log time handling to use epoch time
2025-12-13 18:46:16 +08:00
RC-CHN 46528391c2 feat: add pre-chunk import strategy for knowledge base (#3973)
* feat: 添加文档导入功能及相关测试

* feat: 优化文档上传功能,支持从文件名推断文件类型,并增强文档切片验证

* feat: 添加文档导入功能的无效输入测试,验证 chunks 类型和内容的错误处理

* refactor: 重构文档上传和导入任务的状态管理,添加任务初始化、结果设置和进度更新方法
2025-12-12 23:15:11 +08:00
Oscar 2387abe570 refactor(sidebar): 提取侧边栏项目解析逻辑到工具函数复用 2025-12-11 17:14:02 +08:00
Oscar 7e43cca134 perf(db): 优化重构command相关数据库操作 2025-12-11 17:00:15 +08:00
Oscar 89fdb18936 perf(command): 优化指令管理辅助函数和配置绑定逻辑 2025-12-11 16:51:10 +08:00
Oscar e710454d18 perf(command): 优化命令冲突计数逻辑 2025-12-11 15:45:14 +08:00
Oscar 20024cfec9 perf(dashboard): 删除多余的CommandPage.vue文件(已被模块化引用) 2025-12-11 15:36:22 +08:00
Oscar 042c507127 refactor(commandPanel): 移除未使用的 filterState 常量 2025-12-11 15:28:55 +08:00
Oscar 5e83a19ac5 style(builtin_commands): 补充命令描述 2025-12-11 14:48:28 +08:00
Oscar 1f87984133 Merge branch 'master' of https://github.com/ocetars/AstrBot-OscarDev into feature/command-panel 2025-12-11 14:45:01 +08:00
Oscar c6739105c4 refactor(commands): 重构/help指令以动态显示实际命令并补充部分命令描述 2025-12-11 14:34:46 +08:00
Oscar 6fd86eda13 fix(sidebar): 补全新增侧边栏项后的侧边栏位追加逻辑 2025-12-08 17:28:58 +08:00
Ocetars 693f2988be fix(command): 确保新命令配置的事务提交 2025-12-04 16:38:27 +08:00
Ocetars adcffcc466 style(commandPanel): 微调指令面板UI 2025-12-04 16:17:12 +08:00
Ocetars 238aa30331 refactor(commandPanel): 重命名指令模块目录为 commandPanel 2025-12-04 16:05:49 +08:00
Ocetars 26a27776ab refactor(command): 模块化指令管理面板前端代码 2025-12-04 15:58:11 +08:00
Ocetars eb2c88f802 style(extension): 文案修改 2025-12-04 15:23:53 +08:00
Ocetars 81a0e0f28e refactor(command): 移除指令表格内部加载指示器 2025-12-04 15:12:10 +08:00
Ocetars aa61815fcd feat(extension): 添加插件指令冲突检测与提示
- 在插件安装或启用后,自动检测并提示指令冲突。
- 当检测到指令冲突时,显示警告对话框,告知用户冲突数量及可能的影响。
2025-12-03 20:58:53 +08:00
Ocetars f34902574f style(command): 更新空状态描述 2025-12-03 19:55:42 +08:00
Ocetars b1b031077c refactor(command): 更新指令数展示逻辑 2025-12-03 19:48:22 +08:00
Ocetars 7f0e011126 feat(command): 添加系统插件指令过滤与冲突处理 2025-12-03 19:41:27 +08:00
Ocetars 7b7d9f1b8c style(command-page): 优化命令列表UI 2025-12-03 19:19:56 +08:00
Ocetars fe040da7a4 refactor(command): 修改指令列表排序逻辑 2025-12-03 19:10:20 +08:00
Ocetars b98cd1bd72 style(command): 优化指令组子指令数量显示UI 2025-12-03 18:09:12 +08:00
Ocetars 7fa71c538e feat(command-management): 新增指令层级管理与UI展示
- 【后端】
  - `CommandDescriptor` 新增 `parent_group_handler` 和 `sub_commands` 字段,支持指令层级结构定义。
  - `list_commands` 函数重构,实现指令的层级收集与构建,将子指令正确挂载到其父指令组下。
  - 新增 `_collect_all_descriptors` 和 `_find_parent_group_handler` 辅助函数,用于全面收集指令并定位父指令组。
  - `_build_descriptor` 优化指令类型判断逻辑,明确区分普通指令、指令组和子指令。
  - `_descriptor_to_dict` 递归处理子指令,确保 API 返回完整的指令层级数据。
- 【前端】
  - 指令管理页面 (`CommandPage.vue`) 增加指令类型筛选器,并支持指令组的展开/折叠功能。
  - 表格展示优化,为指令组和子指令添加不同的样式和缩进,提升层级结构的视觉可读性。
  - 指令详情对话框新增指令类型、所属指令组和子指令列表的展示。
  - 更新 `CommandItem` 接口,以适配后端提供的层级数据结构。
- 【i18n】
  - 新增指令类型(指令、指令组、子指令)的国际化文本。
  - 更新指令管理相关 UI 文本,包括表格头部、详情对话框字段和筛选器选项。
2025-12-03 17:58:52 +08:00
Ocetars 97c0be85e4 refactor(command): 调整指令管理中的成员权限显示与筛选
- 更新指令筛选逻辑,当选择“所有人”权限筛选时,将同时包含 `everyone` 和 `member` 权限的指令。
2025-12-03 17:12:09 +08:00
Ocetars b1273ff997 style: UI 细节 2025-12-03 15:55:11 +08:00
Ocetars e560f396c5 refactor(command): 优化指令页面布局并更新冲突警告
- 【布局优化】重新组织指令管理页面布局,将筛选器移至顶部独立行
- 【信息展示】将搜索栏与总指令数、已禁用指令数合并显示,提升页面空间利用率
- 【视觉更新】更新指令冲突警告样式
2025-12-03 15:45:12 +08:00
Ocetars 9c842ecd03 style(command-page): 调整命令页面表格样式和图标大小 2025-12-03 15:24:53 +08:00
Ocetars 281ac6dcfe chore(command-page): 禁用命令表格部分列的排序功能 2025-12-03 15:11:36 +08:00
Ocetars 7aa44ba3d8 feat(command): 优化指令冲突显示与提示
- 【功能】新增指令冲突警告提示,当检测到冲突时显示详细信息及解决方案。
- 【优化】调整指令列表排序逻辑,将冲突指令优先显示并分组。
- 【样式】为冲突指令行添加专属高亮样式,提升视觉识别度。
- 【国际化】更新英文和中文多语言文件,增加指令冲突警告相关的翻译文本。
2025-12-03 15:04:30 +08:00
Ocetars 8144b61ae0 fix(command): 排除已禁用指令的冲突检测
- 只有 `effective_command` 存在且 `enabled` 为 `True` 的指令才会被纳入冲突检测范围。
2025-12-03 14:43:18 +08:00
Ocetars 3da0c77e87 fix(command): 修正指令冲突检测逻辑 2025-12-03 14:34:25 +08:00
Ocetars 5e7a0591d9 refactor(command): 移除指令重命名时的别名功能 2025-12-03 14:25:49 +08:00
Ocetars 09d6b715f0 test: 新增命令管理相关测试 2025-12-02 20:57:03 +08:00
Ocetars f0770c5c4d feat: 新增命令管理国际化支持 2025-12-02 20:56:33 +08:00
Ocetars 0858ec4cba feat: 新增命令管理界面页面 2025-12-02 20:56:21 +08:00
Ocetars ae07835da7 feat: 新增命令管理后台 API 2025-12-02 20:56:05 +08:00
Ocetars 6ba1c51cd2 feat: 将命令管理集成到 Star 框架 2025-12-02 20:55:24 +08:00
Ocetars 2dc28eff89 feat: 实现核心命令管理系统 2025-12-02 20:55:14 +08:00
Ocetars 68c1e4ecf9 feat: 新增命令配置数据库模型 2025-12-02 20:53:53 +08:00
62 changed files with 3857 additions and 667 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.8.0"
__version__ = "4.9.0"
+1 -1
View File
@@ -4,7 +4,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.8.0"
VERSION = "4.9.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
+72
View File
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -314,6 +316,76 @@ class BaseDatabase(abc.ABC):
"""Clear all preferences for a specific scope ID."""
...
@abc.abstractmethod
async def get_command_configs(self) -> list[CommandConfig]:
"""Get all stored command configurations."""
...
@abc.abstractmethod
async def get_command_config(self, handler_full_name: str) -> CommandConfig | None:
"""Fetch a single command configuration by handler."""
...
@abc.abstractmethod
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
"""Create or update a command configuration."""
...
@abc.abstractmethod
async def delete_command_config(self, handler_full_name: str) -> None:
"""Delete a single command configuration."""
...
@abc.abstractmethod
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
"""Bulk delete command configurations."""
...
@abc.abstractmethod
async def list_command_conflicts(
self,
status: str | None = None,
) -> list[CommandConflict]:
"""List recorded command conflict entries."""
...
@abc.abstractmethod
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
"""Create or update a conflict record."""
...
@abc.abstractmethod
async def delete_command_conflicts(self, ids: list[int]) -> None:
"""Delete conflict records."""
...
# @abc.abstractmethod
# async def insert_llm_message(
# self,
+59
View File
@@ -234,6 +234,65 @@ class Attachment(SQLModel, table=True):
)
class CommandConfig(SQLModel, table=True):
"""Per-command configuration overrides for dashboard management."""
__tablename__ = "command_configs" # type: ignore
handler_full_name: str = Field(
primary_key=True,
max_length=512,
)
plugin_name: str = Field(nullable=False, max_length=255)
module_path: str = Field(nullable=False, max_length=255)
original_command: str = Field(nullable=False, max_length=255)
resolved_command: str | None = Field(default=None, max_length=255)
enabled: bool = Field(default=True, nullable=False)
keep_original_alias: bool = Field(default=False, nullable=False)
conflict_key: str | None = Field(default=None, max_length=255)
resolution_strategy: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_managed: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class CommandConflict(SQLModel, table=True):
"""Conflict tracking for duplicated command names."""
__tablename__ = "command_conflicts" # type: ignore
id: int | None = Field(
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
)
conflict_key: str = Field(nullable=False, max_length=255)
handler_full_name: str = Field(nullable=False, max_length=512)
plugin_name: str = Field(nullable=False, max_length=255)
status: str = Field(default="pending", max_length=32)
resolution: str | None = Field(default=None, max_length=64)
resolved_command: str | None = Field(default=None, max_length=255)
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_generated: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"conflict_key",
"handler_full_name",
name="uix_conflict_handler",
),
)
@dataclass
class Conversation:
"""LLM 对话类
+240
View File
@@ -1,6 +1,7 @@
import asyncio
import threading
import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult
@@ -10,6 +11,8 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
)
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
TxResult = T.TypeVar("TxResult")
class SQLiteDatabase(BaseDatabase):
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
)
await session.commit()
# ====
# Command Configuration & Conflict Tracking
# ====
async def _run_in_tx(
self,
fn: Callable[[AsyncSession], Awaitable[TxResult]],
) -> TxResult:
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
return await fn(session)
@staticmethod
def _apply_updates(model, **updates) -> None:
for field, value in updates.items():
if value is not None:
setattr(model, field, value)
@staticmethod
def _new_command_config(
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
return CommandConfig(
handler_full_name=handler_full_name,
plugin_name=plugin_name,
module_path=module_path,
original_command=original_command,
resolved_command=resolved_command,
enabled=True if enabled is None else enabled,
keep_original_alias=False
if keep_original_alias is None
else keep_original_alias,
conflict_key=conflict_key or original_command,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=bool(auto_managed),
)
@staticmethod
def _new_command_conflict(
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
return CommandConflict(
conflict_key=conflict_key,
handler_full_name=handler_full_name,
plugin_name=plugin_name,
status=status or "pending",
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=bool(auto_generated),
)
async def get_command_configs(self) -> list[CommandConfig]:
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(select(CommandConfig))
return list(result.scalars().all())
async def get_command_config(
self,
handler_full_name: str,
) -> CommandConfig | None:
async with self.get_db() as session:
session: AsyncSession
return await session.get(CommandConfig, handler_full_name)
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
async def _op(session: AsyncSession) -> CommandConfig:
config = await session.get(CommandConfig, handler_full_name)
if not config:
config = self._new_command_config(
handler_full_name,
plugin_name,
module_path,
original_command,
resolved_command=resolved_command,
enabled=enabled,
keep_original_alias=keep_original_alias,
conflict_key=conflict_key,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=auto_managed,
)
session.add(config)
else:
self._apply_updates(
config,
plugin_name=plugin_name,
module_path=module_path,
original_command=original_command,
resolved_command=resolved_command,
enabled=enabled,
keep_original_alias=keep_original_alias,
conflict_key=conflict_key,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=auto_managed,
)
await session.flush()
await session.refresh(config)
return config
return await self._run_in_tx(_op)
async def delete_command_config(self, handler_full_name: str) -> None:
await self.delete_command_configs([handler_full_name])
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
if not handler_full_names:
return
async def _op(session: AsyncSession) -> None:
await session.execute(
delete(CommandConfig).where(
col(CommandConfig.handler_full_name).in_(handler_full_names),
),
)
await self._run_in_tx(_op)
async def list_command_conflicts(
self,
status: str | None = None,
) -> list[CommandConflict]:
async with self.get_db() as session:
session: AsyncSession
query = select(CommandConflict)
if status:
query = query.where(CommandConflict.status == status)
result = await session.execute(query)
return list(result.scalars().all())
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
async def _op(session: AsyncSession) -> CommandConflict:
result = await session.execute(
select(CommandConflict).where(
CommandConflict.conflict_key == conflict_key,
CommandConflict.handler_full_name == handler_full_name,
),
)
record = result.scalar_one_or_none()
if not record:
record = self._new_command_conflict(
conflict_key,
handler_full_name,
plugin_name,
status=status,
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=auto_generated,
)
session.add(record)
else:
self._apply_updates(
record,
plugin_name=plugin_name,
status=status,
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=auto_generated,
)
await session.flush()
await session.refresh(record)
return record
return await self._run_in_tx(_op)
async def delete_command_conflicts(self, ids: list[int]) -> None:
if not ids:
return
async def _op(session: AsyncSession) -> None:
await session.execute(
delete(CommandConflict).where(col(CommandConflict.id).in_(ids)),
)
await self._run_in_tx(_op)
# ====
# Deprecated Methods
# ====
+2 -1
View File
@@ -24,6 +24,7 @@ import asyncio
import logging
import os
import sys
import time
from asyncio import Queue
from collections import deque
@@ -148,7 +149,7 @@ class LogQueueHandler(logging.Handler):
self.log_broker.publish(
{
"level": record.levelname,
"time": record.asctime,
"time": time.time(),
"data": log_entry,
},
)
@@ -81,7 +81,12 @@ class LarkPlatformAdapter(Platform):
)
self.lark_api = (
lark.Client.builder().app_id(self.appid).app_secret(self.appsecret).build()
lark.Client.builder()
.app_id(self.appid)
.app_secret(self.appsecret)
.log_level(lark.LogLevel.ERROR)
.domain(self.domain)
.build()
)
self.webhook_server = None
+449
View File
@@ -0,0 +1,449 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from astrbot.core import db_helper
from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
@dataclass
class CommandDescriptor:
handler: StarHandlerMetadata = field(repr=False)
filter_ref: CommandFilter | CommandGroupFilter | None = field(
default=None,
repr=False,
)
handler_full_name: str = ""
handler_name: str = ""
plugin_name: str = ""
plugin_display_name: str | None = None
module_path: str = ""
description: str = ""
command_type: str = "command" # "command" | "group" | "sub_command"
raw_command_name: str | None = None
current_fragment: str | None = None
parent_signature: str = ""
parent_group_handler: str = ""
original_command: str | None = None
effective_command: str | None = None
aliases: list[str] = field(default_factory=list)
permission: str = "everyone"
enabled: bool = True
is_group: bool = False
is_sub_command: bool = False
reserved: bool = False
config: CommandConfig | None = None
has_conflict: bool = False
sub_commands: list[CommandDescriptor] = field(default_factory=list)
async def sync_command_configs() -> None:
"""同步指令配置,清理过期配置。"""
descriptors = _collect_descriptors(include_sub_commands=False)
config_records = await db_helper.get_command_configs()
config_map = _bind_configs_to_descriptors(descriptors, config_records)
live_handlers = {desc.handler_full_name for desc in descriptors}
stale_configs = [key for key in config_map if key not in live_handlers]
if stale_configs:
await db_helper.delete_command_configs(stale_configs)
async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
plugin_name=descriptor.plugin_name or "",
module_path=descriptor.module_path,
original_command=descriptor.original_command or descriptor.handler_name,
resolved_command=(
existing_cfg.resolved_command
if existing_cfg
else descriptor.current_fragment
),
enabled=enabled,
keep_original_alias=False,
conflict_key=existing_cfg.conflict_key
if existing_cfg and existing_cfg.conflict_key
else descriptor.original_command,
resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None,
note=existing_cfg.note if existing_cfg else None,
extra_data=existing_cfg.extra_data if existing_cfg else None,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
await sync_command_configs()
return descriptor
async def rename_command(
handler_full_name: str,
new_fragment: str,
) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
new_fragment = new_fragment.strip()
if not new_fragment:
raise ValueError("指令名不能为空。")
candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
if _is_command_in_use(handler_full_name, candidate_full):
raise ValueError("新的指令名已被其他指令占用,请换一个名称。")
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
plugin_name=descriptor.plugin_name or "",
module_path=descriptor.module_path,
original_command=descriptor.original_command or descriptor.handler_name,
resolved_command=new_fragment,
enabled=True if descriptor.enabled else False,
keep_original_alias=False,
conflict_key=descriptor.original_command,
resolution_strategy="manual_rename",
note=None,
extra_data=None,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
await sync_command_configs()
return descriptor
async def list_commands() -> list[dict[str, Any]]:
descriptors = _collect_descriptors(include_sub_commands=True)
config_records = await db_helper.get_command_configs()
_bind_configs_to_descriptors(descriptors, config_records)
conflict_groups = _group_conflicts(descriptors)
conflict_handler_names: set[str] = {
d.handler_full_name for group in conflict_groups.values() for d in group
}
# 分类,设置冲突标志,将子指令挂载到父指令组
group_map: dict[str, CommandDescriptor] = {}
sub_commands: list[CommandDescriptor] = []
root_commands: list[CommandDescriptor] = []
for desc in descriptors:
desc.has_conflict = desc.handler_full_name in conflict_handler_names
if desc.is_group:
group_map[desc.handler_full_name] = desc
elif desc.is_sub_command:
sub_commands.append(desc)
else:
root_commands.append(desc)
for sub in sub_commands:
if sub.parent_group_handler and sub.parent_group_handler in group_map:
group_map[sub.parent_group_handler].sub_commands.append(sub)
else:
root_commands.append(sub)
# 指令组 + 普通指令,按 effective_command 字母排序
all_commands = list(group_map.values()) + root_commands
all_commands.sort(key=lambda d: (d.effective_command or "").lower())
result = [_descriptor_to_dict(desc) for desc in all_commands]
return result
async def list_command_conflicts() -> list[dict[str, Any]]:
"""列出所有冲突的指令组。"""
descriptors = _collect_descriptors(include_sub_commands=False)
config_records = await db_helper.get_command_configs()
_bind_configs_to_descriptors(descriptors, config_records)
conflict_groups = _group_conflicts(descriptors)
details = [
{
"conflict_key": key,
"handlers": [
{
"handler_full_name": item.handler_full_name,
"plugin": item.plugin_name,
"current_name": item.effective_command,
}
for item in group
],
}
for key, group in conflict_groups.items()
]
return details
# Internal helpers ----------------------------------------------------------
def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
"""收集指令,按需包含子指令。"""
descriptors: list[CommandDescriptor] = []
for handler in star_handlers_registry:
desc = _build_descriptor(handler)
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
return descriptors
def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None:
filter_ref = _locate_primary_filter(handler)
if filter_ref is None:
return None
plugin_meta = star_map.get(handler.handler_module_path)
plugin_name = (
plugin_meta.name if plugin_meta else None
) or handler.handler_module_path
plugin_display = plugin_meta.display_name if plugin_meta else None
is_sub_command = bool(handler.extras_configs.get("sub_command"))
parent_group_handler = ""
if isinstance(filter_ref, CommandFilter):
raw_fragment = getattr(
filter_ref, "_original_command_name", filter_ref.command_name
)
current_fragment = filter_ref.command_name
parent_signature = (filter_ref.parent_command_names or [""])[0].strip()
# 如果是子指令,尝试找到父指令组的 handler_full_name
if is_sub_command and parent_signature:
parent_group_handler = _find_parent_group_handler(
handler.handler_module_path, parent_signature
)
else:
raw_fragment = getattr(
filter_ref, "_original_group_name", filter_ref.group_name
)
current_fragment = filter_ref.group_name
parent_signature = _resolve_group_parent_signature(filter_ref)
original_command = _compose_command(parent_signature, raw_fragment)
effective_command = _compose_command(parent_signature, current_fragment)
# 确定 command_type
if isinstance(filter_ref, CommandGroupFilter):
command_type = "group"
elif is_sub_command:
command_type = "sub_command"
else:
command_type = "command"
descriptor = CommandDescriptor(
handler=handler,
filter_ref=filter_ref,
handler_full_name=handler.handler_full_name,
handler_name=handler.handler_name,
plugin_name=plugin_name,
plugin_display_name=plugin_display,
module_path=handler.handler_module_path,
description=handler.desc or "",
command_type=command_type,
raw_command_name=raw_fragment,
current_fragment=current_fragment,
parent_signature=parent_signature,
parent_group_handler=parent_group_handler,
original_command=original_command,
effective_command=effective_command,
aliases=sorted(getattr(filter_ref, "alias", set())),
permission=_determine_permission(handler),
enabled=handler.enabled,
is_group=isinstance(filter_ref, CommandGroupFilter),
is_sub_command=is_sub_command,
reserved=plugin_meta.reserved if plugin_meta else False,
)
return descriptor
def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None:
handler = star_handlers_registry.get_handler_by_full_name(full_name)
if not handler:
return None
return _build_descriptor(handler)
def _locate_primary_filter(
handler: StarHandlerMetadata,
) -> CommandFilter | CommandGroupFilter | None:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
return filter_ref
return None
def _determine_permission(handler: StarHandlerMetadata) -> str:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, PermissionTypeFilter):
return (
"admin"
if filter_ref.permission_type == PermissionType.ADMIN
else "member"
)
return "everyone"
def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str:
signatures: list[str] = []
parent = group_filter.parent_group
while parent:
signatures.append(getattr(parent, "_original_group_name", parent.group_name))
parent = parent.parent_group
return " ".join(reversed(signatures)).strip()
def _find_parent_group_handler(module_path: str, parent_signature: str) -> str:
"""根据模块路径和父级签名,找到对应的指令组 handler_full_name。"""
parent_sig_normalized = parent_signature.strip()
for handler in star_handlers_registry:
if handler.handler_module_path != module_path:
continue
filter_ref = _locate_primary_filter(handler)
if not isinstance(filter_ref, CommandGroupFilter):
continue
# 检查该指令组的完整指令名是否匹配 parent_signature
group_names = filter_ref.get_complete_command_names()
if parent_sig_normalized in group_names:
return handler.handler_full_name
return ""
def _compose_command(parent_signature: str, fragment: str | None) -> str:
fragment = (fragment or "").strip()
parent_signature = parent_signature.strip()
if not parent_signature:
return fragment
if not fragment:
return parent_signature
return f"{parent_signature} {fragment}"
def _bind_descriptor_with_config(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
_apply_config_to_descriptor(descriptor, config)
_apply_config_to_runtime(descriptor, config)
def _apply_config_to_descriptor(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.config = config
descriptor.enabled = config.enabled
if config.original_command:
descriptor.original_command = config.original_command
new_fragment = config.resolved_command or descriptor.current_fragment
descriptor.current_fragment = new_fragment
descriptor.effective_command = _compose_command(
descriptor.parent_signature,
new_fragment,
)
def _apply_config_to_runtime(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.handler.enabled = config.enabled
if descriptor.filter_ref and descriptor.current_fragment:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
def _bind_configs_to_descriptors(
descriptors: list[CommandDescriptor],
config_records: list[CommandConfig],
) -> dict[str, CommandConfig]:
config_map = {cfg.handler_full_name: cfg for cfg in config_records}
for desc in descriptors:
if cfg := config_map.get(desc.handler_full_name):
_bind_descriptor_with_config(desc, cfg)
return config_map
def _group_conflicts(
descriptors: list[CommandDescriptor],
) -> dict[str, list[CommandDescriptor]]:
conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list)
for desc in descriptors:
if desc.effective_command and desc.enabled:
conflicts[desc.effective_command].append(desc)
return {k: v for k, v in conflicts.items() if len(v) > 1}
def _set_filter_fragment(
filter_ref: CommandFilter | CommandGroupFilter,
fragment: str,
) -> None:
attr = (
"group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name"
)
current_value = getattr(filter_ref, attr)
if fragment == current_value:
return
setattr(filter_ref, attr, fragment)
if hasattr(filter_ref, "_cmpl_cmd_names"):
filter_ref._cmpl_cmd_names = None
def _is_command_in_use(
target_handler_full_name: str,
candidate_full_command: str,
) -> bool:
candidate = candidate_full_command.strip()
for handler in star_handlers_registry:
if handler.handler_full_name == target_handler_full_name:
continue
filter_ref = _locate_primary_filter(handler)
if not filter_ref:
continue
names = {name.strip() for name in filter_ref.get_complete_command_names()}
if candidate in names:
return True
return False
def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]:
result = {
"handler_full_name": desc.handler_full_name,
"handler_name": desc.handler_name,
"plugin": desc.plugin_name,
"plugin_display_name": desc.plugin_display_name,
"module_path": desc.module_path,
"description": desc.description,
"type": desc.command_type,
"parent_signature": desc.parent_signature,
"parent_group_handler": desc.parent_group_handler,
"original_command": desc.original_command,
"current_fragment": desc.current_fragment,
"effective_command": desc.effective_command,
"aliases": desc.aliases,
"permission": desc.permission,
"enabled": desc.enabled,
"is_group": desc.is_group,
"has_conflict": desc.has_conflict,
"reserved": desc.reserved,
}
# 如果是指令组,包含子指令列表
if desc.is_group and desc.sub_commands:
result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands]
else:
result["sub_commands"] = []
return result
+1
View File
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
):
self.command_name = command_name
self.alias = alias if alias else set()
self._original_command_name = command_name
self.parent_command_names = (
parent_command_names if parent_command_names is not None else [""]
)
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
):
self.group_name = group_name
self.alias = alias if alias else set()
self._original_group_name = group_name
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
self.custom_filter_list: list[CustomFilter] = []
self.parent_group = parent_group
+4
View File
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
# 过滤事件类型
if handler.event_type != event_type:
continue
if not handler.enabled:
continue
# 过滤启用状态
if only_activated:
plugin = star_map.get(handler.handler_module_path)
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
extras_configs: dict = field(default_factory=dict)
"""插件注册的一些其他的信息, 如 priority 等"""
enabled: bool = True
def __lt__(self, other: StarHandlerMetadata):
"""定义小于运算符以支持优先队列"""
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
+2
View File
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
from astrbot.core.utils.io import remove_dir
from . import StarMetadata
from .command_management import sync_command_configs
from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
@@ -618,6 +619,7 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
await sync_command_configs()
if not fail_rec:
return True, None
+2
View File
@@ -1,5 +1,6 @@
from .auth import AuthRoute
from .chat import ChatRoute
from .command import CommandRoute
from .config import ConfigRoute
from .conversation import ConversationRoute
from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
__all__ = [
"AuthRoute",
"ChatRoute",
"CommandRoute",
"ConfigRoute",
"ConversationRoute",
"FileRoute",
+82
View File
@@ -0,0 +1,82 @@
from quart import request
from astrbot.core.star.command_management import (
list_command_conflicts,
list_commands,
)
from astrbot.core.star.command_management import (
rename_command as rename_command_service,
)
from astrbot.core.star.command_management import (
toggle_command as toggle_command_service,
)
from .route import Response, Route, RouteContext
class CommandRoute(Route):
def __init__(self, context: RouteContext) -> None:
super().__init__(context)
self.routes = {
"/commands": ("GET", self.get_commands),
"/commands/conflicts": ("GET", self.get_conflicts),
"/commands/toggle": ("POST", self.toggle_command),
"/commands/rename": ("POST", self.rename_command),
}
self.register_routes()
async def get_commands(self):
commands = await list_commands()
summary = {
"total": len(commands),
"disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
"conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
}
return Response().ok({"items": commands, "summary": summary}).__dict__
async def get_conflicts(self):
conflicts = await list_command_conflicts()
return Response().ok(conflicts).__dict__
async def toggle_command(self):
data = await request.get_json()
handler_full_name = data.get("handler_full_name")
enabled = data.get("enabled")
if handler_full_name is None or enabled is None:
return Response().error("handler_full_name 与 enabled 均为必填。").__dict__
if isinstance(enabled, str):
enabled = enabled.lower() in ("1", "true", "yes", "on")
try:
await toggle_command_service(handler_full_name, bool(enabled))
except ValueError as exc:
return Response().error(str(exc)).__dict__
payload = await _get_command_payload(handler_full_name)
return Response().ok(payload).__dict__
async def rename_command(self):
data = await request.get_json()
handler_full_name = data.get("handler_full_name")
new_name = data.get("new_name")
if not handler_full_name or not new_name:
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
try:
await rename_command_service(handler_full_name, new_name)
except ValueError as exc:
return Response().error(str(exc)).__dict__
payload = await _get_command_payload(handler_full_name)
return Response().ok(payload).__dict__
async def _get_command_payload(handler_full_name: str):
commands = await list_commands()
for cmd in commands:
if cmd["handler_full_name"] == handler_full_name:
return cmd
return {}
+91 -1
View File
@@ -1,7 +1,9 @@
import json
import traceback
from datetime import datetime
from io import BytesIO
from quart import request
from quart import request, send_file
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -30,6 +32,7 @@ class ConversationRoute(Route):
"POST",
self.update_history,
),
"/conversation/export": ("POST", self.export_conversations),
}
self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager
@@ -283,3 +286,90 @@ class ConversationRoute(Route):
except Exception as e:
logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"更新对话历史失败: {e!s}").__dict__
async def export_conversations(self):
"""批量导出对话为 JSONL 格式"""
try:
data = await request.get_json()
conversations_to_export = data.get("conversations", [])
if not conversations_to_export:
return Response().error("导出列表不能为空").__dict__
# 收集所有对话的内容
jsonl_lines = []
exported_count = 0
failed_items = []
for conv_info in conversations_to_export:
user_id = conv_info.get("user_id")
cid = conv_info.get("cid")
if not user_id or not cid:
failed_items.append(
f"user_id:{user_id}, cid:{cid} - 缺少必要参数",
)
continue
try:
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
)
if not conversation:
failed_items.append(
f"user_id:{user_id}, cid:{cid} - 对话不存在"
)
continue
# 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1)
content = json.loads(conversation.history)
# 创建导出记录
export_record = {
"cid": cid,
"user_id": user_id,
"platform_id": conversation.platform_id,
"title": conversation.title,
"persona_id": conversation.persona_id,
"created_at": conversation.created_at,
"updated_at": conversation.updated_at,
"content": content,
}
# 将记录转换为 JSON 字符串并添加到 JSONL
jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
exported_count += 1
except Exception as e:
failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}")
logger.error(
f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}"
)
if exported_count == 0:
return Response().error("没有成功导出任何对话").__dict__
# 创建 JSONL 内容
jsonl_content = "\n".join(jsonl_lines)
# 创建一个内存文件对象
file_obj = BytesIO(jsonl_content.encode("utf-8"))
file_obj.seek(0)
# 生成文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"astrbot_conversations_export_{timestamp}.jsonl"
# 返回文件流
return await send_file(
file_obj,
mimetype="application/jsonl",
as_attachment=True,
attachment_filename=filename,
)
except Exception as e:
logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"批量导出对话失败: {e!s}").__dict__
+253 -78
View File
@@ -48,6 +48,7 @@ class KnowledgeBaseRoute(Route):
# 文档管理
"/kb/document/list": ("GET", self.list_documents),
"/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/progress": ("GET", self.get_upload_progress),
"/kb/document/get": ("GET", self.get_document),
@@ -66,6 +67,65 @@ class KnowledgeBaseRoute(Route):
def _get_kb_manager(self):
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(
self,
task_id: str,
@@ -80,11 +140,7 @@ class KnowledgeBaseRoute(Route):
"""后台上传任务"""
try:
# 初始化任务状态
self.upload_tasks[task_id] = {
"status": "processing",
"result": None,
"error": None,
}
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
@@ -100,30 +156,20 @@ class KnowledgeBaseRoute(Route):
for file_idx, file_info in enumerate(files_to_upload):
try:
# 更新整体进度
self.upload_progress[task_id].update(
{
"status": "processing",
"file_index": file_idx,
"file_name": file_info["file_name"],
"stage": "parsing",
"current": 0,
"total": 100,
},
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_info["file_name"],
stage="parsing",
current=0,
total=100,
)
# 创建进度回调函数
async def progress_callback(stage, current, total):
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,
},
)
progress_callback = self._make_progress_callback(
task_id, file_idx, file_info["file_name"]
)
doc = await kb_helper.upload_document(
file_name=file_info["file_name"],
@@ -154,23 +200,99 @@ class KnowledgeBaseRoute(Route):
"failed_count": len(failed_docs),
}
self.upload_tasks[task_id] = {
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
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.upload_tasks[task_id] = {
"status": "failed",
"result": None,
"error": str(e),
self._set_task_result(task_id, "failed", error=str(e))
async def _background_import_task(
self,
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:
self.upload_progress[task_id]["status"] = "failed"
uploaded_docs = []
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):
"""获取知识库列表
@@ -614,11 +736,7 @@ class KnowledgeBaseRoute(Route):
task_id = str(uuid.uuid4())
# 初始化任务状态
self.upload_tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
}
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
@@ -653,6 +771,93 @@ class KnowledgeBaseRoute(Route):
logger.error(traceback.format_exc())
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):
"""获取上传进度和结果
@@ -960,11 +1165,7 @@ class KnowledgeBaseRoute(Route):
task_id = str(uuid.uuid4())
# 初始化任务状态
self.upload_tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
}
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
@@ -1017,11 +1218,7 @@ class KnowledgeBaseRoute(Route):
"""后台上传URL任务"""
try:
# 初始化任务状态
self.upload_tasks[task_id] = {
"status": "processing",
"result": None,
"error": None,
}
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
@@ -1033,18 +1230,7 @@ class KnowledgeBaseRoute(Route):
}
# 创建进度回调函数
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,
},
)
progress_callback = self._make_progress_callback(task_id, 0, f"URL: {url}")
# 上传文档
doc = await kb_helper.upload_from_url(
@@ -1069,20 +1255,9 @@ class KnowledgeBaseRoute(Route):
"failed_count": 0,
}
self.upload_tasks[task_id] = {
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
self._set_task_result(task_id, "completed", result=result)
except Exception as e:
logger.error(f"后台上传URL任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
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"
self._set_task_result(task_id, "failed", error=str(e))
+5 -1
View File
@@ -124,7 +124,11 @@ class PluginRoute(Route):
session.get(url) as response,
):
if response.status == 200:
remote_data = await response.json()
try:
remote_data = await response.json()
except aiohttp.ContentTypeError:
remote_text = await response.text()
remote_data = json.loads(remote_text)
# 检查远程数据是否为空
if not remote_data or (
+20 -4
View File
@@ -3,6 +3,7 @@ import traceback
from quart import request
from astrbot.core import logger
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map
@@ -296,15 +297,30 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表"""
try:
tools = self.tool_mgr.func_list
tools_dict = [
{
tools_dict = []
for tool in tools:
if isinstance(tool, MCPTool):
origin = "mcp"
origin_name = tool.mcp_server_name
elif tool.handler_module_path and star_map.get(
tool.handler_module_path
):
star = star_map[tool.handler_module_path]
origin = "plugin"
origin_name = star.name
else:
origin = "unknown"
origin_name = "unknown"
tool_info = {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
"active": tool.active,
"origin": origin,
"origin_name": origin_name,
}
for tool in tools
]
tools_dict.append(tool_info)
return Response().ok(data=tools_dict).__dict__
except Exception as e:
logger.error(traceback.format_exc())
+1
View File
@@ -67,6 +67,7 @@ class AstrBotDashboard:
core_lifecycle,
core_lifecycle.plugin_manager,
)
self.command_route = CommandRoute(self.context)
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
+19
View File
@@ -0,0 +1,19 @@
## What's Changed
### 新增
- 支持自定义插件源。
- 支持飞书(Lark)的 Webhook 模式(将事件推送至开发者服务器)。
- 支持 “禁用自带指令” 快捷配置项,启用后将禁用所有 AstrBot 自带指令。入口: WebUI -> 配置文件 -> 平台配置。
### 优化
- 从 WebUI 移除了开发版本渠道。
- 当试图测试"Agent Runner"时,提示前往配置文件页测试。
- WebUI 列表项支持批量粘贴、回车创建项目。
### 修复
- Gemini API 部分调用失败的问题。
- WebUI 插件安装加载 Dialog 关闭按钮在手机端下显示异常的问题。
- 部分情况下,WebUI 日志显示不全的问题。
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -4,42 +4,18 @@
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<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"
@click="showMcpServerDialog = true" rounded="xl" size="x-large">
@click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }}
</v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
rounded="xl" size="x-large">
>
{{ tm('mcpServers.buttons.sync') }}
</v-btn>
</div>
</v-row>
<!-- 本地服务器列表 -->
<!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
@@ -57,7 +33,6 @@
</span>
</div>
<div class="d-flex" style="gap: 8px;">
<div>
<div v-if="item.tools && item.tools.length > 0">
@@ -67,8 +42,7 @@
<template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
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>
</template>
<template v-slot:default="{ isActive }">
@@ -78,10 +52,7 @@
</v-card-title>
<v-card-text>
<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>
</v-card-text>
<v-card-actions class="d-flex justify-end">
@@ -91,8 +62,6 @@
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
@@ -105,8 +74,6 @@
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div>
</div>
</template>
</item-card>
</v-col>
@@ -183,8 +150,7 @@
</v-card>
</v-dialog>
<!-- 添加/编辑 MCP 服务器对话框 -->
<!-- 同步 MCP 服务器对话框 -->
<v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
@@ -240,115 +206,8 @@
</v-card>
</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 }}
</v-snackbar>
</div>
@@ -356,15 +215,13 @@
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export default {
name: 'ToolUsePage',
name: 'McpServersSection',
components: {
AstrBotConfig,
VueMonacoEditor,
ItemCard
},
@@ -377,20 +234,15 @@ export default {
return {
refreshInterval: null,
mcpServers: [],
tools: [],
showMcpServerDialog: false,
selectedMcpServerProvider: "modelscope",
mcpServerProviderList: ["modelscope"],
selectedMcpServerProvider: 'modelscope',
mcpServerProviderList: ['modelscope'],
mcpProviderToken: '',
showSyncMcpServerDialog: false,
addServerDialogMessage: "",
showToolsDialog: false,
showTools: true,
addServerDialogMessage: '',
loading: false,
loadingGettingServers: false,
mcpServerUpdateLoaders: {}, // record loading state for each server update
mcpServerUpdateLoaders: {},
isEditMode: false,
serverConfigJson: '',
jsonError: null,
@@ -400,87 +252,50 @@ export default {
tools: []
},
save_message_snack: false,
save_message: "",
save_message_success: "success",
toolSearch: '',
openedPanel: [], //
}
save_message: '',
save_message_success: 'success'
};
},
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() {
return !!this.currentServer.name && !this.jsonError;
},
//
getServerConfigSummary() {
return (server) => {
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
// command
const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key)
);
if (configKeys.length > 0) {
return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') });
}
return this.tm('mcpServers.status.noConfig');
}
},
};
}
},
mounted() {
this.getServers();
this.getTools();
this.refreshInterval = setInterval(() => {
this.getServers();
this.getTools();
}, 5000);
},
unmounted() {
// if it exists
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
openurl(url) {
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() {
this.loadingGettingServers = true;
axios.get('/api/tools/mcp/servers')
.then(response => {
this.mcpServers = response.data.data || [];
this.mcpServers.forEach(server => {
// Ensure each server has a loader state
if (!this.mcpServerUpdateLoaders[server.name]) {
this.mcpServerUpdateLoaders[server.name] = false;
}
@@ -492,24 +307,12 @@ export default {
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() {
try {
if (!this.serverConfigJson.trim()) {
this.jsonError = this.tm('dialogs.addServer.errors.configEmpty');
return false;
}
JSON.parse(this.serverConfigJson);
this.jsonError = null;
return true;
@@ -518,61 +321,51 @@ export default {
return false;
}
},
setConfigTemplate(type = 'stdio') {
let template = {};
if (type === 'streamable_http') {
template = {
transport: "streamable_http",
url: "your mcp server url",
transport: 'streamable_http',
url: 'your mcp server url',
headers: {},
timeout: 5,
sse_read_timeout: 300,
sse_read_timeout: 300
};
} else if (type === 'sse') {
template = {
transport: "sse",
url: "your mcp server url",
transport: 'sse',
url: 'your mcp server url',
headers: {},
timeout: 5,
sse_read_timeout: 300,
sse_read_timeout: 300
};
} else {
template = {
command: "python",
args: ["-m", "your_module"],
command: 'python',
args: ['-m', 'your_module']
};
}
this.serverConfigJson = JSON.stringify(template, null, 2);
},
saveServer() {
if (!this.validateJson()) {
return;
}
this.loading = true;
// JSON
try {
const configObj = JSON.parse(this.serverConfigJson);
//
const serverData = {
name: this.currentServer.name,
active: this.currentServer.active,
...configObj
};
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData)
.then(response => {
this.loading = false;
this.showMcpServerDialog = false;
this.addServerDialogMessage = "";
this.addServerDialogMessage = '';
this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
this.resetForm();
})
@@ -585,14 +378,12 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
}
},
deleteServer(server) {
let serverName = server.name || server;
const serverName = server.name || server;
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => {
this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
})
.catch(error => {
@@ -600,37 +391,22 @@ export default {
});
}
},
editServer(server) {
//
const configCopy = { ...server };
//
try {
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
} catch (e) {
console.error("Error removing basic fields: ", e);
}
//
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
this.currentServer = {
name: server.name,
active: server.active,
tools: server.tools || []
};
// JSON
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true;
this.showMcpServerDialog = true;
},
updateServerStatus(server) {
//
this.mcpServerUpdateLoaders[server.name] = true;
server.active = !server.active;
axios.post('/api/tools/mcp/update', server)
@@ -646,20 +422,16 @@ export default {
this.mcpServerUpdateLoaders[server.name] = false;
});
},
closeServerDialog() {
this.showMcpServerDialog = false;
this.addServerDialogMessage = '';
this.resetForm();
},
testServerConnection() {
if (!this.validateJson()) {
return;
}
this.loading = true;
let configObj;
try {
configObj = JSON.parse(this.serverConfigJson);
@@ -668,9 +440,8 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
return;
}
axios.post('/api/tools/mcp/test', {
"mcp_server_config": configObj,
mcp_server_config: configObj
})
.then(response => {
this.loading = false;
@@ -681,7 +452,6 @@ export default {
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
});
},
resetForm() {
this.currentServer = {
name: '',
@@ -692,58 +462,26 @@ export default {
this.jsonError = null;
this.isEditMode = false;
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_success = 'success';
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_success = 'error';
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() {
if (!this.selectedMcpServerProvider) {
this.showError(this.tm('syncProvider.status.selectProvider'));
return;
}
this.loading = true;
try {
const requestData = {
name: this.selectedMcpServerProvider
};
//
if (this.selectedMcpServerProvider === 'modelscope') {
if (!this.mcpProviderToken.trim()) {
this.showError(this.tm('syncProvider.status.enterToken'));
@@ -752,61 +490,33 @@ export default {
}
requestData.access_token = this.mcpProviderToken.trim();
}
const response = await axios.post('/api/tools/mcp/sync-provider', requestData);
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess'));
this.showSyncMcpServerDialog = false;
this.mcpProviderToken = '';
//
this.getServers();
this.getTools();
} else {
this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' }));
}
} catch (error) {
console.error('同步 MCP 服务器失败:', error);
this.showError(this.tm('syncProvider.messages.syncError', {
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
this.showError(this.tm('syncProvider.messages.syncError', {
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
}));
} finally {
this.loading = false;
}
}
}
}
};
</script>
<style scoped>
.tools-page {
padding: 20px;
padding: 0px;
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 {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
@@ -814,4 +524,4 @@ export default {
margin-top: 4px;
overflow: hidden;
}
</style>
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
availablePlugins: string[];
hasSystemPluginConflict: boolean;
effectiveShowSystemPlugins: boolean;
pluginFilter: string;
typeFilter: string;
permissionFilter: string;
statusFilter: string;
showSystemPlugins: boolean;
searchQuery: string;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:pluginFilter', value: string): void;
(e: 'update:typeFilter', value: string): void;
(e: 'update:permissionFilter', value: string): void;
(e: 'update:statusFilter', value: string): void;
(e: 'update:showSystemPlugins', value: boolean): void;
(e: 'update:searchQuery', value: string): void;
}>();
// Computed items for selects
const pluginItems = computed(() => [
{ title: tm('filters.all'), value: 'all' },
...props.availablePlugins.map(p => ({ title: p, value: p }))
]);
const typeItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('type.group'), value: 'group' },
{ title: tm('type.command'), value: 'command' },
{ title: tm('type.subCommand'), value: 'sub_command' }
];
const permissionItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('permission.everyone'), value: 'everyone' },
{ title: tm('permission.admin'), value: 'admin' }
];
const statusItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('filters.enabled'), value: 'enabled' },
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
<!-- 过滤器行 -->
<v-row class="mb-4" align="center">
<v-col cols="12" sm="6" md="3">
<v-select
:model-value="pluginFilter"
@update:model-value="emit('update:pluginFilter', $event)"
:items="pluginItems"
:label="tm('filters.byPlugin')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="typeFilter"
@update:model-value="emit('update:typeFilter', $event)"
:items="typeItems"
:label="tm('filters.byType')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="permissionFilter"
@update:model-value="emit('update:permissionFilter', $event)"
:items="permissionItems"
:label="tm('filters.byPermission')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="statusFilter"
@update:model-value="emit('update:statusFilter', $event)"
:items="statusItems"
:label="tm('filters.byStatus')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
</v-row>
<!-- 搜索栏 + 统计信息行 -->
<div class="mb-4 d-flex flex-wrap align-center ga-4">
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', $event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
flat
hide-details
single-line
/>
</div>
<div class="d-flex align-center ga-4">
<slot name="stats"></slot>
<v-divider vertical class="mx-1" style="height: 20px;" />
<v-checkbox
:model-value="effectiveShowSystemPlugins"
@update:model-value="emit('update:showSystemPlugins', !!$event)"
:label="tm('filters.showSystemPlugins')"
density="compact"
hide-details
:disabled="hasSystemPluginConflict"
class="system-plugin-checkbox"
>
<template v-slot:label>
<span class="text-body-2">{{ tm('filters.showSystemPlugins') }}</span>
<v-tooltip v-if="hasSystemPluginConflict" location="top">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" size="16" color="warning" class="ml-1">mdi-alert-circle</v-icon>
</template>
{{ tm('filters.systemPluginConflictHint') }}
</v-tooltip>
</template>
</v-checkbox>
</div>
</div>
</template>
<style scoped>
.system-plugin-checkbox {
flex: none;
}
.system-plugin-checkbox :deep(.v-selection-control) {
min-height: auto;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem, TypeInfo, StatusInfo } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
items: CommandItem[];
expandedGroups: Set<string>;
loading?: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'toggle-expand', cmd: CommandItem): void;
(e: 'toggle-command', cmd: CommandItem): void;
(e: 'rename', cmd: CommandItem): void;
(e: 'view-details', cmd: CommandItem): void;
}>();
// 表格表头
const commandHeaders = computed(() => [
{ title: tm('table.headers.command'), key: 'effective_command', minWidth: '100px' },
{ title: tm('table.headers.type'), key: 'type', sortable: false, width: '100px' },
{ title: tm('table.headers.plugin'), key: 'plugin', width: '140px' },
{ title: tm('table.headers.description'), key: 'description', sortable: false },
{ title: tm('table.headers.permission'), key: 'permission', sortable: false, width: '100px' },
{ title: tm('table.headers.status'), key: 'enabled', sortable: false, width: '100px' },
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '140px' }
]);
// 检查组是否展开
const isGroupExpanded = (cmd: CommandItem): boolean => {
return props.expandedGroups.has(cmd.handler_full_name);
};
// 获取类型信息
const getTypeInfo = (type: string): TypeInfo => {
switch (type) {
case 'group':
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: tm('type.command'), color: 'primary', icon: 'mdi-console-line' };
}
};
// 获取权限颜色
const getPermissionColor = (permission: string): string => {
switch (permission) {
case 'admin': return 'error';
default: return 'success';
}
};
// 获取权限标签
const getPermissionLabel = (permission: string): string => {
switch (permission) {
case 'admin': return tm('permission.admin');
default: return tm('permission.everyone');
}
};
// 获取状态信息
const getStatusInfo = (cmd: CommandItem): StatusInfo => {
if (cmd.has_conflict) {
return { text: tm('status.conflict'), color: 'warning', variant: 'flat' };
}
if (cmd.enabled) {
return { text: tm('status.enabled'), color: 'success', variant: 'flat' };
}
return { text: tm('status.disabled'), color: 'error', variant: 'outlined' };
};
// 获取行属性
const getRowProps = ({ item }: { item: CommandItem }) => {
const classes: string[] = [];
if (item.has_conflict) {
classes.push('conflict-row');
}
if (item.type === 'sub_command') {
classes.push('sub-command-row');
}
if (item.is_group) {
classes.push('group-row');
}
return classes.length > 0 ? { class: classes.join(' ') } : {};
};
</script>
<template>
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="commandHeaders"
:items="items"
item-key="handler_full_name"
hover
:row-props="getRowProps"
:loading="props.loading"
>
<template v-slot:item.effective_command="{ item }">
<div class="d-flex align-center py-2">
<!-- 展开/折叠按钮针对指令组 -->
<v-btn
v-if="item.is_group && item.sub_commands?.length > 0"
icon
variant="text"
size="x-small"
class="mr-1"
@click.stop="emit('toggle-expand', item)"
>
<v-icon size="18">{{ isGroupExpanded(item) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</v-btn>
<!-- 子指令缩进 -->
<div v-else-if="item.type === 'sub_command'" class="ml-6"></div>
<div>
<div class="text-subtitle-1 font-weight-medium">
<code :class="{ 'sub-command-code': item.type === 'sub_command' }">{{ item.effective_command }}</code>
</div>
</div>
</div>
</template>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeInfo(item.type).color"
size="small"
variant="tonal"
>
<v-icon start size="14">{{ getTypeInfo(item.type).icon }}</v-icon>
{{ getTypeInfo(item.type).text }}{{ item.is_group && item.sub_commands?.length > 0 ? `(${item.sub_commands.length})` : '' }}
</v-chip>
</template>
<template v-slot:item.plugin="{ item }">
<div class="text-body-2">{{ item.plugin_display_name || item.plugin }}</div>
</template>
<template v-slot:item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.description || '-' }}
</div>
</template>
<template v-slot:item.permission="{ item }">
<v-chip :color="getPermissionColor(item.permission)" size="small" class="font-weight-medium">
{{ getPermissionLabel(item.permission) }}
</v-chip>
</template>
<template v-slot:item.enabled="{ item }">
<v-chip
:color="getStatusInfo(item).color"
size="small"
class="font-weight-medium"
:variant="getStatusInfo(item).variant"
>
{{ getStatusInfo(item).text }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center">
<v-btn-group density="default" variant="text" color="primary">
<v-btn
v-if="!item.enabled"
icon
size="small"
color="success"
@click="emit('toggle-command', item)"
>
<v-icon size="22">mdi-play</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.enable') }}</v-tooltip>
</v-btn>
<v-btn
v-else
icon
size="small"
color="error"
@click="emit('toggle-command', item)"
>
<v-icon size="22">mdi-pause</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.disable') }}</v-tooltip>
</v-btn>
<v-btn icon size="small" color="warning" @click="emit('rename', item)">
<v-icon size="22">mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.rename') }}</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="emit('view-details', item)">
<v-icon size="22">mdi-information</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.viewDetails') }}</v-tooltip>
</v-btn>
</v-btn-group>
</div>
</template>
<template v-slot:no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-console-line</v-icon>
<div class="text-h5 mb-2">{{ tm('empty.noCommands') }}</div>
<div class="text-body-1 mb-4">{{ tm('empty.noCommandsDesc') }}</div>
</div>
</template>
</v-data-table>
</v-card>
</template>
<style scoped>
code {
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
white-space: nowrap;
}
code.sub-command-code {
background-color: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
</style>
<style>
/* 冲突行高亮 */
.v-data-table .conflict-row {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.15) 0%, rgba(var(--v-theme-warning), 0.05) 100%) !important;
border-left: 3px solid rgb(var(--v-theme-warning)) !important;
}
.v-data-table .conflict-row:hover {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.25) 0%, rgba(var(--v-theme-warning), 0.1) 100%) !important;
}
/* 指令组行样式 */
.v-data-table .group-row {
background-color: rgba(var(--v-theme-info), 0.05);
}
.v-data-table .group-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
/* 子指令行样式 */
.v-data-table .sub-command-row {
background-color: rgba(var(--v-theme-info), 0.05);
}
.v-data-table .sub-command-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { CommandItem, TypeInfo } from '../types';
const { t } = useI18n();
const { tm } = useModuleI18n('features/command');
// Props
defineProps<{
show: boolean;
command: CommandItem | null;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
}>();
// 获取类型信息
const getTypeInfo = (type: string): TypeInfo => {
switch (type) {
case 'group':
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: tm('type.command'), color: 'primary', icon: 'mdi-console-line' };
}
};
// 获取权限颜色
const getPermissionColor = (permission: string): string => {
switch (permission) {
case 'admin': return 'error';
default: return 'success';
}
};
// 获取权限标签
const getPermissionLabel = (permission: string): string => {
switch (permission) {
case 'admin': return tm('permission.admin');
default: return tm('permission.everyone');
}
};
</script>
<template>
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
<v-card v-if="command">
<v-card-title class="text-h5">{{ tm('dialogs.details.title') }}</v-card-title>
<v-card-text>
<v-list density="compact">
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.type') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getTypeInfo(command.type).color"
size="small"
variant="tonal"
>
<v-icon start size="14">{{ getTypeInfo(command.type).icon }}</v-icon>
{{ getTypeInfo(command.type).text }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.handler') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.handler_name }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.module') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.module_path }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.originalCommand') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.original_command }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.effectiveCommand') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.effective_command }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.parent_signature">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.parentGroup') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.parent_signature }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.aliases.length > 0">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.aliases') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip v-for="alias in command.aliases" :key="alias" size="small" class="mr-1">
{{ alias }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.is_group && command.sub_commands?.length > 0">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.subCommands') }}</v-list-item-title>
<v-list-item-subtitle>
<div class="d-flex flex-wrap ga-1 mt-1">
<v-chip
v-for="sub in command.sub_commands"
:key="sub.handler_full_name"
size="small"
variant="outlined"
>
{{ sub.current_fragment }}
</v-chip>
</div>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.permission') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip :color="getPermissionColor(command.permission)" size="small">
{{ getPermissionLabel(command.permission) }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.has_conflict">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.conflictStatus') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip color="warning" size="small">{{ tm('status.conflict') }}</v-chip>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="emit('update:show', false)">
{{ t('core.actions.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
code {
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
defineProps<{
show: boolean;
command: CommandItem | null;
newName: string;
loading: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'update:newName', value: string): void;
(e: 'confirm'): void;
}>();
</script>
<template>
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
<v-card>
<v-card-title class="text-h5">{{ tm('dialogs.rename.title') }}</v-card-title>
<v-card-text>
<v-text-field
:model-value="newName"
@update:model-value="emit('update:newName', $event)"
:label="tm('dialogs.rename.newName')"
variant="outlined"
density="compact"
autofocus
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="grey" variant="text" @click="emit('update:show', false)">
{{ tm('dialogs.rename.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="text"
:loading="loading"
@click="emit('confirm')"
>
{{ tm('dialogs.rename.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { ToolItem } from '../types';
const { tm: tmTool } = useModuleI18n('features/tooluse');
const { tm: tmCommand } = useModuleI18n('features/command');
const props = defineProps<{
items: ToolItem[];
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'toggle-tool', tool: ToolItem): void;
}>();
const toolHeaders = computed(() => [
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '160px' },
{ title: tmTool('functionTools.description'), key: 'description' },
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' },
{ title: tmCommand('status.enabled'), key: 'active', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' }
]);
const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {});
</script>
<template>
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="toolHeaders"
:items="items"
item-key="name"
hover
show-expand
class="tool-table"
:loading="props.loading"
>
<template #item.name="{ item }">
<div class="d-flex align-center py-2">
<v-icon color="primary" class="mr-2" size="18">
{{ item.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<div>
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
</div>
</div>
</template>
<template #item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.description || '-' }}
</div>
</template>
<template #item.origin="{ item }">
<v-chip size="small" variant="tonal" color="info" class="text-caption font-weight-medium">
{{ item.origin || '-' }}
</v-chip>
</template>
<template #item.origin_name="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.origin_name || '-' }}
</div>
</template>
<template #item.active="{ item }">
<v-chip :color="item.active ? 'success' : 'error'" size="small" class="font-weight-medium" :variant="item.active ? 'flat' : 'outlined'">
{{ item.active ? tmCommand('status.enabled') : tmCommand('status.disabled') }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-switch
:model-value="item.active"
color="primary"
density="compact"
hide-details
inset
@update:model-value="emit('toggle-tool', item)"
/>
</template>
<template #no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-function-variant</v-icon>
<div class="text-h5 mb-2">{{ tmTool('functionTools.empty') }}</div>
</div>
</template>
<template #expanded-row="{ item }">
<td :colspan="toolHeaders.length + 1" class="pa-4">
<div class="d-flex align-start ga-4">
<v-icon size="20" color="primary">mdi-code-json</v-icon>
<div class="flex-1">
<div class="text-subtitle-2 font-weight-medium mb-2">{{ tmTool('functionTools.parameters') }}</div>
<div v-if="parameterEntries(item).length === 0" class="text-caption text-medium-emphasis">
{{ tmTool('functionTools.noParameters') }}
</div>
<v-table
v-else
density="compact"
class="param-table"
>
<thead>
<tr>
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.paramName') }}</th>
<th class="text-left text-caption text-medium-emphasis" style="width: 140px;">{{ tmTool('functionTools.table.type') }}</th>
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="([paramName, param]) in parameterEntries(item)" :key="paramName">
<td class="font-weight-medium text-body-2">{{ paramName }}</td>
<td class="text-body-2">
<v-chip size="x-small" color="primary" class="text-caption">
{{ param?.type || '-' }}
</v-chip>
</td>
<td class="text-body-2 text-medium-emphasis">{{ param?.description || '-' }}</td>
</tr>
</tbody>
</v-table>
</div>
</div>
</td>
</template>
</v-data-table>
</v-card>
</template>
<style scoped>
.param-table {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
}
.tool-table :deep(.v-data-table__td) {
vertical-align: middle;
}
</style>
@@ -0,0 +1,177 @@
/**
* 指令操作方法 Composable
*/
import { reactive } from 'vue';
import axios from 'axios';
import type { CommandItem, RenameDialogState, DetailsDialogState, TypeInfo, StatusInfo } from '../types';
export function useCommandActions(
toast: (message: string, color?: string) => void,
fetchCommands: () => Promise<void>
) {
// 重命名对话框状态
const renameDialog = reactive<RenameDialogState>({
show: false,
command: null,
newName: '',
loading: false
});
// 详情对话框状态
const detailsDialog = reactive<DetailsDialogState>({
show: false,
command: null
});
/**
* 切换指令启用/禁用状态
*/
const toggleCommand = async (
cmd: CommandItem,
successMessage: string,
errorMessage: string
) => {
try {
const res = await axios.post('/api/commands/toggle', {
handler_full_name: cmd.handler_full_name,
enabled: !cmd.enabled
});
if (res.data.status === 'ok') {
toast(successMessage, 'success');
await fetchCommands();
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
}
};
/**
* 打开重命名对话框
*/
const openRenameDialog = (cmd: CommandItem) => {
renameDialog.command = cmd;
renameDialog.newName = cmd.current_fragment || '';
renameDialog.show = true;
};
/**
* 确认重命名
*/
const confirmRename = async (successMessage: string, errorMessage: string) => {
if (!renameDialog.command || !renameDialog.newName.trim()) return;
renameDialog.loading = true;
try {
const res = await axios.post('/api/commands/rename', {
handler_full_name: renameDialog.command.handler_full_name,
new_name: renameDialog.newName.trim()
});
if (res.data.status === 'ok') {
toast(successMessage, 'success');
renameDialog.show = false;
await fetchCommands();
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
} finally {
renameDialog.loading = false;
}
};
/**
* 打开详情对话框
*/
const openDetailsDialog = (cmd: CommandItem) => {
detailsDialog.command = cmd;
detailsDialog.show = true;
};
/**
* 获取类型显示信息
*/
const getTypeInfo = (type: string, translations: { group: string; subCommand: string; command: string }): TypeInfo => {
switch (type) {
case 'group':
return { text: translations.group, color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: translations.subCommand, color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: translations.command, color: 'primary', icon: 'mdi-console-line' };
}
};
/**
* 获取权限颜色
*/
const getPermissionColor = (permission: string): string => {
switch (permission) {
case 'admin': return 'error';
default: return 'success';
}
};
/**
* 获取权限标签
*/
const getPermissionLabel = (permission: string, translations: { admin: string; everyone: string }): string => {
switch (permission) {
case 'admin': return translations.admin;
default: return translations.everyone;
}
};
/**
* 获取状态显示信息
*/
const getStatusInfo = (
cmd: CommandItem,
translations: { conflict: string; enabled: string; disabled: string }
): StatusInfo => {
if (cmd.has_conflict) {
return { text: translations.conflict, color: 'warning', variant: 'flat' };
}
if (cmd.enabled) {
return { text: translations.enabled, color: 'success', variant: 'flat' };
}
return { text: translations.disabled, color: 'error', variant: 'outlined' };
};
/**
* 获取表格行属性(用于冲突高亮和子指令样式)
*/
const getRowProps = ({ item }: { item: CommandItem }) => {
const classes: string[] = [];
if (item.has_conflict) {
classes.push('conflict-row');
}
if (item.type === 'sub_command') {
classes.push('sub-command-row');
}
if (item.is_group) {
classes.push('group-row');
}
return classes.length > 0 ? { class: classes.join(' ') } : {};
};
return {
// 状态
renameDialog,
detailsDialog,
// 方法
toggleCommand,
openRenameDialog,
confirmRename,
openDetailsDialog,
getTypeInfo,
getPermissionColor,
getPermissionLabel,
getStatusInfo,
getRowProps
};
}
@@ -0,0 +1,187 @@
/**
* 指令过滤逻辑 Composable
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
const searchQuery = ref('');
const pluginFilter = ref('all');
const permissionFilter = ref('all');
const statusFilter = ref('all');
const typeFilter = ref('all');
const showSystemPlugins = ref(false);
// 展开的指令组
const expandedGroups = ref<Set<string>>(new Set());
/**
* 检查是否有涉及系统插件的冲突
*/
const hasSystemPluginConflict = computed(() => {
return commands.value.some(cmd => cmd.has_conflict && cmd.reserved);
});
/**
* 实际是否显示系统插件(如果有系统插件冲突则强制显示)
*/
const effectiveShowSystemPlugins = computed(() => {
return showSystemPlugins.value || hasSystemPluginConflict.value;
});
/**
* 获取可用的插件列表(用于过滤下拉框)
*/
const availablePlugins = computed(() => {
const plugins = new Set(
commands.value
.filter(cmd => effectiveShowSystemPlugins.value || !cmd.reserved)
.map(cmd => cmd.plugin)
);
return Array.from(plugins).sort();
});
/**
* 检查指令是否匹配过滤条件
*/
const matchesFilters = (cmd: CommandItem, query: string): boolean => {
// 系统插件过滤(除非显示系统插件)
if (!effectiveShowSystemPlugins.value && cmd.reserved) {
return false;
}
// 搜索过滤
if (query) {
const matchesSearch =
cmd.effective_command?.toLowerCase().includes(query) ||
cmd.description?.toLowerCase().includes(query) ||
cmd.plugin?.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
// 插件过滤
if (pluginFilter.value !== 'all' && cmd.plugin !== pluginFilter.value) {
return false;
}
// 权限过滤
if (permissionFilter.value !== 'all') {
if (permissionFilter.value === 'everyone') {
if (cmd.permission !== 'everyone' && cmd.permission !== 'member') return false;
} else if (cmd.permission !== permissionFilter.value) {
return false;
}
}
// 状态过滤
if (statusFilter.value !== 'all') {
if (statusFilter.value === 'enabled' && !cmd.enabled) return false;
if (statusFilter.value === 'disabled' && cmd.enabled) return false;
if (statusFilter.value === 'conflict' && !cmd.has_conflict) return false;
}
// 类型过滤
if (typeFilter.value !== 'all') {
if (typeFilter.value === 'group' && cmd.type !== 'group') return false;
if (typeFilter.value === 'command' && cmd.type !== 'command') return false;
if (typeFilter.value === 'sub_command' && cmd.type !== 'sub_command') return false;
}
return true;
};
/**
* 过滤后的指令列表(支持层级结构)
*/
const filteredCommands = computed(() => {
const query = searchQuery.value.toLowerCase();
const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = [];
for (const cmd of commands.value) {
// 对于指令组,检查组本身或子指令是否匹配
if (cmd.is_group) {
const groupMatches = matchesFilters(cmd, query);
const matchingSubCmds = (cmd.sub_commands || []).filter(sub => matchesFilters(sub, query));
// 如果组匹配或有匹配的子指令,则包含它
if (groupMatches || matchingSubCmds.length > 0) {
if (cmd.has_conflict) {
conflictCmds.push(cmd);
} else {
normalCmds.push(cmd);
}
// 如果组已展开,添加匹配的子指令
if (expandedGroups.value.has(cmd.handler_full_name)) {
const subsToShow = query ? matchingSubCmds : (cmd.sub_commands || []);
for (const sub of subsToShow) {
if (sub.has_conflict) {
conflictCmds.push(sub);
} else {
normalCmds.push(sub);
}
}
}
}
} else if (cmd.type !== 'sub_command') {
// 普通指令(子指令通过组处理)
if (matchesFilters(cmd, query)) {
if (cmd.has_conflict) {
conflictCmds.push(cmd);
} else {
normalCmds.push(cmd);
}
}
}
}
// 按 effective_command 排序冲突指令,使其分组在一起
conflictCmds.sort((a, b) => (a.effective_command || '').localeCompare(b.effective_command || ''));
return [...conflictCmds, ...normalCmds];
});
/**
* 切换指令组的展开/折叠状态
*/
const toggleGroupExpand = (cmd: CommandItem) => {
if (!cmd.is_group) return;
if (expandedGroups.value.has(cmd.handler_full_name)) {
expandedGroups.value.delete(cmd.handler_full_name);
} else {
expandedGroups.value.add(cmd.handler_full_name);
}
};
/**
* 检查指令组是否已展开
*/
const isGroupExpanded = (cmd: CommandItem): boolean => {
return expandedGroups.value.has(cmd.handler_full_name);
};
return {
// 状态
searchQuery,
pluginFilter,
permissionFilter,
statusFilter,
typeFilter,
showSystemPlugins,
expandedGroups,
// 计算属性
hasSystemPluginConflict,
effectiveShowSystemPlugins,
availablePlugins,
filteredCommands,
// 方法
matchesFilters,
toggleGroupExpand,
isGroupExpanded
};
}
@@ -0,0 +1,83 @@
/**
* 指令数据管理 Composable
*/
import { ref, reactive } from 'vue';
import axios from 'axios';
import type { CommandItem, CommandSummary, SnackbarState, ToolItem } from '../types';
export function useComponentData() {
const loading = ref(false);
const commands = ref<CommandItem[]>([]);
const tools = ref<ToolItem[]>([]);
const toolsLoading = ref(false);
const summary = reactive<CommandSummary>({
disabled: 0,
conflicts: 0
});
const snackbar = reactive<SnackbarState>({
show: false,
message: '',
color: 'success'
});
/**
* 显示 Toast 消息
*/
const toast = (message: string, color: string = 'success') => {
snackbar.message = message;
snackbar.color = color;
snackbar.show = true;
};
/**
* 获取指令列表
*/
const fetchCommands = async (errorMessage: string) => {
loading.value = true;
try {
const res = await axios.get('/api/commands');
if (res.data.status === 'ok') {
commands.value = res.data.data.items || [];
const s = res.data.data.summary || {};
summary.disabled = s.disabled || 0;
summary.conflicts = s.conflicts || 0;
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
} finally {
loading.value = false;
}
};
const fetchTools = async (errorMessage: string) => {
toolsLoading.value = true;
try {
const res = await axios.get('/api/tools/list');
if (res.data.status === 'ok') {
tools.value = res.data.data || [];
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
} finally {
toolsLoading.value = false;
}
};
return {
loading,
commands,
tools,
toolsLoading,
summary,
snackbar,
toast,
fetchCommands,
fetchTools
};
}
@@ -0,0 +1,307 @@
<script setup lang="ts">
/**
* 组件管理页面 - 主入口
*
* 模块化结构:
* - types.ts: 类型定义
* - composables/useComponentData.ts: 数据获取和状态管理
* - composables/useCommandFilters.ts: 过滤逻辑
* - composables/useCommandActions.ts: 操作方法
* - components/CommandFilters.vue: 过滤器组件
* - components/CommandTable.vue: 表格组件
* - components/RenameDialog.vue: 重命名对话框
* - components/DetailsDialog.vue: 详情对话框
*/
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
// Composables
import { useComponentData } from './composables/useComponentData';
import { useCommandFilters } from './composables/useCommandFilters';
import { useCommandActions } from './composables/useCommandActions';
// Components
import CommandFilters from './components/CommandFilters.vue';
import CommandTable from './components/CommandTable.vue';
import ToolTable from './components/ToolTable.vue';
import RenameDialog from './components/RenameDialog.vue';
import DetailsDialog from './components/DetailsDialog.vue';
// Types
import type { CommandItem, ToolItem } from './types';
defineOptions({ name: 'ComponentPanel' });
const props = withDefaults(defineProps<{ active?: boolean }>(), {
active: true
});
const { tm } = useModuleI18n('features/command');
const { tm: tmTool } = useModuleI18n('features/tooluse');
const viewMode = ref<'commands' | 'tools'>('commands');
const toolSearch = ref('');
// 数据管理
const {
loading,
commands,
tools,
toolsLoading,
summary,
snackbar,
toast,
fetchCommands,
fetchTools
} = useComponentData();
// 过滤逻辑
const {
searchQuery,
pluginFilter,
permissionFilter,
statusFilter,
typeFilter,
showSystemPlugins,
expandedGroups,
hasSystemPluginConflict,
effectiveShowSystemPlugins,
availablePlugins,
filteredCommands,
toggleGroupExpand
} = useCommandFilters(commands);
// 操作方法
const {
renameDialog,
detailsDialog,
toggleCommand,
openRenameDialog,
confirmRename,
openDetailsDialog
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = toolSearch.value.trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
);
});
// 处理切换指令状态
const handleToggleCommand = async (cmd: CommandItem) => {
await toggleCommand(cmd, tm('messages.toggleSuccess'), tm('messages.toggleFailed'));
};
const handleToggleTool = async (tool: ToolItem) => {
const previous = tool.active;
tool.active = !tool.active;
try {
const res = await axios.post('/api/tools/toggle-tool', {
name: tool.name,
activate: tool.active
});
if (res.data.status === 'ok') {
toast(res.data.message || tmTool('messages.toggleToolSuccess'));
} else {
tool.active = previous;
toast(res.data.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
}
} catch (error: any) {
tool.active = previous;
toast(error?.response?.data?.message || error?.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
}
};
// 处理确认重命名
const handleConfirmRename = async () => {
await confirmRename(tm('messages.renameSuccess'), tm('messages.renameFailed'));
};
// 生命周期
onMounted(async () => {
await Promise.all([
fetchCommands(tm('messages.loadFailed')),
fetchTools(tmTool('messages.getToolsError', { error: '' }))
]);
});
watch(() => props.active, async (isActive) => {
if (!isActive) return;
if (viewMode.value === 'commands') {
await fetchCommands(tm('messages.loadFailed'));
} else {
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
}
});
watch(viewMode, async (mode) => {
if (mode === 'commands') {
await fetchCommands(tm('messages.loadFailed'));
} else {
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
}
});
</script>
<template>
<v-row>
<v-col cols="12">
<v-card variant="flat" style="background-color: transparent">
<v-card-text style="padding: 20px 12px; padding-top: 0px;">
<div class="d-flex justify-space-between align-center mb-6 flex-wrap ga-3">
<v-btn-toggle v-model="viewMode" color="primary" variant="outlined" density="comfortable" mandatory>
<v-btn value="commands">
<v-icon size="18" class="mr-1">mdi-console-line</v-icon>
{{ tm('type.command') }}
</v-btn>
<v-btn value="tools">
<v-icon size="18" class="mr-1">mdi-function-variant</v-icon>
{{ tmTool('functionTools.title') }}
</v-btn>
</v-btn-toggle>
<v-progress-linear
v-if="viewMode === 'commands' && loading"
indeterminate
color="primary"
style="max-width: 220px; flex: 1;"
/>
<v-progress-linear
v-else-if="viewMode === 'tools' && toolsLoading"
indeterminate
color="primary"
style="max-width: 220px; flex: 1;"
/>
</div>
<div v-if="viewMode === 'commands'">
<CommandFilters
:plugin-filter="pluginFilter"
@update:plugin-filter="pluginFilter = $event"
:type-filter="typeFilter"
@update:type-filter="typeFilter = $event"
:permission-filter="permissionFilter"
@update:permission-filter="permissionFilter = $event"
:status-filter="statusFilter"
@update:status-filter="statusFilter = $event"
:show-system-plugins="showSystemPlugins"
@update:show-system-plugins="showSystemPlugins = $event"
:search-query="searchQuery"
@update:search-query="searchQuery = $event"
:available-plugins="availablePlugins"
:has-system-plugin-conflict="hasSystemPluginConflict"
:effective-show-system-plugins="effectiveShowSystemPlugins"
>
<template #stats>
<div class="d-flex align-center">
<v-icon size="18" color="primary" class="mr-1">mdi-console-line</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
<span class="text-body-1 font-weight-bold text-primary">{{ filteredCommands.length }}</span>
</div>
<v-divider vertical class="mx-1" style="height: 20px;" />
<div class="d-flex align-center">
<v-icon size="18" color="error" class="mr-1">mdi-close-circle-outline</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.disabled') }}:</span>
<span class="text-body-1 font-weight-bold text-error">{{ summary.disabled }}</span>
</div>
</template>
</CommandFilters>
<v-alert
v-if="summary.conflicts > 0"
type="error"
variant="tonal"
class="mb-4"
prominent
border="start"
>
<template v-slot:prepend>
<v-icon size="28">mdi-alert-circle</v-icon>
</template>
<v-alert-title class="text-subtitle-1 font-weight-bold">
{{ tm('conflictAlert.title') }}
</v-alert-title>
<div class="text-body-2 mt-1">
{{ tm('conflictAlert.description', { count: summary.conflicts }) }}
</div>
<div class="text-body-2 mt-2">
<v-icon size="16" class="mr-1">mdi-lightbulb-outline</v-icon>
{{ tm('conflictAlert.hint') }}
</div>
</v-alert>
<CommandTable
:items="filteredCommands"
:expanded-groups="expandedGroups"
:loading="loading"
@toggle-expand="toggleGroupExpand"
@toggle-command="handleToggleCommand"
@rename="openRenameDialog"
@view-details="openDetailsDialog"
/>
</div>
<div v-else>
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
density="compact"
hide-details
clearable
/>
</div>
<div class="d-flex align-center ga-2">
<div class="d-flex align-center">
<v-icon size="18" color="primary" class="mr-1">mdi-function-variant</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
<span class="text-body-1 font-weight-bold text-primary">{{ filteredTools.length }}</span>
</div>
<v-divider vertical class="mx-1" style="height: 20px;" />
<div class="d-flex align-center">
<v-icon size="18" color="success" class="mr-1">mdi-check-circle-outline</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('status.enabled') }}:</span>
<span class="text-body-1 font-weight-bold text-success">{{ filteredTools.filter(t => t.active).length }}</span>
</div>
</div>
</div>
<ToolTable
:items="filteredTools"
:loading="toolsLoading"
@toggle-tool="handleToggleTool"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 重命名对话框 -->
<RenameDialog
:show="renameDialog.show"
@update:show="renameDialog.show = $event"
:new-name="renameDialog.newName"
@update:new-name="renameDialog.newName = $event"
:command="renameDialog.command"
:loading="renameDialog.loading"
@confirm="handleConfirmRename"
/>
<!-- 详情对话框 -->
<DetailsDialog
:show="detailsDialog.show"
@update:show="detailsDialog.show = $event"
:command="detailsDialog.command"
/>
<!-- Snackbar -->
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
{{ snackbar.message }}
</v-snackbar>
</template>
@@ -0,0 +1,102 @@
/**
* 指令管理模块 - 类型定义
*/
/** 指令项接口 */
export interface CommandItem {
handler_full_name: string;
handler_name: string;
plugin: string;
plugin_display_name: string | null;
module_path: string;
description: string;
type: CommandType;
parent_signature: string;
parent_group_handler: string;
original_command: string;
current_fragment: string;
effective_command: string;
aliases: string[];
permission: PermissionType;
enabled: boolean;
is_group: boolean;
has_conflict: boolean;
reserved: boolean;
sub_commands: CommandItem[];
}
/** 指令类型 */
export type CommandType = 'command' | 'group' | 'sub_command';
/** 权限类型 */
export type PermissionType = 'admin' | 'everyone' | 'member';
/** 指令摘要统计 */
export interface CommandSummary {
disabled: number;
conflicts: number;
}
/** 过滤器状态 */
export interface FilterState {
searchQuery: string;
pluginFilter: string;
permissionFilter: string;
statusFilter: string;
typeFilter: string;
showSystemPlugins: boolean;
}
/** 重命名对话框状态 */
export interface RenameDialogState {
show: boolean;
command: CommandItem | null;
newName: string;
loading: boolean;
}
/** 详情对话框状态 */
export interface DetailsDialogState {
show: boolean;
command: CommandItem | null;
}
/** Toast 消息状态 */
export interface SnackbarState {
show: boolean;
message: string;
color: string;
}
/** 类型信息展示 */
export interface TypeInfo {
text: string;
color: string;
icon: string;
}
/** 状态信息展示 */
export interface StatusInfo {
text: string;
color: string;
variant: 'flat' | 'outlined' | 'text' | 'elevated' | 'tonal' | 'plain';
}
/** MCP/函数工具参数定义 */
export interface ToolParameter {
type?: string;
description?: string;
}
/** MCP/函数工具对象 */
export interface ToolItem {
name: string;
description: string;
active: boolean;
parameters?: {
properties?: Record<string, ToolParameter>;
};
origin?: string;
origin_name?: string;
}
@@ -1,6 +1,7 @@
<script setup>
import { useCommonStore } from '@/stores/common';
import { storeToRefs } from 'pinia';
import axios from 'axios';
</script>
<template>
@@ -24,8 +25,6 @@ import { storeToRefs } from 'pinia';
export default {
name: 'ConsoleDisplayer',
data() {
const commonStore = useCommonStore();
const { log_cache } = storeToRefs(commonStore);
return {
autoScroll: true, // 默认开启自动滚动
logColorAnsiMap: {
@@ -38,7 +37,6 @@ export default {
'\u001b[32m': 'color: #00FF00;', // green
'default': 'color: #FFFFFF;'
},
logCache: log_cache,
historyNum_: -1,
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
selectedLevels: [0, 1, 2, 3, 4], // 默认选中所有级别
@@ -48,7 +46,17 @@ export default {
'WARNING': 'amber',
'ERROR': 'red',
'CRITICAL': 'purple'
}
},
lastProcessedTime: 0, // 记录最后处理的日志时间戳
localLogCache: [], // 本地日志缓存
}
},
computed: {
commonStore() {
return useCommonStore();
},
logCache() {
return this.commonStore.log_cache;
}
},
props: {
@@ -63,13 +71,39 @@ export default {
},
watch: {
logCache: {
handler(val) {
const lastLog = val[this.logCache.length - 1];
if (lastLog && this.isLevelSelected(lastLog.level)) {
this.printLog(lastLog.data);
handler(newVal) {
// 基于 timestamp 处理新增的日志
if (newVal && newVal.length > 0) {
// 确保 DOM 已经准备好
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: {
handler() {
@@ -78,14 +112,37 @@ export default {
deep: true
}
},
mounted() {
if (this.logCache.length === 0) {
this.delayInit()
} else {
this.init()
}
async mounted() {
// 请求历史日志
await this.fetchLogHistory();
// 等待 DOM 准备好后,显示历史日志
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: {
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) {
return this.levelColors[level] || 'grey';
},
@@ -101,41 +158,22 @@ export default {
},
refreshDisplay() {
// 清空现有的显示
const termElement = document.getElementById('term');
if (termElement) {
termElement.innerHTML = '';
}
// 重新显示符合筛选条件的日志
this.init();
},
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)
}
// 重新显示所有符合筛选条件的日志
if (this.localLogCache && this.localLogCache.length > 0) {
this.localLogCache.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
}
}
},
toggleAutoScroll() {
this.autoScroll = !this.autoScroll;
},
@@ -143,6 +181,11 @@ export default {
printLog(log) {
// append 一个 span 标签到 termblock 的方式
let ele = document.getElementById('term')
if (!ele) {
console.warn('term element not found, skipping log print');
return;
}
let span = document.createElement('pre')
let style = this.logColorAnsiMap['default']
for (let key in this.logColorAnsiMap) {
@@ -121,7 +121,8 @@ import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import {
getSidebarCustomization,
setSidebarCustomization,
clearSidebarCustomization
clearSidebarCustomization,
resolveSidebarItems
} from '@/utils/sidebarCustomization';
const { t } = useI18n();
@@ -133,35 +134,12 @@ const draggedItem = ref(null);
function initializeItems() {
const customization = getSidebarCustomization();
if (customization) {
// Load from customization
const allItemsMap = new Map();
sidebarItems.forEach(item => {
if (item.children) {
item.children.forEach(child => {
allItemsMap.set(child.title, child);
});
} else {
allItemsMap.set(item.title, item);
}
});
mainItems.value = customization.mainItems
.map(title => allItemsMap.get(title))
.filter(item => item);
moreItems.value = customization.moreItems
.map(title => allItemsMap.get(title))
.filter(item => item);
} else {
// Load default structure
mainItems.value = sidebarItems.filter(item => !item.children);
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
moreItems.value = moreGroup ? [...moreGroup.children] : [];
}
const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
sidebarItems,
customization
);
mainItems.value = resolvedMain;
moreItems.value = resolvedMore;
}
function openDialog() {
@@ -19,5 +19,6 @@
"submit": "Submit",
"reset": "Reset",
"clear": "Clear",
"save": "Save"
"save": "Save",
"close": "Close"
}
@@ -2,6 +2,7 @@
"dashboard": "Dashboard",
"platforms": "Platforms",
"providers": "Providers",
"commands": "Commands",
"persona": "Persona",
"toolUse": "MCP Tools",
"config": "Config",
@@ -0,0 +1,91 @@
{
"title": "Command Management",
"summary": {
"total": "Displayed commands",
"disabled": "Disabled",
"conflicts": "Conflicts"
},
"conflictAlert": {
"title": "Command Conflicts Detected",
"description": "There are {count} conflicting commands. Conflicting commands will trigger multiple plugins simultaneously, which may cause unexpected behavior.",
"hint": "Click the \"Rename\" button to rename conflicting commands and resolve conflicts."
},
"table": {
"headers": {
"command": "Command",
"type": "Type",
"plugin": "Plugin",
"description": "Description",
"permission": "Permission",
"status": "Status",
"actions": "Actions"
}
},
"type": {
"command": "Command",
"group": "Group",
"subCommand": "Sub-command"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"conflict": "Conflict"
},
"permission": {
"everyone": "Everyone",
"admin": "Admin"
},
"tooltips": {
"enable": "Enable command",
"disable": "Disable command",
"rename": "Rename command",
"viewDetails": "View details"
},
"dialogs": {
"rename": {
"title": "Rename Command",
"newName": "New command name",
"cancel": "Cancel",
"confirm": "Confirm"
},
"details": {
"title": "Command Details",
"type": "Command Type",
"handler": "Handler",
"module": "Module Path",
"originalCommand": "Original Command",
"effectiveCommand": "Effective Command",
"parentGroup": "Parent Group",
"subCommands": "Sub-commands",
"aliases": "Aliases",
"permission": "Permission",
"conflictStatus": "Conflict Status"
}
},
"messages": {
"toggleSuccess": "Command status updated",
"toggleFailed": "Failed to update command status",
"renameSuccess": "Command renamed",
"renameFailed": "Rename failed",
"loadFailed": "Failed to load commands"
},
"search": {
"placeholder": "Search commands..."
},
"empty": {
"noCommands": "No Commands",
"noCommandsDesc": "No commands found"
},
"filters": {
"all": "All",
"enabled": "Enabled",
"disabled": "Disabled",
"conflict": "Conflict",
"byPlugin": "Filter by plugin",
"byType": "Filter by type",
"byPermission": "Filter by permission",
"byStatus": "Filter by status",
"showSystemPlugins": "Show system plugins commands",
"systemPluginConflictHint": "System plugin conflicts detected. Resolve conflicts to hide."
}
}
@@ -13,7 +13,8 @@
"refresh": "Refresh"
},
"batch": {
"deleteSelected": "Delete Selected ({count})"
"deleteSelected": "Delete Selected ({count})",
"exportSelected": "Export Selected ({count})"
},
"pagination": {
"itemsPerPage": "Items per page",
@@ -76,7 +77,8 @@
"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",
"cancel": "Cancel",
"confirm": "Batch Delete"
"confirm": "Batch Delete",
"warning": "Warning: This action cannot be undone!"
}
},
"messages": {
@@ -92,6 +94,9 @@
"noItemSelected": "Please select conversations to delete first",
"batchDeleteSuccess": "Successfully deleted {count} conversations",
"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,7 +2,9 @@
"title": "Extension Management",
"subtitle": "Manage and configure system extensions",
"tabs": {
"installed": "Installed",
"installedPlugins": "Installed Plugins",
"installedMcpServers": "Installed MCP Servers",
"handlersOperation": "Manage Components",
"market": "Extension Market"
},
"search": {
@@ -197,5 +199,12 @@
"errors": {
"confirmNotRegistered": "$confirm not properly registered"
}
},
"conflicts": {
"title": "Command Conflicts Detected",
"message": "This will cause some commands to work abnormally. It is recommended to go to the [Command Management] panel to handle it.",
"pairs": "command conflicts",
"goToManage": "Go to Manage",
"later": "Later"
}
}
@@ -42,7 +42,10 @@
"paramName": "Parameter Name",
"type": "Type",
"description": "Description",
"required": "Required"
"required": "Required",
"origin": "Origin",
"originName": "Origin Name",
"actions": "Actions"
}
},
"marketplace": {
@@ -19,5 +19,6 @@
"submit": "提交",
"reset": "重置",
"clear": "清空",
"save": "保存"
"save": "保存",
"close": "关闭"
}
@@ -2,6 +2,7 @@
"dashboard": "数据统计",
"platforms": "机器人",
"providers": "模型提供商",
"commands": "指令管理",
"persona": "人格设定",
"toolUse": "MCP",
"extension": "插件",
@@ -0,0 +1,91 @@
{
"title": "指令管理",
"summary": {
"total": "展示的指令数",
"disabled": "已禁用",
"conflicts": "有冲突"
},
"conflictAlert": {
"title": "检测到指令冲突",
"description": "当前有 {count} 对指令存在冲突,冲突的指令会同时触发多个插件响应,可能导致意外行为。",
"hint": "请点击「重命名」按钮修改冲突指令的名称以解决冲突。"
},
"table": {
"headers": {
"command": "指令",
"type": "类型",
"plugin": "所属插件",
"description": "描述",
"permission": "权限",
"status": "状态",
"actions": "操作"
}
},
"type": {
"command": "指令",
"group": "指令组",
"subCommand": "子指令"
},
"status": {
"enabled": "已启用",
"disabled": "已禁用",
"conflict": "有冲突"
},
"permission": {
"everyone": "所有人",
"admin": "管理员"
},
"tooltips": {
"enable": "启用指令",
"disable": "禁用指令",
"rename": "重命名指令",
"viewDetails": "查看详情"
},
"dialogs": {
"rename": {
"title": "重命名指令",
"newName": "新指令名",
"cancel": "取消",
"confirm": "确认"
},
"details": {
"title": "指令详情",
"type": "指令类型",
"handler": "处理函数",
"module": "模块路径",
"originalCommand": "原始指令",
"effectiveCommand": "生效指令",
"parentGroup": "所属指令组",
"subCommands": "子指令列表",
"aliases": "别名",
"permission": "权限要求",
"conflictStatus": "冲突状态"
}
},
"messages": {
"toggleSuccess": "指令状态已更新",
"toggleFailed": "更新指令状态失败",
"renameSuccess": "指令已重命名",
"renameFailed": "重命名失败",
"loadFailed": "加载指令列表失败"
},
"search": {
"placeholder": "搜索指令..."
},
"empty": {
"noCommands": "暂无指令",
"noCommandsDesc": "当前筛选条件下没有找到任何指令"
},
"filters": {
"all": "全部",
"enabled": "已启用",
"disabled": "已禁用",
"conflict": "有冲突",
"byPlugin": "按插件筛选",
"byType": "按类型筛选",
"byPermission": "按权限筛选",
"byStatus": "按状态筛选",
"showSystemPlugins": "显示系统插件指令",
"systemPluginConflictHint": "存在涉及系统插件的冲突,需解决冲突后才能隐藏"
}
}
@@ -13,7 +13,8 @@
"refresh": "刷新"
},
"batch": {
"deleteSelected": "删除选中 ({count})"
"deleteSelected": "删除选中 ({count})",
"exportSelected": "导出选中 ({count})"
},
"pagination": {
"itemsPerPage": "每页",
@@ -76,7 +77,8 @@
"message": "确定要删除选中的 {count} 个对话吗?此操作不可恢复,请谨慎操作!",
"andMore": "等 {count} 个",
"cancel": "取消",
"confirm": "批量删除"
"confirm": "批量删除",
"warning": "警告:此操作不可撤销!"
}
},
"messages": {
@@ -92,6 +94,9 @@
"noItemSelected": "请先选择要删除的对话",
"batchDeleteSuccess": "成功删除 {count} 个对话",
"batchDeleteError": "批量删除失败",
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个"
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个",
"exportSuccess": "导出成功",
"exportError": "导出失败",
"noItemSelectedForExport": "请先选择要导出的对话"
}
}
@@ -2,7 +2,9 @@
"title": "插件管理",
"subtitle": "管理和配置系统插件",
"tabs": {
"installed": "已安装",
"installedPlugins": "已安装的插件",
"installedMcpServers": "已安装的 MCP 服务器",
"handlersOperation": "管理组件",
"market": "插件市场"
},
"search": {
@@ -197,5 +199,12 @@
"errors": {
"confirmNotRegistered": "$confirm 未正确注册"
}
},
"conflicts": {
"title": "检测到指令冲突",
"message": "这会导致部分指令工作异常,建议前往【指令管理】面板进行处理。",
"pairs": "对指令冲突",
"goToManage": "前往处理",
"later": "稍后处理"
}
}
@@ -42,7 +42,10 @@
"paramName": "参数名",
"type": "类型",
"description": "描述",
"required": "必填"
"required": "必填",
"origin": "来源",
"originName": "来源名称",
"actions": "操作"
}
},
"marketplace": {
+6 -2
View File
@@ -32,6 +32,7 @@ import zhCNKnowledgeBaseDetail from './locales/zh-CN/features/knowledge-base/det
import zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/document.json';
import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
import zhCNCommand from './locales/zh-CN/features/command.json';
import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
@@ -68,6 +69,7 @@ import enUSKnowledgeBaseDetail from './locales/en-US/features/knowledge-base/det
import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/document.json';
import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
import enUSCommand from './locales/en-US/features/command.json';
import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
@@ -111,7 +113,8 @@ export const translations = {
document: zhCNKnowledgeBaseDocument
},
persona: zhCNPersona,
migration: zhCNMigration
migration: zhCNMigration,
command: zhCNCommand
},
messages: {
errors: zhCNErrors,
@@ -155,7 +158,8 @@ export const translations = {
document: enUSKnowledgeBaseDocument
},
persona: enUSPersona,
migration: enUSMigration
migration: enUSMigration,
command: enUSCommand
},
messages: {
errors: enUSErrors,
@@ -33,11 +33,6 @@ const sidebarItem: menu[] = [
icon: 'mdi-cog',
to: '/config',
},
{
title: 'core.navigation.toolUse',
icon: 'mdi-function-variant',
to: '/tool-use'
},
{
title: 'core.navigation.extension',
icon: 'mdi-puzzle',
-5
View File
@@ -31,11 +31,6 @@ const MainRoutes = {
path: '/providers',
component: () => import('@/views/ProviderPage.vue')
},
{
name: 'ToolUsePage',
path: '/tool-use',
component: () => import('@/views/ToolUsePage.vue')
},
{
name: 'Configs',
path: '/config',
+30 -64
View File
@@ -16,21 +16,6 @@ export const useCommonStore = defineStore({
}),
actions: {
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) {
return
}
@@ -54,25 +39,9 @@ export const useCommonStore = defineStore({
const reader = response.body.getReader();
const decoder = new TextDecoder();
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;
}
}
let bufferedText = '';
const processStream = ({ done, value }) => {
// get bytes length
const bytesLength = value ? value.byteLength : 0;
console.log(`Received ${bytesLength} bytes from live log`);
if (done) {
console.log('SSE stream closed');
setTimeout(() => {
@@ -82,44 +51,41 @@ export const useCommonStore = defineStore({
return;
}
const text = decoder.decode(value);
const lines = text.split('\n\n');
lines.forEach(line => {
if (!line.trim()) {
// Accumulate partial chunks; SSE data may split JSON across reads.
const text = decoder.decode(value, { stream: true });
bufferedText += text;
// 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;
}
if (line.startsWith('data:')) {
const data = line.substring(5).trim();
// {"type":"log","data":"[2021-08-01 00:00:00] INFO: Hello, world!"}
let data_json = {}
try {
data_json = JSON.parse(data);
} catch (e) {
console.warn('Invalid JSON:', data);
// 尝试处理不完整的行
const parsedData = handleIncompleteLine(data);
if (parsedData) {
data_json = parsedData;
} else {
return; // 如果无法解析,跳过当前行
}
const logLine = line.replace('data: ', '').trim();
if (!logLine) {
return;
}
try {
const logObject = JSON.parse(logLine);
// give a uuid if not exists
if (!logObject.uuid) {
logObject.uuid = crypto.randomUUID();
}
if (data_json.type === 'log') {
this.log_cache.push(data_json);
if (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();
}
this.log_cache.push(logObject);
// Limit log cache size
if (this.log_cache.length > this.log_cache_max_len) {
this.log_cache.splice(0, this.log_cache.length - this.log_cache_max_len);
}
} catch (err) {
console.warn('Failed to parse SSE log line, skipping:', err, logLine);
}
});
return reader.read().then(processStream);
};
+89 -51
View File
@@ -41,59 +41,97 @@ export function clearSidebarCustomization() {
}
/**
* Apply customization to sidebar items
* @param {Array} defaultItems - Default sidebar items array
* @returns {Array} Customized sidebar items array (new array, doesn't mutate input)
* 解析侧边栏默认项与用户定制,返回主区/更多区及可选的合并结果
* @param {Array} defaultItems - 默认侧边栏结构
* @param {Object|null} customization - 用户定制(mainItems/moreItems
* @param {Object} options
* @param {boolean} [options.cloneItems=false] - 是否克隆条目以避免外部引用被修改
* @param {boolean} [options.assembleMoreGroup=false] - 是否组装带更多分组的整体数组
* @returns {{ mainItems: Array, moreItems: Array, merged?: Array }}
*/
export function resolveSidebarItems(defaultItems, customization, options = {}) {
const { cloneItems = false, assembleMoreGroup = false } = options;
const all = new Map();
const defaultMain = [];
const defaultMore = [];
// 收集所有条目,按 title 建索引
defaultItems.forEach(item => {
if (item.children) {
item.children.forEach(child => {
all.set(child.title, cloneItems ? { ...child } : child);
defaultMore.push(child.title);
});
} else {
all.set(item.title, cloneItems ? { ...item } : item);
defaultMain.push(item.title);
}
});
const hasCustomization = Boolean(customization);
const mainKeys = hasCustomization ? customization.mainItems || [] : defaultMain;
const moreKeys = hasCustomization ? customization.moreItems || [] : defaultMore;
const used = hasCustomization ? new Set([...mainKeys, ...moreKeys]) : new Set(defaultMain.concat(defaultMore));
const mainItems = mainKeys
.map(title => all.get(title))
.filter(Boolean);
if (hasCustomization) {
// 补充新增默认主区项
defaultMain.forEach(title => {
if (!used.has(title)) {
const item = all.get(title);
if (item) mainItems.push(item);
}
});
}
const moreItems = moreKeys
.map(title => all.get(title))
.filter(Boolean);
if (hasCustomization) {
// 补充新增默认更多区项
defaultMore.forEach(title => {
if (!used.has(title)) {
const item = all.get(title);
if (item) moreItems.push(item);
}
});
}
let merged;
if (assembleMoreGroup) {
const children = cloneItems ? moreItems.map(item => ({ ...item })) : [...moreItems];
if (children.length > 0) {
merged = [
...mainItems,
{
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children
}
];
} else {
merged = [...mainItems];
}
}
return { mainItems, moreItems, merged };
}
/**
* 应用侧边栏定制,返回包含更多分组的完整结构
* @param {Array} defaultItems - 默认侧边栏结构
* @returns {Array} 自定义后的结构(新数组,不修改入参)
*/
export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization();
if (!customization) {
return defaultItems;
}
const { mainItems, moreItems } = customization;
// Create a map of all items by title for quick lookup
// Deep clone items to avoid mutating originals
const allItemsMap = new Map();
defaultItems.forEach(item => {
if (item.children) {
// If it's the "More" group, add children to map
item.children.forEach(child => {
allItemsMap.set(child.title, { ...child });
});
} else {
allItemsMap.set(item.title, { ...item });
}
const { merged } = resolveSidebarItems(defaultItems, customization, {
cloneItems: true,
assembleMoreGroup: true
});
const customizedItems = [];
// Add main items in custom order
mainItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
customizedItems.push(item);
}
});
// If there are items in moreItems, create the "More Features" group
if (moreItems && moreItems.length > 0) {
const moreGroup = {
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children: []
};
moreItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
moreGroup.children.push(item);
}
});
customizedItems.push(moreGroup);
}
return customizedItems;
return merged || defaultItems;
}
+58
View File
@@ -40,6 +40,17 @@
:loading="loading" size="small" class="mr-2">
{{ tm('history.refresh') }}
</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-if="selectedItems.length > 0"
color="error"
@@ -910,6 +921,53 @@ 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) {
if (!timestamp) return this.tm('status.unknown');
+93 -6
View File
@@ -5,18 +5,45 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.vue';
import McpServersSection from '@/components/extension/McpServersSection.vue';
import ComponentPanel from '@/components/extension/componentPanel/index.vue';
import axios from 'axios';
import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import defaultPluginIcon from '@/assets/images/plugin_icon.png';
import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
import { ref, computed, onMounted, reactive, watch } from 'vue';
import { useRouter } from 'vue-router';
const commonStore = useCommonStore();
const { t } = useI18n();
const { tm } = useModuleI18n('features/extension');
const router = useRouter();
// 检查指令冲突并提示
const conflictDialog = reactive({
show: false,
count: 0
});
const checkAndPromptConflicts = async () => {
try {
const res = await axios.get('/api/commands');
if (res.data.status === 'ok') {
const conflicts = res.data.data.summary?.conflicts || 0;
if (conflicts > 0) {
conflictDialog.count = conflicts;
conflictDialog.show = true;
}
}
} catch (err) {
console.debug('Failed to check command conflicts:', err);
}
};
const handleConflictConfirm = () => {
activeTab.value = 'commands';
};
const fileInput = ref(null);
const activeTab = ref('installed');
const extension_data = reactive({
@@ -448,7 +475,9 @@ const pluginOn = async (extension) => {
return;
}
toast(res.data.message, "success");
getExtensions();
await getExtensions();
await checkAndPromptConflicts();
} catch (err) {
toast(err, "error");
}
@@ -782,6 +811,8 @@ const newExtension = async () => {
name: res.data.data.name,
repo: res.data.data.repo || null
});
await checkAndPromptConflicts();
}).catch((err) => {
loading_.value = false;
onLoadingDialogResult(2, err, -1);
@@ -808,6 +839,8 @@ const newExtension = async () => {
name: res.data.data.name,
repo: res.data.data.repo || null
});
await checkAndPromptConflicts();
}).catch((err) => {
loading_.value = false;
toast(tm('messages.installFailed') + " " + err, "error");
@@ -900,21 +933,29 @@ watch(marketSearch, (newVal) => {
<v-tabs v-model="activeTab" color="primary">
<v-tab value="installed">
<v-icon class="mr-2">mdi-puzzle</v-icon>
{{ tm('tabs.installed') }}
{{ tm('tabs.installedPlugins') }}
</v-tab>
<v-tab value="mcp">
<v-icon class="mr-2">mdi-server-network</v-icon>
{{ tm('tabs.installedMcpServers') }}
</v-tab>
<v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon>
{{ tm('tabs.market') }}
</v-tab>
<v-tab value="components">
<v-icon class="mr-2">mdi-wrench</v-icon>
{{ tm('tabs.handlersOperation') }}
</v-tab>
</v-tabs>
<!-- 搜索栏 - 在移动端时独占一行 -->
<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
hide-details single-line>
</v-text-field>
<v-text-field v-else v-model="pluginSearch" density="compact" :label="tm('search.placeholder')"
<v-text-field v-else-if="activeTab === 'installed'" v-model="pluginSearch" density="compact" :label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details single-line>
</v-text-field>
</div>
@@ -1118,6 +1159,24 @@ watch(marketSearch, (newVal) => {
</v-fade-transition>
</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'">
@@ -1544,6 +1603,34 @@ watch(marketSearch, (newVal) => {
<!-- 卸载插件确认对话框列表模式用 -->
<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-card>
@@ -71,6 +71,7 @@ class AdminCommands:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
async def update_dashboard(self, event: AstrMessageEvent):
"""更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板..."))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成。"))
+53 -28
View File
@@ -3,6 +3,7 @@ import aiohttp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.config.default import VERSION
from astrbot.core.star import command_management
from astrbot.core.utils.io import get_dashboard_version
@@ -21,6 +22,46 @@ class HelpCommand:
except BaseException:
return ""
async def _build_reserved_command_lines(self) -> list[str]:
"""
使用实时指令配置生成内置指令清单确保重命名/禁用后与实际生效状态保持一致
"""
try:
commands = await command_management.list_commands()
except BaseException:
return []
lines: list[str] = []
hidden_commands = {"set", "unset", "websearch"}
def walk(items: list[dict], indent: int = 0):
for item in items:
if not item.get("reserved") or not item.get("enabled"):
continue
# 仅展示顶级指令或指令组
if item.get("type") == "sub_command":
continue
if item.get("parent_signature"):
continue
effective = (
item.get("effective_command")
or item.get("original_command")
or item.get("handler_name")
)
if not effective:
continue
if effective in hidden_commands:
continue
description = item.get("description") or ""
desc_text = f" - {description}" if description else ""
indent_prefix = " " * indent
lines.append(f"{indent_prefix}/{effective}{desc_text}")
walk(commands)
return lines
async def help(self, event: AstrMessageEvent):
"""查看帮助"""
notice = ""
@@ -30,34 +71,18 @@ class HelpCommand:
pass
dashboard_version = await get_dashboard_version()
command_lines = await self._build_reserved_command_lines()
commands_section = (
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
)
msg = f"""AstrBot v{VERSION}(WebUI: {dashboard_version})
内置指令:
[System]
/plugin: 查看插件插件帮助
/t2i: 开关文本转图片
/tts: 开关文本转语音
/sid: 获取会话 ID
/op: 管理员
/wl: 白名单
/dashboard_update: 更新管理面板(op)
/alter_cmd: 设置指令权限(op)
[大模型]
/llm: 开启/关闭 LLM
/provider: 大模型提供商
/model: 模型列表
/ls: 对话列表
/new: 创建新对话
/groupnew 群号: 为群聊创建新对话(op)
/switch 序号: 切换对话
/rename 新名字: 重命名当前对话
/del: 删除当前会话对话(op)
/reset: 重置 LLM 会话
/history: 当前对话的对话记录
/persona: 人格情景(op)
/key: API Key(op)
/websearch: 网页搜索
{notice}"""
msg_parts = [
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
"内置指令:",
commands_section,
]
if notice:
msg_parts.append(notice)
msg = "\n".join(msg_parts)
event.set_result(MessageEventResult().message(msg).use_t2i(False))
+3 -2
View File
@@ -49,7 +49,7 @@ class Main(star.Star):
@filter.command_group("tool")
def tool(self):
pass
"""函数工具管理"""
@tool.command("ls")
async def tool_ls(self, event: AstrMessageEvent):
@@ -73,7 +73,7 @@ class Main(star.Star):
@filter.command_group("plugin")
def plugin(self):
pass
"""插件管理"""
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent):
@@ -219,6 +219,7 @@ class Main(star.Star):
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent):
"""更新管理面板"""
await self.admin_c.update_dashboard(event)
@filter.command("set")
+1 -1
View File
@@ -249,7 +249,7 @@ class Main(star.Star):
@filter.command_group("pi")
def pi(self):
pass
"""代码执行器配置"""
@pi.command("absdir")
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
+1 -1
View File
@@ -179,7 +179,7 @@ class Main(star.Star):
@filter.command_group("reminder")
def reminder(self):
"""The command group of the reminder."""
"""待办提醒"""
async def get_upcoming_reminders(self, unified_msg_origin: str):
"""Get upcoming reminders."""
+1
View File
@@ -185,6 +185,7 @@ class Main(star.Star):
@filter.command("websearch")
async def websearch(self, event: AstrMessageEvent, oper: str | None = None):
"""网页搜索指令(已废弃)"""
event.set_result(
MessageEventResult().message(
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.8.0"
version = "4.9.0"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"
+28
View File
@@ -160,6 +160,34 @@ async def test_plugins(app: Quart, authenticated_header: dict):
assert exists is False, "插件 astrbot_plugin_essential 未成功卸载"
@pytest.mark.asyncio
async def test_commands_api(app: Quart, authenticated_header: dict):
"""Tests the command management API endpoints."""
test_client = app.test_client()
# GET /api/commands - list commands
response = await test_client.get("/api/commands", headers=authenticated_header)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert "items" in data["data"]
assert "summary" in data["data"]
summary = data["data"]["summary"]
assert "total" in summary
assert "disabled" in summary
assert "conflicts" in summary
# GET /api/commands/conflicts - list conflicts
response = await test_client.get(
"/api/commands/conflicts", headers=authenticated_header
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
# conflicts is a list
assert isinstance(data["data"], list)
@pytest.mark.asyncio
async def test_check_update(app: Quart, authenticated_header: dict):
test_client = app.test_client()
+209
View File
@@ -0,0 +1,209 @@
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"]