From 62d7df1da701233221fd3505597eb4d67ad6fc40 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 30 Mar 2026 00:48:07 +0800 Subject: [PATCH] chore: stage all dev changes --- .mcp.json | 9 + astrbot/core/agent/context/compressor.py | 4 +- astrbot/core/agent/context/manager.py | 2 +- astrbot/core/agent/context/token_counter.py | 8 +- astrbot/core/agent/context/truncator.py | 2 +- .../dashscope/dashscope_agent_runner.py | 11 +- .../runners/deerflow/deerflow_agent_runner.py | 10 +- .../runners/deerflow/deerflow_api_client.py | 3 +- astrbot/core/astr_main_agent.py | 9 +- astrbot/core/computer/booters/base.py | 2 +- astrbot/core/computer/booters/bwrap.py | 45 ++- astrbot/core/computer/booters/local.py | 6 +- astrbot/core/computer/booters/shipyard.py | 7 +- astrbot/core/computer/booters/shipyard_neo.py | 26 +- astrbot/core/computer/tools/browser.py | 2 +- astrbot/core/computer/tools/fs.py | 2 +- astrbot/core/computer/tools/neo_skills.py | 2 +- astrbot/core/computer/tools/shell.py | 37 +- astrbot/core/db/migration/migra_3_to_4.py | 2 +- astrbot/core/db/vec_db/faiss_impl/vec_db.py | 2 +- .../core/knowledge_base/retrieval/manager.py | 3 +- .../pipeline/content_safety_check/stage.py | 4 +- .../core/pipeline/preprocess_stage/stage.py | 5 +- .../process_stage/method/star_request.py | 5 +- astrbot/core/pipeline/process_stage/stage.py | 4 +- astrbot/core/pipeline/respond/stage.py | 5 +- .../core/pipeline/result_decorate/stage.py | 5 +- .../pipeline/session_status_check/stage.py | 5 +- astrbot/core/pipeline/waking_check/stage.py | 5 +- .../core/pipeline/whitelist_check/stage.py | 5 +- .../aiocqhttp/aiocqhttp_message_event.py | 80 ++-- .../aiocqhttp/aiocqhttp_platform_adapter.py | 2 +- .../sources/dingtalk/dingtalk_adapter.py | 2 +- .../core/platform/sources/kook/kook_types.py | 2 +- .../platform/sources/lark/lark_adapter.py | 2 +- .../core/platform/sources/lark/lark_event.py | 92 +++-- .../platform/sources/line/line_adapter.py | 2 +- .../qqofficial/qqofficial_platform_adapter.py | 2 +- .../qqofficial_webhook/qo_webhook_adapter.py | 6 +- .../qqofficial_webhook/qo_webhook_event.py | 5 +- .../platform/sources/slack/slack_adapter.py | 2 +- .../platform/sources/telegram/tg_adapter.py | 34 +- .../sources/wecom_ai_bot/wecomai_adapter.py | 2 +- .../sources/weixin_oc/weixin_oc_adapter.py | 6 +- .../sources/weixin_oc/weixin_oc_client.py | 3 +- .../core/provider/sources/anthropic_source.py | 3 +- .../core/provider/sources/azure_tts_source.py | 7 +- .../provider/sources/bailian_rerank_source.py | 7 +- .../core/provider/sources/dashscope_tts.py | 7 +- .../core/provider/sources/edge_tts_source.py | 7 +- .../sources/fishaudio_tts_api_source.py | 7 +- .../sources/gemini_embedding_source.py | 7 +- .../core/provider/sources/gemini_source.py | 3 +- .../provider/sources/gemini_tts_source.py | 7 +- astrbot/core/provider/sources/groq_source.py | 3 +- .../provider/sources/gsv_selfhosted_source.py | 7 +- .../core/provider/sources/gsvi_tts_source.py | 7 +- .../core/provider/sources/kimi_code_source.py | 3 +- .../provider/sources/mimo_stt_api_source.py | 7 +- .../provider/sources/mimo_tts_api_source.py | 7 +- .../sources/minimax_tts_api_source.py | 7 +- .../provider/sources/oai_aihubmix_source.py | 3 +- .../sources/openai_embedding_source.py | 7 +- .../provider/sources/openai_tts_api_source.py | 7 +- .../provider/sources/openrouter_source.py | 3 +- .../sources/sensevoice_selfhosted_source.py | 7 +- .../provider/sources/vllm_rerank_source.py | 7 +- .../core/provider/sources/volcengine_tts.py | 7 +- .../provider/sources/whisper_api_source.py | 7 +- .../sources/whisper_selfhosted_source.py | 7 +- astrbot/core/provider/sources/xai_source.py | 3 +- astrbot/core/utils/media_utils.py | 23 +- astrbot/dashboard/routes/api_key.py | 24 +- astrbot/dashboard/routes/auth.py | 23 +- astrbot/dashboard/routes/chat.py | 76 ++-- astrbot/dashboard/routes/chatui_project.py | 62 ++- astrbot/dashboard/routes/command.py | 22 +- astrbot/dashboard/routes/conversation.py | 52 ++- astrbot/dashboard/routes/cron.py | 39 +- astrbot/dashboard/routes/log.py | 15 +- astrbot/dashboard/routes/open_api.py | 37 +- astrbot/dashboard/routes/persona.py | 119 +++--- astrbot/dashboard/routes/platform.py | 10 +- astrbot/dashboard/routes/plugin.py | 134 +++---- astrbot/dashboard/routes/route.py | 2 +- .../dashboard/routes/session_management.py | 112 +++--- astrbot/dashboard/routes/skills.py | 126 +++---- astrbot/dashboard/routes/stat.py | 67 ++-- astrbot/dashboard/routes/subagent.py | 14 +- astrbot/dashboard/routes/tools.py | 118 +++--- astrbot/dashboard/routes/tui_chat.py | 78 ++-- astrbot/dashboard/routes/update.py | 40 +- astrbot/dashboard/server.py | 6 +- dashboard/index.html | 2 + .../fonts/materialdesignicons-webfont.eot | Bin 0 -> 1307880 bytes .../fonts/materialdesignicons-webfont.ttf | Bin 0 -> 1307660 bytes .../fonts/materialdesignicons-webfont.woff | Bin 0 -> 587984 bytes .../fonts/materialdesignicons-webfont.woff2 | Bin 0 -> 403216 bytes dashboard/public/mdi-icons.css | 23 ++ .../i18n/locales/zh-CN/features/settings.json | 1 + dashboard/src/plugins/vuetify.ts | 2 +- dashboard/src/scss/_override.scss | 17 + dashboard/src/views/Settings.vue | 353 +++++++++--------- dashboard/vite.config.mts | 32 +- 104 files changed, 1145 insertions(+), 1117 deletions(-) create mode 100644 .mcp.json create mode 100644 dashboard/public/fonts/materialdesignicons-webfont.eot create mode 100644 dashboard/public/fonts/materialdesignicons-webfont.ttf create mode 100644 dashboard/public/fonts/materialdesignicons-webfont.woff create mode 100644 dashboard/public/fonts/materialdesignicons-webfont.woff2 create mode 100644 dashboard/public/mdi-icons.css diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..83b819d18 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "exa": { + "command": "npx", + "args": ["exa-mcp-server"], + "env": { + "EXA_API_KEY": "0093b9e9-7579-444a-8de2-b6e696b1b413" + } + } +} diff --git a/astrbot/core/agent/context/compressor.py b/astrbot/core/agent/context/compressor.py index fa8fff925..307232465 100644 --- a/astrbot/core/agent/context/compressor.py +++ b/astrbot/core/agent/context/compressor.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable -from ..message import Message +from astrbot.core.agent.message import Message if TYPE_CHECKING: from astrbot import logger @@ -15,7 +15,7 @@ else: if TYPE_CHECKING: from astrbot.core.provider.provider import Provider -from ..context.truncator import ContextTruncator +from astrbot.core.agent.context.truncator import ContextTruncator @runtime_checkable diff --git a/astrbot/core/agent/context/manager.py b/astrbot/core/agent/context/manager.py index 216a3e7e1..bc40c7147 100644 --- a/astrbot/core/agent/context/manager.py +++ b/astrbot/core/agent/context/manager.py @@ -1,6 +1,6 @@ from astrbot import logger +from astrbot.core.agent.message import Message -from ..message import Message from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor from .config import ContextConfig from .token_counter import EstimateTokenCounter diff --git a/astrbot/core/agent/context/token_counter.py b/astrbot/core/agent/context/token_counter.py index 8cad5f99b..bbcde7e50 100644 --- a/astrbot/core/agent/context/token_counter.py +++ b/astrbot/core/agent/context/token_counter.py @@ -1,7 +1,13 @@ import json from typing import Protocol, runtime_checkable -from ..message import AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart +from astrbot.core.agent.message import ( + AudioURLPart, + ImageURLPart, + Message, + TextPart, + ThinkPart, +) @runtime_checkable diff --git a/astrbot/core/agent/context/truncator.py b/astrbot/core/agent/context/truncator.py index 962e2ec33..33e760a92 100644 --- a/astrbot/core/agent/context/truncator.py +++ b/astrbot/core/agent/context/truncator.py @@ -1,4 +1,4 @@ -from ..message import Message +from astrbot.core.agent.message import Message class ContextTruncator: diff --git a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py index beab0b317..766474c23 100644 --- a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +++ b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py @@ -12,6 +12,11 @@ from dashscope.app.application_response import ApplicationResponse import astrbot.core.message.components as Comp from astrbot.core import logger, sp +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.response import AgentResponseData +from astrbot.core.agent.run_context import ContextWrapper, TContext +from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner +from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import ( LLMResponse, @@ -19,12 +24,6 @@ from astrbot.core.provider.entities import ( ) from astrbot.core.provider.provider import Provider -from ...hooks import BaseAgentRunHooks -from ...response import AgentResponseData -from ...run_context import ContextWrapper, TContext -from ...tool_executor import BaseFunctionToolExecutor -from ..base import AgentResponse, AgentState, BaseAgentRunner - if sys.version_info >= (3, 12): from typing import override else: diff --git a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py index 0ca4cfe6f..518d92681 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py @@ -12,6 +12,11 @@ from uuid import uuid4 import astrbot.core.message.components as Comp from astrbot import logger from astrbot.core import sp +from astrbot.core.agent.hooks import BaseAgentRunHooks +from astrbot.core.agent.response import AgentResponseData +from astrbot.core.agent.run_context import ContextWrapper, TContext +from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner +from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import ( LLMResponse, @@ -20,11 +25,6 @@ from astrbot.core.provider.entities import ( from astrbot.core.provider.provider import Provider from astrbot.core.utils.config_number import coerce_int_config -from ...hooks import BaseAgentRunHooks -from ...response import AgentResponseData -from ...run_context import ContextWrapper, TContext -from ...tool_executor import BaseFunctionToolExecutor -from ..base import AgentResponse, AgentState, BaseAgentRunner from .constants import DEERFLOW_SESSION_PREFIX, DEERFLOW_THREAD_ID_KEY from .deerflow_api_client import DeerFlowAPIClient from .deerflow_content_mapper import ( diff --git a/astrbot/core/agent/runners/deerflow/deerflow_api_client.py b/astrbot/core/agent/runners/deerflow/deerflow_api_client.py index e63eab3b2..160875af7 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_api_client.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_api_client.py @@ -1,5 +1,6 @@ import codecs import json +import types from collections.abc import AsyncGenerator from typing import Any @@ -136,7 +137,7 @@ class DeerFlowAPIClient: self, exc_type: type[BaseException] | None, exc: BaseException | None, - tb: object | None, + tb: types.TracebackType | None, ) -> None: await self.close() diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index f34e3a19a..c85708036 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -708,13 +708,10 @@ async def _process_quote_message( except BaseException as exc: logger.error("处理引用图片失败: %s", exc) finally: - if ( - compress_path - and compress_path != path - and os.path.exists(compress_path) - ): + if compress_path and compress_path != path: try: - os.remove(compress_path) + if await asyncio.to_thread(os.path.exists, compress_path): + await asyncio.to_thread(os.remove, compress_path) except Exception as exc: logger.warning("Fail to remove temporary compressed image: %s", exc) diff --git a/astrbot/core/computer/booters/base.py b/astrbot/core/computer/booters/base.py index b2541ca60..2b0ab94ca 100644 --- a/astrbot/core/computer/booters/base.py +++ b/astrbot/core/computer/booters/base.py @@ -3,7 +3,7 @@ from __future__ import annotations import abc from typing import TYPE_CHECKING -from ..olayer import ( +from astrbot.core.computer.olayer import ( BrowserComponent, FileSystemComponent, PythonComponent, diff --git a/astrbot/core/computer/booters/bwrap.py b/astrbot/core/computer/booters/bwrap.py index 5b0f6f947..480ddde31 100644 --- a/astrbot/core/computer/booters/bwrap.py +++ b/astrbot/core/computer/booters/bwrap.py @@ -10,11 +10,15 @@ import sys from dataclasses import dataclass, field from typing import Any +from astrbot.core.computer.olayer import ( + FileSystemComponent, + PythonComponent, + ShellComponent, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_temp_path, ) -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent from .base import ComputerBooter @@ -36,6 +40,16 @@ def _decode_shell_output(output: bytes | None) -> str: return output.decode("utf-8", errors="replace") +def _write_file_sync(path: str, content: str, mode: str, encoding: str) -> None: + with open(path, mode, encoding=encoding) as f: + f.write(content) + + +def _read_file_sync(path: str, encoding: str) -> str: + with open(path, encoding=encoding) as f: + return f.read() + + @dataclass class BwrapConfig: workspace_dir: str @@ -210,17 +224,15 @@ class HostBackedFileSystemComponent(FileSystemComponent): self, path: str, content: str = "", mode: int = 0o644 ) -> dict[str, Any]: p = self._safe_path(path) - os.makedirs(os.path.dirname(p), exist_ok=True) - with open(p, "w", encoding="utf-8") as f: - f.write(content) - os.chmod(p, mode) + await asyncio.to_thread(os.makedirs, os.path.dirname(p), exist_ok=True) + await asyncio.to_thread(_write_file_sync, p, content, "w", "utf-8") + await asyncio.to_thread(os.chmod, p, mode) return {"success": True, "path": p} async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]: p = self._safe_path(path) try: - with open(p, encoding=encoding) as f: - content = f.read() + content = await asyncio.to_thread(_read_file_sync, p, encoding) return {"success": True, "content": content} except Exception as e: return {"success": False, "error": str(e)} @@ -229,10 +241,9 @@ class HostBackedFileSystemComponent(FileSystemComponent): self, path: str, content: str, mode: str = "w", encoding: str = "utf-8" ) -> dict[str, Any]: p = self._safe_path(path) - os.makedirs(os.path.dirname(p), exist_ok=True) + await asyncio.to_thread(os.makedirs, os.path.dirname(p), exist_ok=True) try: - with open(p, mode, encoding=encoding) as f: - f.write(content) + await asyncio.to_thread(_write_file_sync, p, content, mode, encoding) return {"success": True} except Exception as e: return {"success": False, "error": str(e)} @@ -240,10 +251,10 @@ class HostBackedFileSystemComponent(FileSystemComponent): async def delete_file(self, path: str) -> dict[str, Any]: p = self._safe_path(path) try: - if os.path.isdir(p): - shutil.rmtree(p) + if await asyncio.to_thread(os.path.isdir, p): + await asyncio.to_thread(shutil.rmtree, p) else: - os.remove(p) + await asyncio.to_thread(os.remove, p) return {"success": True} except Exception as e: return {"success": False, "error": str(e)} @@ -292,10 +303,10 @@ class BwrapBooter(ComputerBooter): workspace_dir = os.path.join( get_astrbot_temp_path(), f"sandbox_workspace_{session_id}" ) - os.makedirs(workspace_dir, exist_ok=True) + await asyncio.to_thread(os.makedirs, workspace_dir, exist_ok=True) self.config = BwrapConfig( - workspace_dir=os.path.abspath(workspace_dir), + workspace_dir=await asyncio.to_thread(os.path.abspath, workspace_dir), rw_binds=self._rw_binds, ro_binds=self._ro_binds, ) @@ -320,8 +331,8 @@ class BwrapBooter(ComputerBooter): ) async def shutdown(self) -> None: - if self.config and os.path.exists(self.config.workspace_dir): - shutil.rmtree(self.config.workspace_dir, ignore_errors=True) + if self.config and await asyncio.to_thread(os.path.exists, self.config.workspace_dir): + await asyncio.to_thread(shutil.rmtree, self.config.workspace_dir, ignore_errors=True) async def upload_file(self, path: str, file_name: str) -> dict: if not self._fs or not self.config: diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 01436ba69..f547e096c 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -10,13 +10,17 @@ from dataclasses import dataclass from typing import Any from astrbot.api import logger +from astrbot.core.computer.olayer import ( + FileSystemComponent, + PythonComponent, + ShellComponent, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, get_astrbot_root, get_astrbot_temp_path, ) -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent from .base import ComputerBooter _BLOCKED_COMMAND_PATTERNS = [ diff --git a/astrbot/core/computer/booters/shipyard.py b/astrbot/core/computer/booters/shipyard.py index 12a4ce965..ab2f6a143 100644 --- a/astrbot/core/computer/booters/shipyard.py +++ b/astrbot/core/computer/booters/shipyard.py @@ -10,7 +10,12 @@ from astrbot.api import logger if TYPE_CHECKING: from astrbot.core.agent.tool import FunctionTool -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent +from astrbot.core.computer.olayer import ( + FileSystemComponent, + PythonComponent, + ShellComponent, +) + from .base import ComputerBooter diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index aa9aed047..b8a9be8d6 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -39,11 +39,12 @@ class NeoPythonComponent(PythonComponent): self, code: str, kernel_id: str | None = None, - timeout: int = 30, + timeout_sec: int = 30, silent: bool = False, ) -> dict[str, Any]: _ = kernel_id # Bay runtime does not expose kernel_id in current SDK. - result = await self._sandbox.python.exec(code, timeout=timeout) + with anyio.fail_after(timeout_sec): + result = await self._sandbox.python.exec(code) payload = _maybe_model_dump(result) output_text = payload.get("output", "") or "" @@ -81,7 +82,7 @@ class NeoShellComponent(ShellComponent): command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout_sec: int | None = 30, shell: bool = True, background: bool = False, ) -> dict[str, Any]: @@ -103,11 +104,8 @@ class NeoShellComponent(ShellComponent): if background: run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!" - result = await self._sandbox.shell.exec( - run_command, - timeout=timeout or 30, - cwd=cwd, - ) + with anyio.fail_after(timeout_sec or 30): + result = await self._sandbox.shell.exec(run_command, cwd=cwd) payload = _maybe_model_dump(result) stdout = payload.get("output", "") or "" @@ -198,7 +196,7 @@ class NeoBrowserComponent(BrowserComponent): async def exec( self, cmd: str, - timeout: int = 30, + timeout_sec: int = 30, description: str | None = None, tags: str | None = None, learn: bool = False, @@ -206,7 +204,7 @@ class NeoBrowserComponent(BrowserComponent): ) -> dict[str, Any]: result = await self._sandbox.browser.exec( cmd, - timeout=timeout, + timeout_sec=timeout_sec, description=description, tags=tags, learn=learn, @@ -217,7 +215,7 @@ class NeoBrowserComponent(BrowserComponent): async def exec_batch( self, commands: list[str], - timeout: int = 60, + timeout_sec: int = 60, stop_on_error: bool = True, description: str | None = None, tags: str | None = None, @@ -226,7 +224,7 @@ class NeoBrowserComponent(BrowserComponent): ) -> dict[str, Any]: result = await self._sandbox.browser.exec_batch( commands, - timeout=timeout, + timeout_sec=timeout_sec, stop_on_error=stop_on_error, description=description, tags=tags, @@ -238,7 +236,7 @@ class NeoBrowserComponent(BrowserComponent): async def run_skill( self, skill_key: str, - timeout: int = 60, + timeout_sec: int = 60, stop_on_error: bool = True, include_trace: bool = False, description: str | None = None, @@ -246,7 +244,7 @@ class NeoBrowserComponent(BrowserComponent): ) -> dict[str, Any]: result = await self._sandbox.browser.run_skill( skill_key=skill_key, - timeout=timeout, + timeout_sec=timeout_sec, stop_on_error=stop_on_error, include_trace=include_trace, description=description, diff --git a/astrbot/core/computer/tools/browser.py b/astrbot/core/computer/tools/browser.py index b6d56204c..d8e5a6eb6 100644 --- a/astrbot/core/computer/tools/browser.py +++ b/astrbot/core/computer/tools/browser.py @@ -6,8 +6,8 @@ from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.computer.computer_client import get_booter -from ..computer_client import get_booter from .permissions import check_admin_permission diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 30e35ed53..4928d4224 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -9,10 +9,10 @@ from astrbot.api.event import MessageChain from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.computer.computer_client import get_booter from astrbot.core.message.components import File from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..computer_client import get_booter from .permissions import check_admin_permission # @dataclass diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/computer/tools/neo_skills.py index c67264ccd..54dd4e107 100644 --- a/astrbot/core/computer/tools/neo_skills.py +++ b/astrbot/core/computer/tools/neo_skills.py @@ -7,9 +7,9 @@ from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.computer.computer_client import get_booter from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager -from ..computer_client import get_booter from .permissions import check_admin_permission diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index 8e2146e9e..01f4dc814 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -17,6 +17,7 @@ Behavior: from __future__ import annotations +import asyncio import json import os import shlex @@ -140,12 +141,13 @@ class ExecuteShellTool(FunctionTool): else: exec_env = session_env - # Determine timeout from config (fall back to 30) — use the cast wrapper's context + # Determine timeout from config (fall back to 30) config = astr_ctx.context.context.get_config(umo=session_id) + provider_settings: dict = {} + if isinstance(config, dict): + provider_settings = config.get("provider_settings") or {} try: - timeout = int( - config.get("provider_settings", {}).get("tool_call_timeout", 30) - ) + timeout = int(provider_settings.get("tool_call_timeout", 30)) except (ValueError, TypeError): timeout = 30 @@ -180,18 +182,21 @@ class ExecuteShellTool(FunctionTool): parts = shlex.split(cd_part) # cd with no args -> home if len(parts) == 1: - target = os.path.expanduser("~") + target = await asyncio.to_thread(os.path.expanduser, "~") else: target_raw = parts[1] # expand ~ and variables - target_raw = os.path.expanduser(target_raw) - target = ( - target_raw - if os.path.isabs(target_raw) - else os.path.normpath(os.path.join(session_cwd, target_raw)) - ) + target_raw = await asyncio.to_thread(os.path.expanduser, target_raw) + if await asyncio.to_thread(os.path.isabs, target_raw): + target = target_raw + else: + target = await asyncio.to_thread( + os.path.normpath, os.path.join(session_cwd, target_raw) + ) - if not os.path.exists(target) or not os.path.isdir(target): + target_exists = await asyncio.to_thread(os.path.exists, target) + target_isdir = await asyncio.to_thread(os.path.isdir, target) + if not target_exists or not target_isdir: result = { "success": False, "exit_code": -1, @@ -225,7 +230,8 @@ class ExecuteShellTool(FunctionTool): # Background execution: spawn process and return pid immediately. if background: # Start background process; do not wait. Use shell to support pipes/redirects. - popen = subprocess.Popen( + popen = await asyncio.to_thread( + subprocess.Popen, ["/bin/sh", "-c", command_to_run], cwd=session_cwd, env=exec_env, @@ -241,7 +247,8 @@ class ExecuteShellTool(FunctionTool): return json.dumps(result) # Foreground execution: run to completion, capture output. - completed = subprocess.run( + completed = await asyncio.to_thread( + subprocess.run, ["/bin/sh", "-c", command_to_run], cwd=session_cwd, env=exec_env, @@ -268,7 +275,7 @@ class ExecuteShellTool(FunctionTool): { "success": False, "exit_code": -1, - "stdout": getattr(e, "output", "") or "", + "stdout": e.stdout or "", "stderr": f"Command timed out after {timeout} seconds", "cwd": session_cwd, } diff --git a/astrbot/core/db/migration/migra_3_to_4.py b/astrbot/core/db/migration/migra_3_to_4.py index cef7baae5..95967d7a8 100644 --- a/astrbot/core/db/migration/migra_3_to_4.py +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -7,10 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from astrbot.api import logger, sp from astrbot.core.config import AstrBotConfig from astrbot.core.config.default import DB_PATH +from astrbot.core.db import BaseDatabase from astrbot.core.db.po import ConversationV2, PlatformMessageHistory from astrbot.core.platform.astr_message_event import MessageSesion -from .. import BaseDatabase from .shared_preferences_v3 import sp as sp_v3 from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3 diff --git a/astrbot/core/db/vec_db/faiss_impl/vec_db.py b/astrbot/core/db/vec_db/faiss_impl/vec_db.py index 672bafbac..7c6a908ed 100644 --- a/astrbot/core/db/vec_db/faiss_impl/vec_db.py +++ b/astrbot/core/db/vec_db/faiss_impl/vec_db.py @@ -4,9 +4,9 @@ import uuid import numpy as np from astrbot import logger +from astrbot.core.db.vec_db.base import BaseVecDB, Result from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider -from ..base import BaseVecDB, Result from .document_storage import DocumentStorage from .embedding_storage import EmbeddingStorage diff --git a/astrbot/core/knowledge_base/retrieval/manager.py b/astrbot/core/knowledge_base/retrieval/manager.py index 8d12a24f7..181507905 100644 --- a/astrbot/core/knowledge_base/retrieval/manager.py +++ b/astrbot/core/knowledge_base/retrieval/manager.py @@ -10,12 +10,11 @@ from astrbot import logger from astrbot.core.db.vec_db.base import Result from astrbot.core.db.vec_db.faiss_impl import FaissVecDB from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase +from astrbot.core.knowledge_base.kb_helper import KBHelper from astrbot.core.knowledge_base.retrieval.rank_fusion import RankFusion from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever from astrbot.core.provider.provider import RerankProvider -from ..kb_helper import KBHelper - @dataclass class RetrievalResult: diff --git a/astrbot/core/pipeline/content_safety_check/stage.py b/astrbot/core/pipeline/content_safety_check/stage.py index fa195c300..475ee93a7 100644 --- a/astrbot/core/pipeline/content_safety_check/stage.py +++ b/astrbot/core/pipeline/content_safety_check/stage.py @@ -2,10 +2,10 @@ from collections.abc import AsyncGenerator from astrbot.core import logger from astrbot.core.message.message_event_result import MessageEventResult +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent -from ..context import PipelineContext -from ..stage import Stage, register_stage from .strategies.strategy import StrategySelector diff --git a/astrbot/core/pipeline/preprocess_stage/stage.py b/astrbot/core/pipeline/preprocess_stage/stage.py index 39e81f489..9dc0b4dbb 100644 --- a/astrbot/core/pipeline/preprocess_stage/stage.py +++ b/astrbot/core/pipeline/preprocess_stage/stage.py @@ -5,11 +5,10 @@ from collections.abc import AsyncGenerator from astrbot.core import logger from astrbot.core.message.components import Image, Plain, Record +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent -from ..context import PipelineContext -from ..stage import Stage, register_stage - @register_stage class PreProcessStage(Stage): diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 9596a286f..6b1fa498b 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -6,13 +6,12 @@ from typing import Any from astrbot.core import logger from astrbot.core.message.message_event_result import MessageEventResult +from astrbot.core.pipeline.context import PipelineContext, call_event_hook, call_handler +from astrbot.core.pipeline.process_stage.stage import Stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import EventType, StarHandlerMetadata -from ...context import PipelineContext, call_event_hook, call_handler -from ..stage import Stage - class StarRequestSubStage(Stage): async def initialize(self, ctx: PipelineContext) -> None: diff --git a/astrbot/core/pipeline/process_stage/stage.py b/astrbot/core/pipeline/process_stage/stage.py index 68be5d3f2..ebf28d750 100644 --- a/astrbot/core/pipeline/process_stage/stage.py +++ b/astrbot/core/pipeline/process_stage/stage.py @@ -1,11 +1,11 @@ from collections.abc import AsyncGenerator +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider.entities import ProviderRequest from astrbot.core.star.star_handler import StarHandlerMetadata -from ..context import PipelineContext -from ..stage import Stage, register_stage from .method.agent_request import AgentRequestSubStage from .method.star_request import StarRequestSubStage diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 43232b31d..15da2711a 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -8,13 +8,12 @@ import astrbot.core.message.components as Comp from astrbot.core import logger from astrbot.core.message.components import BaseMessageComponent, ComponentType from astrbot.core.message.message_event_result import MessageChain, ResultContentType +from astrbot.core.pipeline.context import PipelineContext, call_event_hook +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star_handler import EventType from astrbot.core.utils.path_util import path_Mapping -from ..context import PipelineContext, call_event_hook -from ..stage import Stage, register_stage - @register_stage class RespondStage(Stage): diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 4ee746130..97a398d3c 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -8,15 +8,14 @@ from astrbot.core import file_token_service, html_renderer, logger from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply from astrbot.core.message.message_event_result import ResultContentType from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage, registered_stages from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.message_type import MessageType from astrbot.core.star.session_llm_manager import SessionServiceManager from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry -from ..context import PipelineContext -from ..stage import Stage, register_stage, registered_stages - @register_stage class ResultDecorateStage(Stage): diff --git a/astrbot/core/pipeline/session_status_check/stage.py b/astrbot/core/pipeline/session_status_check/stage.py index bf91cde2c..9682a08ec 100644 --- a/astrbot/core/pipeline/session_status_check/stage.py +++ b/astrbot/core/pipeline/session_status_check/stage.py @@ -1,12 +1,11 @@ from collections.abc import AsyncGenerator from astrbot.core import logger +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.session_llm_manager import SessionServiceManager -from ..context import PipelineContext -from ..stage import Stage, register_stage - @register_stage class SessionStatusCheckStage(Stage): diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 2c9c506b3..2048793bc 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -3,6 +3,8 @@ from collections.abc import AsyncGenerator, Callable from astrbot import logger from astrbot.core.message.components import At, AtAll, Reply from astrbot.core.message.message_event_result import MessageChain, MessageEventResult +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.message_type import MessageType from astrbot.core.star.filter.command_group import CommandGroupFilter @@ -11,9 +13,6 @@ from astrbot.core.star.session_plugin_manager import SessionPluginManager from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry -from ..context import PipelineContext -from ..stage import Stage, register_stage - UNIQUE_SESSION_ID_BUILDERS: dict[str, Callable[[AstrMessageEvent], str | None]] = { "aiocqhttp": lambda e: f"{e.get_sender_id()}_{e.get_group_id()}", "slack": lambda e: f"{e.get_sender_id()}_{e.get_group_id()}", diff --git a/astrbot/core/pipeline/whitelist_check/stage.py b/astrbot/core/pipeline/whitelist_check/stage.py index c3cdf038d..917e42959 100644 --- a/astrbot/core/pipeline/whitelist_check/stage.py +++ b/astrbot/core/pipeline/whitelist_check/stage.py @@ -1,12 +1,11 @@ from collections.abc import AsyncGenerator from astrbot.core import logger +from astrbot.core.pipeline.context import PipelineContext +from astrbot.core.pipeline.stage import Stage, register_stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.message_type import MessageType -from ..context import PipelineContext -from ..stage import Stage, register_stage - @register_stage class WhitelistCheckStage(Stage): diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 64a7d6620..099c79d13 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -87,8 +87,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent): new_seg.file = new_path modified = True except Exception as upload_err: - raise f"NapCat 文件流式上传失败: {upload_err}" - # 上传失败,保留原文件路径,但继续后续 segments 处理 + raise RuntimeError( + f"NapCat 文件流式上传失败: {upload_err}" + ) from upload_err new_chain.chain.append(new_seg) if not modified: return False @@ -112,45 +113,56 @@ class AiocqhttpMessageEvent(AstrMessageEvent): file_path = path path = Path(file_path) - if not path.exists(): + if not await asyncio.to_thread(path.exists): raise FileNotFoundError(f"文件不存在: {file_path}") # 第一次遍历:计算文件总大小和 SHA256 哈希 - hasher = hashlib.sha256() - total_size = 0 - with open(path, "rb") as f: - while True: - chunk = f.read(CHUNK_SIZE) - if not chunk: - break - hasher.update(chunk) - total_size += len(chunk) - sha256_hash = hasher.hexdigest() + def _read_all_and_hash(): + hasher = hashlib.sha256() + total_size = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(CHUNK_SIZE) + if not chunk: + break + hasher.update(chunk) + total_size += len(chunk) + return hasher.hexdigest(), total_size + + sha256_hash, total_size = await asyncio.to_thread(_read_all_and_hash) total_chunks = (total_size + CHUNK_SIZE - 1) // CHUNK_SIZE # 第二次遍历:逐块上传 stream_id = str(uuid.uuid4()) - with open(path, "rb") as f: - for i in range(total_chunks): - chunk = f.read(CHUNK_SIZE) - if not chunk: - break - chunk_b64 = base64.b64encode(chunk).decode("utf-8") - params = { - "stream_id": stream_id, - "chunk_data": chunk_b64, - "chunk_index": i, - "total_chunks": total_chunks, - "file_size": total_size, - "expected_sha256": sha256_hash, - "filename": path.name, - "file_retention": FILE_RETENTION_MS, # 单位为毫秒 - } - resp = await bot.call_action("upload_file_stream", **params) - if not cls._is_upload_success_response( - resp, expected_statuses=("chunk_received", "file_complete") - ): - raise OSError(f"上传分片 {i} 失败: {resp}") + + async def _read_chunk(file_pos: int) -> bytes: + def _read_chunk_sync(file_pos: int) -> bytes: + with open(path, "rb") as f: + f.seek(file_pos) + return f.read(CHUNK_SIZE) + + return await asyncio.to_thread(_read_chunk_sync, file_pos) + + for i in range(total_chunks): + chunk = await _read_chunk(i * CHUNK_SIZE) + if not chunk: + break + chunk_b64 = base64.b64encode(chunk).decode("utf-8") + params = { + "stream_id": stream_id, + "chunk_data": chunk_b64, + "chunk_index": i, + "total_chunks": total_chunks, + "file_size": total_size, + "expected_sha256": sha256_hash, + "filename": path.name, + "file_retention": FILE_RETENTION_MS, # 单位为毫秒 + } + resp = await bot.call_action("upload_file_stream", **params) + if not cls._is_upload_success_response( + resp, expected_statuses=("chunk_received", "file_complete") + ): + raise OSError(f"上传分片 {i} 失败: {resp}") # 发送完成信号 complete_params = {"stream_id": stream_id, "is_complete": True} diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index a89f027c3..8d7c70179 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -29,8 +29,8 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter -from ...register import register_platform_adapter from .aiocqhttp_message_event import AiocqhttpMessageEvent diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 3ec1a6851..677c0db2a 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -22,6 +22,7 @@ from astrbot.api.platform import ( ) from astrbot.core import sp from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import ( @@ -31,7 +32,6 @@ from astrbot.core.utils.media_utils import ( get_media_duration, ) -from ...register import register_platform_adapter from .dingtalk_event import DingtalkMessageEvent diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py index e754cf40d..23442db57 100644 --- a/astrbot/core/platform/sources/kook/kook_types.py +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -84,7 +84,7 @@ class KookBaseDataClass(BaseModel): def to_dict( self, - mode: Literal["json", "python"] | str = "python", + mode: Literal["json", "python"] = "python", by_alias=True, exclude_none=True, exclude_unset=False, diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index 1d7a94045..e8e41a0f2 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -26,10 +26,10 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.webhook_utils import log_webhook_info -from ...register import register_platform_adapter from .lark_event import LarkMessageEvent from .server import LarkWebhookServer diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 1c7dd0b43..7d7fe3151 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -146,7 +146,7 @@ class LarkMessageEvent(AstrMessageEvent): Returns: 成功返回file_key,失败返回None """ - if not path or not os.path.exists(path): + if not path or not await asyncio.to_thread(os.path.exists, path): logger.error(f"[Lark] 文件不存在: {path}") return None @@ -155,36 +155,38 @@ class LarkMessageEvent(AstrMessageEvent): return None try: - with open(path, "rb") as file_obj: - body_builder = ( - CreateFileRequestBody.builder() - .file_type(file_type) - .file_name(os.path.basename(path)) - .file(file_obj) - ) - if duration is not None: - body_builder.duration(duration) + # Read file content in a thread to avoid blocking the event loop + def _read_file() -> bytes: + with open(path, "rb") as f: + return f.read() - request = ( - CreateFileRequest.builder() - .request_body(body_builder.build()) - .build() - ) - response = await lark_client.im.v1.file.acreate(request) + file_bytes = await asyncio.to_thread(_read_file) - if not response.success(): - logger.error( - f"[Lark] 无法上传文件({response.code}): {response.msg}" - ) - return None + body_builder = ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(os.path.basename(path)) + .file(BytesIO(file_bytes)) + ) + if duration is not None: + body_builder.duration(duration) - if response.data is None: - logger.error("[Lark] 上传文件成功但未返回数据(data is None)") - return None + request = ( + CreateFileRequest.builder().request_body(body_builder.build()).build() + ) + response = await lark_client.im.v1.file.acreate(request) - file_key = response.data.file_key - logger.debug(f"[Lark] 文件上传成功: {file_key}") - return file_key + if not response.success(): + logger.error(f"[Lark] 无法上传文件({response.code}): {response.msg}") + return None + + if response.data is None: + logger.error("[Lark] 上传文件成功但未返回数据(data is None)") + return None + + file_key = response.data.file_key + logger.debug(f"[Lark] 文件上传成功: {file_key}") + return file_key except Exception as e: logger.error(f"[Lark] 无法打开或上传文件: {e}") @@ -217,8 +219,12 @@ class LarkMessageEvent(AstrMessageEvent): temp_dir, f"lark_image_{uuid.uuid4().hex[:8]}.jpg", ) - with open(file_path, "wb") as f: - f.write(BytesIO(image_data).getvalue()) + + def _write_image(): + with open(file_path, "wb") as f: + f.write(BytesIO(image_data).getvalue()) + + await asyncio.to_thread(_write_image) else: file_path = comp.file if comp.file else "" @@ -227,7 +233,13 @@ class LarkMessageEvent(AstrMessageEvent): logger.error("[Lark] 图片路径为空,无法上传") continue try: - image_file = open(file_path, "rb") + + def _open_image(): + return open(file_path, "rb") + + image_file = await asyncio.to_thread( + lambda: open(file_path, "rb") + ) except Exception as e: logger.error(f"[Lark] 无法打开图片文件: {e}") continue @@ -634,7 +646,9 @@ class LarkMessageEvent(AstrMessageEvent): logger.error(f"[Lark] 无法获取音频文件路径: {e}") return - if not original_audio_path or not os.path.exists(original_audio_path): + if not original_audio_path or not await asyncio.to_thread( + os.path.exists, original_audio_path + ): logger.error(f"[Lark] 音频文件不存在: {original_audio_path}") return @@ -664,9 +678,11 @@ class LarkMessageEvent(AstrMessageEvent): ) # 清理转换后的临时音频文件 - if converted_audio_path and os.path.exists(converted_audio_path): + if converted_audio_path and await asyncio.to_thread( + os.path.exists, converted_audio_path + ): try: - os.remove(converted_audio_path) + await asyncio.to_thread(os.remove, converted_audio_path) logger.debug(f"[Lark] 已删除转换后的音频文件: {converted_audio_path}") except Exception as e: logger.warning(f"[Lark] 删除转换后的音频文件失败: {e}") @@ -707,7 +723,9 @@ class LarkMessageEvent(AstrMessageEvent): logger.error(f"[Lark] 无法获取视频文件路径: {e}") return - if not original_video_path or not os.path.exists(original_video_path): + if not original_video_path or not await asyncio.to_thread( + os.path.exists, original_video_path + ): logger.error(f"[Lark] 视频文件不存在: {original_video_path}") return @@ -737,9 +755,11 @@ class LarkMessageEvent(AstrMessageEvent): ) # 清理转换后的临时视频文件 - if converted_video_path and os.path.exists(converted_video_path): + if converted_video_path and await asyncio.to_thread( + os.path.exists, converted_video_path + ): try: - os.remove(converted_video_path) + await asyncio.to_thread(os.remove, converted_video_path) logger.debug(f"[Lark] 已删除转换后的视频文件: {converted_video_path}") except Exception as e: logger.warning(f"[Lark] 删除转换后的视频文件失败: {e}") diff --git a/astrbot/core/platform/sources/line/line_adapter.py b/astrbot/core/platform/sources/line/line_adapter.py index a1d331df4..c6f834200 100644 --- a/astrbot/core/platform/sources/line/line_adapter.py +++ b/astrbot/core/platform/sources/line/line_adapter.py @@ -17,10 +17,10 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.webhook_utils import log_webhook_info -from ...register import register_platform_adapter from .line_api import LineAPIClient from .line_event import LineMessageEvent diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 0b1ea2903..9a0deb72b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -24,8 +24,8 @@ from astrbot.api.platform import ( ) from astrbot.core.message.components import BaseMessageComponent from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter -from ...register import register_platform_adapter from .qqofficial_message_event import QQOfficialMessageEvent # remove logger handler diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index 7ab835725..bed9502dc 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -10,10 +10,12 @@ from astrbot import logger from astrbot.api.event import MessageChain from astrbot.api.platform import AstrBotMessage, MessageType, Platform, PlatformMetadata from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter +from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + QQOfficialPlatformAdapter, +) from astrbot.core.utils.webhook_utils import log_webhook_info -from ...register import register_platform_adapter -from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_server import QQOfficialWebhook diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py index 5ceeb2c70..cbd28e726 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py @@ -1,8 +1,9 @@ from botpy import Client from astrbot.api.platform import AstrBotMessage, PlatformMetadata - -from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent +from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( + QQOfficialMessageEvent, +) class QQOfficialWebhookMessageEvent(QQOfficialMessageEvent): diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index 9488ad4d4..54c729a62 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -20,9 +20,9 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.webhook_utils import log_webhook_info -from ...register import register_platform_adapter from .client import SlackSocketClient, SlackWebhookClient from .slack_event import SlackMessageEvent diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index cf15de2e5..00fe6598d 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -174,8 +174,11 @@ class TelegramPlatformAdapter(Platform): error_callback=self._on_polling_error ) logger.info("Telegram Platform Adapter is running.") - while self.application.updater.running and not self._terminating: - await asyncio.sleep(1) + # Wait for termination or polling to stop. + _termination_event = asyncio.Event() + if self._terminating: + _termination_event.set() + await _termination_event.wait() if not self._terminating: logger.warning( @@ -361,33 +364,36 @@ class TelegramPlatformAdapter(Platform): logger.warning("Received an update without a message.") return None + # Assign to local variable so type checker can infer non-None + msg = update.message + def _apply_caption() -> None: - if update.message.caption: - message.message_str = update.message.caption + if msg.caption: + message.message_str = msg.caption message.message.append(Comp.Plain(message.message_str)) - if update.message.caption and update.message.caption_entities: - for entity in update.message.caption_entities: + if msg.caption and msg.caption_entities: + for entity in msg.caption_entities: if entity.type == "mention": - name = update.message.caption[ + name = msg.caption[ entity.offset + 1 : entity.offset + entity.length ] message.message.append(Comp.At(qq=name, name=name)) message = AstrBotMessage() - message.session_id = str(update.message.chat.id) + message.session_id = str(msg.chat.id) # 获得是群聊还是私聊 - if update.message.chat.type == ChatType.PRIVATE: + if msg.chat.type == ChatType.PRIVATE: message.type = MessageType.FRIEND_MESSAGE else: message.type = MessageType.GROUP_MESSAGE - message.group_id = str(update.message.chat.id) - if update.message.is_topic_message and update.message.message_thread_id: + message.group_id = str(msg.chat.id) + if msg.is_topic_message and msg.message_thread_id: # Telegram Topic Group: include thread id to isolate per-topic sessions. - message.group_id += "#" + str(update.message.message_thread_id) + message.group_id += "#" + str(msg.message_thread_id) message.session_id = message.group_id - message.message_id = str(update.message.message_id) - _from_user = update.message.from_user + message.message_id = str(msg.message_id) + _from_user = msg.from_user if not _from_user: logger.warning("[Telegram] Received a message without a from_user.") return None diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index eef80bff5..2177375e7 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -22,9 +22,9 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.webhook_utils import log_webhook_info -from ...register import register_platform_adapter from .wecomai_api import ( WecomAIBotAPIClient, WecomAIBotMessageParser, diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index aac167171..ab0f090fc 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -555,7 +555,7 @@ class WeixinOCAdapter(Platform): item_type: int, file_name: str, ) -> dict[str, Any]: - raw_bytes = media_path.read_bytes() + raw_bytes = await asyncio.to_thread(media_path.read_bytes) raw_size = len(raw_bytes) raw_md5 = hashlib.md5(raw_bytes).hexdigest() file_key = uuid.uuid4().hex @@ -761,7 +761,9 @@ class WeixinOCAdapter(Platform): if not path: return None media_path = Path(path) - if not media_path.exists() or not media_path.is_file(): + path_exists = await asyncio.to_thread(media_path.exists) + path_is_file = await asyncio.to_thread(media_path.is_file) + if not path_exists or not path_is_file: return None return media_path diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py index 51b0b6ed7..1e1205614 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import base64 import hashlib import json @@ -113,7 +114,7 @@ class WeixinOCClient: aes_key_hex: str, media_path: Path, ) -> str: - raw_data = media_path.read_bytes() + raw_data = await asyncio.to_thread(media_path.read_bytes) logger.debug( "weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s", self.adapter_id, diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 0637ca747..014d416d6 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -15,14 +15,13 @@ from astrbot.api.provider import Provider from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.network_utils import ( is_connection_error, log_connection_failure, ) -from ..register import register_provider_adapter - @register_provider_adapter( "anthropic_chat_completion", diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 15d4e9aa1..b6c31468f 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -12,12 +12,11 @@ from httpx import AsyncClient, Timeout from astrbot import logger from astrbot.core.config.default import VERSION +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts" TEMP_DIR.mkdir(parents=True, exist_ok=True) AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$" diff --git a/astrbot/core/provider/sources/bailian_rerank_source.py b/astrbot/core/provider/sources/bailian_rerank_source.py index 334ca87c9..a58176a5f 100644 --- a/astrbot/core/provider/sources/bailian_rerank_source.py +++ b/astrbot/core/provider/sources/bailian_rerank_source.py @@ -3,10 +3,9 @@ import os import aiohttp from astrbot import logger - -from ..entities import ProviderType, RerankResult -from ..provider import RerankProvider -from ..register import register_provider_adapter +from astrbot.core.provider.entities import ProviderType, RerankResult +from astrbot.core.provider.provider import RerankProvider +from astrbot.core.provider.register import register_provider_adapter class BailianRerankError(Exception): diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 4a26bc96d..2c74fd33c 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -16,12 +16,11 @@ except ( ): # pragma: no cover - older dashscope versions without Qwen TTS support MultiModalConversation = None +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( "dashscope_tts", diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 1ea8bde5c..9f93aa159 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -7,12 +7,11 @@ import anyio import edge_tts from astrbot.core import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - """ edge_tts 方式,能够免费、快速生成语音,使用需要先安装edge-tts库 ``` diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 02b9f1105..ad6982b4f 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -9,12 +9,11 @@ from httpx import AsyncClient from pydantic import BaseModel, conint from astrbot import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - class ServeReferenceAudio(BaseModel): audio: bytes diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py index 61ba9cadb..3885d00eb 100644 --- a/astrbot/core/provider/sources/gemini_embedding_source.py +++ b/astrbot/core/provider/sources/gemini_embedding_source.py @@ -5,10 +5,9 @@ from google.genai import types from google.genai.errors import APIError from astrbot import logger - -from ..entities import ProviderType -from ..provider import EmbeddingProvider -from ..register import register_provider_adapter +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import EmbeddingProvider +from astrbot.core.provider.register import register_provider_adapter @register_provider_adapter( diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 0ec05bdc3..9614f81fe 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -18,11 +18,10 @@ from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure -from ..register import register_provider_adapter - class SuppressNonTextPartsWarning(logging.Filter): """过滤 Gemini SDK 中的非文本部分警告""" diff --git a/astrbot/core/provider/sources/gemini_tts_source.py b/astrbot/core/provider/sources/gemini_tts_source.py index d6954ef82..3215dde0f 100644 --- a/astrbot/core/provider/sources/gemini_tts_source.py +++ b/astrbot/core/provider/sources/gemini_tts_source.py @@ -6,12 +6,11 @@ from google import genai from google.genai import types from astrbot import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( "gemini_tts", diff --git a/astrbot/core/provider/sources/groq_source.py b/astrbot/core/provider/sources/groq_source.py index af4029f67..4fc8a9dc4 100644 --- a/astrbot/core/provider/sources/groq_source.py +++ b/astrbot/core/provider/sources/groq_source.py @@ -1,4 +1,5 @@ -from ..register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter + from .openai_source import ProviderOpenAIOfficial diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index 74280d247..a209f7711 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -6,12 +6,11 @@ import aiofiles import aiohttp from astrbot import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( provider_type_name="gsv_tts_selfhost", diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 6ee9a93e3..14b010382 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -5,12 +5,11 @@ import uuid import aiofiles import aiohttp +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( "gsvi_tts_api", diff --git a/astrbot/core/provider/sources/kimi_code_source.py b/astrbot/core/provider/sources/kimi_code_source.py index 02c200271..96d924e7b 100644 --- a/astrbot/core/provider/sources/kimi_code_source.py +++ b/astrbot/core/provider/sources/kimi_code_source.py @@ -1,4 +1,5 @@ -from ..register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter + from .anthropic_source import ProviderAnthropic KIMI_CODE_API_BASE = "https://api.kimi.com/coding" diff --git a/astrbot/core/provider/sources/mimo_stt_api_source.py b/astrbot/core/provider/sources/mimo_stt_api_source.py index 9b03e2efc..c30b7992a 100644 --- a/astrbot/core/provider/sources/mimo_stt_api_source.py +++ b/astrbot/core/provider/sources/mimo_stt_api_source.py @@ -1,6 +1,7 @@ -from ..entities import ProviderType -from ..provider import STTProvider -from ..register import register_provider_adapter +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import STTProvider +from astrbot.core.provider.register import register_provider_adapter + from .mimo_api_common import ( DEFAULT_MIMO_API_BASE, DEFAULT_MIMO_STT_MODEL, diff --git a/astrbot/core/provider/sources/mimo_tts_api_source.py b/astrbot/core/provider/sources/mimo_tts_api_source.py index 2966bfb7d..679966ce7 100644 --- a/astrbot/core/provider/sources/mimo_tts_api_source.py +++ b/astrbot/core/provider/sources/mimo_tts_api_source.py @@ -1,9 +1,10 @@ import base64 import uuid -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter + from .mimo_api_common import ( DEFAULT_MIMO_API_BASE, DEFAULT_MIMO_TTS_MODEL, diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index 44b9ba7a6..1d7f0c388 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -7,12 +7,11 @@ import aiofiles import aiohttp from astrbot.api import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( "minimax_tts_api", diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py index 51c116436..4253a47e6 100644 --- a/astrbot/core/provider/sources/oai_aihubmix_source.py +++ b/astrbot/core/provider/sources/oai_aihubmix_source.py @@ -1,7 +1,8 @@ from collections.abc import MutableMapping from typing import cast -from ..register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter + from .openai_source import ProviderOpenAIOfficial diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index ae531996a..7a503aeb4 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -2,10 +2,9 @@ import httpx from openai import AsyncOpenAI from astrbot import logger - -from ..entities import ProviderType -from ..provider import EmbeddingProvider -from ..register import register_provider_adapter +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import EmbeddingProvider +from astrbot.core.provider.register import register_provider_adapter @register_provider_adapter( diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 8e0c00bd9..abc472da8 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -6,12 +6,11 @@ import httpx from openai import NOT_GIVEN, AsyncOpenAI from astrbot import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( "openai_tts_api", diff --git a/astrbot/core/provider/sources/openrouter_source.py b/astrbot/core/provider/sources/openrouter_source.py index aff2685fe..5a9a06e82 100644 --- a/astrbot/core/provider/sources/openrouter_source.py +++ b/astrbot/core/provider/sources/openrouter_source.py @@ -1,7 +1,8 @@ from collections.abc import MutableMapping from typing import cast -from ..register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter + from .openai_source import ProviderOpenAIOfficial diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index e16378ce8..10bcc9bc0 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -13,14 +13,13 @@ from funasr_onnx import SenseVoiceSmall from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess from astrbot.core import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import STTProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav -from ..entities import ProviderType -from ..provider import STTProvider -from ..register import register_provider_adapter - @register_provider_adapter( "sensevoice_stt_selfhost", diff --git a/astrbot/core/provider/sources/vllm_rerank_source.py b/astrbot/core/provider/sources/vllm_rerank_source.py index fa3a509fa..48b683026 100644 --- a/astrbot/core/provider/sources/vllm_rerank_source.py +++ b/astrbot/core/provider/sources/vllm_rerank_source.py @@ -1,10 +1,9 @@ import aiohttp from astrbot import logger - -from ..entities import ProviderType, RerankResult -from ..provider import RerankProvider -from ..register import register_provider_adapter +from astrbot.core.provider.entities import ProviderType, RerankResult +from astrbot.core.provider.provider import RerankProvider +from astrbot.core.provider.register import register_provider_adapter @register_provider_adapter( diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py index 3b3d664f9..9ff7718ce 100644 --- a/astrbot/core/provider/sources/volcengine_tts.py +++ b/astrbot/core/provider/sources/volcengine_tts.py @@ -8,12 +8,11 @@ import aiohttp import anyio from astrbot import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import TTSProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from ..entities import ProviderType -from ..provider import TTSProvider -from ..register import register_provider_adapter - @register_provider_adapter( "volcengine_tts", diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 80305440b..fa657578e 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -5,6 +5,9 @@ import anyio from openai import NOT_GIVEN, AsyncOpenAI from astrbot.core import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import STTProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import convert_audio_to_wav @@ -13,10 +16,6 @@ from astrbot.core.utils.tencent_record_helper import ( tencent_silk_to_wav, ) -from ..entities import ProviderType -from ..provider import STTProvider -from ..register import register_provider_adapter - def _open_file_rb(path: str): return open(path, "rb") diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index fa3100f73..0709426fa 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -7,14 +7,13 @@ import anyio import whisper from astrbot.core import logger +from astrbot.core.provider.entities import ProviderType +from astrbot.core.provider.provider import STTProvider +from astrbot.core.provider.register import register_provider_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav -from ..entities import ProviderType -from ..provider import STTProvider -from ..register import register_provider_adapter - @register_provider_adapter( "openai_whisper_selfhost", diff --git a/astrbot/core/provider/sources/xai_source.py b/astrbot/core/provider/sources/xai_source.py index 77c5858d5..f18551e8d 100644 --- a/astrbot/core/provider/sources/xai_source.py +++ b/astrbot/core/provider/sources/xai_source.py @@ -1,4 +1,5 @@ -from ..register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter + from .openai_source import ProviderOpenAIOfficial diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index 967ed7192..85788f4eb 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -389,19 +389,22 @@ async def compress_image( if len(data) < min_file_size_bytes: return url_or_path else: - local_path = Path(url_or_path) - if not local_path.exists(): - return url_or_path - if local_path.stat().st_size < min_file_size_bytes: - return url_or_path - with local_path.open("rb") as f: - data = f.read() - if not data: - return url_or_path + def _read_local_path(): + lp = Path(url_or_path) + if not lp.exists(): + return None + if lp.stat().st_size < min_file_size_bytes: + return None + with lp.open("rb") as f: + return f.read() + + data = await asyncio.to_thread(_read_local_path) + if not data: + return url_or_path temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True) # Offload the blocking image processing task to a thread. return await asyncio.to_thread( diff --git a/astrbot/dashboard/routes/api_key.py b/astrbot/dashboard/routes/api_key.py index c12833870..f4674ff77 100644 --- a/astrbot/dashboard/routes/api_key.py +++ b/astrbot/dashboard/routes/api_key.py @@ -65,7 +65,7 @@ class ApiKeyRoute(Route): async def list_api_keys(self): keys = await self.db.list_api_keys() return ( - Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__ + Response().ok(data=[self._serialize_api_key(key) for key in keys]).to_json() ) async def create_api_key(self): @@ -84,9 +84,9 @@ class ApiKeyRoute(Route): ] normalized_scopes = list(dict.fromkeys(normalized_scopes)) if not normalized_scopes: - return Response().error("At least one valid scope is required").__dict__ + return Response().error("At least one valid scope is required").to_json() else: - return Response().error("Invalid scopes").__dict__ + return Response().error("Invalid scopes").to_json() expires_at = None expires_in_days = post_data.get("expires_in_days") @@ -94,10 +94,10 @@ class ApiKeyRoute(Route): try: expires_in_days_int = int(expires_in_days) except (TypeError, ValueError): - return Response().error("expires_in_days must be an integer").__dict__ + return Response().error("expires_in_days must be an integer").to_json() if expires_in_days_int <= 0: return ( - Response().error("expires_in_days must be greater than 0").__dict__ + Response().error("expires_in_days must be greater than 0").to_json() ) expires_at = datetime.now(timezone.utc) + timedelta( days=expires_in_days_int @@ -119,26 +119,26 @@ class ApiKeyRoute(Route): payload = self._serialize_api_key(api_key) payload["api_key"] = raw_key - return Response().ok(data=payload).__dict__ + return Response().ok(data=payload).to_json() async def revoke_api_key(self): post_data = await request.json or {} key_id = post_data.get("key_id") if not key_id: - return Response().error("Missing key: key_id").__dict__ + return Response().error("Missing key: key_id").to_json() success = await self.db.revoke_api_key(key_id) if not success: - return Response().error("API key not found").__dict__ - return Response().ok().__dict__ + return Response().error("API key not found").to_json() + return Response().ok().to_json() async def delete_api_key(self): post_data = await request.json or {} key_id = post_data.get("key_id") if not key_id: - return Response().error("Missing key: key_id").__dict__ + return Response().error("Missing key: key_id").to_json() success = await self.db.delete_api_key(key_id) if not success: - return Response().error("API key not found").__dict__ - return Response().ok().__dict__ + return Response().error("API key not found").to_json() + return Response().ok().to_json() diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 588e61f2e..646a05571 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -41,15 +41,14 @@ class AuthRoute(Route): # Security: Require non-empty credentials if not input_username or not input_password: - return Response().error("用户名和密码不能为空").__dict__ + return Response().error("用户名和密码不能为空").to_json() # Check if password has been configured via CLI if not self._is_password_set(stored_password_hash): await asyncio.sleep(3) return ( Response() - .error("管理员密码未设置,请先运行 'astrbot conf admin' 命令设置密码") - .__dict__ + .error("管理员密码未设置,请先运行 'astrbot conf admin' 命令设置密码").to_json() ) # Normal login flow - credentials must match stored admin account @@ -65,50 +64,48 @@ class AuthRoute(Route): "username": stored_username, "change_pwd_hint": False, }, - ) - .__dict__ + ).to_json() ) # Security: Don't reveal whether it's username or password error await asyncio.sleep(3) - return Response().error("用户名或密码错误").__dict__ + return Response().error("用户名或密码错误").to_json() async def edit_account(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) stored_password_hash = self.config["dashboard"]["password"] post_data = await request.json if not self._matches_dashboard_password(stored_password_hash, post_data): - return Response().error("原密码错误").__dict__ + return Response().error("原密码错误").to_json() new_pwd = post_data.get("new_password", None) new_username = post_data.get("new_username", None) if not new_pwd and not new_username: - return Response().error("新用户名和新密码不能同时为空").__dict__ + return Response().error("新用户名和新密码不能同时为空").to_json() # Verify password confirmation if new_pwd: confirm_pwd = post_data.get("confirm_password", None) if confirm_pwd != new_pwd: - return Response().error("两次输入的新密码不一致").__dict__ + return Response().error("两次输入的新密码不一致").to_json() # Hash the new password before storing to ensure backend and CLI use the same format try: new_hash = hash_dashboard_password_secure(new_pwd) except Exception as e: - return Response().error(f"Failed to hash new password: {e}").__dict__ + return Response().error(f"Failed to hash new password: {e}").to_json() self.config["dashboard"]["password"] = new_hash if new_username: self.config["dashboard"]["username"] = new_username self.config.save_config() - return Response().ok(None, "修改成功").__dict__ + return Response().ok(None, "修改成功").to_json() def generate_jwt(self, username): payload = { diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 82b6d39d8..0deea2144 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -97,7 +97,7 @@ class ChatRoute(Route): async def get_file(self): filename = request.args.get("filename") if not filename: - return Response().error("Missing key: filename").__dict__ + return Response().error("Missing key: filename").to_json() try: file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) @@ -116,7 +116,7 @@ class ChatRoute(Route): try: resolved_file_path.relative_to(resolved_base_dir) except ValueError: - return Response().error("Invalid file path").__dict__ + return Response().error("Invalid file path").to_json() filename_ext = os.path.splitext(filename)[1].lower() if filename_ext == ".wav": @@ -126,18 +126,18 @@ class ChatRoute(Route): return await send_file(str(resolved_file_path)) except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ + return Response().error("File access error").to_json() async def get_attachment(self): """Get attachment file by attachment_id.""" attachment_id = request.args.get("attachment_id") if not attachment_id: - return Response().error("Missing key: attachment_id").__dict__ + return Response().error("Missing key: attachment_id").to_json() try: attachment = await self.db.get_attachment_by_id(attachment_id) if not attachment: - return Response().error("Attachment not found").__dict__ + return Response().error("Attachment not found").to_json() file_path = attachment.path resolved_file_path = _resolve_path(file_path) @@ -147,13 +147,13 @@ class ChatRoute(Route): ) except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ + return Response().error("File access error").to_json() async def post_file(self): """Upload a file and create an attachment record, return attachment_id.""" post_data = await request.files if "file" not in post_data: - return Response().error("Missing key: file").__dict__ + return Response().error("Missing key: file").to_json() file = post_data["file"] filename = file.filename or f"{uuid.uuid4()!s}" @@ -180,7 +180,7 @@ class ChatRoute(Route): ) if not attachment: - return Response().error("Failed to create attachment").__dict__ + return Response().error("Failed to create attachment").to_json() filename = os.path.basename(attachment.path) @@ -192,8 +192,7 @@ class ChatRoute(Route): "filename": filename, "type": attach_type, } - ) - .__dict__ + ).to_json() ) async def _build_user_message_parts(self, message: str | list) -> list[dict]: @@ -313,13 +312,13 @@ class ChatRoute(Route): if post_data is None: post_data = await request.json if post_data is None: - return Response().error("Missing JSON body").__dict__ + return Response().error("Missing JSON body").to_json() if "message" not in post_data and "files" not in post_data: - return Response().error("Missing key: message or files").__dict__ + return Response().error("Missing key: message or files").to_json() if "session_id" not in post_data and "conversation_id" not in post_data: return ( - Response().error("Missing key: session_id or conversation_id").__dict__ + Response().error("Missing key: session_id or conversation_id").to_json() ) message = post_data["message"] @@ -329,7 +328,7 @@ class ChatRoute(Route): enable_streaming = post_data.get("enable_streaming", True) if not session_id: - return Response().error("session_id is empty").__dict__ + return Response().error("session_id is empty").to_json() webchat_conv_id = session_id @@ -338,8 +337,7 @@ class ChatRoute(Route): if not webchat_message_parts_have_content(message_parts): return ( Response() - .error("Message content is empty (reply only is not allowed)") - .__dict__ + .error("Message content is empty (reply only is not allowed)").to_json() ) message_id = str(uuid.uuid4()) @@ -573,18 +571,18 @@ class ChatRoute(Route): """Stop active agent runs for a session.""" post_data = await request.json if post_data is None: - return Response().error("Missing JSON body").__dict__ + return Response().error("Missing JSON body").to_json() session_id = post_data.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() username = g.get("username", "guest") session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() message_type = ( MessageType.GROUP_MESSAGE.value @@ -597,7 +595,7 @@ class ChatRoute(Route): ) stopped_count = active_event_registry.request_agent_stop_all(umo) - return Response().ok(data={"stopped_count": stopped_count}).__dict__ + return Response().ok(data={"stopped_count": stopped_count}).to_json() async def _delete_session_internal(self, session, username: str) -> None: """Delete a single session and all its related data.""" @@ -647,30 +645,30 @@ class ChatRoute(Route): """Delete a Platform session and all its related data.""" session_id = request.args.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() username = g.get("username", "guest") session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self._delete_session_internal(session, username) - return Response().ok().__dict__ + return Response().ok().to_json() async def batch_delete_sessions(self): """Batch delete multiple Platform sessions.""" post_data = await request.json if post_data is None: - return Response().error("Missing JSON body").__dict__ + return Response().error("Missing JSON body").to_json() if not isinstance(post_data, dict): - return Response().error("Invalid JSON body: expected object").__dict__ + return Response().error("Invalid JSON body: expected object").to_json() session_ids = post_data.get("session_ids") if not session_ids or not isinstance(session_ids, list): - return Response().error("Missing or invalid key: session_ids").__dict__ + return Response().error("Missing or invalid key: session_ids").to_json() username = g.get("username", "guest") sessions = await self.db.get_platform_sessions_by_ids(session_ids) @@ -703,8 +701,7 @@ class ChatRoute(Route): "failed_count": len(failed_items), "failed_items": failed_items, } - ) - .__dict__ + ).to_json() ) def _extract_attachment_ids(self, history_list) -> list[str]: @@ -763,8 +760,7 @@ class ChatRoute(Route): "session_id": session.session_id, "platform_id": session.platform_id, } - ) - .__dict__ + ).to_json() ) async def get_sessions(self): @@ -799,13 +795,13 @@ class ChatRoute(Route): } ) - return Response().ok(data=sessions_data).__dict__ + return Response().ok(data=sessions_data).to_json() async def get_session(self): """Get session information and message history by session_id.""" session_id = request.args.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() # 获取会话信息以确定 platform_id session = await self.db.get_platform_session_by_id(session_id) @@ -840,7 +836,7 @@ class ChatRoute(Route): "emoji": project_info.emoji, } - return Response().ok(data=response_data).__dict__ + return Response().ok(data=response_data).to_json() async def update_session_display_name(self): """Update a Platform session's display name.""" @@ -850,18 +846,18 @@ class ChatRoute(Route): display_name = post_data.get("display_name") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() if display_name is None: - return Response().error("Missing key: display_name").__dict__ + return Response().error("Missing key: display_name").to_json() username = g.get("username", "guest") # 验证会话是否存在且属于当前用户 session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() # 更新 display_name await self.db.update_platform_session( @@ -869,4 +865,4 @@ class ChatRoute(Route): display_name=display_name, ) - return Response().ok().__dict__ + return Response().ok().to_json() diff --git a/astrbot/dashboard/routes/chatui_project.py b/astrbot/dashboard/routes/chatui_project.py index 6ba570f55..947e0b5e4 100644 --- a/astrbot/dashboard/routes/chatui_project.py +++ b/astrbot/dashboard/routes/chatui_project.py @@ -35,7 +35,7 @@ class ChatUIProjectRoute(Route): description = post_data.get("description") if not title: - return Response().error("Missing key: title").__dict__ + return Response().error("Missing key: title").to_json() project = await self.db.create_chatui_project( creator=username, @@ -55,8 +55,7 @@ class ChatUIProjectRoute(Route): "created_at": to_utc_isoformat(project.created_at), "updated_at": to_utc_isoformat(project.updated_at), } - ) - .__dict__ + ).to_json() ) async def list_projects(self): @@ -77,23 +76,23 @@ class ChatUIProjectRoute(Route): for project in projects ] - return Response().ok(data=projects_data).__dict__ + return Response().ok(data=projects_data).to_json() async def get_project(self): """Get a specific ChatUI project.""" project_id = request.args.get("project_id") if not project_id: - return Response().error("Missing key: project_id").__dict__ + return Response().error("Missing key: project_id").to_json() username = g.get("username", "guest") project = await self.db.get_chatui_project_by_id(project_id) if not project: - return Response().error(f"Project {project_id} not found").__dict__ + return Response().error(f"Project {project_id} not found").to_json() # Verify ownership if project.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() return ( Response() @@ -106,8 +105,7 @@ class ChatUIProjectRoute(Route): "created_at": to_utc_isoformat(project.created_at), "updated_at": to_utc_isoformat(project.updated_at), } - ) - .__dict__ + ).to_json() ) async def update_chatui_project(self): @@ -120,16 +118,16 @@ class ChatUIProjectRoute(Route): description = post_data.get("description") if not project_id: - return Response().error("Missing key: project_id").__dict__ + return Response().error("Missing key: project_id").to_json() username = g.get("username", "guest") # Verify ownership project = await self.db.get_chatui_project_by_id(project_id) if not project: - return Response().error(f"Project {project_id} not found").__dict__ + return Response().error(f"Project {project_id} not found").to_json() if project.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self.db.update_chatui_project( project_id=project_id, @@ -138,26 +136,26 @@ class ChatUIProjectRoute(Route): description=description, ) - return Response().ok().__dict__ + return Response().ok().to_json() async def delete_project(self): """Delete a ChatUI project.""" project_id = request.args.get("project_id") if not project_id: - return Response().error("Missing key: project_id").__dict__ + return Response().error("Missing key: project_id").to_json() username = g.get("username", "guest") # Verify ownership project = await self.db.get_chatui_project_by_id(project_id) if not project: - return Response().error(f"Project {project_id} not found").__dict__ + return Response().error(f"Project {project_id} not found").to_json() if project.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self.db.delete_chatui_project(project_id) - return Response().ok().__dict__ + return Response().ok().to_json() async def add_session_to_project(self): """Add a session to a project.""" @@ -167,29 +165,29 @@ class ChatUIProjectRoute(Route): project_id = post_data.get("project_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() if not project_id: - return Response().error("Missing key: project_id").__dict__ + return Response().error("Missing key: project_id").to_json() username = g.get("username", "guest") # Verify project ownership project = await self.db.get_chatui_project_by_id(project_id) if not project: - return Response().error(f"Project {project_id} not found").__dict__ + return Response().error(f"Project {project_id} not found").to_json() if project.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() # Verify session ownership session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self.db.add_session_to_project(session_id, project_id) - return Response().ok().__dict__ + return Response().ok().to_json() async def remove_session_from_project(self): """Remove a session from its project.""" @@ -198,35 +196,35 @@ class ChatUIProjectRoute(Route): session_id = post_data.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() username = g.get("username", "guest") # Verify session ownership session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self.db.remove_session_from_project(session_id) - return Response().ok().__dict__ + return Response().ok().to_json() async def get_project_sessions(self): """Get all sessions in a project.""" project_id = request.args.get("project_id") if not project_id: - return Response().error("Missing key: project_id").__dict__ + return Response().error("Missing key: project_id").to_json() username = g.get("username", "guest") # Verify project ownership project = await self.db.get_chatui_project_by_id(project_id) if not project: - return Response().error(f"Project {project_id} not found").__dict__ + return Response().error(f"Project {project_id} not found").to_json() if project.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() sessions = await self.db.get_project_sessions(project_id) @@ -243,4 +241,4 @@ class ChatUIProjectRoute(Route): for session in sessions ] - return Response().ok(data=sessions_data).__dict__ + return Response().ok(data=sessions_data).to_json() diff --git a/astrbot/dashboard/routes/command.py b/astrbot/dashboard/routes/command.py index aae82e1b1..058dc76eb 100644 --- a/astrbot/dashboard/routes/command.py +++ b/astrbot/dashboard/routes/command.py @@ -36,11 +36,11 @@ class CommandRoute(Route): "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__ + return Response().ok({"items": commands, "summary": summary}).to_json() async def get_conflicts(self): conflicts = await list_command_conflicts() - return Response().ok(conflicts).__dict__ + return Response().ok(conflicts).to_json() async def toggle_command(self): data = await request.get_json() @@ -48,7 +48,7 @@ class CommandRoute(Route): enabled = data.get("enabled") if handler_full_name is None or enabled is None: - return Response().error("handler_full_name 与 enabled 均为必填。").__dict__ + return Response().error("handler_full_name 与 enabled 均为必填。").to_json() if isinstance(enabled, str): enabled = enabled.lower() in ("1", "true", "yes", "on") @@ -56,10 +56,10 @@ class CommandRoute(Route): try: await toggle_command_service(handler_full_name, bool(enabled)) except ValueError as exc: - return Response().error(str(exc)).__dict__ + return Response().error(str(exc)).to_json() payload = await _get_command_payload(handler_full_name) - return Response().ok(payload).__dict__ + return Response().ok(payload).to_json() async def rename_command(self): data = await request.get_json() @@ -68,15 +68,15 @@ class CommandRoute(Route): aliases = data.get("aliases") if not handler_full_name or not new_name: - return Response().error("handler_full_name 与 new_name 均为必填。").__dict__ + return Response().error("handler_full_name 与 new_name 均为必填。").to_json() try: await rename_command_service(handler_full_name, new_name, aliases=aliases) except ValueError as exc: - return Response().error(str(exc)).__dict__ + return Response().error(str(exc)).to_json() payload = await _get_command_payload(handler_full_name) - return Response().ok(payload).__dict__ + return Response().ok(payload).to_json() async def update_permission(self): data = await request.get_json() @@ -85,16 +85,16 @@ class CommandRoute(Route): if not handler_full_name or not permission: return ( - Response().error("handler_full_name 与 permission 均为必填。").__dict__ + Response().error("handler_full_name 与 permission 均为必填。").to_json() ) try: await update_command_permission_service(handler_full_name, permission) except ValueError as exc: - return Response().error(str(exc)).__dict__ + return Response().error(str(exc)).to_json() payload = await _get_command_payload(handler_full_name) - return Response().ok(payload).__dict__ + return Response().ok(payload).to_json() async def _get_command_payload(handler_full_name: str): diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index 11ab1c7ec..a3a388d0c 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -81,7 +81,7 @@ class ConversationRoute(Route): ) except Exception as e: logger.error(f"数据库查询出错: {e!s}\n{traceback.format_exc()}") - return Response().error(f"数据库查询出错: {e!s}").__dict__ + return Response().error(f"数据库查询出错: {e!s}").to_json() # 计算总页数 total_pages = ( @@ -97,12 +97,12 @@ class ConversationRoute(Route): "total_pages": total_pages, }, } - return Response().ok(result).__dict__ + return Response().ok(result).to_json() except Exception as e: error_msg = f"获取对话列表失败: {e!s}\n{traceback.format_exc()}" logger.error(error_msg) - return Response().error(f"获取对话列表失败: {e!s}").__dict__ + return Response().error(f"获取对话列表失败: {e!s}").to_json() async def get_conv_detail(self): """获取指定对话详情(通过POST请求)""" @@ -112,14 +112,14 @@ class ConversationRoute(Route): cid = data.get("cid") if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ + return Response().error("缺少必要参数: user_id 和 cid").to_json() conversation = await self.conv_mgr.get_conversation( unified_msg_origin=user_id, conversation_id=cid, ) if not conversation: - return Response().error("对话不存在").__dict__ + return Response().error("对话不存在").to_json() return ( Response() @@ -133,13 +133,12 @@ class ConversationRoute(Route): "created_at": conversation.created_at, "updated_at": conversation.updated_at, }, - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取对话详情失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取对话详情失败: {e!s}").__dict__ + return Response().error(f"获取对话详情失败: {e!s}").to_json() async def upd_conv(self): """更新对话信息(标题和角色ID)""" @@ -150,13 +149,13 @@ class ConversationRoute(Route): title = data.get("title") if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ + return Response().error("缺少必要参数: user_id 和 cid").to_json() conversation = await self.conv_mgr.get_conversation( unified_msg_origin=user_id, conversation_id=cid, ) if not conversation: - return Response().error("对话不存在").__dict__ + return Response().error("对话不存在").to_json() persona_id = data.get("persona_id", conversation.persona_id) @@ -167,11 +166,11 @@ class ConversationRoute(Route): title=title, persona_id=persona_id, ) - return Response().ok({"message": "对话信息更新成功"}).__dict__ + return Response().ok({"message": "对话信息更新成功"}).to_json() except Exception as e: logger.error(f"更新对话信息失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新对话信息失败: {e!s}").__dict__ + return Response().error(f"更新对话信息失败: {e!s}").to_json() async def del_conv(self): """删除对话""" @@ -184,7 +183,7 @@ class ConversationRoute(Route): conversations = data.get("conversations", []) if not conversations: return ( - Response().error("批量删除时conversations参数不能为空").__dict__ + Response().error("批量删除时conversations参数不能为空").to_json() ) deleted_count = 0 @@ -222,25 +221,24 @@ class ConversationRoute(Route): "failed_count": len(failed_items), "failed_items": failed_items, }, - ) - .__dict__ + ).to_json() ) # 单个删除 user_id = data.get("user_id") cid = data.get("cid") if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ + return Response().error("缺少必要参数: user_id 和 cid").to_json() await self.core_lifecycle.conversation_manager.delete_conversation( unified_msg_origin=user_id, conversation_id=cid, ) - return Response().ok({"message": "对话删除成功"}).__dict__ + return Response().ok({"message": "对话删除成功"}).to_json() except Exception as e: logger.error(f"删除对话失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"删除对话失败: {e!s}").__dict__ + return Response().error(f"删除对话失败: {e!s}").to_json() async def update_history(self): """更新对话历史内容""" @@ -251,10 +249,10 @@ class ConversationRoute(Route): history = data.get("history") if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ + return Response().error("缺少必要参数: user_id 和 cid").to_json() if history is None: - return Response().error("缺少必要参数: history").__dict__ + return Response().error("缺少必要参数: history").to_json() # 历史记录必须是合法的 JSON 字符串 try: @@ -265,7 +263,7 @@ class ConversationRoute(Route): json.loads(history) except json.JSONDecodeError: return ( - Response().error("history 必须是有效的 JSON 字符串或数组").__dict__ + Response().error("history 必须是有效的 JSON 字符串或数组").to_json() ) conversation = await self.conv_mgr.get_conversation( @@ -273,7 +271,7 @@ class ConversationRoute(Route): conversation_id=cid, ) if not conversation: - return Response().error("对话不存在").__dict__ + return Response().error("对话不存在").to_json() history = json.loads(history) if isinstance(history, str) else history @@ -283,11 +281,11 @@ class ConversationRoute(Route): history=history, ) - return Response().ok({"message": "对话历史更新成功"}).__dict__ + return Response().ok({"message": "对话历史更新成功"}).to_json() except Exception as e: logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新对话历史失败: {e!s}").__dict__ + return Response().error(f"更新对话历史失败: {e!s}").to_json() async def export_conversations(self): """批量导出对话为 JSONL 格式""" @@ -296,7 +294,7 @@ class ConversationRoute(Route): conversations_to_export = data.get("conversations", []) if not conversations_to_export: - return Response().error("导出列表不能为空").__dict__ + return Response().error("导出列表不能为空").to_json() # 收集所有对话的内容 jsonl_lines = [] @@ -351,7 +349,7 @@ class ConversationRoute(Route): ) if exported_count == 0: - return Response().error("没有成功导出任何对话").__dict__ + return Response().error("没有成功导出任何对话").to_json() # 创建 JSONL 内容 jsonl_content = "\n".join(jsonl_lines) @@ -374,4 +372,4 @@ class ConversationRoute(Route): except Exception as e: logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"批量导出对话失败: {e!s}").__dict__ + return Response().error(f"批量导出对话失败: {e!s}").to_json() diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py index 6b4e155bb..c6ae3182c 100644 --- a/astrbot/dashboard/routes/cron.py +++ b/astrbot/dashboard/routes/cron.py @@ -42,27 +42,27 @@ class CronRoute(Route): cron_mgr = self.core_lifecycle.cron_manager if cron_mgr is None: return jsonify( - Response().error("Cron manager not initialized").__dict__ + Response().error("Cron manager not initialized").to_json() ) job_type = request.args.get("type") jobs = await cron_mgr.list_jobs(job_type) data = [self._serialize_job(j) for j in jobs] - return jsonify(Response().ok(data=data).__dict__) + return jsonify(Response().ok(data=data).to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to list jobs: {e!s}").__dict__) + return jsonify(Response().error(f"Failed to list jobs: {e!s}").to_json()) async def create_job(self): try: cron_mgr = self.core_lifecycle.cron_manager if cron_mgr is None: return jsonify( - Response().error("Cron manager not initialized").__dict__ + Response().error("Cron manager not initialized").to_json() ) payload = await request.json if not isinstance(payload, dict): - return jsonify(Response().error("Invalid payload").__dict__) + return jsonify(Response().error("Invalid payload").to_json()) name = payload.get("name") or "active_agent_task" cron_expression = payload.get("cron_expression") @@ -76,16 +76,15 @@ class CronRoute(Route): run_at = payload.get("run_at") if not session: - return jsonify(Response().error("session is required").__dict__) + return jsonify(Response().error("session is required").to_json()) if run_once and not run_at: return jsonify( - Response().error("run_at is required when run_once=true").__dict__ + Response().error("run_at is required when run_once=true").to_json() ) if (not run_once) and not cron_expression: return jsonify( Response() - .error("cron_expression is required when run_once=false") - .__dict__ + .error("cron_expression is required when run_once=false").to_json() ) if run_once and cron_expression: cron_expression = None # ignore cron when run_once specified @@ -95,7 +94,7 @@ class CronRoute(Route): run_at_dt = datetime.fromisoformat(str(run_at)) except Exception: return jsonify( - Response().error("run_at must be ISO datetime").__dict__ + Response().error("run_at must be ISO datetime").to_json() ) job_payload = { @@ -118,22 +117,22 @@ class CronRoute(Route): run_at=run_at_dt, ) - return jsonify(Response().ok(data=self._serialize_job(job)).__dict__) + return jsonify(Response().ok(data=self._serialize_job(job)).to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to create job: {e!s}").__dict__) + return jsonify(Response().error(f"Failed to create job: {e!s}").to_json()) async def update_job(self, job_id: str): try: cron_mgr = self.core_lifecycle.cron_manager if cron_mgr is None: return jsonify( - Response().error("Cron manager not initialized").__dict__ + Response().error("Cron manager not initialized").to_json() ) payload = await request.json if not isinstance(payload, dict): - return jsonify(Response().error("Invalid payload").__dict__) + return jsonify(Response().error("Invalid payload").to_json()) updates = { "name": payload.get("name"), @@ -154,21 +153,21 @@ class CronRoute(Route): job = await cron_mgr.update_job(job_id, **updates) if not job: - return jsonify(Response().error("Job not found").__dict__) - return jsonify(Response().ok(data=self._serialize_job(job)).__dict__) + return jsonify(Response().error("Job not found").to_json()) + return jsonify(Response().ok(data=self._serialize_job(job)).to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to update job: {e!s}").__dict__) + return jsonify(Response().error(f"Failed to update job: {e!s}").to_json()) async def delete_job(self, job_id: str): try: cron_mgr = self.core_lifecycle.cron_manager if cron_mgr is None: return jsonify( - Response().error("Cron manager not initialized").__dict__ + Response().error("Cron manager not initialized").to_json() ) await cron_mgr.delete_job(job_id) - return jsonify(Response().ok(message="deleted").__dict__) + return jsonify(Response().ok(message="deleted").to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to delete job: {e!s}").__dict__) + return jsonify(Response().error(f"Failed to delete job: {e!s}").to_json()) diff --git a/astrbot/dashboard/routes/log.py b/astrbot/dashboard/routes/log.py index e7eebef6e..c6e589c04 100644 --- a/astrbot/dashboard/routes/log.py +++ b/astrbot/dashboard/routes/log.py @@ -110,35 +110,34 @@ class LogRoute(Route): data={ "logs": logs, }, - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取日志历史失败: {e}") - return Response().error(f"获取日志历史失败: {e}").__dict__ + return Response().error(f"获取日志历史失败: {e}").to_json() async def get_trace_settings(self): """获取 Trace 设置""" try: trace_enable = self.config.get("trace_enable", True) - return Response().ok(data={"trace_enable": trace_enable}).__dict__ + return Response().ok(data={"trace_enable": trace_enable}).to_json() except Exception as e: logger.error(f"获取 Trace 设置失败: {e}") - return Response().error(f"获取 Trace 设置失败: {e}").__dict__ + return Response().error(f"获取 Trace 设置失败: {e}").to_json() async def update_trace_settings(self): """更新 Trace 设置""" try: data = await request.json if data is None: - return Response().error("请求数据为空").__dict__ + return Response().error("请求数据为空").to_json() trace_enable = data.get("trace_enable") if trace_enable is not None: self.config["trace_enable"] = bool(trace_enable) self.config.save_config() - return Response().ok(message="Trace 设置已更新").__dict__ + return Response().ok(message="Trace 设置已更新").to_json() except Exception as e: logger.error(f"更新 Trace 设置失败: {e}") - return Response().error(f"更新 Trace 设置失败: {e}").__dict__ + return Response().error(f"更新 Trace 设置失败: {e}").to_json() diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py index fdf213cf8..71d8f7e7a 100644 --- a/astrbot/dashboard/routes/open_api.py +++ b/astrbot/dashboard/routes/open_api.py @@ -150,9 +150,9 @@ class OpenApiRoute(Route): post_data.get("username") ) if username_err: - return Response().error(username_err).__dict__ + return Response().error(username_err).to_json() if not effective_username: - return Response().error("Invalid username").__dict__ + return Response().error("Invalid username").to_json() raw_session_id = post_data.get("session_id", post_data.get("conversation_id")) session_id = str(raw_session_id).strip() if raw_session_id is not None else "" @@ -164,11 +164,11 @@ class OpenApiRoute(Route): session_id, ) if ensure_session_err: - return Response().error(ensure_session_err).__dict__ + return Response().error(ensure_session_err).to_json() config_id, resolve_err = self._resolve_chat_config_id(post_data) if resolve_err: - return Response().error(resolve_err).__dict__ + return Response().error(resolve_err).to_json() original_username = g.get("username", "guest") g.username = effective_username @@ -191,8 +191,7 @@ class OpenApiRoute(Route): ) return ( Response() - .error(f"Failed to update chat config route: {e}") - .__dict__ + .error(f"Failed to update chat config route: {e}").to_json() ) try: return await self.chat_route.chat(post_data=post_data) @@ -574,7 +573,7 @@ class OpenApiRoute(Route): request.args.get("username") ) if username_err: - return Response().error(username_err).__dict__ + return Response().error(username_err).to_json() assert username is not None # for type checker @@ -582,7 +581,7 @@ class OpenApiRoute(Route): page = int(request.args.get("page", 1)) page_size = int(request.args.get("page_size", 20)) except ValueError: - return Response().error("page and page_size must be integers").__dict__ + return Response().error("page and page_size must be integers").to_json() if page < 1: page = 1 @@ -628,13 +627,12 @@ class OpenApiRoute(Route): "page_size": page_size, "total": total, } - ) - .__dict__ + ).to_json() ) async def get_chat_configs(self): conf_list = self._get_chat_config_list() - return Response().ok(data={"configs": conf_list}).__dict__ + return Response().ok(data={"configs": conf_list}).to_json() async def _build_message_chain_from_payload( self, @@ -652,14 +650,14 @@ class OpenApiRoute(Route): umo = post_data.get("umo") if message_payload is None: - return Response().error("Missing key: message").__dict__ + return Response().error("Missing key: message").to_json() if not umo: - return Response().error("Missing key: umo").__dict__ + return Response().error("Missing key: umo").to_json() try: session = MessageSesion.from_str(str(umo)) except Exception as e: - return Response().error(f"Invalid umo: {e}").__dict__ + return Response().error(f"Invalid umo: {e}").to_json() platform_id = session.platform_name platform_inst = next( @@ -673,8 +671,7 @@ class OpenApiRoute(Route): if not platform_inst: return ( Response() - .error(f"Bot not found or not running for platform: {platform_id}") - .__dict__ + .error(f"Bot not found or not running for platform: {platform_id}").to_json() ) try: @@ -682,12 +679,12 @@ class OpenApiRoute(Route): message_payload ) await platform_inst.send_by_session(session, message_chain) - return Response().ok().__dict__ + return Response().ok().to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(f"Open API send_message failed: {e}", exc_info=True) - return Response().error(f"Failed to send message: {e}").__dict__ + return Response().error(f"Failed to send message: {e}").to_json() async def get_bots(self): bot_ids = [] @@ -699,4 +696,4 @@ class OpenApiRoute(Route): and platform_id not in bot_ids ): bot_ids.append(platform_id) - return Response().ok(data={"bot_ids": bot_ids}).__dict__ + return Response().ok(data={"bot_ids": bot_ids}).to_json() diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index a473fcd1b..055ba9558 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -71,12 +71,11 @@ class PersonaRoute(Route): } for persona in personas ], - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取人格列表失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取人格列表失败: {e!s}").__dict__ + return Response().error(f"获取人格列表失败: {e!s}").to_json() async def get_persona_detail(self): """获取指定人格的详细信息""" @@ -85,11 +84,11 @@ class PersonaRoute(Route): persona_id = data.get("persona_id") if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ + return Response().error("缺少必要参数: persona_id").to_json() persona = await self.persona_mgr.get_persona(persona_id) if not persona: - return Response().error("人格不存在").__dict__ + return Response().error("人格不存在").to_json() return ( Response() @@ -110,12 +109,11 @@ class PersonaRoute(Route): if persona.updated_at else None, }, - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取人格详情失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取人格详情失败: {e!s}").__dict__ + return Response().error(f"获取人格详情失败: {e!s}").to_json() async def create_persona(self): """创建新人格""" @@ -131,22 +129,21 @@ class PersonaRoute(Route): sort_order = data.get("sort_order", 0) if not persona_id: - return Response().error("人格ID不能为空").__dict__ + return Response().error("人格ID不能为空").to_json() if not system_prompt: - return Response().error("系统提示词不能为空").__dict__ + return Response().error("系统提示词不能为空").to_json() if custom_error_message is not None: if not isinstance(custom_error_message, str): - return Response().error("自定义报错回复信息必须是字符串").__dict__ + return Response().error("自定义报错回复信息必须是字符串").to_json() custom_error_message = custom_error_message.strip() or None # 验证 begin_dialogs 格式 if begin_dialogs and len(begin_dialogs) % 2 != 0: return ( Response() - .error("预设对话数量必须为偶数(用户和助手轮流对话)") - .__dict__ + .error("预设对话数量必须为偶数(用户和助手轮流对话)").to_json() ) persona = await self.persona_mgr.create_persona( @@ -182,14 +179,13 @@ class PersonaRoute(Route): else None, }, }, - ) - .__dict__ + ).to_json() ) except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(f"创建人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"创建人格失败: {e!s}").__dict__ + return Response().error(f"创建人格失败: {e!s}").to_json() async def update_persona(self): """更新人格信息""" @@ -206,13 +202,13 @@ class PersonaRoute(Route): custom_error_message = data.get("custom_error_message") if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ + return Response().error("缺少必要参数: persona_id").to_json() if has_custom_error_message: if custom_error_message is not None and not isinstance( custom_error_message, str ): - return Response().error("自定义报错回复信息必须是字符串").__dict__ + return Response().error("自定义报错回复信息必须是字符串").to_json() if isinstance(custom_error_message, str): custom_error_message = custom_error_message.strip() or None @@ -220,8 +216,7 @@ class PersonaRoute(Route): if begin_dialogs is not None and len(begin_dialogs) % 2 != 0: return ( Response() - .error("预设对话数量必须为偶数(用户和助手轮流对话)") - .__dict__ + .error("预设对话数量必须为偶数(用户和助手轮流对话)").to_json() ) update_kwargs = { @@ -238,12 +233,12 @@ class PersonaRoute(Route): await self.persona_mgr.update_persona(**update_kwargs) - return Response().ok({"message": "人格更新成功"}).__dict__ + return Response().ok({"message": "人格更新成功"}).to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(f"更新人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新人格失败: {e!s}").__dict__ + return Response().error(f"更新人格失败: {e!s}").to_json() async def delete_persona(self): """删除人格""" @@ -252,16 +247,16 @@ class PersonaRoute(Route): persona_id = data.get("persona_id") if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ + return Response().error("缺少必要参数: persona_id").to_json() await self.persona_mgr.delete_persona(persona_id) - return Response().ok({"message": "人格删除成功"}).__dict__ + return Response().ok({"message": "人格删除成功"}).to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"删除人格失败: {e!s}").__dict__ + return Response().error(f"删除人格失败: {e!s}").to_json() async def clone_persona(self): """克隆人格""" @@ -271,10 +266,10 @@ class PersonaRoute(Route): new_persona_id = data.get("new_persona_id", "").strip() if not source_persona_id: - return Response().error("缺少必要参数: source_persona_id").__dict__ + return Response().error("缺少必要参数: source_persona_id").to_json() if not new_persona_id: - return Response().error("新人格ID不能为空").__dict__ + return Response().error("新人格ID不能为空").to_json() persona = await self.persona_mgr.clone_persona( source_persona_id=source_persona_id, @@ -303,14 +298,13 @@ class PersonaRoute(Route): else None, }, }, - ) - .__dict__ + ).to_json() ) except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(f"克隆人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"克隆人格失败: {e!s}").__dict__ + return Response().error(f"克隆人格失败: {e!s}").to_json() async def move_persona(self): """移动人格到指定文件夹""" @@ -320,16 +314,16 @@ class PersonaRoute(Route): folder_id = data.get("folder_id") # None 表示移动到根目录 if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ + return Response().error("缺少必要参数: persona_id").to_json() await self.persona_mgr.move_persona_to_folder(persona_id, folder_id) - return Response().ok({"message": "人格移动成功"}).__dict__ + return Response().ok({"message": "人格移动成功"}).to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(f"移动人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"移动人格失败: {e!s}").__dict__ + return Response().error(f"移动人格失败: {e!s}").to_json() # ==== # Folder Routes @@ -362,21 +356,20 @@ class PersonaRoute(Route): } for folder in folders ], - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取文件夹列表失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取文件夹列表失败: {e!s}").__dict__ + return Response().error(f"获取文件夹列表失败: {e!s}").to_json() async def get_folder_tree(self): """获取文件夹树形结构""" try: tree = await self.persona_mgr.get_folder_tree() - return Response().ok(tree).__dict__ + return Response().ok(tree).to_json() except Exception as e: logger.error(f"获取文件夹树失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取文件夹树失败: {e!s}").__dict__ + return Response().error(f"获取文件夹树失败: {e!s}").to_json() async def get_folder_detail(self): """获取指定文件夹的详细信息""" @@ -385,11 +378,11 @@ class PersonaRoute(Route): folder_id = data.get("folder_id") if not folder_id: - return Response().error("缺少必要参数: folder_id").__dict__ + return Response().error("缺少必要参数: folder_id").to_json() folder = await self.persona_mgr.get_folder(folder_id) if not folder: - return Response().error("文件夹不存在").__dict__ + return Response().error("文件夹不存在").to_json() return ( Response() @@ -407,12 +400,11 @@ class PersonaRoute(Route): if folder.updated_at else None, }, - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取文件夹详情失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取文件夹详情失败: {e!s}").__dict__ + return Response().error(f"获取文件夹详情失败: {e!s}").to_json() async def create_folder(self): """创建文件夹""" @@ -424,7 +416,7 @@ class PersonaRoute(Route): sort_order = data.get("sort_order", 0) if not name: - return Response().error("文件夹名称不能为空").__dict__ + return Response().error("文件夹名称不能为空").to_json() folder = await self.persona_mgr.create_folder( name=name, @@ -452,12 +444,11 @@ class PersonaRoute(Route): else None, }, }, - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"创建文件夹失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"创建文件夹失败: {e!s}").__dict__ + return Response().error(f"创建文件夹失败: {e!s}").to_json() async def update_folder(self): """更新文件夹信息""" @@ -470,7 +461,7 @@ class PersonaRoute(Route): sort_order = data.get("sort_order") if not folder_id: - return Response().error("缺少必要参数: folder_id").__dict__ + return Response().error("缺少必要参数: folder_id").to_json() await self.persona_mgr.update_folder( folder_id=folder_id, @@ -480,10 +471,10 @@ class PersonaRoute(Route): sort_order=sort_order, ) - return Response().ok({"message": "文件夹更新成功"}).__dict__ + return Response().ok({"message": "文件夹更新成功"}).to_json() except Exception as e: logger.error(f"更新文件夹失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新文件夹失败: {e!s}").__dict__ + return Response().error(f"更新文件夹失败: {e!s}").to_json() async def delete_folder(self): """删除文件夹""" @@ -492,14 +483,14 @@ class PersonaRoute(Route): folder_id = data.get("folder_id") if not folder_id: - return Response().error("缺少必要参数: folder_id").__dict__ + return Response().error("缺少必要参数: folder_id").to_json() await self.persona_mgr.delete_folder(folder_id) - return Response().ok({"message": "文件夹删除成功"}).__dict__ + return Response().ok({"message": "文件夹删除成功"}).to_json() except Exception as e: logger.error(f"删除文件夹失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"删除文件夹失败: {e!s}").__dict__ + return Response().error(f"删除文件夹失败: {e!s}").to_json() async def reorder_items(self): """批量更新排序顺序 @@ -519,26 +510,24 @@ class PersonaRoute(Route): items = data.get("items", []) if not items: - return Response().error("items 不能为空").__dict__ + return Response().error("items 不能为空").to_json() # 验证每个 item 的格式 for item in items: if not all(k in item for k in ("id", "type", "sort_order")): return ( Response() - .error("每个 item 必须包含 id, type, sort_order 字段") - .__dict__ + .error("每个 item 必须包含 id, type, sort_order 字段").to_json() ) if item["type"] not in ("persona", "folder"): return ( Response() - .error("type 字段必须是 'persona' 或 'folder'") - .__dict__ + .error("type 字段必须是 'persona' 或 'folder'").to_json() ) await self.persona_mgr.batch_update_sort_order(items) - return Response().ok({"message": "排序更新成功"}).__dict__ + return Response().ok({"message": "排序更新成功"}).to_json() except Exception as e: logger.error(f"更新排序失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新排序失败: {e!s}").__dict__ + return Response().error(f"更新排序失败: {e!s}").to_json() diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py index 227c71a6c..f59640906 100644 --- a/astrbot/dashboard/routes/platform.py +++ b/astrbot/dashboard/routes/platform.py @@ -56,7 +56,7 @@ class PlatformRoute(Route): if not platform_adapter: logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台") - return Response().error("未找到对应平台").__dict__, 404 + return Response().error("未找到对应平台").to_json(), 404 # 调用平台适配器的 webhook_callback 方法 try: @@ -66,10 +66,10 @@ class PlatformRoute(Route): logger.error( f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法" ) - return Response().error("平台未支持统一 Webhook 模式").__dict__, 500 + return Response().error("平台未支持统一 Webhook 模式").to_json(), 500 except Exception as e: logger.error(f"处理 webhook 回调时发生错误: {e}", exc_info=True) - return Response().error("处理回调失败").__dict__, 500 + return Response().error("处理回调失败").to_json(), 500 def _find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None: """根据 webhook_uuid 查找对应的平台适配器 @@ -94,7 +94,7 @@ class PlatformRoute(Route): """ try: stats = self.platform_manager.get_all_stats() - return Response().ok(stats).__dict__ + return Response().ok(stats).to_json() except Exception as e: logger.error(f"获取平台统计信息失败: {e}", exc_info=True) - return Response().error(f"获取统计信息失败: {e}").__dict__, 500 + return Response().error(f"获取统计信息失败: {e}").to_json(), 500 diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 8e8503e2b..4efa69802 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -114,45 +114,42 @@ class PluginRoute(Route): "message": message, "astrbot_version": version_spec, } - ) - .__dict__ + ).to_json() ) except Exception as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def reload_failed_plugins(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) try: data = await request.get_json() dir_name = data.get("dir_name") # 这里拿的是目录名,不是插件名 if not dir_name: - return Response().error("缺少插件目录名").__dict__ + return Response().error("缺少插件目录名").to_json() # 调用 star_manager.py 中的函数 # 注意:传入的是目录名 success, err = await self.plugin_manager.reload_failed_plugin(dir_name) if success: - return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__ + return Response().ok(None, f"插件 {dir_name} 重载成功。").to_json() else: - return Response().error(f"重载失败: {err}").__dict__ + return Response().error(f"重载失败: {err}").to_json() except Exception as e: logger.error(f"/api/plugin/reload-failed: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def reload_plugins(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) data = await request.get_json() @@ -160,11 +157,11 @@ class PluginRoute(Route): try: success, message = await self.plugin_manager.reload(plugin_name) if not success: - return Response().error(message or "插件重载失败").__dict__ - return Response().ok(None, "重载成功。").__dict__ + return Response().error(message or "插件重载失败").to_json() + return Response().ok(None, "重载成功。").to_json() except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def get_online_plugins(self): custom = request.args.get("custom_registry") @@ -181,7 +178,7 @@ class PluginRoute(Route): cached_data = await self._load_plugin_cache(source.cache_file) if cached_data: logger.debug("缓存MD5匹配,使用缓存的插件市场数据") - return Response().ok(cached_data).__dict__ + return Response().ok(cached_data).to_json() # 尝试获取远程数据 remote_data = None @@ -221,7 +218,7 @@ class PluginRoute(Route): remote_data, current_md5, ) - return Response().ok(remote_data).__dict__ + return Response().ok(remote_data).to_json() logger.error(f"请求 {url} 失败,状态码:{response.status}") except Exception as e: logger.error(f"请求 {url} 失败,错误:{e}") @@ -232,9 +229,9 @@ class PluginRoute(Route): if cached_data: logger.warning("远程插件市场数据获取失败,使用缓存数据") - return Response().ok(cached_data, "使用缓存数据,可能不是最新版本").__dict__ + return Response().ok(cached_data, "使用缓存数据,可能不是最新版本").to_json() - return Response().error("获取插件列表失败,且没有可用的缓存数据").__dict__ + return Response().error("获取插件列表失败,且没有可用的缓存数据").to_json() def _build_registry_source(self, custom_url: str | None) -> RegistrySource: """构建注册表源信息""" @@ -440,13 +437,12 @@ class PluginRoute(Route): _plugin_resp.append(_t) return ( Response() - .ok(_plugin_resp, message=self.plugin_manager.failed_plugin_info) - .__dict__ + .ok(_plugin_resp, message=self.plugin_manager.failed_plugin_info).to_json() ) async def get_failed_plugins(self): """专门获取加载失败的插件列表(字典格式)""" - return Response().ok(self.plugin_manager.failed_plugin_dict).__dict__ + return Response().ok(self.plugin_manager.failed_plugin_dict).to_json() async def get_plugin_handlers_info(self, handler_full_names: list[str]): """解析插件行为""" @@ -513,8 +509,7 @@ class PluginRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -534,7 +529,7 @@ class PluginRoute(Route): ) # self.core_lifecycle.restart() logger.info(f"安装插件 {repo_url} 成功。") - return Response().ok(plugin_info, "安装成功。").__dict__ + return Response().ok(plugin_info, "安装成功。").to_json() except PluginVersionIncompatibleError as e: return { "status": "warning", @@ -546,14 +541,13 @@ class PluginRoute(Route): } except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def install_plugin_upload(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) try: @@ -575,7 +569,7 @@ class PluginRoute(Route): ) # self.core_lifecycle.restart() logger.info(f"安装插件 {file.filename} 成功") - return Response().ok(plugin_info, "安装成功。").__dict__ + return Response().ok(plugin_info, "安装成功。").to_json() except PluginVersionIncompatibleError as e: return { "status": "warning", @@ -587,14 +581,13 @@ class PluginRoute(Route): } except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def uninstall_plugin(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -609,17 +602,16 @@ class PluginRoute(Route): delete_data=delete_data, ) logger.info(f"卸载插件 {plugin_name} 成功") - return Response().ok(None, "卸载成功").__dict__ + return Response().ok(None, "卸载成功").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def uninstall_failed_plugin(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -627,7 +619,7 @@ class PluginRoute(Route): delete_config = post_data.get("delete_config", False) delete_data = post_data.get("delete_data", False) if not dir_name: - return Response().error("缺少失败插件目录名").__dict__ + return Response().error("缺少失败插件目录名").to_json() try: logger.info(f"正在卸载失败插件 {dir_name}") @@ -637,17 +629,16 @@ class PluginRoute(Route): delete_data=delete_data, ) logger.info(f"卸载失败插件 {dir_name} 成功") - return Response().ok(None, "卸载成功").__dict__ + return Response().ok(None, "卸载成功").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def update_plugin(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -659,17 +650,16 @@ class PluginRoute(Route): # self.core_lifecycle.restart() await self.plugin_manager.reload(plugin_name) logger.info(f"更新插件 {plugin_name} 成功。") - return Response().ok(None, "更新成功。").__dict__ + return Response().ok(None, "更新成功。").to_json() except Exception as e: logger.error(f"/api/plugin/update: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def update_all_plugins(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -677,7 +667,7 @@ class PluginRoute(Route): proxy: str = post_data.get("proxy", "") if not isinstance(plugin_names, list) or not plugin_names: - return Response().error("插件列表不能为空").__dict__ + return Response().error("插件列表不能为空").to_json() results = [] sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY) @@ -715,14 +705,13 @@ class PluginRoute(Route): else f"批量更新完成,其中 {len(failed)}/{len(results)} 个插件失败。" ) - return Response().ok({"results": results}, message).__dict__ + return Response().ok({"results": results}, message).to_json() async def off_plugin(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -730,17 +719,16 @@ class PluginRoute(Route): try: await self.plugin_manager.turn_off_plugin(plugin_name) logger.info(f"停用插件 {plugin_name} 。") - return Response().ok(None, "停用成功。").__dict__ + return Response().ok(None, "停用成功。").to_json() except Exception as e: logger.error(f"/api/plugin/off: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def on_plugin(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) post_data = await request.get_json() @@ -748,10 +736,10 @@ class PluginRoute(Route): try: await self.plugin_manager.turn_on_plugin(plugin_name) logger.info(f"启用插件 {plugin_name} 。") - return Response().ok(None, "启用成功。").__dict__ + return Response().ok(None, "启用成功。").to_json() except Exception as e: logger.error(f"/api/plugin/on: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def get_plugin_readme(self): plugin_name = request.args.get("name") @@ -759,7 +747,7 @@ class PluginRoute(Route): if not plugin_name: logger.warning("插件名称为空") - return Response().error("插件名称不能为空").__dict__ + return Response().error("插件名称不能为空").to_json() plugin_obj = None for plugin in self.plugin_manager.context.get_all_stars(): @@ -769,11 +757,11 @@ class PluginRoute(Route): if not plugin_obj: logger.warning(f"插件 {plugin_name} 不存在") - return Response().error(f"插件 {plugin_name} 不存在").__dict__ + return Response().error(f"插件 {plugin_name} 不存在").to_json() if not plugin_obj.root_dir_name: logger.warning(f"插件 {plugin_name} 目录不存在") - return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ + return Response().error(f"插件 {plugin_name} 目录不存在").to_json() if plugin_obj.reserved: plugin_dir = os.path.join( @@ -788,13 +776,13 @@ class PluginRoute(Route): if not await anyio.Path(plugin_dir).is_dir(): logger.warning(f"无法找到插件目录: {plugin_dir}") - return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ + return Response().error(f"无法找到插件 {plugin_name} 的目录").to_json() readme_path = os.path.join(plugin_dir, "README.md") if not await anyio.Path(readme_path).is_file(): logger.warning(f"插件 {plugin_name} 没有README文件") - return Response().error(f"插件 {plugin_name} 没有README文件").__dict__ + return Response().error(f"插件 {plugin_name} 没有README文件").to_json() try: async with await anyio.open_file(readme_path, encoding="utf-8") as f: @@ -802,12 +790,11 @@ class PluginRoute(Route): return ( Response() - .ok({"content": readme_content}, "成功获取README内容") - .__dict__ + .ok({"content": readme_content}, "成功获取README内容").to_json() ) except Exception as e: logger.error(f"/api/plugin/readme: {traceback.format_exc()}") - return Response().error(f"读取README文件失败: {e!s}").__dict__ + return Response().error(f"读取README文件失败: {e!s}").to_json() async def get_plugin_changelog(self): """获取插件更新日志 @@ -819,7 +806,7 @@ class PluginRoute(Route): if not plugin_name: logger.warning("插件名称为空") - return Response().error("插件名称不能为空").__dict__ + return Response().error("插件名称不能为空").to_json() # 查找插件 plugin_obj = None @@ -830,11 +817,11 @@ class PluginRoute(Route): if not plugin_obj: logger.warning(f"插件 {plugin_name} 不存在") - return Response().error(f"插件 {plugin_name} 不存在").__dict__ + return Response().error(f"插件 {plugin_name} 不存在").to_json() if not plugin_obj.root_dir_name: logger.warning(f"插件 {plugin_name} 目录不存在") - return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ + return Response().error(f"插件 {plugin_name} 目录不存在").to_json() if plugin_obj.reserved: plugin_dir = os.path.join( @@ -849,7 +836,7 @@ class PluginRoute(Route): if not await anyio.Path(plugin_dir).is_dir(): logger.warning(f"无法找到插件目录: {plugin_dir}") - return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ + return Response().error(f"无法找到插件 {plugin_name} 的目录").to_json() # 尝试多种可能的文件名 changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"] @@ -863,21 +850,20 @@ class PluginRoute(Route): changelog_content = await f.read() return ( Response() - .ok({"content": changelog_content}, "成功获取更新日志") - .__dict__ + .ok({"content": changelog_content}, "成功获取更新日志").to_json() ) except Exception as e: logger.error(f"/api/plugin/changelog: {traceback.format_exc()}") - return Response().error(f"读取更新日志失败: {e!s}").__dict__ + return Response().error(f"读取更新日志失败: {e!s}").to_json() # 没有找到 changelog 文件,返回 ok 但 content 为 null logger.warning(f"插件 {plugin_name} 没有更新日志文件") - return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__ + return Response().ok({"content": None}, "该插件没有更新日志文件").to_json() async def get_custom_source(self): """获取自定义插件源""" sources = await sp.global_get("custom_plugin_sources", []) - return Response().ok(sources).__dict__ + return Response().ok(sources).to_json() async def save_custom_source(self): """保存自定义插件源""" @@ -885,10 +871,10 @@ class PluginRoute(Route): data = await request.get_json() sources = data.get("sources", []) if not isinstance(sources, list): - return Response().error("sources fields must be a list").__dict__ + return Response().error("sources fields must be a list").to_json() await sp.global_put("custom_plugin_sources", sources) - return Response().ok(None, "保存成功").__dict__ + return Response().ok(None, "保存成功").to_json() except Exception as e: logger.error(f"/api/plugin/source/save: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py index 3fddd4b49..3849e8ff6 100644 --- a/astrbot/dashboard/routes/route.py +++ b/astrbot/dashboard/routes/route.py @@ -59,7 +59,7 @@ def runtime_status_response( core_lifecycle, include_failure_details=include_failure_details, ), - ).__dict__ + ).to_json() ) response.status_code = status_code return response diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py index fc632d1f5..2c29d0ca4 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -241,12 +241,11 @@ class SessionManagementRoute(Route): "available_kbs": available_kbs, "available_rule_keys": AVAILABLE_SESSION_RULE_KEYS, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取规则列表失败: {e!s}") - return Response().error(f"获取规则列表失败: {e!s}").__dict__ + return Response().error(f"获取规则列表失败: {e!s}").to_json() async def update_session_rule(self): """更新某个 umo 的自定义规则 @@ -265,11 +264,11 @@ class SessionManagementRoute(Route): rule_value = data.get("rule_value") if not umo: - return Response().error("缺少必要参数: umo").__dict__ + return Response().error("缺少必要参数: umo").to_json() if not rule_key: - return Response().error("缺少必要参数: rule_key").__dict__ + return Response().error("缺少必要参数: rule_key").to_json() if rule_key not in AVAILABLE_SESSION_RULE_KEYS: - return Response().error(f"不支持的规则键: {rule_key}").__dict__ + return Response().error(f"不支持的规则键: {rule_key}").to_json() if rule_key == "session_plugin_config": rule_value = { @@ -281,12 +280,11 @@ class SessionManagementRoute(Route): return ( Response() - .ok({"message": f"规则 {rule_key} 已更新", "umo": umo}) - .__dict__ + .ok({"message": f"规则 {rule_key} 已更新", "umo": umo}).to_json() ) except Exception as e: logger.error(f"更新会话规则失败: {e!s}") - return Response().error(f"更新会话规则失败: {e!s}").__dict__ + return Response().error(f"更新会话规则失败: {e!s}").to_json() async def delete_session_rule(self): """删除某个 umo 的自定义规则 @@ -303,25 +301,24 @@ class SessionManagementRoute(Route): rule_key = data.get("rule_key") if not umo: - return Response().error("缺少必要参数: umo").__dict__ + return Response().error("缺少必要参数: umo").to_json() if rule_key: # 删除单个规则 if rule_key not in AVAILABLE_SESSION_RULE_KEYS: - return Response().error(f"不支持的规则键: {rule_key}").__dict__ + return Response().error(f"不支持的规则键: {rule_key}").to_json() await sp.session_remove(umo, rule_key) return ( Response() - .ok({"message": f"规则 {rule_key} 已删除", "umo": umo}) - .__dict__ + .ok({"message": f"规则 {rule_key} 已删除", "umo": umo}).to_json() ) else: # 删除该 umo 的所有规则 await sp.clear_async("umo", umo) - return Response().ok({"message": "所有规则已删除", "umo": umo}).__dict__ + return Response().ok({"message": "所有规则已删除", "umo": umo}).to_json() except Exception as e: logger.error(f"删除会话规则失败: {e!s}") - return Response().error(f"删除会话规则失败: {e!s}").__dict__ + return Response().error(f"删除会话规则失败: {e!s}").to_json() async def batch_delete_session_rule(self): """批量删除多个 umo 的自定义规则 @@ -347,10 +344,10 @@ class SessionManagementRoute(Route): # 如果是自定义分组 if scope == "custom_group": if not group_id: - return Response().error("请指定分组 ID").__dict__ + return Response().error("请指定分组 ID").to_json() groups = self._get_groups() if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ + return Response().error(f"分组 '{group_id}' 不存在").to_json() umos = groups[group_id].get("umos", []) else: async with self.db_helper.get_db() as session: @@ -376,13 +373,13 @@ class SessionManagementRoute(Route): umos = all_umos if not umos: - return Response().error("缺少必要参数: umos 或有效的 scope").__dict__ + return Response().error("缺少必要参数: umos 或有效的 scope").to_json() if not isinstance(umos, list): - return Response().error("参数 umos 必须是数组").__dict__ + return Response().error("参数 umos 必须是数组").to_json() if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS: - return Response().error(f"不支持的规则键: {rule_key}").__dict__ + return Response().error(f"不支持的规则键: {rule_key}").to_json() # 批量删除 success_count = 0 @@ -411,8 +408,7 @@ class SessionManagementRoute(Route): "success_count": success_count, "failed_umos": failed_umos, } - ) - .__dict__ + ).to_json() ) else: return ( @@ -422,12 +418,11 @@ class SessionManagementRoute(Route): "message": message, "success_count": success_count, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"批量删除会话规则失败: {e!s}") - return Response().error(f"批量删除会话规则失败: {e!s}").__dict__ + return Response().error(f"批量删除会话规则失败: {e!s}").to_json() async def list_umos(self): """列出所有有对话记录的 umo,从 Conversations 表中找 @@ -445,10 +440,10 @@ class SessionManagementRoute(Route): ) umos = [row[0] for row in result.fetchall()] - return Response().ok({"umos": umos}).__dict__ + return Response().ok({"umos": umos}).to_json() except Exception as e: logger.error(f"获取 UMO 列表失败: {e!s}") - return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__ + return Response().error(f"获取 UMO 列表失败: {e!s}").to_json() async def list_all_umos_with_status(self): """获取所有有对话记录的 UMO 及其服务状态(支持分页、搜索、筛选) @@ -598,12 +593,11 @@ class SessionManagementRoute(Route): "available_tts_providers": available_tts_providers, "available_stt_providers": available_stt_providers, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"获取会话状态列表失败: {e!s}") - return Response().error(f"获取会话状态列表失败: {e!s}").__dict__ + return Response().error(f"获取会话状态列表失败: {e!s}").to_json() async def batch_update_service(self): """批量更新多个 UMO 的服务状态 (LLM/TTS/Session) @@ -629,17 +623,17 @@ class SessionManagementRoute(Route): # 如果没有任何修改 if llm_enabled is None and tts_enabled is None and session_enabled is None: - return Response().error("至少需要指定一个要修改的状态").__dict__ + return Response().error("至少需要指定一个要修改的状态").to_json() # 如果指定了 scope,获取符合条件的所有 umo if scope and not umos: # 如果是自定义分组 if scope == "custom_group": if not group_id: - return Response().error("请指定分组 ID").__dict__ + return Response().error("请指定分组 ID").to_json() groups = self._get_groups() if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ + return Response().error(f"分组 '{group_id}' 不存在").to_json() umos = groups[group_id].get("umos", []) else: async with self.db_helper.get_db() as session: @@ -665,7 +659,7 @@ class SessionManagementRoute(Route): umos = all_umos if not umos: - return Response().error("没有找到符合条件的会话").__dict__ + return Response().error("没有找到符合条件的会话").to_json() # 批量更新 success_count = 0 @@ -716,12 +710,11 @@ class SessionManagementRoute(Route): "failed_count": len(failed_umos), "failed_umos": failed_umos, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"批量更新服务状态失败: {e!s}") - return Response().error(f"批量更新服务状态失败: {e!s}").__dict__ + return Response().error(f"批量更新服务状态失败: {e!s}").to_json() async def batch_update_provider(self): """批量更新多个 UMO 的 Provider 配置 @@ -744,8 +737,7 @@ class SessionManagementRoute(Route): if not provider_type or not provider_id: return ( Response() - .error("缺少必要参数: provider_type, provider_id") - .__dict__ + .error("缺少必要参数: provider_type, provider_id").to_json() ) # 转换 provider_type @@ -757,8 +749,7 @@ class SessionManagementRoute(Route): if provider_type not in provider_type_map: return ( Response() - .error(f"不支持的 provider_type: {provider_type}") - .__dict__ + .error(f"不支持的 provider_type: {provider_type}").to_json() ) provider_type_enum = provider_type_map[provider_type] @@ -769,10 +760,10 @@ class SessionManagementRoute(Route): # 如果是自定义分组 if scope == "custom_group": if not group_id: - return Response().error("请指定分组 ID").__dict__ + return Response().error("请指定分组 ID").to_json() groups = self._get_groups() if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ + return Response().error(f"分组 '{group_id}' 不存在").to_json() umos = groups[group_id].get("umos", []) else: async with self.db_helper.get_db() as session: @@ -798,7 +789,7 @@ class SessionManagementRoute(Route): umos = all_umos if not umos: - return Response().error("没有找到符合条件的会话").__dict__ + return Response().error("没有找到符合条件的会话").to_json() # 批量更新 success_count = 0 @@ -826,12 +817,11 @@ class SessionManagementRoute(Route): "failed_count": len(failed_umos), "failed_umos": failed_umos, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"批量更新 Provider 失败: {e!s}") - return Response().error(f"批量更新 Provider 失败: {e!s}").__dict__ + return Response().error(f"批量更新 Provider 失败: {e!s}").to_json() # ==================== 分组管理 API ==================== @@ -858,10 +848,10 @@ class SessionManagementRoute(Route): "umo_count": len(group_data.get("umos", [])), } ) - return Response().ok({"groups": groups_list}).__dict__ + return Response().ok({"groups": groups_list}).to_json() except Exception as e: logger.error(f"获取分组列表失败: {e!s}") - return Response().error(f"获取分组列表失败: {e!s}").__dict__ + return Response().error(f"获取分组列表失败: {e!s}").to_json() async def create_group(self): """创建新分组""" @@ -871,7 +861,7 @@ class SessionManagementRoute(Route): umos = data.get("umos", []) if not name: - return Response().error("分组名称不能为空").__dict__ + return Response().error("分组名称不能为空").to_json() groups = self._get_groups() @@ -899,12 +889,11 @@ class SessionManagementRoute(Route): "umo_count": len(umos), }, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"创建分组失败: {e!s}") - return Response().error(f"创建分组失败: {e!s}").__dict__ + return Response().error(f"创建分组失败: {e!s}").to_json() async def update_group(self): """更新分组(改名、增删成员)""" @@ -917,12 +906,12 @@ class SessionManagementRoute(Route): remove_umos = data.get("remove_umos", []) if not group_id: - return Response().error("分组 ID 不能为空").__dict__ + return Response().error("分组 ID 不能为空").to_json() groups = self._get_groups() if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ + return Response().error(f"分组 '{group_id}' 不存在").to_json() group = groups[group_id] @@ -956,12 +945,11 @@ class SessionManagementRoute(Route): "umo_count": len(group["umos"]), }, } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(f"更新分组失败: {e!s}") - return Response().error(f"更新分组失败: {e!s}").__dict__ + return Response().error(f"更新分组失败: {e!s}").to_json() async def delete_group(self): """删除分组""" @@ -970,19 +958,19 @@ class SessionManagementRoute(Route): group_id = data.get("id") if not group_id: - return Response().error("分组 ID 不能为空").__dict__ + return Response().error("分组 ID 不能为空").to_json() groups = self._get_groups() if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ + return Response().error(f"分组 '{group_id}' 不存在").to_json() group_name = groups[group_id].get("name", group_id) del groups[group_id] self._save_groups(groups) - return Response().ok({"message": f"分组 '{group_name}' 已删除"}).__dict__ + return Response().ok({"message": f"分组 '{group_name}' 已删除"}).to_json() except Exception as e: logger.error(f"删除分组失败: {e!s}") - return Response().error(f"删除分组失败: {e!s}").__dict__ + return Response().error(f"删除分组失败: {e!s}").to_json() diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index 7d8aee926..be8fb60e0 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -3,6 +3,7 @@ import re import shutil import traceback from collections.abc import Awaitable, Callable +from dataclasses import asdict from pathlib import Path from typing import Any @@ -125,10 +126,10 @@ class SkillsRoute(Route): except ValueError as e: # Config not ready — expected when Neo isn't set up yet logger.debug("[Neo] %s", e) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def get_skills(self): try: @@ -144,23 +145,21 @@ class SkillsRoute(Route): Response() .ok( { - "skills": [skill.__dict__ for skill in skills], + "skills": [asdict(skill) for skill in skills], "runtime": runtime, "sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(), } - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def upload_skill(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) temp_path = None @@ -168,10 +167,10 @@ class SkillsRoute(Route): files = await request.files file = files.get("file") if not file: - return Response().error("Missing file").__dict__ + return Response().error("Missing file").to_json() filename = os.path.basename(file.filename or "skill.zip") if not filename.lower().endswith(".zip"): - return Response().error("Only .zip files are supported").__dict__ + return Response().error("Only .zip files are supported").to_json() temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) @@ -201,12 +200,11 @@ class SkillsRoute(Route): return ( Response() - .ok({"name": skill_name}, "Skill uploaded successfully.") - .__dict__ + .ok({"name": skill_name}, "Skill uploaded successfully.").to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() finally: if temp_path and await anyio.Path(temp_path).exists(): try: @@ -219,8 +217,7 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) try: @@ -228,7 +225,7 @@ class SkillsRoute(Route): file_list = files.getlist("files") if not file_list: - return Response().error("No files provided").__dict__ + return Response().error("No files provided").to_json() succeeded = [] failed = [] @@ -323,8 +320,7 @@ class SkillsRoute(Route): "skipped": skipped, }, message, - ) - .__dict__ + ).to_json() ) if failed_count == 0 and success_count == 0: message = f"All {total} file(s) were skipped." @@ -338,8 +334,7 @@ class SkillsRoute(Route): "skipped": skipped, }, message, - ) - .__dict__ + ).to_json() ) if success_count == 0 and skipped_count == 0: message = f"Upload failed for all {total} file(s)." @@ -350,7 +345,7 @@ class SkillsRoute(Route): "failed": failed, "skipped": skipped, } - return resp.__dict__ + return resp.to_json() message = f"Partial success: {success_count}/{total} skill(s) uploaded." return ( @@ -363,21 +358,20 @@ class SkillsRoute(Route): "skipped": skipped, }, message, - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def download_skill(self): try: name = str(request.args.get("name") or "").strip() if not name: - return Response().error("Missing skill name").__dict__ + return Response().error("Missing skill name").to_json() if not _SKILL_NAME_RE.match(name): - return Response().error("Invalid skill name").__dict__ + return Response().error("Invalid skill name").to_json() skill_mgr = SkillManager() if skill_mgr.is_sandbox_only_skill(name): @@ -385,14 +379,13 @@ class SkillsRoute(Route): Response() .error( "Sandbox preset skill cannot be downloaded from local skill files." - ) - .__dict__ + ).to_json() ) skill_dir = Path(skill_mgr.skills_root) / name skill_md = skill_dir / "SKILL.md" if not skill_dir.is_dir() or not skill_md.exists(): - return Response().error("Local skill not found").__dict__ + return Response().error("Local skill not found").to_json() export_dir = Path(get_astrbot_temp_path()) / "skill_exports" export_dir.mkdir(parents=True, exist_ok=True) @@ -416,48 +409,46 @@ class SkillsRoute(Route): ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def update_skill(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) try: data = await request.get_json() name = data.get("name") active = data.get("active", True) if not name: - return Response().error("Missing skill name").__dict__ + return Response().error("Missing skill name").to_json() SkillManager().set_skill_active(name, bool(active)) - return Response().ok({"name": name, "active": bool(active)}).__dict__ + return Response().ok({"name": name, "active": bool(active)}).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def delete_skill(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) try: data = await request.get_json() name = data.get("name") if not name: - return Response().error("Missing skill name").__dict__ + return Response().error("Missing skill name").to_json() SkillManager().delete_skill(name) try: await sync_skills_to_active_sandboxes() except Exception: logger.warning("Failed to sync deleted skills to active sandboxes.") - return Response().ok({"name": name}).__dict__ + return Response().ok({"name": name}).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() async def get_neo_candidates(self): logger.info("[Neo] GET /skills/neo/candidates requested.") @@ -476,7 +467,7 @@ class SkillsRoute(Route): result = _to_jsonable(candidates) total = result.get("total", "?") if isinstance(result, dict) else "?" logger.info(f"[Neo] Candidates fetched: total={total}") - return Response().ok(result).__dict__ + return Response().ok(result).to_json() return await self._with_neo_client(_do) @@ -499,7 +490,7 @@ class SkillsRoute(Route): result = _to_jsonable(releases) total = result.get("total", "?") if isinstance(result, dict) else "?" logger.info(f"[Neo] Releases fetched: total={total}") - return Response().ok(result).__dict__ + return Response().ok(result).to_json() return await self._with_neo_client(_do) @@ -507,12 +498,12 @@ class SkillsRoute(Route): logger.info("[Neo] GET /skills/neo/payload requested.") payload_ref = request.args.get("payload_ref", "") if not payload_ref: - return Response().error("Missing payload_ref").__dict__ + return Response().error("Missing payload_ref").to_json() async def _do(client): payload = await client.skills.get_payload(payload_ref) logger.info(f"[Neo] Payload fetched: ref={payload_ref}") - return Response().ok(_to_jsonable(payload)).__dict__ + return Response().ok(_to_jsonable(payload)).to_json() return await self._with_neo_client(_do) @@ -520,15 +511,14 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) logger.info("[Neo] POST /skills/neo/evaluate requested.") data = await request.get_json() candidate_id = data.get("candidate_id") passed_value = data.get("passed") if not candidate_id or passed_value is None: - return Response().error("Missing candidate_id or passed").__dict__ + return Response().error("Missing candidate_id or passed").to_json() passed = _to_bool(passed_value, False) async def _do(client): @@ -542,7 +532,7 @@ class SkillsRoute(Route): logger.info( f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}" ) - return Response().ok(_to_jsonable(result)).__dict__ + return Response().ok(_to_jsonable(result)).to_json() return await self._with_neo_client(_do) @@ -550,8 +540,7 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) logger.info("[Neo] POST /skills/neo/promote requested.") data = await request.get_json() @@ -559,9 +548,9 @@ class SkillsRoute(Route): stage = data.get("stage", "canary") sync_to_local = _to_bool(data.get("sync_to_local"), True) if not candidate_id: - return Response().error("Missing candidate_id").__dict__ + return Response().error("Missing candidate_id").to_json() if stage not in {"canary", "stable"}: - return Response().error("Invalid stage, must be canary/stable").__dict__ + return Response().error("Invalid stage, must be canary/stable").to_json() async def _do(client): sync_mgr = NeoSkillSyncManager() @@ -590,7 +579,7 @@ class SkillsRoute(Route): "release": release_json, "rollback": result.get("rollback"), } - return resp.__dict__ + return resp.to_json() # Try to push latest local skills to all active sandboxes. if not did_sync_to_local: @@ -599,7 +588,7 @@ class SkillsRoute(Route): except Exception: logger.warning("Failed to sync skills to active sandboxes.") - return Response().ok({"release": release_json, "sync": sync_json}).__dict__ + return Response().ok({"release": release_json, "sync": sync_json}).to_json() return await self._with_neo_client(_do) @@ -607,19 +596,18 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) logger.info("[Neo] POST /skills/neo/rollback requested.") data = await request.get_json() release_id = data.get("release_id") if not release_id: - return Response().error("Missing release_id").__dict__ + return Response().error("Missing release_id").to_json() async def _do(client): result = await client.skills.rollback_release(release_id) logger.info(f"[Neo] Release rolled back: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ + return Response().ok(_to_jsonable(result)).to_json() return await self._with_neo_client(_do) @@ -627,8 +615,7 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) logger.info("[Neo] POST /skills/neo/sync requested.") data = await request.get_json() @@ -636,7 +623,7 @@ class SkillsRoute(Route): skill_key = data.get("skill_key") require_stable = _to_bool(data.get("require_stable"), True) if not release_id and not skill_key: - return Response().error("Missing release_id or skill_key").__dict__ + return Response().error("Missing release_id or skill_key").to_json() async def _do(client): sync_mgr = NeoSkillSyncManager() @@ -662,8 +649,7 @@ class SkillsRoute(Route): "map_path": result.map_path, "synced_at": result.synced_at, } - ) - .__dict__ + ).to_json() ) return await self._with_neo_client(_do) @@ -672,20 +658,19 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) logger.info("[Neo] POST /skills/neo/delete-candidate requested.") data = await request.get_json() candidate_id = data.get("candidate_id") reason = data.get("reason") if not candidate_id: - return Response().error("Missing candidate_id").__dict__ + return Response().error("Missing candidate_id").to_json() async def _do(client): result = await self._delete_neo_candidate(client, candidate_id, reason) logger.info(f"[Neo] Candidate deleted: id={candidate_id}") - return Response().ok(_to_jsonable(result)).__dict__ + return Response().ok(_to_jsonable(result)).to_json() return await self._with_neo_client(_do) @@ -693,19 +678,18 @@ class SkillsRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) logger.info("[Neo] POST /skills/neo/delete-release requested.") data = await request.get_json() release_id = data.get("release_id") reason = data.get("reason") if not release_id: - return Response().error("Missing release_id").__dict__ + return Response().error("Missing release_id").to_json() async def _do(client): result = await self._delete_neo_release(client, release_id, reason) logger.info(f"[Neo] Release deleted: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ + return Response().ok(_to_jsonable(result)).to_json() return await self._with_neo_client(_do) diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index f5b302b25..9aae6f844 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -4,6 +4,7 @@ import re import threading import time import traceback +from dataclasses import asdict from functools import cmp_to_key from pathlib import Path @@ -66,12 +67,11 @@ class StatRoute(Route): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) await self.core_lifecycle.restart() - return Response().ok().__dict__ + return Response().ok().to_json() def _get_running_time_components(self, total_seconds: int): """将总秒数转换为时分秒组件""" @@ -94,8 +94,7 @@ class StatRoute(Route): "change_pwd_hint": self.is_default_cred(), "need_migration": need_migration, }, - ) - .__dict__ + ).to_json() ) async def get_start_time(self): @@ -104,19 +103,19 @@ class StatRoute(Route): self.core_lifecycle, include_failure_details=False, ) - return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__ + return Response().ok({"start_time": self.core_lifecycle.start_time}).to_json() async def get_runtime_status(self): - return Response().ok(build_runtime_status_data(self.core_lifecycle)).__dict__ + return Response().ok(build_runtime_status_data(self.core_lifecycle)).to_json() async def get_storage_status(self): try: status = await asyncio.to_thread(self.storage_cleaner.get_status) - return Response().ok(status).__dict__ + return Response().ok(status).to_json() except Exception: logger.error("获取存储占用失败", exc_info=True) return ( - Response().error("获取存储占用失败, 请查看后端日志了解详情。").__dict__ + Response().error("获取存储占用失败, 请查看后端日志了解详情。").to_json() ) async def cleanup_storage(self): @@ -127,12 +126,12 @@ class StatRoute(Route): target = str(data.get("target", "all")) result = await asyncio.to_thread(self.storage_cleaner.cleanup, target) - return Response().ok(result).__dict__ + return Response().ok(result).to_json() except ValueError as e: - return Response().error(str(e)).__dict__ + return Response().error(str(e)).to_json() except Exception: logger.error("清理存储失败", exc_info=True) - return Response().error("清理存储失败, 请查看后端日志了解详情。").__dict__ + return Response().error("清理存储失败, 请查看后端日志了解详情。").to_json() async def get_stat(self): if not is_runtime_request_ready(self.core_lifecycle): @@ -156,7 +155,7 @@ class StatRoute(Route): idx += 1 message_time_based_stats.append([bucket_end, cnt]) - stat_dict = stat.__dict__ + stat_dict = asdict(stat) cpu_percent = psutil.cpu_percent(interval=0.5) thread_count = threading.active_count() @@ -200,10 +199,10 @@ class StatRoute(Route): }, ) - return Response().ok(stat_dict).__dict__ + return Response().ok(stat_dict).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(e.__str__()).__dict__ + return Response().error(e.__str__()).to_json() async def test_ghproxy_connection(self): """测试 GitHub 代理连接是否可用。""" @@ -212,7 +211,7 @@ class StatRoute(Route): proxy_url: str = data.get("proxy_url") if not proxy_url: - return Response().error("proxy_url is required").__dict__ + return Response().error("proxy_url is required").to_json() proxy_url = proxy_url.rstrip("/") @@ -232,28 +231,28 @@ class StatRoute(Route): ret = { "latency": round((end_time - start_time) * 1000, 2), } - return Response().ok(data=ret).__dict__ + return Response().ok(data=ret).to_json() return ( - Response().error(f"Failed. Status code: {response.status}").__dict__ + Response().error(f"Failed. Status code: {response.status}").to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return Response().error(f"Error: {e!s}").to_json() async def get_changelog(self): """获取指定版本的更新日志""" try: version = request.args.get("version") if not version: - return Response().error("version parameter is required").__dict__ + return Response().error("version parameter is required").to_json() version = version.lstrip("v") # 防止路径遍历攻击 if not re.match(r"^[a-zA-Z0-9._-]+$", version): - return Response().error("Invalid version format").__dict__ + return Response().error("Invalid version format").to_json() if ".." in version or "/" in version or "\\" in version: - return Response().error("Invalid version format").__dict__ + return Response().error("Invalid version format").to_json() filename = f"v{version}.md" project_path = get_astrbot_path() @@ -267,28 +266,26 @@ class StatRoute(Route): logger.warning( f"Path traversal attempt detected: {version} -> {changelog_path}", ) - return Response().error("Invalid version format").__dict__ + return Response().error("Invalid version format").to_json() if not await anyio.Path(changelog_path).exists(): return ( Response() - .error(f"Changelog for version {version} not found") - .__dict__ + .error(f"Changelog for version {version} not found").to_json() ) if not await anyio.Path(changelog_path).is_file(): return ( Response() - .error(f"Changelog for version {version} not found") - .__dict__ + .error(f"Changelog for version {version} not found").to_json() ) async with await anyio.open_file(changelog_path, encoding="utf-8") as f: content = await f.read() - return Response().ok({"content": content, "version": version}).__dict__ + return Response().ok({"content": content, "version": version}).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return Response().error(f"Error: {e!s}").to_json() async def list_changelog_versions(self): """获取所有可用的更新日志版本列表""" @@ -297,7 +294,7 @@ class StatRoute(Route): changelogs_dir = os.path.join(project_path, "changelogs") if not await anyio.Path(changelogs_dir).exists(): - return Response().ok({"versions": []}).__dict__ + return Response().ok({"versions": []}).to_json() versions = [] for filename in os.listdir(changelogs_dir): @@ -316,10 +313,10 @@ class StatRoute(Route): ), ) - return Response().ok({"versions": versions}).__dict__ + return Response().ok({"versions": versions}).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return Response().error(f"Error: {e!s}").to_json() async def get_first_notice(self): """读取项目根目录 FIRST_NOTICE.md 内容。""" @@ -351,9 +348,9 @@ class StatRoute(Route): continue content = notice_path.read_text(encoding="utf-8") if content.strip(): - return Response().ok({"content": content}).__dict__ + return Response().ok({"content": content}).to_json() - return Response().ok({"content": None}).__dict__ + return Response().ok({"content": None}).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return Response().error(f"Error: {e!s}").to_json() diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index e3d77f73a..951402b17 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -59,16 +59,16 @@ class SubAgentRoute(Route): if isinstance(a, dict): a.setdefault("provider_id", None) a.setdefault("persona_id", None) - return jsonify(Response().ok(data=data).__dict__) + return jsonify(Response().ok(data=data).to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"获取 subagent 配置失败: {e!s}").__dict__) + return jsonify(Response().error(f"获取 subagent 配置失败: {e!s}").to_json()) async def update_config(self): try: data = await request.json if not isinstance(data, dict): - return jsonify(Response().error("配置必须为 JSON 对象").__dict__) + return jsonify(Response().error("配置必须为 JSON 对象").to_json()) cfg = self.core_lifecycle.astrbot_config cfg["subagent_orchestrator"] = data @@ -82,10 +82,10 @@ class SubAgentRoute(Route): if orch is not None: await orch.reload_from_config(data) - return jsonify(Response().ok(message="保存成功").__dict__) + return jsonify(Response().ok(message="保存成功").to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"保存 subagent 配置失败: {e!s}").__dict__) + return jsonify(Response().error(f"保存 subagent 配置失败: {e!s}").to_json()) async def get_available_tools(self): """Return all registered tools (name/description/parameters/active/origin). @@ -111,7 +111,7 @@ class SubAgentRoute(Route): "handler_module_path": tool.handler_module_path, } ) - return jsonify(Response().ok(data=tools_dict).__dict__) + return jsonify(Response().ok(data=tools_dict).to_json()) except Exception as e: logger.error(traceback.format_exc()) - return jsonify(Response().error(f"获取可用工具失败: {e!s}").__dict__) + return jsonify(Response().error(f"获取可用工具失败: {e!s}").to_json()) diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 97d8148d0..45dd6d54c 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -112,10 +112,10 @@ class ToolsRoute(Route): servers.append(server_info) - return Response().ok(servers).__dict__ + return Response().ok(servers).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to get MCP server list: {e!s}").__dict__ + return Response().error(f"Failed to get MCP server list: {e!s}").to_json() async def add_mcp_server(self): try: @@ -125,7 +125,7 @@ class ToolsRoute(Route): # 检查必填字段 if not name: - return Response().error("Server name cannot be empty").__dict__ + return Response().error("Server name cannot be empty").to_json() # 移除特殊字段并检查配置是否有效 has_valid_config = False @@ -140,7 +140,7 @@ class ToolsRoute(Route): server_data["mcpServers"] ) except ValueError as e: - return Response().error(f"{e!s}").__dict__ + return Response().error(f"{e!s}").to_json() else: server_config[key] = value has_valid_config = True @@ -148,20 +148,19 @@ class ToolsRoute(Route): if not has_valid_config: return ( Response() - .error("A valid server configuration is required") - .__dict__ + .error("A valid server configuration is required").to_json() ) config = self.tool_mgr.load_mcp_config() if name in config["mcpServers"]: - return Response().error(f"Server {name} already exists").__dict__ + return Response().error(f"Server {name} already exists").to_json() try: await self.tool_mgr.test_mcp_server_connection(server_config) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"MCP connection test failed: {e!s}").__dict__ + return Response().error(f"MCP connection test failed: {e!s}").to_json() config["mcpServers"][name] = server_config @@ -177,23 +176,22 @@ class ToolsRoute(Route): err_msg = f"Timed out while enabling MCP server {name}." if not rollback_ok: err_msg += " Configuration rollback failed. Please check the config manually." - return Response().error(err_msg).__dict__ + return Response().error(err_msg).to_json() except Exception as e: logger.error(traceback.format_exc()) rollback_ok = self._rollback_mcp_server(name) err_msg = f"Failed to enable MCP server {name}: {e!s}" if not rollback_ok: err_msg += " Configuration rollback failed. Please check the config manually." - return Response().error(err_msg).__dict__ + return Response().error(err_msg).to_json() return ( Response() - .ok(None, f"Successfully added MCP server {name}") - .__dict__ + .ok(None, f"Successfully added MCP server {name}").to_json() ) - return Response().error("Failed to save configuration").__dict__ + return Response().error("Failed to save configuration").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to add MCP server: {e!s}").__dict__ + return Response().error(f"Failed to add MCP server: {e!s}").to_json() async def update_mcp_server(self): try: @@ -203,17 +201,17 @@ class ToolsRoute(Route): old_name = server_data.get("oldName") or name if not name: - return Response().error("Server name cannot be empty").__dict__ + return Response().error("Server name cannot be empty").to_json() config = self.tool_mgr.load_mcp_config() if old_name not in config["mcpServers"]: - return Response().error(f"Server {old_name} does not exist").__dict__ + return Response().error(f"Server {old_name} does not exist").to_json() is_rename = name != old_name if name in config["mcpServers"] and is_rename: - return Response().error(f"Server {name} already exists").__dict__ + return Response().error(f"Server {name} already exists").to_json() # 获取活动状态 old_config = config["mcpServers"][old_name] @@ -244,7 +242,7 @@ class ToolsRoute(Route): server_data["mcpServers"] ) except ValueError as e: - return Response().error(f"{e!s}").__dict__ + return Response().error(f"{e!s}").to_json() else: server_config[key] = value only_update_active = False @@ -279,8 +277,7 @@ class ToolsRoute(Route): Response() .error( f"Timed out while disabling MCP server {old_name} before enabling: {e!s}" - ) - .__dict__ + ).to_json() ) except Exception as e: logger.error(traceback.format_exc()) @@ -288,8 +285,7 @@ class ToolsRoute(Route): Response() .error( f"Failed to disable MCP server {old_name} before enabling: {e!s}" - ) - .__dict__ + ).to_json() ) try: await self.tool_mgr.enable_mcp_server( @@ -300,15 +296,13 @@ class ToolsRoute(Route): except TimeoutError: return ( Response() - .error(f"Timed out while enabling MCP server {name}.") - .__dict__ + .error(f"Timed out while enabling MCP server {name}.").to_json() ) except Exception as e: logger.error(traceback.format_exc()) return ( Response() - .error(f"Failed to enable MCP server {name}: {e!s}") - .__dict__ + .error(f"Failed to enable MCP server {name}: {e!s}").to_json() ) # 如果要停用服务器 elif old_name in self.tool_mgr.mcp_server_runtime_view: @@ -317,26 +311,23 @@ class ToolsRoute(Route): except TimeoutError: return ( Response() - .error(f"Timed out while disabling MCP server {old_name}.") - .__dict__ + .error(f"Timed out while disabling MCP server {old_name}.").to_json() ) except Exception as e: logger.error(traceback.format_exc()) return ( Response() - .error(f"Failed to disable MCP server {old_name}: {e!s}") - .__dict__ + .error(f"Failed to disable MCP server {old_name}: {e!s}").to_json() ) return ( Response() - .ok(None, f"Successfully updated MCP server {name}") - .__dict__ + .ok(None, f"Successfully updated MCP server {name}").to_json() ) - return Response().error("Failed to save configuration").__dict__ + return Response().error("Failed to save configuration").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to update MCP server: {e!s}").__dict__ + return Response().error(f"Failed to update MCP server: {e!s}").to_json() async def delete_mcp_server(self): try: @@ -344,12 +335,12 @@ class ToolsRoute(Route): name = server_data.get("name", "") if not name: - return Response().error("Server name cannot be empty").__dict__ + return Response().error("Server name cannot be empty").to_json() config = self.tool_mgr.load_mcp_config() if name not in config["mcpServers"]: - return Response().error(f"Server {name} does not exist").__dict__ + return Response().error(f"Server {name} does not exist").to_json() del config["mcpServers"][name] @@ -360,25 +351,22 @@ class ToolsRoute(Route): except TimeoutError: return ( Response() - .error(f"Timed out while disabling MCP server {name}.") - .__dict__ + .error(f"Timed out while disabling MCP server {name}.").to_json() ) except Exception as e: logger.error(traceback.format_exc()) return ( Response() - .error(f"Failed to disable MCP server {name}: {e!s}") - .__dict__ + .error(f"Failed to disable MCP server {name}: {e!s}").to_json() ) return ( Response() - .ok(None, f"Successfully deleted MCP server {name}") - .__dict__ + .ok(None, f"Successfully deleted MCP server {name}").to_json() ) - return Response().error("Failed to save configuration").__dict__ + return Response().error("Failed to save configuration").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to delete MCP server: {e!s}").__dict__ + return Response().error(f"Failed to delete MCP server: {e!s}").to_json() async def test_mcp_connection(self): """Test MCP server connection.""" @@ -387,7 +375,7 @@ class ToolsRoute(Route): config = server_data.get("mcp_server_config", None) if not isinstance(config, dict) or not config: - return Response().error("Invalid MCP server configuration").__dict__ + return Response().error("Invalid MCP server configuration").to_json() if "mcpServers" in config: mcp_servers = config["mcpServers"] @@ -396,36 +384,32 @@ class ToolsRoute(Route): Response() .error( "Only one MCP server configuration can be tested at a time" - ) - .__dict__ + ).to_json() ) try: config = _extract_mcp_server_config(mcp_servers) except EmptyMcpServersError: return ( Response() - .error("MCP server configuration cannot be empty") - .__dict__ + .error("MCP server configuration cannot be empty").to_json() ) except ValueError as e: - return Response().error(f"{e!s}").__dict__ + return Response().error(f"{e!s}").to_json() elif not config: return ( Response() - .error("MCP server configuration cannot be empty") - .__dict__ + .error("MCP server configuration cannot be empty").to_json() ) tools_name = await self.tool_mgr.test_mcp_server_connection(config) return ( Response() - .ok(data=tools_name, message="🎉 MCP server is available!") - .__dict__ + .ok(data=tools_name, message="🎉 MCP server is available!").to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to test MCP connection: {e!s}").__dict__ + return Response().error(f"Failed to test MCP connection: {e!s}").to_json() async def get_tool_list(self): """Get all registered tools.""" @@ -462,10 +446,10 @@ class ToolsRoute(Route): "source": source, } tools_dict.append(tool_info) - return Response().ok(data=tools_dict).__dict__ + return Response().ok(data=tools_dict).to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to get tool list: {e!s}").__dict__ + return Response().error(f"Failed to get tool list: {e!s}").to_json() async def toggle_tool(self): """Activate or deactivate a specified tool.""" @@ -477,34 +461,32 @@ class ToolsRoute(Route): if not tool_name or action is None: return ( Response() - .error("Missing required parameters: name or activate") - .__dict__ + .error("Missing required parameters: name or activate").to_json() ) # Internal tools cannot be toggled by users for t in self.tool_mgr.func_list: if t.name == tool_name and getattr(t, "source", "") == "internal": - return Response().error("内置工具不支持手动启用/停用").__dict__ + return Response().error("内置工具不支持手动启用/停用").to_json() if action: try: ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map) except ValueError as e: - return Response().error(f"Failed to activate tool: {e!s}").__dict__ + return Response().error(f"Failed to activate tool: {e!s}").to_json() else: ok = self.tool_mgr.deactivate_llm_tool(tool_name) if ok: - return Response().ok(None, "Operation successful.").__dict__ + return Response().ok(None, "Operation successful.").to_json() return ( Response() - .error(f"Tool {tool_name} does not exist or the operation failed.") - .__dict__ + .error(f"Tool {tool_name} does not exist or the operation failed.").to_json() ) except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Failed to operate tool: {e!s}").__dict__ + return Response().error(f"Failed to operate tool: {e!s}").to_json() async def sync_provider(self): """Sync MCP provider configuration.""" @@ -517,10 +499,10 @@ class ToolsRoute(Route): await self.tool_mgr.sync_modelscope_mcp_servers(access_token) case _: return ( - Response().error(f"Unknown provider: {provider_name}").__dict__ + Response().error(f"Unknown provider: {provider_name}").to_json() ) - return Response().ok(message="Sync completed").__dict__ + return Response().ok(message="Sync completed").to_json() except Exception as e: logger.error(traceback.format_exc()) - return Response().error(f"Sync failed: {e!s}").__dict__ + return Response().error(f"Sync failed: {e!s}").to_json() diff --git a/astrbot/dashboard/routes/tui_chat.py b/astrbot/dashboard/routes/tui_chat.py index e1c84e65f..931f41aa1 100644 --- a/astrbot/dashboard/routes/tui_chat.py +++ b/astrbot/dashboard/routes/tui_chat.py @@ -95,7 +95,7 @@ class TUIChatRoute(Route): async def get_file(self): filename = request.args.get("filename") if not filename: - return Response().error("Missing key: filename").__dict__ + return Response().error("Missing key: filename").to_json() try: file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) @@ -103,12 +103,12 @@ class TUIChatRoute(Route): resolved_base_dir = _resolve_path(self.attachments_dir) if not await anyio.Path(resolved_file_path).exists(): - return Response().error("File not found").__dict__ + return Response().error("File not found").to_json() try: resolved_file_path.relative_to(resolved_base_dir) except ValueError: - return Response().error("Invalid file path").__dict__ + return Response().error("Invalid file path").to_json() filename_ext = os.path.splitext(filename)[1].lower() if filename_ext == ".wav": @@ -118,18 +118,18 @@ class TUIChatRoute(Route): return await send_file(str(resolved_file_path)) except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ + return Response().error("File access error").to_json() async def get_attachment(self): """Get attachment file by attachment_id.""" attachment_id = request.args.get("attachment_id") if not attachment_id: - return Response().error("Missing key: attachment_id").__dict__ + return Response().error("Missing key: attachment_id").to_json() try: attachment = await self.db.get_attachment_by_id(attachment_id) if not attachment: - return Response().error("Attachment not found").__dict__ + return Response().error("Attachment not found").to_json() file_path = attachment.path resolved_file_path = _resolve_path(file_path) @@ -139,13 +139,13 @@ class TUIChatRoute(Route): ) except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ + return Response().error("File access error").to_json() async def post_file(self): """Upload a file and create an attachment record, return attachment_id.""" post_data = await request.files if "file" not in post_data: - return Response().error("Missing key: file").__dict__ + return Response().error("Missing key: file").to_json() file = post_data["file"] filename = file.filename or f"{uuid.uuid4()!s}" @@ -170,7 +170,7 @@ class TUIChatRoute(Route): ) if not attachment: - return Response().error("Failed to create attachment").__dict__ + return Response().error("Failed to create attachment").to_json() filename = os.path.basename(attachment.path) @@ -182,8 +182,7 @@ class TUIChatRoute(Route): "filename": filename, "type": attach_type, } - ) - .__dict__ + ).to_json() ) async def _build_user_message_parts(self, message: str | list) -> list[dict]: @@ -243,13 +242,13 @@ class TUIChatRoute(Route): if post_data is None: post_data = await request.json if post_data is None: - return Response().error("Missing JSON body").__dict__ + return Response().error("Missing JSON body").to_json() if "message" not in post_data and "files" not in post_data: - return Response().error("Missing key: message or files").__dict__ + return Response().error("Missing key: message or files").to_json() if "session_id" not in post_data and "conversation_id" not in post_data: return ( - Response().error("Missing key: session_id or conversation_id").__dict__ + Response().error("Missing key: session_id or conversation_id").to_json() ) message = post_data["message"] @@ -259,7 +258,7 @@ class TUIChatRoute(Route): enable_streaming = post_data.get("enable_streaming", True) if not session_id: - return Response().error("session_id is empty").__dict__ + return Response().error("session_id is empty").to_json() tui_conv_id = session_id @@ -267,8 +266,7 @@ class TUIChatRoute(Route): if not webchat_message_parts_have_content(message_parts): return ( Response() - .error("Message content is empty (reply only is not allowed)") - .__dict__ + .error("Message content is empty (reply only is not allowed)").to_json() ) message_id = str(uuid.uuid4()) @@ -476,18 +474,18 @@ class TUIChatRoute(Route): """Stop active agent runs for a session.""" post_data = await request.json if post_data is None: - return Response().error("Missing JSON body").__dict__ + return Response().error("Missing JSON body").to_json() session_id = post_data.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() username = g.get("username", "guest") session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() message_type = ( MessageType.GROUP_MESSAGE.value @@ -500,7 +498,7 @@ class TUIChatRoute(Route): ) stopped_count = active_event_registry.request_agent_stop_all(umo) - return Response().ok(data={"stopped_count": stopped_count}).__dict__ + return Response().ok(data={"stopped_count": stopped_count}).to_json() async def _delete_session_internal(self, session, username: str) -> None: """Delete a single session and all its related data.""" @@ -543,30 +541,30 @@ class TUIChatRoute(Route): """Delete a Platform session and all its related data.""" session_id = request.args.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() username = g.get("username", "guest") session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self._delete_session_internal(session, username) - return Response().ok().__dict__ + return Response().ok().to_json() async def batch_delete_sessions(self): """Batch delete multiple Platform sessions.""" post_data = await request.json if post_data is None: - return Response().error("Missing JSON body").__dict__ + return Response().error("Missing JSON body").to_json() if not isinstance(post_data, dict): - return Response().error("Invalid JSON body: expected object").__dict__ + return Response().error("Invalid JSON body: expected object").to_json() session_ids = post_data.get("session_ids") if not session_ids or not isinstance(session_ids, list): - return Response().error("Missing or invalid key: session_ids").__dict__ + return Response().error("Missing or invalid key: session_ids").to_json() username = g.get("username", "guest") sessions = await self.db.get_platform_sessions_by_ids(session_ids) @@ -599,8 +597,7 @@ class TUIChatRoute(Route): "failed_count": len(failed_items), "failed_items": failed_items, } - ) - .__dict__ + ).to_json() ) def _extract_attachment_ids(self, history_list) -> list[str]: @@ -654,8 +651,7 @@ class TUIChatRoute(Route): "session_id": session.session_id, "platform_id": session.platform_id, } - ) - .__dict__ + ).to_json() ) async def get_sessions(self): @@ -688,13 +684,13 @@ class TUIChatRoute(Route): } ) - return Response().ok(data=sessions_data).__dict__ + return Response().ok(data=sessions_data).to_json() async def get_session(self): """Get session information and message history by session_id.""" session_id = request.args.get("session_id") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() session = await self.db.get_platform_session_by_id(session_id) platform_id = session.platform_id if session else "tui" @@ -725,7 +721,7 @@ class TUIChatRoute(Route): "emoji": project_info.emoji, } - return Response().ok(data=response_data).__dict__ + return Response().ok(data=response_data).to_json() async def update_session_display_name(self): """Update a Platform session's display name.""" @@ -735,21 +731,21 @@ class TUIChatRoute(Route): display_name = post_data.get("display_name") if not session_id: - return Response().error("Missing key: session_id").__dict__ + return Response().error("Missing key: session_id").to_json() if display_name is None: - return Response().error("Missing key: display_name").__dict__ + return Response().error("Missing key: display_name").to_json() username = g.get("username", "guest") session = await self.db.get_platform_session_by_id(session_id) if not session: - return Response().error(f"Session {session_id} not found").__dict__ + return Response().error(f"Session {session_id} not found").to_json() if session.creator != username: - return Response().error("Permission denied").__dict__ + return Response().error("Permission denied").to_json() await self.db.update_platform_session( session_id=session_id, display_name=display_name, ) - return Response().ok().__dict__ + return Response().ok().to_json() diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index a035423e1..f040c96fe 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -37,7 +37,7 @@ class UpdateRoute(Route): async def do_migration(self): need_migration = await check_migration_needed_v4(self.core_lifecycle.db) if not need_migration: - return Response().ok(None, "不需要进行迁移。").__dict__ + return Response().ok(None, "不需要进行迁移。").to_json() try: data = await request.json pim = data.get("platform_id_map", {}) @@ -46,10 +46,10 @@ class UpdateRoute(Route): pim, self.core_lifecycle.astrbot_config, ) - return Response().ok(None, "迁移成功。").__dict__ + return Response().ok(None, "迁移成功。").to_json() except Exception as e: logger.error(f"迁移失败: {traceback.format_exc()}") - return Response().error(f"迁移失败: {e!s}").__dict__ + return Response().error(f"迁移失败: {e!s}").to_json() async def check_update(self): type_ = request.args.get("type", None) @@ -59,8 +59,7 @@ class UpdateRoute(Route): if type_ == "dashboard": return ( Response() - .ok({"has_new_version": dv != f"v{VERSION}", "current_version": dv}) - .__dict__ + .ok({"has_new_version": dv != f"v{VERSION}", "current_version": dv}).to_json() ) ret = await self.astrbot_updator.check_update(None, None, False) return Response( @@ -72,18 +71,18 @@ class UpdateRoute(Route): "dashboard_version": dv, "dashboard_has_new_version": bool(dv and dv != f"v{VERSION}"), }, - ).__dict__ + ).to_json() except Exception as e: logger.warning(f"检查更新失败: {e!s} (不影响除项目更新外的正常使用)") - return Response().error(e.__str__()).__dict__ + return Response().error(e.__str__()).to_json() async def get_releases(self): try: ret = await self.astrbot_updator.get_releases() - return Response().ok(ret).__dict__ + return Response().ok(ret).to_json() except Exception as e: logger.error(f"/api/update/releases: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return Response().error(e.__str__()).to_json() async def update_project(self): data = await request.json @@ -122,19 +121,17 @@ class UpdateRoute(Route): await self.core_lifecycle.restart() ret = ( Response() - .ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。") - .__dict__ + .ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。").to_json() ) return ret, 200, CLEAR_SITE_DATA_HEADERS ret = ( Response() - .ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。") - .__dict__ + .ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。").to_json() ) return ret, 200, CLEAR_SITE_DATA_HEADERS except Exception as e: logger.error(f"/api/update_project: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return Response().error(e.__str__()).to_json() async def update_dashboard(self): try: @@ -142,29 +139,28 @@ class UpdateRoute(Route): await download_dashboard(version=f"v{VERSION}", latest=False) except Exception as e: logger.error(f"下载管理面板文件失败: {e}。") - return Response().error(f"下载管理面板文件失败: {e}").__dict__ - ret = Response().ok(None, "更新成功。刷新页面即可应用新版本面板。").__dict__ + return Response().error(f"下载管理面板文件失败: {e}").to_json() + ret = Response().ok(None, "更新成功。刷新页面即可应用新版本面板。").to_json() return ret, 200, CLEAR_SITE_DATA_HEADERS except Exception as e: logger.error(f"/api/update_dashboard: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return Response().error(e.__str__()).to_json() async def install_pip_package(self): if DEMO_MODE: return ( Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ + .error("You are not permitted to do this operation in demo mode").to_json() ) data = await request.json package = data.get("package", "") mirror = data.get("mirror", None) if not package: - return Response().error("缺少参数 package 或不合法。").__dict__ + return Response().error("缺少参数 package 或不合法。").to_json() try: await pip_installer.install(package, mirror=mirror) - return Response().ok(None, "安装成功。").__dict__ + return Response().ok(None, "安装成功。").to_json() except Exception as e: logger.error(f"/api/update_pip: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return Response().error(e.__str__()).to_json() diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 8a4688b59..997fa1a76 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -418,7 +418,7 @@ class AstrBotDashboard: if request.path.startswith("/api/v1"): raw_key = self._extract_raw_api_key() if not raw_key: - r = jsonify(Response().error("Missing API key").__dict__) + r = jsonify(Response().error("Missing API key").to_json()) r.status_code = 401 return r key_hash = hashlib.pbkdf2_hmac( @@ -429,7 +429,7 @@ class AstrBotDashboard: ).hex() api_key = await self.db.get_active_api_key_by_hash(key_hash) if not api_key: - r = jsonify(Response().error("Invalid API key").__dict__) + r = jsonify(Response().error("Invalid API key").to_json()) r.status_code = 401 return r @@ -439,7 +439,7 @@ class AstrBotDashboard: scopes = list(ALL_OPEN_API_SCOPES) required_scope = self._get_required_open_api_scope(request.path) if required_scope and "*" not in scopes and required_scope not in scopes: - r = jsonify(Response().error("Insufficient API key scope").__dict__) + r = jsonify(Response().error("Insufficient API key scope").to_json()) r.status_code = 403 return r diff --git a/dashboard/index.html b/dashboard/index.html index 771f72aff..8961a40cd 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -11,6 +11,8 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap" /> + + diff --git a/dashboard/public/fonts/materialdesignicons-webfont.eot b/dashboard/public/fonts/materialdesignicons-webfont.eot new file mode 100644 index 0000000000000000000000000000000000000000..3eb89692a5b3c19020b8d5bc3f2e293dd476338b GIT binary patch literal 1307880 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 z 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