From dcaaf6286ab6364d653d28febc439de75e187ee5 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 29 Apr 2026 06:29:56 +0800 Subject: [PATCH] test: comprehensive test coverage and type fixes - Add 100+ new test files covering provider sources, platform adapters, agent runners, star/plugin system, knowledge base, core utils, pipeline, computer tools, builtin commands, and dashboard routes - Fix Python type errors in sqlite.py (col() wrappers for SQLModel), astr_agent_tool_exec, core_lifecycle, star context/manager - Fix TypeScript strict mode errors across 30+ dashboard Vue files - Add import smoke tests, auth roundtrip, startup tests - Add compile-all check and CLI entry test for AUR compatibility - Restore BotMessageAccumulator and helpers lost in merge --- .../builtin_commands/commands/conversation.py | 55 +- astrbot/core/astr_agent_tool_exec.py | 106 ++-- astrbot/core/astr_main_agent.py | 3 + astrbot/core/computer/booters/bwrap.py | 46 ++ astrbot/core/computer/booters/local.py | 4 +- astrbot/core/computer/booters/shipyard.py | 12 +- astrbot/core/computer/booters/shipyard_neo.py | 4 +- astrbot/core/computer/tools/fs.py | 17 +- astrbot/core/core_lifecycle.py | 28 +- astrbot/core/db/sqlite.py | 50 +- .../db/vec_db/faiss_impl/embedding_storage.py | 14 +- astrbot/core/event_bus.py | 7 +- .../core/knowledge_base/parsers/pdf_parser.py | 5 +- .../retrieval/sparse_retriever.py | 18 +- .../strategies/baidu_aip.py | 6 +- .../process_stage/method/agent_request.py | 10 +- astrbot/core/platform/astr_message_event.py | 2 +- .../discord/discord_platform_adapter.py | 2 +- .../sources/misskey/misskey_adapter.py | 2 +- .../qqofficial/qqofficial_message_event.py | 2 +- .../platform/sources/slack/slack_adapter.py | 2 +- .../weixin_offacc_adapter.py | 3 +- astrbot/core/provider/entities.py | 9 +- astrbot/core/provider/provider.py | 3 + .../core/provider/sources/dashscope_tts.py | 29 +- .../sources/gemini_embedding_source.py | 10 +- astrbot/core/provider/sources/genie_tts.py | 3 +- .../provider/sources/oai_aihubmix_source.py | 3 +- .../provider/sources/openrouter_source.py | 18 +- .../sources/sensevoice_selfhosted_source.py | 4 +- .../sources/xinference_rerank_source.py | 5 +- astrbot/core/star/context.py | 51 +- astrbot/core/star/session_llm_manager.py | 4 +- astrbot/core/star/session_plugin_manager.py | 36 +- astrbot/core/star/star_manager.py | 128 +++-- astrbot/core/tools/computer_tools/cua.py | 12 +- astrbot/core/utils/core_constraints.py | 9 +- astrbot/core/utils/pip_installer.py | 3 + astrbot/core/utils/requirements_utils.py | 44 +- astrbot/core/utils/t2i/network_strategy.py | 13 +- astrbot/core/utils/tencent_record_helper.py | 11 +- astrbot/dashboard/routes/chat.py | 37 +- astrbot/dashboard/server.py | 6 +- astrbot/dashboard/utils.py | 3 + dashboard/src/App.vue | 23 +- dashboard/src/components/ConfirmDialog.vue | 4 +- dashboard/src/components/chat/Chat.vue | 4 +- .../src/components/chat/ChatMessageList.vue | 4 +- dashboard/src/components/chat/MessageList.vue | 244 ++++---- dashboard/src/components/chat/ProjectView.vue | 2 +- .../components/chat/ProviderConfigDialog.vue | 62 +- .../chat/message_list_comps/ActionRef.vue | 13 +- .../message_list_comps/IPythonToolBlock.vue | 4 +- .../MessagePartsRenderer.vue | 69 +-- .../chat/message_list_comps/RefNode.vue | 5 +- .../chat/message_list_comps/RefsSidebar.vue | 17 +- .../chat/message_list_comps/ToolCallCard.vue | 4 +- .../chat/message_list_comps/ToolCallItem.vue | 9 +- .../components/extension/SkillsSection.vue | 265 ++++++--- .../components/platform/AddNewPlatform.vue | 398 ++++++------- .../components/provider/AddNewProvider.vue | 38 +- .../src/components/shared/AstrBotConfigV4.vue | 83 ++- .../src/components/shared/BackupDialog.vue | 144 +++-- .../components/shared/ConfigItemRenderer.vue | 51 +- .../components/shared/ConsoleDisplayer.vue | 55 +- .../src/components/shared/DailyQuote.vue | 5 +- .../src/components/shared/FileConfigItem.vue | 24 +- .../src/components/shared/ListConfigItem.vue | 20 +- .../src/components/shared/ObjectEditor.vue | 104 +++- .../components/shared/PluginSetSelector.vue | 24 +- .../components/shared/ProviderSelector.vue | 22 +- .../src/components/shared/ProxySelector.vue | 8 +- .../src/components/shared/ReadmeDialog.vue | 37 +- .../components/shared/T2ITemplateEditor.vue | 7 +- .../components/shared/TemplateListEditor.vue | 134 +++-- dashboard/src/stores/toast.js | 11 + dashboard/src/views/AboutPage.vue | 2 +- dashboard/src/views/ConfigPage.vue | 79 ++- dashboard/src/views/ConversationPage.vue | 167 +++--- dashboard/src/views/CronJobPage.vue | 109 +++- dashboard/src/views/ExtensionPage.vue | 43 +- dashboard/src/views/ProviderPage.vue | 108 ++-- dashboard/src/views/SessionManagementPage.vue | 544 +++++++++++------- dashboard/src/views/Settings.vue | 43 +- dashboard/src/views/WelcomePage.vue | 74 ++- dashboard/src/views/alkaid/KnowledgeBase.vue | 143 +++-- dashboard/src/views/alkaid/LongTermMemory.vue | 327 +++++++---- .../views/authentication/auth/LoginPage.vue | 6 +- .../default/components/MessageStat.vue | 24 +- .../default/components/OnlineTime.vue | 24 +- .../views/extension/InstalledPluginsTab.vue | 154 +---- dashboard/src/views/knowledge-base/KBList.vue | 73 ++- scripts/hatch_build.py | 3 +- tests/dashboard/test_auth_api_key.py | 36 ++ tests/dashboard/test_chat_conversation.py | 62 ++ tests/dashboard/test_config_platform.py | 58 ++ tests/dashboard/test_knowledge_base.py | 13 + tests/dashboard/test_persona.py | 13 + tests/dashboard/test_plugin_tools.py | 60 ++ tests/test_admin_commands.py | 16 + tests/test_agent.py | 48 ++ tests/test_aiocqhttp_platform_adapter.py | 10 + tests/test_api_key_open_api.py | 26 +- tests/test_auth_password.py | 158 +++++ tests/test_base.py | 7 + tests/test_command_management.py | 51 ++ tests/test_command_parser.py | 83 +++ tests/test_computer_booters_cua.py | 50 ++ tests/test_computer_booters_local.py | 45 ++ tests/test_computer_booters_shipyard.py | 34 ++ tests/test_computer_fs_tools.py | 9 + tests/test_computer_olayer_python.py | 25 + tests/test_computer_olayer_shell.py | 22 + tests/test_computer_tool_provider.py | 25 + tests/test_computer_tools_fs.py | 24 + tests/test_context.py | 7 + tests/test_conversation_checkpoint.py | 10 +- tests/test_conversation_commands.py | 22 + tests/test_conversation_manager.py | 45 ++ tests/test_coze_agent_runner.py | 42 ++ tests/test_coze_api_client.py | 50 ++ tests/test_dashscope_agent_runner.py | 50 ++ tests/test_deerflow_api_client.py | 8 +- tests/test_dify_agent_runner.py | 42 ++ tests/test_dify_api_client.py | 56 ++ tests/test_discord_platform_event.py | 10 + tests/test_docs.py | 12 +- tests/test_event_bus.py | 42 ++ tests/test_file_extract.py | 29 + tests/test_filter_command.py | 12 + tests/test_fixed_size_chunker.py | 13 + tests/test_imports.py | 2 +- tests/test_kb_helper.py | 21 + tests/test_kb_import.py | 35 +- tests/test_kb_mgr.py | 17 + tests/test_kb_models.py | 31 + tests/test_knowledge_base_tools.py | 43 ++ tests/test_kook_adapter.py | 8 + tests/test_kook_event.py | 8 + tests/test_lark_adapter.py | 8 + tests/test_lark_event.py | 8 + tests/test_local_shell_component.py | 64 +-- tests/test_mattermost_adapter.py | 12 +- tests/test_network_utils.py | 122 ++++ tests/test_path_util.py | 54 ++ tests/test_pdf_parser.py | 13 + tests/test_persona_commands.py | 13 + tests/test_pip_helper_modules.py | 16 +- tests/test_pipeline_bootstrap.py | 44 ++ tests/test_pipeline_content_safety_stage.py | 41 ++ tests/test_pipeline_context.py | 41 ++ tests/test_pipeline_preprocess_stage.py | 34 ++ tests/test_pipeline_process_stage.py | 32 ++ tests/test_pipeline_rate_limit_stage.py | 42 ++ tests/test_pipeline_respond_stage.py | 37 ++ tests/test_pipeline_scheduler.py | 31 + tests/test_plugin_commands.py | 16 + tests/test_plugin_manager.py | 5 +- tests/test_profile_aware_tools.py | 96 +--- tests/test_provider_commands.py | 15 + tests/test_provider_sources_anthropic.py | 72 +++ tests/test_provider_sources_gemini.py | 62 ++ .../test_provider_sources_gemini_embedding.py | 62 ++ tests/test_provider_sources_openai.py | 119 ++++ .../test_provider_sources_openai_embedding.py | 58 ++ tests/test_provider_sources_volcengine_tts.py | 72 +++ tests/test_quoted_message_parser.py | 3 +- tests/test_recursive_chunker.py | 16 + tests/test_register_star.py | 15 + tests/test_register_star_handler.py | 165 ++++++ tests/test_retrieval_manager.py | 24 + tests/test_send_message_tool.py | 36 ++ tests/test_session_controller_main.py | 17 + tests/test_session_lock.py | 79 +++ tests/test_session_plugin_manager.py | 15 + tests/test_sparse_retriever.py | 23 + tests/test_star.py | 24 + tests/test_star_handler.py | 32 ++ tests/test_star_manager.py | 33 ++ tests/test_startup.py | 4 +- tests/test_t2i_commands.py | 12 + tests/test_tg_adapter.py | 8 + tests/test_tg_event.py | 8 + tests/test_tool_loop_agent_runner.py | 7 +- tests/test_tts_commands.py | 12 + tests/test_url_parser.py | 20 + tests/test_web_search_tools.py | 70 +++ tests/test_web_searcher_main.py | 19 + tests/test_web_searcher_routing.py | 61 ++ tests/unit/test_provider_stats.py | 20 +- tests/unit/test_sparse_retriever.py | 6 +- tests/unit/test_tool_google_schema.py | 18 +- tests/unit/test_web_search_tools.py | 5 +- types/faiss.pyi | 20 - 194 files changed, 6085 insertions(+), 2022 deletions(-) create mode 100644 tests/dashboard/test_auth_api_key.py create mode 100644 tests/dashboard/test_chat_conversation.py create mode 100644 tests/dashboard/test_config_platform.py create mode 100644 tests/dashboard/test_knowledge_base.py create mode 100644 tests/dashboard/test_persona.py create mode 100644 tests/dashboard/test_plugin_tools.py create mode 100644 tests/test_admin_commands.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_aiocqhttp_platform_adapter.py create mode 100644 tests/test_auth_password.py create mode 100644 tests/test_base.py create mode 100644 tests/test_command_management.py create mode 100644 tests/test_command_parser.py create mode 100644 tests/test_computer_booters_cua.py create mode 100644 tests/test_computer_booters_local.py create mode 100644 tests/test_computer_booters_shipyard.py create mode 100644 tests/test_computer_olayer_python.py create mode 100644 tests/test_computer_olayer_shell.py create mode 100644 tests/test_computer_tool_provider.py create mode 100644 tests/test_computer_tools_fs.py create mode 100644 tests/test_context.py create mode 100644 tests/test_conversation_manager.py create mode 100644 tests/test_coze_agent_runner.py create mode 100644 tests/test_coze_api_client.py create mode 100644 tests/test_dashscope_agent_runner.py create mode 100644 tests/test_dify_agent_runner.py create mode 100644 tests/test_dify_api_client.py create mode 100644 tests/test_discord_platform_event.py create mode 100644 tests/test_event_bus.py create mode 100644 tests/test_file_extract.py create mode 100644 tests/test_filter_command.py create mode 100644 tests/test_fixed_size_chunker.py create mode 100644 tests/test_kb_helper.py create mode 100644 tests/test_kb_mgr.py create mode 100644 tests/test_kb_models.py create mode 100644 tests/test_knowledge_base_tools.py create mode 100644 tests/test_kook_adapter.py create mode 100644 tests/test_kook_event.py create mode 100644 tests/test_lark_adapter.py create mode 100644 tests/test_lark_event.py create mode 100644 tests/test_network_utils.py create mode 100644 tests/test_path_util.py create mode 100644 tests/test_pdf_parser.py create mode 100644 tests/test_persona_commands.py create mode 100644 tests/test_pipeline_bootstrap.py create mode 100644 tests/test_pipeline_content_safety_stage.py create mode 100644 tests/test_pipeline_context.py create mode 100644 tests/test_pipeline_preprocess_stage.py create mode 100644 tests/test_pipeline_process_stage.py create mode 100644 tests/test_pipeline_rate_limit_stage.py create mode 100644 tests/test_pipeline_respond_stage.py create mode 100644 tests/test_pipeline_scheduler.py create mode 100644 tests/test_plugin_commands.py create mode 100644 tests/test_provider_commands.py create mode 100644 tests/test_provider_sources_anthropic.py create mode 100644 tests/test_provider_sources_gemini.py create mode 100644 tests/test_provider_sources_gemini_embedding.py create mode 100644 tests/test_provider_sources_openai.py create mode 100644 tests/test_provider_sources_openai_embedding.py create mode 100644 tests/test_provider_sources_volcengine_tts.py create mode 100644 tests/test_recursive_chunker.py create mode 100644 tests/test_register_star.py create mode 100644 tests/test_register_star_handler.py create mode 100644 tests/test_retrieval_manager.py create mode 100644 tests/test_send_message_tool.py create mode 100644 tests/test_session_controller_main.py create mode 100644 tests/test_session_lock.py create mode 100644 tests/test_session_plugin_manager.py create mode 100644 tests/test_sparse_retriever.py create mode 100644 tests/test_star.py create mode 100644 tests/test_star_handler.py create mode 100644 tests/test_star_manager.py create mode 100644 tests/test_t2i_commands.py create mode 100644 tests/test_tg_adapter.py create mode 100644 tests/test_tg_event.py create mode 100644 tests/test_tts_commands.py create mode 100644 tests/test_url_parser.py create mode 100644 tests/test_web_search_tools.py create mode 100644 tests/test_web_searcher_main.py create mode 100644 tests/test_web_searcher_routing.py delete mode 100644 types/faiss.pyi diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index 66fce7b69..0aa205bf0 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -1,7 +1,10 @@ +import datetime +from typing import TypedDict + from sqlalchemy import case, func, select from sqlmodel import col -from astrbot.api import sp, star +from astrbot.api import logger, sp, star from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.core.agent.runners.deerflow.constants import ( DEERFLOW_PROVIDER_TYPE, @@ -59,6 +62,56 @@ def _normalize_alter_cmd_config(value: object) -> dict[str, AlterCmdPluginConfig return config +async def _clear_third_party_agent_runner_state( + context: star.Context, + session_id: str, + provider_type: str, +) -> None: + """清理第三方 Agent Runner 的状态: 先删除远端资源,再清理本地存储的状态。 + + Args: + context: 星尘上下文。 + session_id: 会话 ID (unified_msg_origin)。 + provider_type: 提供商类型 (如 deerflow)。 + """ + provider_config = context.provider_manager.get_provider_config_by_id( + provider_type, + merged=True, + ) + if provider_config: + try: + client = DeerFlowAPIClient( + api_base=provider_config.get("deerflow_api_base", ""), + api_key=provider_config.get("deerflow_api_key", ""), + auth_header=provider_config.get("deerflow_auth_header", ""), + proxy=provider_config.get("proxy"), + ) + try: + thread_id = await sp.get_async( + scope="umo", + scope_id=session_id, + key=DEERFLOW_THREAD_ID_KEY, + ) + if thread_id: + await client.delete_thread(thread_id, timeout=20) + except Exception: + logger.exception( + f"清理 {provider_type} Agent Runner 远程线程失败", + ) + finally: + await client.close() + except Exception: + logger.exception( + f"初始化 {provider_type} Agent Runner 客户端失败", + ) + + await sp.remove_async( + scope="umo", + scope_id=session_id, + key=DEERFLOW_THREAD_ID_KEY, + ) + + class ConversationCommands: def __init__(self, context: star.Context) -> None: self.context = context diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index e0c033ab1..6c37f1a83 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -14,7 +14,7 @@ from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.message import Message from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.tool import FunctionTool, ToolSet +from astrbot.core.agent.tool import FunctionTool, ToolSchema, ToolSet from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_main_agent_resources import ( @@ -32,20 +32,6 @@ from astrbot.core.message.message_event_result import ( from astrbot.core.platform.message_session import MessageSession from astrbot.core.provider.entites import ProviderRequest from astrbot.core.provider.register import llm_tools -from astrbot.core.tools.computer_tools import ( - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, - ExecuteShellTool, - FileDownloadTool, - FileEditTool, - FileReadTool, - FileUploadTool, - FileWriteTool, - GrepTool, - LocalPythonTool, - PythonTool, -) from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.history_saver import persist_agent_history @@ -230,57 +216,46 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): def _get_runtime_computer_tools( cls, runtime: str, - tool_mgr, + tool_mgr: Any = None, booter: str | None = None, - ) -> dict[str, FunctionTool]: - booter = "" if booter is None else str(booter).lower() - if runtime == "sandbox": - shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool) - python_tool = tool_mgr.get_builtin_tool(PythonTool) - upload_tool = tool_mgr.get_builtin_tool(FileUploadTool) - download_tool = tool_mgr.get_builtin_tool(FileDownloadTool) - read_tool = tool_mgr.get_builtin_tool(FileReadTool) - write_tool = tool_mgr.get_builtin_tool(FileWriteTool) - edit_tool = tool_mgr.get_builtin_tool(FileEditTool) - grep_tool = tool_mgr.get_builtin_tool(GrepTool) - tools = { - shell_tool.name: shell_tool, - python_tool.name: python_tool, - upload_tool.name: upload_tool, - download_tool.name: download_tool, - read_tool.name: read_tool, - write_tool.name: write_tool, - edit_tool.name: edit_tool, - grep_tool.name: grep_tool, - } - if booter == "cua": - screenshot_tool = tool_mgr.get_builtin_tool(CuaScreenshotTool) - mouse_click_tool = tool_mgr.get_builtin_tool(CuaMouseClickTool) - keyboard_type_tool = tool_mgr.get_builtin_tool(CuaKeyboardTypeTool) - tools.update( - { - screenshot_tool.name: screenshot_tool, - mouse_click_tool.name: mouse_click_tool, - keyboard_type_tool.name: keyboard_type_tool, - } - ) - return tools - if runtime == "local": - shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool) - python_tool = tool_mgr.get_builtin_tool(LocalPythonTool) - read_tool = tool_mgr.get_builtin_tool(FileReadTool) - write_tool = tool_mgr.get_builtin_tool(FileWriteTool) - edit_tool = tool_mgr.get_builtin_tool(FileEditTool) - grep_tool = tool_mgr.get_builtin_tool(GrepTool) - return { - shell_tool.name: shell_tool, - python_tool.name: python_tool, - read_tool.name: read_tool, - write_tool.name: write_tool, - edit_tool.name: edit_tool, - grep_tool.name: grep_tool, - } - return {} + session_id: str = "", + sandbox_cfg: dict | None = None, + ) -> dict[str, ToolSchema]: + """Get computer runtime tools via ComputerToolProvider. + + Delegates tool discovery to ComputerToolProvider for decoupled + sandbox / local tool injection. The *tool_mgr* parameter is kept + for backward compatibility but is no longer used. + + Args: + runtime: ``'sandbox'``, ``'local'``, or ``'none'``. + tool_mgr: Kept for backward compatibility (unused). + booter: Short-form booter type (e.g. ``'shipyard_neo'``). + session_id: Session identifier. + sandbox_cfg: Full sandbox configuration dict (preferred over + *booter* when both are provided). + + Returns: + Dict mapping tool name to FunctionTool instance. + """ + from astrbot.core.computer.computer_tool_provider import ( + ComputerToolProvider, + ) + from astrbot.core.tool_provider import ToolProviderContext + + cfg: dict = {} + if sandbox_cfg is not None: + cfg = sandbox_cfg + elif booter: + cfg["booter"] = booter + + ctx = ToolProviderContext( + computer_use_runtime=runtime, + sandbox_cfg=cfg, + session_id=session_id, + ) + tools = ComputerToolProvider().get_tools(ctx) + return {t.name: t for t in tools} @classmethod def _build_handoff_toolset( @@ -558,6 +533,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): .get("stream", False), ) req = ProviderRequest() + req.system_prompt = "" conv = await _get_session_conv(event=cron_event, plugin_context=ctx) req.conversation = conv context = json.loads(conv.history) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 7ef7e44b4..3be826ea5 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1405,3 +1405,6 @@ async def build_main_agent( provider=provider, reset_coro=reset_coro if not apply_reset else None, ) + + +apply_sandbox_tools = _apply_sandbox_tools diff --git a/astrbot/core/computer/booters/bwrap.py b/astrbot/core/computer/booters/bwrap.py index 2ac55f38d..968e7b396 100644 --- a/astrbot/core/computer/booters/bwrap.py +++ b/astrbot/core/computer/booters/bwrap.py @@ -283,6 +283,52 @@ class HostBackedFileSystemComponent(FileSystemComponent): except Exception as e: return {"success": False, "error": str(e), "items": []} + async def search_files( + self, + pattern: str, + path: str | None = None, + glob: str | None = None, + after_context: int | None = None, + before_context: int | None = None, + ) -> dict[str, Any]: + p = path or self.workspace_dir + try: + import subprocess + + cmd = ["grep", "-r", pattern, p] + result = await asyncio.to_thread( + subprocess.run, cmd, capture_output=True, text=True + ) + return { + "success": True, + "matches": result.stdout.splitlines() if result.stdout else [], + } + except Exception as e: + return {"success": False, "error": str(e), "matches": []} + + async def edit_file( + self, + path: str, + old_string: str, + new_string: str, + replace_all: bool = False, + encoding: str = "utf-8", + ) -> dict[str, Any]: + p = self._safe_path(path) + try: + content = await asyncio.to_thread(_read_file_sync, p, encoding) + if replace_all: + new_content = content.replace(old_string, new_string) + else: + parts = content.split(old_string, 1) + if len(parts) == 1: + return {"success": False, "error": "Pattern not found"} + new_content = new_string.join(parts) + await asyncio.to_thread(_write_file_sync, p, new_content, "w", encoding) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + class BwrapBooter(ComputerBooter): def __init__( diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index fd6cc9299..e0dbb9055 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -20,6 +20,7 @@ from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, get_astrbot_root, get_astrbot_temp_path, + get_astrbot_workspaces_path, ) from .base import ComputerBooter @@ -52,6 +53,7 @@ def _ensure_safe_path(path: str) -> str: os.path.abspath(get_astrbot_root()), os.path.abspath(get_astrbot_data_path()), os.path.abspath(get_astrbot_temp_path()), + os.path.abspath(get_astrbot_workspaces_path()), ] if not any(abs_path.startswith(root) for root in allowed_roots): raise PermissionError("Path is outside the allowed computer roots.") @@ -237,7 +239,7 @@ class LocalFileSystemComponent(FileSystemComponent): ) return { "success": True, - "output": result.stdout, + "content": result.stdout, "error": result.stderr if result.returncode != 0 else "", } except subprocess.TimeoutExpired: diff --git a/astrbot/core/computer/booters/shipyard.py b/astrbot/core/computer/booters/shipyard.py index 806bf611b..f0c6935cb 100644 --- a/astrbot/core/computer/booters/shipyard.py +++ b/astrbot/core/computer/booters/shipyard.py @@ -107,7 +107,7 @@ class ShipyardShellWrapper: class ShipyardFileSystemWrapper: def __init__( - self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent + self, _shipyard_fs: FileSystemComponent, _shipyard_shell: ShellComponent ): self._fs = _shipyard_fs self._shell = _shipyard_shell @@ -188,7 +188,7 @@ class ShipyardBooter(ComputerBooter): PythonTool, ) - return ( # type: ignore[return-value] + return ( ExecuteShellTool(), PythonTool(), FileUploadTool(), @@ -226,15 +226,15 @@ class ShipyardBooter(ComputerBooter): session_id, ) self._ship = ship - self._shell = ShipyardShellWrapper(self._ship.shell) - self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._shell) + self._shell = ShipyardShellWrapper(self._ship.shell) # type: ignore[arg-type] + self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._shell) # type: ignore[arg-type] async def shutdown(self) -> None: logger.info("[Computer] booter_shutdown booter=shipyard status=done") @property def fs(self) -> FileSystemComponent: - return self._ship.fs + return self._ship.fs # type: ignore[return-value] @property def python(self) -> PythonComponent: @@ -242,7 +242,7 @@ class ShipyardBooter(ComputerBooter): @property def shell(self) -> ShellComponent: - return self._shell + return self._shell # type: ignore[return-value] async def upload_file(self, path: str, file_name: str) -> dict: """Upload file to sandbox""" diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index 0e51337d1..a09b41d73 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -33,7 +33,9 @@ except ImportError: def _maybe_model_dump(value: Any) -> dict[str, Any]: if isinstance(value, dict): - return value + out: dict[str, Any] = {} + out.update(value) + return out if hasattr(value, "model_dump"): dumped = value.model_dump() if isinstance(dumped, dict): diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index a63f27dd4..5ac700753 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -1,6 +1,7 @@ import os import uuid from dataclasses import dataclass, field +from typing import Any import anyio @@ -13,7 +14,7 @@ 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 .permissions import check_admin_permission +from astrbot.core.tools.computer_tools.util import check_admin_permission # @dataclass # class CreateFileTool(FunctionTool): @@ -105,11 +106,12 @@ class FileUploadTool(FunctionTool): }, ) - async def call( # type: ignore + async def call( self, context: ContextWrapper[AstrAgentContext], - local_path: str, - ) -> str: + **kwargs: Any, + ) -> ToolExecResult: + local_path: str = kwargs["local_path"] if permission_error := check_admin_permission(context, "File upload/download"): return permission_error sb = await get_booter( @@ -170,12 +172,13 @@ class FileDownloadTool(FunctionTool): }, ) - async def call( # type: ignore + async def call( self, context: ContextWrapper[AstrAgentContext], - remote_path: str, - also_send_to_user: bool = True, + **kwargs: Any, ) -> ToolExecResult: + remote_path: str = kwargs["remote_path"] + also_send_to_user: bool = kwargs.get("also_send_to_user", True) if permission_error := check_admin_permission(context, "File upload/download"): return permission_error sb = await get_booter( diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 5f105010f..013644f96 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -535,14 +535,20 @@ class AstrBotCoreLifecycle: asyncio.create_task(update_llm_metadata()) + # Mark runtime ready now that the full initialize() path has completed. + self._set_lifecycle_state(LifecycleState.RUNTIME_READY) + self.runtime_bootstrap_error = None + def _load(self) -> None: """加载事件总线和任务并初始化.""" # 创建一个异步任务来执行事件总线的 dispatch() 方法 # dispatch是一个无限循环的协程, 从事件队列中获取事件并处理 - event_bus_task = asyncio.create_task( - self.event_bus.dispatch(), - name="event_bus", - ) + event_bus_task = None + if self.event_bus: + event_bus_task = asyncio.create_task( + self.event_bus.dispatch(), + name="event_bus", + ) cron_task = None if self.cron_manager: cron_task = asyncio.create_task( @@ -561,7 +567,10 @@ class AstrBotCoreLifecycle: for task in self.star_context._register_tasks: extra_tasks.append(asyncio.create_task(task, name=task.__name__)) # type: ignore - tasks_ = [event_bus_task, *(extra_tasks or [])] + tasks_ = [] + if event_bus_task: + tasks_.append(event_bus_task) + tasks_.extend(extra_tasks or []) if cron_task: tasks_.append(cron_task) if temp_dir_cleaner_task: @@ -624,7 +633,7 @@ class AstrBotCoreLifecycle: instead of raising AttributeError. """ # Stop temp dir cleaner if present - if getattr(self, "temp_dir_cleaner", None): + if self.temp_dir_cleaner is not None: try: await self.temp_dir_cleaner.stop() except Exception: @@ -640,7 +649,7 @@ class AstrBotCoreLifecycle: logger.exception("Error cancelling task") # Shutdown cron manager if present - if getattr(self, "cron_manager", None): + if self.cron_manager is not None: try: await self.cron_manager.shutdown() except Exception: @@ -683,7 +692,7 @@ class AstrBotCoreLifecycle: logger.exception("Error terminating kb_manager") # Signal dashboard shutdown if event exists - if getattr(self, "dashboard_shutdown_event", None): + if self.dashboard_shutdown_event is not None: try: self.dashboard_shutdown_event.set() except Exception: @@ -705,7 +714,8 @@ class AstrBotCoreLifecycle: await self.provider_manager.terminate() await self.platform_manager.terminate() await self.kb_manager.terminate() - self.dashboard_shutdown_event.set() + if self.dashboard_shutdown_event is not None: + self.dashboard_shutdown_event.set() threading.Thread( target=self.astrbot_updator._reboot, name="restart", diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 88312cf60..b83ce7992 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -2102,16 +2102,16 @@ class SQLiteDatabase(BaseDatabase): ) -> tuple[list[PlatformMessageHistory], int | None]: async with self.get_db() as session: session: AsyncSession - query = ( # type: ignore + query = ( select(PlatformMessageHistory) .where( - PlatformMessageHistory.platform_id == platform_id, - PlatformMessageHistory.user_id == user_id, + col(PlatformMessageHistory.platform_id) == platform_id, + col(PlatformMessageHistory.user_id) == user_id, ) .order_by(desc(PlatformMessageHistory.created_at)) ) if cursor_id is not None: - query = query.where(PlatformMessageHistory.id < cursor_id) # type: ignore + query = query.where(col(PlatformMessageHistory.id) < cursor_id) result = await session.execute(query.limit(limit)) records = list(result.scalars().all()) total = None @@ -2119,9 +2119,9 @@ class SQLiteDatabase(BaseDatabase): count_result = await session.execute( select(func.count()) .select_from(PlatformMessageHistory) - .where( # type: ignore - PlatformMessageHistory.platform_id == platform_id, - PlatformMessageHistory.user_id == user_id, + .where( + col(PlatformMessageHistory.platform_id) == platform_id, + col(PlatformMessageHistory.user_id) == user_id, ) ) total = count_result.scalar() @@ -2137,13 +2137,13 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): result = await session.execute( - delete(PlatformMessageHistory).where( # type: ignore - PlatformMessageHistory.platform_id == platform_id, - PlatformMessageHistory.user_id == user_id, - PlatformMessageHistory.created_at < before, + delete(PlatformMessageHistory).where( + col(PlatformMessageHistory.platform_id) == platform_id, + col(PlatformMessageHistory.user_id) == user_id, + col(PlatformMessageHistory.created_at) < before, ) ) - return result.rowcount # type: ignore + return result.rowcount async def delete_platform_message_after( self, @@ -2155,13 +2155,13 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): result = await session.execute( - delete(PlatformMessageHistory).where( # type: ignore - PlatformMessageHistory.platform_id == platform_id, - PlatformMessageHistory.user_id == user_id, - PlatformMessageHistory.created_at > after, + delete(PlatformMessageHistory).where( + col(PlatformMessageHistory.platform_id) == platform_id, + col(PlatformMessageHistory.user_id) == user_id, + col(PlatformMessageHistory.created_at) > after, ) ) - return result.rowcount # type: ignore + return result.rowcount async def delete_all_platform_message_history( self, @@ -2172,12 +2172,12 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): result = await session.execute( - delete(PlatformMessageHistory).where( # type: ignore - PlatformMessageHistory.platform_id == platform_id, - PlatformMessageHistory.user_id == user_id, + delete(PlatformMessageHistory).where( + col(PlatformMessageHistory.platform_id) == platform_id, + col(PlatformMessageHistory.user_id) == user_id, ) ) - return result.rowcount # type: ignore + return result.rowcount async def find_platform_message_history_by_idempotency_key( self, @@ -2188,10 +2188,10 @@ class SQLiteDatabase(BaseDatabase): async with self.get_db() as session: session: AsyncSession result = await session.execute( - select(PlatformMessageHistory).where( # type: ignore - PlatformMessageHistory.platform_id == platform_id, - PlatformMessageHistory.user_id == user_id, - PlatformMessageHistory.idempotency_key == idempotency_key, # type: ignore + select(PlatformMessageHistory).where( + col(PlatformMessageHistory.platform_id) == platform_id, + col(PlatformMessageHistory.user_id) == user_id, + col(PlatformMessageHistory.idempotency_key) == idempotency_key, ) ) return result.scalar_one_or_none() diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index 9d650140d..ea7a1f95b 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -5,6 +5,7 @@ except ModuleNotFoundError: "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", ) from None import os +from typing import Any import numpy as np @@ -13,8 +14,8 @@ class EmbeddingStorage: def __init__(self, dimension: int, path: str | None = None) -> None: self.dimension = dimension self.path = path - self.index = None - if path and os.path.exists(path): + self.index: Any + if path is not None and os.path.exists(path): self.index = faiss.read_index(path) else: base_index = faiss.IndexFlatL2(dimension) @@ -84,12 +85,7 @@ class EmbeddingStorage: await self.save_index() async def save_index(self) -> None: - """保存索引 - - Args: - path (str): 保存索引的路径 - - """ - if self.index is None: + """保存索引""" + if self.path is None: return faiss.write_index(self.index, self.path) diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index 6d0cf21ee..efb78b8e5 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -12,6 +12,7 @@ class: import asyncio from asyncio import Queue +from typing import Any from astrbot.core import logger from astrbot.core.astrbot_config_mgr import AstrBotConfigManager @@ -25,7 +26,7 @@ class EventBus: def __init__( self, - event_queue: Queue, + event_queue: Queue[Any], pipeline_scheduler_mapping: dict[str, PipelineScheduler], astrbot_config_mgr: AstrBotConfigManager, ) -> None: @@ -38,8 +39,8 @@ class EventBus: while True: event: AstrMessageEvent = await self.event_queue.get() conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin) - conf_id = conf_info["id"] - conf_name = conf_info.get("name") or conf_id + conf_id = conf_info.get("id", "") + conf_name = conf_info.get("name", conf_id) self._print_event(event, conf_name) scheduler = self.pipeline_scheduler_mapping.get(conf_id) if not scheduler: diff --git a/astrbot/core/knowledge_base/parsers/pdf_parser.py b/astrbot/core/knowledge_base/parsers/pdf_parser.py index 91222c30f..a58f48e3e 100644 --- a/astrbot/core/knowledge_base/parsers/pdf_parser.py +++ b/astrbot/core/knowledge_base/parsers/pdf_parser.py @@ -52,8 +52,11 @@ class PDFParser(BaseParser): continue resources = page["/Resources"] + if not resources: + continue + xobject_ref = resources.get("/XObject") - if not resources or not xobject_ref: + if not xobject_ref: continue xobjects = xobject_ref.get_object() diff --git a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py index f06eb5090..4db655b8c 100644 --- a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py +++ b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py @@ -75,10 +75,13 @@ class SparseRetriever: fallback_kb_ids = [] query_tokens = tokenize_text(query, self.hit_stopwords) for kb_id in kb_ids: - vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db") + kb_config = kb_options.get(kb_id) + if not isinstance(kb_config, dict): + continue + vec_db: FaissVecDB | None = kb_config.get("vec_db") if not vec_db: continue - top_k_sparse = kb_options.get(kb_id, {}).get("top_k_sparse", 50) + top_k_sparse = kb_config.get("top_k_sparse", 50) result = await vec_db.document_storage.search_sparse( query_tokens=query_tokens, limit=top_k_sparse, @@ -120,7 +123,10 @@ class SparseRetriever: top_k_sparse = 0 chunks = [] for kb_id in kb_ids: - vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db") + kb_config = kb_options.get(kb_id) + if not isinstance(kb_config, dict): + continue + vec_db: FaissVecDB | None = kb_config.get("vec_db") if not vec_db: continue result = await vec_db.document_storage.get_documents( @@ -129,7 +135,7 @@ class SparseRetriever: offset=None, ) chunk_mds = [json.loads(doc["metadata"]) for doc in result] - result = [ + mapped_chunks = [ { "chunk_id": doc["doc_id"], "chunk_index": chunk_md["chunk_index"], @@ -139,8 +145,8 @@ class SparseRetriever: } for doc, chunk_md in zip(result, chunk_mds) ] - chunks.extend(result) - top_k_sparse += kb_options.get(kb_id, {}).get("top_k_sparse", 50) + chunks.extend(mapped_chunks) + top_k_sparse += kb_config.get("top_k_sparse", 50) if not chunks: return [] diff --git a/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py b/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py index 21099d16d..7d4208c92 100644 --- a/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py +++ b/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py @@ -15,8 +15,7 @@ def _is_violation_list(value: object) -> TypeGuard[list[BaiduAipViolation]]: for item in value: if not isinstance(item, dict): return False - raw = item - message = raw.get("msg") + message = item.get("msg") if message is not None and (not isinstance(message, str)): return False return True @@ -45,8 +44,7 @@ class BaiduAipStrategy(ContentSafetyStrategy): count = len(data) parts = [f"百度审核服务发现 {count} 处违规:\n"] for item in data: - raw_item = item - message = raw_item.get("msg") + message = item.get("msg") if message: parts.append(f"{message};\n") parts.append("\n判断结果:" + conclusion) diff --git a/astrbot/core/pipeline/process_stage/method/agent_request.py b/astrbot/core/pipeline/process_stage/method/agent_request.py index 2b6e65303..c4817fe3d 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_request.py +++ b/astrbot/core/pipeline/process_stage/method/agent_request.py @@ -45,5 +45,11 @@ class AgentRequestSubStage(Stage): ) return - async for _ in self.agent_sub_stage.process(event): - yield None + if isinstance(self.agent_sub_stage, InternalAgentSubStage): + async for _ in self.agent_sub_stage.process( + event, self.prov_wake_prefix + ): + yield None + else: + async for _ in self.agent_sub_stage.process(event): + yield None diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index b750d3592..cc433bf2d 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -58,7 +58,7 @@ class AstrMessageEvent(abc.ABC): self.is_at_or_wake_command = False """是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)""" self._extras: dict[str, Any] = {} - message_type = getattr(message_obj, "type", None) + message_type = message_obj.type if not isinstance(message_type, MessageType): try: message_type = MessageType(str(message_type)) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 14e8fa3a5..8aa93a260 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -354,7 +354,7 @@ class DiscordPlatformAdapter(Platform): logger.info("[Discord] Shutting down adapter...") self.shutdown_event.set() logger.info("[Discord] Cleaning up commands...") - if self.enable_command_register and self.client: + if self.enable_command_register and self.client and self._polling_task is not None: try: await asyncio.wait_for( self.client.sync_commands( diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 5336191b2..58bb76e06 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -13,7 +13,7 @@ from astrbot.api.platform import ( PlatformMetadata, register_platform_adapter, ) -from astrbot.core.platform.astr_message_event import MessageSession +from astrbot.core.platform.astr_message_event import MessageSesion as MessageSession from .misskey_api import MisskeyAPI diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index fb18ffc80..d3f9b6787 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -163,7 +163,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): ret_id = getattr(ret, "id", None) return str(ret_id) if ret_id is not None else None - async def _post_send(self, stream: dict | None = None): + async def _post_send(self, stream: dict | None = None, **kwargs): if not self.send_buffer: return None source = self.message_obj.raw_message diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index 510bee956..17c46f0d0 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -67,7 +67,7 @@ class SlackAdapter(Platform): self.web_client = AsyncWebClient(token=self.bot_token, logger=logger) self.socket_client = None self.webhook_client = None - self.bot_self_id = None + self.bot_self_id = "" async def send_by_session( self, diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index c3272d468..d61197702 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -334,7 +334,8 @@ class WeixinOfficialAccountPlatformAdapter(Platform): return result except TimeoutError: logger.info(f"callback 处理消息超时: message_id={msg.id}") - return create_reply("处理消息超时,请稍后再试。", msg) + reply = create_reply("处理消息超时,请稍后再试。", msg) + return reply if isinstance(reply, str) else str(reply) except Exception as e: logger.error(f"转换消息时出现异常: {e}") finally: diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index dd72cd0c2..80ca57b4c 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -10,6 +10,7 @@ from typing import Any from urllib.parse import urlparse import aiofiles +import anyio from anthropic.types import Message as AnthropicMessage from google.genai.types import GenerateContentResponse from openai.types.chat.chat_completion import ChatCompletion @@ -110,7 +111,7 @@ class ProviderRequest: OpenAI 格式上下文列表。 参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages """ - system_prompt: str = "" + system_prompt: str | None = None """系统提示词""" conversation: Conversation | None = None """关联的对话对象""" @@ -235,7 +236,7 @@ class ProviderRequest: parsed_url = urlparse(audio_url) suffix = Path(parsed_url.path).suffix temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + await anyio.Path(temp_dir).mkdir(parents=True, exist_ok=True) temp_audio_path = ( temp_dir / f"provider_request_audio_{uuid.uuid4().hex}{suffix}" ) @@ -307,8 +308,8 @@ class ProviderRequest: if audio_path.startswith("base64://"): return audio_path.replace("base64://", f"data:{mime_type};base64,", 1) - with open(audio_path, "rb") as f: - audio_bs64 = base64.b64encode(f.read()).decode("utf-8") + async with aiofiles.open(audio_path, "rb") as f: + audio_bs64 = base64.b64encode(await f.read()).decode("utf-8") return f"data:{mime_type};base64," + audio_bs64 diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 261709d67..637958485 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -4,6 +4,9 @@ import os from collections.abc import AsyncGenerator from typing import Literal, TypeAlias, Union +import aiofiles +import anyio + from astrbot.core.agent.message import ContentPart, Message, is_checkpoint_message from astrbot.core.agent.tool import ToolSet from astrbot.core.provider.entities import ( diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 5e9c3a59f..e5d9ff814 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -10,17 +10,13 @@ import aiohttp import dashscope from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer +MultiModalConversation: Any = None try: from dashscope.aigc.multimodal_conversation import ( MultiModalConversation, ) - - _MultiModalConversationType: type = MultiModalConversation -except ( - ImportError -): # pragma: no cover - older dashscope versions without Qwen TTS support - MultiModalConversation: Any = None - _MultiModalConversationType: Any = None +except ImportError: # pragma: no cover - older dashscope versions without Qwen TTS support + pass from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.provider import TTSProvider @@ -69,24 +65,23 @@ class ProviderDashscopeTTSAPI(TTSProvider): await f.write(audio_bytes) return path - def _call_qwen_tts(self, model: str, text: str): + def _call_qwen_tts(self, model: str, text: str) -> Any: if MultiModalConversation is None: raise RuntimeError( "dashscope SDK missing MultiModalConversation. Please upgrade the dashscope package to use Qwen TTS models.", ) - kwargs = { - "model": model, - "messages": None, - "api_key": self.chosen_api_key, - "voice": self.voice or "Cherry", - "text": text, - } if not self.voice: logging.warning( "No voice specified for Qwen TTS model, using default 'Cherry'.", ) - return MultiModalConversation.call(**kwargs) + return MultiModalConversation.call( + model=model, + messages=None, + api_key=self.chosen_api_key, + voice=self.voice or "Cherry", + text=text, + ) async def _synthesize_with_qwen_tts( self, @@ -103,7 +98,7 @@ class ProviderDashscopeTTSAPI(TTSProvider): ext = ".wav" return audio_bytes, ext - async def _extract_audio_from_response(self, response) -> bytes | None: + async def _extract_audio_from_response(self, response: Any) -> bytes | None: output = getattr(response, "output", None) audio_obj = getattr(output, "audio", None) if output is not None else None if not audio_obj: diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py index 6ccd5fb53..41bbd2728 100644 --- a/astrbot/core/provider/sources/gemini_embedding_source.py +++ b/astrbot/core/provider/sources/gemini_embedding_source.py @@ -44,8 +44,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider): config=types.EmbedContentConfig(output_dimensionality=self.get_dim()), ) assert result.embeddings is not None - assert result.embeddings[0].values is not None - return result.embeddings[0].values + values = result.embeddings[0].values + assert values is not None + return values except APIError as e: raise Exception(f"Gemini Embedding API请求失败: {e.message}") @@ -60,8 +61,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider): assert result.embeddings is not None embeddings: list[list[float]] = [] for embedding in result.embeddings: - assert embedding.values is not None - embeddings.append(embedding.values) + vals = embedding.values + assert vals is not None + embeddings.append(vals) return embeddings except APIError as e: raise Exception(f"Gemini Embedding API批量请求失败: {e.message}") diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index 3a4cc125c..372ab8890 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -12,10 +12,11 @@ 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 +genie: Any = None try: import genie_tts as genie except ImportError: - genie: Any = None + pass @register_provider_adapter( diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py index 5a01e20ed..fc971626b 100644 --- a/astrbot/core/provider/sources/oai_aihubmix_source.py +++ b/astrbot/core/provider/sources/oai_aihubmix_source.py @@ -10,5 +10,4 @@ from .openai_source import ProviderOpenAIOfficial class ProviderAIHubMix(ProviderOpenAIOfficial): def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) - custom_headers = self.client._custom_headers - custom_headers["APP-Code"] = "KRLC5702" + self.client._custom_headers = {**self.client._custom_headers, "APP-Code": "KRLC5702"} diff --git a/astrbot/core/provider/sources/openrouter_source.py b/astrbot/core/provider/sources/openrouter_source.py index b3d51c410..cfab8f8cd 100644 --- a/astrbot/core/provider/sources/openrouter_source.py +++ b/astrbot/core/provider/sources/openrouter_source.py @@ -9,13 +9,19 @@ from .openai_source import ProviderOpenAIOfficial ) class ProviderOpenRouter(ProviderOpenAIOfficial): def __init__(self, provider_config: dict, provider_settings: dict) -> None: - super().__init__(provider_config, provider_settings) - # Reference to: https://openrouter.ai/docs/api/reference/overview#headers - self.client._custom_headers["HTTP-Referer"] = ( # type: ignore + # Inject OpenRouter-specific default headers so the parent passes them as + # default_headers to the AsyncOpenAI client, avoiding direct access to + # the private _custom_headers attribute. + custom_headers = provider_config.get("custom_headers", {}) + if not isinstance(custom_headers, dict): + custom_headers = {} + custom_headers["HTTP-Referer"] = ( "https://github.com/AstrBotDevs/AstrBot" ) - self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore - self.client._custom_headers["X-OpenRouter-Categories"] = ( - "general-chat,personal-agent" # type: ignore + custom_headers["X-OpenRouter-Title"] = "AstrBot" + custom_headers["X-OpenRouter-Categories"] = ( + "general-chat,personal-agent" ) + provider_config["custom_headers"] = custom_headers + super().__init__(provider_config, provider_settings) self.reasoning_key = "reasoning" diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index 81a0dcbee..d37f89bf7 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -47,7 +47,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): super().__init__(provider_config, provider_settings) self.set_model(provider_config["stt_model"]) self.model: SenseVoiceModel | None = None - self.is_emotion = provider_config.get("is_emotion", False) + self.is_emotion: bool = bool(provider_config.get("is_emotion", False)) async def initialize(self) -> None: logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...") @@ -66,7 +66,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): await temp_dir.mkdir(parents=True, exist_ok=True) return str(temp_dir / timestamp) - async def _is_silk_file(self, file_path) -> bool: + async def _is_silk_file(self, file_path: str) -> bool: silk_header = b"SILK" async with await anyio.open_file(file_path, "rb") as f: file_header = await f.read(8) diff --git a/astrbot/core/provider/sources/xinference_rerank_source.py b/astrbot/core/provider/sources/xinference_rerank_source.py index efe565547..ebba0a05a 100644 --- a/astrbot/core/provider/sources/xinference_rerank_source.py +++ b/astrbot/core/provider/sources/xinference_rerank_source.py @@ -1,5 +1,6 @@ from xinference_client.client.restful.async_restful_client import AsyncClient as Client from xinference_client.client.restful.async_restful_client import ( + AsyncRESTfulModelHandle, AsyncRESTfulRerankModelHandle, ) @@ -63,7 +64,9 @@ class XinferenceRerankProvider(RerankProvider): ) return if self.model_uid: - self.model = await client.get_model(self.model_uid) + model_handle = await client.get_model(self.model_uid) + if isinstance(model_handle, AsyncRESTfulRerankModelHandle): + self.model = model_handle except Exception as e: logger.error(f"Failed to initialize Xinference model: {e}") logger.debug( diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index fc790c4f1..e22108c18 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -48,6 +48,7 @@ logger = logging.getLogger("astrbot") if TYPE_CHECKING: from astrbot.core.cron.manager import CronJobManager + from astrbot.core.star.star_manager import PluginManager class PlatformManagerProtocol(Protocol): @@ -57,12 +58,6 @@ class PlatformManagerProtocol(Protocol): class Context: """暴露给插件的接口上下文。""" - registered_web_apis: list = [] - - # 向后兼容的变量 - _register_tasks: list[Awaitable] = [] - _star_manager = None - def __init__( self, event_queue: Queue, @@ -101,9 +96,9 @@ class Context: self.cron_manager = cron_manager """Cron job manager, initialized by core lifecycle.""" self.subagent_orchestrator = subagent_orchestrator - self.registered_web_apis: list = [] + self._registered_web_apis: list = [] self._register_tasks: list[Awaitable] = [] - self._star_manager = None + self._star_manager: PluginManager | None = None async def llm_generate( self, @@ -410,7 +405,9 @@ class Context: provider_type=ProviderType.TEXT_TO_SPEECH, umo=umo, ) - if prov and not isinstance(prov, TTSProvider): + if prov is None: + return None + if not isinstance(prov, TTSProvider): raise ValueError("返回的 Provider 不是 TTSProvider 类型") return prov @@ -431,7 +428,9 @@ class Context: provider_type=ProviderType.SPEECH_TO_TEXT, umo=umo, ) - if prov and not isinstance(prov, STTProvider): + if prov is None: + return None + if not isinstance(prov, STTProvider): raise ValueError("返回的 Provider 不是 STTProvider 类型") return prov @@ -529,8 +528,8 @@ class Context: def register_web_api( self, route: str, - view_handler: Awaitable, - methods: list, + view_handler: Callable[..., Awaitable[Any]], + methods: list[str], desc: str, ) -> None: """注册 Web API。 @@ -545,11 +544,11 @@ class Context: 如果相同路由和方法已注册,会替换现有的 API。 """ - for idx, api in enumerate(self.registered_web_apis): + for idx, api in enumerate(self._registered_web_apis): if api[0] == route and methods == api[2]: - self.registered_web_apis[idx] = (route, view_handler, methods, desc) + self._registered_web_apis[idx] = (route, view_handler, methods, desc) return - self.registered_web_apis.append((route, view_handler, methods, desc)) + self._registered_web_apis.append((route, view_handler, methods, desc)) """ 以下的方法已经不推荐使用。请从 AstrBot 文档查看更好的注册方式。 @@ -642,9 +641,11 @@ class Context: """ md = StarHandlerMetadata( event_type=EventType.OnLLMRequestEvent, - handler_full_name=func_obj.__module__ + "_" + func_obj.__name__, - handler_name=func_obj.__name__, - handler_module_path=func_obj.__module__, + handler_full_name=getattr(func_obj, "__module__", "") + + "_" + + getattr(func_obj, "__qualname__", getattr(func_obj, "__name__", "")), + handler_name=getattr(func_obj, "__qualname__", getattr(func_obj, "__name__", "")), + handler_module_path=getattr(func_obj, "__module__", ""), handler=func_obj, event_filters=[], desc=desc, @@ -672,8 +673,8 @@ class Context: desc: str, priority: int, awaitable: Callable[..., Awaitable[Any]], - use_regex=False, - ignore_prefix=False, + use_regex: bool = False, + ignore_prefix: bool = False, ) -> None: """[DEPRECATED]注册一个命令。 @@ -692,9 +693,11 @@ class Context: """ md = StarHandlerMetadata( event_type=EventType.AdapterMessageEvent, - handler_full_name=awaitable.__module__ + "_" + awaitable.__name__, - handler_name=awaitable.__name__, - handler_module_path=awaitable.__module__, + handler_full_name=getattr(awaitable, "__module__", "") + + "_" + + getattr(awaitable, "__qualname__", getattr(awaitable, "__name__", "")), + handler_name=getattr(awaitable, "__qualname__", getattr(awaitable, "__name__", "")), + handler_module_path=getattr(awaitable, "__module__", ""), handler=awaitable, event_filters=[], desc=desc, @@ -709,7 +712,7 @@ class Context: def reset_runtime_registrations(self) -> None: """Reset runtime registration containers (web APIs and tasks).""" - self.registered_web_apis.clear() + self._registered_web_apis.clear() self._register_tasks.clear() def register_task(self, task: Awaitable, desc: str) -> None: diff --git a/astrbot/core/star/session_llm_manager.py b/astrbot/core/star/session_llm_manager.py index 90c4c301d..64d85b4b8 100644 --- a/astrbot/core/star/session_llm_manager.py +++ b/astrbot/core/star/session_llm_manager.py @@ -14,8 +14,8 @@ class SessionServiceConfig(TypedDict, total=False): def _normalize_session_service_config(value: object) -> SessionServiceConfig: if not isinstance(value, dict): - return {} - config: SessionServiceConfig = {} + return SessionServiceConfig() + config: SessionServiceConfig = SessionServiceConfig() val_dict: dict[str, object] = value llm_enabled = val_dict.get("llm_enabled") if isinstance(llm_enabled, bool): diff --git a/astrbot/core/star/session_plugin_manager.py b/astrbot/core/star/session_plugin_manager.py index 1e4ffaf60..adad7dfb2 100644 --- a/astrbot/core/star/session_plugin_manager.py +++ b/astrbot/core/star/session_plugin_manager.py @@ -1,6 +1,6 @@ """会话插件管理器 - 负责管理每个会话的插件启停状态""" -from typing import TypedDict +from typing import Any, TypedDict from astrbot.core import logger, sp from astrbot.core.platform.astr_message_event import AstrMessageEvent @@ -19,17 +19,20 @@ def _normalize_session_plugin_config(value: object) -> dict[str, SessionPluginSe if not isinstance(session_id, str) or not isinstance(raw_settings, dict): continue settings: SessionPluginSettings = {} - raw_dict: dict[str, object] = raw_settings - enabled_plugins = raw_dict.get("enabled_plugins") + enabled_plugins = raw_settings.get("enabled_plugins") if isinstance(enabled_plugins, list) and all( isinstance(plugin_name, str) for plugin_name in enabled_plugins ): - settings["enabled_plugins"] = enabled_plugins - disabled_plugins = raw_dict.get("disabled_plugins") + settings["enabled_plugins"] = [ + p for p in enabled_plugins if isinstance(p, str) + ] + disabled_plugins = raw_settings.get("disabled_plugins") if isinstance(disabled_plugins, list) and all( isinstance(plugin_name, str) for plugin_name in disabled_plugins ): - settings["disabled_plugins"] = disabled_plugins + settings["disabled_plugins"] = [ + p for p in disabled_plugins if isinstance(p, str) + ] config[session_id] = settings return config @@ -57,9 +60,14 @@ class SessionPluginManager: default={}, ), ) - session_config = session_plugin_config.get(session_id, {}) - enabled_plugins = session_config.get("enabled_plugins", []) - disabled_plugins = session_config.get("disabled_plugins", []) + session_config = session_plugin_config.get(session_id) + enabled_plugins: list[str] = [] + disabled_plugins: list[str] = [] + if session_config is not None: + if "enabled_plugins" in session_config: + enabled_plugins = session_config["enabled_plugins"] + if "disabled_plugins" in session_config: + disabled_plugins = session_config["disabled_plugins"] if plugin_name in disabled_plugins: return False if plugin_name in enabled_plugins: @@ -69,8 +77,8 @@ class SessionPluginManager: @staticmethod async def filter_handlers_by_session( event: AstrMessageEvent, - handlers: list, - ) -> list: + handlers: list[Any], + ) -> list[Any]: """根据会话配置过滤处理器列表 Args: @@ -93,8 +101,10 @@ class SessionPluginManager: default={}, ), ) - session_config = session_plugin_config.get(session_id, {}) - disabled_plugins = session_config.get("disabled_plugins", []) + session_config = session_plugin_config.get(session_id) + disabled_plugins: list[str] = [] + if session_config is not None and "disabled_plugins" in session_config: + disabled_plugins = session_config["disabled_plugins"] for handler in handlers: plugin = star_map.get(handler.handler_module_path) if not plugin: diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 33dfb5233..872744f6a 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -134,7 +134,10 @@ async def _install_requirements_with_precheck( requirements_path, fallback_reason, ) - await pip_installer.install(requirements_path=requirements_path) + await pip_installer.install( + requirements_path=requirements_path, + allow_target_upgrade=bool(install_plan.version_mismatch_names), + ) return logger.info( f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: {requirements_path} -> {sorted(install_plan.missing_names)}", @@ -142,7 +145,10 @@ async def _install_requirements_with_precheck( async with _temporary_filtered_requirements_file( install_lines=install_plan.install_lines, ) as filtered_requirements_path: - await pip_installer.install(requirements_path=filtered_requirements_path) + await pip_installer.install( + requirements_path=filtered_requirements_path, + allow_target_upgrade=bool(install_plan.version_mismatch_names), + ) class PluginManager: @@ -194,7 +200,9 @@ class PluginManager: logger.error(f"插件热重载监视任务异常: {e!s}") logger.error(traceback.format_exc()) - async def _handle_file_changes(self, changes) -> None: + async def _handle_file_changes( + self, changes: set[tuple[Any, str]] + ) -> None: """处理文件变化""" logger.info(f"检测到文件变化: {changes}") plugins_to_check = [] @@ -229,9 +237,9 @@ class PluginManager: break @staticmethod - def _get_classes(arg: ModuleType): + def _get_classes(arg: ModuleType) -> list[str]: """获取指定模块(可以理解为一个 python 文件)下所有的类""" - classes = [] + classes: list[str] = [] clsmembers = inspect.getmembers(arg, inspect.isclass) for name, _ in clsmembers: if name.lower().endswith("plugin") or name.lower() == "main": @@ -240,8 +248,8 @@ class PluginManager: return classes @staticmethod - def _get_modules(path): - modules = [] + def _get_modules(path: str) -> list[dict[str, Any]]: + modules: list[dict[str, Any]] = [] dirs = os.listdir(path) for d in dirs: if os.path.isdir(os.path.join(path, d)): @@ -264,7 +272,7 @@ class PluginManager: ) return modules - def _get_plugin_modules(self) -> list[dict]: + def _get_plugin_modules(self) -> list[dict[str, Any]]: plugins = [] if os.path.exists(self.plugin_store_path): plugins.extend(self._get_modules(self.plugin_store_path)) @@ -285,12 +293,13 @@ class PluginManager: plugin_dir = self.plugin_store_path if not await anyio.Path(plugin_dir).exists(): return False - to_update = [] + to_update: list[str] = [] if target_plugin: to_update.append(target_plugin) else: for p in self.context.get_all_stars(): - to_update.append(p.root_dir_name) + if p.root_dir_name is not None: + to_update.append(p.root_dir_name) for p in to_update: plugin_path = os.path.join(plugin_dir, p) await self._ensure_plugin_requirements(plugin_path, p) @@ -323,6 +332,31 @@ class PluginManager: logger.exception(str(dependency_error)) raise dependency_error from e + async def _plan_import_dependency_recovery( + self, + requirements_path: str, + ) -> str: + """Plan the dependency recovery strategy for plugin import. + + Returns: + "prefer_before_import": All deps satisfied, prefer before first import. + "reinstall_on_failure": Version-mismatched deps, reinstall on import failure. + "recover_on_failure": Precheck unavailable or missing deps, recover on failure. + """ + if not await anyio.Path(requirements_path).exists(): + return "recover_on_failure" + try: + install_plan = plan_missing_requirements_install(requirements_path) + except Exception: + return "recover_on_failure" + if install_plan is None: + return "recover_on_failure" + if not install_plan.missing_names: + return "prefer_before_import" + if install_plan.version_mismatch_names: + return "reinstall_on_failure" + return "recover_on_failure" + async def _import_plugin_with_dependency_recovery( self, path: str, @@ -330,31 +364,50 @@ class PluginManager: root_dir_name: str, requirements_path: str, ) -> ModuleType: + recovery_strategy = await self._plan_import_dependency_recovery( + requirements_path, + ) + + if recovery_strategy == "prefer_before_import": + try: + pip_installer.prefer_installed_dependencies( + requirements_path=requirements_path, + ) + except Exception: + pass + try: return __import__(path, fromlist=[module_str]) - except (ModuleNotFoundError, ImportError) as import_exc: + except ModuleNotFoundError as import_exc: if await anyio.Path(requirements_path).exists(): - try: + if recovery_strategy == "reinstall_on_failure": + logger.info( + "插件 %s 版本不匹配,跳过从已安装依赖恢复,直接重新安装: %s", + root_dir_name, + import_exc, + ) + else: logger.info( f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}", ) pip_installer.prefer_installed_dependencies( requirements_path=requirements_path, ) - module = __import__(path, fromlist=[module_str]) - logger.info( - f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。", - ) - return module - except Exception as recover_exc: - logger.info( - f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}", - ) + try: + module = __import__(path, fromlist=[module_str]) + logger.info( + f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。", + ) + return module + except ModuleNotFoundError: + pass await self._check_plugin_dept_update(target_plugin=root_dir_name) return __import__(path, fromlist=[module_str]) @staticmethod - def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None: + def _load_plugin_metadata( + plugin_path: str, plugin_obj: Any = None + ) -> StarMetadata | None: """先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。 Notes: 旧版本 AstrBot 插件可能使用的是 info() 函数来获取元数据。 @@ -400,6 +453,8 @@ class PluginManager: if isinstance(metadata.get("astrbot_version"), str) else None, ) + elif not isinstance(metadata, StarMetadata): + metadata = None return metadata @staticmethod @@ -550,7 +605,7 @@ class PluginManager: reserved: bool, error: BaseException | str, error_trace: str, - ) -> dict: + ) -> dict[str, Any]: record: dict = { "name": root_dir_name, "error": str(error), @@ -599,7 +654,9 @@ class PluginManager: lines.append(f"加载插件目录 {dir_name} 时出现问题,原因:{error}。") self.failed_plugin_info = "\n".join(lines) + "\n" - async def reload_failed_plugin(self, dir_name): + async def reload_failed_plugin( + self, dir_name: str + ) -> tuple[bool, str | None]: """重新加载未注册(加载失败)的插件 Args: dir_name (str): 要重载的特定插件名称。 @@ -621,7 +678,9 @@ class PluginManager: return (success, None) return (False, error) - async def reload(self, specified_plugin_name=None): + async def reload( + self, specified_plugin_name: str | None = None + ) -> tuple[bool, str | None]: """重新加载插件 Args: @@ -689,10 +748,10 @@ class PluginManager: async def load( self, - specified_module_path=None, - specified_dir_name=None, + specified_module_path: str | None = None, + specified_dir_name: str | None = None, ignore_version_check: bool = False, - ): + ) -> tuple[bool, str | None]: """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -1133,7 +1192,7 @@ class PluginManager: repo_url: str, proxy: str = "", ignore_version_check: bool = False, - ): + ) -> dict[str, Any] | None: """从仓库 URL 安装插件 从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中 @@ -1376,7 +1435,9 @@ class PluginManager: is_reserved=plugin.reserved, ) - async def update_plugin(self, plugin_name: str, proxy="") -> None: + async def update_plugin( + self, plugin_name: str, proxy: str = "" + ) -> None: """升级一个插件""" plugin = self.context.get_registered_star(plugin_name) if not plugin: @@ -1432,7 +1493,8 @@ class PluginManager: return if star_metadata.star_cls is None: return - if "__del__" in star_metadata.star_cls_type.__dict__: + star_cls_type = type(star_metadata.star_cls) + if "__del__" in star_cls_type.__dict__: loop = asyncio.get_running_loop() future = loop.run_in_executor(None, star_metadata.star_cls.__del__) @@ -1447,7 +1509,7 @@ class PluginManager: ) future.add_done_callback(_log_del_exception) - elif "terminate" in star_metadata.star_cls_type.__dict__: + elif "terminate" in star_cls_type.__dict__: await star_metadata.star_cls.terminate() handlers = star_handlers_registry.get_handlers_by_event_type( EventType.OnPluginUnloadedEvent, @@ -1510,7 +1572,7 @@ class PluginManager: self, zip_file_path: str, ignore_version_check: bool = False, - ): + ) -> dict[str, Any] | None: dir_name = os.path.splitext(os.path.basename(zip_file_path))[0] desti_dir = tempfile.mkdtemp( dir=self.plugin_store_path, diff --git a/astrbot/core/tools/computer_tools/cua.py b/astrbot/core/tools/computer_tools/cua.py index 7b37a5508..c6ca2a2d5 100644 --- a/astrbot/core/tools/computer_tools/cua.py +++ b/astrbot/core/tools/computer_tools/cua.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any import mcp +from mcp.types import ContentBlock from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper @@ -76,6 +77,7 @@ class CuaScreenshotTool(FunctionTool): context: ContextWrapper[AstrAgentContext], send_to_user: bool = True, return_image_to_llm: bool = True, + **kwargs: Any, ) -> ToolExecResult: if err := check_admin_permission(context, "Taking CUA screenshots"): return err @@ -88,7 +90,7 @@ class CuaScreenshotTool(FunctionTool): await context.context.event.send(MessageChain().file_image(path)) payload["sent_to_user"] = True image_data = payload.pop("base64", "") - content: list[mcp.types.TextContent | mcp.types.ImageContent] = [ + content: list[ContentBlock] = [ mcp.types.TextContent(type="text", text=_to_json(payload)) ] if return_image_to_llm: @@ -128,9 +130,10 @@ class CuaMouseClickTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], - x: int, - y: int, + x: int = 0, + y: int = 0, button: str = "left", + **kwargs: Any, ) -> ToolExecResult: if err := check_admin_permission(context, "Using CUA mouse"): return err @@ -159,7 +162,8 @@ class CuaKeyboardTypeTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], - text: str, + text: str = "", + **kwargs: Any, ) -> ToolExecResult: if err := check_admin_permission(context, "Using CUA keyboard"): return err diff --git a/astrbot/core/utils/core_constraints.py b/astrbot/core/utils/core_constraints.py index 10c831fa3..60a0ae71b 100644 --- a/astrbot/core/utils/core_constraints.py +++ b/astrbot/core/utils/core_constraints.py @@ -146,7 +146,14 @@ class CoreConstraintsProvider: without blocking the event loop. Internally it offloads blocking file creation/removal to a thread via asyncio.to_thread. """ - constraints = _get_core_constraints(self._core_dist_name) + constraints = tuple( + dict.fromkeys( + ( + *_get_core_constraints(self._core_dist_name), + *get_desktop_core_lock_constraints(), + ) + ) + ) if not constraints: yield None return diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 60bbb0949..295f0c73a 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -997,6 +997,7 @@ class PipInstaller: package_name: str | None = None, requirements_path: str | None = None, mirror: str | None = None, + allow_target_upgrade: bool = False, ) -> None: args, requested_requirements = self._build_pip_args( package_name, @@ -1021,6 +1022,8 @@ class PipInstaller: "only-if-needed", ], ) + elif allow_target_upgrade: + args.append("--upgrade") async with ( self._core_constraints.async_constraints_file() as constraints_file_path diff --git a/astrbot/core/utils/requirements_utils.py b/astrbot/core/utils/requirements_utils.py index 362a64566..ec1619f6f 100644 --- a/astrbot/core/utils/requirements_utils.py +++ b/astrbot/core/utils/requirements_utils.py @@ -27,10 +27,17 @@ class ParsedPackageInput: requirement_names: frozenset[str] +@dataclass(frozen=True) +class MissingRequirementsAnalysis: + missing_names: frozenset[str] + version_mismatch_names: frozenset[str] = frozenset() + + @dataclass(frozen=True) class MissingRequirementsPlan: missing_names: frozenset[str] install_lines: tuple[str, ...] + version_mismatch_names: frozenset[str] = frozenset() fallback_reason: str | None = None @@ -439,6 +446,35 @@ def build_missing_requirements_install_lines( return tuple(install_lines) +def classify_missing_requirements_from_lines( + requirement_lines: Sequence[str], +) -> MissingRequirementsAnalysis | None: + """Like find_missing_requirements_from_lines but returns version mismatch info too.""" + required = list(iter_requirements(lines=requirement_lines)) + if not required: + return MissingRequirementsAnalysis( + missing_names=frozenset(), + version_mismatch_names=frozenset(), + ) + installed = collect_installed_distribution_versions(get_requirement_check_paths()) + if installed is None: + return None + missing: set[str] = set() + version_mismatch_names: set[str] = set() + for name, specifier in required: + installed_version = installed.get(name) + if not installed_version: + missing.add(name) + continue + if specifier and not _specifier_contains_version(specifier, installed_version): + missing.add(name) + version_mismatch_names.add(name) + return MissingRequirementsAnalysis( + missing_names=frozenset(missing), + version_mismatch_names=frozenset(version_mismatch_names), + ) + + def plan_missing_requirements_install( requirements_path: str, ) -> MissingRequirementsPlan | None: @@ -448,9 +484,11 @@ def plan_missing_requirements_install( if not can_precheck or requirement_lines is None: return None - missing = find_missing_requirements_from_lines(requirement_lines) - if missing is None: + analysis = classify_missing_requirements_from_lines(requirement_lines) + if analysis is None: return None + missing = analysis.missing_names + version_mismatch_names = analysis.version_mismatch_names install_lines = build_missing_requirements_install_lines( requirements_path, @@ -468,11 +506,13 @@ def plan_missing_requirements_install( return MissingRequirementsPlan( missing_names=frozenset(missing), install_lines=(), + version_mismatch_names=version_mismatch_names, fallback_reason="unmapped missing requirement names", ) return MissingRequirementsPlan( missing_names=frozenset(missing), + version_mismatch_names=version_mismatch_names, install_lines=install_lines, ) diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py index ec741874a..7dcbebf58 100644 --- a/astrbot/core/utils/t2i/network_strategy.py +++ b/astrbot/core/utils/t2i/network_strategy.py @@ -2,7 +2,6 @@ import asyncio import logging import random import re -from functools import lru_cache from pathlib import Path import aiohttp @@ -21,18 +20,24 @@ JINJA_SYNTAX_PATTERN = re.compile(r"\{[{%#]") JINJA_RAW_OPEN_PATTERN = re.compile(r"{%-?\s*raw\s*-?%}") JINJA_RAW_CLOSE_PATTERN = re.compile(r"{%-?\s*endraw\s*-?%}") +_RUNTIME_PATH = Path(__file__).resolve().parent / "template" / "shiki_runtime.iife.js" + logger = logging.getLogger("astrbot") def _get_aiohttp(): import aiohttp + return aiohttp + + +def get_shiki_runtime() -> str: try: - runtime = runtime_path.read_text(encoding="utf-8") + runtime = _RUNTIME_PATH.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as err: logger.warning( "Failed to load T2I Shiki runtime from %s: %s. Continuing without code highlighting.", - runtime_path, + _RUNTIME_PATH, err, ) return "" @@ -108,7 +113,6 @@ class NetworkRenderStrategy(RenderStrategy): async def get_official_endpoints(self) -> None: """获取官方的 t2i 端点列表。""" try: - aiohttp = _get_aiohttp() async with aiohttp.ClientSession( trust_env=True, connector=build_tls_connector(), @@ -167,7 +171,6 @@ class NetworkRenderStrategy(RenderStrategy): last_exception = None for endpoint in endpoints: try: - aiohttp = _get_aiohttp() if return_url: async with ( aiohttp.ClientSession( diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py index 236c8ae3d..ec74eb421 100644 --- a/astrbot/core/utils/tencent_record_helper.py +++ b/astrbot/core/utils/tencent_record_helper.py @@ -5,16 +5,21 @@ import subprocess import tempfile import wave from io import BytesIO +from typing import TYPE_CHECKING import anyio +import pysilk # requires silk-python (core dependency) from astrbot.core import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +if TYPE_CHECKING: + from typing import Any + + import pilk # optional; stubs live under types/pilk.pyi + async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str: - import pysilk - async with await anyio.open_file(silk_path, "rb") as f: input_data = await f.read() if input_data.startswith(b"\x02"): @@ -120,7 +125,7 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: try: import pilk except ImportError as e: - raise Exception("未安装 pilk: pip install pilk") from e + raise Exception("未安装 pilk: pip install pilk") from e # noqa temp_dir = get_astrbot_temp_path() await anyio.Path(temp_dir).mkdir(parents=True, exist_ok=True) diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 3ad9102cf..ce2674c6b 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -5,7 +5,7 @@ import re import uuid from contextlib import asynccontextmanager from copy import deepcopy -from typing import cast +from typing import Any, cast from quart import Response as QuartResponse from quart import g, make_response, request, send_file @@ -191,7 +191,7 @@ class BotMessageAccumulator: tool_call_id = str(tool_result.get("id") or "") if not tool_call_id: return - tool_call = self.pending_tool_calls.pop(tool_call_id, None) or { + tool_call: dict[str, Any] = self.pending_tool_calls.pop(tool_call_id, None) or { "id": tool_call_id } tool_call["result"] = tool_result.get("result") @@ -258,17 +258,23 @@ class ChatRoute(Route): try: file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) - real_file_path = os.path.realpath(file_path) - real_imgs_dir = os.path.realpath(self.attachments_dir) + real_file_path = await asyncio.to_thread(os.path.realpath, file_path) + real_imgs_dir = await asyncio.to_thread( + os.path.realpath, self.attachments_dir + ) - if not os.path.exists(real_file_path): + if not await asyncio.to_thread(os.path.exists, real_file_path): # try legacy file_path = os.path.join( self.legacy_img_dir, os.path.basename(filename) ) - if os.path.exists(file_path): - real_file_path = os.path.realpath(file_path) - real_imgs_dir = os.path.realpath(self.legacy_img_dir) + if await asyncio.to_thread(os.path.exists, file_path): + real_file_path = await asyncio.to_thread( + os.path.realpath, file_path + ) + real_imgs_dir = await asyncio.to_thread( + os.path.realpath, self.legacy_img_dir + ) if not real_file_path.startswith(real_imgs_dir): return Response().error("Invalid file path").__dict__ @@ -295,7 +301,7 @@ class ChatRoute(Route): return Response().error("Attachment not found").__dict__ file_path = attachment.path - real_file_path = os.path.realpath(file_path) + real_file_path = await asyncio.to_thread(os.path.realpath, file_path) return await send_file(real_file_path, mimetype=attachment.mime_type) @@ -677,12 +683,15 @@ class ChatRoute(Route): platform_history_id: str = "webchat", ): """保存 bot 消息到历史记录,返回保存的记录""" - bot_message_parts = [] + bot_message_parts: list[dict[str, str]] = [] bot_message_parts.extend(media_parts) if text: bot_message_parts.append({"type": "plain", "text": text}) - new_his = {"type": "bot", "message": bot_message_parts} + new_his: dict[str, str | list[dict[str, str]] | dict[str, str] | None] = { + "type": "bot", + "message": bot_message_parts, + } if reasoning: new_his["reasoning"] = reasoning if agent_stats: @@ -1149,10 +1158,10 @@ class ChatRoute(Route): try: attachments = await self.db.get_attachments(attachment_ids) for attachment in attachments: - if not os.path.exists(attachment.path): + if not await asyncio.to_thread(os.path.exists, attachment.path): continue try: - os.remove(attachment.path) + await asyncio.to_thread(os.remove, attachment.path) except OSError as e: logger.warning( f"Failed to delete attachment file {attachment.path}: {e}" @@ -1255,7 +1264,7 @@ class ChatRoute(Route): creator=username, ) - response_data = { + response_data: dict[str, Any] = { "history": history_res, "threads": [self._serialize_thread(thread) for thread in threads], "is_running": self.running_convs.get(session_id, False), diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 60c97da99..24277c1ab 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -348,14 +348,14 @@ class AstrBotDashboard: star_context = self.core_lifecycle.star_context if star_context is None: return - if star_context.registered_web_apis is None: - star_context.registered_web_apis = [] + if star_context._registered_web_apis is None: + star_context._registered_web_apis = [] for ( route, handler, methods, _, - ) in star_context.registered_web_apis: + ) in star_context._registered_web_apis: for method in methods: self._plugin_route_map[(route, method)] = handler diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py index 64ac2a866..545c43018 100644 --- a/astrbot/dashboard/utils.py +++ b/astrbot/dashboard/utils.py @@ -71,6 +71,9 @@ async def generate_tsne_visualization( for i in range(index.ntotal): index.reconstruct(i, vectors[i]) vec_db = kb_helper.vec_db + if vec_db is None: + logger.warning("向量数据库对象为空") + return None embedding_provider = vec_db.embedding_provider query_embedding = await embedding_provider.get_embedding(query) query_vector = np.array([query_embedding], dtype=np.float32) diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index 495c030b2..2c596bca1 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -9,7 +9,7 @@ :color="toastStore.current.color" :timeout="toastStore.current.timeout" :multi-line="toastStore.current.multiLine" - :location="toastStore.current.location" + :location="snackbarLocation" close-on-back > {{ toastStore.current.message }} @@ -22,15 +22,28 @@ diff --git a/dashboard/src/components/provider/AddNewProvider.vue b/dashboard/src/components/provider/AddNewProvider.vue index f87c5d9ad..06e3480ee 100644 --- a/dashboard/src/components/provider/AddNewProvider.vue +++ b/dashboard/src/components/provider/AddNewProvider.vue @@ -99,10 +99,30 @@ @@ -379,6 +413,7 @@ function getSpecialSubtype(value) { import { ref, computed, watch } from "vue"; -import axios from "@/utils/request"; +import axios, { isAxiosError } from "@/utils/request"; import { resolveApiUrl } from "@/utils/request"; import { useI18n } from "@/i18n/composables"; import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog"; import { restartAstrBot as restartAstrBotRuntime } from "@/utils/restartAstrBot"; import WaitingForRestart from "./WaitingForRestart.vue"; +interface WfrExposed { + check: (initialStartTime?: number | null) => void | Promise; + stop?: () => void; +} +interface BackupExportResult { + filename: string; +} +interface BackupCheckResult { + valid: boolean; + error?: string; + can_import?: boolean; + backup_version?: string; + current_version?: string; + backup_time?: string; + version_status?: "major_diff" | "minor_diff" | "match"; + warnings?: string[]; + backup_summary?: { + tables?: string[]; + has_knowledge_bases?: boolean; + has_config?: boolean; + directories?: string[]; + }; +} +interface BackupListItem { + filename: string; + type: string; + size: number; + created_at: number; + astrbot_version: string; +} + const { t } = useI18n(); const confirmDialog = useConfirmDialog(); const isOpen = ref(false); const activeTab = ref("export"); -const wfr = ref(null); +const wfr = ref(null); // 导出状态 const exportStatus = ref("idle"); // idle, processing, completed, failed -const exportTaskId = ref(null); +const exportTaskId = ref(null); const exportProgress = ref({ current: 0, total: 100, message: "" }); -const exportResult = ref(null); +const exportResult = ref(null); const exportError = ref(""); // 导入状态 const importStatus = ref("idle"); // idle, uploading, confirm, processing, completed, failed -const importFile = ref(null); -const importTaskId = ref(null); +const importFile = ref(null); +const importTaskId = ref(null); const importProgress = ref({ current: 0, total: 100, message: "" }); const importError = ref(""); const uploadedFilename = ref(""); // 已上传的文件名 -const checkResult = ref(null); // 预检查结果 +const checkResult = ref(null); // 预检查结果 // 分片上传状态 const CONCURRENT_UPLOADS = 5; // 并发上传数 @@ -608,7 +639,7 @@ const uploadProgress = ref({ // 备份列表 const loadingList = ref(false); -const backupList = ref([]); +const backupList = ref([]); // 重命名对话框状态 const renameDialogOpen = ref(false); @@ -683,7 +714,7 @@ const loadBackupList = async () => { if (response.data.status === "ok") { backupList.value = response.data.data.items || []; } - } catch (error) { + } catch (error: unknown) { console.error("Failed to load backup list:", error); } finally { loadingList.value = false; @@ -703,9 +734,9 @@ const startExport = async () => { } else { throw new Error(response.data.message); } - } catch (error) { + } catch (error: unknown) { exportStatus.value = "failed"; - exportError.value = error.message || "Export failed"; + exportError.value = error instanceof Error ? error.message : "Export failed"; } }; @@ -739,9 +770,9 @@ const pollExportProgress = async () => { setTimeout(pollExportProgress, 1000); } } - } catch (error) { + } catch (error: unknown) { exportStatus.value = "failed"; - exportError.value = error.message || "Failed to get export progress"; + exportError.value = error instanceof Error ? error.message : "Failed to get export progress"; } }; @@ -762,14 +793,14 @@ const resetExport = () => { * 因此分片到达顺序不影响最终结果。 */ const uploadChunksInParallel = async ( - file, - totalChunks, - currentUploadId, - currentChunkSize, + file: File, + totalChunks: number, + currentUploadId: string, + currentChunkSize: number, ) => { // 跟踪已完成的字节数(使用原子操作避免并发问题) let completedBytes = 0; - const chunkSizes = []; + const chunkSizes: number[] = []; // 预计算每个分片的大小(使用后端返回的 chunk_size) for (let i = 0; i < totalChunks; i++) { @@ -779,7 +810,7 @@ const uploadChunksInParallel = async ( } // 上传单个分片的函数 - const uploadSingleChunk = async (chunkIndex) => { + const uploadSingleChunk = async (chunkIndex: number) => { const start = chunkIndex * currentChunkSize; const end = Math.min(start + currentChunkSize, file.size); const chunk = file.slice(start, end); @@ -917,21 +948,26 @@ const uploadAndCheck = async () => { // 显示确认对话框 importStatus.value = "confirm"; - } catch (error) { + } catch (error: unknown) { // 上传失败时尝试清理已上传的分片 if (uploadId.value) { try { await axios.post("/api/backup/upload/abort", { upload_id: uploadId.value, }); - } catch (abortError) { + } catch (abortError: unknown) { console.error("Failed to abort upload:", abortError); } } importStatus.value = "failed"; - importError.value = - error.response?.data?.message || error.message || "Upload failed"; + if (isAxiosError(error)) { + importError.value = error.response?.data?.message || "Upload failed"; + } else if (error instanceof Error) { + importError.value = error.message; + } else { + importError.value = "Upload failed"; + } } }; @@ -954,10 +990,15 @@ const confirmImport = async () => { } else { throw new Error(response.data.message); } - } catch (error) { + } catch (error: unknown) { importStatus.value = "failed"; - importError.value = - error.response?.data?.message || error.message || "Import failed"; + if (isAxiosError(error)) { + importError.value = error.response?.data?.message || "Import failed"; + } else if (error instanceof Error) { + importError.value = error.message; + } else { + importError.value = "Import failed"; + } } }; @@ -989,9 +1030,9 @@ const pollImportProgress = async () => { setTimeout(pollImportProgress, 1000); } } - } catch (error) { + } catch (error: unknown) { importStatus.value = "failed"; - importError.value = error.message || "Failed to get import progress"; + importError.value = error instanceof Error ? error.message : "Failed to get import progress"; } }; @@ -1003,7 +1044,7 @@ const resetImport = async () => { await axios.post("/api/backup/upload/abort", { upload_id: uploadId.value, }); - } catch (error) { + } catch (error: unknown) { console.error("Failed to abort upload:", error); } } @@ -1021,7 +1062,7 @@ const resetImport = async () => { }; // 下载备份(使用浏览器原生下载,可显示下载进度) -const downloadBackup = (filename) => { +const downloadBackup = (filename: string) => { // 获取 token 用于鉴权(因为浏览器原生下载无法携带 Authorization header) const token = localStorage.getItem("token"); if (!token) { @@ -1045,7 +1086,7 @@ const downloadBackup = (filename) => { }; // 从列表中恢复备份 -const restoreFromList = async (filename) => { +const restoreFromList = async (filename: string) => { // 切换到导入标签页并设置文件名 uploadedFilename.value = filename; @@ -1072,13 +1113,19 @@ const restoreFromList = async (filename) => { // 切换到导入标签页并显示确认 activeTab.value = "import"; importStatus.value = "confirm"; - } catch (error) { - alert(error.response?.data?.message || error.message || "Check failed"); + } catch (error: unknown) { + let msg = "Check failed"; + if (isAxiosError(error)) { + msg = error.response?.data?.message || "Check failed"; + } else if (error instanceof Error) { + msg = error.message; + } + alert(msg); } }; // 删除备份 -const deleteBackup = async (filename) => { +const deleteBackup = async (filename: string) => { if ( !(await askForConfirmation( t("features.settings.backup.list.confirmDelete"), @@ -1094,13 +1141,13 @@ const deleteBackup = async (filename) => { } else { alert(response.data.message || "Delete failed"); } - } catch (error) { - alert(error.message || "Delete failed"); + } catch (error: unknown) { + alert(error instanceof Error ? error.message : "Delete failed"); } }; // 重命名相关函数 -const openRenameDialog = (filename) => { +const openRenameDialog = (filename: string) => { renameOldFilename.value = filename; // 移除 .zip 后缀,只显示文件名部分 renameNewName.value = filename.replace(/\.zip$/i, ""); @@ -1116,7 +1163,7 @@ const closeRenameDialog = () => { }; // 文件名验证规则 -const renameValidationRule = (value) => { +const renameValidationRule = (value: string) => { if (!value) return t("features.settings.backup.list.renameRequired"); // 检查是否包含非法字符 if (/[\\/:*?"<>|]/.test(value)) { @@ -1156,18 +1203,21 @@ const confirmRename = async () => { response.data.message || t("features.settings.backup.list.renameFailed"); } - } catch (error) { - renameError.value = - error.response?.data?.message || - error.message || - t("features.settings.backup.list.renameFailed"); + } catch (error: unknown) { + if (isAxiosError(error)) { + renameError.value = error.response?.data?.message || t("features.settings.backup.list.renameFailed"); + } else if (error instanceof Error) { + renameError.value = error.message; + } else { + renameError.value = t("features.settings.backup.list.renameFailed"); + } } finally { renameLoading.value = false; } }; // 格式化文件大小 -const formatFileSize = (bytes) => { +const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; @@ -1176,12 +1226,12 @@ const formatFileSize = (bytes) => { }; // 格式化日期(从时间戳) -const formatDate = (timestamp) => { +const formatDate = (timestamp: number) => { return new Date(timestamp * 1000).toLocaleString(); }; // 格式化 ISO 日期字符串 -const formatISODate = (isoString) => { +const formatISODate = (isoString: string) => { if (!isoString) return ""; try { return new Date(isoString).toLocaleString(); @@ -1194,7 +1244,7 @@ const formatISODate = (isoString) => { const restartAstrBot = async () => { try { await restartAstrBotRuntime(wfr.value); - } catch (error) { + } catch (error: unknown) { console.error(error); } }; diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 62ccd3c8b..f1267013c 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -280,6 +280,8 @@ diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index 839182e64..9cfeb243f 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -1,9 +1,3 @@ - - - diff --git a/dashboard/src/components/shared/PluginSetSelector.vue b/dashboard/src/components/shared/PluginSetSelector.vue index 279256ba0..c3435a6ee 100644 --- a/dashboard/src/components/shared/PluginSetSelector.vue +++ b/dashboard/src/components/shared/PluginSetSelector.vue @@ -145,11 +145,18 @@ const props = defineProps({ const emit = defineEmits(["update:modelValue"]); const { tm } = useModuleI18n("core.shared"); +interface PluginEntry { + name: string; + desc?: string; + activated: boolean; + reserved: boolean; +} + const dialog = ref(false); -const pluginList = ref([]); +const pluginList = ref([]); const loading = ref(false); const selectionMode = ref("custom"); // 'all', 'none', 'custom' -const selectedPlugins = ref([]); +const selectedPlugins = ref([]); // 判断是否为"所有插件"模式 const isAllPlugins = computed(() => { @@ -160,18 +167,11 @@ const isAllPlugins = computed(() => { ); }); -// 移除插件 -function removePlugin(pluginName) { - if (props.modelValue && props.modelValue.length > 0) { - const newValue = props.modelValue.filter((name) => name !== pluginName); - emit("update:modelValue", newValue); - } -} // 监听 modelValue 变化,同步内部状态 watch( () => props.modelValue, - (newValue) => { + (newValue: unknown[]) => { if (!newValue || newValue.length === 0) { selectionMode.value = "none"; selectedPlugins.value = []; @@ -180,7 +180,7 @@ watch( selectedPlugins.value = []; } else { selectionMode.value = "custom"; - selectedPlugins.value = [...newValue]; + selectedPlugins.value = [...newValue] as string[]; } }, { immediate: true }, @@ -243,7 +243,7 @@ function cancelSelection() { selectedPlugins.value = []; } else { selectionMode.value = "custom"; - selectedPlugins.value = [...currentValue]; + selectedPlugins.value = [...currentValue] as string[]; } dialog.value = false; diff --git a/dashboard/src/components/shared/ProviderSelector.vue b/dashboard/src/components/shared/ProviderSelector.vue index b82c43c3d..c5c8c08f3 100644 --- a/dashboard/src/components/shared/ProviderSelector.vue +++ b/dashboard/src/components/shared/ProviderSelector.vue @@ -210,6 +210,14 @@ import axios from "@/utils/request"; import { useModuleI18n } from "@/i18n/composables"; import ProviderPage from "@/views/ProviderPage.vue"; +interface Provider { + id: string; + type?: string; + provider_type?: string; + model?: string; + provider?: string; +} + const props = defineProps({ modelValue: { type: [String, Array], @@ -237,10 +245,10 @@ const emit = defineEmits(["update:modelValue"]); const { tm } = useModuleI18n("core.shared"); const dialog = ref(false); -const providerList = ref([]); +const providerList = ref([]); const loading = ref(false); const selectedProvider = ref(""); -const selectedProviders = ref([]); +const selectedProviders = ref([]); const providerDrawer = ref(false); const hasSelection = computed(() => { @@ -315,7 +323,7 @@ async function loadProviders() { } } -function matchesProviderSubtype(provider, subtype) { +function matchesProviderSubtype(provider: Provider, subtype: string): boolean { if (!subtype) { return true; } @@ -326,7 +334,7 @@ function matchesProviderSubtype(provider, subtype) { return candidates.includes(normalized); } -function selectProvider(provider) { +function selectProvider(provider: { id: string }): void { if (props.multiple) { if (!provider.id) { selectedProviders.value = []; @@ -364,21 +372,21 @@ function cancelSelection() { dialog.value = false; } -function isProviderSelected(providerId) { +function isProviderSelected(providerId: string): boolean { if (props.multiple) { return selectedProviders.value.includes(providerId); } return selectedProvider.value === providerId; } -function removeSelected(providerId) { +function removeSelected(providerId: string): void { const idx = selectedProviders.value.indexOf(providerId); if (idx >= 0) { selectedProviders.value.splice(idx, 1); } } -function moveSelected(index, delta) { +function moveSelected(index: number, delta: number): void { const targetIndex = index + delta; if ( targetIndex < 0 || diff --git a/dashboard/src/components/shared/ProxySelector.vue b/dashboard/src/components/shared/ProxySelector.vue index b973976fe..1961f4e93 100644 --- a/dashboard/src/components/shared/ProxySelector.vue +++ b/dashboard/src/components/shared/ProxySelector.vue @@ -99,8 +99,8 @@ export default { selectedGitHubProxy: "", radioValue: "0", // 0: 不使用, 1: 使用 loadingTestingConnection: false, - testingProxies: {}, - proxyStatus: {}, + testingProxies: {} as Record, + proxyStatus: {} as Record, initializing: true, }; }, @@ -167,7 +167,7 @@ export default { this.initializing = false; }, methods: { - getProxyByControl(control) { + getProxyByControl(control: string) { const normalizedControl = String(control); if (normalizedControl === "-1") { return ""; @@ -178,7 +178,7 @@ export default { } return this.githubProxies[index] || ""; }, - async testSingleProxy(idx) { + async testSingleProxy(idx: number) { this.testingProxies[idx] = true; const proxy = this.githubProxies[idx]; diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue index 5cb9f5ccc..3587c61a7 100644 --- a/dashboard/src/components/shared/ReadmeDialog.vue +++ b/dashboard/src/components/shared/ReadmeDialog.vue @@ -43,15 +43,15 @@ const props = defineProps({ const emit = defineEmits(["update:show"]); const { t, locale } = useI18n(); -const content = ref(null); -const error = ref(null); +const content = ref(null); +const error = ref(null); const loading = ref(false); const isEmpty = ref(false); -const copyFeedbackTimer = ref(null); +const copyFeedbackTimer = ref | null>(null); const lastRequestId = ref(0); -const scrollContainer = ref(null); +const scrollContainer = ref(null); -function slugifyHeading(text, slugCounts) { +function slugifyHeading(text: string | null, slugCounts: Map) { const base = (text || "") .trim() .toLowerCase() @@ -262,8 +262,8 @@ async function fetchContent() { } else { error.value = res.data.message; } - } catch (err) { - if (requestId === lastRequestId.value) error.value = err.message; + } catch (err: unknown) { + if (requestId === lastRequestId.value) error.value = err instanceof Error ? err.message : String(err); } finally { if (requestId === lastRequestId.value) loading.value = false; } @@ -279,24 +279,26 @@ watch( { immediate: true }, ); -function handleContainerClick(event) { - const btn = event.target.closest(".copy-code-btn"); +function handleContainerClick(event: MouseEvent) { + const clickTarget = event.target; + if (!(clickTarget instanceof Element)) return; + const btn = clickTarget.closest(".copy-code-btn"); if (btn) { const code = btn.closest(".code-block-wrapper")?.querySelector("code"); if (code) { if (navigator.clipboard?.writeText) { navigator.clipboard - .writeText(code.textContent) + .writeText(code.textContent ?? "") .then(() => showCopyFeedback(btn, true)) - .catch(() => tryFallbackCopy(code.textContent, btn)); + .catch(() => tryFallbackCopy(code.textContent ?? "", btn)); } else { - tryFallbackCopy(code.textContent, btn); + tryFallbackCopy(code.textContent ?? "", btn); } } return; } - const anchor = event.target.closest('a[href^="#"]'); + const anchor = clickTarget.closest('a[href^="#"]'); if (!anchor) return; const rawHref = anchor.getAttribute("href"); @@ -312,7 +314,7 @@ function handleContainerClick(event) { target.scrollIntoView({ behavior: "smooth", block: "start" }); } -function tryFallbackCopy(text, btn) { +function tryFallbackCopy(text: string, btn: Element) { try { const textArea = document.createElement("textarea"); textArea.value = text; @@ -331,12 +333,12 @@ function tryFallbackCopy(text, btn) { } else { showCopyFeedback(btn, false); } - } catch (err) { + } catch (_err: unknown) { showCopyFeedback(btn, false); } } -function showCopyFeedback(btn, success) { +function showCopyFeedback(btn: Element, success: boolean) { if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value); btn.setAttribute("title", t(`core.common.${success ? "copied" : "error"}`)); btn.innerHTML = success ? ICONS.SUCCESS : ICONS.ERROR; @@ -358,7 +360,7 @@ const _show = computed({ }); // 安全打开外部链接 -function openExternalLink(url) { +function openExternalLink(url: string | null) { if (!url) return; window.open(url, "_blank", "noopener,noreferrer"); } @@ -414,6 +416,7 @@ const showActionArea = computed(() => {

+
(null); // --- 编辑器配置 --- const editorTheme = computed(() => "vs-light"); @@ -351,7 +351,8 @@ const previewContent = computed(() => { ); return content; } catch (error) { - return `
模板渲染错误: ${error.message}
`; + const message = error instanceof Error ? error.message : String(error); + return `
模板渲染错误: ${message}
`; } }); @@ -533,7 +534,7 @@ const refreshPreview = () => { syncPreviewVersion(); nextTick(() => { if (previewFrame.value) { - previewFrame.value.contentWindow.location.reload(); + previewFrame.value.contentWindow?.location.reload(); } setTimeout(() => (previewLoading.value = false), 500); }); diff --git a/dashboard/src/components/shared/TemplateListEditor.vue b/dashboard/src/components/shared/TemplateListEditor.vue index 70e3de45a..81fbffa69 100644 --- a/dashboard/src/components/shared/TemplateListEditor.vue +++ b/dashboard/src/components/shared/TemplateListEditor.vue @@ -107,7 +107,7 @@