From f72b1c3d30821949732d5ac204078a9ebbf53698 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 8 Jun 2026 10:23:17 +0800 Subject: [PATCH 01/29] refactor: migrate to fastapi --- astrbot/core/platform/platform.py | 2 +- astrbot/core/platform/sources/lark/server.py | 2 +- .../qqofficial_webhook/qo_webhook_server.py | 10 +- astrbot/core/platform/sources/slack/client.py | 24 +- .../platform/sources/wecom/wecom_adapter.py | 16 +- .../sources/wecom_ai_bot/wecomai_server.py | 24 +- .../weixin_offacc_adapter.py | 16 +- astrbot/core/platform/webhook_server.py | 107 + astrbot/dashboard/fastapi_compat.py | 598 ++ astrbot/dashboard/plugin_page_auth.py | 2 +- astrbot/dashboard/routes/api_key.py | 142 +- astrbot/dashboard/routes/auth.py | 461 +- astrbot/dashboard/routes/backup.py | 1152 +--- astrbot/dashboard/routes/chat.py | 1749 +---- astrbot/dashboard/routes/chatui_project.py | 261 +- astrbot/dashboard/routes/command.py | 126 +- astrbot/dashboard/routes/config.py | 1680 +---- astrbot/dashboard/routes/conversation.py | 431 +- astrbot/dashboard/routes/cron.py | 306 +- astrbot/dashboard/routes/file.py | 10 +- astrbot/dashboard/routes/knowledge_base.py | 1320 +--- astrbot/dashboard/routes/live_chat.py | 938 +-- astrbot/dashboard/routes/log.py | 131 +- astrbot/dashboard/routes/open_api.py | 712 +- astrbot/dashboard/routes/persona.py | 523 +- astrbot/dashboard/routes/platform.py | 268 +- astrbot/dashboard/routes/plugin.py | 2144 +----- astrbot/dashboard/routes/route.py | 5 +- .../dashboard/routes/session_management.py | 993 +-- astrbot/dashboard/routes/skills.py | 972 +-- astrbot/dashboard/routes/stat.py | 583 +- astrbot/dashboard/routes/static_file.py | 28 +- astrbot/dashboard/routes/subagent.py | 116 +- astrbot/dashboard/routes/t2i.py | 252 +- astrbot/dashboard/routes/tools.py | 651 +- astrbot/dashboard/routes/update.py | 382 +- astrbot/dashboard/routes/util.py | 117 - astrbot/dashboard/server.py | 190 +- astrbot/dashboard/services/__init__.py | 1 + astrbot/dashboard/services/api_key_service.py | 139 + astrbot/dashboard/services/auth_service.py | 452 ++ astrbot/dashboard/services/backup_service.py | 694 ++ astrbot/dashboard/services/chat_service.py | 1626 +++++ .../services/chatui_project_service.py | 162 + astrbot/dashboard/services/command_service.py | 133 + astrbot/dashboard/services/config_service.py | 1701 +++++ .../services/conversation_service.py | 337 + astrbot/dashboard/services/cron_service.py | 285 + astrbot/dashboard/services/file_service.py | 15 + .../services/knowledge_base_service.py | 863 +++ .../dashboard/services/live_chat_service.py | 982 +++ astrbot/dashboard/services/log_service.py | 97 + .../dashboard/services/open_api_service.py | 650 ++ astrbot/dashboard/services/persona_service.py | 293 + .../dashboard/services/platform_service.py | 218 + .../dashboard/services/plugin_page_service.py | 923 +++ astrbot/dashboard/services/plugin_service.py | 1165 ++++ .../services/route_bridge_service.py | 119 + .../services/session_management_service.py | 715 ++ astrbot/dashboard/services/skills_service.py | 877 +++ astrbot/dashboard/services/stat_service.py | 532 ++ .../dashboard/services/static_file_service.py | 36 + .../dashboard/services/subagent_service.py | 96 + astrbot/dashboard/services/t2i_service.py | 150 + astrbot/dashboard/services/tools_service.py | 568 ++ astrbot/dashboard/services/update_service.py | 412 ++ astrbot/dashboard/v1/__init__.py | 1 + astrbot/dashboard/v1/app.py | 74 + astrbot/dashboard/v1/auth.py | 86 + astrbot/dashboard/v1/compat_aliases.py | 424 ++ astrbot/dashboard/v1/responses.py | 22 + astrbot/dashboard/v1/routers/__init__.py | 22 + astrbot/dashboard/v1/routers/bots.py | 188 + .../dashboard/v1/routers/compat/__init__.py | 19 + astrbot/dashboard/v1/routers/compat/auth.py | 152 + astrbot/dashboard/v1/routers/compat/chat.py | 342 + astrbot/dashboard/v1/routers/compat/common.py | 59 + .../v1/routers/compat/conversations.py | 116 + astrbot/dashboard/v1/routers/compat/files.py | 72 + .../v1/routers/compat/knowledge_bases.py | 267 + .../dashboard/v1/routers/compat/personas.py | 224 + .../dashboard/v1/routers/compat/sessions.py | 217 + astrbot/dashboard/v1/routers/compat/system.py | 647 ++ .../dashboard/v1/routers/config_profiles.py | 174 + astrbot/dashboard/v1/routers/extensions.py | 749 +++ .../dashboard/v1/routers/open_api_compat.py | 223 + astrbot/dashboard/v1/routers/plugins.py | 894 +++ astrbot/dashboard/v1/routers/providers.py | 403 ++ astrbot/dashboard/v1/schemas.py | 172 + dashboard/package.json | 1 + dashboard/scripts/generate_openapi_client.py | 364 + dashboard/src/api/generated/openapi-v1.ts | 3307 +++++++++ dashboard/src/api/http.ts | 104 + dashboard/src/api/v1.ts | 1559 +++++ dashboard/src/components/chat/Chat.vue | 14 +- dashboard/src/components/chat/ChatInput.vue | 10 +- .../src/components/chat/ChatMessageList.vue | 7 +- .../src/components/chat/ConfigSelector.vue | 27 +- dashboard/src/components/chat/LiveMode.vue | 4 +- dashboard/src/components/chat/MessageList.vue | 7 +- .../components/chat/MessageListDEPRECATED.vue | 3 +- .../src/components/chat/ProviderModelMenu.vue | 8 +- .../src/components/chat/RegenerateMenu.vue | 8 +- .../src/components/chat/StandaloneChat.vue | 17 +- dashboard/src/components/chat/ThreadPanel.vue | 11 +- .../extension/McpServersSection.vue | 22 +- .../components/extension/SkillsSection.vue | 64 +- .../composables/useCommandActions.ts | 16 +- .../composables/useComponentData.ts | 6 +- .../composables/useToolActions.ts | 12 +- .../extension/componentPanel/index.vue | 2 +- dashboard/src/components/folder/README.md | 10 +- .../components/platform/AddNewPlatform.vue | 39 +- .../platform/PlatformRegistrationAction.vue | 19 +- .../src/components/shared/AstrBotConfig.vue | 11 +- .../src/components/shared/BackupDialog.vue | 46 +- .../src/components/shared/ChangelogDialog.vue | 10 +- .../components/shared/ConsoleDisplayer.vue | 6 +- .../shared/DashboardTotpManager.vue | 4 +- .../shared/DashboardTotpSetupDialog.vue | 8 +- .../src/components/shared/FileConfigItem.vue | 37 +- .../shared/KnowledgeBaseSelector.vue | 10 +- .../src/components/shared/MigrationDialog.vue | 8 +- .../src/components/shared/PersonaForm.vue | 19 +- .../components/shared/PersonaQuickPreview.vue | 8 +- .../src/components/shared/PersonaSelector.vue | 14 +- .../components/shared/PluginSetSelector.vue | 4 +- .../components/shared/ProviderSelector.vue | 8 +- .../src/components/shared/ProxySelector.vue | 4 +- .../src/components/shared/ReadmeDialog.vue | 16 +- .../components/shared/StorageCleanupPanel.vue | 6 +- .../components/shared/T2ITemplateEditor.vue | 22 +- .../src/components/shared/TraceDisplayer.vue | 6 +- .../components/shared/WaitingForRestart.vue | 4 +- dashboard/src/composables/useConversations.ts | 21 +- dashboard/src/composables/useMediaHandling.ts | 13 +- dashboard/src/composables/useMessages.ts | 47 +- .../src/composables/usePluginSidebarItems.ts | 4 +- dashboard/src/composables/useProjects.ts | 26 +- .../useProviderModelConfigDialog.ts | 18 +- .../src/composables/useProviderSources.ts | 46 +- dashboard/src/composables/useSessions.ts | 20 +- .../en-US/features/config-metadata.json | 2 +- .../ru-RU/features/config-metadata.json | 2 +- .../zh-CN/features/config-metadata.json | 2 +- dashboard/src/layouts/full/FullLayout.vue | 8 +- .../full/vertical-header/VerticalHeader.vue | 62 +- dashboard/src/main.ts | 44 +- dashboard/src/stores/auth.ts | 39 +- dashboard/src/stores/common.js | 26 +- dashboard/src/stores/personaStore.ts | 38 +- dashboard/src/utils/restartAstrBot.ts | 6 +- dashboard/src/views/ConfigPage.vue | 43 +- dashboard/src/views/ConsolePage.vue | 4 +- dashboard/src/views/ConversationPage.vue | 47 +- dashboard/src/views/CronJobPage.vue | 21 +- dashboard/src/views/PlatformPage.vue | 19 +- dashboard/src/views/PluginPagePage.vue | 16 +- dashboard/src/views/ProviderPage.vue | 21 +- dashboard/src/views/SessionManagementPage.vue | 60 +- dashboard/src/views/Settings.vue | 10 +- dashboard/src/views/SubAgentPage.vue | 6 +- dashboard/src/views/TracePage.vue | 6 +- dashboard/src/views/WelcomePage.vue | 24 +- dashboard/src/views/alkaid/KnowledgeBase.vue | 46 +- dashboard/src/views/alkaid/LongTermMemory.vue | 12 +- .../views/authentication/auth/LoginPage.vue | 4 +- .../views/authentication/auth/SetupPage.vue | 4 +- .../src/views/extension/PluginDetailPage.vue | 13 +- .../src/views/extension/useExtensionPage.js | 77 +- .../views/knowledge-base/DocumentDetail.vue | 19 +- .../src/views/knowledge-base/KBDetail.vue | 6 +- dashboard/src/views/knowledge-base/KBList.vue | 23 +- .../components/DocumentsTab.vue | 29 +- .../components/RetrievalTab.vue | 4 +- .../knowledge-base/components/SettingsTab.vue | 9 +- .../components/TavilyKeyDialog.vue | 15 +- dashboard/src/views/stats/StatsPage.vue | 14 +- docs/.vitepress/config.mjs | 2 + docs/en/deploy/astrbot/package.md | 68 +- docs/en/dev/star/guides/plugin-pages.md | 2 +- docs/zh/deploy/astrbot/package.md | 65 + docs/zh/dev/star/guides/plugin-pages.md | 2 +- openspec/openapi-v1.yaml | 5985 +++++++++++++++++ pyproject.toml | 2 +- requirements.txt | 2 +- tests/test_api_key_open_api.py | 104 +- tests/test_backup.py | 2 +- tests/test_chat_route.py | 8 +- tests/test_cli_command_aliases.py | 25 + tests/test_cli_service.py | 319 + tests/test_computer_config.py | 24 +- tests/test_conversation_checkpoint.py | 9 +- tests/test_dashboard.py | 157 +- tests/test_fastapi_v1_dashboard.py | 1605 +++++ tests/test_kb_import.py | 32 +- tests/unit/test_dashboard_util.py | 3 +- tests/unit/test_live_chat_service.py | 89 + tests/unit/test_open_api_service_ws.py | 133 + .../unit/test_upload_filename_sanitization.py | 19 +- 200 files changed, 36901 insertions(+), 15856 deletions(-) create mode 100644 astrbot/core/platform/webhook_server.py create mode 100644 astrbot/dashboard/fastapi_compat.py delete mode 100644 astrbot/dashboard/routes/util.py create mode 100644 astrbot/dashboard/services/__init__.py create mode 100644 astrbot/dashboard/services/api_key_service.py create mode 100644 astrbot/dashboard/services/auth_service.py create mode 100644 astrbot/dashboard/services/backup_service.py create mode 100644 astrbot/dashboard/services/chat_service.py create mode 100644 astrbot/dashboard/services/chatui_project_service.py create mode 100644 astrbot/dashboard/services/command_service.py create mode 100644 astrbot/dashboard/services/config_service.py create mode 100644 astrbot/dashboard/services/conversation_service.py create mode 100644 astrbot/dashboard/services/cron_service.py create mode 100644 astrbot/dashboard/services/file_service.py create mode 100644 astrbot/dashboard/services/knowledge_base_service.py create mode 100644 astrbot/dashboard/services/live_chat_service.py create mode 100644 astrbot/dashboard/services/log_service.py create mode 100644 astrbot/dashboard/services/open_api_service.py create mode 100644 astrbot/dashboard/services/persona_service.py create mode 100644 astrbot/dashboard/services/platform_service.py create mode 100644 astrbot/dashboard/services/plugin_page_service.py create mode 100644 astrbot/dashboard/services/plugin_service.py create mode 100644 astrbot/dashboard/services/route_bridge_service.py create mode 100644 astrbot/dashboard/services/session_management_service.py create mode 100644 astrbot/dashboard/services/skills_service.py create mode 100644 astrbot/dashboard/services/stat_service.py create mode 100644 astrbot/dashboard/services/static_file_service.py create mode 100644 astrbot/dashboard/services/subagent_service.py create mode 100644 astrbot/dashboard/services/t2i_service.py create mode 100644 astrbot/dashboard/services/tools_service.py create mode 100644 astrbot/dashboard/services/update_service.py create mode 100644 astrbot/dashboard/v1/__init__.py create mode 100644 astrbot/dashboard/v1/app.py create mode 100644 astrbot/dashboard/v1/auth.py create mode 100644 astrbot/dashboard/v1/compat_aliases.py create mode 100644 astrbot/dashboard/v1/responses.py create mode 100644 astrbot/dashboard/v1/routers/__init__.py create mode 100644 astrbot/dashboard/v1/routers/bots.py create mode 100644 astrbot/dashboard/v1/routers/compat/__init__.py create mode 100644 astrbot/dashboard/v1/routers/compat/auth.py create mode 100644 astrbot/dashboard/v1/routers/compat/chat.py create mode 100644 astrbot/dashboard/v1/routers/compat/common.py create mode 100644 astrbot/dashboard/v1/routers/compat/conversations.py create mode 100644 astrbot/dashboard/v1/routers/compat/files.py create mode 100644 astrbot/dashboard/v1/routers/compat/knowledge_bases.py create mode 100644 astrbot/dashboard/v1/routers/compat/personas.py create mode 100644 astrbot/dashboard/v1/routers/compat/sessions.py create mode 100644 astrbot/dashboard/v1/routers/compat/system.py create mode 100644 astrbot/dashboard/v1/routers/config_profiles.py create mode 100644 astrbot/dashboard/v1/routers/extensions.py create mode 100644 astrbot/dashboard/v1/routers/open_api_compat.py create mode 100644 astrbot/dashboard/v1/routers/plugins.py create mode 100644 astrbot/dashboard/v1/routers/providers.py create mode 100644 astrbot/dashboard/v1/schemas.py create mode 100644 dashboard/scripts/generate_openapi_client.py create mode 100644 dashboard/src/api/generated/openapi-v1.ts create mode 100644 dashboard/src/api/http.ts create mode 100644 dashboard/src/api/v1.ts create mode 100644 openspec/openapi-v1.yaml create mode 100644 tests/test_cli_command_aliases.py create mode 100644 tests/test_cli_service.py create mode 100644 tests/test_fastapi_v1_dashboard.py create mode 100644 tests/unit/test_live_chat_service.py create mode 100644 tests/unit/test_open_api_service_ws.py diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py index b32891096e..af2b1a0b5e 100644 --- a/astrbot/core/platform/platform.py +++ b/astrbot/core/platform/platform.py @@ -157,7 +157,7 @@ async def webhook_callback(self, request: Any) -> Any: 当 Dashboard 收到 /api/platform/webhook/{uuid} 请求时,会调用此方法。 Args: - request: Quart 请求对象 + request: webhook 请求对象 Returns: 响应内容,格式取决于具体平台的要求 diff --git a/astrbot/core/platform/sources/lark/server.py b/astrbot/core/platform/sources/lark/server.py index 52177ebb0c..e83ab5a2fc 100644 --- a/astrbot/core/platform/sources/lark/server.py +++ b/astrbot/core/platform/sources/lark/server.py @@ -132,7 +132,7 @@ async def handle_callback(self, request) -> tuple[dict, int] | dict: """处理 webhook 回调,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: webhook 请求对象 Returns: 响应数据 diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 275f01c720..2c9f6cabab 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -5,12 +5,12 @@ from binascii import Error as BinasciiError from typing import cast -import quart from botpy import BotAPI, BotHttp, BotWebSocket, Client, ConnectionSession, Token from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ed25519 from astrbot.api import logger +from astrbot.core.platform.webhook_server import FastAPIWebhookServer # remove logger handler for handler in logging.root.handlers[:]: @@ -90,7 +90,7 @@ def __init__( self.api: BotAPI = BotAPI(http=self.http) self.token = Token(self.appid, self.secret) - self.server = quart.Quart(__name__) + self.server = FastAPIWebhookServer("qq-official-webhook") self.server.add_url_rule( "/astrbot-qo-webhook/callback", view_func=self.callback, @@ -159,15 +159,15 @@ def pop_extra_data(self, message_id: str) -> dict: """Pop and return extra fields cached from the raw webhook payload for a given message ID.""" return self._extra_data_cache.pop(message_id, {}) - async def callback(self): + async def callback(self, request): """内部服务器的回调入口""" - return await self.handle_callback(quart.request) + return await self.handle_callback(request) async def handle_callback(self, request) -> dict: """处理 webhook 回调,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 响应数据 diff --git a/astrbot/core/platform/sources/slack/client.py b/astrbot/core/platform/sources/slack/client.py index efd7a6f3d2..97f5f26ae1 100644 --- a/astrbot/core/platform/sources/slack/client.py +++ b/astrbot/core/platform/sources/slack/client.py @@ -2,11 +2,10 @@ import hashlib import hmac import json -import logging from collections.abc import Callable from typing import cast -from quart import Quart, Response, request +from fastapi.responses import Response from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest @@ -14,10 +13,11 @@ from slack_sdk.web.async_client import AsyncWebClient from astrbot.api import logger +from astrbot.core.platform.webhook_server import FastAPIWebhookServer class SlackWebhookClient: - """Slack Webhook 模式客户端,使用 Quart 作为 Web 服务器""" + """Slack Webhook 模式客户端,使用 FastAPI 作为 Web 服务器""" def __init__( self, @@ -35,20 +35,16 @@ def __init__( self.path = path self.event_handler = event_handler - self.app = Quart(__name__) + self.app = FastAPIWebhookServer("slack-webhook") self._setup_routes() - # 禁用 Quart 的默认日志输出 - logging.getLogger("quart.app").setLevel(logging.WARNING) - logging.getLogger("quart.serving").setLevel(logging.WARNING) - self.shutdown_event = asyncio.Event() def _setup_routes(self) -> None: """设置路由""" @self.app.route(self.path, methods=["POST"]) - async def slack_events(): + async def slack_events(request): """内部服务器的 POST 回调入口""" return await self.handle_callback(request) @@ -61,7 +57,7 @@ async def handle_callback(self, req): """处理 Slack 回调请求,可被统一 webhook 入口复用 Args: - req: Quart 请求对象 + req: webhook 请求对象 Returns: Response 对象或字典 @@ -75,7 +71,7 @@ async def handle_callback(self, req): timestamp = req.headers.get("X-Slack-Request-Timestamp") signature = req.headers.get("X-Slack-Signature") if not timestamp or not signature: - return Response("Missing headers", status=400) + return Response("Missing headers", status_code=400) # Calculate the HMAC signature sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}" my_signature = ( @@ -89,7 +85,7 @@ async def handle_callback(self, req): # Verify the signature if not hmac.compare_digest(my_signature, signature): logger.warning("Slack request signature verification failed") - return Response("Invalid signature", status=400) + return Response("Invalid signature", status_code=400) logger.info(f"Received Slack event: {event_data}") # 处理 URL 验证事件 @@ -99,11 +95,11 @@ async def handle_callback(self, req): if self.event_handler and event_data.get("type") == "event_callback": await self.event_handler(event_data) - return Response("", status=200) + return Response("", status_code=200) except Exception as e: logger.error(f"处理 Slack 事件时出错: {e}") - return Response("Internal Server Error", status=500) + return Response("Internal Server Error", status_code=500) async def start(self) -> None: """启动 Webhook 服务器""" diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 58ee927d28..fed71e37d4 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -8,7 +8,6 @@ from typing import Any, cast from urllib.parse import unquote -import quart from requests import Response from wechatpy.enterprise import WeChatClient, parse_message from wechatpy.enterprise.crypto import WeChatCrypto @@ -28,6 +27,7 @@ ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.webhook_server import FastAPIWebhookServer from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import MediaResolver from astrbot.core.utils.webhook_utils import log_webhook_info @@ -65,7 +65,7 @@ def _extract_wecom_media_filename(disposition: str | None) -> str | None: class WecomServer: def __init__(self, event_queue: asyncio.Queue, config: dict) -> None: - self.server = quart.Quart(__name__) + self.server = FastAPIWebhookServer("wecom-webhook") self.port = int(cast(str, config.get("port"))) self.callback_server_host = config.get("callback_server_host", "0.0.0.0") self.server.add_url_rule( @@ -89,15 +89,15 @@ def __init__(self, event_queue: asyncio.Queue, config: dict) -> None: self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None self.shutdown_event = asyncio.Event() - async def verify(self): + async def verify(self, request): """内部服务器的 GET 验证入口""" - return await self.handle_verify(quart.request) + return await self.handle_verify(request) async def handle_verify(self, request) -> str: """处理验证请求,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 验证响应 @@ -117,15 +117,15 @@ async def handle_verify(self, request) -> str: logger.error("验证请求有效性失败,签名异常,请检查配置。") raise - async def callback_command(self): + async def callback_command(self, request): """内部服务器的 POST 回调入口""" - return await self.handle_callback(quart.request) + return await self.handle_callback(request) async def handle_callback(self, request) -> str: """处理回调请求,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 响应内容 diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py index 80ec5179e3..acf162b123 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py @@ -6,9 +6,8 @@ from collections.abc import Callable from typing import Any -import quart - from astrbot.api import logger +from astrbot.core.platform.webhook_server import FastAPIWebhookServer from .wecomai_api import WecomAIBotAPIClient from .wecomai_utils import WecomAIBotConstants @@ -38,14 +37,13 @@ def __init__( self.api_client = api_client self.message_handler = message_handler - self.app = quart.Quart(__name__) + self.app = FastAPIWebhookServer("wecom-ai-bot-webhook") self._setup_routes() self.shutdown_event = asyncio.Event() def _setup_routes(self) -> None: - """设置 Quart 路由""" - # 使用 Quart 的 add_url_rule 方法添加路由 + """设置 FastAPI 路由""" self.app.add_url_rule( "/webhook/wecom-ai-bot", view_func=self.verify_url, @@ -58,15 +56,15 @@ def _setup_routes(self) -> None: methods=["POST"], ) - async def verify_url(self): + async def verify_url(self, request): """内部服务器的 GET 验证入口""" - return await self.handle_verify(quart.request) + return await self.handle_verify(request) async def handle_verify(self, request): """处理 URL 验证请求,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 验证响应元组 (content, status_code, headers) @@ -91,15 +89,15 @@ async def handle_verify(self, request): result = self.api_client.verify_url(msg_signature, timestamp, nonce, echostr) return result, 200, {"Content-Type": "text/plain"} - async def handle_message(self): + async def handle_message(self, request): """内部服务器的 POST 消息回调入口""" - return await self.handle_callback(quart.request) + return await self.handle_callback(request) async def handle_callback(self, request): """处理消息回调,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 响应元组 (content, status_code, headers) @@ -186,5 +184,5 @@ async def shutdown(self) -> None: self.shutdown_event.set() def get_app(self): - """获取 Quart 应用实例""" - return self.app + """获取 FastAPI 应用实例""" + return self.app.app 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 ba459746eb..865a9ac9b2 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 @@ -5,7 +5,6 @@ from collections.abc import Callable, Coroutine from typing import Any, cast -import quart from requests import Response from wechatpy import WeChatClient, create_reply, parse_message from wechatpy.crypto import WeChatCrypto @@ -25,6 +24,7 @@ ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.webhook_server import FastAPIWebhookServer from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import MediaResolver from astrbot.core.utils.webhook_utils import log_webhook_info @@ -44,7 +44,7 @@ def __init__( config: dict, user_buffer: dict[Any, dict[str, Any]], ) -> None: - self.server = quart.Quart(__name__) + self.server = FastAPIWebhookServer("weixin-official-account-webhook") self.port = int(cast(int | str, config.get("port"))) self.callback_server_host = config.get("callback_server_host", "0.0.0.0") self.token = config.get("token") @@ -73,15 +73,15 @@ def __init__( self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调 - async def verify(self): + async def verify(self, request): """内部服务器的 GET 验证入口""" - return await self.handle_verify(quart.request) + return await self.handle_verify(request) async def handle_verify(self, request) -> str: """处理验证请求,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 验证响应 @@ -105,9 +105,9 @@ async def handle_verify(self, request) -> str: logger.error("验证请求有效性失败,签名异常,请检查配置。") return "err" - async def callback_command(self): + async def callback_command(self, request): """内部服务器的 POST 回调入口""" - return await self.handle_callback(quart.request) + return await self.handle_callback(request) def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str: if xml and "" not in xml and nonce and timestamp: @@ -129,7 +129,7 @@ async def handle_callback(self, request) -> str: """处理回调请求,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 响应内容 diff --git a/astrbot/core/platform/webhook_server.py b/astrbot/core/platform/webhook_server.py new file mode 100644 index 0000000000..8c9efb7fa1 --- /dev/null +++ b/astrbot/core/platform/webhook_server.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response +from hypercorn.asyncio import serve +from hypercorn.config import Config as HyperConfig + + +class WebhookRequest: + def __init__(self, request: Request) -> None: + self._request = request + self.args = request.query_params + self.headers = request.headers + self.method = request.method + + @property + def json(self): + return self._request.json() + + async def get_data(self) -> bytes: + return await self._request.body() + + async def get_json(self, *, force: bool = False, silent: bool = False): + try: + return await self._request.json() + except Exception: + if silent: + return None + raise + + +def _response_from_result(result: Any): + if isinstance(result, Response): + return result + + if isinstance(result, tuple): + content = result[0] if result else "" + status_code = ( + result[1] if len(result) > 1 and isinstance(result[1], int) else 200 + ) + headers = result[2] if len(result) > 2 and isinstance(result[2], dict) else None + if isinstance(content, dict | list): + return JSONResponse(content, status_code=status_code, headers=headers) + return Response( + content=content, + status_code=status_code, + headers=headers, + media_type=headers.get("Content-Type") if headers else None, + ) + + if isinstance(result, dict | list): + return JSONResponse(result) + + return result + + +class FastAPIWebhookServer: + def __init__(self, name: str) -> None: + self.app = FastAPI(title=name, docs_url=None, redoc_url=None, openapi_url=None) + + def add_url_rule( + self, + path: str, + view_func: Callable, + methods: list[str] | None = None, + ) -> None: + async def endpoint(request: Request): + if inspect.signature(view_func).parameters: + result = view_func(WebhookRequest(request)) + else: + result = view_func() + if inspect.isawaitable(result): + result = await result + return _response_from_result(result) + + self.app.add_api_route( + path, + endpoint, + methods=methods or ["GET"], + include_in_schema=False, + ) + + def route(self, path: str, methods: list[str] | None = None): + def decorator(view_func: Callable): + self.add_url_rule(path, view_func, methods) + return view_func + + return decorator + + async def run_task( + self, + *, + host: str, + port: int, + shutdown_trigger: Callable | None = None, + **_kwargs, + ) -> None: + config = HyperConfig() + config.bind = [f"{host}:{port}"] + await serve(self.app, config, shutdown_trigger=shutdown_trigger) + + async def shutdown(self) -> None: + return None diff --git a/astrbot/dashboard/fastapi_compat.py b/astrbot/dashboard/fastapi_compat.py new file mode 100644 index 0000000000..651f19836f --- /dev/null +++ b/astrbot/dashboard/fastapi_compat.py @@ -0,0 +1,598 @@ +from __future__ import annotations + +import contextvars +import inspect +import re +from collections.abc import Callable, Iterable +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Request, WebSocket +from fastapi.encoders import jsonable_encoder +from fastapi.responses import FileResponse, JSONResponse, Response +from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.responses import StreamingResponse + +_request_var: contextvars.ContextVar[CompatRequest] = contextvars.ContextVar( + "dashboard_request" +) +_websocket_var: contextvars.ContextVar[CompatWebSocket] = contextvars.ContextVar( + "dashboard_websocket" +) +_g_var: contextvars.ContextVar[CompatG] = contextvars.ContextVar("dashboard_g") +_app_var: contextvars.ContextVar[FastAPIAppAdapter] = contextvars.ContextVar( + "dashboard_app" +) + + +class CompatArgs: + def __init__(self, values) -> None: + self._values = values + + def get(self, key: str, default: Any = None, type: Callable | None = None): + value = self._values.get(key, default) + if value is default or type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + +class CompatMultiDict: + def __init__(self, pairs: list[tuple[str, Any]]) -> None: + self._pairs = pairs + + def get(self, key: str, default: Any = None, type: Callable | None = None): + for item_key, item_value in reversed(self._pairs): + if item_key != key: + continue + if type is None: + return item_value + try: + return type(item_value) + except (TypeError, ValueError): + return default + return default + + def getlist(self, key: str) -> list[Any]: + return [item_value for item_key, item_value in self._pairs if item_key == key] + + def keys(self): + return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() + + def values(self): + return [self[key] for key in self.keys()] + + def items(self): + return [(key, self[key]) for key in self.keys()] + + def __contains__(self, key: str) -> bool: + return any(item_key == key for item_key, _ in self._pairs) + + def __getitem__(self, key: str): + value = self.get(key) + if value is None and key not in self: + raise KeyError(key) + return value + + def __bool__(self) -> bool: + return bool(self._pairs) + + +class CompatUploadFile: + def __init__(self, upload_file: StarletteUploadFile) -> None: + self._upload_file = upload_file + self.filename = upload_file.filename + self.content_type = upload_file.content_type + self.headers = upload_file.headers + self.content_length = self._resolve_content_length() + + def _resolve_content_length(self) -> int | None: + try: + raw = self.headers.get("content-length") + return int(raw) if raw else None + except (TypeError, ValueError): + return None + + async def save(self, destination: str | Path) -> None: + path = Path(destination) + try: + await self._upload_file.seek(0) + except Exception: + pass + with path.open("wb") as output: + while True: + chunk = await self._upload_file.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + + def __getattr__(self, key: str): + return getattr(self._upload_file, key) + + +class CompatG: + def __init__(self) -> None: + self._values: dict[str, Any] = {} + + def get(self, key: str, default: Any = None): + return self._values.get(key, default) + + def __getattr__(self, key: str): + try: + return self._values[key] + except KeyError as exc: + raise AttributeError(key) from exc + + def __setattr__(self, key: str, value: Any) -> None: + if key == "_values": + super().__setattr__(key, value) + return + self._values[key] = value + + +class CompatRequest: + def __init__(self, request: Request) -> None: + self._request = request + self.args = CompatArgs(request.query_params) + self.headers = request.headers + self.cookies = request.cookies + self.method = request.method + self.path = request.url.path + self.content_type = request.headers.get("content-type") + self.remote_addr = request.client.host if request.client else None + self._form_cache: CompatMultiDict | None = None + self._files_cache: CompatMultiDict | None = None + + @property + def json(self): + return self.get_json() + + @property + def files(self): + return self._load_files() + + @property + def form(self): + return self._load_form() + + async def get_json(self, silent: bool = False): + try: + return await self._request.json() + except Exception: + if silent: + return None + raise + + async def _load_form_parts(self) -> None: + if self._form_cache is not None and self._files_cache is not None: + return + form = await self._request.form() + form_pairs: list[tuple[str, Any]] = [] + file_pairs: list[tuple[str, Any]] = [] + for key, value in form.multi_items(): + if isinstance(value, StarletteUploadFile): + file_pairs.append((key, CompatUploadFile(value))) + else: + form_pairs.append((key, value)) + self._form_cache = CompatMultiDict(form_pairs) + self._files_cache = CompatMultiDict(file_pairs) + + async def _load_form(self) -> CompatMultiDict: + await self._load_form_parts() + assert self._form_cache is not None + return self._form_cache + + async def _load_files(self) -> CompatMultiDict: + await self._load_form_parts() + assert self._files_cache is not None + return self._files_cache + + +class CompatWebSocket: + def __init__(self, websocket: WebSocket) -> None: + self._websocket = websocket + self.args = CompatArgs(websocket.query_params) + self.headers = websocket.headers + + async def accept(self) -> None: + await self._websocket.accept() + + async def receive_json(self): + return await self._websocket.receive_json() + + async def send_json(self, payload: Any) -> None: + await self._websocket.send_json(payload) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + await self._websocket.close(code=code, reason=reason or "") + + +class CompatTestHeaders: + def __init__(self, headers: httpx.Headers) -> None: + self._headers = headers + + def getlist(self, key: str) -> list[str]: + values = self._headers.get_list(key) + if key.lower() == "set-cookie": + return [value.replace('=""', "=") for value in values] + return values + + def get(self, key: str, default: Any = None): + value = self._headers.get(key, default) + if isinstance(value, str) and key.lower() == "set-cookie": + return value.replace('=""', "=") + return value + + def __getitem__(self, key: str): + return self._headers[key] + + def __contains__(self, key: str) -> bool: + return key in self._headers + + +class CompatTestResponse: + def __init__(self, response: httpx.Response) -> None: + self._response = response + self.status_code = response.status_code + self.headers = CompatTestHeaders(response.headers) + self.data = response.content + self.content = response.content + self.text = response.text + + async def get_json(self): + return self._response.json() + + async def get_data(self): + return self._response.content + + +class CompatTestClient: + def __init__(self, app: FastAPI) -> None: + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) + + @staticmethod + def _is_file_storage(value: Any) -> bool: + return hasattr(value, "stream") and hasattr(value, "filename") + + @classmethod + def _file_tuple(cls, value: Any): + stream = value.stream + if hasattr(stream, "seek"): + stream.seek(0) + content = stream.read() + filename = getattr(value, "filename", "upload.bin") + content_type = getattr(value, "content_type", None) + return filename, content, content_type + + @classmethod + def _normalize_data(cls, data: Any): + if not isinstance(data, dict): + return data, None + + form: dict[str, Any] = {} + files: list[tuple[str, tuple]] = [] + for key, value in data.items(): + if cls._is_file_storage(value): + files.append((key, cls._file_tuple(value))) + continue + if isinstance(value, Iterable) and not isinstance( + value, str | bytes | dict + ): + values = list(value) + if values and all(cls._is_file_storage(item) for item in values): + files.extend((key, cls._file_tuple(item)) for item in values) + continue + form[key] = value + return form, files or None + + @classmethod + def _normalize_files(cls, files: Any): + if isinstance(files, dict): + items = files.items() + elif isinstance(files, Iterable) and not isinstance(files, str | bytes): + items = files + else: + return files + + normalized_files: list[tuple[str, Any]] = [] + for key, value in items: + if cls._is_file_storage(value): + normalized_files.append((key, cls._file_tuple(value))) + continue + if isinstance(value, Iterable) and not isinstance( + value, str | bytes | dict + ): + values = list(value) + if values and all(cls._is_file_storage(item) for item in values): + normalized_files.extend( + (key, cls._file_tuple(item)) for item in values + ) + continue + normalized_files.append((key, value)) + return normalized_files + + async def request(self, method: str, url: str, **kwargs): + data = kwargs.pop("data", None) + if data is not None and "files" not in kwargs: + normalized_data, files = self._normalize_data(data) + kwargs["data"] = normalized_data + if files: + kwargs["files"] = files + elif data is not None: + kwargs["data"] = data + if "files" in kwargs: + kwargs["files"] = self._normalize_files(kwargs["files"]) + response = await self._client.request(method, url, **kwargs) + return CompatTestResponse(response) + + async def get(self, url: str, **kwargs): + return await self.request("GET", url, **kwargs) + + async def post(self, url: str, **kwargs): + return await self.request("POST", url, **kwargs) + + async def put(self, url: str, **kwargs): + return await self.request("PUT", url, **kwargs) + + async def patch(self, url: str, **kwargs): + return await self.request("PATCH", url, **kwargs) + + async def delete(self, url: str, **kwargs): + return await self.request("DELETE", url, **kwargs) + + +class _ContextProxy: + def __init__(self, var) -> None: + self._var = var + + def __getattr__(self, key: str): + return getattr(self._var.get(), key) + + def __setattr__(self, key: str, value: Any) -> None: + if key == "_var": + super().__setattr__(key, value) + return + setattr(self._var.get(), key, value) + + +request = _ContextProxy(_request_var) +websocket = _ContextProxy(_websocket_var) +g = _ContextProxy(_g_var) +current_app = _ContextProxy(_app_var) + + +@contextmanager +def bind_request_context( + request_: Request, + app: FastAPIAppAdapter, + g_obj: CompatG | None = None, +): + token_request = _request_var.set(CompatRequest(request_)) + token_g = _g_var.set(g_obj or getattr(request_.state, "dashboard_g", CompatG())) + token_app = _app_var.set(app) + try: + yield _g_var.get() + finally: + _app_var.reset(token_app) + _g_var.reset(token_g) + _request_var.reset(token_request) + + +@contextmanager +def bind_websocket_context( + websocket_: WebSocket, + app: FastAPIAppAdapter, + g_obj: CompatG | None = None, +): + token_websocket = _websocket_var.set(CompatWebSocket(websocket_)) + token_g = _g_var.set(g_obj or getattr(websocket_.state, "dashboard_g", CompatG())) + token_app = _app_var.set(app) + try: + yield + finally: + _app_var.reset(token_app) + _g_var.reset(token_g) + _websocket_var.reset(token_websocket) + + +def jsonify(payload: Any = None): + return JSONResponse(payload if payload is not None else {}) + + +async def make_response(*args): + if not args: + return Response() + content = args[0] + status_code = args[1] if len(args) > 1 and isinstance(args[1], int) else None + headers = args[1] if len(args) > 1 and isinstance(args[1], dict) else None + if len(args) > 2 and isinstance(args[2], dict): + headers = args[2] + if isinstance(content, Response): + if status_code is not None: + content.status_code = status_code + if headers: + content.headers.update(headers) + return content + if hasattr(content, "__aiter__"): + return StreamingResponse( + content, + status_code=status_code or 200, + headers=headers, + ) + return Response( + content=content, + status_code=status_code or 200, + headers=headers, + ) + + +async def send_file(path: str | Path, mimetype: str | None = None, **kwargs): + filename = kwargs.get("attachment_filename") or kwargs.get("download_name") + as_attachment = bool(kwargs.get("as_attachment")) + return FileResponse( + path, + media_type=mimetype, + filename=filename if as_attachment else None, + ) + + +def abort(status_code: int): + raise HTTPException(status_code=status_code) + + +def _convert_rule(path: str) -> str: + converted = re.sub(r"", r"{\1:path}", path) + converted = re.sub(r"<([A-Za-z_][A-Za-z0-9_]*)>", r"{\1}", converted) + return converted + + +async def _call_view(view_func: Callable, path_params: dict[str, Any]): + result = view_func(**path_params) + if inspect.isawaitable(result): + result = await result + return _coerce_view_result(result) + + +def _coerce_view_result(result: Any): + if isinstance(result, Response): + return result + + if isinstance(result, tuple): + content = result[0] if result else None + status_code = next((item for item in result[1:] if isinstance(item, int)), 200) + headers = next( + (item for item in result[1:] if isinstance(item, dict)), + None, + ) + if isinstance(content, Response): + content.status_code = status_code + if headers: + content.headers.update(headers) + return content + return _response_from_content(content, status_code=status_code, headers=headers) + + if isinstance(result, dict | list): + return JSONResponse(jsonable_encoder(result)) + return result + + +def _response_from_content( + content: Any, + *, + status_code: int, + headers: dict[str, str] | None = None, +): + if isinstance(content, dict | list): + return JSONResponse( + jsonable_encoder(content), + status_code=status_code, + headers=headers, + ) + return Response( + content=content, + status_code=status_code, + headers=headers, + ) + + +async def call_request_view( + request_: Request, + app: FastAPIAppAdapter, + view_func: Callable, + path_params: dict[str, Any] | None = None, + g_obj: CompatG | None = None, +): + with bind_request_context(request_, app, g_obj): + return await _call_view(view_func, path_params or {}) + + +async def call_websocket_view( + websocket_: WebSocket, + app: FastAPIAppAdapter, + view_func: Callable, + path_params: dict[str, Any] | None = None, + *, + accept: bool = True, +): + if accept: + await websocket_.accept() + with bind_websocket_context(websocket_, app): + return await _call_view(view_func, path_params or {}) + + +class FastAPIAppAdapter: + def __init__(self, app: FastAPI, static_folder: str | None = None) -> None: + self._app = app + self.static_folder = static_folder + self.config: dict[str, Any] = {} + self.debug = False + self.testing = False + self.name = "dashboard" + + def add_url_rule( + self, + path: str, + view_func: Callable, + methods: list[str] | None = None, + endpoint: str | None = None, + ) -> None: + route_path = _convert_rule(path) + methods = methods or ["GET"] + + async def endpoint_func(request_: Request): + with bind_request_context(request_, self): + return await _call_view(view_func, dict(request_.path_params)) + + self._app.add_api_route( + route_path, + endpoint_func, + methods=methods, + name=endpoint, + include_in_schema=False, + ) + + def websocket(self, path: str): + route_path = _convert_rule(path) + + def decorator(view_func: Callable): + async def endpoint_func(websocket_: WebSocket): + return await call_websocket_view( + websocket_, + self, + view_func, + dict(websocket_.path_params), + ) + + self._app.add_api_websocket_route( + route_path, + endpoint_func, + name=getattr(view_func, "__name__", None), + ) + return view_func + + return decorator + + def errorhandler(self, _status_code: int): + def decorator(func: Callable): + return func + + return decorator + + async def send_static_file(self, filename: str): + if not self.static_folder: + raise HTTPException(status_code=404) + return FileResponse(Path(self.static_folder) / filename) + + def test_client(self): + self.testing = True + return CompatTestClient(self._app) + + +CompatResponse = Response diff --git a/astrbot/dashboard/plugin_page_auth.py b/astrbot/dashboard/plugin_page_auth.py index f2571b3eef..894491d5c5 100644 --- a/astrbot/dashboard/plugin_page_auth.py +++ b/astrbot/dashboard/plugin_page_auth.py @@ -1,6 +1,6 @@ from urllib.parse import unquote -from quart import request +from astrbot.dashboard.fastapi_compat import request PLUGIN_PAGE_CONTENT_PREFIX = "/api/plugin/page/content/" PLUGIN_PAGE_BRIDGE_PATH = "/api/plugin/page/bridge-sdk.js" diff --git a/astrbot/dashboard/routes/api_key.py b/astrbot/dashboard/routes/api_key.py index 4b957fe8ea..8d657f3463 100644 --- a/astrbot/dashboard/routes/api_key.py +++ b/astrbot/dashboard/routes/api_key.py @@ -1,21 +1,17 @@ -import hashlib -import secrets -from datetime import datetime, timedelta, timezone - -from quart import g, request - from astrbot.core.db import BaseDatabase -from astrbot.core.utils.datetime_utils import normalize_datetime_utc +from astrbot.dashboard.fastapi_compat import g, request +from astrbot.dashboard.services.api_key_service import ( + ApiKeyService, + ApiKeyServiceError, +) from .route import Response, Route, RouteContext -ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im") - class ApiKeyRoute(Route): def __init__(self, context: RouteContext, db: BaseDatabase) -> None: super().__init__(context) - self.db = db + self.service = ApiKeyService(db) self.routes = { "/apikey/list": ("GET", self.list_api_keys), "/apikey/create": ("POST", self.create_api_key), @@ -25,119 +21,39 @@ def __init__(self, context: RouteContext, db: BaseDatabase) -> None: self.register_routes() @staticmethod - def _normalize_utc(dt: datetime | None) -> datetime | None: - return normalize_datetime_utc(dt) - - @classmethod - def _serialize_datetime(cls, dt: datetime | None) -> str | None: - normalized = cls._normalize_utc(dt) - if normalized is None: - return None - return normalized.astimezone().isoformat() + def _ok(data=None): + return Response().ok(data=data).__dict__ @staticmethod - def _hash_key(raw_key: str) -> str: - return hashlib.pbkdf2_hmac( - "sha256", - raw_key.encode("utf-8"), - b"astrbot_api_key", - 100_000, - ).hex() + def _error(message: str): + return Response().error(message).__dict__ - @staticmethod - def _serialize_api_key(key) -> dict: - expires_at = ApiKeyRoute._normalize_utc(key.expires_at) - return { - "key_id": key.key_id, - "name": key.name, - "key_prefix": key.key_prefix, - "scopes": key.scopes or [], - "created_by": key.created_by, - "created_at": ApiKeyRoute._serialize_datetime(key.created_at), - "updated_at": ApiKeyRoute._serialize_datetime(key.updated_at), - "last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at), - "expires_at": ApiKeyRoute._serialize_datetime(key.expires_at), - "revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at), - "is_revoked": key.revoked_at is not None, - "is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)), - } + async def _json_body(self): + return await request.json or {} - async def list_api_keys(self): - keys = await self.db.list_api_keys() - return ( - Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__ - ) + async def _run(self, operation): + try: + return self._ok(await operation()) + except ApiKeyServiceError as exc: + return self._error(str(exc)) - async def create_api_key(self): - post_data = await request.json or {} + async def _run_json(self, operation): + payload = await self._json_body() + return await self._run(lambda: operation(payload)) - name = str(post_data.get("name", "")).strip() or "Untitled API Key" - scopes = post_data.get("scopes") - if scopes is None: - normalized_scopes = list(ALL_OPEN_API_SCOPES) - elif isinstance(scopes, list): - normalized_scopes = [ - scope - for scope in scopes - if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES - ] - normalized_scopes = list(dict.fromkeys(normalized_scopes)) - if not normalized_scopes: - return Response().error("At least one valid scope is required").__dict__ - else: - return Response().error("Invalid scopes").__dict__ + async def list_api_keys(self): + return await self._run(self.service.list_api_keys) - expires_at = None - expires_in_days = post_data.get("expires_in_days") - if expires_in_days is not None: - try: - expires_in_days_int = int(expires_in_days) - except (TypeError, ValueError): - return Response().error("expires_in_days must be an integer").__dict__ - if expires_in_days_int <= 0: - return ( - Response().error("expires_in_days must be greater than 0").__dict__ - ) - expires_at = datetime.now(timezone.utc) + timedelta( - days=expires_in_days_int + async def create_api_key(self): + return await self._run_json( + lambda payload: self.service.create_api_key_from_legacy_payload( + payload, + created_by=g.get("username", "unknown"), ) - - raw_key = f"abk_{secrets.token_urlsafe(32)}" - key_hash = self._hash_key(raw_key) - key_prefix = raw_key[:12] - created_by = g.get("username", "unknown") - - api_key = await self.db.create_api_key( - name=name, - key_hash=key_hash, - key_prefix=key_prefix, - scopes=normalized_scopes, # type: ignore - created_by=created_by, - expires_at=expires_at, ) - payload = self._serialize_api_key(api_key) - payload["api_key"] = raw_key - return Response().ok(data=payload).__dict__ - async def revoke_api_key(self): - post_data = await request.json or {} - key_id = post_data.get("key_id") - if not key_id: - return Response().error("Missing key: key_id").__dict__ - - success = await self.db.revoke_api_key(key_id) - if not success: - return Response().error("API key not found").__dict__ - return Response().ok().__dict__ + return await self._run_json(self.service.revoke_api_key_from_legacy_payload) async def delete_api_key(self): - post_data = await request.json or {} - key_id = post_data.get("key_id") - if not key_id: - return Response().error("Missing key: key_id").__dict__ - - success = await self.db.delete_api_key(key_id) - if not success: - return Response().error("API key not found").__dict__ - return Response().ok().__dict__ + return await self._run_json(self.service.delete_api_key_from_legacy_payload) diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 70747a8a29..b39ec15a8f 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -1,73 +1,27 @@ -import asyncio -import datetime -import os - -import jwt -import pyotp -from quart import current_app, g, jsonify, make_response, request - -from astrbot import logger -from astrbot.core import DEMO_MODE -from astrbot.core.utils.auth_password import ( - is_default_dashboard_password, - is_legacy_dashboard_password, - validate_dashboard_password, - verify_dashboard_password, +from astrbot.dashboard.fastapi_compat import ( + current_app, + g, + jsonify, + make_response, + request, ) -from astrbot.core.utils.totp import ( +from astrbot.dashboard.services.auth_service import ( + DASHBOARD_JWT_COOKIE_MAX_AGE, + DASHBOARD_JWT_COOKIE_NAME, TOTP_TRUSTED_DEVICE_COOKIE_NAME, TOTP_TRUSTED_DEVICE_MAX_AGE, - TwoFactorCodeType, - consume_configured_totp_code, - consume_rotation_verified, - consume_totp_code, - generate_recovery_code, - is_totp_enabled, - is_totp_trusted_device_valid, - issue_totp_trusted_device, - revoke_user_trusted_devices, - set_pending_totp_secret, - set_rotation_verified, - verify_configured_2fa_code, -) -from astrbot.dashboard.password_state import ( - get_dashboard_password_hash, - is_password_change_required, - is_password_storage_upgraded, - set_dashboard_password_hashes, - set_password_change_required, - set_password_storage_upgraded, + AuthService, + AuthServiceResult, ) from .route import Response, Route, RouteContext -DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt" -DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 -SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" -SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" -LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"} -DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE = ( - "Login failed. If this is your first time using AstrBot, the old default " - "astrbot password has been replaced by a random strong password printed in " - "the startup logs. Check the initial password in the logs and try again. " - "Learn more: https://docs.astrbot.app/en/faq.html\n\n" - "登录失败。如果您是初次使用,旧版默认 astrbot 密码已改为启动日志中输出的" - "随机强密码。请使用日志中提供的的初始密码来登录。了解更多:" - "https://docs.astrbot.app/faq.html" -) -LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE = ( - "Incorrect username or password. If you cannot log in after upgrading " - "AstrBot even though the password is correct, see " - "https://docs.astrbot.app/en/faq.html\n\n" - "用户名或密码错误。如果你在升级 AstrBot 后遇到了密码正确但无法登录的情况," - "请参考 https://docs.astrbot.app/faq.html" -) +__all__ = ("AuthRoute",) class AuthRoute(Route): def __init__(self, context: RouteContext, db) -> None: super().__init__(context) - self.db = db self.routes = { "/auth/login": ("POST", self.login), "/auth/logout": ("POST", self.logout), @@ -78,261 +32,43 @@ def __init__(self, context: RouteContext, db) -> None: "/auth/totp/recovery": ("POST", self.totp_recovery), "/auth/account/edit": ("POST", self.edit_account), } + self.service = AuthService(db, self.config) self.register_routes() - async def setup_status(self): - return ( - Response() - .ok( - { - "setup_required": await self._is_setup_required(), - "skip_default_password_auth": self._can_skip_default_password_auth(), - "password_upgrade_required": not await is_password_storage_upgraded( - self.db, - self.config, - ), - } - ) - .__dict__ - ) - - async def totp_setup(self): - post_data = await request.json - - if isinstance(post_data, dict) and post_data.get("secret"): - secret = post_data["secret"] - code = post_data.get("code") - if not isinstance(secret, str) or not secret.strip(): - return Response().error("Invalid request payload").__dict__ - - if not isinstance(code, str) or not code.strip(): - return Response().error("TOTP 验证码是必需的").__dict__ - if not await consume_totp_code(secret, code): - return Response().error("TOTP 验证码无效").__dict__ - - if is_totp_enabled(self.config) and not consume_rotation_verified(): - return Response().error("需要先验证当前 TOTP").__dict__ - - set_pending_totp_secret(secret) - recovery_code, recovery_code_hash = generate_recovery_code() - return ( - Response() - .ok( - { - "recovery_code": recovery_code, - "recovery_code_hash": recovery_code_hash, - }, - "TOTP verified", - ) - .__dict__ - ) - - if is_totp_enabled(self.config): - if not isinstance(post_data, dict): - return Response().error("Invalid request payload").__dict__ - - set_rotation_verified(False) + async def _json_body(self): + return await request.json - code = post_data.get("code") - if isinstance(code, str) and code.strip(): - if await consume_configured_totp_code(self.config, code): - set_rotation_verified(True) - return Response().ok({"secret": pyotp.random_base32()}).__dict__ - return Response().error("当前 TOTP 验证码无效").__dict__ + async def _service_json_response(self, operation, *args, **kwargs): + return await self._service_response( + await operation(await self._json_body(), *args, **kwargs) + ) - return Response().error("需要提供 TOTP 验证码或新密钥").__dict__ + async def setup_status(self): + return await self._service_response(await self.service.setup_status()) - return Response().ok({"secret": pyotp.random_base32()}).__dict__ + async def totp_setup(self): + return await self._service_json_response(self.service.totp_setup) async def totp_recovery(self): - # This endpoint MUST NOT persist the recovery code. - recovery_code, recovery_code_hash = generate_recovery_code() - return ( - Response() - .ok( - { - "recovery_code": recovery_code, - "recovery_code_hash": recovery_code_hash, - } - ) - .__dict__ - ) + return await self._service_response(await self.service.totp_recovery()) async def setup(self): - if not self._can_skip_default_password_auth(): - return Response().error("Setup without password is not enabled").__dict__ - if not await self._is_setup_required(): - return Response().error("Setup is not required").__dict__ - - return await self._complete_setup() + return await self._service_json_response(self.service.setup) async def setup_authenticated(self): - if not await self._is_setup_required(): - return Response().error("Setup is not required").__dict__ - if not isinstance(getattr(g, "username", None), str): - return Response().error("未授权").__dict__ - - return await self._complete_setup() - - async def _complete_setup(self): - post_data = await request.json - if not isinstance(post_data, dict): - return Response().error("Invalid request payload").__dict__ - - new_username = post_data.get("username") - new_password = post_data.get("password") - confirm_password = post_data.get("confirm_password") - if not isinstance(new_username, str) or len(new_username.strip()) < 3: - return Response().error("用户名长度至少3位").__dict__ - if not isinstance(new_password, str): - return Response().error("新密码无效").__dict__ - if not isinstance(confirm_password, str) or confirm_password != new_password: - return Response().error("两次输入的新密码不一致").__dict__ - - try: - validate_dashboard_password(new_password) - except ValueError as e: - return Response().error(str(e)).__dict__ - - username = new_username.strip() - self.config["dashboard"]["username"] = username - set_dashboard_password_hashes(self.config, new_password) - await set_password_storage_upgraded(self.db, self.config, True) - await set_password_change_required(self.db, self.config, False) - self.config.save_config() - - token = self.generate_jwt(username) - payload = Response().ok( - { - "token": token, - "username": username, - "change_pwd_hint": False, - "legacy_pwd_hint": False, - "password_upgrade_required": False, - }, - "Setup completed successfully", + return await self._service_json_response( + self.service.setup_authenticated, + getattr(g, "username", None), ) - response = await make_response(jsonify(payload.__dict__)) - self._set_dashboard_jwt_cookie(response, token) - return response async def login(self): - username = self.config["dashboard"]["username"] - storage_upgraded = await is_password_storage_upgraded(self.db, self.config) - password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) - post_data = await request.json - - req_username = ( - post_data.get("username") if isinstance(post_data, dict) else None - ) - req_password = ( - post_data.get("password") if isinstance(post_data, dict) else None - ) - totp_code = post_data.get("code") if isinstance(post_data, dict) else None - trust_device_flag = ( - post_data.get("trust_device_flag") is True - if isinstance(post_data, dict) - else False + return await self._service_json_response( + self.service.login, + trusted_device_cookie_token=request.cookies.get( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + "", + ).strip(), ) - if not isinstance(req_username, str) or not isinstance(req_password, str): - return Response().error("Invalid request payload").__dict__ - - login_verified = req_username == username and verify_dashboard_password( - password, req_password - ) - - if not login_verified: - await asyncio.sleep(3) - if req_password == "astrbot": - return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ - if is_legacy_dashboard_password(password): - return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ - return await self._error_response( - "用户名或密码错误", - 401, - ) - - totp_verified = False - - if is_totp_enabled(self.config): - cookie_token = request.cookies.get( - TOTP_TRUSTED_DEVICE_COOKIE_NAME, "" - ).strip() - if not await is_totp_trusted_device_valid( - self.config, self.db, cookie_token - ): - if not isinstance(totp_code, str) or not totp_code.strip(): - response = await make_response( - jsonify( - { - "status": "error", - "message": "需要 TOTP 验证", - "data": {"totp_required": True}, - } - ) - ) - response.status_code = 401 - return response - verified_type = await verify_configured_2fa_code( - self.config, totp_code, allow_recovery=True - ) - if verified_type is TwoFactorCodeType.TOTP: - totp_verified = True - elif verified_type is TwoFactorCodeType.RECOVERY: - self.config["dashboard"]["totp"] = { - "enable": False, - "secret": "", - "recovery_code_hash": "", - } - await revoke_user_trusted_devices(self.db) - self.config.save_config() - elif len(totp_code) == 6 and totp_code.isdigit(): - return await self._error_response("TOTP 验证码无效", 401) - else: - return await self._error_response("恢复码无效", 401) - - change_pwd_hint = False - legacy_pwd_hint = is_legacy_dashboard_password(password) - password_change_required = await is_password_change_required( - self.db, - self.config, - ) - if ( - storage_upgraded - and username == "astrbot" - and is_default_dashboard_password(password) - and not DEMO_MODE - ): - change_pwd_hint = True - legacy_pwd_hint = True - logger.warning("为了保证安全,请尽快修改默认密码。") - if password_change_required and not DEMO_MODE: - change_pwd_hint = True - token = self.generate_jwt(username) - login_data = { - "token": token, - "username": username, - "change_pwd_hint": change_pwd_hint, - "legacy_pwd_hint": legacy_pwd_hint, - "password_upgrade_required": not storage_upgraded, - } - payload = Response().ok(login_data) - response = await make_response(jsonify(payload.__dict__)) - self._set_dashboard_jwt_cookie(response, token) - - if totp_verified and trust_device_flag: - raw_token = await issue_totp_trusted_device(self.config, self.db) - if raw_token: - response.set_cookie( - TOTP_TRUSTED_DEVICE_COOKIE_NAME, - raw_token, - max_age=TOTP_TRUSTED_DEVICE_MAX_AGE, - httponly=True, - samesite="Strict", - secure=AuthRoute._use_secure_dashboard_jwt_cookie(), - path="/api/auth", - ) - return response async def logout(self): response = await make_response( @@ -342,117 +78,38 @@ async def logout(self): return response async def edit_account(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - storage_upgraded = await is_password_storage_upgraded(self.db, self.config) - password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) - post_data = await request.json - if not isinstance(post_data, dict): - return Response().error("Invalid request payload").__dict__ - - req_password = post_data.get("password") - if not isinstance(req_password, str): - return Response().error("Invalid request payload").__dict__ - - if not verify_dashboard_password(password, req_password): - return Response().error("原密码错误").__dict__ - - new_pwd = post_data.get("new_password", None) - new_username = post_data.get("new_username", None) - password_change_required = await is_password_change_required( - self.db, - self.config, - ) - if (not storage_upgraded or password_change_required) and not new_pwd: - return Response().error("请设置新密码以完成安全升级").__dict__ - if not new_pwd and not new_username: - return Response().error("新用户名和新密码不能同时为空").__dict__ - - # Verify password confirmation - if new_pwd: - if not isinstance(new_pwd, str): - return Response().error("新密码无效").__dict__ - confirm_pwd = post_data.get("confirm_password", None) - if not isinstance(confirm_pwd, str) or confirm_pwd != new_pwd: - return Response().error("两次输入的新密码不一致").__dict__ - try: - validate_dashboard_password(new_pwd) - except ValueError as e: - return Response().error(str(e)).__dict__ - set_dashboard_password_hashes(self.config, new_pwd) - await set_password_storage_upgraded(self.db, self.config, True) - await set_password_change_required(self.db, self.config, False) - if is_totp_enabled(self.config): - await revoke_user_trusted_devices(self.db) - if new_username: - self.config["dashboard"]["username"] = new_username - - self.config.save_config() - - return Response().ok(None, "Updated account successfully").__dict__ + return await self._service_json_response(self.service.edit_account) def generate_jwt(self, username): - payload = { - "username": username, - "exp": datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(days=7), - } - jwt_token = self.config["dashboard"].get("jwt_secret", None) - if not jwt_token: - raise ValueError("JWT secret is not set in the cmd_config.") - token = jwt.encode(payload, jwt_token, algorithm="HS256") - return token - - async def _is_setup_required(self) -> bool: - if DEMO_MODE: - return False - - dashboard_config = self.config["dashboard"] - password_change_required = await is_password_change_required( - self.db, - self.config, - ) - if password_change_required: - return True + return self.service.generate_jwt(username) - storage_upgraded = await is_password_storage_upgraded(self.db, self.config) - if not storage_upgraded: - return False - - return dashboard_config.get( - "username" - ) == "astrbot" and is_default_dashboard_password( - dashboard_config.get("pbkdf2_password", "") + async def _service_response(self, result: AuthServiceResult): + payload = ( + Response().error(result.message or "") + if result.status == "error" + else Response().ok(result.data, result.message) ) + if result.status == "error" and result.data is not None: + payload.data = result.data - @staticmethod - async def _error_response(message: str, status_code: int): - response = await make_response(jsonify(Response().error(message).__dict__)) - response.status_code = status_code + response = await make_response(jsonify(payload.__dict__)) + response.status_code = result.status_code + + if result.jwt_token: + self._set_dashboard_jwt_cookie(response, result.jwt_token) + + if result.trusted_device_token: + response.set_cookie( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + result.trusted_device_token, + max_age=TOTP_TRUSTED_DEVICE_MAX_AGE, + httponly=True, + samesite="Strict", + secure=AuthRoute._use_secure_dashboard_jwt_cookie(), + path="/api/auth", + ) return response - def _can_skip_default_password_auth(self) -> bool: - if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV): - return False - host = ( - os.environ.get("DASHBOARD_HOST") - or os.environ.get("ASTRBOT_DASHBOARD_HOST") - or self.config["dashboard"].get("host", "") - ) - return str(host).strip().lower() in LOCAL_DASHBOARD_HOSTS - - @staticmethod - def _env_flag_enabled(name: str) -> bool: - value = os.environ.get(name) - if value is None and name == SKIP_DEFAULT_PASSWORD_AUTH_ENV: - value = os.environ.get(SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY) - return str(value or "").strip().lower() in {"1", "true", "yes", "on"} - @staticmethod def _use_secure_dashboard_jwt_cookie() -> bool: return bool( diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index ecc5dbfc80..73172f70a2 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -1,86 +1,19 @@ """备份管理 API 路由""" -import asyncio -import json -import os -import re -import shutil -import time -import traceback -import uuid -import zipfile -from datetime import datetime -from pathlib import Path - -import jwt -from quart import request, send_file - from astrbot.core import logger -from astrbot.core.backup.exporter import AstrBotExporter -from astrbot.core.backup.importer import AstrBotImporter from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase -from astrbot.core.utils.astrbot_path import ( - get_astrbot_backups_path, - get_astrbot_data_path, +from astrbot.dashboard.fastapi_compat import request, send_file +from astrbot.dashboard.services.backup_service import ( + BackupService, + BackupServiceError, ) from .route import Response, Route, RouteContext -# 分片上传常量 -CHUNK_SIZE = 1024 * 1024 # 1MB -UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时) - - -def secure_filename(filename: str) -> str: - """清洗文件名,移除路径遍历字符和危险字符 - - Args: - filename: 原始文件名 - - Returns: - 安全的文件名 - """ - # 跨平台处理:先将反斜杠替换为正斜杠,再取文件名 - filename = filename.replace("\\", "/") - # 仅保留文件名部分,移除路径 - filename = os.path.basename(filename) - - # 替换路径遍历字符 - filename = filename.replace("..", "_") - - # 仅保留字母、数字、下划线、连字符、点 - filename = re.sub(r"[^\w\-.]", "_", filename) - - # 移除前导点(隐藏文件)和尾部点 - filename = filename.strip(".") - - # 如果文件名为空或只包含下划线,生成一个默认名称 - if not filename or filename.replace("_", "") == "": - filename = "backup" - - return filename - - -def generate_unique_filename(original_filename: str) -> str: - """生成唯一的文件名,在原文件名后添加时间戳后缀避免重名 - - Args: - original_filename: 原始文件名(已清洗) - - Returns: - 添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名} - """ - name, ext = os.path.splitext(original_filename) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return f"{name}_{timestamp}{ext}" - class BackupRoute(Route): - """备份管理路由 - - 提供备份导出、导入、列表等 API 接口 - """ + """备份管理路由""" def __init__( self, @@ -89,1018 +22,157 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.db = db - self.core_lifecycle = core_lifecycle - self.backup_dir = get_astrbot_backups_path() - self.data_dir = get_astrbot_data_path() - self.chunks_dir = os.path.join(self.backup_dir, ".chunks") - - # 任务状态跟踪 - self.backup_tasks: dict[str, dict] = {} - self.backup_progress: dict[str, dict] = {} - - # 分片上传会话跟踪 - # upload_id -> {filename, total_chunks, received_chunks, last_activity, chunk_dir} - self.upload_sessions: dict[str, dict] = {} - - # 后台清理任务句柄 - self._cleanup_task: asyncio.Task | None = None - - # 注册路由 + self.service = BackupService(db, core_lifecycle) self.routes = { "/backup/list": ("GET", self.list_backups), "/backup/export": ("POST", self.export_backup), - "/backup/upload": ("POST", self.upload_backup), # 上传文件(兼容小文件) - "/backup/upload/init": ("POST", self.upload_init), # 分片上传初始化 - "/backup/upload/chunk": ("POST", self.upload_chunk), # 上传分片 - "/backup/upload/complete": ("POST", self.upload_complete), # 完成分片上传 - "/backup/upload/abort": ("POST", self.upload_abort), # 取消上传 - "/backup/check": ("POST", self.check_backup), # 预检查 - "/backup/import": ("POST", self.import_backup), # 确认导入 + "/backup/upload": ("POST", self.upload_backup), + "/backup/upload/init": ("POST", self.upload_init), + "/backup/upload/chunk": ("POST", self.upload_chunk), + "/backup/upload/complete": ("POST", self.upload_complete), + "/backup/upload/abort": ("POST", self.upload_abort), + "/backup/check": ("POST", self.check_backup), + "/backup/import": ("POST", self.import_backup), "/backup/progress": ("GET", self.get_progress), "/backup/download": ("GET", self.download_backup), "/backup/delete": ("POST", self.delete_backup), - "/backup/rename": ("POST", self.rename_backup), # 重命名备份 + "/backup/rename": ("POST", self.rename_backup), } self.register_routes() - def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None: - """初始化任务状态""" - self.backup_tasks[task_id] = { - "type": task_type, - "status": status, - "result": None, - "error": None, - } - self.backup_progress[task_id] = { - "status": status, - "stage": "waiting", - "current": 0, - "total": 100, - "message": "", - } - - def _set_task_result( - self, - task_id: str, - status: str, - result: dict | None = None, - error: str | None = None, - ) -> None: - """设置任务结果""" - if task_id in self.backup_tasks: - self.backup_tasks[task_id]["status"] = status - self.backup_tasks[task_id]["result"] = result - self.backup_tasks[task_id]["error"] = error - if task_id in self.backup_progress: - self.backup_progress[task_id]["status"] = status - - def _update_progress( - self, - task_id: str, - *, - status: str | None = None, - stage: str | None = None, - current: int | None = None, - total: int | None = None, - message: str | None = None, - ) -> None: - """更新任务进度""" - if task_id not in self.backup_progress: - return - p = self.backup_progress[task_id] - if status is not None: - p["status"] = status - if stage is not None: - p["stage"] = stage - if current is not None: - p["current"] = current - if total is not None: - p["total"] = total - if message is not None: - p["message"] = message - - def _make_progress_callback(self, task_id: str): - """创建进度回调函数""" - - async def _callback( - stage: str, current: int, total: int, message: str = "" - ) -> None: - self._update_progress( - task_id, - status="processing", - stage=stage, - current=current, - total=total, - message=message, - ) - - return _callback - - def _ensure_cleanup_task_started(self) -> None: - """确保后台清理任务已启动(在异步上下文中延迟启动)""" - if self._cleanup_task is None or self._cleanup_task.done(): - try: - self._cleanup_task = asyncio.create_task( - self._cleanup_expired_uploads() - ) - except RuntimeError: - # 如果没有运行中的事件循环,跳过(等待下次异步调用时启动) - pass - - async def _cleanup_expired_uploads(self) -> None: - """定期清理过期的上传会话 - - 基于 last_activity 字段判断过期,避免清理活跃的上传会话。 - """ - while True: - try: - await asyncio.sleep(300) # 每5分钟检查一次 - current_time = time.time() - expired_sessions = [] - - for upload_id, session in self.upload_sessions.items(): - # 使用 last_activity 判断过期,而非 created_at - last_activity = session.get("last_activity", session["created_at"]) - if current_time - last_activity > UPLOAD_EXPIRE_SECONDS: - expired_sessions.append(upload_id) - - for upload_id in expired_sessions: - await self._cleanup_upload_session(upload_id) - logger.info(f"清理过期的上传会话: {upload_id}") - - except asyncio.CancelledError: - # 任务被取消,正常退出 - break - except Exception as e: - logger.error(f"清理过期上传会话失败: {e}") - - async def _cleanup_upload_session(self, upload_id: str) -> None: - """清理上传会话""" - if upload_id in self.upload_sessions: - session = self.upload_sessions[upload_id] - chunk_dir = session.get("chunk_dir") - if chunk_dir and os.path.exists(chunk_dir): - try: - shutil.rmtree(chunk_dir) - except Exception as e: - logger.warning(f"清理分片目录失败: {e}") - del self.upload_sessions[upload_id] - - def _get_backup_manifest(self, zip_path: str) -> dict | None: - """从备份文件读取 manifest.json - - Args: - zip_path: ZIP 文件路径 - - Returns: - dict | None: manifest 内容,如果不是有效备份则返回 None - """ - try: - with zipfile.ZipFile(zip_path, "r") as zf: - if "manifest.json" in zf.namelist(): - manifest_data = zf.read("manifest.json") - return json.loads(manifest_data.decode("utf-8")) - else: - # 没有 manifest.json,不是有效的 AstrBot 备份 - return None - except Exception as e: - logger.debug(f"读取备份 manifest 失败: {e}") - return None # 无法读取,不是有效备份 + @staticmethod + def _ok(data: dict | list | None = None, message: str | None = None) -> dict: + return Response().ok(data, message).__dict__ + + @staticmethod + def _error(message: str) -> dict: + return Response().error(message).__dict__ + + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} + + async def _run(self, operation, *, prefix: str): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + if isinstance(result, tuple): + data, message = result + return self._ok(data, message) + return self._ok(result) + except BackupServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("%s: %s", prefix, exc, exc_info=True) + return self._error(f"{prefix}: {exc!s}") + + async def _run_json(self, operation, *, prefix: str): + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run(invoke, prefix=prefix) async def list_backups(self): - # 确保后台清理任务已启动 - self._ensure_cleanup_task_started() - - """获取备份列表 - - Query 参数: - - page: 页码 (默认 1) - - page_size: 每页数量 (默认 20) - """ - try: - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 20, type=int) - - # 确保备份目录存在 - Path(self.backup_dir).mkdir(parents=True, exist_ok=True) - - # 获取所有备份文件 - backup_files = [] - for filename in os.listdir(self.backup_dir): - # 只处理 .zip 文件,排除隐藏文件和目录 - if not filename.endswith(".zip") or filename.startswith("."): - continue - - file_path = os.path.join(self.backup_dir, filename) - if not os.path.isfile(file_path): - continue - - # 读取 manifest.json 获取备份信息 - # 如果返回 None,说明不是有效的 AstrBot 备份,跳过 - manifest = self._get_backup_manifest(file_path) - if manifest is None: - logger.debug(f"跳过无效备份文件: {filename}") - continue - - stat = os.stat(file_path) - backup_files.append( - { - "filename": filename, - "size": stat.st_size, - "created_at": stat.st_mtime, - "type": manifest.get( - "origin", "exported" - ), # 老版本没有 origin 默认为 exported - "astrbot_version": manifest.get("astrbot_version", "未知"), - "exported_at": manifest.get("exported_at"), - } - ) - - # 按创建时间倒序排序 - backup_files.sort(key=lambda x: x["created_at"], reverse=True) - - # 分页 - start = (page - 1) * page_size - end = start + page_size - items = backup_files[start:end] - - return ( - Response() - .ok( - { - "items": items, - "total": len(backup_files), - "page": page, - "page_size": page_size, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取备份列表失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取备份列表失败: {e!s}").__dict__ + return await self._run( + lambda: self.service.list_backups_from_legacy_query( + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 20), + ), + prefix="获取备份列表失败", + ) async def export_backup(self): - """创建备份 - - 返回: - - task_id: 任务ID,用于查询导出进度 - """ - try: - # 生成任务ID - task_id = str(uuid.uuid4()) - - # 初始化任务状态 - self._init_task(task_id, "export", "pending") - - # 启动后台导出任务 - asyncio.create_task(self._background_export_task(task_id)) - - return ( - Response() - .ok( - { - "task_id": task_id, - "message": "export task created, processing in background", - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"创建备份失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"创建备份失败: {e!s}").__dict__ - - async def _background_export_task(self, task_id: str) -> None: - """后台导出任务""" - try: - self._update_progress(task_id, status="processing", message="正在初始化...") - - # 获取知识库管理器 - kb_manager = getattr(self.core_lifecycle, "kb_manager", None) - - exporter = AstrBotExporter( - main_db=self.db, - kb_manager=kb_manager, - config_path=os.path.join(self.data_dir, "cmd_config.json"), - ) - - # 创建进度回调 - progress_callback = self._make_progress_callback(task_id) - - # 执行导出 - zip_path = await exporter.export_all( - output_dir=self.backup_dir, - progress_callback=progress_callback, - ) - - # 设置成功结果 - self._set_task_result( - task_id, - "completed", - result={ - "filename": os.path.basename(zip_path), - "path": zip_path, - "size": os.path.getsize(zip_path), - }, - ) - except Exception as e: - logger.error(f"后台导出任务 {task_id} 失败: {e}") - logger.error(traceback.format_exc()) - self._set_task_result(task_id, "failed", error=str(e)) + return await self._run(self.service.export_backup, prefix="创建备份失败") async def upload_backup(self): - """上传备份文件 - - 将备份文件上传到服务器,返回保存的文件名。 - 上传后应调用 check_backup 进行预检查。 - - Form Data: - - file: 备份文件 (.zip) - - 返回: - - filename: 保存的文件名 - """ - try: + async def _operation(): files = await request.files - if "file" not in files: - return Response().error("缺少备份文件").__dict__ + return await self.service.upload_backup(files.get("file")) - file = files["file"] - if not file.filename or not file.filename.endswith(".zip"): - return Response().error("请上传 ZIP 格式的备份文件").__dict__ - - # 清洗文件名并生成唯一名称,防止路径遍历和覆盖 - safe_filename = secure_filename(file.filename) - unique_filename = generate_unique_filename(safe_filename) - - # 保存上传的文件 - Path(self.backup_dir).mkdir(parents=True, exist_ok=True) - zip_path = os.path.join(self.backup_dir, unique_filename) - await file.save(zip_path) - - logger.info( - f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})" - ) - - return ( - Response() - .ok( - { - "filename": unique_filename, - "original_filename": file.filename, - "size": os.path.getsize(zip_path), - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"上传备份文件失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"上传备份文件失败: {e!s}").__dict__ + return await self._run(_operation, prefix="上传备份文件失败") async def upload_init(self): - """初始化分片上传 - - 创建一个上传会话,返回 upload_id 供后续分片上传使用。 - - JSON Body: - - filename: 原始文件名 - - total_size: 文件总大小(字节) - - 返回: - - upload_id: 上传会话 ID - - chunk_size: 分片大小(由后端决定) - - total_chunks: 分片总数(由后端根据 total_size 和 chunk_size 计算) - """ - try: - data = await request.json - filename = data.get("filename") - total_size = data.get("total_size", 0) - - if not filename: - return Response().error("缺少 filename 参数").__dict__ - - if not filename.endswith(".zip"): - return Response().error("请上传 ZIP 格式的备份文件").__dict__ - - if total_size <= 0: - return Response().error("无效的文件大小").__dict__ - - # 由后端计算分片总数,确保前后端一致 - import math - - total_chunks = math.ceil(total_size / CHUNK_SIZE) - - # 生成上传 ID - upload_id = str(uuid.uuid4()) - - # 创建分片存储目录 - chunk_dir = os.path.join(self.chunks_dir, upload_id) - Path(chunk_dir).mkdir(parents=True, exist_ok=True) - - # 清洗文件名 - safe_filename = secure_filename(filename) - unique_filename = generate_unique_filename(safe_filename) - - # 创建上传会话 - current_time = time.time() - self.upload_sessions[upload_id] = { - "filename": unique_filename, - "original_filename": filename, - "total_size": total_size, - "total_chunks": total_chunks, - "received_chunks": set(), - "created_at": current_time, - "last_activity": current_time, # 用于判断会话是否活跃 - "chunk_dir": chunk_dir, - } - - logger.info( - f"初始化分片上传: upload_id={upload_id}, " - f"filename={unique_filename}, total_chunks={total_chunks}" - ) - - return ( - Response() - .ok( - { - "upload_id": upload_id, - "chunk_size": CHUNK_SIZE, - "total_chunks": total_chunks, - "filename": unique_filename, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"初始化分片上传失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"初始化分片上传失败: {e!s}").__dict__ + return await self._run_json( + self.service.upload_init, + prefix="初始化分片上传失败", + ) async def upload_chunk(self): - """上传分片 - - 上传单个分片数据。 - - Form Data: - - upload_id: 上传会话 ID - - chunk_index: 分片索引(从 0 开始) - - chunk: 分片数据 - - 返回: - - received: 已接收的分片数量 - - total: 分片总数 - """ - try: + async def _operation(): form = await request.form files = await request.files - - upload_id = form.get("upload_id") - chunk_index_str = form.get("chunk_index") - - if not upload_id or chunk_index_str is None: - return Response().error("缺少必要参数").__dict__ - - try: - chunk_index = int(chunk_index_str) - except ValueError: - return Response().error("无效的分片索引").__dict__ - - if "chunk" not in files: - return Response().error("缺少分片数据").__dict__ - - # 验证上传会话 - if upload_id not in self.upload_sessions: - return Response().error("上传会话不存在或已过期").__dict__ - - session = self.upload_sessions[upload_id] - - # 验证分片索引 - if chunk_index < 0 or chunk_index >= session["total_chunks"]: - return Response().error("分片索引超出范围").__dict__ - - # 保存分片 - chunk_file = files["chunk"] - chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part") - await chunk_file.save(chunk_path) - - # 记录已接收的分片,并更新最后活动时间 - session["received_chunks"].add(chunk_index) - session["last_activity"] = time.time() # 刷新活动时间,防止活跃上传被清理 - - received_count = len(session["received_chunks"]) - total_chunks = session["total_chunks"] - - logger.debug( - f"接收分片: upload_id={upload_id}, " - f"chunk={chunk_index + 1}/{total_chunks}" - ) - - return ( - Response() - .ok( - { - "received": received_count, - "total": total_chunks, - "chunk_index": chunk_index, - } - ) - .__dict__ + return await self.service.upload_chunk( + upload_id=form.get("upload_id"), + chunk_index_str=form.get("chunk_index"), + chunk_file=files.get("chunk"), ) - except Exception as e: - logger.error(f"上传分片失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"上传分片失败: {e!s}").__dict__ - def _mark_backup_as_uploaded(self, zip_path: str) -> None: - """修改备份文件的 manifest.json,将 origin 设置为 uploaded - - 使用 zipfile 的 append 模式添加新的 manifest.json, - ZIP 规范中后添加的同名文件会覆盖先前的文件。 - - Args: - zip_path: ZIP 文件路径 - """ - try: - # 读取原有 manifest - manifest = {"origin": "uploaded", "uploaded_at": datetime.now().isoformat()} - with zipfile.ZipFile(zip_path, "r") as zf: - if "manifest.json" in zf.namelist(): - manifest_data = zf.read("manifest.json") - manifest = json.loads(manifest_data.decode("utf-8")) - manifest["origin"] = "uploaded" - manifest["uploaded_at"] = datetime.now().isoformat() - - # 使用 append 模式添加新的 manifest.json - # ZIP 规范中,后添加的同名文件会覆盖先前的 - with zipfile.ZipFile(zip_path, "a") as zf: - new_manifest = json.dumps(manifest, ensure_ascii=False, indent=2) - zf.writestr("manifest.json", new_manifest) - - logger.debug(f"已标记备份为上传来源: {zip_path}") - except Exception as e: - logger.warning(f"标记备份来源失败: {e}") + return await self._run(_operation, prefix="上传分片失败") async def upload_complete(self): - """完成分片上传 - - 合并所有分片为完整文件。 - - JSON Body: - - upload_id: 上传会话 ID - - 返回: - - filename: 合并后的文件名 - - size: 文件大小 - """ - try: - data = await request.json - upload_id = data.get("upload_id") - - if not upload_id: - return Response().error("缺少 upload_id 参数").__dict__ - - # 验证上传会话 - if upload_id not in self.upload_sessions: - return Response().error("上传会话不存在或已过期").__dict__ - - session = self.upload_sessions[upload_id] - - # 检查是否所有分片都已接收 - received = session["received_chunks"] - total = session["total_chunks"] - - if len(received) != total: - missing = set(range(total)) - received - return ( - Response() - .error(f"分片不完整,缺少: {sorted(missing)[:10]}...") - .__dict__ - ) - - # 合并分片 - chunk_dir = session["chunk_dir"] - filename = session["filename"] - - Path(self.backup_dir).mkdir(parents=True, exist_ok=True) - output_path = os.path.join(self.backup_dir, filename) - - try: - with open(output_path, "wb") as outfile: - for i in range(total): - chunk_path = os.path.join(chunk_dir, f"{i}.part") - with open(chunk_path, "rb") as chunk_file: - # 分块读取,避免内存溢出 - while True: - data_block = chunk_file.read(8192) - if not data_block: - break - outfile.write(data_block) - - file_size = os.path.getsize(output_path) - - # 标记备份为上传来源(修改 manifest.json 中的 origin 字段) - self._mark_backup_as_uploaded(output_path) - - logger.info( - f"分片上传完成: {filename}, size={file_size}, chunks={total}" - ) - - # 清理分片目录 - await self._cleanup_upload_session(upload_id) - - return ( - Response() - .ok( - { - "filename": filename, - "original_filename": session["original_filename"], - "size": file_size, - } - ) - .__dict__ - ) - except Exception as e: - # 如果合并失败,删除不完整的文件 - if os.path.exists(output_path): - os.remove(output_path) - raise e - - except Exception as e: - logger.error(f"完成分片上传失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"完成分片上传失败: {e!s}").__dict__ + return await self._run_json( + self.service.upload_complete, + prefix="完成分片上传失败", + ) async def upload_abort(self): - """取消分片上传 - - 取消上传并清理已上传的分片。 - - JSON Body: - - upload_id: 上传会话 ID - """ - try: - data = await request.json - upload_id = data.get("upload_id") - - if not upload_id: - return Response().error("缺少 upload_id 参数").__dict__ - - if upload_id not in self.upload_sessions: - # 会话已不存在,可能已过期或已完成 - return Response().ok(message="上传已取消").__dict__ - - # 清理会话 - await self._cleanup_upload_session(upload_id) - - logger.info(f"取消分片上传: {upload_id}") - - return Response().ok(message="上传已取消").__dict__ - except Exception as e: - logger.error(f"取消上传失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"取消上传失败: {e!s}").__dict__ + return await self._run_json( + self.service.upload_abort, + prefix="取消上传失败", + ) async def check_backup(self): - """预检查备份文件 - - 检查备份文件的版本兼容性,返回确认信息。 - 用户确认后调用 import_backup 执行导入。 - - JSON Body: - - filename: 已上传的备份文件名 - - 返回: - - ImportPreCheckResult: 预检查结果 - """ - try: - data = await request.json - filename = data.get("filename") - if not filename: - return Response().error("缺少 filename 参数").__dict__ - - # 安全检查 - 防止路径遍历 - if ".." in filename or "/" in filename or "\\" in filename: - return Response().error("无效的文件名").__dict__ - - zip_path = os.path.join(self.backup_dir, filename) - if not os.path.exists(zip_path): - return Response().error(f"备份文件不存在: {filename}").__dict__ - - # 获取知识库管理器(用于构造 importer) - kb_manager = getattr(self.core_lifecycle, "kb_manager", None) - - importer = AstrBotImporter( - main_db=self.db, - kb_manager=kb_manager, - config_path=os.path.join(self.data_dir, "cmd_config.json"), - ) - - # 执行预检查 - check_result = importer.pre_check(zip_path) - - return Response().ok(check_result.to_dict()).__dict__ - except Exception as e: - logger.error(f"预检查备份文件失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"预检查备份文件失败: {e!s}").__dict__ + return await self._run_json( + self.service.check_backup, + prefix="预检查备份文件失败", + ) async def import_backup(self): - """执行备份导入 - - 在用户确认后执行实际的导入操作。 - 需要先调用 upload_backup 上传文件,再调用 check_backup 预检查。 - - JSON Body: - - filename: 已上传的备份文件名(必填) - - confirmed: 用户已确认(必填,必须为 true) - - 返回: - - task_id: 任务ID,用于查询导入进度 - """ - try: - data = await request.json - filename = data.get("filename") - confirmed = data.get("confirmed", False) - - if not filename: - return Response().error("缺少 filename 参数").__dict__ - - if not confirmed: - return ( - Response() - .error("请先确认导入。导入将会清空并覆盖现有数据,此操作不可撤销。") - .__dict__ - ) - - # 安全检查 - 防止路径遍历 - if ".." in filename or "/" in filename or "\\" in filename: - return Response().error("无效的文件名").__dict__ - - zip_path = os.path.join(self.backup_dir, filename) - if not os.path.exists(zip_path): - return Response().error(f"备份文件不存在: {filename}").__dict__ - - # 生成任务ID - task_id = str(uuid.uuid4()) - - # 初始化任务状态 - self._init_task(task_id, "import", "pending") - - # 启动后台导入任务 - asyncio.create_task(self._background_import_task(task_id, zip_path)) - - return ( - Response() - .ok( - { - "task_id": task_id, - "message": "import task created, processing in background", - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"导入备份失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"导入备份失败: {e!s}").__dict__ - - async def _background_import_task(self, task_id: str, zip_path: str) -> None: - """后台导入任务""" - try: - self._update_progress(task_id, status="processing", message="正在初始化...") - - # 获取知识库管理器 - kb_manager = getattr(self.core_lifecycle, "kb_manager", None) - - importer = AstrBotImporter( - main_db=self.db, - kb_manager=kb_manager, - config_path=os.path.join(self.data_dir, "cmd_config.json"), - ) - - # 创建进度回调 - progress_callback = self._make_progress_callback(task_id) - - # 执行导入 - result = await importer.import_all( - zip_path=zip_path, - mode="replace", - progress_callback=progress_callback, - ) - - # 设置结果 - if result.success: - self._set_task_result( - task_id, - "completed", - result=result.to_dict(), - ) - else: - self._set_task_result( - task_id, - "failed", - error="; ".join(result.errors), - ) - except Exception as e: - logger.error(f"后台导入任务 {task_id} 失败: {e}") - logger.error(traceback.format_exc()) - self._set_task_result(task_id, "failed", error=str(e)) + return await self._run_json( + self.service.import_backup, + prefix="导入备份失败", + ) async def get_progress(self): - """获取任务进度 - - Query 参数: - - task_id: 任务 ID (必填) - """ - try: - task_id = request.args.get("task_id") - if not task_id: - return Response().error("缺少参数 task_id").__dict__ - - if task_id not in self.backup_tasks: - return Response().error("找不到该任务").__dict__ - - task_info = self.backup_tasks[task_id] - status = task_info["status"] - - response_data = { - "task_id": task_id, - "type": task_info["type"], - "status": status, - } - - # 如果任务正在处理,返回进度信息 - if status == "processing" and task_id in self.backup_progress: - response_data["progress"] = self.backup_progress[task_id] - - # 如果任务完成,返回结果 - if status == "completed": - response_data["result"] = task_info["result"] - - # 如果任务失败,返回错误信息 - if status == "failed": - response_data["error"] = task_info["error"] - - return Response().ok(response_data).__dict__ - except Exception as e: - logger.error(f"获取任务进度失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取任务进度失败: {e!s}").__dict__ + return await self._run( + lambda: self.service.get_progress_from_legacy_query( + request.args.get("task_id") + ), + prefix="获取任务进度失败", + ) async def download_backup(self): - """下载备份文件 - - Query 参数: - - filename: 备份文件名 (必填) - - token: JWT token (必填,用于浏览器原生下载鉴权) - - 注意: 此路由已被添加到 auth_middleware 白名单中, - 使用 URL 参数中的 token 进行鉴权,以支持浏览器原生下载。 - """ try: - filename = request.args.get("filename") - token = request.args.get("token") - - if not filename: - return Response().error("缺少参数 filename").__dict__ - - if not token: - return Response().error("缺少参数 token").__dict__ - - # 验证 JWT token - try: - jwt_secret = self.config.get("dashboard", {}).get("jwt_secret") - if not jwt_secret: - return Response().error("服务器配置错误").__dict__ - - # Verify JWT token with strict security options - jwt.decode( - token, - jwt_secret, - algorithms=["HS256"], - options={ - "require": ["exp"], # Require expiration claim - "verify_signature": True, # Explicitly verify signature - "verify_exp": True, # Verify expiration - }, - ) - except jwt.ExpiredSignatureError: - return Response().error("Token 已过期,请刷新页面后重试").__dict__ - except jwt.InvalidTokenError: - return Response().error("Token 无效").__dict__ - - # 安全检查 - 防止路径遍历 - if ".." in filename or "/" in filename or "\\" in filename: - return Response().error("无效的文件名").__dict__ - - file_path = os.path.join(self.backup_dir, filename) - if not os.path.exists(file_path): - return Response().error("备份文件不存在").__dict__ - + download = self.service.prepare_download_from_legacy_query( + filename=request.args.get("filename"), + token=request.args.get("token"), + ) return await send_file( - file_path, + download.path, as_attachment=True, - attachment_filename=filename, - conditional=True, # 启用 Range 请求支持(断点续传) + attachment_filename=download.filename, + conditional=True, ) - except Exception as e: - logger.error(f"下载备份失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"下载备份失败: {e!s}").__dict__ + except BackupServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("下载备份失败: %s", exc, exc_info=True) + return self._error(f"下载备份失败: {exc!s}") async def delete_backup(self): - """删除备份文件 - - Body: - - filename: 备份文件名 (必填) - """ - try: - data = await request.json - filename = data.get("filename") - if not filename: - return Response().error("缺少参数 filename").__dict__ - - # 安全检查 - 防止路径遍历 - if ".." in filename or "/" in filename or "\\" in filename: - return Response().error("无效的文件名").__dict__ - - file_path = os.path.join(self.backup_dir, filename) - if not os.path.exists(file_path): - return Response().error("备份文件不存在").__dict__ - - os.remove(file_path) - return Response().ok(message="删除备份成功").__dict__ - except Exception as e: - logger.error(f"删除备份失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"删除备份失败: {e!s}").__dict__ + return await self._run_json( + self.service.delete_backup, + prefix="删除备份失败", + ) async def rename_backup(self): - """重命名备份文件 + return await self._run_json( + self.service.rename_backup, + prefix="重命名备份失败", + ) - Body: - - filename: 当前文件名 (必填) - - new_name: 新文件名 (必填,不含扩展名) - """ - try: - data = await request.json - filename = data.get("filename") - new_name = data.get("new_name") - - if not filename: - return Response().error("缺少参数 filename").__dict__ - - if not new_name: - return Response().error("缺少参数 new_name").__dict__ - - # 安全检查 - 防止路径遍历 - if ".." in filename or "/" in filename or "\\" in filename: - return Response().error("无效的文件名").__dict__ - - # 清洗新文件名(移除路径和危险字符) - new_name = secure_filename(new_name) - - # 移除新文件名中的扩展名(如果有的话) - if new_name.endswith(".zip"): - new_name = new_name[:-4] - - # 验证新文件名不为空 - if not new_name or new_name.replace("_", "") == "": - return Response().error("新文件名无效").__dict__ - # 强制使用 .zip 扩展名 - new_filename = f"{new_name}.zip" - - # 检查原文件是否存在 - old_path = os.path.join(self.backup_dir, filename) - if not os.path.exists(old_path): - return Response().error("备份文件不存在").__dict__ - - # 检查新文件名是否已存在 - new_path = os.path.join(self.backup_dir, new_filename) - if os.path.exists(new_path): - return Response().error(f"文件名 '{new_filename}' 已存在").__dict__ - - # 执行重命名 - os.rename(old_path, new_path) - - logger.info(f"备份文件重命名: {filename} -> {new_filename}") - - return ( - Response() - .ok( - { - "old_filename": filename, - "new_filename": new_filename, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"重命名备份失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"重命名备份失败: {e!s}").__dict__ +__all__ = ["BackupRoute"] diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 5ff1913b9e..6889a041f0 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -1,245 +1,17 @@ -import asyncio -import json -import os -import re -import uuid -from contextlib import asynccontextmanager -from copy import deepcopy -from pathlib import Path, PurePosixPath -from typing import Any, cast +from typing import cast -from quart import Response as QuartResponse -from quart import g, make_response, request, send_file - -from astrbot.core import logger, sp -from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase -from astrbot.core.platform.message_type import MessageType -from astrbot.core.platform.sources.webchat.message_parts_helper import ( - build_webchat_message_parts, - create_attachment_part_from_existing_file, - strip_message_parts_path_fields, - webchat_message_parts_have_content, +from astrbot.dashboard.fastapi_compat import Response as CompatResponse +from astrbot.dashboard.fastapi_compat import g, make_response, request, send_file +from astrbot.dashboard.services.chat_service import ( + ChatService, + ChatServiceError, ) -from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.active_event_registry import active_event_registry -from astrbot.core.utils.astrbot_path import get_astrbot_data_path -from astrbot.core.utils.datetime_utils import to_utc_isoformat from .route import Response, Route, RouteContext -# SSE heartbeat message to keep the connection alive during long-running operations -SSE_HEARTBEAT = ": heartbeat\n\n" - - -def _sanitize_upload_filename(filename: str | None) -> str: - if not filename: - return f"{uuid.uuid4()!s}" - normalized = filename.replace("\\", "/") - name = PurePosixPath(normalized).name.replace("\x00", "").strip() - if name in ("", ".", ".."): - return f"{uuid.uuid4()!s}" - return name - - -@asynccontextmanager -async def track_conversation(convs: dict, conv_id: str): - convs[conv_id] = True - try: - yield - finally: - convs.pop(conv_id, None) - - -async def _poll_webchat_stream_result(back_queue, username: str): - try: - result = await asyncio.wait_for(back_queue.get(), timeout=1) - except asyncio.TimeoutError: - # Return a sentinel so the caller can send an SSE heartbeat to - # keep the connection alive during long-running operations (e.g. - # context compression with reasoning models). See #6938. - return None, False - except asyncio.CancelledError: - logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") - return None, True - except Exception as e: - logger.error(f"WebChat stream error: {e}") - return None, False - return result, False - - -def normalize_legacy_reasoning_message_parts( - message_parts: list[dict] | None, - reasoning: str = "", -) -> list[dict]: - parts: list[dict] = [] - for part in message_parts or []: - if not isinstance(part, dict): - continue - copied = dict(part) - if copied.get("type") == "reasoning": - copied = {"type": "think", "think": copied.get("text", "")} - parts.append(copied) - if reasoning and not any(part.get("type") == "think" for part in parts): - parts.insert(0, {"type": "think", "think": reasoning}) - return parts - - -def extract_reasoning_from_message_parts(message_parts: list[dict]) -> str: - reasoning_parts: list[str] = [] - for part in message_parts: - if part.get("type") != "think": - continue - think = part.get("think") - if isinstance(think, str) and think: - reasoning_parts.append(think) - return "".join(reasoning_parts) - - -def collect_plain_text_from_message_parts(message_parts: list[dict]) -> str: - text_parts: list[str] = [] - for part in message_parts: - if part.get("type") != "plain": - continue - text = part.get("text") - if isinstance(text, str) and text: - text_parts.append(text) - return "".join(text_parts) - - -def build_bot_history_content( - message_parts: list[dict], - *, - agent_stats: dict | None = None, - refs: dict | None = None, - include_legacy_reasoning_field: bool = True, -) -> dict[str, Any]: - normalized_parts = normalize_legacy_reasoning_message_parts(message_parts) - content: dict[str, Any] = {"type": "bot", "message": normalized_parts} - reasoning = extract_reasoning_from_message_parts(normalized_parts) - if reasoning and include_legacy_reasoning_field: - # Keep the legacy field for old clients while the canonical structure - # moves to message parts. - content["reasoning"] = reasoning - if agent_stats: - content["agent_stats"] = agent_stats - if refs: - content["refs"] = refs - return content - - -class BotMessageAccumulator: - def __init__(self) -> None: - self.parts: list[dict] = [] - self.pending_text = "" - self.pending_tool_calls: dict[str, dict] = {} - - def has_content(self) -> bool: - return bool(self.parts or self.pending_text or self.pending_tool_calls) - - def add_plain( - self, - result_text: str, - *, - chain_type: str | None, - streaming: bool, - ) -> None: - if chain_type == "tool_call": - self._flush_pending_text() - self._store_tool_call(result_text) - return - - if chain_type == "tool_call_result": - self._flush_pending_text() - self._store_tool_call_result(result_text) - return - - if chain_type == "reasoning": - self._flush_pending_text() - self._append_think_part(result_text) - return - - if streaming: - self.pending_text += result_text - else: - self.pending_text = result_text - - def add_attachment(self, part: dict | None) -> None: - if not part: - return - self._flush_pending_text() - self.parts.append(part) - - def build_message_parts( - self, *, include_pending_tool_calls: bool = False - ) -> list[dict]: - self._flush_pending_text() - if include_pending_tool_calls and self.pending_tool_calls: - for tool_call in self.pending_tool_calls.values(): - self.parts.append({"type": "tool_call", "tool_calls": [tool_call]}) - self.pending_tool_calls = {} - return self.parts - - def plain_text(self) -> str: - return collect_plain_text_from_message_parts(self.build_message_parts()) - - def reasoning_text(self) -> str: - return extract_reasoning_from_message_parts(self.build_message_parts()) - - def _flush_pending_text(self) -> None: - if not self.pending_text: - return - - if self.parts and self.parts[-1].get("type") == "plain": - last_text = self.parts[-1].get("text") - self.parts[-1]["text"] = f"{last_text or ''}{self.pending_text}" - else: - self.parts.append({"type": "plain", "text": self.pending_text}) - self.pending_text = "" - - def _append_think_part(self, text: str) -> None: - if not text: - return - - if self.parts and self.parts[-1].get("type") == "think": - last_text = self.parts[-1].get("think") - self.parts[-1]["think"] = f"{last_text or ''}{text}" - else: - self.parts.append({"type": "think", "think": text}) - - def _store_tool_call(self, result_text: str) -> None: - tool_call = self._parse_json_object(result_text) - if not tool_call: - return - tool_call_id = str(tool_call.get("id") or "") - if not tool_call_id: - return - self.pending_tool_calls[tool_call_id] = tool_call - - def _store_tool_call_result(self, result_text: str) -> None: - tool_result = self._parse_json_object(result_text) - if not tool_result: - return - - 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 { - "id": tool_call_id - } - tool_call["result"] = tool_result.get("result") - tool_call["finished_ts"] = tool_result.get("ts") - self.parts.append({"type": "tool_call", "tool_calls": [tool_call]}) - - @staticmethod - def _parse_json_object(raw_text: str) -> dict | None: - try: - parsed = json.loads(raw_text) - except json.JSONDecodeError: - return None - return parsed if isinstance(parsed, dict) else None +__all__ = ["ChatRoute"] class ChatRoute(Route): @@ -248,6 +20,7 @@ def __init__( context: RouteContext, db: BaseDatabase, core_lifecycle: AstrBotCoreLifecycle, + service: ChatService | None = None, ) -> None: super().__init__(context) self.routes = { @@ -272,766 +45,90 @@ def __init__( "/chat/get_attachment": ("GET", self.get_attachment), "/chat/post_file": ("POST", self.post_file), } - self.core_lifecycle = core_lifecycle + self.service = service or ChatService(db, core_lifecycle) self.register_routes() - self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") - self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") - os.makedirs(self.attachments_dir, exist_ok=True) - self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] - self.conv_mgr = core_lifecycle.conversation_manager - self.platform_history_mgr = core_lifecycle.platform_message_history_manager - self.db = db - self.umop_config_router = core_lifecycle.umop_config_router + @staticmethod + def _ok(data=None): + return Response().ok(data=data).__dict__ - self.running_convs: dict[str, bool] = {} + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - async def get_file(self): - filename = request.args.get("filename") - if not filename: - return Response().error("Missing key: filename").__dict__ + @staticmethod + async def _json_body(): + return await request.get_json() + async def _run(self, operation): 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) + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except ChatServiceError as exc: + return self._error(str(exc)) - if not 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 not real_file_path.startswith(real_imgs_dir): - return Response().error("Invalid file path").__dict__ + async def _run_json(self, operation): + async def invoke(): + data = await self._json_body() + return operation(data) - filename_ext = os.path.splitext(filename)[1].lower() - if filename_ext == ".wav": - return await send_file(real_file_path, mimetype="audio/wav") - if filename_ext[1:] in self.supported_imgs: - return await send_file(real_file_path, mimetype="image/jpeg") - return await send_file(real_file_path) + return await self._run(invoke) + async def get_file(self): + try: + ( + file_path, + mimetype, + ) = await self.service.resolve_webchat_file_from_legacy_query( + request.args.get("filename") + ) + if mimetype: + return await send_file(file_path, mimetype=mimetype) + return await send_file(file_path) + except ChatServiceError as exc: + return self._error(str(exc)) except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ + return self._error("File access error") async def get_attachment(self): """Get attachment file by attachment_id.""" - attachment_id = request.args.get("attachment_id") - if not attachment_id: - return Response().error("Missing key: attachment_id").__dict__ - try: - attachment = await self.db.get_attachment_by_id(attachment_id) - if not attachment: - return Response().error("Attachment not found").__dict__ - - file_path = attachment.path - real_file_path = os.path.realpath(file_path) - - return await send_file(real_file_path, mimetype=attachment.mime_type) - + ( + file_path, + mimetype, + ) = await self.service.resolve_attachment_file_from_legacy_query( + request.args.get("attachment_id") + ) + return await send_file(file_path, mimetype=mimetype) + except ChatServiceError as exc: + return self._error(str(exc)) except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ + return self._error("File access error") async def post_file(self): """Upload a file and create an attachment record, return attachment_id.""" - post_data = await request.files - if "file" not in post_data: - return Response().error("Missing key: file").__dict__ - - file = post_data["file"] - filename = _sanitize_upload_filename(file.filename) - content_type = file.content_type or "application/octet-stream" - - # 根据 content_type 判断文件类型并添加扩展名 - if content_type.startswith("image"): - attach_type = "image" - elif content_type.startswith("audio"): - attach_type = "record" - elif content_type.startswith("video"): - attach_type = "video" - else: - attach_type = "file" - - attachments_dir = Path(self.attachments_dir).resolve(strict=False) - file_path = (attachments_dir / filename).resolve(strict=False) - if not file_path.is_relative_to(attachments_dir): - return Response().error("Invalid filename").__dict__ - - await file.save(str(file_path)) - - # 创建 attachment 记录 - attachment = await self.db.insert_attachment( - path=str(file_path), - type=attach_type, - mime_type=content_type, - ) - - if not attachment: - return Response().error("Failed to create attachment").__dict__ - - filename = os.path.basename(attachment.path) - - return ( - Response() - .ok( - data={ - "attachment_id": attachment.attachment_id, - "filename": filename, - "type": attach_type, - } - ) - .__dict__ + return await self._run( + self.service.save_uploaded_file_from_legacy_files(await request.files) ) - async def _build_user_message_parts(self, message: str | list) -> list[dict]: - """构建用户消息的部分列表。""" - return await build_webchat_message_parts( - message, - get_attachment_by_id=self.db.get_attachment_by_id, - strict=False, - ) - - async def _create_attachment_from_file( - self, filename: str, attach_type: str - ) -> dict | None: - """从本地文件创建 attachment 并返回消息部分。""" - return await create_attachment_part_from_existing_file( - filename, - attach_type=attach_type, - insert_attachment=self.db.insert_attachment, - attachments_dir=self.attachments_dir, - fallback_dirs=[self.legacy_img_dir], - ) - - def _extract_web_search_refs( - self, accumulated_text: str, accumulated_parts: list - ) -> dict: - """从消息中提取 web_search_tavily 的引用 - - Args: - accumulated_text: 累积的文本内容 - accumulated_parts: 累积的消息部分列表 - - Returns: - 包含 used 列表的字典,记录被引用的搜索结果 - """ - supported = [ - "web_search_baidu", - "web_search_tavily", - "web_search_bocha", - "web_search_brave", - ] - # 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果 - web_search_results = {} - tool_call_parts = [ - p - for p in accumulated_parts - if p.get("type") == "tool_call" and p.get("tool_calls") - ] - - for part in tool_call_parts: - for tool_call in part["tool_calls"]: - if tool_call.get("name") not in supported or not tool_call.get( - "result" - ): - continue - try: - result_data = json.loads(tool_call["result"]) - for item in result_data.get("results", []): - if idx := item.get("index"): - web_search_results[idx] = { - "url": item.get("url"), - "title": item.get("title"), - "snippet": item.get("snippet"), - } - except (json.JSONDecodeError, KeyError): - pass - - if not web_search_results: - return {} - - # 从文本中提取所有 xxx 标签并去重 - ref_indices = { - m.strip() for m in re.findall(r"(.*?)", accumulated_text) - } - - # 构建被引用的结果列表 - used_refs = [] - for ref_index in ref_indices: - if ref_index not in web_search_results: - continue - payload = {"index": ref_index, **web_search_results[ref_index]} - if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]): - payload["favicon"] = favicon - used_refs.append(payload) - - return {"used": used_refs} if used_refs else {} - - def _sanitize_message_content(self, content: dict) -> dict: - """Normalize editable WebChat message content before persisting.""" - if not isinstance(content, dict): - raise ValueError("Missing key: content") - - normalized = deepcopy(content) - message_type = normalized.get("type") - if message_type not in {"user", "bot"}: - raise ValueError("Invalid key: content.type") - - message_parts = normalized.get("message") - if not isinstance(message_parts, list): - raise ValueError("Missing key: content.message") - normalized["message"] = strip_message_parts_path_fields(message_parts) - return normalized - - def _extract_platform_message_text(self, content: dict | None) -> str: - if not isinstance(content, dict): - return "" - message_parts = content.get("message") - if not isinstance(message_parts, list): - return "" - texts: list[str] = [] - for part in message_parts: - if isinstance(part, dict) and part.get("type") == "plain": - text = part.get("text") - if isinstance(text, str): - texts.append(text) - return "".join(texts) - - def _build_webchat_unified_msg_origin(self, session) -> str: - message_type = ( - MessageType.GROUP_MESSAGE.value - if session.is_group - else MessageType.FRIEND_MESSAGE.value - ) - return ( - f"{session.platform_id}:{message_type}:" - f"{session.platform_id}!{session.creator}!{session.session_id}" - ) - - def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str: - return ( - f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}" - ) - - def _serialize_thread(self, thread) -> dict: - return { - "thread_id": thread.thread_id, - "parent_session_id": thread.parent_session_id, - "parent_message_id": thread.parent_message_id, - "base_checkpoint_id": thread.base_checkpoint_id, - "selected_text": thread.selected_text, - "created_at": to_utc_isoformat(thread.created_at), - "updated_at": to_utc_isoformat(thread.updated_at), - } - - async def _delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None: - for thread_id in thread_ids: - unified_msg_origin = self._build_thread_unified_msg_origin( - creator, thread_id - ) - active_event_registry.request_agent_stop_all(unified_msg_origin) - await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) - await self.platform_history_mgr.delete( - platform_id="webchat_thread", - user_id=thread_id, - offset_sec=99999999, - ) - webchat_queue_mgr.remove_queues(thread_id) - self.running_convs.pop(thread_id, None) - - async def _load_current_conversation_history(self, session) -> tuple[str, list]: - unified_msg_origin = self._build_webchat_unified_msg_origin(session) - conversation_id = await self.conv_mgr.get_curr_conversation_id( - unified_msg_origin - ) - if not conversation_id: - return "", [] - - conversation = await self.conv_mgr.get_conversation( - unified_msg_origin=unified_msg_origin, - conversation_id=conversation_id, - ) - if not conversation: - return "", [] - - try: - history = json.loads(conversation.history or "[]") - except json.JSONDecodeError: - return "", [] - return conversation_id, history if isinstance(history, list) else [] - - def _find_checkpoint_index( - self, history: list[dict], checkpoint_id: str - ) -> int | None: - for index, message in enumerate(history): - if get_checkpoint_id(message) == checkpoint_id: - return index - return None - - def _find_turn_range( - self, history: list[dict], checkpoint_id: str - ) -> tuple[int, int] | None: - checkpoint_index = self._find_checkpoint_index(history, checkpoint_id) - if checkpoint_index is None: - return None - - start = 0 - for index in range(checkpoint_index - 1, -1, -1): - if is_checkpoint_message(history[index]): - start = index + 1 - break - return start, checkpoint_index - - def _is_latest_checkpoint(self, history: list[dict], checkpoint_id: str) -> bool: - for message in reversed(history): - current_checkpoint_id = get_checkpoint_id(message) - if current_checkpoint_id: - return current_checkpoint_id == checkpoint_id - return False - - def _replace_user_conversation_content(self, original_content, edited_text: str): - if isinstance(original_content, str): - return edited_text - if not isinstance(original_content, list): - return edited_text - - result: list[dict] = [] - inserted_text = False - for part in original_content: - if not isinstance(part, dict): - result.append(part) - continue - if part.get("type") != "text": - result.append(part) - continue - text = part.get("text") - if isinstance(text, str) and text.startswith(""): - result.append(part) - continue - if not inserted_text and edited_text: - result.append({"type": "text", "text": edited_text}) - inserted_text = True - - if not inserted_text and edited_text: - result.insert(0, {"type": "text", "text": edited_text}) - return result - - def _replace_assistant_conversation_content( - self, - original_content, - edited_text: str, - reasoning: str, - ): - if isinstance(original_content, str): - return edited_text - if not isinstance(original_content, list): - return [{"type": "text", "text": edited_text}] if edited_text else [] - - result: list[dict] = [] - inserted_text = False - inserted_think = False - for part in original_content: - if not isinstance(part, dict): - result.append(part) - continue - if part.get("type") == "text": - if not inserted_text and edited_text: - result.append({"type": "text", "text": edited_text}) - inserted_text = True - continue - if part.get("type") == "think": - if not inserted_think and reasoning: - result.append({"type": "think", "think": reasoning}) - inserted_think = True - continue - result.append(part) - - if reasoning and not inserted_think: - result.insert(0, {"type": "think", "think": reasoning}) - if edited_text and not inserted_text: - result.append({"type": "text", "text": edited_text}) - return result - - def _find_turn_user_index( - self, history: list[dict], start: int, end: int - ) -> int | None: - for index in range(start, end): - message = history[index] - if isinstance(message, dict) and message.get("role") == "user": - return index - return None - - def _find_turn_final_assistant_index( - self, history: list[dict], start: int, end: int - ) -> int | None: - for index in range(end - 1, start - 1, -1): - message = history[index] - if not isinstance(message, dict) or message.get("role") != "assistant": - continue - if message.get("tool_calls") and not message.get("content"): - continue - return index - return None - - async def _get_sorted_platform_history(self, session) -> list: - history_list = await self.platform_history_mgr.get( - platform_id=session.platform_id, - user_id=session.session_id, - page=1, - page_size=100000, - ) - history_list.sort(key=lambda item: (item.created_at, item.id)) - return history_list - - async def _delete_platform_history_after( - self, session, message_id: int - ) -> list[int]: - history_list = await self._get_sorted_platform_history(session) - should_delete = False - deleted_ids: list[int] = [] - for item in history_list: - if should_delete: - if item.id is not None: - deleted_ids.append(item.id) - await self.platform_history_mgr.delete_by_id(item.id) - continue - if item.id == message_id: - should_delete = True - return deleted_ids - - async def _save_bot_message( - self, - webchat_conv_id: str, - message_parts: list[dict], - agent_stats: dict, - refs: dict, - llm_checkpoint_id: str | None = None, - platform_history_id: str = "webchat", - ): - """保存 bot 消息到历史记录,返回保存的记录""" - new_his = build_bot_history_content( - message_parts, - agent_stats=agent_stats, - refs=refs, - ) - - record = await self.platform_history_mgr.insert( - platform_id=platform_history_id, - user_id=webchat_conv_id, - content=new_his, - sender_id="bot", - sender_name="bot", - llm_checkpoint_id=llm_checkpoint_id, - ) - return record - async def chat(self, post_data: dict | None = None): username = g.get("username", "guest") if post_data is None: - post_data = await request.json + post_data = await request.get_json() if post_data is None: - return Response().error("Missing JSON body").__dict__ - if "message" not in post_data and "files" not in post_data: - return Response().error("Missing key: message or files").__dict__ - - if "session_id" not in post_data and "conversation_id" not in post_data: - return ( - Response().error("Missing key: session_id or conversation_id").__dict__ - ) - - message = post_data["message"] - session_id = post_data.get("session_id", post_data.get("conversation_id")) - selected_provider = post_data.get("selected_provider") - selected_model = post_data.get("selected_model") - enable_streaming = post_data.get("enable_streaming", True) - platform_history_id = post_data.get("_platform_history_id") or "webchat" - thread_selected_text = post_data.get("_thread_selected_text") - - if not session_id: - return Response().error("session_id is empty").__dict__ - - webchat_conv_id = session_id - - # 构建用户消息段(包含 path 用于传递给 adapter) - message_parts = await self._build_user_message_parts(message) - if not webchat_message_parts_have_content(message_parts): - return ( - Response() - .error("Message content is empty (reply only is not allowed)") - .__dict__ - ) - - message_id = str(uuid.uuid4()) - llm_checkpoint_id = post_data.get("_llm_checkpoint_id") or str(uuid.uuid4()) - skip_user_history = bool(post_data.get("_skip_user_history")) - back_queue = webchat_queue_mgr.get_or_create_back_queue( - message_id, - webchat_conv_id, - ) - saved_user_record = None - - async def stream(): - client_disconnected = False - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - - async def flush_pending_bot_message(): - nonlocal message_accumulator, agent_stats, refs - if not (message_accumulator.has_content() or refs or agent_stats): - return None - - message_parts_to_save = message_accumulator.build_message_parts( - include_pending_tool_calls=True - ) - plain_text = collect_plain_text_from_message_parts( - message_parts_to_save - ) - - try: - extracted_refs = self._extract_web_search_refs( - plain_text, - message_parts_to_save, - ) - except Exception as e: - logger.exception( - f"Failed to extract web search refs: {e}", - exc_info=True, - ) - extracted_refs = refs - - saved_record = await self._save_bot_message( - webchat_conv_id, - message_parts_to_save, - agent_stats, - extracted_refs, - llm_checkpoint_id, - platform_history_id, - ) - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - return saved_record - - def build_attachment_saved_event(part: dict | None) -> str | None: - if not part or not part.get("attachment_id") or not part.get("type"): - return None - - payload = { - "type": "attachment_saved", - "data": { - "id": part["attachment_id"], - "type": part["type"], - }, - } - return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" - - try: - # Emit session_id first so clients can bind the stream immediately. - session_info = { - "type": "session_id", - "data": None, - "session_id": webchat_conv_id, - } - yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n" - if saved_user_record and not client_disconnected: - user_saved_info = { - "type": "user_message_saved", - "data": { - "id": saved_user_record.id, - "created_at": to_utc_isoformat( - saved_user_record.created_at - ), - "llm_checkpoint_id": llm_checkpoint_id, - }, - } - yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n" - - async with track_conversation(self.running_convs, webchat_conv_id): - while True: - result, should_break = await _poll_webchat_stream_result( - back_queue, username - ) - if should_break: - client_disconnected = True - break - if not result: - # Send an SSE comment as keep-alive so the client - # doesn't time out during slow backend ops like - # context compression with reasoning models (#6938). - if not client_disconnected: - yield SSE_HEARTBEAT - continue - - if ( - "message_id" in result - and result["message_id"] != message_id - ): - logger.warning("webchat stream message_id mismatch") - continue - - result_text = result["data"] - msg_type = result.get("type") - streaming = result.get("streaming", False) - chain_type = result.get("chain_type") - - if chain_type == "agent_stats": - stats_info = { - "type": "agent_stats", - "data": json.loads(result_text), - } - yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n" - agent_stats = stats_info["data"] - continue - - # 发送 SSE 数据 - try: - if not client_disconnected: - yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" - except Exception as e: - if not client_disconnected: - logger.debug( - f"[WebChat] 用户 {username} 断开聊天长连接。 {e}" - ) - client_disconnected = True - - try: - if not client_disconnected: - await asyncio.sleep(0.05) - except asyncio.CancelledError: - logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") - client_disconnected = True - - # 累积消息部分 - if msg_type == "plain": - message_accumulator.add_plain( - result_text, - chain_type=chain_type, - streaming=streaming, - ) - elif msg_type == "image": - filename = result_text.replace("[IMAGE]", "") - part = await self._create_attachment_from_file( - filename, "image" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - elif msg_type == "record": - filename = result_text.replace("[RECORD]", "") - part = await self._create_attachment_from_file( - filename, "record" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - elif msg_type == "file": - # 格式: [FILE]filename - filename = result_text.replace("[FILE]", "") - part = await self._create_attachment_from_file( - filename, "file" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - elif msg_type == "video": - filename = result_text.replace("[VIDEO]", "") - part = await self._create_attachment_from_file( - filename, "video" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - - should_save = False - if msg_type == "end": - should_save = message_accumulator.has_content() or bool( - refs or agent_stats - ) - elif (streaming and msg_type == "complete") or not streaming: - if chain_type not in ("tool_call", "tool_call_result"): - should_save = True - - if should_save: - saved_record = await flush_pending_bot_message() - # 发送保存的消息信息给前端 - if saved_record and not client_disconnected: - saved_info = { - "type": "message_saved", - "data": { - "id": saved_record.id, - "created_at": to_utc_isoformat( - saved_record.created_at - ), - "llm_checkpoint_id": llm_checkpoint_id, - }, - } - try: - yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" - except Exception: - pass - if msg_type == "end": - break - except BaseException as e: - logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) - finally: - try: - await flush_pending_bot_message() - except Exception as e: - logger.exception( - f"Failed to persist pending webchat message: {e}", - exc_info=True, - ) - webchat_queue_mgr.remove_back_queue(message_id) - - # 将消息放入会话特定的队列 - chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) - await chat_queue.put( - ( - username, - webchat_conv_id, - { - "message": message_parts, - "selected_provider": selected_provider, - "selected_model": selected_model, - "enable_streaming": enable_streaming, - "message_id": message_id, - "llm_checkpoint_id": llm_checkpoint_id, - "thread_selected_text": thread_selected_text, - }, - ), - ) - - message_parts_for_storage = strip_message_parts_path_fields(message_parts) - - if not skip_user_history: - saved_user_record = await self.platform_history_mgr.insert( - platform_id=platform_history_id, - user_id=webchat_conv_id, - content={"type": "user", "message": message_parts_for_storage}, - sender_id=username, - sender_name=username, - llm_checkpoint_id=llm_checkpoint_id, - ) + return self._error("Missing JSON body") + try: + stream = await self.service.build_chat_stream(username, post_data) + except ChatServiceError as exc: + return self._error(str(exc)) response = cast( - QuartResponse, + CompatResponse, await make_response( - stream(), + stream, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -1044,700 +141,116 @@ def build_attachment_saved_event(part: dict | None) -> str | None: return response async def stop_session(self): - """Stop active agent runs for a session.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - message_type = ( - MessageType.GROUP_MESSAGE.value - if session.is_group - else MessageType.FRIEND_MESSAGE.value - ) - umo = ( - f"{session.platform_id}:{message_type}:" - f"{session.platform_id}!{username}!{session_id}" - ) - stopped_count = active_event_registry.request_agent_stop_all(umo) - - return Response().ok(data={"stopped_count": stopped_count}).__dict__ - - async def _delete_session_internal(self, session, username: str) -> None: - """Delete a single session and all its related data.""" - session_id = session.session_id - - # 删除该会话下的所有对话 - message_type = "GroupMessage" if session.is_group else "FriendMessage" - unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}" - await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) - - # 获取消息历史中的所有附件 ID 并删除附件 - history_list = await self.platform_history_mgr.get( - platform_id=session.platform_id, - user_id=session_id, - page=1, - page_size=100000, # 获取足够多的记录 - ) - attachment_ids = self._extract_attachment_ids(history_list) - if attachment_ids: - await self._delete_attachments(attachment_ids) - - # 删除消息历史 - await self.platform_history_mgr.delete( - platform_id=session.platform_id, - user_id=session_id, - offset_sec=99999999, - ) - thread_ids = await self.db.delete_webchat_threads_by_parent_session(session_id) - await self._delete_threads_by_ids(thread_ids, username) - - # 删除与会话关联的配置路由 - try: - await self.umop_config_router.delete_route(unified_msg_origin) - except ValueError as exc: - logger.warning( - "Failed to delete UMO route %s during session cleanup: %s", - unified_msg_origin, - exc, + return await self._run_json( + lambda data: self.service.stop_session_from_legacy_payload( + g.get("username", "guest"), + data, ) - - # 清理队列(仅对 webchat) - if session.platform_id == "webchat": - webchat_queue_mgr.remove_queues(session_id) - - # 删除会话 - await self.db.delete_platform_session(session_id) + ) async def delete_webchat_session(self): """Delete a Platform session and all its related data.""" - session_id = request.args.get("session_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - username = g.get("username", "guest") - - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - await self._delete_session_internal(session, username) - - return Response().ok().__dict__ + return await self._run( + lambda: self.service.delete_webchat_session_from_legacy_query( + g.get("username", "guest"), + request.args.get("session_id"), + ) + ) async def batch_delete_sessions(self): """Batch delete multiple Platform sessions.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - if not isinstance(post_data, dict): - return Response().error("Invalid JSON body: expected object").__dict__ - - session_ids = post_data.get("session_ids") - if not session_ids or not isinstance(session_ids, list): - return Response().error("Missing or invalid key: session_ids").__dict__ - - username = g.get("username", "guest") - sessions = await self.db.get_platform_sessions_by_ids(session_ids) - sessions_by_id = {session.session_id: session for session in sessions} - deleted_count = 0 - failed_items = [] - - for sid in session_ids: - session = sessions_by_id.get(sid) - if not session: - failed_items.append({"session_id": sid, "reason": "not found"}) - continue - if session.creator != username: - failed_items.append({"session_id": sid, "reason": "permission denied"}) - continue - - try: - await self._delete_session_internal(session, username) - deleted_count += 1 - sessions_by_id.pop(sid, None) - except Exception: - logger.warning("Failed to delete session %s", sid) - failed_items.append({"session_id": sid, "reason": "internal_error"}) - - return ( - Response() - .ok( - data={ - "deleted_count": deleted_count, - "failed_count": len(failed_items), - "failed_items": failed_items, - } + return await self._run_json( + lambda data: self.service.batch_delete_sessions_from_legacy_payload( + g.get("username", "guest"), + data, ) - .__dict__ ) - def _extract_attachment_ids(self, history_list) -> list[str]: - """从消息历史中提取所有 attachment_id""" - attachment_ids = [] - for history in history_list: - content = history.content - if not content or "message" not in content: - continue - message_parts = content.get("message", []) - for part in message_parts: - if isinstance(part, dict) and "attachment_id" in part: - attachment_ids.append(part["attachment_id"]) - return attachment_ids - - async def _delete_attachments(self, attachment_ids: list[str]) -> None: - """删除附件(包括数据库记录和磁盘文件)""" - try: - attachments = await self.db.get_attachments(attachment_ids) - for attachment in attachments: - if not os.path.exists(attachment.path): - continue - try: - os.remove(attachment.path) - except OSError as e: - logger.warning( - f"Failed to delete attachment file {attachment.path}: {e}" - ) - except Exception as e: - logger.warning(f"Failed to get attachments: {e}") - - # 批量删除数据库记录 - try: - await self.db.delete_attachments(attachment_ids) - except Exception as e: - logger.warning(f"Failed to delete attachments: {e}") - async def new_session(self): - """Create a new Platform session (default: webchat).""" - username = g.get("username", "guest") - - # 获取可选的 platform_id 参数,默认为 webchat - platform_id = request.args.get("platform_id", "webchat") - - # 创建新会话 - session = await self.db.create_platform_session( - creator=username, - platform_id=platform_id, - is_group=0, - ) - - return ( - Response() - .ok( - data={ - "session_id": session.session_id, - "platform_id": session.platform_id, - } + return await self._run( + self.service.new_session_from_legacy_query( + g.get("username", "guest"), + request.args.get("platform_id", "webchat"), ) - .__dict__ ) async def get_sessions(self): - """Get all Platform sessions for the current user.""" - username = g.get("username", "guest") - - # 获取可选的 platform_id 参数 - platform_id = request.args.get("platform_id") - - sessions, _ = await self.db.get_platform_sessions_by_creator_paginated( - creator=username, - platform_id=platform_id, - page=1, - page_size=100, # 暂时返回前100个 - exclude_project_sessions=True, - ) - - # 转换为字典格式 - sessions_data = [] - for item in sessions: - session = item["session"] - - sessions_data.append( - { - "session_id": session.session_id, - "platform_id": session.platform_id, - "creator": session.creator, - "display_name": session.display_name, - "is_group": session.is_group, - "created_at": to_utc_isoformat(session.created_at), - "updated_at": to_utc_isoformat(session.updated_at), - } + return await self._run( + self.service.get_sessions_from_legacy_query( + g.get("username", "guest"), + request.args.get("platform_id"), ) - - return Response().ok(data=sessions_data).__dict__ - - async def get_session(self): - """Get session information and message history by session_id.""" - session_id = request.args.get("session_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - - # 获取会话信息以确定 platform_id - session = await self.db.get_platform_session_by_id(session_id) - platform_id = session.platform_id if session else "webchat" - - # 获取项目信息(如果会话属于某个项目) - username = g.get("username", "guest") - project_info = await self.db.get_project_by_session( - session_id=session_id, creator=username - ) - - # Get platform message history using session_id - history_ls = await self.platform_history_mgr.get( - platform_id=platform_id, - user_id=session_id, - page=1, - page_size=1000, ) - history_res = [history.model_dump() for history in history_ls] - threads = await self.db.get_webchat_threads_by_parent_session( - parent_session_id=session_id, - creator=username, + async def get_session(self): + return await self._run( + self.service.get_session_from_legacy_query( + g.get("username", "guest"), + request.args.get("session_id"), + ) ) - response_data = { - "history": history_res, - "threads": [self._serialize_thread(thread) for thread in threads], - "is_running": self.running_convs.get(session_id, False), - } - - # 如果会话属于项目,添加项目信息 - if project_info: - response_data["project"] = { - "project_id": project_info.project_id, - "title": project_info.title, - "emoji": project_info.emoji, - } - - return Response().ok(data=response_data).__dict__ - async def create_thread(self): - """Create or reuse a side thread from a selected assistant message.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - parent_message_id = post_data.get("parent_message_id") - selected_text = str(post_data.get("selected_text") or "").strip() - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if parent_message_id is None: - return Response().error("Missing key: parent_message_id").__dict__ - if not selected_text: - return Response().error("Missing key: selected_text").__dict__ - - try: - parent_message_id = int(parent_message_id) - except (TypeError, ValueError): - return Response().error("Invalid key: parent_message_id").__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - parent_record = await self.db.get_platform_message_history_by_id( - parent_message_id - ) - if ( - not parent_record - or parent_record.platform_id != session.platform_id - or parent_record.user_id != session_id - ): - return Response().error("Parent message not found").__dict__ - if not isinstance(parent_record.content, dict): - return Response().error("Invalid parent message content").__dict__ - if parent_record.content.get("type") != "bot": - return Response().error("Only bot messages can create threads").__dict__ - - checkpoint_id = parent_record.llm_checkpoint_id - if not checkpoint_id: - return ( - Response().error("Parent message is not linked to LLM history").__dict__ + return await self._run_json( + lambda data: self.service.create_thread_from_legacy_payload( + g.get("username", "guest"), + data, ) - - existing = await self.db.get_webchat_thread_by_parent_message_and_text( - parent_session_id=session_id, - parent_message_id=parent_message_id, - selected_text=selected_text, - creator=username, ) - if existing: - return Response().ok(data=self._serialize_thread(existing)).__dict__ - - conversation_id, history = await self._load_current_conversation_history( - session - ) - turn_range = self._find_turn_range(history, checkpoint_id) - if not conversation_id or not turn_range: - return Response().error("Linked checkpoint not found").__dict__ - - _start, end = turn_range - base_history = history[: end + 1] - thread = await self.db.create_webchat_thread( - creator=username, - parent_session_id=session_id, - parent_message_id=parent_message_id, - base_checkpoint_id=checkpoint_id, - selected_text=selected_text, - ) - await self.conv_mgr.new_conversation( - unified_msg_origin=self._build_thread_unified_msg_origin( - username, - thread.thread_id, - ), - platform_id="webchat", - content=base_history, - ) - return Response().ok(data=self._serialize_thread(thread)).__dict__ async def get_thread(self): - """Get a side thread and its message history.""" - thread_id = request.args.get("thread_id") - if not thread_id: - return Response().error("Missing key: thread_id").__dict__ - - username = g.get("username", "guest") - thread = await self.db.get_webchat_thread_by_id(thread_id) - if not thread: - return Response().error(f"Thread {thread_id} not found").__dict__ - if thread.creator != username: - return Response().error("Permission denied").__dict__ - - history_ls = await self.platform_history_mgr.get( - platform_id="webchat_thread", - user_id=thread_id, - page=1, - page_size=1000, - ) - return ( - Response() - .ok( - data={ - "thread": self._serialize_thread(thread), - "history": [history.model_dump() for history in history_ls], - "is_running": self.running_convs.get(thread_id, False), - } + return await self._run( + self.service.get_thread_from_legacy_query( + g.get("username", "guest"), + request.args.get("thread_id"), ) - .__dict__ ) async def send_thread_message(self): """Send a message inside a WebChat side thread.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - thread_id = post_data.get("thread_id") - if not thread_id: - return Response().error("Missing key: thread_id").__dict__ - - username = g.get("username", "guest") - thread = await self.db.get_webchat_thread_by_id(thread_id) - if not thread: - return Response().error(f"Thread {thread_id} not found").__dict__ - if thread.creator != username: - return Response().error("Permission denied").__dict__ - - return await self.chat( - { - "session_id": thread.thread_id, - "message": post_data.get("message", []), - "enable_streaming": post_data.get("enable_streaming", True), - "selected_provider": post_data.get("selected_provider"), - "selected_model": post_data.get("selected_model"), - "_platform_history_id": "webchat_thread", - "_thread_selected_text": thread.selected_text, - } - ) - - async def delete_thread(self): - """Delete a WebChat side thread and its isolated history.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - thread_id = post_data.get("thread_id") - if not thread_id: - return Response().error("Missing key: thread_id").__dict__ - - username = g.get("username", "guest") - thread = await self.db.get_webchat_thread_by_id(thread_id) - if not thread: - return Response().error(f"Thread {thread_id} not found").__dict__ - if thread.creator != username: - return Response().error("Permission denied").__dict__ - - await self.db.delete_webchat_thread(thread_id) - await self._delete_threads_by_ids([thread_id], username) - return Response().ok(data={"thread_id": thread_id}).__dict__ - - async def update_message(self): - """Update a persisted WebChat message and its linked LLM turn.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - message_id = post_data.get("message_id") - content = post_data.get("content") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if message_id is None: - return Response().error("Missing key: message_id").__dict__ - try: - message_id = int(message_id) - content = self._sanitize_message_content(content) - except (TypeError, ValueError) as exc: - return Response().error(str(exc)).__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - record = await self.db.get_platform_message_history_by_id(message_id) - if not record: - return Response().error(f"Message {message_id} not found").__dict__ - if record.platform_id != session.platform_id or record.user_id != session_id: - return Response().error("Message does not belong to the session").__dict__ - if not isinstance(record.content, dict): - return Response().error("Invalid message content").__dict__ - if record.content.get("type") != content.get("type"): - return Response().error("Message type cannot be changed").__dict__ - if content.get("type") != "user": - return Response().error("Only user messages can be edited").__dict__ - - platform_history = await self._get_sorted_platform_history(session) - latest_user_record = next( - ( - item - for item in reversed(platform_history) - if isinstance(item.content, dict) and item.content.get("type") == "user" - ), - None, - ) - if not latest_user_record or latest_user_record.id != message_id: - return ( - Response().error("Only the latest user message can be edited").__dict__ + return await self.chat( + await self.service.prepare_thread_chat_payload_from_legacy_payload( + g.get("username", "guest"), + await self._json_body(), + ) ) + except ChatServiceError as exc: + return self._error(str(exc)) - checkpoint_id = record.llm_checkpoint_id - if not checkpoint_id: - return ( - Response() - .error("This message is not linked to LLM history and cannot be edited") - .__dict__ + async def delete_thread(self): + return await self._run_json( + lambda data: self.service.delete_thread_from_legacy_payload( + g.get("username", "guest"), + data, ) - - conversation_id, history = await self._load_current_conversation_history( - session ) - turn_range = self._find_turn_range(history, checkpoint_id) - if not conversation_id or not turn_range: - return Response().error("Linked checkpoint not found").__dict__ - if not self._is_latest_checkpoint(history, checkpoint_id): - return Response().error("Only the latest turn can be edited").__dict__ - start, _end = turn_range - - target_index = self._find_turn_user_index(history, start, _end) - if target_index is None: - return Response().error("Linked user message not found").__dict__ - - new_checkpoint_id = str(uuid.uuid4()) - truncated_history = history[:start] - await self.platform_history_mgr.update( - message_id=message_id, - content=content, - llm_checkpoint_id=new_checkpoint_id, - ) - deleted_message_ids = await self._delete_platform_history_after( - session, message_id - ) - thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids( - session_id, - deleted_message_ids, - ) - await self._delete_threads_by_ids(thread_ids, username) - await self.conv_mgr.update_conversation( - unified_msg_origin=self._build_webchat_unified_msg_origin(session), - conversation_id=conversation_id, - history=truncated_history, - ) - await self.db.update_platform_session(session_id=session_id) - updated = await self.db.get_platform_message_history_by_id(message_id) - return ( - Response() - .ok( - data={ - "message": updated.model_dump() if updated else None, - "needs_regenerate": True, - "truncated_after_message": True, - } + async def update_message(self): + """Update a persisted WebChat message and its linked LLM turn.""" + return await self._run_json( + lambda data: self.service.update_message_from_legacy_payload( + g.get("username", "guest"), + data, ) - .__dict__ ) async def regenerate_message(self): """Regenerate the latest bot message linked to an LLM checkpoint.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - message_id = post_data.get("message_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if message_id is None: - return Response().error("Missing key: message_id").__dict__ - try: - message_id = int(message_id) - except (TypeError, ValueError): - return Response().error("Invalid key: message_id").__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - target_record = await self.db.get_platform_message_history_by_id(message_id) - if not target_record: - return Response().error(f"Message {message_id} not found").__dict__ - if ( - target_record.platform_id != session.platform_id - or target_record.user_id != session_id - ): - return Response().error("Message does not belong to the session").__dict__ - if not isinstance(target_record.content, dict): - return Response().error("Invalid message content").__dict__ - if target_record.content.get("type") != "bot": - return Response().error("Only bot messages can be regenerated").__dict__ - - checkpoint_id = target_record.llm_checkpoint_id - if not checkpoint_id: - return Response().error("Message is not linked to LLM history").__dict__ - - conversation_id, history = await self._load_current_conversation_history( - session - ) - turn_range = self._find_turn_range(history, checkpoint_id) - if not conversation_id or not turn_range: - return Response().error("Linked checkpoint not found").__dict__ - if not self._is_latest_checkpoint(history, checkpoint_id): - return ( - Response().error("Regenerating older turns requires branching").__dict__ + return await self.chat( + await self.service.prepare_regenerate_message_payload_from_legacy_payload( + g.get("username", "guest"), + await self._json_body(), + ) ) - - start, end = turn_range - user_index = self._find_turn_user_index(history, start, end) - if user_index is None: - return Response().error("Linked user message not found").__dict__ - - platform_history = await self._get_sorted_platform_history(session) - source_user_record = next( - ( - item - for item in reversed(platform_history) - if item.llm_checkpoint_id == checkpoint_id - and isinstance(item.content, dict) - and item.content.get("type") == "user" - ), - None, - ) - if not source_user_record: - return Response().error("Linked user display message not found").__dict__ - - old_bot_record_ids = [ - item.id - for item in platform_history - if item.id is not None - and item.llm_checkpoint_id == checkpoint_id - and isinstance(item.content, dict) - and item.content.get("type") == "bot" - ] - if not old_bot_record_ids: - return Response().error("Linked bot display message not found").__dict__ - - new_checkpoint_id = str(uuid.uuid4()) - # The WebChat send path adds the current user message from the prompt. - # Remove the whole old turn here to avoid duplicating that user message. - new_history = history[:start] + history[end + 1 :] - await self.conv_mgr.update_conversation( - unified_msg_origin=self._build_webchat_unified_msg_origin(session), - conversation_id=conversation_id, - history=new_history, - ) - thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids( - session_id, - old_bot_record_ids, - ) - await self._delete_threads_by_ids(thread_ids, username) - for old_bot_record_id in old_bot_record_ids: - await self.platform_history_mgr.delete_by_id(old_bot_record_id) - await self.platform_history_mgr.update( - message_id=source_user_record.id, - llm_checkpoint_id=new_checkpoint_id, - ) - - return await self.chat( - { - "session_id": session_id, - "message": source_user_record.content.get("message", []), - "enable_streaming": post_data.get("enable_streaming", True), - "selected_provider": post_data.get("selected_provider"), - "selected_model": post_data.get("selected_model"), - "_skip_user_history": True, - "_llm_checkpoint_id": new_checkpoint_id, - } - ) + except ChatServiceError as exc: + return self._error(str(exc)) async def update_session_display_name(self): - """Update a Platform session's display name.""" - post_data = await request.json - - session_id = post_data.get("session_id") - display_name = post_data.get("display_name") - - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if display_name is None: - return Response().error("Missing key: display_name").__dict__ - - username = g.get("username", "guest") - - # 验证会话是否存在且属于当前用户 - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - # 更新 display_name - await self.db.update_platform_session( - session_id=session_id, - display_name=display_name, + return await self._run_json( + lambda data: self.service.update_session_display_name_from_legacy_payload( + g.get("username", "guest"), + data, + ) ) - - return Response().ok().__dict__ diff --git a/astrbot/dashboard/routes/chatui_project.py b/astrbot/dashboard/routes/chatui_project.py index 6ba570f552..b6cf7e1fcd 100644 --- a/astrbot/dashboard/routes/chatui_project.py +++ b/astrbot/dashboard/routes/chatui_project.py @@ -1,7 +1,9 @@ -from quart import g, request - from astrbot.core.db import BaseDatabase -from astrbot.core.utils.datetime_utils import to_utc_isoformat +from astrbot.dashboard.fastapi_compat import g, request +from astrbot.dashboard.services.chatui_project_service import ( + ChatUIProjectService, + ChatUIProjectServiceError, +) from .route import Response, Route, RouteContext @@ -22,225 +24,96 @@ def __init__(self, context: RouteContext, db: BaseDatabase) -> None: ), "/chatui_project/get_sessions": ("GET", self.get_project_sessions), } - self.db = db + self.service = ChatUIProjectService(db) self.register_routes() - async def create_project(self): - """Create a new ChatUI project.""" - username = g.get("username", "guest") - post_data = await request.json + @staticmethod + def _username() -> str: + return g.get("username", "guest") - title = post_data.get("title") - emoji = post_data.get("emoji", "📁") - description = post_data.get("description") + @staticmethod + def _service_error(exc: ChatUIProjectServiceError): + return Response().error(str(exc)).__dict__ - if not title: - return Response().error("Missing key: title").__dict__ + @staticmethod + def _ok(data=None): + return Response().ok(data=data).__dict__ - project = await self.db.create_chatui_project( - creator=username, - title=title, - emoji=emoji, - description=description, - ) + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - return ( - Response() - .ok( - data={ - "project_id": project.project_id, - "title": project.title, - "emoji": project.emoji, - "description": project.description, - "created_at": to_utc_isoformat(project.created_at), - "updated_at": to_utc_isoformat(project.updated_at), - } - ) - .__dict__ - ) + async def _run(self, operation): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except ChatUIProjectServiceError as exc: + return self._service_error(exc) - async def list_projects(self): - """Get all ChatUI projects for the current user.""" - username = g.get("username", "guest") + async def _run_json(self, operation): + async def invoke(): + data = await self._json_body() + return operation(data) - projects = await self.db.get_chatui_projects_by_creator(creator=username) + return await self._run(invoke) - projects_data = [ - { - "project_id": project.project_id, - "title": project.title, - "emoji": project.emoji, - "description": project.description, - "created_at": to_utc_isoformat(project.created_at), - "updated_at": to_utc_isoformat(project.updated_at), - } - for project in projects - ] + async def create_project(self): + """Create a new ChatUI project.""" + return await self._run_json( + lambda data: self.service.create_project(self._username(), data) + ) - return Response().ok(data=projects_data).__dict__ + async def list_projects(self): + """Get all ChatUI projects for the current user.""" + return await self._run(lambda: self.service.list_projects(self._username())) async def get_project(self): """Get a specific ChatUI project.""" - project_id = request.args.get("project_id") - if not project_id: - return Response().error("Missing key: project_id").__dict__ - - username = g.get("username", "guest") - - project = await self.db.get_chatui_project_by_id(project_id) - if not project: - return Response().error(f"Project {project_id} not found").__dict__ - - # Verify ownership - if project.creator != username: - return Response().error("Permission denied").__dict__ - - return ( - Response() - .ok( - data={ - "project_id": project.project_id, - "title": project.title, - "emoji": project.emoji, - "description": project.description, - "created_at": to_utc_isoformat(project.created_at), - "updated_at": to_utc_isoformat(project.updated_at), - } + return await self._run( + lambda: self.service.get_project_from_legacy_query( + self._username(), + request.args.get("project_id"), ) - .__dict__ ) async def update_chatui_project(self): """Update a ChatUI project.""" - post_data = await request.json - - project_id = post_data.get("project_id") - title = post_data.get("title") - emoji = post_data.get("emoji") - description = post_data.get("description") - - if not project_id: - return Response().error("Missing key: project_id").__dict__ - - username = g.get("username", "guest") - - # Verify ownership - project = await self.db.get_chatui_project_by_id(project_id) - if not project: - return Response().error(f"Project {project_id} not found").__dict__ - if project.creator != username: - return Response().error("Permission denied").__dict__ - - await self.db.update_chatui_project( - project_id=project_id, - title=title, - emoji=emoji, - description=description, + return await self._run_json( + lambda data: self.service.update_project(self._username(), data) ) - return Response().ok().__dict__ - async def delete_project(self): """Delete a ChatUI project.""" - project_id = request.args.get("project_id") - if not project_id: - return Response().error("Missing key: project_id").__dict__ - - username = g.get("username", "guest") - - # Verify ownership - project = await self.db.get_chatui_project_by_id(project_id) - if not project: - return Response().error(f"Project {project_id} not found").__dict__ - if project.creator != username: - return Response().error("Permission denied").__dict__ - - await self.db.delete_chatui_project(project_id) - - return Response().ok().__dict__ + return await self._run( + lambda: self.service.delete_project_from_legacy_query( + self._username(), + request.args.get("project_id"), + ) + ) async def add_session_to_project(self): """Add a session to a project.""" - post_data = await request.json - - session_id = post_data.get("session_id") - project_id = post_data.get("project_id") - - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if not project_id: - return Response().error("Missing key: project_id").__dict__ - - username = g.get("username", "guest") - - # Verify project ownership - project = await self.db.get_chatui_project_by_id(project_id) - if not project: - return Response().error(f"Project {project_id} not found").__dict__ - if project.creator != username: - return Response().error("Permission denied").__dict__ - - # Verify session ownership - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - await self.db.add_session_to_project(session_id, project_id) - - return Response().ok().__dict__ + return await self._run_json( + lambda data: self.service.add_session_to_project(self._username(), data) + ) async def remove_session_from_project(self): """Remove a session from its project.""" - post_data = await request.json - - session_id = post_data.get("session_id") - - if not session_id: - return Response().error("Missing key: session_id").__dict__ - - username = g.get("username", "guest") - - # Verify session ownership - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - await self.db.remove_session_from_project(session_id) - - return Response().ok().__dict__ + return await self._run_json( + lambda data: self.service.remove_session_from_project( + self._username(), + data, + ) + ) async def get_project_sessions(self): """Get all sessions in a project.""" - project_id = request.args.get("project_id") - if not project_id: - return Response().error("Missing key: project_id").__dict__ - - username = g.get("username", "guest") - - # Verify project ownership - project = await self.db.get_chatui_project_by_id(project_id) - if not project: - return Response().error(f"Project {project_id} not found").__dict__ - if project.creator != username: - return Response().error("Permission denied").__dict__ - - sessions = await self.db.get_project_sessions(project_id) - - sessions_data = [ - { - "session_id": session.session_id, - "platform_id": session.platform_id, - "creator": session.creator, - "display_name": session.display_name, - "is_group": session.is_group, - "created_at": to_utc_isoformat(session.created_at), - "updated_at": to_utc_isoformat(session.updated_at), - } - for session in sessions - ] - - return Response().ok(data=sessions_data).__dict__ + return await self._run( + lambda: self.service.get_project_sessions_from_legacy_query( + self._username(), + request.args.get("project_id"), + ) + ) diff --git a/astrbot/dashboard/routes/command.py b/astrbot/dashboard/routes/command.py index 1921fa4a44..3ebb787bc3 100644 --- a/astrbot/dashboard/routes/command.py +++ b/astrbot/dashboard/routes/command.py @@ -1,17 +1,7 @@ -from quart import request - -from astrbot.core.star.command_management import ( - list_command_conflicts, - list_commands, -) -from astrbot.core.star.command_management import ( - rename_command as rename_command_service, -) -from astrbot.core.star.command_management import ( - toggle_command as toggle_command_service, -) -from astrbot.core.star.command_management import ( - update_command_permission as update_command_permission_service, +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.command_service import ( + CommandService, + CommandServiceError, ) from .route import Response, Route, RouteContext @@ -20,7 +10,7 @@ class CommandRoute(Route): def __init__(self, context: RouteContext, core_lifecycle=None) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle + self.service = CommandService(self.config, core_lifecycle) self.routes = { "/commands": ("GET", self.get_commands), "/commands/conflicts": ("GET", self.get_conflicts), @@ -30,88 +20,50 @@ def __init__(self, context: RouteContext, core_lifecycle=None) -> None: } self.register_routes() - async def get_commands(self): - commands = await list_commands() - summary = { - "total": len(commands), - "disabled": len([cmd for cmd in commands if not cmd["enabled"]]), - "conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]), - } - # 优先从指定 config_id 的配置中读取唤醒词,否则使用默认配置 - config_id = request.args.get("config_id", "").strip() - wake_prefix = self.config.get("wake_prefix", ["/"]) - if config_id and self.core_lifecycle: - acm = getattr(self.core_lifecycle, "astrbot_config_mgr", None) - if acm and config_id in acm.confs: - wake_prefix = acm.confs[config_id].get("wake_prefix", wake_prefix) - return ( - Response() - .ok({"items": commands, "summary": summary, "wake_prefix": wake_prefix}) - .__dict__ - ) + @staticmethod + def _ok(data=None): + return Response().ok(data).__dict__ - async def get_conflicts(self): - conflicts = await list_command_conflicts() - return Response().ok(conflicts).__dict__ + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - async def toggle_command(self): + @staticmethod + async def _json_body() -> dict: data = await request.get_json() - handler_full_name = data.get("handler_full_name") - enabled = data.get("enabled") - - if handler_full_name is None or enabled is None: - return Response().error("handler_full_name 与 enabled 均为必填。").__dict__ - - if isinstance(enabled, str): - enabled = enabled.lower() in ("1", "true", "yes", "on") + return data if isinstance(data, dict) else {} + async def _run(self, operation): try: - await toggle_command_service(handler_full_name, bool(enabled)) - except ValueError as exc: - return Response().error(str(exc)).__dict__ + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except CommandServiceError as exc: + return self._error(str(exc)) - payload = await _get_command_payload(handler_full_name) - return Response().ok(payload).__dict__ - - async def rename_command(self): - data = await request.get_json() - handler_full_name = data.get("handler_full_name") - new_name = data.get("new_name") - aliases = data.get("aliases") + async def _run_json(self, operation): + async def invoke(): + data = await self._json_body() + return operation(data) - if not handler_full_name or not new_name: - return Response().error("handler_full_name 与 new_name 均为必填。").__dict__ + return await self._run(invoke) - try: - await rename_command_service(handler_full_name, new_name, aliases=aliases) - except ValueError as exc: - return Response().error(str(exc)).__dict__ - - payload = await _get_command_payload(handler_full_name) - return Response().ok(payload).__dict__ - - async def update_permission(self): - data = await request.get_json() - handler_full_name = data.get("handler_full_name") - permission = data.get("permission") - - if not handler_full_name or not permission: - return ( - Response().error("handler_full_name 与 permission 均为必填。").__dict__ + async def get_commands(self): + return await self._run( + self.service.list_commands_from_legacy_query( + request.args.get("config_id", "") ) + ) - try: - await update_command_permission_service(handler_full_name, permission) - except ValueError as exc: - return Response().error(str(exc)).__dict__ + async def get_conflicts(self): + return await self._run(self.service.list_conflicts()) - payload = await _get_command_payload(handler_full_name) - return Response().ok(payload).__dict__ + async def toggle_command(self): + return await self._run_json(self.service.toggle_command_from_legacy_payload) + async def rename_command(self): + return await self._run_json(self.service.rename_command_from_legacy_payload) -async def _get_command_payload(handler_full_name: str): - commands = await list_commands() - for cmd in commands: - if cmd["handler_full_name"] == handler_full_name: - return cmd - return {} + async def update_permission(self): + return await self._run_json(self.service.update_permission_from_legacy_payload) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index aebe26047c..e663b7c4de 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -1,375 +1,24 @@ -import asyncio -import copy -import inspect -import os import traceback -from pathlib import Path -from typing import Any +from inspect import isawaitable -from quart import jsonify, make_response, request - -from astrbot.core import astrbot_config, file_token_service, logger -from astrbot.core.config.astrbot_config import AstrBotConfig -from astrbot.core.config.default import ( - CONFIG_METADATA_2, - CONFIG_METADATA_3, - CONFIG_METADATA_3_SYSTEM, - DEFAULT_CONFIG, - DEFAULT_VALUE_MAP, -) -from astrbot.core.config.i18n_utils import ConfigMetadataI18n +from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.platform.register import platform_cls_map, platform_registry -from astrbot.core.provider import Provider -from astrbot.core.provider.register import provider_registry -from astrbot.core.star.star import StarMetadata, star_registry -from astrbot.core.utils.astrbot_path import ( - get_astrbot_plugin_data_path, +from astrbot.dashboard.fastapi_compat import jsonify, make_response, request +from astrbot.dashboard.services.config_service import ( + BotConfigService, + ConfigDisplayService, + ConfigFileService, + ConfigProfileService, + ConfigRoutingService, + ProviderConfigService, ) -from astrbot.core.utils.llm_metadata import LLM_METADATAS -from astrbot.core.utils.totp import ( - TwoFactorCodeType, - is_totp_enabled, - revoke_user_trusted_devices, - set_pending_totp_secret, - verify_configured_2fa_code, -) -from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config +from astrbot.dashboard.v1.responses import ApiError from .route import Response, Route, RouteContext -from .util import ( - config_key_to_folder, - get_schema_item, - normalize_rel_path, - sanitize_filename, -) -MAX_FILE_BYTES = 500 * 1024 * 1024 -PROTECTED_2FA_CONFIG_PATHS = ( - ("dashboard", "totp", "enable"), - ("dashboard", "totp", "secret"), - ("dashboard", "totp", "recovery_code_hash"), -) TWO_FACTOR_CODE_HEADER = "X-2FA-Code" -def try_cast(value: Any, type_: str): - if type_ == "int": - try: - return int(value) - except (ValueError, TypeError): - return None - elif ( - type_ == "float" - and isinstance(value, str) - and value.replace(".", "", 1).isdigit() - ) or (type_ == "float" and isinstance(value, int)): - return float(value) - elif type_ == "float": - try: - return float(value) - except (ValueError, TypeError): - return None - - -def _expect_type(value, expected_type, path_key, errors, expected_name=None) -> bool: - if not isinstance(value, expected_type): - errors.append( - f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, " - f"得到了 {type(value).__name__}" - ) - return False - return True - - -def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None: - if not _expect_type(value, list, path_key, errors, "list"): - return - - templates = meta.get("templates") - if not isinstance(templates, dict): - templates = {} - - for idx, item in enumerate(value): - item_path = f"{path_key}[{idx}]" - if not _expect_type(item, dict, item_path, errors, "dict"): - continue - - template_key = item.get("__template_key") or item.get("template") - if not template_key: - errors.append(f"缺少模板选择 {item_path}: 需要 __template_key") - continue - - template_meta = templates.get(template_key) - if not template_meta: - errors.append(f"未知模板 {item_path}: {template_key}") - continue - - validate_fn( - item, - template_meta.get("items", {}), - path=f"{path_key}.templates.{template_key}.", - ) - - -def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]: - errors = [] - - def validate(data: dict, metadata: dict = schema, path="") -> None: - for key, value in data.items(): - if key not in metadata: - continue - meta = metadata[key] - if "type" not in meta: - logger.debug(f"配置项 {path}{key} 没有类型定义, 跳过校验") - continue - # null 转换 - if value is None: - data[key] = DEFAULT_VALUE_MAP[meta["type"]] - continue - - if meta["type"] == "template_list": - _validate_template_list(value, meta, f"{path}{key}", errors, validate) - continue - - if meta["type"] == "file": - if not _expect_type(value, list, f"{path}{key}", errors, "list"): - continue - for idx, item in enumerate(value): - if not isinstance(item, str): - errors.append( - f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}", - ) - continue - normalized = normalize_rel_path(item) - if not normalized or not normalized.startswith("files/"): - errors.append( - f"Invalid file path {path}{key}[{idx}]: {item}", - ) - continue - key_path = f"{path}{key}" - expected_folder = config_key_to_folder(key_path) - expected_prefix = f"files/{expected_folder}/" - if not normalized.startswith(expected_prefix): - errors.append( - f"Invalid file path {path}{key}[{idx}]: {item}", - ) - continue - value[idx] = normalized - continue - - if meta["type"] == "list" and not isinstance(value, list): - errors.append( - f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}", - ) - elif ( - meta["type"] == "list" - and isinstance(value, list) - and value - and "items" in meta - and isinstance(value[0], dict) - ): - # 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置 - for item in value: - validate(item, meta["items"], path=f"{path}{key}.") - elif meta["type"] == "object" and isinstance(value, dict): - validate(value, meta["items"], path=f"{path}{key}.") - - if meta["type"] == "int" and not isinstance(value, int): - casted = try_cast(value, "int") - if casted is None: - errors.append( - f"错误的类型 {path}{key}: 期望是 int, 得到了 {type(value).__name__}", - ) - data[key] = casted - elif meta["type"] == "float" and not isinstance(value, float): - casted = try_cast(value, "float") - if casted is None: - errors.append( - f"错误的类型 {path}{key}: 期望是 float, 得到了 {type(value).__name__}", - ) - data[key] = casted - elif meta["type"] == "bool" and not isinstance(value, bool): - errors.append( - f"错误的类型 {path}{key}: 期望是 bool, 得到了 {type(value).__name__}", - ) - elif meta["type"] in ["string", "text"] and not isinstance(value, str): - errors.append( - f"错误的类型 {path}{key}: 期望是 string, 得到了 {type(value).__name__}", - ) - elif meta["type"] == "list" and not isinstance(value, list): - errors.append( - f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}", - ) - elif meta["type"] == "object" and not isinstance(value, dict): - errors.append( - f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}", - ) - - if is_core: - meta_all = { - **schema["platform_group"]["metadata"], - **schema["provider_group"]["metadata"], - **schema["misc_config_group"]["metadata"], - } - validate(data, meta_all) - else: - validate(data, schema) - - return errors, data - - -def _log_computer_config_changes(old_config: dict, new_config: dict) -> None: - """Compare and log Computer/sandbox configuration changes.""" - old_ps = old_config.get("provider_settings", {}) - new_ps = new_config.get("provider_settings", {}) - - # Check computer_use_runtime - old_runtime = old_ps.get("computer_use_runtime", "none") - new_runtime = new_ps.get("computer_use_runtime", "none") - if old_runtime != new_runtime: - logger.info( - "[Computer] Config changed: computer_use_runtime %s -> %s", - old_runtime, - new_runtime, - ) - - # Check sandbox sub-keys - old_sandbox = old_ps.get("sandbox", {}) - new_sandbox = new_ps.get("sandbox", {}) - all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys()) - for key in sorted(all_keys): - old_val = old_sandbox.get(key) - new_val = new_sandbox.get(key) - if old_val != new_val: - # Mask tokens/secrets in log output - if "token" in key or "secret" in key: - old_display = "***" if old_val else "(empty)" - new_display = "***" if new_val else "(empty)" - else: - old_display = old_val - new_display = new_val - logger.info( - "[Computer] Config changed: sandbox.%s %s -> %s", - key, - old_display, - new_display, - ) - - -def _get_nested_value(data: dict, path: tuple[str, ...]) -> Any: - current = data - for key in path: - if not isinstance(current, dict) or key not in current: - return None - current = current[key] - return current - - -def _set_nested_value(data: dict, path: tuple[str, ...], value: Any) -> None: - current = data - for key in path[:-1]: - next_value = current.get(key) - if not isinstance(next_value, dict): - next_value = {} - current[key] = next_value - current = next_value - current[path[-1]] = value - - -def _protected_2fa_config_changed(old_config: dict, new_config: dict) -> bool: - return any( - _get_nested_value(old_config, path) != _get_nested_value(new_config, path) - for path in PROTECTED_2FA_CONFIG_PATHS - ) - - -async def _validate_neo_connectivity( - post_config: dict, -) -> str | None: - """Check if Bay is reachable when Shipyard Neo sandbox is configured. - - Returns a warning message string if Bay isn't reachable, or None if - everything looks fine (or Neo isn't configured). - """ - ps = post_config.get("provider_settings", {}) - runtime = ps.get("computer_use_runtime", "none") - sandbox = ps.get("sandbox", {}) - booter = sandbox.get("booter", "") - - # Only check when sandbox mode + shipyard_neo is selected - if runtime != "sandbox" or booter != "shipyard_neo": - return None - - endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/") - if not endpoint: - return "⚠️ Shipyard Neo endpoint 未设置" - - access_token = sandbox.get("shipyard_neo_access_token", "") - if not access_token: - # Try auto-discovery - from astrbot.core.computer.computer_client import _discover_bay_credentials - - access_token = _discover_bay_credentials(endpoint) - - if not access_token: - return ( - "⚠️ 未找到 Bay API Key。请填写访问令牌," - "或确保 Bay 的 credentials.json 可被自动发现。" - ) - - # Connectivity check - import aiohttp - - health_url = f"{endpoint}/health" - try: - async with aiohttp.ClientSession() as session: - async with session.get( - health_url, - timeout=aiohttp.ClientTimeout(total=5), - ) as resp: - if resp.status != 200: - return ( - f"⚠️ Bay 健康检查失败 (HTTP {resp.status})," - f"请确认 Bay 正在运行: {endpoint}" - ) - except Exception: - return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。" - - return None - - -def save_config( - post_config: dict, config: AstrBotConfig, is_core: bool = False -) -> None: - """验证并保存配置""" - errors = None - - # Snapshot old Computer config for change detection - if is_core: - _log_computer_config_changes(dict(config), post_config) - - try: - if is_core: - errors, post_config = validate_config( - post_config, - CONFIG_METADATA_2, - is_core, - ) - else: - errors, post_config = validate_config( - post_config, getattr(config, "schema", {}), is_core - ) - except BaseException as e: - logger.error(traceback.format_exc()) - logger.warning(f"验证配置时出现异常: {e}") - raise ValueError(f"验证配置时出现异常: {e}") - if errors: - raise ValueError(f"格式校验未通过: {errors}") - - config.save_config(post_config) - - class ConfigRoute(Route): def __init__( self, @@ -377,12 +26,15 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle - self.config: AstrBotConfig = core_lifecycle.astrbot_config - self.db = core_lifecycle.db - self._logo_token_cache = {} # 缓存logo token,避免重复注册 - self.acm = core_lifecycle.astrbot_config_mgr - self.ucr = core_lifecycle.umop_config_router + self.config_profile_service = ConfigProfileService( + core_lifecycle, + core_lifecycle.db, + ) + self.config_display_service = ConfigDisplayService(core_lifecycle) + self.config_file_service = ConfigFileService(core_lifecycle) + self.config_routing_service = ConfigRoutingService(core_lifecycle) + self.bot_config_service = BotConfigService(core_lifecycle) + self.provider_config_service = ProviderConfigService(core_lifecycle) self.routes = { "/config/abconf/new": ("POST", self.create_abconf), "/config/abconf": ("GET", self.get_abconf), @@ -427,700 +79,202 @@ def __init__( } self.register_routes() - async def delete_provider_source(self): - """删除 provider_source,并更新关联的 providers""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - provider_source_id = post_data.get("id") - if not provider_source_id: - return Response().error("缺少 provider_source_id").__dict__ + @staticmethod + def _ok(data=None, message: str | None = None): + return Response().ok(data, message).__dict__ - provider_sources = self.config.get("provider_sources", []) - target_idx = next( - ( - i - for i, ps in enumerate(provider_sources) - if ps.get("id") == provider_source_id - ), - -1, - ) - - if target_idx == -1: - return Response().error("未找到对应的 provider source").__dict__ - - # 删除 provider_source - del provider_sources[target_idx] - - # 写回配置 - self.config["provider_sources"] = provider_sources + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - # 删除引用了该 provider_source 的 providers - await self.core_lifecycle.provider_manager.delete_provider( - provider_source_id=provider_source_id - ) + async def _json_body(self): + return await request.json + async def _run( + self, + operation, + *, + message: str | None = None, + result_as_message: bool = False, + error_prefix: str | None = None, + trace: bool = True, + ): try: - save_config(self.config, self.config, is_core=True) + result = operation() + if isawaitable(result): + result = await result + if result_as_message: + return self._ok(message=str(result) if result is not None else message) + return self._ok(result, message) + except ValueError as e: + return self._error(str(e)) except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + if trace: + logger.error(traceback.format_exc()) + error_message = f"{error_prefix}: {e!s}" if error_prefix else str(e) + return self._error(error_message) - return Response().ok(message="删除 provider source 成功").__dict__ + async def _run_json(self, operation, **kwargs): + payload = await self._json_body() + return await self._run(lambda: operation(payload), **kwargs) + + async def delete_provider_source(self): + """删除 provider_source,并更新关联的 providers""" + return await self._run_json( + self.provider_config_service.delete_provider_source_from_legacy_payload, + result_as_message=True, + ) async def update_provider_source(self): """更新或新增 provider_source,并重载关联的 providers""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - new_source_config = post_data.get("config") or post_data - original_id = post_data.get("original_id") - if not original_id: - return Response().error("缺少 original_id").__dict__ - - if not isinstance(new_source_config, dict): - return Response().error("缺少或错误的配置数据").__dict__ - - # 确保配置中有 id 字段 - if not new_source_config.get("id"): - new_source_config["id"] = original_id - - provider_sources = self.config.get("provider_sources", []) - - for ps in provider_sources: - if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id: - return ( - Response() - .error( - f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.", - ) - .__dict__ - ) - - # 查找旧的 provider_source,若不存在则追加为新配置 - target_idx = next( - (i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id), - -1, + return await self._run_json( + self.provider_config_service.upsert_provider_source_from_legacy_payload, + result_as_message=True, ) - old_id = original_id - if target_idx == -1: - provider_sources.append(new_source_config) - else: - old_id = provider_sources[target_idx].get("id") - provider_sources[target_idx] = new_source_config - - # 更新引用了该 provider_source 的 providers - affected_providers = [] - for provider in self.config.get("provider", []): - if provider.get("provider_source_id") == old_id: - provider["provider_source_id"] = new_source_config["id"] - affected_providers.append(provider) - - # 写回配置 - self.config["provider_sources"] = provider_sources - - try: - save_config(self.config, self.config, is_core=True) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - - # 重载受影响的 providers,使新的 source 配置生效 - reload_errors = [] - prov_mgr = self.core_lifecycle.provider_manager - for provider in affected_providers: - try: - await prov_mgr.reload(provider) - except Exception as e: - logger.error(traceback.format_exc()) - reload_errors.append(f"{provider.get('id')}: {e}") - - if reload_errors: - return ( - Response() - .error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors)) - .__dict__ - ) - - return Response().ok(message="更新 provider source 成功").__dict__ - async def get_provider_template(self): - provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys( - { - "provider_group": { - "metadata": { - "provider": CONFIG_METADATA_2["provider_group"]["metadata"][ - "provider" - ] - } - } - } - ) - config_schema = { - "provider": provider_metadata["provider_group"]["metadata"]["provider"] - } - data = { - "config_schema": config_schema, - "providers": astrbot_config["provider"], - "provider_sources": astrbot_config["provider_sources"], - } - return Response().ok(data=data).__dict__ + return self._ok(data=self.provider_config_service.get_provider_schema()) async def get_uc_table(self): """获取 UMOP 配置路由表""" - return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ + return self._ok(self.config_routing_service.list_routes()) async def update_ucr_all(self): """更新 UMOP 配置路由表的全部内容""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - new_routing = post_data.get("routing", None) - - if not new_routing or not isinstance(new_routing, dict): - return Response().error("缺少或错误的路由表数据").__dict__ - - try: - await self.ucr.update_routing_data(new_routing) - return Response().ok(message="更新成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"更新路由表失败: {e!s}").__dict__ + return await self._run_json( + self.config_routing_service.replace_routes_from_legacy_payload, + result_as_message=True, + error_prefix="更新路由表失败", + ) async def update_ucr(self): """更新 UMOP 配置路由表""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - umo = post_data.get("umo", None) - conf_id = post_data.get("conf_id", None) - - if not umo or not conf_id: - return Response().error("缺少 UMO 或配置文件 ID").__dict__ - - try: - await self.ucr.update_route(umo, conf_id) - return Response().ok(message="更新成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"更新路由表失败: {e!s}").__dict__ + return await self._run_json( + self.config_routing_service.upsert_route_from_legacy_payload, + result_as_message=True, + error_prefix="更新路由表失败", + ) async def delete_ucr(self): """删除 UMOP 配置路由表中的一项""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - umo = post_data.get("umo", None) - - if not umo: - return Response().error("缺少 UMO").__dict__ - - try: - if umo in self.ucr.umop_to_conf_id: - del self.ucr.umop_to_conf_id[umo] - await self.ucr.update_routing_data(self.ucr.umop_to_conf_id) - return Response().ok(message="删除成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"删除路由表项失败: {e!s}").__dict__ + return await self._run_json( + self.config_routing_service.delete_route_from_legacy_payload, + result_as_message=True, + error_prefix="删除路由表项失败", + ) async def get_default_config(self): """获取默认配置文件""" - metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__ + return self._ok(self.config_profile_service.get_profile_schema()) async def get_abconf_list(self): """获取所有 AstrBot 配置文件的列表""" - abconf_list = self.acm.get_conf_list() - return Response().ok({"info_list": abconf_list}).__dict__ + return self._ok(self.config_profile_service.list_profiles()) async def create_abconf(self): """创建新的 AstrBot 配置文件""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - name = post_data.get("name", None) - config = post_data.get("config", DEFAULT_CONFIG) - - try: - conf_id = self.acm.create_conf(name=name, config=config) - await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ + return await self._run_json( + self.config_profile_service.create_profile_from_legacy_payload, + message="创建成功", + ) async def get_abconf(self): """获取指定 AstrBot 配置文件""" - abconf_id = request.args.get("id") - system_config = request.args.get("system_config", "0").lower() == "1" - if not abconf_id and not system_config: - return Response().error("缺少配置文件 ID").__dict__ - - try: - if system_config: - abconf = self.acm.confs["default"] - metadata = ConfigMetadataI18n.convert_to_i18n_keys( - CONFIG_METADATA_3_SYSTEM - ) - return Response().ok({"config": abconf, "metadata": metadata}).__dict__ - if abconf_id is None: - raise ValueError("abconf_id cannot be None") - abconf = self.acm.confs[abconf_id] - metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - return Response().ok({"config": abconf, "metadata": metadata}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ + return await self._run( + lambda: self.config_profile_service.get_profile_from_legacy_args( + request.args + ) + ) async def delete_abconf(self): """删除指定 AstrBot 配置文件""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - conf_id = post_data.get("id") - if not conf_id: - return Response().error("缺少配置文件 ID").__dict__ - - try: - success = self.acm.delete_conf(conf_id) - if success: - self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None) - return Response().ok(message="删除成功").__dict__ - return Response().error("删除失败").__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"删除配置文件失败: {e!s}").__dict__ + return await self._run_json( + self.config_profile_service.delete_profile_from_legacy_payload, + result_as_message=True, + error_prefix="删除配置文件失败", + ) async def update_abconf(self): """更新指定 AstrBot 配置文件信息""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - conf_id = post_data.get("id") - if not conf_id: - return Response().error("缺少配置文件 ID").__dict__ - - name = post_data.get("name") - - try: - success = self.acm.update_conf_info(conf_id, name=name) - if success: - return Response().ok(message="更新成功").__dict__ - return Response().error("更新失败").__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"更新配置文件失败: {e!s}").__dict__ - - async def _test_single_provider(self, provider): - """辅助函数:测试单个 provider 的可用性""" - meta = provider.meta() - provider_name = provider.provider_config.get("id", "Unknown Provider") - provider_capability_type = meta.provider_type - - status_info = { - "id": getattr(meta, "id", "Unknown ID"), - "model": getattr(meta, "model", "Unknown Model"), - "type": provider_capability_type.value, - "name": provider_name, - "status": "unavailable", # 默认为不可用 - "error": None, - } - logger.debug( - f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})", + return await self._run_json( + self.config_profile_service.rename_profile_from_legacy_payload, + result_as_message=True, + error_prefix="更新配置文件失败", ) - try: - await provider.test() - status_info["status"] = "available" - logger.info( - f"Provider {status_info['name']} (ID: {status_info['id']}) is available.", - ) - except Exception as e: - error_message = str(e) - status_info["error"] = error_message - logger.warning( - f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}", - ) - logger.debug( - f"Traceback for {status_info['name']}:\n{traceback.format_exc()}", - ) - - return status_info - - def _error_response( - self, - message: str, - status_code: int = 500, - log_fn=logger.error, - ): - log_fn(message) - # 记录更详细的traceback信息,但只在是严重错误时 - if status_code == 500: - log_fn(traceback.format_exc()) - return Response().error(message).__dict__ - async def check_one_provider_status(self): """API: check a single LLM Provider's status by id""" - provider_id = request.args.get("id") - if not provider_id: - return self._error_response( - "Missing provider_id parameter", - 400, - logger.warning, - ) - - logger.info(f"API call: /config/provider/check_one id={provider_id}") - try: - prov_mgr = self.core_lifecycle.provider_manager - target = prov_mgr.inst_map.get(provider_id) - - if not target: - logger.warning( - f"Provider with id '{provider_id}' not found in provider_manager.", - ) - return ( - Response() - .error(f"Provider with id '{provider_id}' not found") - .__dict__ - ) - - result = await self._test_single_provider(target) - return Response().ok(result).__dict__ - - except Exception as e: - return self._error_response( - f"Critical error checking provider {provider_id}: {e}", - 500, - ) + return await self._run( + lambda: self.provider_config_service.test_provider_from_legacy_args( + request.args + ), + error_prefix="Critical error checking provider", + ) async def get_configs(self): - # plugin_name 为空时返回 AstrBot 配置 - # 否则返回指定 plugin_name 的插件配置 - plugin_name = request.args.get("plugin_name", None) - if not plugin_name: - return Response().ok(await self._get_astrbot_config()).__dict__ - return Response().ok(await self._get_plugin_config(plugin_name)).__dict__ + return await self._run( + lambda: self.config_display_service.get_configs_from_legacy_args( + request.args + ) + ) async def get_provider_config_list(self): - provider_type = request.args.get("provider_type", None) - if not provider_type: - return Response().error("缺少参数 provider_type").__dict__ - provider_type_ls = provider_type.split(",") - provider_list = [] - ps = self.core_lifecycle.provider_manager.providers_config - p_source_pt = { - psrc["id"]: psrc.get("provider_type", "chat_completion") - for psrc in self.core_lifecycle.provider_manager.provider_sources_config - } - for provider in ps: - ps_id = provider.get("provider_source_id", None) - if ( - ps_id - and ps_id in p_source_pt - and p_source_pt[ps_id] in provider_type_ls - ): - # chat - prov = self.core_lifecycle.provider_manager.get_merged_provider_config( - provider - ) - provider_list.append(prov) - elif not ps_id and provider.get("provider_type", "") in provider_type_ls: - # agent runner, embedding, etc - provider_list.append(provider) - return Response().ok(provider_list).__dict__ + return await self._run( + lambda: self.provider_config_service.list_providers_from_legacy_args( + request.args + ) + ) async def get_provider_model_list(self): """获取指定提供商的模型列表""" - provider_id = request.args.get("provider_id", None) - if not provider_id: - return Response().error("缺少参数 provider_id").__dict__ - - prov_mgr = self.core_lifecycle.provider_manager - provider = prov_mgr.inst_map.get(provider_id, None) - if not provider: - return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__ - if not isinstance(provider, Provider): - return ( - Response() - .error(f"提供商 {provider_id} 类型不支持获取模型列表") - .__dict__ + return await self._run( + lambda: self.provider_config_service.list_provider_models_from_legacy_args( + request.args ) - - try: - models = await provider.get_models() - models = models or [] - - metadata_map = {} - for model_id in models: - meta = LLM_METADATAS.get(model_id) - if meta: - metadata_map[model_id] = meta - - ret = { - "models": models, - "provider_id": provider_id, - "model_metadata": metadata_map, - } - return Response().ok(ret).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + ) async def get_embedding_dim(self): """获取嵌入模型的维度""" - post_data = await request.json - provider_config = post_data.get("provider_config", None) - if not provider_config: - return Response().error("缺少参数 provider_config").__dict__ - - try: - # 动态导入 EmbeddingProvider - from astrbot.core.provider.provider import EmbeddingProvider - from astrbot.core.provider.register import provider_cls_map - - # 获取 provider 类型 - provider_type = provider_config.get("type", None) - if not provider_type: - return Response().error("provider_config 缺少 type 字段").__dict__ - - # 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器 - if provider_type not in provider_cls_map: - try: - self.core_lifecycle.provider_manager.dynamic_import_provider( - provider_type, - ) - except ImportError: - logger.error(traceback.format_exc()) - return ( - Response() - .error( - "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志" - ) - .__dict__ - ) - - # 获取对应的 provider 类 - if provider_type not in provider_cls_map: - return ( - Response() - .error(f"未找到适用于 {provider_type} 的提供商适配器") - .__dict__ - ) - - provider_metadata = provider_cls_map[provider_type] - cls_type = provider_metadata.cls_type - - if not cls_type: - return Response().error(f"无法找到 {provider_type} 的类").__dict__ - - # 实例化 provider - inst = cls_type(provider_config, {}) - - # 检查是否是 EmbeddingProvider - if not isinstance(inst, EmbeddingProvider): - return Response().error("提供商不是 EmbeddingProvider 类型").__dict__ - - init_fn = getattr(inst, "initialize", None) - if inspect.iscoroutinefunction(init_fn): - await init_fn() - - # 通过实际请求验证当前 embedding_dimensions 是否可用 - vec = await inst.get_embedding("echo") - dim = len(vec) - - logger.info( - f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}", - ) - - return Response().ok({"embedding_dimensions": dim}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ + return await self._run_json( + self.provider_config_service.get_embedding_dimension_from_legacy_payload, + error_prefix="获取嵌入维度失败", + ) async def get_provider_source_models(self): """获取指定 provider_source 支持的模型列表 本质上会临时初始化一个 Provider 实例,调用 get_models() 获取模型列表,然后销毁实例 """ - provider_source_id = request.args.get("source_id") - if not provider_source_id: - return Response().error("缺少参数 source_id").__dict__ - - try: - from astrbot.core.provider.register import provider_cls_map - - # 从配置中查找对应的 provider_source - provider_sources = self.config.get("provider_sources", []) - provider_source = None - for ps in provider_sources: - if ps.get("id") == provider_source_id: - provider_source = ps - break - - if not provider_source: - return ( - Response() - .error(f"未找到 ID 为 {provider_source_id} 的 provider_source") - .__dict__ - ) - - # 获取 provider 类型 - provider_type = provider_source.get("type", None) - if not provider_type: - return Response().error("provider_source 缺少 type 字段").__dict__ - - try: - self.core_lifecycle.provider_manager.dynamic_import_provider( - provider_type - ) - except ImportError as e: - logger.error(traceback.format_exc()) - return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__ - - # 获取对应的 provider 类 - if provider_type not in provider_cls_map: - return ( - Response() - .error(f"未找到适用于 {provider_type} 的提供商适配器") - .__dict__ - ) - - provider_metadata = provider_cls_map[provider_type] - cls_type = provider_metadata.cls_type - - if not cls_type: - return Response().error(f"无法找到 {provider_type} 的类").__dict__ - - # 检查是否是 Provider 类型 - if not issubclass(cls_type, Provider): - return ( - Response() - .error(f"提供商 {provider_type} 不支持获取模型列表") - .__dict__ - ) - - # 临时实例化 provider - inst = cls_type(provider_source, {}) - - # 如果有 initialize 方法,调用它 - init_fn = getattr(inst, "initialize", None) - if inspect.iscoroutinefunction(init_fn): - await init_fn() - - # 获取模型列表 - models = await inst.get_models() - models = models or [] - - metadata_map = {} - for model_id in models: - meta = LLM_METADATAS.get(model_id) - if meta: - metadata_map[model_id] = meta - - # 销毁实例(如果有 terminate 方法) - terminate_fn = getattr(inst, "terminate", None) - if inspect.iscoroutinefunction(terminate_fn): - await terminate_fn() - - return ( - Response() - .ok({"models": models, "model_metadata": metadata_map}) - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"获取模型列表失败: {e!s}").__dict__ + return await self._run( + lambda: self.provider_config_service.list_provider_source_models_for_legacy( + request.args.get("source_id") + ), + error_prefix="获取模型列表失败", + ) async def get_platform_list(self): """获取所有平台的列表""" - platform_list = [] - for platform in self.config["platform"]: - platform_list.append(platform) - return Response().ok({"platforms": platform_list}).__dict__ + return self._ok(self.bot_config_service.list_platforms_for_legacy()) async def post_astrbot_configs(self): data = await request.json - if not isinstance(data, dict): - return Response().error("Invalid request payload").__dict__ - config = data.get("config", None) - conf_id = data.get("conf_id", None) try: - if not isinstance(config, dict): - return Response().error("Invalid config payload").__dict__ - - if conf_id not in self.acm.confs: - raise ValueError(f"Config file {conf_id} does not exist") - - # 不更新 provider_sources, provider, platform - # 这些配置有单独的接口进行更新 - if conf_id == "default": - no_update_keys = ["provider_sources", "provider", "platform"] - for key in no_update_keys: - config[key] = self.acm.default_conf[key] - - current_config = self.acm.confs[conf_id] - protected_2fa_changed = _protected_2fa_config_changed( - current_config, config - ) - verified_2fa = None - if await self._requires_config_2fa(current_config, protected_2fa_changed): - verified_2fa = await self._verify_config_2fa(current_config) - if not verified_2fa: - return await self._config_2fa_required_response() - - if not _get_nested_value(config, ("dashboard", "totp", "enable")): - _set_nested_value(config, ("dashboard", "totp", "secret"), "") - _set_nested_value( - config, ("dashboard", "totp", "recovery_code_hash"), "" + message = ( + await self.config_profile_service.update_profile_from_legacy_payload( + data, + two_factor_code=request.headers.get(TWO_FACTOR_CODE_HEADER), ) - - set_pending_totp_secret(None) - await self._save_astrbot_configs(config, conf_id) - if protected_2fa_changed: - await revoke_user_trusted_devices(self.db) - await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - - # Non-blocking Bay connectivity check - warning = await _validate_neo_connectivity(config) - if warning: - return Response().ok(None, f"保存成功。{warning}").__dict__ - return Response().ok(None, "保存成功~").__dict__ + ) + return Response().ok(None, message or "保存成功~").__dict__ + except ApiError as e: + if e.status_code == 401 and e.data == {"totp_required": True}: + return await self._config_2fa_required_response() + return Response().error(e.message).__dict__ except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ - async def _requires_config_2fa( - self, current_config: dict, protected_2fa_changed: bool - ) -> bool: - if not is_totp_enabled(current_config): - return False - if not protected_2fa_changed: - return False - return True - - async def _verify_config_2fa( - self, current_config: dict - ) -> TwoFactorCodeType | None: - code = request.headers.get(TWO_FACTOR_CODE_HEADER, "").strip() - if not code: - return None - - return await verify_configured_2fa_code( - current_config, code, include_pending=True, allow_recovery=False - ) - async def _config_2fa_required_response(self): response = await make_response( jsonify( @@ -1136,522 +290,86 @@ async def _config_2fa_required_response(self): return response async def post_plugin_configs(self): - post_configs = await request.json - plugin_name = request.args.get("plugin_name", "unknown") - try: - await self._save_plugin_configs(post_configs, plugin_name) - await self.core_lifecycle.plugin_manager.reload(plugin_name) - return ( - Response() - .ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。") - .__dict__ - ) - except Exception as e: - return Response().error(str(e)).__dict__ - - def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: - for plugin_md in star_registry: - if plugin_md.name == plugin_name: - return plugin_md - return None - - def _resolve_config_file_scope( - self, - ) -> tuple[str, str, str, StarMetadata, AstrBotConfig]: - """将请求参数解析为一个明确的配置作用域。 - - 当前支持的 scope: - - scope=plugin:name=,key= - """ - - scope = request.args.get("scope") or "plugin" - name = request.args.get("name") - key_path = request.args.get("key") - - if scope != "plugin": - raise ValueError(f"Unsupported scope: {scope}") - if not name or not key_path: - raise ValueError("Missing name or key parameter") - - md = self._get_plugin_metadata_by_name(name) - if not md or not md.config: - raise ValueError(f"Plugin {name} not found or has no config") - - return scope, name, key_path, md, md.config + return await self._run_json( + lambda payload: ( + self.config_file_service.save_plugin_configs_from_legacy_payload( + payload, + plugin_name=request.args.get("plugin_name", "unknown"), + ) + ), + result_as_message=True, + trace=False, + ) async def upload_config_file(self): """上传文件到插件数据目录(用于某个 file 类型配置项)。""" - - try: - scope, name, key_path, md, config = self._resolve_config_file_scope() - except ValueError as e: - return Response().error(str(e)).__dict__ - - meta = get_schema_item(getattr(config, "schema", None), key_path) - if not meta or meta.get("type") != "file": - return Response().error("Config item not found or not file type").__dict__ - - file_types = meta.get("file_types") - allowed_exts: list[str] = [] - if isinstance(file_types, list): - allowed_exts = [ - str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip() - ] - files = await request.files - if not files: - return Response().error("No files uploaded").__dict__ - - storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) - plugin_root_path = (storage_root_path / name).resolve(strict=False) - try: - plugin_root_path.relative_to(storage_root_path) - except ValueError: - return Response().error("Invalid name parameter").__dict__ - plugin_root_path.mkdir(parents=True, exist_ok=True) - - uploaded: list[str] = [] - folder = config_key_to_folder(key_path) - errors: list[str] = [] - for file in files.values(): - filename = sanitize_filename(file.filename or "") - if not filename: - errors.append("Invalid filename") - continue - - file_size = getattr(file, "content_length", None) - if isinstance(file_size, int) and file_size > MAX_FILE_BYTES: - errors.append(f"File too large: {filename}") - continue - - ext = os.path.splitext(filename)[1].lstrip(".").lower() - if allowed_exts and ext not in allowed_exts: - errors.append(f"Unsupported file type: {filename}") - continue - - rel_path = f"files/{folder}/{filename}" - save_path = (plugin_root_path / rel_path).resolve(strict=False) - try: - save_path.relative_to(plugin_root_path) - except ValueError: - errors.append(f"Invalid path: {filename}") - continue - - save_path.parent.mkdir(parents=True, exist_ok=True) - await file.save(str(save_path)) - if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES: - save_path.unlink() - errors.append(f"File too large: {filename}") - continue - uploaded.append(rel_path) - - if not uploaded: - return ( - Response() - .error( - "Upload failed: " + ", ".join(errors) - if errors - else "Upload failed", - ) - .__dict__ + return await self._run( + lambda: self.config_file_service.upload_config_file_from_legacy_request( + request.args, + files, ) - - return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ + ) async def delete_config_file(self): """删除插件数据目录中的文件。""" - - scope = request.args.get("scope") or "plugin" - name = request.args.get("name") - if not name: - return Response().error("Missing name parameter").__dict__ - if scope != "plugin": - return Response().error(f"Unsupported scope: {scope}").__dict__ - - data = await request.get_json() - rel_path = data.get("path") if isinstance(data, dict) else None - rel_path = normalize_rel_path(rel_path) - if not rel_path or not rel_path.startswith("files/"): - return Response().error("Invalid path parameter").__dict__ - - md = self._get_plugin_metadata_by_name(name) - if not md: - return Response().error(f"Plugin {name} not found").__dict__ - - storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) - plugin_root_path = (storage_root_path / name).resolve(strict=False) - try: - plugin_root_path.relative_to(storage_root_path) - except ValueError: - return Response().error("Invalid name parameter").__dict__ - target_path = (plugin_root_path / rel_path).resolve(strict=False) - try: - target_path.relative_to(plugin_root_path) - except ValueError: - return Response().error("Invalid path parameter").__dict__ - if target_path.is_file(): - target_path.unlink() - - return Response().ok(None, "Deleted").__dict__ + payload = await request.get_json() + return await self._run( + lambda: self.config_file_service.delete_config_file_from_legacy_request( + request.args, + payload, + ), + result_as_message=True, + trace=False, + ) async def get_config_file_list(self): """获取配置项对应目录下的文件列表。""" - - try: - _, name, key_path, _, config = self._resolve_config_file_scope() - except ValueError as e: - return Response().error(str(e)).__dict__ - - meta = get_schema_item(getattr(config, "schema", None), key_path) - if not meta or meta.get("type") != "file": - return Response().error("Config item not found or not file type").__dict__ - - storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) - plugin_root_path = (storage_root_path / name).resolve(strict=False) - try: - plugin_root_path.relative_to(storage_root_path) - except ValueError: - return Response().error("Invalid name parameter").__dict__ - - folder = config_key_to_folder(key_path) - target_dir = (plugin_root_path / "files" / folder).resolve(strict=False) - try: - target_dir.relative_to(plugin_root_path) - except ValueError: - return Response().error("Invalid path parameter").__dict__ - - if not target_dir.exists() or not target_dir.is_dir(): - return Response().ok({"files": []}).__dict__ - - files: list[str] = [] - for path in target_dir.rglob("*"): - if not path.is_file(): - continue - try: - rel_path = path.relative_to(plugin_root_path).as_posix() - except ValueError: - continue - if rel_path.startswith("files/"): - files.append(rel_path) - - return Response().ok({"files": files}).__dict__ + return await self._run( + lambda: self.config_file_service.list_config_files_from_legacy_args( + request.args + ), + trace=False, + ) async def post_new_platform(self): - new_platform_config = await request.json - - # 如果是支持统一 webhook 模式的平台,生成 webhook_uuid - ensure_platform_webhook_config(new_platform_config) - - self.config["platform"].append(new_platform_config) - try: - save_config(self.config, self.config, is_core=True) - await self.core_lifecycle.platform_manager.load_platform( - new_platform_config, - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "新增平台配置成功~").__dict__ + return await self._run_json( + self.bot_config_service.create_bot_from_legacy_payload, + result_as_message=True, + trace=False, + ) async def post_new_provider(self): - new_provider_config = await request.json - - try: - await self.core_lifecycle.provider_manager.create_provider( - new_provider_config - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "新增服务提供商配置成功").__dict__ + return await self._run_json( + self.provider_config_service.create_provider_from_legacy_payload, + result_as_message=True, + trace=False, + ) async def post_update_platform(self): - update_platform_config = await request.json - origin_platform_id = update_platform_config.get("id", None) - new_config = update_platform_config.get("config", None) - if not origin_platform_id or not new_config: - return Response().error("参数错误").__dict__ - - if origin_platform_id != new_config.get("id", None): - return Response().error("机器人名称不允许修改").__dict__ - - # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid - ensure_platform_webhook_config(new_config) - - for i, platform in enumerate(self.config["platform"]): - if platform["id"] == origin_platform_id: - self.config["platform"][i] = new_config - break - else: - return Response().error("未找到对应平台").__dict__ - - try: - save_config(self.config, self.config, is_core=True) - await self.core_lifecycle.platform_manager.reload(new_config) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "更新平台配置成功~").__dict__ + return await self._run_json( + self.bot_config_service.update_bot_from_legacy_payload, + result_as_message=True, + trace=False, + ) async def post_update_provider(self): - update_provider_config = await request.json - origin_provider_id = update_provider_config.get("id", None) - new_config = update_provider_config.get("config", None) - if not origin_provider_id or not new_config: - return Response().error("参数错误").__dict__ - - try: - await self.core_lifecycle.provider_manager.update_provider( - origin_provider_id, new_config - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "更新成功,已经实时生效~").__dict__ + return await self._run_json( + self.provider_config_service.update_provider_from_legacy_payload, + result_as_message=True, + trace=False, + ) async def post_delete_platform(self): - platform_id = await request.json - platform_id = platform_id.get("id") - for i, platform in enumerate(self.config["platform"]): - if platform["id"] == platform_id: - del self.config["platform"][i] - break - else: - return Response().error("未找到对应平台").__dict__ - try: - save_config(self.config, self.config, is_core=True) - await self.core_lifecycle.platform_manager.terminate_platform(platform_id) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "删除平台配置成功~").__dict__ - - async def post_delete_provider(self): - provider_id = await request.json - provider_id = provider_id.get("id", "") - if not provider_id: - return Response().error("缺少参数 id").__dict__ - - try: - await self.core_lifecycle.provider_manager.delete_provider( - provider_id=provider_id - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "删除成功,已经实时生效。").__dict__ - - async def get_llm_tools(self): - """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" - tool_mgr = self.core_lifecycle.provider_manager.llm_tools - tools = tool_mgr.get_func_desc_openai_style() - return Response().ok(tools).__dict__ - - async def _register_platform_logo(self, platform, platform_default_tmpl) -> None: - """注册平台logo文件并生成访问令牌""" - if not platform.logo_path: - return - - try: - # 检查缓存 - cache_key = f"{platform.name}:{platform.logo_path}" - if cache_key in self._logo_token_cache: - cached_token = self._logo_token_cache[cache_key] - # 确保platform_default_tmpl[platform.name]存在且为字典 - if platform.name not in platform_default_tmpl or not isinstance( - platform_default_tmpl[platform.name], dict - ): - platform_default_tmpl[platform.name] = {} - platform_default_tmpl[platform.name]["logo_token"] = cached_token - logger.debug(f"Using cached logo token for platform {platform.name}") - return - - # 获取平台适配器类 - platform_cls = platform_cls_map.get(platform.name) - if not platform_cls: - logger.warning(f"Platform class not found for {platform.name}") - return - - # 获取插件目录路径 - module_file = inspect.getfile(platform_cls) - plugin_dir = os.path.dirname(module_file) - - # 解析logo文件路径 - logo_file_path = os.path.join(plugin_dir, platform.logo_path) - - # 检查文件是否存在并注册令牌 - if os.path.exists(logo_file_path): - logo_token = await file_token_service.register_file( - logo_file_path, - timeout=3600, - ) - - # 确保platform_default_tmpl[platform.name]存在且为字典 - if platform.name not in platform_default_tmpl or not isinstance( - platform_default_tmpl[platform.name], dict - ): - platform_default_tmpl[platform.name] = {} - - platform_default_tmpl[platform.name]["logo_token"] = logo_token - - # 缓存token - self._logo_token_cache[cache_key] = logo_token - - logger.debug(f"Logo token registered for platform {platform.name}") - else: - logger.warning( - f"Platform {platform.name} logo file not found: {logo_file_path}", - ) - - except (ImportError, AttributeError) as e: - logger.warning( - f"Failed to import required modules for platform {platform.name}: {e}", - ) - except OSError as e: - logger.warning(f"File system error for platform {platform.name} logo: {e}") - except Exception as e: - logger.warning( - f"Unexpected error registering logo for platform {platform.name}: {e}", - ) - - def _inject_platform_metadata_with_i18n( - self, platform, metadata, platform_i18n_translations: dict - ): - """将配置元数据注入到 metadata 中并处理国际化键转换。""" - metadata["platform_group"]["metadata"]["platform"].setdefault("items", {}) - platform_items_to_inject = copy.deepcopy(platform.config_metadata) - - if platform.i18n_resources: - i18n_prefix = f"platform_group.platform.{platform.name}" - - for lang, lang_data in platform.i18n_resources.items(): - platform_i18n_translations.setdefault(lang, {}).setdefault( - "platform_group", {} - ).setdefault("platform", {})[platform.name] = lang_data - - for field_key, field_value in platform_items_to_inject.items(): - for key in ("description", "hint", "labels"): - if key in field_value: - field_value[key] = f"{i18n_prefix}.{field_key}.{key}" - - metadata["platform_group"]["metadata"]["platform"]["items"].update( - platform_items_to_inject + return await self._run_json( + self.bot_config_service.delete_bot_from_legacy_payload, + result_as_message=True, + trace=False, ) - async def _get_astrbot_config(self): - config = self.config - metadata = copy.deepcopy(CONFIG_METADATA_2) - platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys( - { - "platform_group": { - "metadata": { - "platform": metadata["platform_group"]["metadata"]["platform"] - } - } - } + async def post_delete_provider(self): + return await self._run_json( + self.provider_config_service.delete_provider_from_legacy_payload, + result_as_message=True, + trace=False, ) - metadata["platform_group"]["metadata"]["platform"] = platform_i18n[ - "platform_group" - ]["metadata"]["platform"] - - # 平台适配器的默认配置模板注入 - platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][ - "config_template" - ] - - # 收集平台的 i18n 翻译数据 - platform_i18n_translations = {} - - # 收集需要注册logo的平台 - logo_registration_tasks = [] - for platform in platform_registry: - if platform.default_config_tmpl: - platform_default_tmpl[platform.name] = copy.deepcopy( - platform.default_config_tmpl - ) - - # 注入配置元数据(在 convert_to_i18n_keys 之后,使用国际化键) - if platform.config_metadata: - self._inject_platform_metadata_with_i18n( - platform, metadata, platform_i18n_translations - ) - - # 收集logo注册任务 - if platform.logo_path: - logo_registration_tasks.append( - self._register_platform_logo(platform, platform_default_tmpl), - ) - - # 并行执行logo注册 - if logo_registration_tasks: - await asyncio.gather(*logo_registration_tasks, return_exceptions=True) - - # 服务提供商的默认配置模板注入 - provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][ - "config_template" - ] - for provider in provider_registry: - if provider.default_config_tmpl: - provider_default_tmpl[provider.type] = provider.default_config_tmpl - - return { - "metadata": metadata, - "config": config, - "platform_i18n_translations": platform_i18n_translations, - } - - async def _get_plugin_config(self, plugin_name: str): - ret: dict = {"metadata": None, "config": None, "i18n": {}} - - for plugin_md in star_registry: - if plugin_md.name == plugin_name: - if not plugin_md.config: - break - ret["config"] = ( - plugin_md.config - ) # 这是自定义的 Dict 类(AstrBotConfig) - ret["metadata"] = { - plugin_name: { - "description": f"{plugin_name} 配置", - "type": "object", - "items": plugin_md.config.schema, # 初始化时通过 __setattr__ 存入了 schema - }, - } - ret["i18n"] = plugin_md.i18n - break - - return ret - - async def _save_astrbot_configs( - self, post_configs: dict, conf_id: str | None = None - ) -> None: - try: - if conf_id not in self.acm.confs: - raise ValueError(f"配置文件 {conf_id} 不存在") - astrbot_config = self.acm.confs[conf_id] - - # 保留服务端的 t2i_active_template 值 - if "t2i_active_template" in astrbot_config: - post_configs["t2i_active_template"] = astrbot_config[ - "t2i_active_template" - ] - - save_config(post_configs, astrbot_config, is_core=True) - except Exception as e: - raise e - - async def _save_plugin_configs(self, post_configs: dict, plugin_name: str) -> None: - md = None - for plugin_md in star_registry: - if plugin_md.name == plugin_name: - md = plugin_md - - if not md: - raise ValueError(f"插件 {plugin_name} 不存在") - if not md.config: - raise ValueError(f"插件 {plugin_name} 没有注册配置") - assert md.config is not None - - try: - errors, post_configs = validate_config( - post_configs, getattr(md.config, "schema", {}), is_core=False - ) - if errors: - raise ValueError(f"格式校验未通过: {errors}") - md.config.save_config(post_configs) - except Exception as e: - raise e diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index e15837fd8d..a7d4bbff3b 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -1,15 +1,11 @@ -import json -import traceback -from dataclasses import asdict -from datetime import datetime -from io import BytesIO - -from quart import request, send_file - from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase -from astrbot.core.umo_alias import build_umo_alias_map, parse_umo, serialize_umo_alias +from astrbot.dashboard.fastapi_compat import request, send_file +from astrbot.dashboard.services.conversation_service import ( + ConversationService, + ConversationServiceError, +) from .route import Response, Route, RouteContext @@ -24,379 +20,102 @@ def __init__( super().__init__(context) self.routes = { "/conversation/list": ("GET", self.list_conversations), - "/conversation/detail": ( - "POST", - self.get_conv_detail, - ), + "/conversation/detail": ("POST", self.get_conv_detail), "/conversation/update": ("POST", self.upd_conv), "/conversation/delete": ("POST", self.del_conv), - "/conversation/update_history": ( - "POST", - self.update_history, - ), + "/conversation/update_history": ("POST", self.update_history), "/conversation/export": ("POST", self.export_conversations), } - self.db_helper = db_helper - self.conv_mgr = core_lifecycle.conversation_manager - self.core_lifecycle = core_lifecycle + self.service = ConversationService(db_helper, core_lifecycle) self.register_routes() - def _build_umo_info(self, umo: str | None, alias_map: dict) -> dict: - umo_str = umo or "" - return { - "umo": umo_str, - **parse_umo(umo_str), - **serialize_umo_alias(alias_map.get(umo_str), umo_str), - } - - def _serialize_conversation(self, conversation, alias_map: dict) -> dict: - return { - **asdict(conversation), - "umo_info": self._build_umo_info(conversation.user_id, alias_map), - } + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - async def list_conversations(self): - """获取对话列表,支持分页、排序和筛选""" - try: - # 获取分页参数 - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 20, type=int) + @staticmethod + def _ok(data=None): + return Response().ok(data).__dict__ - # 获取筛选参数 - platforms = request.args.get("platforms", "") - message_types = request.args.get("message_types", "") - search_query = request.args.get("search", "") - exclude_ids = request.args.get("exclude_ids", "") - exclude_platforms = request.args.get("exclude_platforms", "") + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - # 转换为列表 - platform_list = platforms.split(",") if platforms else [] - message_type_list = message_types.split(",") if message_types else [] - exclude_id_list = exclude_ids.split(",") if exclude_ids else [] - exclude_platform_list = ( - exclude_platforms.split(",") if exclude_platforms else [] - ) - - page = max(page, 1) - if page_size < 1: - page_size = 20 - page_size = min(page_size, 100) - - try: - ( - conversations, - total_count, - ) = await self.conv_mgr.get_filtered_conversations( - page=page, - page_size=page_size, - platforms=platform_list, - message_types=message_type_list, - search_query=search_query, - exclude_ids=exclude_id_list, - exclude_platforms=exclude_platform_list, - ) - except Exception as e: - logger.error(f"数据库查询出错: {e!s}\n{traceback.format_exc()}") - return Response().error(f"数据库查询出错: {e!s}").__dict__ - - # 计算总页数 - total_pages = ( - (total_count + page_size - 1) // page_size if total_count > 0 else 1 - ) - umos = sorted({conv.user_id for conv in conversations if conv.user_id}) - alias_map = build_umo_alias_map(await self.db_helper.get_umo_aliases(umos)) - - result = { - "conversations": [ - self._serialize_conversation(conversation, alias_map) - for conversation in conversations - ], - "pagination": { - "page": page, - "page_size": page_size, - "total": total_count, - "total_pages": total_pages, - }, - } - return Response().ok(result).__dict__ + async def _run(self, operation, *, label: str): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except ConversationServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("%s: %s", label, exc, exc_info=True) + return self._error(f"{label}: {exc!s}") + + async def _run_json(self, operation, *, label: str): + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run(invoke, label=label) - except Exception as e: - error_msg = f"获取对话列表失败: {e!s}\n{traceback.format_exc()}" - logger.error(error_msg) - return Response().error(f"获取对话列表失败: {e!s}").__dict__ + async def list_conversations(self): + """获取对话列表,支持分页、排序和筛选""" + return await self._run( + self.service.list_conversations_from_legacy_query( + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 20), + platforms=request.args.get("platforms", ""), + message_types=request.args.get("message_types", ""), + search_query=request.args.get("search", ""), + exclude_ids=request.args.get("exclude_ids", ""), + exclude_platforms=request.args.get("exclude_platforms", ""), + ), + label="获取对话列表失败", + ) async def get_conv_detail(self): """获取指定对话详情(通过POST请求)""" - try: - data = await request.get_json() - user_id = data.get("user_id") - cid = data.get("cid") - - if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ - - conversation = await self.conv_mgr.get_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - ) - if not conversation: - return Response().error("对话不存在").__dict__ - - alias_map = build_umo_alias_map( - await self.db_helper.get_umo_aliases([user_id]) - ) - return ( - Response() - .ok( - { - "user_id": user_id, - "cid": cid, - "title": conversation.title, - "persona_id": conversation.persona_id, - "history": conversation.history, - "created_at": conversation.created_at, - "updated_at": conversation.updated_at, - "umo_info": self._build_umo_info(user_id, alias_map), - }, - ) - .__dict__ - ) - - except Exception as e: - logger.error(f"获取对话详情失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取对话详情失败: {e!s}").__dict__ + return await self._run_json( + self.service.get_conversation_detail, + label="获取对话详情失败", + ) async def upd_conv(self): """更新对话信息(标题和角色ID)""" - try: - data = await request.get_json() - user_id = data.get("user_id") - cid = data.get("cid") - title = data.get("title") - - if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ - conversation = await self.conv_mgr.get_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - ) - if not conversation: - return Response().error("对话不存在").__dict__ - - persona_id = data.get("persona_id", conversation.persona_id) - - if title is not None or persona_id is not None: - await self.conv_mgr.update_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - title=title, - persona_id=persona_id, - ) - return Response().ok({"message": "对话信息更新成功"}).__dict__ - - except Exception as e: - logger.error(f"更新对话信息失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新对话信息失败: {e!s}").__dict__ + return await self._run_json( + self.service.update_conversation, + label="更新对话信息失败", + ) async def del_conv(self): """删除对话""" - try: - data = await request.get_json() - - # 检查是否是批量删除 - if "conversations" in data: - # 批量删除 - conversations = data.get("conversations", []) - if not conversations: - return ( - Response().error("批量删除时conversations参数不能为空").__dict__ - ) - - deleted_count = 0 - failed_items = [] - - for conv in conversations: - user_id = conv.get("user_id") - cid = conv.get("cid") - - if not user_id or not cid: - failed_items.append( - f"user_id:{user_id}, cid:{cid} - 缺少必要参数", - ) - continue - - try: - await self.core_lifecycle.conversation_manager.delete_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - ) - deleted_count += 1 - except Exception as e: - failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}") - - message = f"成功删除 {deleted_count} 个对话" - if failed_items: - message += f",失败 {len(failed_items)} 个" - - return ( - Response() - .ok( - { - "message": message, - "deleted_count": deleted_count, - "failed_count": len(failed_items), - "failed_items": failed_items, - }, - ) - .__dict__ - ) - # 单个删除 - user_id = data.get("user_id") - cid = data.get("cid") - - if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ - - await self.core_lifecycle.conversation_manager.delete_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - ) - return Response().ok({"message": "对话删除成功"}).__dict__ - - except Exception as e: - logger.error(f"删除对话失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"删除对话失败: {e!s}").__dict__ + return await self._run_json( + self.service.delete_conversation, + label="删除对话失败", + ) async def update_history(self): """更新对话历史内容""" - try: - data = await request.get_json() - user_id = data.get("user_id") - cid = data.get("cid") - history = data.get("history") - - if not user_id or not cid: - return Response().error("缺少必要参数: user_id 和 cid").__dict__ - - if history is None: - return Response().error("缺少必要参数: history").__dict__ - - # 历史记录必须是合法的 JSON 字符串 - try: - if isinstance(history, list): - history = json.dumps(history) - else: - # 验证是否为有效的 JSON 字符串 - json.loads(history) - except json.JSONDecodeError: - return ( - Response().error("history 必须是有效的 JSON 字符串或数组").__dict__ - ) - - conversation = await self.conv_mgr.get_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - ) - if not conversation: - return Response().error("对话不存在").__dict__ - - history = json.loads(history) if isinstance(history, str) else history - - await self.conv_mgr.update_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - history=history, - ) - - return Response().ok({"message": "对话历史更新成功"}).__dict__ - - except Exception as e: - logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新对话历史失败: {e!s}").__dict__ + return await self._run_json( + self.service.update_history, + label="更新对话历史失败", + ) async def export_conversations(self): """批量导出对话为 JSONL 格式""" try: - data = await request.get_json() - conversations_to_export = data.get("conversations", []) - - if not conversations_to_export: - return Response().error("导出列表不能为空").__dict__ - - # 收集所有对话的内容 - jsonl_lines = [] - exported_count = 0 - failed_items = [] - - for conv_info in conversations_to_export: - user_id = conv_info.get("user_id") - cid = conv_info.get("cid") - - if not user_id or not cid: - failed_items.append( - f"user_id:{user_id}, cid:{cid} - 缺少必要参数", - ) - continue - - try: - conversation = await self.conv_mgr.get_conversation( - unified_msg_origin=user_id, - conversation_id=cid, - ) - - if not conversation: - failed_items.append( - f"user_id:{user_id}, cid:{cid} - 对话不存在" - ) - continue - - # 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1) - content = json.loads(conversation.history) - - # 创建导出记录 - export_record = { - "cid": cid, - "user_id": user_id, - "platform_id": conversation.platform_id, - "title": conversation.title, - "persona_id": conversation.persona_id, - "created_at": conversation.created_at, - "updated_at": conversation.updated_at, - "content": content, - } - - # 将记录转换为 JSON 字符串并添加到 JSONL - jsonl_lines.append(json.dumps(export_record, ensure_ascii=False)) - exported_count += 1 - - except Exception as e: - failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}") - logger.error( - f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}" - ) - - if exported_count == 0: - return Response().error("没有成功导出任何对话").__dict__ - - # 创建 JSONL 内容 - jsonl_content = "\n".join(jsonl_lines) - - # 创建一个内存文件对象 - file_obj = BytesIO(jsonl_content.encode("utf-8")) - file_obj.seek(0) - - # 生成文件名 - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"astrbot_conversations_export_{timestamp}.jsonl" - - # 返回文件流 + export = await self.service.export_conversations(await self._json_body()) return await send_file( - file_obj, - mimetype="application/jsonl", + export.file_obj, + mimetype=export.mimetype, as_attachment=True, - attachment_filename=filename, + attachment_filename=export.filename, ) - - except Exception as e: - logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"批量导出对话失败: {e!s}").__dict__ + except ConversationServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("批量导出对话失败: %s", exc, exc_info=True) + return self._error(f"批量导出对话失败: {exc!s}") diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py index 85dbc25095..4ce3ab11f0 100644 --- a/astrbot/dashboard/routes/cron.py +++ b/astrbot/dashboard/routes/cron.py @@ -1,11 +1,6 @@ -import asyncio -import traceback -from datetime import datetime, timezone - -from quart import jsonify, request - -from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.dashboard.fastapi_compat import jsonify, request +from astrbot.dashboard.services.cron_service import CronService, CronServiceError from .route import Response, Route, RouteContext @@ -15,8 +10,7 @@ def __init__( self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle - self._background_tasks: set[asyncio.Task] = set() + self.service = CronService(core_lifecycle) self.routes = [ ("/cron/jobs", ("GET", self.list_jobs)), ("/cron/jobs", ("POST", self.create_job)), @@ -26,276 +20,50 @@ def __init__( ] self.register_routes() - def _serialize_job(self, job) -> dict: - data = job.model_dump() if hasattr(job, "model_dump") else job.__dict__ - for k in ["created_at", "updated_at", "last_run_at", "next_run_time"]: - v = data.get(k) - if isinstance(v, datetime): - # Attach UTC - if v.tzinfo is None: - v = v.replace(tzinfo=timezone.utc) - data[k] = v.isoformat() - # expose note explicitly for UI (prefer payload.note then description) - payload = data.get("payload") or {} - data["note"] = payload.get("note") or data.get("description") or "" - data["run_at"] = payload.get("run_at") - data["run_once"] = data.get("run_once", False) - # status is internal; hide to avoid implying one-time completion for recurring jobs - data.pop("status", None) - return data - - async def list_jobs(self): - try: - cron_mgr = self.core_lifecycle.cron_manager - if cron_mgr is None: - return jsonify( - Response().error("Cron manager not initialized").__dict__ - ) - job_type = request.args.get("type") - jobs = await cron_mgr.list_jobs(job_type) - data = [self._serialize_job(j) for j in jobs] - return jsonify(Response().ok(data=data).__dict__) - except Exception as e: # noqa: BLE001 - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to list jobs: {e!s}").__dict__) - - async def create_job(self): - try: - cron_mgr = self.core_lifecycle.cron_manager - if cron_mgr is None: - return jsonify( - Response().error("Cron manager not initialized").__dict__ - ) + @staticmethod + def _ok(data=None, message: str | None = None): + return jsonify(Response().ok(data=data, message=message).__dict__) - payload = await request.json - if not isinstance(payload, dict): - return jsonify(Response().error("Invalid payload").__dict__) + @staticmethod + def _error(message: str): + return jsonify(Response().error(message).__dict__) - name = payload.get("name") or "active_agent_task" - cron_expression = payload.get("cron_expression") - note = payload.get("note") or payload.get("description") or name - session = str(payload.get("session") or "").strip() - persona_id = payload.get("persona_id") - provider_id = payload.get("provider_id") - timezone = payload.get("timezone") - enabled = bool(payload.get("enabled", True)) - run_once = bool(payload.get("run_once", False)) - run_at = payload.get("run_at") + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - if run_once and not run_at: - return jsonify( - Response().error("run_at is required when run_once=true").__dict__ - ) - if (not run_once) and not cron_expression: - return jsonify( - Response() - .error("cron_expression is required when run_once=false") - .__dict__ - ) - if run_once and cron_expression: - cron_expression = None # ignore cron when run_once specified - run_at_dt = None - if run_at: - try: - run_at_dt = datetime.fromisoformat(str(run_at)) - except Exception: - return jsonify( - Response().error("run_at must be ISO datetime").__dict__ - ) - - job_payload = { - "session": session, - "note": note, - "persona_id": persona_id, - "provider_id": provider_id, - "run_at": run_at, - "origin": "api", - } - - job = await cron_mgr.add_active_job( - name=name, - cron_expression=cron_expression, - payload=job_payload, - description=note, - timezone=timezone, - enabled=enabled, - run_once=run_once, - run_at=run_at_dt, - ) - - return jsonify(Response().ok(data=self._serialize_job(job)).__dict__) - except Exception as e: # noqa: BLE001 - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to create job: {e!s}").__dict__) - - async def update_job(self, job_id: str): + async def _run(self, operation, *, message: str | None = None): try: - cron_mgr = self.core_lifecycle.cron_manager - if cron_mgr is None: - return jsonify( - Response().error("Cron manager not initialized").__dict__ - ) - - payload = await request.json - if not isinstance(payload, dict): - return jsonify(Response().error("Invalid payload").__dict__) - - job = await cron_mgr.db.get_cron_job(job_id) - if not job: - return jsonify(Response().error("Job not found").__dict__) + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result, message) + except CronServiceError as exc: + return self._error(str(exc)) - updates = {} - if "name" in payload: - name = str(payload.get("name") or "").strip() - if not name: - return jsonify(Response().error("name cannot be empty").__dict__) - updates["name"] = name + async def _run_json(self, operation, *, message: str | None = None): + async def invoke(): + data = await self._json_body() + return operation(data) - if "enabled" in payload: - updates["enabled"] = bool(payload.get("enabled")) + return await self._run(invoke, message=message) - if "timezone" in payload: - timezone = payload.get("timezone") - updates["timezone"] = str(timezone).strip() or None - - next_run_once = ( - bool(payload.get("run_once")) - if "run_once" in payload - else bool(job.run_once) - ) - - if job.job_type == "active_agent": - merged_payload = ( - dict(job.payload) if isinstance(job.payload, dict) else {} - ) - if "payload" in payload and isinstance(payload.get("payload"), dict): - merged_payload.update(payload["payload"]) - - if "session" in payload: - session = str(payload.get("session") or "").strip() - if session: - merged_payload["session"] = session - else: - merged_payload.pop("session", None) - - note_updated = False - if "note" in payload: - note = str(payload.get("note") or "").strip() - if not note: - return jsonify( - Response().error("note cannot be empty").__dict__ - ) - merged_payload["note"] = note - updates["description"] = note - note_updated = True - elif "description" in payload: - description = str(payload.get("description") or "").strip() - if not description: - return jsonify( - Response().error("description cannot be empty").__dict__ - ) - updates["description"] = description - merged_payload["note"] = description - note_updated = True - - if not note_updated and updates.get("description") is None: - existing_note = str( - merged_payload.get("note") or job.description or "" - ).strip() - if existing_note: - merged_payload["note"] = existing_note - - next_cron_expression = ( - payload.get("cron_expression") - if "cron_expression" in payload - else job.cron_expression - ) - if next_cron_expression is not None: - next_cron_expression = str(next_cron_expression).strip() or None - - run_at_raw = ( - payload.get("run_at") - if "run_at" in payload - else merged_payload.get("run_at") - ) - run_at_iso = None - if run_at_raw: - try: - run_at_iso = datetime.fromisoformat(str(run_at_raw)).isoformat() - except Exception: - return jsonify( - Response().error("run_at must be ISO datetime").__dict__ - ) - - if next_run_once: - if not run_at_iso: - return jsonify( - Response() - .error("run_at is required when run_once=true") - .__dict__ - ) - next_cron_expression = None - merged_payload["run_at"] = run_at_iso - else: - if not next_cron_expression: - return jsonify( - Response() - .error("cron_expression is required when run_once=false") - .__dict__ - ) - merged_payload.pop("run_at", None) - - updates["run_once"] = next_run_once - updates["cron_expression"] = next_cron_expression - updates["payload"] = merged_payload - else: - if "cron_expression" in payload: - cron_expression = str(payload.get("cron_expression") or "").strip() - if not cron_expression: - return jsonify( - Response().error("cron_expression cannot be empty").__dict__ - ) - updates["cron_expression"] = cron_expression + async def list_jobs(self): + return await self._run( + self.service.list_jobs_from_legacy_query(request.args.get("type")) + ) - if "description" in payload: - description = str(payload.get("description") or "").strip() - updates["description"] = description or None + async def create_job(self): + return await self._run_json(self.service.create_job) - job = await cron_mgr.update_job(job_id, **updates) - if not job: - return jsonify(Response().error("Job not found").__dict__) - return jsonify(Response().ok(data=self._serialize_job(job)).__dict__) - except Exception as e: # noqa: BLE001 - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to update job: {e!s}").__dict__) + async def update_job(self, job_id: str): + return await self._run_json( + lambda payload: self.service.update_job(job_id, payload) + ) async def delete_job(self, job_id: str): - try: - cron_mgr = self.core_lifecycle.cron_manager - if cron_mgr is None: - return jsonify( - Response().error("Cron manager not initialized").__dict__ - ) - await cron_mgr.delete_job(job_id) - return jsonify(Response().ok(message="deleted").__dict__) - except Exception as e: # noqa: BLE001 - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to delete job: {e!s}").__dict__) + return await self._run(self.service.delete_job(job_id), message="deleted") async def run_job_now(self, job_id: str): - try: - cron_mgr = self.core_lifecycle.cron_manager - if cron_mgr is None: - return jsonify( - Response().error("Cron manager not initialized").__dict__ - ) - job = await cron_mgr.db.get_cron_job(job_id) - if not job: - return jsonify(Response().error("Job not found").__dict__) - task = asyncio.create_task(cron_mgr.run_job_now(job_id)) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - return jsonify(Response().ok(message="started").__dict__) - except Exception as e: # noqa: BLE001 - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"Failed to run job: {e!s}").__dict__) + return await self._run(self.service.run_job_now(job_id), message="started") diff --git a/astrbot/dashboard/routes/file.py b/astrbot/dashboard/routes/file.py index 1880150bf0..7365a8f3fc 100644 --- a/astrbot/dashboard/routes/file.py +++ b/astrbot/dashboard/routes/file.py @@ -1,6 +1,5 @@ -from quart import abort, send_file - -from astrbot.core import file_token_service +from astrbot.dashboard.fastapi_compat import abort, send_file +from astrbot.dashboard.services.file_service import FileService, FileServiceError from .route import Route, RouteContext @@ -11,6 +10,7 @@ def __init__( context: RouteContext, ) -> None: super().__init__(context) + self.service = FileService() self.routes = { "/file/": ("GET", self.serve_file), } @@ -18,7 +18,7 @@ def __init__( async def serve_file(self, file_token: str): try: - file_path = await file_token_service.handle_file(file_token) + file_path = await self.service.resolve_token_file(file_token) return await send_file(file_path) - except (FileNotFoundError, KeyError): + except FileServiceError: return abort(404) diff --git a/astrbot/dashboard/routes/knowledge_base.py b/astrbot/dashboard/routes/knowledge_base.py index 1b6f7a435d..eadf615719 100644 --- a/astrbot/dashboard/routes/knowledge_base.py +++ b/astrbot/dashboard/routes/knowledge_base.py @@ -1,28 +1,18 @@ """知识库管理 API 路由""" -import asyncio -import os -import traceback -import uuid -from typing import Any - -import aiofiles -from quart import request - from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.knowledge_base_service import ( + KnowledgeBaseService, + KnowledgeBaseServiceError, +) -from ..utils import generate_tsne_visualization from .route import Response, Route, RouteContext class KnowledgeBaseRoute(Route): - """知识库管理路由 - - 提供知识库、文档、检索、会话配置等 API 接口 - """ + """知识库管理路由""" def __init__( self, @@ -30,24 +20,15 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle - self.kb_manager = None # 延迟初始化 - self.kb_db = None - self.session_config_db = None # 会话配置数据库 - self.retrieval_manager = None - self.upload_progress = {} # 存储上传进度 {task_id: {status, file_index, file_total, stage, current, total}} - self.upload_tasks = {} # 存储后台上传任务 {task_id: {"status", "result", "error"}} + self.service = KnowledgeBaseService(core_lifecycle) - # 注册路由 self.routes = { - # 知识库管理 "/kb/list": ("GET", self.list_kbs), "/kb/create": ("POST", self.create_kb), "/kb/get": ("GET", self.get_kb), "/kb/update": ("POST", self.update_kb), "/kb/delete": ("POST", self.delete_kb), "/kb/stats": ("GET", self.get_kb_stats), - # 文档管理 "/kb/document/list": ("GET", self.list_documents), "/kb/document/upload": ("POST", self.upload_document), "/kb/document/import": ("POST", self.import_documents), @@ -55,1234 +36,153 @@ def __init__( "/kb/document/upload/progress": ("GET", self.get_upload_progress), "/kb/document/get": ("GET", self.get_document), "/kb/document/delete": ("POST", self.delete_document), - # # 块管理 "/kb/chunk/list": ("GET", self.list_chunks), "/kb/chunk/delete": ("POST", self.delete_chunk), - # # 多媒体管理 - # "/kb/media/list": ("GET", self.list_media), - # "/kb/media/delete": ("POST", self.delete_media), - # 检索 "/kb/retrieve": ("POST", self.retrieve), } self.register_routes() - def _get_kb_manager(self): - return self.core_lifecycle.kb_manager - - def _init_task(self, task_id: str, status: str = "pending") -> None: - self.upload_tasks[task_id] = { - "status": status, - "result": None, - "error": None, - } - - def _set_task_result( - self, task_id: str, status: str, result: Any = None, error: str | None = None - ) -> None: - self.upload_tasks[task_id] = { - "status": status, - "result": result, - "error": error, - } - if task_id in self.upload_progress: - self.upload_progress[task_id]["status"] = status - - def _update_progress( - self, - task_id: str, - *, - status: str | None = None, - file_index: int | None = None, - file_name: str | None = None, - stage: str | None = None, - current: int | None = None, - total: int | None = None, - ) -> None: - if task_id not in self.upload_progress: - return - p = self.upload_progress[task_id] - if status is not None: - p["status"] = status - if file_index is not None: - p["file_index"] = file_index - if file_name is not None: - p["file_name"] = file_name - if stage is not None: - p["stage"] = stage - if current is not None: - p["current"] = current - if total is not None: - p["total"] = total - - def _make_progress_callback(self, task_id: str, file_idx: int, file_name: str): - async def _callback(stage: str, current: int, total: int) -> None: - self._update_progress( - task_id, - status="processing", - file_index=file_idx, - file_name=file_name, - stage=stage, - current=current, - total=total, - ) - - return _callback - @staticmethod - def _format_failed_doc_error(file_name: str, error: Exception) -> str: - message = str(error).strip() or "上传失败:发生未知错误。" - if message.startswith(file_name): - return message - return f"{file_name}: {message}" - - async def _background_upload_task( - self, - task_id: str, - kb_helper, - files_to_upload: list, - chunk_size: int, - chunk_overlap: int, - batch_size: int, - tasks_limit: int, - max_retries: int, - ) -> None: - """后台上传任务""" - try: - # 初始化任务状态 - self._init_task(task_id, status="processing") - self.upload_progress[task_id] = { - "status": "processing", - "file_index": 0, - "file_total": len(files_to_upload), - "stage": "waiting", - "current": 0, - "total": 100, - } - - uploaded_docs = [] - failed_docs = [] - - for file_idx, file_info in enumerate(files_to_upload): - try: - # 更新整体进度 - self._update_progress( - task_id, - status="processing", - file_index=file_idx, - file_name=file_info["file_name"], - stage="parsing", - current=0, - total=100, - ) - - # 创建进度回调函数 - progress_callback = self._make_progress_callback( - task_id, file_idx, file_info["file_name"] - ) - - doc = await kb_helper.upload_document( - file_name=file_info["file_name"], - file_content=file_info["file_content"], - file_type=file_info["file_type"], - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - progress_callback=progress_callback, - ) - - uploaded_docs.append(doc.model_dump()) - except Exception as e: - logger.error(f"上传文档 {file_info['file_name']} 失败: {e}") - failed_docs.append( - { - "file_name": file_info["file_name"], - "error": self._format_failed_doc_error( - file_info["file_name"], e - ), - }, - ) - - # 更新任务完成状态 - result = { - "task_id": task_id, - "uploaded": uploaded_docs, - "failed": failed_docs, - "total": len(files_to_upload), - "success_count": len(uploaded_docs), - "failed_count": len(failed_docs), - } + def _ok(data: dict | list | None = None, message: str | None = None) -> dict: + return Response().ok(data, message).__dict__ - self._set_task_result(task_id, "completed", result=result) - - except Exception as e: - logger.error(f"后台上传任务 {task_id} 失败: {e}") - logger.error(traceback.format_exc()) - self._set_task_result(task_id, "failed", error=str(e)) - - async def _background_import_task( - self, - task_id: str, - kb_helper, - documents: list, - batch_size: int, - tasks_limit: int, - max_retries: int, - ) -> None: - """后台导入预切片文档任务""" - try: - # 初始化任务状态 - self._init_task(task_id, status="processing") - self.upload_progress[task_id] = { - "status": "processing", - "file_index": 0, - "file_total": len(documents), - "stage": "waiting", - "current": 0, - "total": 100, - } - - uploaded_docs = [] - failed_docs = [] - - for file_idx, doc_info in enumerate(documents): - file_name = doc_info.get("file_name", f"imported_doc_{file_idx}") - chunks = doc_info.get("chunks", []) - - try: - # 更新整体进度 - self._update_progress( - task_id, - status="processing", - file_index=file_idx, - file_name=file_name, - stage="importing", - current=0, - total=100, - ) - - # 创建进度回调函数 - progress_callback = self._make_progress_callback( - task_id, file_idx, file_name - ) - - # 调用 upload_document,传入 pre_chunked_text - doc = await kb_helper.upload_document( - file_name=file_name, - file_content=None, # 预切片模式下不需要原始内容 - file_type=doc_info.get("file_type") - or ( - file_name.rsplit(".", 1)[-1].lower() - if "." in file_name - else "txt" - ), - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - progress_callback=progress_callback, - pre_chunked_text=chunks, - ) - - uploaded_docs.append(doc.model_dump()) - except Exception as e: - logger.error(f"导入文档 {file_name} 失败: {e}") - failed_docs.append( - { - "file_name": file_name, - "error": self._format_failed_doc_error(file_name, e), - }, - ) - - # 更新任务完成状态 - result = { - "task_id": task_id, - "uploaded": uploaded_docs, - "failed": failed_docs, - "total": len(documents), - "success_count": len(uploaded_docs), - "failed_count": len(failed_docs), - } - - self._set_task_result(task_id, "completed", result=result) + @staticmethod + def _error(message: str) -> dict: + return Response().error(message).__dict__ - except Exception as e: - logger.error(f"后台导入任务 {task_id} 失败: {e}") - logger.error(traceback.format_exc()) - self._set_task_result(task_id, "failed", error=str(e)) + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} + + async def _run(self, operation, *, prefix: str): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + if isinstance(result, tuple): + data, message = result + return self._ok(data, message) + return self._ok(result) + except (KnowledgeBaseServiceError, ValueError) as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("%s: %s", prefix, exc, exc_info=True) + return self._error(f"{prefix}: {exc!s}") + + async def _run_json(self, operation, *, prefix: str): + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run(invoke, prefix=prefix) async def list_kbs(self): - """获取知识库列表 - - Query 参数: - - page: 页码 (默认 1) - - page_size: 每页数量 (默认 20) - - refresh_stats: 是否刷新统计信息 (默认 false,首次加载时可设为 true) - """ - try: - kb_manager = self._get_kb_manager() - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 20, type=int) - - kbs = await kb_manager.list_kbs() - - # 转换为字典列表 - kb_list = [] - for kb in kbs: - kb_dict = kb.model_dump() - # include init_error from KBHelper if present - kb_helper = await kb_manager.get_kb(kb.kb_id) - if kb_helper and kb_helper.init_error: - kb_dict["init_error"] = kb_helper.init_error - kb_list.append(kb_dict) - - return ( - Response() - .ok({"items": kb_list, "page": page, "page_size": page_size}) - .__dict__ - ) - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"获取知识库列表失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取知识库列表失败: {e!s}").__dict__ + return await self._run( + self.service.list_kbs_from_legacy_query( + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 20), + ), + prefix="获取知识库列表失败", + ) async def create_kb(self): - """创建知识库 - - Body: - - kb_name: 知识库名称 (必填) - - description: 描述 (可选) - - emoji: 图标 (可选) - - embedding_provider_id: 嵌入模型提供商ID (可选) - - rerank_provider_id: 重排序模型提供商ID (可选) - - chunk_size: 分块大小 (可选, 默认512) - - chunk_overlap: 块重叠大小 (可选, 默认50) - - top_k_dense: 密集检索数量 (可选, 默认50) - - top_k_sparse: 稀疏检索数量 (可选, 默认50) - - top_m_final: 最终返回数量 (可选, 默认5) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - kb_name = data.get("kb_name") - if not kb_name: - return Response().error("知识库名称不能为空").__dict__ - - description = data.get("description") - emoji = data.get("emoji") - embedding_provider_id = data.get("embedding_provider_id") - rerank_provider_id = data.get("rerank_provider_id") - chunk_size = data.get("chunk_size") - chunk_overlap = data.get("chunk_overlap") - top_k_dense = data.get("top_k_dense") - top_k_sparse = data.get("top_k_sparse") - top_m_final = data.get("top_m_final") - - # pre-check embedding dim - if not embedding_provider_id: - return Response().error("缺少参数 embedding_provider_id").__dict__ - prv = await kb_manager.provider_manager.get_provider_by_id( - embedding_provider_id, - ) # type: ignore - if not prv or not isinstance(prv, EmbeddingProvider): - return ( - Response().error(f"嵌入模型不存在或类型错误({type(prv)})").__dict__ - ) - try: - vec = await prv.get_embedding("astrbot") - if len(vec) != prv.get_dim(): - raise ValueError( - f"嵌入向量维度不匹配,实际是 {len(vec)},然而配置是 {prv.get_dim()}", - ) - except Exception as e: - return Response().error(f"测试嵌入模型失败: {e!s}").__dict__ - # pre-check rerank - if rerank_provider_id: - rerank_prv: RerankProvider = ( - await kb_manager.provider_manager.get_provider_by_id( - rerank_provider_id, - ) - ) # type: ignore - if not rerank_prv: - return Response().error("重排序模型不存在").__dict__ - # 检查重排序模型可用性 - try: - res = await rerank_prv.rerank( - query="astrbot", - documents=["astrbot knowledge base"], - ) - if not res: - raise ValueError("重排序模型返回结果异常") - except Exception as e: - return ( - Response() - .error(f"测试重排序模型失败: {e!s},请检查平台日志输出。") - .__dict__ - ) - - kb_helper = await kb_manager.create_kb( - kb_name=kb_name, - description=description, - emoji=emoji, - embedding_provider_id=embedding_provider_id, - rerank_provider_id=rerank_provider_id, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - top_k_dense=top_k_dense, - top_k_sparse=top_k_sparse, - top_m_final=top_m_final, - ) - kb = kb_helper.kb - - return Response().ok(kb.model_dump(), "创建知识库成功").__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"创建知识库失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"创建知识库失败: {e!s}").__dict__ + return await self._run_json(self.service.create_kb, prefix="创建知识库失败") async def get_kb(self): - """获取知识库详情 - - Query 参数: - - kb_id: 知识库 ID (必填) - """ - try: - kb_manager = self._get_kb_manager() - kb_id = request.args.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - kb = kb_helper.kb - - return Response().ok(kb.model_dump()).__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"获取知识库详情失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取知识库详情失败: {e!s}").__dict__ + return await self._run( + self.service.get_kb_from_legacy_query(request.args.get("kb_id")), + prefix="获取知识库详情失败", + ) async def update_kb(self): - """更新知识库 - - Body: - - kb_id: 知识库 ID (必填) - - kb_name: 新的知识库名称 (可选) - - description: 新的描述 (可选) - - emoji: 新的图标 (可选) - - embedding_provider_id: 新的嵌入模型提供商ID (可选) - - rerank_provider_id: 新的重排序模型提供商ID (可选) - - chunk_size: 分块大小 (可选) - - chunk_overlap: 块重叠大小 (可选) - - top_k_dense: 密集检索数量 (可选) - - top_k_sparse: 稀疏检索数量 (可选) - - top_m_final: 最终返回数量 (可选) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - kb_id = data.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - - kb_name = data.get("kb_name") - description = data.get("description") - emoji = data.get("emoji") - embedding_provider_id = data.get("embedding_provider_id") - rerank_provider_id = data.get("rerank_provider_id") - chunk_size = data.get("chunk_size") - chunk_overlap = data.get("chunk_overlap") - top_k_dense = data.get("top_k_dense") - top_k_sparse = data.get("top_k_sparse") - top_m_final = data.get("top_m_final") - - # 检查是否至少提供了一个更新字段 - if all( - v is None - for v in [ - kb_name, - description, - emoji, - embedding_provider_id, - rerank_provider_id, - chunk_size, - chunk_overlap, - top_k_dense, - top_k_sparse, - top_m_final, - ] - ): - return Response().error("至少需要提供一个更新字段").__dict__ - - kb_helper = await kb_manager.update_kb( - kb_id=kb_id, - kb_name=kb_name, - description=description, - emoji=emoji, - embedding_provider_id=embedding_provider_id, - rerank_provider_id=rerank_provider_id, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - top_k_dense=top_k_dense, - top_k_sparse=top_k_sparse, - top_m_final=top_m_final, - ) - - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - kb = kb_helper.kb - return Response().ok(kb.model_dump(), "更新知识库成功").__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"更新知识库失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"更新知识库失败: {e!s}").__dict__ + return await self._run_json(self.service.update_kb, prefix="更新知识库失败") async def delete_kb(self): - """删除知识库 - - Body: - - kb_id: 知识库 ID (必填) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - kb_id = data.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - - success = await kb_manager.delete_kb(kb_id) - if not success: - return Response().error("知识库不存在").__dict__ - - return Response().ok(message="删除知识库成功").__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"删除知识库失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"删除知识库失败: {e!s}").__dict__ + return await self._run_json(self.service.delete_kb, prefix="删除知识库失败") async def get_kb_stats(self): - """获取知识库统计信息 - - Query 参数: - - kb_id: 知识库 ID (必填) - """ - try: - kb_manager = self._get_kb_manager() - kb_id = request.args.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - kb = kb_helper.kb - - stats = { - "kb_id": kb.kb_id, - "kb_name": kb.kb_name, - "doc_count": kb.doc_count, - "chunk_count": kb.chunk_count, - "created_at": kb.created_at.isoformat(), - "updated_at": kb.updated_at.isoformat(), - } - - return Response().ok(stats).__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"获取知识库统计失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取知识库统计失败: {e!s}").__dict__ - - # ===== 文档管理 API ===== + return await self._run( + self.service.get_kb_stats_from_legacy_query(request.args.get("kb_id")), + prefix="获取知识库统计失败", + ) async def list_documents(self): - """获取文档列表 - - Query 参数: - - kb_id: 知识库 ID (必填) - - page: 页码 (默认 1) - - page_size: 每页数量 (默认 20) - """ - try: - kb_manager = self._get_kb_manager() - kb_id = request.args.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 100, type=int) - - offset = (page - 1) * page_size - limit = page_size - - doc_list = await kb_helper.list_documents(offset=offset, limit=limit) - - doc_list = [doc.model_dump() for doc in doc_list] - - return ( - Response() - .ok({"items": doc_list, "page": page, "page_size": page_size}) - .__dict__ - ) - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"获取文档列表失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取文档列表失败: {e!s}").__dict__ + return await self._run( + self.service.list_documents_from_legacy_query( + kb_id=request.args.get("kb_id"), + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 100), + ), + prefix="获取文档列表失败", + ) async def upload_document(self): - """上传文档 - - 支持两种方式: - 1. multipart/form-data 文件上传(支持多文件,最多10个) - 2. JSON 格式 base64 编码上传(支持多文件,最多10个) - - Form Data (multipart/form-data): - - kb_id: 知识库 ID (必填) - - file: 文件对象 (必填,可多个,字段名为 file, file1, file2, ... 或 files[]) - - JSON Body (application/json): - - kb_id: 知识库 ID (必填) - - files: 文件数组 (必填) - - file_name: 文件名 (必填) - - file_content: base64 编码的文件内容 (必填) - - 返回: - - task_id: 任务ID,用于查询上传进度和结果 - """ - try: - kb_manager = self._get_kb_manager() - - # 检查 Content-Type - content_type = request.content_type - kb_id = None - chunk_size = None - chunk_overlap = None - batch_size = 32 - tasks_limit = 3 - max_retries = 3 - files_to_upload = [] # 存储待上传的文件信息列表 - - if content_type and "multipart/form-data" not in content_type: - return ( - Response().error("Content-Type 须为 multipart/form-data").__dict__ - ) + async def _operation(): form_data = await request.form files = await request.files - - kb_id = form_data.get("kb_id") - chunk_size = int(form_data.get("chunk_size", 512)) - chunk_overlap = int(form_data.get("chunk_overlap", 50)) - batch_size = int(form_data.get("batch_size", 32)) - tasks_limit = int(form_data.get("tasks_limit", 3)) - max_retries = int(form_data.get("max_retries", 3)) - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - - # 收集所有文件 - file_list = [] - # 支持 file, file1, file2, ... 或 files[] 格式 - for key in files.keys(): - if key == "file" or key.startswith("file") or key == "files[]": - file_items = files.getlist(key) - file_list.extend(file_items) - - if not file_list: - return Response().error("缺少文件").__dict__ - - # 限制文件数量 - if len(file_list) > 10: - return Response().error("最多只能上传10个文件").__dict__ - - # 处理每个文件 - for file in file_list: - file_name = file.filename - - # 保存到临时文件 - temp_file_path = os.path.join( - get_astrbot_temp_path(), - f"kb_upload_{uuid.uuid4()}_{file_name}", - ) - await file.save(temp_file_path) - - try: - # 异步读取文件内容 - async with aiofiles.open(temp_file_path, "rb") as f: - file_content = await f.read() - - # 提取文件类型 - file_type = ( - file_name.rsplit(".", 1)[-1].lower() if "." in file_name else "" - ) - - files_to_upload.append( - { - "file_name": file_name, - "file_content": file_content, - "file_type": file_type, - }, - ) - finally: - # 清理临时文件 - if os.path.exists(temp_file_path): - os.remove(temp_file_path) - - # 获取知识库 - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - # 生成任务ID - task_id = str(uuid.uuid4()) - - # 初始化任务状态 - self._init_task(task_id, status="pending") - - # 启动后台任务 - asyncio.create_task( - self._background_upload_task( - task_id=task_id, - kb_helper=kb_helper, - files_to_upload=files_to_upload, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - ), + return await self.service.upload_document( + content_type=request.content_type, + form_data=form_data, + files=files, ) - return ( - Response() - .ok( - { - "task_id": task_id, - "file_count": len(files_to_upload), - "message": "task created, processing in background", - }, - ) - .__dict__ - ) - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"上传文档失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"上传文档失败: {e!s}").__dict__ - - def _validate_import_request(self, data: dict): - kb_id = data.get("kb_id") - if not kb_id: - raise ValueError("缺少参数 kb_id") - - documents = data.get("documents") - if not documents or not isinstance(documents, list): - raise ValueError("缺少参数 documents 或格式错误") - - for doc in documents: - if "file_name" not in doc or "chunks" not in doc: - raise ValueError("文档格式错误,必须包含 file_name 和 chunks") - if not isinstance(doc["chunks"], list): - raise ValueError("chunks 必须是列表") - if not all( - isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"] - ): - raise ValueError("chunks 必须是非空字符串列表") - - batch_size = data.get("batch_size", 32) - tasks_limit = data.get("tasks_limit", 3) - max_retries = data.get("max_retries", 3) - return kb_id, documents, batch_size, tasks_limit, max_retries + return await self._run(_operation, prefix="上传文档失败") async def import_documents(self): - """导入预切片文档 - - Body: - - kb_id: 知识库 ID (必填) - - documents: 文档列表 (必填) - - file_name: 文件名 (必填) - - chunks: 切片列表 (必填, list[str]) - - file_type: 文件类型 (可选, 默认从文件名推断或为 txt) - - batch_size: 批处理大小 (可选, 默认32) - - tasks_limit: 并发任务限制 (可选, 默认3) - - max_retries: 最大重试次数 (可选, 默认3) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - kb_id, documents, batch_size, tasks_limit, max_retries = ( - self._validate_import_request(data) - ) - - # 获取知识库 - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - # 生成任务ID - task_id = str(uuid.uuid4()) - - # 初始化任务状态 - self._init_task(task_id, status="pending") - - # 启动后台任务 - asyncio.create_task( - self._background_import_task( - task_id=task_id, - kb_helper=kb_helper, - documents=documents, - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - ), - ) - - return ( - Response() - .ok( - { - "task_id": task_id, - "doc_count": len(documents), - "message": "import task created, processing in background", - }, - ) - .__dict__ - ) - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"导入文档失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"导入文档失败: {e!s}").__dict__ + return await self._run_json( + self.service.import_documents, + prefix="导入文档失败", + ) async def get_upload_progress(self): - """获取上传进度和结果 - - Query 参数: - - task_id: 任务 ID (必填) - - 返回状态: - - pending: 任务待处理 - - processing: 任务处理中 - - completed: 任务完成 - - failed: 任务失败 - """ - try: - task_id = request.args.get("task_id") - if not task_id: - return Response().error("缺少参数 task_id").__dict__ - - # 检查任务是否存在 - if task_id not in self.upload_tasks: - return Response().error("找不到该任务").__dict__ - - task_info = self.upload_tasks[task_id] - status = task_info["status"] - - # 构建返回数据 - response_data = { - "task_id": task_id, - "status": status, - } - - # 如果任务正在处理,返回进度信息 - if status == "processing" and task_id in self.upload_progress: - response_data["progress"] = self.upload_progress[task_id] - - # 如果任务完成,返回结果 - if status == "completed": - response_data["result"] = task_info["result"] - # 清理已完成的任务 - # del self.upload_tasks[task_id] - # if task_id in self.upload_progress: - # del self.upload_progress[task_id] - - # 如果任务失败,返回错误信息 - if status == "failed": - response_data["error"] = task_info["error"] - - return Response().ok(response_data).__dict__ - - except Exception as e: - logger.error(f"获取上传进度失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取上传进度失败: {e!s}").__dict__ + return await self._run( + lambda: self.service.get_upload_progress_from_legacy_query( + request.args.get("task_id") + ), + prefix="获取上传进度失败", + ) async def get_document(self): - """获取文档详情 - - Query 参数: - - doc_id: 文档 ID (必填) - """ - try: - kb_manager = self._get_kb_manager() - kb_id = request.args.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - doc_id = request.args.get("doc_id") - if not doc_id: - return Response().error("缺少参数 doc_id").__dict__ - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - doc = await kb_helper.get_document(doc_id) - if not doc: - return Response().error("文档不存在").__dict__ - - return Response().ok(doc.model_dump()).__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"获取文档详情失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取文档详情失败: {e!s}").__dict__ + return await self._run( + self.service.get_document_from_legacy_query( + kb_id=request.args.get("kb_id"), + doc_id=request.args.get("doc_id"), + ), + prefix="获取文档详情失败", + ) async def delete_document(self): - """删除文档 - - Body: - - kb_id: 知识库 ID (必填) - - doc_id: 文档 ID (必填) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - kb_id = data.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - doc_id = data.get("doc_id") - if not doc_id: - return Response().error("缺少参数 doc_id").__dict__ - - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - await kb_helper.delete_document(doc_id) - return Response().ok(message="删除文档成功").__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"删除文档失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"删除文档失败: {e!s}").__dict__ + return await self._run_json( + self.service.delete_document, + prefix="删除文档失败", + ) async def delete_chunk(self): - """删除文本块 - - Body: - - kb_id: 知识库 ID (必填) - - chunk_id: 块 ID (必填) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - kb_id = data.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - chunk_id = data.get("chunk_id") - if not chunk_id: - return Response().error("缺少参数 chunk_id").__dict__ - doc_id = data.get("doc_id") - if not doc_id: - return Response().error("缺少参数 doc_id").__dict__ - - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - await kb_helper.delete_chunk(chunk_id, doc_id) - return Response().ok(message="删除文本块成功").__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"删除文本块失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"删除文本块失败: {e!s}").__dict__ + return await self._run_json( + self.service.delete_chunk, + prefix="删除文本块失败", + ) async def list_chunks(self): - """获取块列表 - - Query 参数: - - kb_id: 知识库 ID (必填) - - page: 页码 (默认 1) - - page_size: 每页数量 (默认 20) - """ - try: - kb_manager = self._get_kb_manager() - kb_id = request.args.get("kb_id") - doc_id = request.args.get("doc_id") - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 100, type=int) - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - if not doc_id: - return Response().error("缺少参数 doc_id").__dict__ - kb_helper = await kb_manager.get_kb(kb_id) - offset = (page - 1) * page_size - limit = page_size - if not kb_helper: - return Response().error("知识库不存在").__dict__ - chunk_list = await kb_helper.get_chunks_by_doc_id( - doc_id=doc_id, - offset=offset, - limit=limit, - ) - return ( - Response() - .ok( - data={ - "items": chunk_list, - "page": page, - "page_size": page_size, - "total": await kb_helper.get_chunk_count_by_doc_id(doc_id), - }, - ) - .__dict__ - ) - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"获取块列表失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"获取块列表失败: {e!s}").__dict__ - - # ===== 检索 API ===== + return await self._run( + self.service.list_chunks_from_legacy_query( + kb_id=request.args.get("kb_id"), + doc_id=request.args.get("doc_id"), + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 100), + ), + prefix="获取块列表失败", + ) async def retrieve(self): - """检索知识库 - - Body: - - query: 查询文本 (必填) - - kb_ids: 知识库 ID 列表 (必填) - - top_k: 返回结果数量 (可选, 默认 5) - - debug: 是否启用调试模式,返回 t-SNE 可视化图片 (可选, 默认 False) - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - query = data.get("query") - kb_names = data.get("kb_names") - debug = data.get("debug", False) - - if not query: - return Response().error("缺少参数 query").__dict__ - if not kb_names or not isinstance(kb_names, list): - return Response().error("缺少参数 kb_names 或格式错误").__dict__ - - top_k = data.get("top_k", 5) - - results = await kb_manager.retrieve( - query=query, - kb_names=kb_names, - top_m_final=top_k, - ) - result_list = [] - if results: - result_list = results["results"] - - response_data = { - "results": result_list, - "total": len(result_list), - "query": query, - } - - # Debug 模式:生成 t-SNE 可视化 - if debug: - try: - img_base64 = await generate_tsne_visualization( - query, - kb_names, - kb_manager, - ) - if img_base64: - response_data["visualization"] = img_base64 - except Exception as e: - logger.error(f"生成 t-SNE 可视化失败: {e}") - logger.error(traceback.format_exc()) - response_data["visualization_error"] = str(e) - - return Response().ok(response_data).__dict__ - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"检索失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"检索失败: {e!s}").__dict__ + return await self._run_json(self.service.retrieve, prefix="检索失败") async def upload_document_from_url(self): - """从 URL 上传文档 - - Body: - - kb_id: 知识库 ID (必填) - - url: 要提取内容的网页 URL (必填) - - chunk_size: 分块大小 (可选, 默认512) - - chunk_overlap: 块重叠大小 (可选, 默认50) - - batch_size: 批处理大小 (可选, 默认32) - - tasks_limit: 并发任务限制 (可选, 默认3) - - max_retries: 最大重试次数 (可选, 默认3) - - 返回: - - task_id: 任务ID,用于查询上传进度和结果 - """ - try: - kb_manager = self._get_kb_manager() - data = await request.json - - kb_id = data.get("kb_id") - if not kb_id: - return Response().error("缺少参数 kb_id").__dict__ - - url = data.get("url") - if not url: - return Response().error("缺少参数 url").__dict__ - - chunk_size = data.get("chunk_size", 512) - chunk_overlap = data.get("chunk_overlap", 50) - batch_size = data.get("batch_size", 32) - tasks_limit = data.get("tasks_limit", 3) - max_retries = data.get("max_retries", 3) - enable_cleaning = data.get("enable_cleaning", False) - cleaning_provider_id = data.get("cleaning_provider_id") - - # 获取知识库 - kb_helper = await kb_manager.get_kb(kb_id) - if not kb_helper: - return Response().error("知识库不存在").__dict__ - - # 生成任务ID - task_id = str(uuid.uuid4()) - - # 初始化任务状态 - self._init_task(task_id, status="pending") - - # 启动后台任务 - asyncio.create_task( - self._background_upload_from_url_task( - task_id=task_id, - kb_helper=kb_helper, - url=url, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - enable_cleaning=enable_cleaning, - cleaning_provider_id=cleaning_provider_id, - ), - ) - - return ( - Response() - .ok( - { - "task_id": task_id, - "url": url, - "message": "URL upload task created, processing in background", - }, - ) - .__dict__ - ) - - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"从URL上传文档失败: {e}") - logger.error(traceback.format_exc()) - return Response().error(f"从URL上传文档失败: {e!s}").__dict__ - - async def _background_upload_from_url_task( - self, - task_id: str, - kb_helper, - url: str, - chunk_size: int, - chunk_overlap: int, - batch_size: int, - tasks_limit: int, - max_retries: int, - enable_cleaning: bool, - cleaning_provider_id: str | None, - ) -> None: - """后台上传URL任务""" - try: - # 初始化任务状态 - self._init_task(task_id, status="processing") - self.upload_progress[task_id] = { - "status": "processing", - "file_index": 0, - "file_total": 1, - "file_name": f"URL: {url}", - "stage": "extracting", - "current": 0, - "total": 100, - } - - # 创建进度回调函数 - progress_callback = self._make_progress_callback(task_id, 0, f"URL: {url}") - - # 上传文档 - doc = await kb_helper.upload_from_url( - url=url, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - progress_callback=progress_callback, - enable_cleaning=enable_cleaning, - cleaning_provider_id=cleaning_provider_id, - ) - - # 更新任务完成状态 - result = { - "task_id": task_id, - "uploaded": [doc.model_dump()], - "failed": [], - "total": 1, - "success_count": 1, - "failed_count": 0, - } + return await self._run_json( + self.service.upload_document_from_url, + prefix="从URL上传文档失败", + ) - self._set_task_result(task_id, "completed", result=result) - except Exception as e: - logger.error(f"后台上传URL任务 {task_id} 失败: {e}") - logger.error(traceback.format_exc()) - self._set_task_result(task_id, "failed", error=str(e)) +__all__ = ["KnowledgeBaseRoute"] diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index d7705882db..55e53d22c5 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -1,115 +1,12 @@ -import asyncio -import json -import os -import re -import time -import uuid -import wave from typing import Any -import jwt -from quart import websocket - -from astrbot import logger -from astrbot.core import sp from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.platform.sources.webchat.message_parts_helper import ( - build_webchat_message_parts, - create_attachment_part_from_existing_file, - strip_message_parts_path_fields, - webchat_message_parts_have_content, -) -from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path -from astrbot.core.utils.datetime_utils import to_utc_isoformat +from astrbot.dashboard.fastapi_compat import websocket +from astrbot.dashboard.services.live_chat_service import LiveChatService -from .chat import ( - BotMessageAccumulator, - build_bot_history_content, - collect_plain_text_from_message_parts, -) from .route import Route, RouteContext -class LiveChatSession: - """Live Chat 会话管理器""" - - def __init__(self, session_id: str, username: str) -> None: - self.session_id = session_id - self.username = username - self.conversation_id = str(uuid.uuid4()) - self.is_speaking = False - self.is_processing = False - self.should_interrupt = False - self.audio_frames: list[bytes] = [] - self.current_stamp: str | None = None - self.temp_audio_path: str | None = None - self.chat_subscriptions: dict[str, str] = {} - self.chat_subscription_tasks: dict[str, asyncio.Task] = {} - self.ws_send_lock = asyncio.Lock() - - def start_speaking(self, stamp: str) -> None: - """开始说话""" - self.is_speaking = True - self.current_stamp = stamp - self.audio_frames = [] - logger.debug(f"[Live Chat] {self.username} 开始说话 stamp={stamp}") - - def add_audio_frame(self, data: bytes) -> None: - """添加音频帧""" - if self.is_speaking: - self.audio_frames.append(data) - - async def end_speaking(self, stamp: str) -> tuple[str | None, float]: - """结束说话,返回组装的 WAV 文件路径和耗时""" - start_time = time.time() - if not self.is_speaking or stamp != self.current_stamp: - logger.warning( - f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}" - ) - return None, 0.0 - - self.is_speaking = False - - if not self.audio_frames: - logger.warning("[Live Chat] 没有音频帧数据") - return None, 0.0 - - # 组装 WAV 文件 - try: - temp_dir = get_astrbot_temp_path() - os.makedirs(temp_dir, exist_ok=True) - audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav") - - # 假设前端发送的是 PCM 数据,采样率 16000Hz,单声道,16位 - with wave.open(audio_path, "wb") as wav_file: - wav_file.setnchannels(1) # 单声道 - wav_file.setsampwidth(2) # 16位 = 2字节 - wav_file.setframerate(16000) # 采样率 16000Hz - for frame in self.audio_frames: - wav_file.writeframes(frame) - - self.temp_audio_path = audio_path - logger.info( - f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes" - ) - return audio_path, time.time() - start_time - - except Exception as e: - logger.error(f"[Live Chat] 组装 WAV 文件失败: {e}", exc_info=True) - return None, 0.0 - - def cleanup(self) -> None: - """清理临时文件""" - if self.temp_audio_path and os.path.exists(self.temp_audio_path): - try: - os.remove(self.temp_audio_path) - logger.debug(f"[Live Chat] 已删除临时文件: {self.temp_audio_path}") - except Exception as e: - logger.warning(f"[Live Chat] 删除临时文件失败: {e}") - self.temp_audio_path = None - - class LiveChatRoute(Route): """Live Chat WebSocket 路由""" @@ -120,16 +17,9 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle - self.db = db - self.plugin_manager = core_lifecycle.plugin_manager - self.platform_history_mgr = core_lifecycle.platform_message_history_manager - self.sessions: dict[str, LiveChatSession] = {} - self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") - self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") - os.makedirs(self.attachments_dir, exist_ok=True) + self.service = LiveChatService(db, core_lifecycle) + self.sessions = self.service.sessions - # 注册 WebSocket 路由 self.app.websocket("/api/live_chat/ws")(self.live_chat_ws) self.app.websocket("/api/unified_chat/ws")(self.unified_chat_ws) @@ -142,819 +32,13 @@ async def unified_chat_ws(self) -> None: await self._unified_ws_loop(force_ct=None) async def _unified_ws_loop(self, force_ct: str | None = None) -> None: - """统一 WebSocket 循环""" - # WebSocket 不能通过 header 传递 token,需要从 query 参数获取 - # 注意:WebSocket 上下文使用 websocket.args 而不是 request.args - token = websocket.args.get("token") - if not token: - await websocket.close(1008, "Missing authentication token") - return - - try: - jwt_secret = self.config["dashboard"].get("jwt_secret") - payload = jwt.decode(token, jwt_secret, algorithms=["HS256"]) - username = payload["username"] - except jwt.ExpiredSignatureError: - await websocket.close(1008, "Token expired") - return - except jwt.InvalidTokenError: - await websocket.close(1008, "Invalid token") - return - - session_id = f"webchat_live!{username}!{uuid.uuid4()}" - live_session = LiveChatSession(session_id, username) - self.sessions[session_id] = live_session - - logger.info(f"[Live Chat] WebSocket 连接建立: {username}") - - try: - while True: - message = await websocket.receive_json() - ct = force_ct or message.get("ct", "live") - if ct == "chat": - await self._handle_chat_message(live_session, message) - else: - await self._handle_message(live_session, message) - - except Exception as e: - logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True) - - finally: - # 清理会话 - if session_id in self.sessions: - await self._cleanup_chat_subscriptions(live_session) - live_session.cleanup() - del self.sessions[session_id] - logger.info(f"[Live Chat] WebSocket 连接关闭: {username}") - - async def _create_attachment_from_file( - self, filename: str, attach_type: str - ) -> dict | None: - """从本地文件创建 attachment 并返回消息部分。""" - return await create_attachment_part_from_existing_file( - filename, - attach_type=attach_type, - insert_attachment=self.db.insert_attachment, - attachments_dir=self.attachments_dir, - fallback_dirs=[self.legacy_img_dir], - ) - - def _extract_web_search_refs( - self, accumulated_text: str, accumulated_parts: list - ) -> dict: - """从消息中提取 web_search 引用。""" - supported = [ - "web_search_baidu", - "web_search_tavily", - "web_search_bocha", - "web_search_brave", - ] - web_search_results = {} - tool_call_parts = [ - p - for p in accumulated_parts - if p.get("type") == "tool_call" and p.get("tool_calls") - ] - - for part in tool_call_parts: - for tool_call in part["tool_calls"]: - if tool_call.get("name") not in supported or not tool_call.get( - "result" - ): - continue - try: - result_data = json.loads(tool_call["result"]) - for item in result_data.get("results", []): - if idx := item.get("index"): - web_search_results[idx] = { - "url": item.get("url"), - "title": item.get("title"), - "snippet": item.get("snippet"), - } - except (json.JSONDecodeError, KeyError): - pass - - if not web_search_results: - return {} - - ref_indices = { - m.strip() for m in re.findall(r"(.*?)", accumulated_text) - } - - used_refs = [] - for ref_index in ref_indices: - if ref_index not in web_search_results: - continue - payload = {"index": ref_index, **web_search_results[ref_index]} - if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]): - payload["favicon"] = favicon - used_refs.append(payload) - - return {"used": used_refs} if used_refs else {} - - async def _save_bot_message( - self, - webchat_conv_id: str, - message_parts: list[dict], - agent_stats: dict, - refs: dict, - llm_checkpoint_id: str | None = None, - ): - """保存 bot 消息到历史记录。""" - new_his = build_bot_history_content( - message_parts, - agent_stats=agent_stats, - refs=refs, - ) - - return await self.platform_history_mgr.insert( - platform_id="webchat", - user_id=webchat_conv_id, - content=new_his, - sender_id="bot", - sender_name="bot", - llm_checkpoint_id=llm_checkpoint_id, - ) - - async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None: - async with session.ws_send_lock: - await websocket.send_json(payload) - - async def _forward_chat_subscription( - self, - session: LiveChatSession, - chat_session_id: str, - request_id: str, - ) -> None: - back_queue = webchat_queue_mgr.get_or_create_back_queue( - request_id, chat_session_id - ) - try: - while True: - result = await back_queue.get() - if not result: - continue - await self._send_chat_payload(session, {"ct": "chat", **result}) - except asyncio.CancelledError: - pass - except Exception as e: - logger.error( - f"[Live Chat] chat subscription forward failed ({chat_session_id}): {e}", - exc_info=True, - ) - finally: - webchat_queue_mgr.remove_back_queue(request_id) - if session.chat_subscriptions.get(chat_session_id) == request_id: - session.chat_subscriptions.pop(chat_session_id, None) - session.chat_subscription_tasks.pop(chat_session_id, None) - - async def _ensure_chat_subscription( - self, - session: LiveChatSession, - chat_session_id: str, - ) -> str: - existing_request_id = session.chat_subscriptions.get(chat_session_id) - existing_task = session.chat_subscription_tasks.get(chat_session_id) - if existing_request_id and existing_task and not existing_task.done(): - return existing_request_id - - request_id = f"ws_sub_{uuid.uuid4().hex}" - session.chat_subscriptions[chat_session_id] = request_id - task = asyncio.create_task( - self._forward_chat_subscription(session, chat_session_id, request_id), - name=f"chat_ws_sub_{chat_session_id}", - ) - session.chat_subscription_tasks[chat_session_id] = task - return request_id - - async def _cleanup_chat_subscriptions(self, session: LiveChatSession) -> None: - tasks = list(session.chat_subscription_tasks.values()) - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - for request_id in list(session.chat_subscriptions.values()): - webchat_queue_mgr.remove_back_queue(request_id) - session.chat_subscriptions.clear() - session.chat_subscription_tasks.clear() - - async def _handle_chat_message( - self, session: LiveChatSession, message: dict - ) -> None: - """处理 Chat Mode 消息(ct=chat)""" - msg_type = message.get("t") - - if msg_type == "bind": - chat_session_id = message.get("session_id") - if not isinstance(chat_session_id, str) or not chat_session_id: - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": "session_id is required", - "code": "INVALID_MESSAGE_FORMAT", - }, - ) - return - - request_id = await self._ensure_chat_subscription(session, chat_session_id) - await self._send_chat_payload( - session, - { - "ct": "chat", - "type": "session_bound", - "session_id": chat_session_id, - "message_id": request_id, - }, - ) - return - - if msg_type == "interrupt": - session.should_interrupt = True - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": "INTERRUPTED", - "code": "INTERRUPTED", - }, - ) - return - - if msg_type != "send": - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": f"Unsupported message type: {msg_type}", - "code": "INVALID_MESSAGE_FORMAT", - }, - ) - return - - if session.is_processing: - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": "Session is busy", - "code": "PROCESSING_ERROR", - }, - ) - return - - payload = message.get("message") - session_id = message.get("session_id") or session.session_id - message_id = message.get("message_id") or str(uuid.uuid4()) - selected_provider = message.get("selected_provider") - selected_model = message.get("selected_model") - selected_stt_provider = message.get("selected_stt_provider") - selected_tts_provider = message.get("selected_tts_provider") - persona_prompt = message.get("persona_prompt") - show_reasoning = message.get("show_reasoning") - enable_streaming = message.get("enable_streaming", True) - - if not isinstance(payload, list): - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": "message must be list", - "code": "INVALID_MESSAGE_FORMAT", - }, - ) - return - - message_parts = await self._build_chat_message_parts(payload) - has_content = webchat_message_parts_have_content(message_parts) - if not has_content: - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": "Message content is empty", - "code": "INVALID_MESSAGE_FORMAT", - }, - ) - return - - await self._ensure_chat_subscription(session, session_id) - - session.is_processing = True - session.should_interrupt = False - back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id) - llm_checkpoint_id = str(uuid.uuid4()) - - try: - pending_bot_message_flusher = None - chat_queue = webchat_queue_mgr.get_or_create_queue(session_id) - await chat_queue.put( - ( - session.username, - session_id, - { - "message": message_parts, - "selected_provider": selected_provider, - "selected_model": selected_model, - "selected_stt_provider": selected_stt_provider, - "selected_tts_provider": selected_tts_provider, - "persona_prompt": persona_prompt, - "show_reasoning": show_reasoning, - "enable_streaming": enable_streaming, - "message_id": message_id, - "llm_checkpoint_id": llm_checkpoint_id, - }, - ), - ) - - message_parts_for_storage = strip_message_parts_path_fields(message_parts) - saved_user_record = await self.platform_history_mgr.insert( - platform_id="webchat", - user_id=session_id, - content={"type": "user", "message": message_parts_for_storage}, - sender_id=session.username, - sender_name=session.username, - llm_checkpoint_id=llm_checkpoint_id, - ) - await self._send_chat_payload( - session, - { - "ct": "chat", - "type": "user_message_saved", - "data": { - "id": saved_user_record.id, - "created_at": to_utc_isoformat(saved_user_record.created_at), - "llm_checkpoint_id": llm_checkpoint_id, - }, - }, - ) - - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - - async def flush_pending_bot_message(): - nonlocal message_accumulator, agent_stats, refs - if not (message_accumulator.has_content() or refs or agent_stats): - return None - - message_parts_to_save = message_accumulator.build_message_parts( - include_pending_tool_calls=True - ) - plain_text = collect_plain_text_from_message_parts( - message_parts_to_save - ) - try: - extracted_refs = self._extract_web_search_refs( - plain_text, - message_parts_to_save, - ) - except Exception as e: - logger.exception( - f"[Live Chat] Failed to extract web search refs: {e}", - exc_info=True, - ) - extracted_refs = refs - - saved_record = await self._save_bot_message( - session_id, - message_parts_to_save, - agent_stats, - extracted_refs, - llm_checkpoint_id, - ) - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - return saved_record - - pending_bot_message_flusher = flush_pending_bot_message - - async def send_attachment_saved_event(part: dict | None) -> None: - if not part or not part.get("attachment_id") or not part.get("type"): - return - - await self._send_chat_payload( - session, - { - "ct": "chat", - "type": "attachment_saved", - "data": { - "id": part["attachment_id"], - "type": part["type"], - }, - }, - ) - - while True: - if session.should_interrupt: - session.should_interrupt = False - await flush_pending_bot_message() - break - - try: - result = await asyncio.wait_for(back_queue.get(), timeout=1) - except asyncio.TimeoutError: - continue - - if not result: - continue - if result.get("message_id") and result.get("message_id") != message_id: - continue - - result_text = result.get("data", "") - msg_type = result.get("type") - streaming = result.get("streaming", False) - chain_type = result.get("chain_type") - if chain_type == "agent_stats": - try: - parsed_agent_stats = json.loads(result_text) - agent_stats = parsed_agent_stats - await self._send_chat_payload( - session, - { - "ct": "chat", - "type": "agent_stats", - "data": parsed_agent_stats, - }, - ) - except Exception: - pass - continue - - outgoing = {"ct": "chat", **result} - await self._send_chat_payload(session, outgoing) - - if msg_type == "plain": - message_accumulator.add_plain( - result_text, - chain_type=chain_type, - streaming=streaming, - ) - elif msg_type == "image": - filename = str(result_text).replace("[IMAGE]", "") - part = await self._create_attachment_from_file(filename, "image") - message_accumulator.add_attachment(part) - await send_attachment_saved_event(part) - elif msg_type == "record": - filename = str(result_text).replace("[RECORD]", "") - part = await self._create_attachment_from_file(filename, "record") - message_accumulator.add_attachment(part) - await send_attachment_saved_event(part) - elif msg_type == "file": - filename = str(result_text).replace("[FILE]", "").split("|", 1)[0] - part = await self._create_attachment_from_file(filename, "file") - message_accumulator.add_attachment(part) - await send_attachment_saved_event(part) - elif msg_type == "video": - filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0] - part = await self._create_attachment_from_file(filename, "video") - message_accumulator.add_attachment(part) - await send_attachment_saved_event(part) - - should_save = False - if msg_type == "end": - should_save = bool( - message_accumulator.has_content() or refs or agent_stats - ) - elif (streaming and msg_type == "complete") or not streaming: - if chain_type not in ( - "tool_call", - "tool_call_result", - "agent_stats", - ): - should_save = True - - if should_save: - saved_record = await flush_pending_bot_message() - if saved_record: - await self._send_chat_payload( - session, - { - "ct": "chat", - "type": "message_saved", - "data": { - "id": saved_record.id, - "created_at": to_utc_isoformat( - saved_record.created_at - ), - "llm_checkpoint_id": llm_checkpoint_id, - }, - }, - ) - - if msg_type == "end": - break - - except Exception as e: - logger.error(f"[Live Chat] 处理 chat 消息失败: {e}", exc_info=True) - await self._send_chat_payload( - session, - { - "ct": "chat", - "t": "error", - "data": f"处理失败: {str(e)}", - "code": "PROCESSING_ERROR", - }, - ) - finally: - try: - if pending_bot_message_flusher is not None: - await pending_bot_message_flusher() - except Exception as e: - logger.exception( - f"[Live Chat] Failed to persist pending chat message: {e}", - exc_info=True, - ) - session.is_processing = False - webchat_queue_mgr.remove_back_queue(message_id) - - async def _build_chat_message_parts(self, message: list[dict]) -> list[dict]: - """构建 chat websocket 用户消息段(复用 webchat 逻辑)""" - return await build_webchat_message_parts( - message, - get_attachment_by_id=self.db.get_attachment_by_id, - strict=False, + await self.service.run_websocket_session( + token=websocket.args.get("token"), + force_ct=force_ct, + receive_json=websocket.receive_json, + send_json=websocket.send_json, + close=websocket.close, ) - async def _handle_message(self, session: LiveChatSession, message: dict) -> None: - """处理 WebSocket 消息""" - msg_type = message.get("t") # 使用 t 代替 type - - if msg_type == "start_speaking": - # 开始说话 - stamp = message.get("stamp") - if not stamp: - logger.warning("[Live Chat] start_speaking 缺少 stamp") - return - session.start_speaking(stamp) - - elif msg_type == "speaking_part": - # 音频片段 - audio_data_b64 = message.get("data") - if not audio_data_b64: - return - - # 解码 base64 - import base64 - - try: - audio_data = base64.b64decode(audio_data_b64) - session.add_audio_frame(audio_data) - except Exception as e: - logger.error(f"[Live Chat] 解码音频数据失败: {e}") - - elif msg_type == "end_speaking": - # 结束说话 - stamp = message.get("stamp") - if not stamp: - logger.warning("[Live Chat] end_speaking 缺少 stamp") - return - - audio_path, assemble_duration = await session.end_speaking(stamp) - if not audio_path: - await websocket.send_json({"t": "error", "data": "音频组装失败"}) - return - - # 处理音频:STT -> LLM -> TTS - await self._process_audio(session, audio_path, assemble_duration) - - elif msg_type == "interrupt": - # 用户打断 - session.should_interrupt = True - logger.info(f"[Live Chat] 用户打断: {session.username}") - - async def _process_audio( - self, session: LiveChatSession, audio_path: str, assemble_duration: float - ) -> None: - """处理音频:STT -> LLM -> 流式 TTS""" - try: - # 发送 WAV 组装耗时 - await websocket.send_json( - {"t": "metrics", "data": {"wav_assemble_time": assemble_duration}} - ) - wav_assembly_finish_time = time.time() - - session.is_processing = True - session.should_interrupt = False - - # 1. STT - 语音转文字 - ctx = self.plugin_manager.context - stt_provider = ctx.provider_manager.stt_provider_insts[0] - - if not stt_provider: - logger.error("[Live Chat] STT Provider 未配置") - await websocket.send_json({"t": "error", "data": "语音识别服务未配置"}) - return - - await websocket.send_json( - {"t": "metrics", "data": {"stt": stt_provider.meta().type}} - ) - - user_text = await stt_provider.get_text(audio_path) - if not user_text: - logger.warning("[Live Chat] STT 识别结果为空") - return - - logger.info(f"[Live Chat] STT 结果: {user_text}") - - await websocket.send_json( - { - "t": "user_msg", - "data": {"text": user_text, "ts": int(time.time() * 1000)}, - } - ) - - # 2. 构造消息事件并发送到 pipeline - # 使用 webchat queue 机制 - cid = session.conversation_id - queue = webchat_queue_mgr.get_or_create_queue(cid) - - message_id = str(uuid.uuid4()) - payload = { - "message_id": message_id, - "message": [{"type": "plain", "text": user_text}], # 直接发送文本 - "action_type": "live", # 标记为 live mode - } - - # 将消息放入队列 - await queue.put((session.username, cid, payload)) - - # 3. 等待响应并流式发送 TTS 音频 - back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, cid) - - bot_text = "" - audio_playing = False - - try: - while True: - if session.should_interrupt: - # 用户打断,停止处理 - logger.info("[Live Chat] 检测到用户打断") - await websocket.send_json({"t": "stop_play"}) - # 保存消息并标记为被打断 - await self._save_interrupted_message( - session, user_text, bot_text - ) - # 清空队列中未处理的消息 - while not back_queue.empty(): - try: - back_queue.get_nowait() - except asyncio.QueueEmpty: - break - break - - try: - result = await asyncio.wait_for(back_queue.get(), timeout=0.5) - except asyncio.TimeoutError: - continue - - if not result: - continue - - result_message_id = result.get("message_id") - if result_message_id != message_id: - logger.warning( - f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}" - ) - continue - - result_type = result.get("type") - result_chain_type = result.get("chain_type") - data = result.get("data", "") - - if result_chain_type == "agent_stats": - try: - stats = json.loads(data) - await websocket.send_json( - { - "t": "metrics", - "data": { - "llm_ttft": stats.get("time_to_first_token", 0), - "llm_total_time": stats.get("end_time", 0) - - stats.get("start_time", 0), - }, - } - ) - except Exception as e: - logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}") - continue - - if result_chain_type == "tts_stats": - try: - stats = json.loads(data) - await websocket.send_json( - { - "t": "metrics", - "data": stats, - } - ) - except Exception as e: - logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}") - continue - - if result_type == "plain": - # 普通文本消息 - bot_text += data - - elif result_type == "audio_chunk": - # 流式音频数据 - if not audio_playing: - audio_playing = True - logger.debug("[Live Chat] 开始播放音频流") - - # Calculate latency from wav assembly finish to first audio chunk - speak_to_first_frame_latency = ( - time.time() - wav_assembly_finish_time - ) - await websocket.send_json( - { - "t": "metrics", - "data": { - "speak_to_first_frame": speak_to_first_frame_latency - }, - } - ) - - text = result.get("text") - if text: - await websocket.send_json( - { - "t": "bot_text_chunk", - "data": {"text": text}, - } - ) - - # 发送音频数据给前端 - await websocket.send_json( - { - "t": "response", - "data": data, # base64 编码的音频数据 - } - ) - - elif result_type in ["complete", "end"]: - # 处理完成 - logger.info(f"[Live Chat] Bot 回复完成: {bot_text}") - - # 如果没有音频流,发送 bot 消息文本 - if not audio_playing: - await websocket.send_json( - { - "t": "bot_msg", - "data": { - "text": bot_text, - "ts": int(time.time() * 1000), - }, - } - ) - - # 发送结束标记 - await websocket.send_json({"t": "end"}) - - # 发送总耗时 - wav_to_tts_duration = time.time() - wav_assembly_finish_time - await websocket.send_json( - { - "t": "metrics", - "data": {"wav_to_tts_total_time": wav_to_tts_duration}, - } - ) - break - finally: - webchat_queue_mgr.remove_back_queue(message_id) - - except Exception as e: - logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True) - await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"}) - - finally: - session.is_processing = False - session.should_interrupt = False - - async def _save_interrupted_message( - self, session: LiveChatSession, user_text: str, bot_text: str - ) -> None: - """保存被打断的消息""" - interrupted_text = bot_text + " [用户打断]" - logger.info(f"[Live Chat] 保存打断消息: {interrupted_text}") - # 简单记录到日志,实际保存逻辑可以后续完善 - try: - timestamp = int(time.time() * 1000) - logger.info( - f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})" - ) - if bot_text: - logger.info( - f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})" - ) - except Exception as e: - logger.error(f"[Live Chat] 记录消息失败: {e}", exc_info=True) +__all__ = ["LiveChatRoute"] diff --git a/astrbot/dashboard/routes/log.py b/astrbot/dashboard/routes/log.py index e7eebef6e6..d3673726d5 100644 --- a/astrbot/dashboard/routes/log.py +++ b/astrbot/dashboard/routes/log.py @@ -1,30 +1,18 @@ -import asyncio -import json -import time -from collections.abc import AsyncGenerator from typing import cast -from quart import Response as QuartResponse -from quart import make_response, request - -from astrbot.core import LogBroker, logger +from astrbot.core import LogBroker +from astrbot.dashboard.fastapi_compat import Response as CompatResponse +from astrbot.dashboard.fastapi_compat import make_response, request +from astrbot.dashboard.services.log_service import LogService, LogServiceError from .route import Response, Route, RouteContext -def _format_log_sse(log: dict, ts: float) -> str: - """辅助函数:格式化 SSE 消息""" - payload = { - "type": "log", - **log, - } - return f"id: {ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" - - class LogRoute(Route): def __init__(self, context: RouteContext, log_broker: LogBroker) -> None: super().__init__(context) self.log_broker = log_broker + self.service = LogService(log_broker, self.config) self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"]) self.app.add_url_rule( "/api/log-history", @@ -42,51 +30,46 @@ def __init__(self, context: RouteContext, log_broker: LogBroker) -> None: methods=["POST"], ) - async def _replay_cached_logs( - self, last_event_id: str - ) -> AsyncGenerator[str, None]: - """辅助生成器:重放缓存的日志""" - try: - last_ts = float(last_event_id) - cached_logs = list(self.log_broker.log_cache) - - for log_item in cached_logs: - log_ts = float(log_item.get("time", 0)) + @staticmethod + def _ok(data=None, message: str | None = None): + return Response().ok(data=data, message=message).__dict__ - if log_ts > last_ts: - yield _format_log_sse(log_item, log_ts) + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - except ValueError: - pass - except Exception as e: - logger.error(f"Log SSE 补发历史错误: {e}") + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - async def log(self) -> QuartResponse: + async def _run(self, operation, *, result_as_message: bool = False): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + if result_as_message: + return self._ok(message=str(result)) + return self._ok(result) + except LogServiceError as exc: + return self._error(str(exc)) + + async def _run_json(self, operation, *, result_as_message: bool = False): + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run(invoke, result_as_message=result_as_message) + + async def log(self) -> CompatResponse: last_event_id = request.headers.get("Last-Event-ID") async def stream(): - queue = None - try: - if last_event_id: - async for event in self._replay_cached_logs(last_event_id): - yield event - - queue = self.log_broker.register() - while True: - message = await queue.get() - current_ts = message.get("time", time.time()) - yield _format_log_sse(message, current_ts) - - except asyncio.CancelledError: - pass - except Exception as e: - logger.error(f"Log SSE 连接错误: {e}") - finally: - if queue: - self.log_broker.unregister(queue) + async for event in self.service.stream_log_events(last_event_id): + yield event response = cast( - QuartResponse, + CompatResponse, await make_response( stream(), { @@ -102,43 +85,15 @@ async def stream(): async def log_history(self): """获取日志历史""" - try: - logs = list(self.log_broker.log_cache) - return ( - Response() - .ok( - data={ - "logs": logs, - }, - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取日志历史失败: {e}") - return Response().error(f"获取日志历史失败: {e}").__dict__ + return await self._run(self.service.get_log_history) async def get_trace_settings(self): """获取 Trace 设置""" - try: - trace_enable = self.config.get("trace_enable", True) - return Response().ok(data={"trace_enable": trace_enable}).__dict__ - except Exception as e: - logger.error(f"获取 Trace 设置失败: {e}") - return Response().error(f"获取 Trace 设置失败: {e}").__dict__ + return await self._run(self.service.get_trace_settings) async def update_trace_settings(self): """更新 Trace 设置""" - try: - data = await request.json - if data is None: - return Response().error("请求数据为空").__dict__ - - trace_enable = data.get("trace_enable") - if trace_enable is not None: - self.config["trace_enable"] = bool(trace_enable) - self.config.save_config() - - return Response().ok(message="Trace 设置已更新").__dict__ - except Exception as e: - logger.error(f"更新 Trace 设置失败: {e}") - return Response().error(f"更新 Trace 设置失败: {e}").__dict__ + return await self._run_json( + self.service.update_trace_settings_from_legacy_payload, + result_as_message=True, + ) diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py index 52b412b2b5..e1bf3d3933 100644 --- a/astrbot/dashboard/routes/open_api.py +++ b/astrbot/dashboard/routes/open_api.py @@ -1,28 +1,27 @@ -import asyncio -import hashlib -import json -from uuid import uuid4 +from typing import cast -from quart import g, request, websocket - -from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase -from astrbot.core.platform.message_session import MessageSesion -from astrbot.core.platform.sources.webchat.message_parts_helper import ( - build_message_chain_from_payload, - strip_message_parts_path_fields, - webchat_message_parts_have_content, +from astrbot.dashboard.fastapi_compat import ( + Response as CompatResponse, ) -from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.datetime_utils import to_utc_isoformat - -from .api_key import ALL_OPEN_API_SCOPES -from .chat import ( - BotMessageAccumulator, - ChatRoute, - collect_plain_text_from_message_parts, +from astrbot.dashboard.fastapi_compat import ( + make_response, + request, + send_file, + websocket, +) +from astrbot.dashboard.services.chat_service import ( + ChatService, + ChatServiceError, + extract_web_search_refs, +) +from astrbot.dashboard.services.open_api_service import ( + OpenApiService, + OpenApiServiceError, + OpenApiWebSocketChatBridge, ) + from .route import Response, Route, RouteContext @@ -32,13 +31,14 @@ def __init__( context: RouteContext, db: BaseDatabase, core_lifecycle: AstrBotCoreLifecycle, - chat_route: ChatRoute, + chat_service: ChatService, + *, + register_routes: bool = True, ) -> None: super().__init__(context) - self.db = db self.core_lifecycle = core_lifecycle - self.platform_manager = core_lifecycle.platform_manager - self.chat_route = chat_route + self.chat_service = chat_service + self.service = OpenApiService(db, core_lifecycle) self.routes = { "/v1/chat": ("POST", self.chat_send), @@ -51,151 +51,85 @@ def __init__( "/v1/im/message": ("POST", self.send_message), "/v1/im/bots": ("GET", self.get_bots), } - self.register_routes() - self.app.websocket("/api/v1/chat/ws")(self.chat_ws) + if register_routes: + self.register_routes() + self.app.websocket("/api/v1/chat/ws")(self.chat_ws) @staticmethod - def _resolve_open_username( - raw_username: str | None, - ) -> tuple[str | None, str | None]: - if raw_username is None: - return None, "Missing key: username" - username = str(raw_username).strip() - if not username: - return None, "username is empty" - return username, None - - def _get_chat_config_list(self) -> list[dict]: - conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list() - - result = [] - for conf_info in conf_list: - conf_id = str(conf_info.get("id", "")).strip() - result.append( - { - "id": conf_id, - "name": str(conf_info.get("name", "")).strip(), - "path": str(conf_info.get("path", "")).strip(), - "is_default": conf_id == "default", - } - ) - return result - - def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]: - raw_config_id = post_data.get("config_id") - raw_config_name = post_data.get("config_name") - config_id = str(raw_config_id).strip() if raw_config_id is not None else "" - config_name = ( - str(raw_config_name).strip() if raw_config_name is not None else "" - ) - - if not config_id and not config_name: - return None, None - - conf_list = self._get_chat_config_list() - conf_map = {item["id"]: item for item in conf_list} + def _ok(data=None): + return Response().ok(data=data).__dict__ - if config_id: - if config_id not in conf_map: - return None, f"config_id not found: {config_id}" - return config_id, None + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - if not config_name: - return None, "config_name is empty" + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - matched = [item for item in conf_list if item["name"] == config_name] - if not matched: - return None, f"config_name not found: {config_name}" - if len(matched) > 1: - return ( - None, - f"config_name is ambiguous, please use config_id: {config_name}", - ) + async def _run(self, operation): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except (OpenApiServiceError, ChatServiceError) as exc: + return self._error(str(exc)) - return matched[0]["id"], None + async def _run_json(self, operation): + async def invoke(): + data = await self._json_body() + return operation(data) - async def _ensure_chat_session( - self, - username: str, - session_id: str, - ) -> str | None: - session = await self.db.get_platform_session_by_id(session_id) - if session: - if session.creator != username: - return "session_id belongs to another username" - return None + return await self._run(invoke) - try: - await self.db.create_platform_session( - creator=username, - platform_id="webchat", - session_id=session_id, - is_group=0, - ) - except Exception as e: - # Handle rare race when same session_id is created concurrently. - existing = await self.db.get_platform_session_by_id(session_id) - if existing and existing.creator == username: - return None - logger.error("Failed to create chat session %s: %s", session_id, e) - return f"Failed to create session: {e}" - - return None + def _get_chat_config_list(self) -> list[dict]: + return self.service.get_chat_config_list() async def chat_send(self): post_data = await request.get_json(silent=True) or {} - effective_username, username_err = self._resolve_open_username( - post_data.get("username") - ) - if username_err: - return Response().error(username_err).__dict__ - if not effective_username: - return Response().error("Invalid username").__dict__ + try: + ( + effective_username, + session_id, + config_id, + ) = await self.service.prepare_chat_send( + post_data, + self._get_chat_config_list(), + ) + except OpenApiServiceError as exc: + return self._error(str(exc)) - raw_session_id = post_data.get("session_id", post_data.get("conversation_id")) - session_id = str(raw_session_id).strip() if raw_session_id is not None else "" - if not session_id: - session_id = str(uuid4()) - post_data["session_id"] = session_id - ensure_session_err = await self._ensure_chat_session( - effective_username, - session_id, + config_err = await self.service.update_session_config_route( + username=effective_username, + session_id=session_id, + config_id=config_id, ) - if ensure_session_err: - return Response().error(ensure_session_err).__dict__ + if config_err: + return self._error(config_err) - config_id, resolve_err = self._resolve_chat_config_id(post_data) - if resolve_err: - return Response().error(resolve_err).__dict__ + return await self._chat_response(effective_username, post_data) - original_username = g.get("username", "guest") - g.username = effective_username - if config_id: - umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}" - try: - if config_id == "default": - await self.core_lifecycle.umop_config_router.delete_route(umo) - else: - await self.core_lifecycle.umop_config_router.update_route( - umo, config_id - ) - except Exception as e: - logger.error( - "Failed to update chat config route for %s with %s: %s", - umo, - config_id, - e, - exc_info=True, - ) - return ( - Response() - .error(f"Failed to update chat config route: {e}") - .__dict__ - ) + async def _chat_response(self, username: str, post_data: dict): try: - return await self.chat_route.chat(post_data=post_data) - finally: - g.username = original_username + stream = await self.chat_service.build_chat_stream(username, post_data) + except ChatServiceError as exc: + return self._error(str(exc)) + response = cast( + CompatResponse, + await make_response( + stream, + { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + }, + ), + ) + response.timeout = None + return response @staticmethod def _extract_ws_api_key() -> str | None: @@ -213,451 +147,71 @@ def _extract_ws_api_key() -> str | None: return auth_header.removeprefix("ApiKey ").strip() return None - async def _authenticate_chat_ws_api_key(self) -> tuple[bool, str | None]: - raw_key = self._extract_ws_api_key() - if not raw_key: - return False, "Missing API key" - - key_hash = hashlib.pbkdf2_hmac( - "sha256", - raw_key.encode("utf-8"), - b"astrbot_api_key", - 100_000, - ).hex() - api_key = await self.db.get_active_api_key_by_hash(key_hash) - if not api_key: - return False, "Invalid API key" - - if isinstance(api_key.scopes, list): - scopes = api_key.scopes - else: - scopes = list(ALL_OPEN_API_SCOPES) - - if "*" not in scopes and "chat" not in scopes: - return False, "Insufficient API key scope" - - await self.db.touch_api_key(api_key.key_id) - return True, None - - async def _send_chat_ws_error(self, message: str, code: str) -> None: - await websocket.send_json( - { - "type": "error", - "code": code, - "data": message, - } - ) - - async def _update_session_config_route( + async def _insert_webchat_user_message( self, - *, - username: str, session_id: str, - config_id: str | None, - ) -> str | None: - if not config_id: - return None - - umo = f"webchat:FriendMessage:webchat!{username}!{session_id}" - try: - if config_id == "default": - await self.core_lifecycle.umop_config_router.delete_route(umo) - else: - await self.core_lifecycle.umop_config_router.update_route( - umo, config_id - ) - except Exception as e: - logger.error( - "Failed to update chat config route for %s with %s: %s", - umo, - config_id, - e, - exc_info=True, - ) - return f"Failed to update chat config route: {e}" - return None - - async def _handle_chat_ws_send(self, post_data: dict) -> None: - effective_username, username_err = self._resolve_open_username( - post_data.get("username") - ) - if username_err or not effective_username: - await self._send_chat_ws_error( - username_err or "Invalid username", "BAD_USER" - ) - return - - message = post_data.get("message") - if message is None: - await self._send_chat_ws_error("Missing key: message", "INVALID_MESSAGE") - return - - raw_session_id = post_data.get("session_id", post_data.get("conversation_id")) - session_id = str(raw_session_id).strip() if raw_session_id is not None else "" - if not session_id: - session_id = str(uuid4()) - - ensure_session_err = await self._ensure_chat_session( - effective_username, - session_id, - ) - if ensure_session_err: - await self._send_chat_ws_error(ensure_session_err, "SESSION_ERROR") - return - - config_id, resolve_err = self._resolve_chat_config_id(post_data) - if resolve_err: - await self._send_chat_ws_error(resolve_err, "CONFIG_ERROR") - return - - config_err = await self._update_session_config_route( - username=effective_username, + effective_username: str, + message_parts: list, + ) -> None: + await self.service.insert_webchat_user_message( session_id=session_id, - config_id=config_id, + effective_username=effective_username, + message_parts=message_parts, ) - if config_err: - await self._send_chat_ws_error(config_err, "CONFIG_ERROR") - return - - message_parts = await self.chat_route._build_user_message_parts(message) - if not webchat_message_parts_have_content(message_parts): - await self._send_chat_ws_error( - "Message content is empty (reply only is not allowed)", - "INVALID_MESSAGE", - ) - return - - message_id = str(post_data.get("message_id") or uuid4()) - selected_provider = post_data.get("selected_provider") - selected_model = post_data.get("selected_model") - enable_streaming = post_data.get("enable_streaming", True) - - back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id) - try: - chat_queue = webchat_queue_mgr.get_or_create_queue(session_id) - await chat_queue.put( - ( - effective_username, - session_id, - { - "message": message_parts, - "selected_provider": selected_provider, - "selected_model": selected_model, - "enable_streaming": enable_streaming, - "message_id": message_id, - }, - ) - ) - - message_parts_for_storage = strip_message_parts_path_fields(message_parts) - await self.chat_route.platform_history_mgr.insert( - platform_id="webchat", - user_id=session_id, - content={"type": "user", "message": message_parts_for_storage}, - sender_id=effective_username, - sender_name=effective_username, - ) - - await websocket.send_json( - { - "type": "session_id", - "data": None, - "session_id": session_id, - "message_id": message_id, - } - ) - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - while True: - try: - result = await asyncio.wait_for(back_queue.get(), timeout=1) - except asyncio.TimeoutError: - continue - - if not result: - continue - - if "message_id" in result and result["message_id"] != message_id: - logger.warning("openapi ws stream message_id mismatch") - continue - - result_text = result.get("data", "") - msg_type = result.get("type") - streaming = result.get("streaming", False) - chain_type = result.get("chain_type") - - if chain_type == "agent_stats": - try: - stats_info = { - "type": "agent_stats", - "data": json.loads(result_text), - } - await websocket.send_json(stats_info) - agent_stats = stats_info["data"] - except Exception: - pass - continue - - await websocket.send_json(result) - - if msg_type == "plain": - message_accumulator.add_plain( - result_text, - chain_type=chain_type, - streaming=streaming, - ) - elif msg_type == "image": - filename = str(result_text).replace("[IMAGE]", "") - part = await self.chat_route._create_attachment_from_file( - filename, "image" - ) - message_accumulator.add_attachment(part) - elif msg_type == "record": - filename = str(result_text).replace("[RECORD]", "") - part = await self.chat_route._create_attachment_from_file( - filename, "record" - ) - message_accumulator.add_attachment(part) - elif msg_type == "file": - filename = str(result_text).replace("[FILE]", "") - part = await self.chat_route._create_attachment_from_file( - filename, "file" - ) - message_accumulator.add_attachment(part) - elif msg_type == "video": - filename = str(result_text).replace("[VIDEO]", "") - part = await self.chat_route._create_attachment_from_file( - filename, "video" - ) - message_accumulator.add_attachment(part) - - should_save = False - if msg_type == "end": - should_save = bool( - message_accumulator.has_content() or refs or agent_stats - ) - elif (streaming and msg_type == "complete") or not streaming: - if chain_type not in ("tool_call", "tool_call_result"): - should_save = True - - if should_save: - message_parts_to_save = message_accumulator.build_message_parts( - include_pending_tool_calls=True - ) - plain_text = collect_plain_text_from_message_parts( - message_parts_to_save - ) - try: - refs = self.chat_route._extract_web_search_refs( - plain_text, - message_parts_to_save, - ) - except Exception as e: - logger.exception( - f"Open API WS failed to extract web search refs: {e}", - exc_info=True, - ) - - saved_record = await self.chat_route._save_bot_message( - session_id, - message_parts_to_save, - agent_stats, - refs, - ) - if saved_record: - await websocket.send_json( - { - "type": "message_saved", - "data": { - "id": saved_record.id, - "created_at": to_utc_isoformat( - saved_record.created_at - ), - }, - "session_id": session_id, - } - ) - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - if msg_type == "end": - break - except Exception as e: - logger.exception(f"Open API WS chat failed: {e}", exc_info=True) - await self._send_chat_ws_error( - f"Failed to process message: {e}", "PROCESSING_ERROR" - ) - finally: - webchat_queue_mgr.remove_back_queue(message_id) + def _build_chat_ws_bridge(self) -> OpenApiWebSocketChatBridge: + return OpenApiWebSocketChatBridge( + build_user_message_parts=self.chat_service.build_user_message_parts, + create_attachment_from_file=self.chat_service.create_attachment_from_file, + extract_web_search_refs=extract_web_search_refs, + insert_user_message=self._insert_webchat_user_message, + save_bot_message=self.chat_service.save_bot_message, + ) async def chat_ws(self) -> None: - authed, auth_err = await self._authenticate_chat_ws_api_key() - if not authed: - await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED") - await websocket.close(1008, auth_err or "Unauthorized") - return - - try: - while True: - message = await websocket.receive_json() - if not isinstance(message, dict): - await self._send_chat_ws_error( - "message must be an object", - "INVALID_MESSAGE", - ) - continue - - msg_type = message.get("t", "send") - if msg_type == "ping": - await websocket.send_json({"type": "pong"}) - continue - if msg_type != "send": - await self._send_chat_ws_error( - f"Unsupported message type: {msg_type}", - "INVALID_MESSAGE", - ) - continue - - await self._handle_chat_ws_send(message) - except Exception as e: - logger.debug("Open API WS connection closed: %s", e) + await self.service.run_chat_websocket( + raw_api_key=self._extract_ws_api_key(), + receive_json=websocket.receive_json, + send_json=websocket.send_json, + close=websocket.close, + conf_list=self._get_chat_config_list(), + chat_bridge=self._build_chat_ws_bridge(), + ) async def openapi_upload_file(self): - return await self.chat_route.post_file() - - async def openapi_get_file(self): - return await self.chat_route.get_attachment() - - async def get_chat_sessions(self): - username, username_err = self._resolve_open_username( - request.args.get("username") + return await self._run( + self.chat_service.save_uploaded_file_from_legacy_files(await request.files) ) - if username_err: - return Response().error(username_err).__dict__ - - assert username is not None # for type checker + async def openapi_get_file(self): try: - page = int(request.args.get("page", 1)) - page_size = int(request.args.get("page_size", 20)) - except ValueError: - return Response().error("page and page_size must be integers").__dict__ - - if page < 1: - page = 1 - if page_size < 1: - page_size = 1 - if page_size > 100: - page_size = 100 - - platform_id = request.args.get("platform_id") - - ( - paginated_sessions, - total, - ) = await self.db.get_platform_sessions_by_creator_paginated( - creator=username, - platform_id=platform_id, - page=page, - page_size=page_size, - exclude_project_sessions=True, - ) - - sessions_data = [] - for item in paginated_sessions: - session = item["session"] - sessions_data.append( - { - "session_id": session.session_id, - "platform_id": session.platform_id, - "creator": session.creator, - "display_name": session.display_name, - "is_group": session.is_group, - "created_at": to_utc_isoformat(session.created_at), - "updated_at": to_utc_isoformat(session.updated_at), - } + ( + file_path, + mimetype, + ) = await self.chat_service.resolve_attachment_file_from_legacy_query( + request.args.get("attachment_id") ) + return await send_file(file_path, mimetype=mimetype) + except ChatServiceError as exc: + return self._error(str(exc)) + except (FileNotFoundError, OSError): + return self._error("File access error") - return ( - Response() - .ok( - data={ - "sessions": sessions_data, - "page": page, - "page_size": page_size, - "total": total, - } + async def get_chat_sessions(self): + return await self._run( + self.service.get_chat_sessions_from_legacy_query( + username=request.args.get("username"), + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 20), + platform_id=request.args.get("platform_id"), ) - .__dict__ ) async def get_chat_configs(self): - conf_list = self._get_chat_config_list() - return Response().ok(data={"configs": conf_list}).__dict__ - - async def _build_message_chain_from_payload( - self, - message_payload: str | list, - ): - return await build_message_chain_from_payload( - message_payload, - get_attachment_by_id=self.db.get_attachment_by_id, - strict=True, - ) + return self._ok({"configs": self._get_chat_config_list()}) async def send_message(self): - post_data = await request.json or {} - message_payload = post_data.get("message", {}) - umo = post_data.get("umo") - - if message_payload is None: - return Response().error("Missing key: message").__dict__ - if not umo: - return Response().error("Missing key: umo").__dict__ - - try: - session = MessageSesion.from_str(str(umo)) - except Exception as e: - return Response().error(f"Invalid umo: {e}").__dict__ - - platform_id = session.platform_name - platform_inst = next( - ( - inst - for inst in self.platform_manager.platform_insts - if inst.meta().id == platform_id - ), - None, - ) - if not platform_inst: - return ( - Response() - .error(f"Bot not found or not running for platform: {platform_id}") - .__dict__ - ) - - try: - message_chain = await self._build_message_chain_from_payload( - message_payload - ) - await platform_inst.send_by_session(session, message_chain) - return Response().ok().__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"Open API send_message failed: {e}", exc_info=True) - return Response().error(f"Failed to send message: {e}").__dict__ + return await self._run_json(self.service.send_message) async def get_bots(self): - bot_ids = [] - for platform in self.core_lifecycle.astrbot_config.get("platform", []): - platform_id = platform.get("id") if isinstance(platform, dict) else None - if ( - isinstance(platform_id, str) - and platform_id - and platform_id not in bot_ids - ): - bot_ids.append(platform_id) - return Response().ok(data={"bot_ids": bot_ids}).__dict__ + return await self._run(self.service.get_bots) diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index 8a805d4322..b6afc1ee52 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -1,11 +1,10 @@ -import traceback - -from quart import request - from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.db import BaseDatabase -from astrbot.core.sentinels import NOT_GIVEN +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.persona_service import ( + PersonaService, + PersonaServiceError, +) from .route import Response, Route, RouteContext @@ -14,7 +13,7 @@ class PersonaRoute(Route): def __init__( self, context: RouteContext, - db_helper: BaseDatabase, + db_helper, core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) @@ -26,7 +25,6 @@ def __init__( "/persona/delete": ("POST", self.delete_persona), "/persona/move": ("POST", self.move_persona), "/persona/reorder": ("POST", self.reorder_items), - # Folder routes "/persona/folder/list": ("GET", self.list_folders), "/persona/folder/tree": ("GET", self.get_folder_tree), "/persona/folder/detail": ("POST", self.get_folder_detail), @@ -34,464 +32,129 @@ def __init__( "/persona/folder/update": ("POST", self.update_folder), "/persona/folder/delete": ("POST", self.delete_folder), } - self.db_helper = db_helper - self.persona_mgr = core_lifecycle.persona_mgr + self.service = PersonaService(core_lifecycle) self.register_routes() + @staticmethod + def _ok(data): + return Response().ok(data).__dict__ + + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ + + @staticmethod + async def _run(self, operation, *, label: str): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except (PersonaServiceError, ValueError) as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("%s: %s", label, exc, exc_info=True) + return self._error(f"{label}: {exc!s}") + async def list_personas(self): """获取所有人格列表""" - try: - # 支持按文件夹筛选 - folder_id = request.args.get("folder_id") - if folder_id is not None: - personas = await self.persona_mgr.get_personas_by_folder( - folder_id if folder_id else None - ) - else: - personas = await self.persona_mgr.get_all_personas() - return ( - Response() - .ok( - [ - { - "persona_id": persona.persona_id, - "system_prompt": persona.system_prompt, - "begin_dialogs": persona.begin_dialogs or [], - "tools": persona.tools, - "skills": persona.skills, - "custom_error_message": persona.custom_error_message, - "folder_id": persona.folder_id, - "sort_order": persona.sort_order, - "created_at": persona.created_at.isoformat() - if persona.created_at - else None, - "updated_at": persona.updated_at.isoformat() - if persona.updated_at - else None, - } - for persona in personas - ], - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取人格列表失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取人格列表失败: {e!s}").__dict__ + return await self._run( + lambda: self.service.list_personas_from_legacy_query( + folder_id=request.args.get("folder_id"), + has_folder_id="folder_id" in request.args, + ), + label="获取人格列表失败", + ) async def get_persona_detail(self): """获取指定人格的详细信息""" - try: - data = await request.get_json() - persona_id = data.get("persona_id") - - if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ - - persona = await self.persona_mgr.get_persona(persona_id) - if not persona: - return Response().error("人格不存在").__dict__ - - return ( - Response() - .ok( - { - "persona_id": persona.persona_id, - "system_prompt": persona.system_prompt, - "begin_dialogs": persona.begin_dialogs or [], - "tools": persona.tools, - "skills": persona.skills, - "custom_error_message": persona.custom_error_message, - "folder_id": persona.folder_id, - "sort_order": persona.sort_order, - "created_at": persona.created_at.isoformat() - if persona.created_at - else None, - "updated_at": persona.updated_at.isoformat() - if persona.updated_at - else None, - }, - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取人格详情失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取人格详情失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.get_persona_detail(data), + label="获取人格详情失败", + ) async def create_persona(self): """创建新人格""" - try: - data = await request.get_json() - persona_id = data.get("persona_id", "").strip() - system_prompt = data.get("system_prompt", "").strip() - begin_dialogs = data.get("begin_dialogs", []) - tools = data.get("tools") - skills = data.get("skills") - custom_error_message = data.get("custom_error_message") - folder_id = data.get("folder_id") # None 表示根目录 - sort_order = data.get("sort_order", 0) - - if not persona_id: - return Response().error("人格ID不能为空").__dict__ - - if not system_prompt: - return Response().error("系统提示词不能为空").__dict__ - - if custom_error_message is not None: - if not isinstance(custom_error_message, str): - return Response().error("自定义报错回复信息必须是字符串").__dict__ - custom_error_message = custom_error_message.strip() or None - - # 验证 begin_dialogs 格式 - if begin_dialogs and len(begin_dialogs) % 2 != 0: - return ( - Response() - .error("预设对话数量必须为偶数(用户和助手轮流对话)") - .__dict__ - ) - - persona = await self.persona_mgr.create_persona( - persona_id=persona_id, - system_prompt=system_prompt, - begin_dialogs=begin_dialogs if begin_dialogs else None, - tools=tools if tools else None, - skills=skills if skills else None, - custom_error_message=custom_error_message, - folder_id=folder_id, - sort_order=sort_order, - ) - - return ( - Response() - .ok( - { - "message": "人格创建成功", - "persona": { - "persona_id": persona.persona_id, - "system_prompt": persona.system_prompt, - "begin_dialogs": persona.begin_dialogs or [], - "tools": persona.tools or [], - "skills": persona.skills or [], - "custom_error_message": persona.custom_error_message, - "folder_id": persona.folder_id, - "sort_order": persona.sort_order, - "created_at": persona.created_at.isoformat() - if persona.created_at - else None, - "updated_at": persona.updated_at.isoformat() - if persona.updated_at - else None, - }, - }, - ) - .__dict__ - ) - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"创建人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"创建人格失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.create_persona(data), + label="创建人格失败", + ) async def update_persona(self): """更新人格信息""" - try: - data = await request.get_json() - persona_id = data.get("persona_id") - system_prompt = data.get("system_prompt") - begin_dialogs = data.get("begin_dialogs") - has_tools = "tools" in data - tools = data.get("tools") - has_skills = "skills" in data - skills = data.get("skills") - has_custom_error_message = "custom_error_message" in data - custom_error_message = data.get("custom_error_message") - - if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ - - if has_custom_error_message: - if custom_error_message is not None and not isinstance( - custom_error_message, str - ): - return Response().error("自定义报错回复信息必须是字符串").__dict__ - if isinstance(custom_error_message, str): - custom_error_message = custom_error_message.strip() or None - - # 验证 begin_dialogs 格式 - if begin_dialogs is not None and len(begin_dialogs) % 2 != 0: - return ( - Response() - .error("预设对话数量必须为偶数(用户和助手轮流对话)") - .__dict__ - ) - - update_kwargs = { - "persona_id": persona_id, - "system_prompt": system_prompt, - "begin_dialogs": begin_dialogs, - } - if has_tools: - update_kwargs["tools"] = tools - if has_skills: - update_kwargs["skills"] = skills - if has_custom_error_message: - update_kwargs["custom_error_message"] = custom_error_message - - await self.persona_mgr.update_persona(**update_kwargs) - - return Response().ok({"message": "人格更新成功"}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"更新人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新人格失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.update_persona(data), + label="更新人格失败", + ) async def delete_persona(self): """删除人格""" - try: - data = await request.get_json() - persona_id = data.get("persona_id") - - if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ - - await self.persona_mgr.delete_persona(persona_id) - - return Response().ok({"message": "人格删除成功"}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"删除人格失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.delete_persona(data), + label="删除人格失败", + ) async def move_persona(self): """移动人格到指定文件夹""" - try: - data = await request.get_json() - persona_id = data.get("persona_id") - folder_id = data.get("folder_id") # None 表示移动到根目录 - - if not persona_id: - return Response().error("缺少必要参数: persona_id").__dict__ - - await self.persona_mgr.move_persona_to_folder(persona_id, folder_id) - - return Response().ok({"message": "人格移动成功"}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(f"移动人格失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"移动人格失败: {e!s}").__dict__ - - # ==== - # Folder Routes - # ==== + data = await request.get_json() + return await self._run( + self.service.move_persona(data), + label="移动人格失败", + ) async def list_folders(self): """获取文件夹列表""" - try: - parent_id = request.args.get("parent_id") - # 空字符串视为 None(根目录) - if parent_id == "": - parent_id = None - folders = await self.persona_mgr.get_folders(parent_id) - return ( - Response() - .ok( - [ - { - "folder_id": folder.folder_id, - "name": folder.name, - "parent_id": folder.parent_id, - "description": folder.description, - "sort_order": folder.sort_order, - "created_at": folder.created_at.isoformat() - if folder.created_at - else None, - "updated_at": folder.updated_at.isoformat() - if folder.updated_at - else None, - } - for folder in folders - ], - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取文件夹列表失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取文件夹列表失败: {e!s}").__dict__ + return await self._run( + lambda: self.service.list_folders_from_legacy_query( + request.args.get("parent_id") + ), + label="获取文件夹列表失败", + ) async def get_folder_tree(self): """获取文件夹树形结构""" - try: - tree = await self.persona_mgr.get_folder_tree() - return Response().ok(tree).__dict__ - except Exception as e: - logger.error(f"获取文件夹树失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取文件夹树失败: {e!s}").__dict__ + return await self._run(self.service.get_folder_tree, label="获取文件夹树失败") async def get_folder_detail(self): """获取指定文件夹的详细信息""" - try: - data = await request.get_json() - folder_id = data.get("folder_id") - - if not folder_id: - return Response().error("缺少必要参数: folder_id").__dict__ - - folder = await self.persona_mgr.get_folder(folder_id) - if not folder: - return Response().error("文件夹不存在").__dict__ - - return ( - Response() - .ok( - { - "folder_id": folder.folder_id, - "name": folder.name, - "parent_id": folder.parent_id, - "description": folder.description, - "sort_order": folder.sort_order, - "created_at": folder.created_at.isoformat() - if folder.created_at - else None, - "updated_at": folder.updated_at.isoformat() - if folder.updated_at - else None, - }, - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取文件夹详情失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"获取文件夹详情失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.get_folder_detail(data), + label="获取文件夹详情失败", + ) async def create_folder(self): """创建文件夹""" - try: - data = await request.get_json() - name = data.get("name", "").strip() - parent_id = data.get("parent_id") - description = data.get("description") - sort_order = data.get("sort_order", 0) - - if not name: - return Response().error("文件夹名称不能为空").__dict__ - - folder = await self.persona_mgr.create_folder( - name=name, - parent_id=parent_id, - description=description, - sort_order=sort_order, - ) - - return ( - Response() - .ok( - { - "message": "文件夹创建成功", - "folder": { - "folder_id": folder.folder_id, - "name": folder.name, - "parent_id": folder.parent_id, - "description": folder.description, - "sort_order": folder.sort_order, - "created_at": folder.created_at.isoformat() - if folder.created_at - else None, - "updated_at": folder.updated_at.isoformat() - if folder.updated_at - else None, - }, - }, - ) - .__dict__ - ) - except Exception as e: - logger.error(f"创建文件夹失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"创建文件夹失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.create_folder(data), + label="创建文件夹失败", + ) async def update_folder(self): """更新文件夹信息""" - try: - data = await request.get_json() - folder_id = data.get("folder_id") - name = data.get("name") - parent_id = data.get("parent_id") if "parent_id" in data else NOT_GIVEN - description = ( - data.get("description") if "description" in data else NOT_GIVEN - ) - sort_order = data.get("sort_order") - - if not folder_id: - return Response().error("缺少必要参数: folder_id").__dict__ - - await self.persona_mgr.update_folder( - folder_id=folder_id, - name=name, - parent_id=parent_id, - description=description, - sort_order=sort_order, - ) - - return Response().ok({"message": "文件夹更新成功"}).__dict__ - except Exception as e: - logger.error(f"更新文件夹失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新文件夹失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.update_folder(data), + label="更新文件夹失败", + ) async def delete_folder(self): """删除文件夹""" - try: - data = await request.get_json() - folder_id = data.get("folder_id") - - if not folder_id: - return Response().error("缺少必要参数: folder_id").__dict__ - - await self.persona_mgr.delete_folder(folder_id) - - return Response().ok({"message": "文件夹删除成功"}).__dict__ - except Exception as e: - logger.error(f"删除文件夹失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"删除文件夹失败: {e!s}").__dict__ + data = await request.get_json() + return await self._run( + self.service.delete_folder(data), + label="删除文件夹失败", + ) async def reorder_items(self): - """批量更新排序顺序 - - 请求体格式: - { - "items": [ - {"id": "persona_id_1", "type": "persona", "sort_order": 0}, - {"id": "persona_id_2", "type": "persona", "sort_order": 1}, - {"id": "folder_id_1", "type": "folder", "sort_order": 0}, - ... - ] - } - """ - try: - data = await request.get_json() - items = data.get("items", []) - - if not items: - return Response().error("items 不能为空").__dict__ - - # 验证每个 item 的格式 - for item in items: - if not all(k in item for k in ("id", "type", "sort_order")): - return ( - Response() - .error("每个 item 必须包含 id, type, sort_order 字段") - .__dict__ - ) - if item["type"] not in ("persona", "folder"): - return ( - Response() - .error("type 字段必须是 'persona' 或 'folder'") - .__dict__ - ) - - await self.persona_mgr.batch_update_sort_order(items) - - return Response().ok({"message": "排序更新成功"}).__dict__ - except Exception as e: - logger.error(f"更新排序失败: {e!s}\n{traceback.format_exc()}") - return Response().error(f"更新排序失败: {e!s}").__dict__ + """批量更新排序顺序""" + data = await request.get_json() + return await self._run( + self.service.reorder_items(data), + label="更新排序失败", + ) diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py index e302658584..24956a3e10 100644 --- a/astrbot/dashboard/routes/platform.py +++ b/astrbot/dashboard/routes/platform.py @@ -1,39 +1,20 @@ -"""统一 Webhook 路由 +"""Unified webhook routes. -提供统一的 webhook 回调入口,支持多个平台使用同一端口接收回调。 +Provides a unified webhook callback entrypoint for multiple platforms. """ -import secrets -import string - -from quart import request - -from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.platform import Platform -from astrbot.core.platform.sources.dingtalk.app_registration import ( - poll_dingtalk_app_registration_once, - request_dingtalk_app_registration, -) -from astrbot.core.platform.sources.lark.app_registration import ( - poll_app_registration_once, - request_app_registration, -) -from astrbot.core.platform.sources.lark.bot_info import request_lark_bot_info -from astrbot.core.platform.sources.weixin_oc.login_registration import ( - poll_weixin_oc_login_once, - request_weixin_oc_login_qr, +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.platform_service import ( + PlatformService, + PlatformServiceError, ) from .route import Response, Route, RouteContext -def _random_platform_id_suffix() -> str: - return "_" + "".join(secrets.choice(string.ascii_lowercase) for _ in range(4)) - - class PlatformRoute(Route): - """统一 Webhook 路由""" + """Unified webhook route.""" def __init__( self, @@ -41,21 +22,17 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle - self.platform_manager = core_lifecycle.platform_manager + self.service = PlatformService(core_lifecycle) self._register_webhook_routes() def _register_webhook_routes(self) -> None: - """注册 webhook 路由""" - # 统一 webhook 入口,支持 GET 和 POST self.app.add_url_rule( "/api/platform/webhook/", view_func=self.unified_webhook_callback, methods=["GET", "POST"], ) - # 平台统计信息接口 self.app.add_url_rule( "/api/platform/stats", view_func=self.get_platform_stats, @@ -68,218 +45,37 @@ def _register_webhook_routes(self) -> None: methods=["POST"], ) - async def unified_webhook_callback(self, webhook_uuid: str): - """统一 webhook 回调入口 - - Args: - webhook_uuid: 平台配置中的 webhook_uuid + @staticmethod + def _ok(data=None): + return Response().ok(data).__dict__ - Returns: - 根据平台适配器返回相应的响应 - """ - # 根据 webhook_uuid 查找对应的平台 - platform_adapter = self._find_platform_by_uuid(webhook_uuid) + @staticmethod + def _error(exc: PlatformServiceError): + return Response().error(str(exc)).__dict__, exc.status_code - if not platform_adapter: - logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台") - return Response().error("未找到对应平台").__dict__, 404 - - # 调用平台适配器的 webhook_callback 方法 + async def _run(self, operation): try: - result = await platform_adapter.webhook_callback(request) - return result - except NotImplementedError: - logger.error( - f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法" - ) - return Response().error("平台未支持统一 Webhook 模式").__dict__, 500 - except Exception as e: - logger.error(f"处理 webhook 回调时发生错误: {e}", exc_info=True) - return Response().error("处理回调失败").__dict__, 500 - - def _find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None: - """根据 webhook_uuid 查找对应的平台适配器 + return self._ok(await operation()) + except PlatformServiceError as exc: + return self._error(exc) - Args: - webhook_uuid: webhook UUID + async def _run_sync(self, operation): + try: + return self._ok(operation()) + except PlatformServiceError as exc: + return self._error(exc) - Returns: - 平台适配器实例,未找到则返回 None - """ - for platform in self.platform_manager.platform_insts: - if platform.config.get("webhook_uuid") == webhook_uuid: - if platform.unified_webhook(): - return platform - return None + async def unified_webhook_callback(self, webhook_uuid: str): + return await self._run( + lambda: self.service.handle_webhook_callback(webhook_uuid, request) + ) async def get_platform_stats(self): - """获取所有平台的统计信息 - - Returns: - 包含平台统计信息的响应 - """ - try: - stats = self.platform_manager.get_all_stats() - return Response().ok(stats).__dict__ - except Exception as e: - logger.error(f"获取平台统计信息失败: {e}", exc_info=True) - return Response().error(f"获取统计信息失败: {e}").__dict__, 500 + return await self._run_sync(self.service.get_platform_stats) async def handle_platform_registration(self, platform_type: str): """Handle dashboard one-click platform registration actions.""" - try: - payload = await request.get_json(silent=True) or {} - action = str(payload.get("action", "")).strip().lower() - if not action: - return Response().error("Missing action").__dict__, 400 - - platform_config = payload.get("platform_config") - if not isinstance(platform_config, dict): - platform_config = {} - - if platform_type == "lark": - return await self._handle_lark_registration( - action, - payload, - platform_config, - ) - if platform_type == "weixin_oc": - return await self._handle_weixin_oc_registration( - action, - payload, - platform_config, - ) - if platform_type == "dingtalk": - return await self._handle_dingtalk_registration(action, payload) - - return Response().error( - f"Unsupported platform registration: {platform_type}" - ).__dict__, 404 - except Exception as e: - logger.error(f"处理平台一键创建请求失败: {e}", exc_info=True) - return Response().error(str(e)).__dict__, 500 - - async def _handle_lark_registration( - self, - action: str, - payload: dict, - platform_config: dict, - ): - domain = str(platform_config.get("domain") or "").strip() - - if action == "start": - registration = await request_app_registration(domain) - return ( - Response() - .ok( - { - "status": "pending", - "device_code": registration.device_code, - "registration_code": registration.device_code, - "user_code": registration.user_code, - "verification_uri": registration.verification_uri, - "verification_uri_complete": registration.verification_uri_complete, - "expires_in": registration.expires_in, - "interval": registration.interval, - } - ) - .__dict__ - ) - - if action == "poll": - device_code = str( - payload.get("device_code") or payload.get("registration_code") or "" - ).strip() - if not device_code: - return Response().error("Missing device_code").__dict__, 400 - result = await poll_app_registration_once( - domain=domain, - device_code=device_code, - ) - if result.get("status") == "created": - try: - bot_info = await request_lark_bot_info( - domain=str(result.get("domain") or domain), - app_id=str(result.get("app_id") or ""), - app_secret=str(result.get("app_secret") or ""), - ) - if bot_info.app_name: - result["bot_name"] = bot_info.app_name - if bot_info.open_id: - result["bot_open_id"] = bot_info.open_id - except Exception as e: - logger.error(f"获取飞书机器人信息失败: {e}", exc_info=True) - return Response().ok(result).__dict__ - - return Response().error(f"Unsupported action: {action}").__dict__, 400 - - async def _handle_dingtalk_registration(self, action: str, payload: dict): - if action == "start": - registration = await request_dingtalk_app_registration() - return ( - Response() - .ok( - { - "status": "pending", - "device_code": registration.device_code, - "registration_code": registration.device_code, - "user_code": registration.user_code, - "verification_uri": registration.verification_uri, - "verification_uri_complete": registration.verification_uri_complete, - "expires_in": registration.expires_in, - "interval": registration.interval, - } - ) - .__dict__ - ) - - if action == "poll": - device_code = str( - payload.get("device_code") or payload.get("registration_code") or "" - ).strip() - if not device_code: - return Response().error("Missing device_code").__dict__, 400 - result = await poll_dingtalk_app_registration_once(device_code) - if result.get("status") == "created": - result["platform_id_suffix"] = _random_platform_id_suffix() - return Response().ok(result).__dict__ - - return Response().error(f"Unsupported action: {action}").__dict__, 400 - - async def _handle_weixin_oc_registration( - self, - action: str, - payload: dict, - platform_config: dict, - ): - if action == "start": - registration = await request_weixin_oc_login_qr(platform_config) - return ( - Response() - .ok( - { - "status": "pending", - "registration_code": registration.qrcode, - "qrcode": registration.qrcode, - "qrcode_img_content": registration.qrcode_img_content, - "interval": registration.interval, - } - ) - .__dict__ - ) - - if action == "poll": - qrcode = str( - payload.get("qrcode") or payload.get("registration_code") or "" - ).strip() - if not qrcode: - return Response().error("Missing qrcode").__dict__, 400 - result = await poll_weixin_oc_login_once( - platform_config=platform_config, - qrcode=qrcode, - ) - if result.get("status") == "created": - result["platform_id_suffix"] = _random_platform_id_suffix() - return Response().ok(result).__dict__ - - return Response().error(f"Unsupported action: {action}").__dict__, 400 + payload = await request.get_json(silent=True) or {} + return await self._run( + lambda: self.service.handle_platform_registration(platform_type, payload) + ) diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index ff785a0379..7d2d717a4f 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -1,106 +1,26 @@ -import asyncio -import hashlib -import json -import mimetypes -import os -import posixpath -import re -import ssl -import traceback -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import cast -from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit +from __future__ import annotations -import aiofiles -import aiohttp -import certifi -import jwt -from aiofiles import ospath as aio_ospath -from quart import Response as QuartResponse -from quart import g, make_response, request +from typing import TYPE_CHECKING, cast -from astrbot.api import sp -from astrbot.core import DEMO_MODE, file_token_service, logger -from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes -from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.skills.skill_manager import SkillManager -from astrbot.core.star.filter.command import CommandFilter -from astrbot.core.star.filter.command_group import CommandGroupFilter -from astrbot.core.star.filter.permission import PermissionTypeFilter -from astrbot.core.star.filter.regex import RegexFilter -from astrbot.core.star.star import StarMetadata -from astrbot.core.star.star_handler import EventType, star_handlers_registry -from astrbot.core.star.star_manager import ( - PluginManager, - PluginVersionIncompatibleError, +from astrbot.core import logger +from astrbot.dashboard.fastapi_compat import Response as CompatResponse +from astrbot.dashboard.fastapi_compat import g, make_response, request +from astrbot.dashboard.services.plugin_page_service import ( + PluginPageContentPayload, + PluginPageService, + PluginPageServiceError, ) -from astrbot.core.utils.astrbot_path import ( - get_astrbot_data_path, - get_astrbot_temp_path, +from astrbot.dashboard.services.plugin_service import ( + PluginService, + PluginServiceError, + PluginServiceWarning, ) from .route import Response, Route, RouteContext -PLUGIN_UPDATE_CONCURRENCY = ( - 3 # limit concurrent updates to avoid overwhelming plugin sources -) -_PLUGIN_PAGE_BRIDGE_FILE = ( - Path(__file__).resolve().parent.parent / "plugin_page_bridge.js" -) -_HTML_ASSET_ATTR_RE = re.compile( - r"(?Psrc|href)=(?P[\"\'])(?P.*?)(?P=quote)", - re.IGNORECASE, -) -_CSS_URL_RE = re.compile( - r"url\(\s*(?P[\"\']?)(?P.*?)(?P=quote)\s*\)", - re.IGNORECASE, -) -_JS_DYNAMIC_IMPORT_RE = re.compile( - r"(?P\bimport\s*\(\s*)(?P[\"\'])(?P.*?)(?P=quote)(?P\s*\))", - re.IGNORECASE, -) -_JS_MODULE_FROM_RE = re.compile( - r"(?P\b(?:import|export)\s+(?:[^;]*?\s+from\s+))(?P[\"\'])(?P.*?)(?P=quote)", - re.IGNORECASE | re.DOTALL, -) -_JS_SIDE_EFFECT_IMPORT_RE = re.compile( - r"(?P\bimport\s+)(?P[\"\'])(?P[^\"'\r\n]+)(?P=quote)", - re.IGNORECASE, -) -_PLUGIN_PAGE_ASSET_TOKEN_TYPE = "plugin_page_asset" -_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS = 60 -_PLUGIN_PAGE_ROOT_DIR_NAME = "pages" -_PLUGIN_PAGE_ENTRY_FILE_NAME = "index.html" - - -def _normalize_plugin_page_asset_path(asset_path: str) -> str: - return PluginRoute._normalize_plugin_page_path(asset_path, allow_empty=True) - - -PLUGIN_COMPONENT_TYPE_ORDER = { - "page": 0, - "skill": 1, - "command": 2, - "llm_tool": 3, - "listener": 4, - "hook": 5, -} - - -@dataclass -class PluginPage: - name: str - title: str - entry_file: str = _PLUGIN_PAGE_ENTRY_FILE_NAME - - -@dataclass -class RegistrySource: - urls: list[str] - cache_file: str - md5_url: str | None # None means "no remote MD5, always treat cache as stale" +if TYPE_CHECKING: + from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + from astrbot.core.star.star_manager import PluginManager class PluginRoute(Route): @@ -133,8 +53,11 @@ def __init__( "/plugin/source/save": ("POST", self.save_custom_source), "/plugin/source/get-failed-plugins": ("GET", self.get_failed_plugins), } - self.core_lifecycle = core_lifecycle - self.plugin_manager = plugin_manager + self.service = PluginService(core_lifecycle, plugin_manager) + self.page_service = PluginPageService( + plugin_manager, + core_lifecycle=core_lifecycle, + ) self.register_routes() self.app.add_url_rule( "/api/plugin/page/content///", @@ -155,19 +78,45 @@ def __init__( methods=["GET"], ) - self.translated_event_type = { - EventType.AdapterMessageEvent: "平台消息下发时", - EventType.OnLLMRequestEvent: "LLM 请求时", - EventType.OnLLMResponseEvent: "LLM 响应后", - EventType.OnAgentBeginEvent: "Agent 开始运行时", - EventType.OnAgentDoneEvent: "Agent 运行完成后", - EventType.OnDecoratingResultEvent: "回复消息前", - EventType.OnCallingFuncToolEvent: "函数工具", - EventType.OnAfterMessageSentEvent: "发送消息后", - EventType.OnPluginErrorEvent: "插件报错时", - } + @staticmethod + def _service_ok(result): + if isinstance(result, tuple): + data, message = result + return Response().ok(data, message).__dict__ + return Response().ok(result).__dict__ - self._logo_cache = {} + async def _run_service(self, operation, *, log_label: str | None = None): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._service_ok(result) + except PluginServiceWarning as exc: + return { + "status": "warning", + "message": str(exc), + "data": exc.data, + } + except PluginServiceError as exc: + return Response().error(str(exc)).__dict__ + except Exception as exc: + if log_label: + logger.error("%s: %s", log_label, exc, exc_info=True) + else: + logger.error(str(exc), exc_info=True) + return Response().error(str(exc)).__dict__ + + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} + + async def _run_json(self, operation, *, log_label: str | None = None): + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run_service(invoke, log_label=log_label) async def get_plugin_page_entry(self, plugin_name: str, page_name: str): return await self._serve_plugin_page_content(plugin_name, page_name, "") @@ -185,41 +134,18 @@ async def get_plugin_page_asset( ) async def get_plugin_page_bridge_sdk(self): - if not await aio_ospath.isfile(str(_PLUGIN_PAGE_BRIDGE_FILE)): - return await self._plugin_page_error_response( - 404, "Plugin Page bridge SDK not found" + try: + payload = await self.page_service.serve_bridge_sdk( + asset_token=request.args.get("asset_token", "").strip(), + locale=self._get_request_locale(), + theme=self._get_request_theme(), ) - bridge_js = await self._read_plugin_page_text(_PLUGIN_PAGE_BRIDGE_FILE) - initial_context = self._get_plugin_page_initial_context() - if initial_context: - context_json = json.dumps(initial_context, ensure_ascii=False) - bridge_js += ( - f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n" + except PluginPageServiceError as exc: + return await self._plugin_page_error_response( + exc.status_code, + str(exc), ) - response = cast( - QuartResponse, - await make_response( - bridge_js, {"Content-Type": "application/javascript; charset=utf-8"} - ), - ) - return self._apply_plugin_page_security_headers(response) - - def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: - for plugin in self.plugin_manager.context.get_all_stars(): - if plugin.name == plugin_name: - return plugin - return None - - @staticmethod - def _get_by_path(source: dict | None, key: str): - if not isinstance(source, dict) or not key: - return None - current = source - for part in key.split("."): - if not isinstance(current, dict) or part not in current: - return None - current = current[part] - return current + return await self._plugin_page_payload_response(payload) @staticmethod def _get_request_locale(default: str = "zh-CN") -> str: @@ -234,613 +160,6 @@ def _get_request_theme() -> str | None: theme = request.args.get("theme", "").strip() return theme if theme in ("dark", "light") else None - @staticmethod - def _apply_theme_to_html(html: str, theme: str) -> str: - def _replace_html_tag(m: re.Match) -> str: - attrs = m.group(1) or "" - attrs = re.sub( - r'\s+data-theme\s*=\s*["\'][^"\']*["\']', - "", - attrs, - flags=re.IGNORECASE, - ) - return f'' - - html = re.sub( - r"]*)>", - _replace_html_tag, - html, - count=1, - flags=re.IGNORECASE, - ) - - meta_tag = f'' - - html = re.sub( - r']*name\s*=\s*["\']color-scheme["\'][^>]*>', - "", - html, - flags=re.IGNORECASE, - ) - - head_match = re.search(r"]*>", html, re.IGNORECASE) - if head_match: - html = html.replace( - head_match.group(0), f"{head_match.group(0)}{meta_tag}", 1 - ) - else: - html = re.sub( - r"(]*>)", - rf"\1{meta_tag}", - html, - count=1, - flags=re.IGNORECASE, - ) - return html - - def _get_plugin_page_initial_context(self) -> dict | None: - asset_token = request.args.get("asset_token", "").strip() - if not asset_token: - return None - jwt_secret = self.config.get("dashboard", {}).get("jwt_secret") - if not isinstance(jwt_secret, str) or not jwt_secret.strip(): - return None - - try: - payload = jwt.decode(asset_token, jwt_secret, algorithms=["HS256"]) - except jwt.InvalidTokenError: - return None - if payload.get("token_type") != _PLUGIN_PAGE_ASSET_TOKEN_TYPE: - return None - - plugin_name = payload.get("plugin_name") - page_name = payload.get("page_name") - if not isinstance(plugin_name, str) or not isinstance(page_name, str): - return None - - plugin = self._get_plugin_metadata_by_name(plugin_name) - if not plugin: - return None - - locale = ( - payload.get("locale") - if isinstance(payload.get("locale"), str) - else self._get_request_locale() - ) - plugin_i18n = plugin.i18n or {} - try: - plugin_root = self._get_plugin_root_dir(plugin) - fresh_i18n = PluginManager._load_plugin_i18n(str(plugin_root)) - if fresh_i18n: - plugin_i18n = fresh_i18n - except (OSError, ValueError): - pass - - locale_data = plugin_i18n.get(locale) - display_name = ( - self._get_by_path(locale_data, "metadata.display_name") - or plugin.display_name - or plugin.name - ) - page_title = ( - self._get_by_path(locale_data, f"pages.{page_name}.title") or page_name - ) - - theme = self._get_request_theme() - - return { - "pluginName": plugin.name, - "displayName": display_name, - "pageName": page_name, - "pageTitle": page_title, - "locale": locale, - "i18n": plugin_i18n, - "isDark": theme == "dark", - } - - @staticmethod - def _normalize_plugin_page_path( - raw_path: str, - *, - base_dir: str | None = None, - allow_empty: bool = False, - ) -> str: - path = raw_path.replace("\\", "/").strip() - if base_dir: - path = posixpath.join(base_dir, path) - normalized = posixpath.normpath(path) - if normalized in {"", "."}: - if allow_empty: - return "" - raise ValueError("Invalid plugin Page asset path") - if ( - normalized.startswith("../") - or normalized == ".." - or normalized.startswith("/") - ): - raise ValueError("Invalid plugin Page asset path") - return normalized - - @staticmethod - def _normalize_plugin_page_name(raw_name: str) -> str: - page_name = raw_name.strip() - if not page_name: - raise ValueError("Invalid plugin Page name") - normalized = posixpath.normpath(page_name.replace("\\", "/")) - if ( - normalized != page_name - or normalized in {".", ".."} - or normalized.startswith(".") - or "/" in page_name - or "\\" in page_name - ): - raise ValueError("Invalid plugin Page name") - return page_name - - def _get_plugin_root_dir(self, plugin: StarMetadata) -> Path: - if not plugin.root_dir_name: - raise FileNotFoundError("Plugin directory metadata is missing") - - base_dir = Path( - self.plugin_manager.reserved_plugin_path - if plugin.reserved - else self.plugin_manager.plugin_store_path - ).resolve(strict=False) - plugin_root = (base_dir / plugin.root_dir_name).resolve(strict=False) - plugin_root.relative_to(base_dir) - return plugin_root - - async def _resolve_plugin_pages_root( - self, - plugin: StarMetadata, - ) -> Path: - plugin_root = self._get_plugin_root_dir(plugin) - pages_root = (plugin_root / _PLUGIN_PAGE_ROOT_DIR_NAME).resolve(strict=False) - pages_root.relative_to(plugin_root) - if pages_root == plugin_root: - raise FileNotFoundError("Plugin Pages root directory is invalid") - if not await aio_ospath.isdir(str(pages_root)): - raise FileNotFoundError("Plugin Pages root directory does not exist") - return pages_root - - async def _discover_plugin_pages(self, plugin: StarMetadata) -> list[PluginPage]: - try: - pages_root = await self._resolve_plugin_pages_root(plugin) - except (FileNotFoundError, ValueError): - return [] - - pages: list[PluginPage] = [] - try: - page_dirs = sorted( - (item for item in pages_root.iterdir() if item.is_dir()), - key=lambda item: item.name.lower(), - ) - except OSError: - return [] - - for page_dir in page_dirs: - try: - page_name = self._normalize_plugin_page_name(page_dir.name) - except ValueError: - continue - entry_path = page_dir / _PLUGIN_PAGE_ENTRY_FILE_NAME - if not await aio_ospath.isfile(str(entry_path)): - continue - pages.append( - PluginPage( - name=page_name, - title=page_name, - entry_file=_PLUGIN_PAGE_ENTRY_FILE_NAME, - ) - ) - return pages - - async def _get_plugin_page( - self, - plugin: StarMetadata, - page_name: str, - ) -> PluginPage: - normalized_name = self._normalize_plugin_page_name(page_name) - for page in await self._discover_plugin_pages(plugin): - if page.name == normalized_name: - return page - raise FileNotFoundError("Plugin Page entry not found") - - async def _resolve_plugin_page_root( - self, - plugin: StarMetadata, - page_name: str, - ) -> Path: - normalized_name = self._normalize_plugin_page_name(page_name) - pages_root = await self._resolve_plugin_pages_root(plugin) - page_root = (pages_root / normalized_name).resolve(strict=False) - page_root.relative_to(pages_root) - if not await aio_ospath.isdir(str(page_root)): - raise FileNotFoundError("Plugin Page root directory does not exist") - return page_root - - async def _resolve_plugin_page_file( - self, - plugin: StarMetadata, - page_name: str, - asset_path: str, - ) -> Path: - page = await self._get_plugin_page(plugin, page_name) - page_root = await self._resolve_plugin_page_root(plugin, page.name) - target_name = _normalize_plugin_page_asset_path(asset_path) or page.entry_file - target_path = (page_root / target_name).resolve(strict=False) - target_path.relative_to(page_root) - if not await aio_ospath.isfile(str(target_path)): - raise FileNotFoundError("Plugin Page asset not found") - return target_path - - @staticmethod - def _is_rewritable_asset_url(raw_url: str) -> bool: - value = raw_url.strip() - lower = value.lower() - if not value: - return False - if value.startswith(("#", "/#")): - return False - if lower.startswith( - ( - "http://", - "https://", - "//", - "data:", - "javascript:", - "mailto:", - "tel:", - "blob:", - ) - ): - return False - return True - - @staticmethod - def _resolve_referenced_asset_path( - base_asset_path: str, - referenced_url: str, - ) -> str: - parts = urlsplit(referenced_url) - referenced_path = parts.path.strip() - if not referenced_path: - raise ValueError("Plugin Page referenced asset path is empty") - base_dir = posixpath.dirname(base_asset_path) if base_asset_path else "" - normalized = PluginRoute._normalize_plugin_page_path( - referenced_path, - base_dir=base_dir, - ) - if not normalized: - raise ValueError("Plugin Page referenced asset path is invalid") - return normalized - - def _build_plugin_page_asset_url( - self, - plugin_name: str, - page_name: str, - asset_path: str, - original_query: str = "", - original_fragment: str = "", - extra_query_params: dict[str, str] | None = None, - ) -> str: - path = self._build_plugin_page_content_path(plugin_name, page_name, asset_path) - query_dict = dict(parse_qsl(original_query, keep_blank_values=True)) - if extra_query_params: - for key, value in extra_query_params.items(): - if value: - query_dict[key] = value - query = urlencode(query_dict) - return urlunsplit( - ( - "", - "", - path, - query, - original_fragment, - ) - ) - - @staticmethod - def _build_plugin_page_content_path( - plugin_name: str, - page_name: str, - asset_path: str = "", - ) -> str: - encoded_plugin_name = quote(plugin_name, safe="") - encoded_page_name = quote( - PluginRoute._normalize_plugin_page_name(page_name), - safe="", - ) - if not asset_path: - return ( - f"/api/plugin/page/content/{encoded_plugin_name}/{encoded_page_name}/" - ) - safe_asset_path = _normalize_plugin_page_asset_path(asset_path) - encoded_path = "/".join( - quote(part, safe="") for part in safe_asset_path.split("/") - ) - return ( - f"/api/plugin/page/content/{encoded_plugin_name}/" - f"{encoded_page_name}/{encoded_path}" - ) - - @staticmethod - def _get_plugin_page_bridge_sdk_url( - extra_query_params: dict[str, str] | None = None, - ) -> str: - query = urlencode(extra_query_params or {}) - return urlunsplit( - ( - "", - "", - "/api/plugin/page/bridge-sdk.js", - query, - "", - ) - ) - - @staticmethod - def _is_js_relative_module_specifier(raw_url: str) -> bool: - value = raw_url.strip() - return value.startswith(("./", "../", "/")) - - def _rewrite_relative_asset_url( - self, - raw_url: str, - base_asset_path: str, - plugin_name: str, - page_name: str, - extra_query_params: dict[str, str] | None = None, - ) -> str | None: - candidate = raw_url.strip() - if not self._is_rewritable_asset_url(candidate): - return None - parts = urlsplit(candidate) - asset_path = self._resolve_referenced_asset_path(base_asset_path, candidate) - return self._build_plugin_page_asset_url( - plugin_name, - page_name, - asset_path, - original_query=parts.query, - original_fragment=parts.fragment, - extra_query_params=extra_query_params, - ) - - def _rewrite_plugin_page_html( - self, - html_text: str, - plugin_name: str, - page_name: str, - entry_asset_path: str, - extra_query_params: dict[str, str] | None = None, - ) -> str: - def replace_attr(match: re.Match[str]) -> str: - raw_url = match.group("url") - attr = match.group("attr") - quote_char = match.group("quote") - - if raw_url.strip() == "/api/plugin/page/bridge-sdk.js": - url = self._get_plugin_page_bridge_sdk_url(extra_query_params) - return f"{attr}={quote_char}{url}{quote_char}" - - if not self._is_rewritable_asset_url(raw_url): - return match.group(0) - - try: - rewritten_url = self._rewrite_relative_asset_url( - raw_url, - entry_asset_path, - plugin_name, - page_name, - extra_query_params=extra_query_params, - ) - if not rewritten_url: - return match.group(0) - return f"{attr}={quote_char}{rewritten_url}{quote_char}" - except ValueError: - return match.group(0) - - rewritten_html = _HTML_ASSET_ATTR_RE.sub(replace_attr, html_text) - theme = self._get_request_theme() - if theme: - rewritten_html = self._apply_theme_to_html(rewritten_html, theme) - if "/api/plugin/page/bridge-sdk.js" not in rewritten_html: - bridge_tag = f'' - if "" in rewritten_html: - rewritten_html = rewritten_html.replace( - "", f"{bridge_tag}", 1 - ) - else: - rewritten_html += bridge_tag - return rewritten_html - - def _rewrite_plugin_page_css( - self, - css_text: str, - plugin_name: str, - page_name: str, - css_asset_path: str, - extra_query_params: dict[str, str] | None = None, - ) -> str: - def replace_url(match: re.Match[str]) -> str: - raw_url = match.group("url").strip() - quote_char = match.group("quote") or "" - try: - rewritten_url = self._rewrite_relative_asset_url( - raw_url, - css_asset_path, - plugin_name, - page_name, - extra_query_params=extra_query_params, - ) - if not rewritten_url: - return match.group(0) - return f"url({quote_char}{rewritten_url}{quote_char})" - except ValueError: - return match.group(0) - - return _CSS_URL_RE.sub(replace_url, css_text) - - def _rewrite_plugin_page_js( - self, - js_text: str, - plugin_name: str, - page_name: str, - js_asset_path: str, - extra_query_params: dict[str, str] | None = None, - ) -> str: - def rewrite_specifier(raw_url: str) -> str: - if not self._is_js_relative_module_specifier(raw_url): - return raw_url - if not self._is_rewritable_asset_url(raw_url): - return raw_url - rewritten = self._rewrite_relative_asset_url( - raw_url, - js_asset_path, - plugin_name, - page_name, - extra_query_params=extra_query_params, - ) - return rewritten or raw_url - - def replace_dynamic(match: re.Match[str]) -> str: - raw_url = match.group("url") - try: - rewritten = rewrite_specifier(raw_url) - except ValueError: - return match.group(0) - return ( - f"{match.group('prefix')}{match.group('quote')}{rewritten}" - f"{match.group('quote')}{match.group('suffix')}" - ) - - def replace_from(match: re.Match[str]) -> str: - raw_url = match.group("url") - try: - rewritten = rewrite_specifier(raw_url) - except ValueError: - return match.group(0) - return f"{match.group('prefix')}{match.group('quote')}{rewritten}{match.group('quote')}" - - rewritten_js = _JS_DYNAMIC_IMPORT_RE.sub(replace_dynamic, js_text) - rewritten_js = _JS_MODULE_FROM_RE.sub(replace_from, rewritten_js) - - def replace_side_effect(match: re.Match[str]) -> str: - raw_url = match.group("url") - if raw_url.startswith(("{", "*")): - return match.group(0) - try: - rewritten = rewrite_specifier(raw_url) - except ValueError: - return match.group(0) - return f"{match.group('prefix')}{match.group('quote')}{rewritten}{match.group('quote')}" - - return _JS_SIDE_EFFECT_IMPORT_RE.sub(replace_side_effect, rewritten_js) - - @staticmethod - async def _read_plugin_page_text(file_path: Path) -> str: - async with aiofiles.open(file_path, encoding="utf-8") as file: - return await file.read() - - @staticmethod - async def _read_plugin_page_binary(file_path: Path) -> bytes: - async with aiofiles.open(file_path, mode="rb") as file: - return await file.read() - - @staticmethod - def _guess_plugin_page_mime_type(file_path: Path) -> str: - return mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" - - async def _serialize_plugin_page( - self, - plugin: StarMetadata, - page_name: str, - *, - include_content_path: bool = False, - ) -> dict | None: - plugin_name = plugin.name.strip() if isinstance(plugin.name, str) else "" - if not plugin_name: - return None - try: - page = await self._get_plugin_page(plugin, page_name) - await self._resolve_plugin_page_file(plugin, page.name, "") - except (FileNotFoundError, ValueError): - return None - - page_data = { - "name": page.name, - "title": page.title, - "i18n_key": f"pages.{page.name}", - } - if include_content_path: - asset_token = ( - self._issue_plugin_page_asset_token(plugin_name, page.name) or "" - ) - extra_query_params = {"asset_token": asset_token} if asset_token else None - page_data["content_path"] = self._build_plugin_page_asset_url( - plugin_name, - page.name, - "", - extra_query_params=extra_query_params, - ) - return page_data - - async def _serialize_plugin_pages(self, plugin: StarMetadata) -> list[dict]: - pages = [] - for page in await self._discover_plugin_pages(plugin): - page_data = await self._serialize_plugin_page(plugin, page.name) - if page_data: - pages.append(page_data) - return pages - - def _issue_plugin_page_asset_token( - self, - plugin_name: str, - page_name: str, - ) -> str | None: - jwt_secret = self.config.get("dashboard", {}).get("jwt_secret") - if not isinstance(jwt_secret, str) or not jwt_secret.strip(): - return None - - username = getattr(g, "username", None) - if not isinstance(username, str) or not username.strip(): - return None - - now = datetime.now(timezone.utc) - payload = { - "username": username, - "token_type": _PLUGIN_PAGE_ASSET_TOKEN_TYPE, - "plugin_name": plugin_name, - "page_name": page_name, - "locale": self._get_request_locale(), - "iat": now, - "exp": now + timedelta(seconds=_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS), - } - return cast(str, jwt.encode(payload, jwt_secret, algorithm="HS256")) - - def _prepare_plugin_page_query_params( - self, - plugin_name: str, - page_name: str, - ) -> dict[str, str] | None: - asset_token = request.args.get("asset_token", "").strip() - if not asset_token: - asset_token = ( - self._issue_plugin_page_asset_token(plugin_name, page_name) or "" - ) - theme = self._get_request_theme() - - if not asset_token and not theme: - return None - - params: dict[str, str] = {} - if asset_token: - params["asset_token"] = asset_token - if theme: - params["theme"] = theme - return params - @staticmethod async def _plugin_page_error_response(status_code: int, message: str): response = await make_response(message, status_code) @@ -850,107 +169,20 @@ async def _plugin_page_error_response(status_code: int, message: str): return response @staticmethod - def _apply_plugin_page_security_headers(response: QuartResponse) -> QuartResponse: - response.headers["Cache-Control"] = "no-store" - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Cross-Origin-Resource-Policy"] = "cross-origin" - # Sandboxed iframes without allow-same-origin load ES modules with Origin: null. - # CORS read access is allowed here; JWT/asset_token still protects the assets. - response.headers["Access-Control-Allow-Origin"] = "*" - - # When running under the AstrBot Launcher the dashboard is embedded in a - # cross-origin iframe (the Tauri webview). Since frame-ancestors and - # X-Frame-Options inspect the *entire* ancestor chain, enforcing them here - # would block plugin pages from loading inside the nested iframe. - csp = "object-src 'none'; base-uri 'self'" - if os.environ.get("ASTRBOT_LAUNCHER") not in ("1", "true"): - response.headers["X-Frame-Options"] = "SAMEORIGIN" - csp = f"frame-ancestors 'self'; {csp}" - response.headers["Content-Security-Policy"] = csp - + def _apply_plugin_page_security_headers(response: CompatResponse) -> CompatResponse: + for name, value in PluginPageService.build_security_headers().items(): + response.headers[name] = value return response - async def _serve_plugin_page_html_asset( + async def _plugin_page_payload_response( self, - file_path: Path, - plugin_name: str, - page_name: str, - asset_path: str, - extra_query_params: dict[str, str] | None, + payload: PluginPageContentPayload, ): - html_text = await self._read_plugin_page_text(file_path) - rewritten_html = self._rewrite_plugin_page_html( - html_text, - plugin_name, - page_name, - asset_path, - extra_query_params=extra_query_params, - ) response = cast( - QuartResponse, + CompatResponse, await make_response( - rewritten_html, {"Content-Type": "text/html; charset=utf-8"} - ), - ) - return self._apply_plugin_page_security_headers(response) - - async def _serve_plugin_page_css_asset( - self, - file_path: Path, - plugin_name: str, - page_name: str, - asset_path: str, - extra_query_params: dict[str, str] | None, - ): - css_text = await self._read_plugin_page_text(file_path) - rewritten_css = self._rewrite_plugin_page_css( - css_text, - plugin_name, - page_name, - asset_path, - extra_query_params=extra_query_params, - ) - response = cast( - QuartResponse, - await make_response( - rewritten_css, {"Content-Type": "text/css; charset=utf-8"} - ), - ) - return self._apply_plugin_page_security_headers(response) - - async def _serve_plugin_page_js_asset( - self, - file_path: Path, - plugin_name: str, - page_name: str, - asset_path: str, - extra_query_params: dict[str, str] | None, - ): - js_text = await self._read_plugin_page_text(file_path) - rewritten_js = self._rewrite_plugin_page_js( - js_text, - plugin_name, - page_name, - asset_path, - extra_query_params=extra_query_params, - ) - response = cast( - QuartResponse, - await make_response( - rewritten_js, - {"Content-Type": "application/javascript; charset=utf-8"}, - ), - ) - return self._apply_plugin_page_security_headers(response) - - async def _serve_plugin_page_static_asset(self, file_path: Path): - raw_bytes = await self._read_plugin_page_binary(file_path) - response = cast( - QuartResponse, - await make_response( - raw_bytes, - {"Content-Type": self._guess_plugin_page_mime_type(file_path)}, + payload.content, + {"Content-Type": payload.content_type}, ), ) return self._apply_plugin_page_security_headers(response) @@ -961,1172 +193,176 @@ async def _serve_plugin_page_content( page_name: str, asset_path: str, ): - plugin = self._get_plugin_metadata_by_name(plugin_name) - if not plugin: - return await self._plugin_page_error_response(404, "Plugin not found") - if not plugin.activated: - return await self._plugin_page_error_response(403, "Plugin is disabled") - try: - page = await self._get_plugin_page(plugin, page_name) - file_path = await self._resolve_plugin_page_file( - plugin, - page.name, - asset_path, - ) - except (FileNotFoundError, ValueError): + payload = await self.page_service.serve_page_content( + plugin_name=plugin_name, + page_name=page_name, + asset_path=asset_path, + asset_token=request.args.get("asset_token", "").strip(), + username=getattr(g, "username", None), + locale=self._get_request_locale(), + theme=self._get_request_theme(), + ) + except PluginPageServiceError as exc: return await self._plugin_page_error_response( - 404, "Plugin Page asset not found" + exc.status_code, + str(exc), ) + return await self._plugin_page_payload_response(payload) - extra_query_params = self._prepare_plugin_page_query_params( - plugin_name, - page.name, + async def check_plugin_compatibility(self): + return await self._run_json( + self.service.check_plugin_compatibility, + log_label="/api/plugin/check-compat", ) - served_asset_path = asset_path or page.entry_file - suffix = file_path.suffix.lower() - handlers = { - ".html": self._serve_plugin_page_html_asset, - ".css": self._serve_plugin_page_css_asset, - ".js": self._serve_plugin_page_js_asset, - ".mjs": self._serve_plugin_page_js_asset, - } - handler = handlers.get(suffix) - if handler: - return await handler( - file_path, - plugin_name, - page.name, - served_asset_path, - extra_query_params, - ) - return await self._serve_plugin_page_static_asset(file_path) - async def _sync_skills_after_plugin_change(self) -> None: - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync plugin-provided skills to active sandboxes.") - - async def check_plugin_compatibility(self): + async def get_plugin_page_entry_config(self): try: - data = await request.get_json() - version_spec = data.get("astrbot_version", "") - is_valid, message = self.plugin_manager._validate_astrbot_version_specifier( - version_spec - ) return ( Response() .ok( - { - "compatible": is_valid, - "message": message, - "astrbot_version": version_spec, - } + await self.page_service.get_plugin_page_entry_config( + plugin_name=request.args.get("name"), + page_name=request.args.get("page"), + username=getattr(g, "username", None), + locale=self._get_request_locale(), + ) ) .__dict__ ) - except Exception as e: - return Response().error(str(e)).__dict__ - - async def get_plugin_page_entry_config(self): - plugin_name = request.args.get("name") - if not plugin_name: - return Response().error("缺少插件名").__dict__ - page_name = request.args.get("page") - if not page_name: - return Response().error("缺少 Page 名称").__dict__ - - for plugin in self.plugin_manager.context.get_all_stars(): - if plugin.name != plugin_name: - continue - if not plugin.activated: - return Response().error("插件未启用").__dict__ - - page = await self._serialize_plugin_page( - plugin, - page_name, - include_content_path=True, - ) - if not page: - return Response().error("插件 Page 不存在").__dict__ - return Response().ok(page).__dict__ - - return Response().error("插件不存在").__dict__ + except PluginPageServiceError as exc: + return Response().error(str(exc)).__dict__ async def reload_failed_plugins(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - try: - data = await request.get_json() - dir_name = data.get("dir_name") # 这里拿的是目录名,不是插件名 - - if not dir_name: - return Response().error("缺少插件目录名").__dict__ - - # 调用 star_manager.py 中的函数 - # 注意:传入的是目录名 - success, err = await self.plugin_manager.reload_failed_plugin(dir_name) - - if success: - await self._sync_skills_after_plugin_change() - return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__ - else: - return Response().error(f"重载失败: {err}").__dict__ - - except Exception as e: - logger.error(f"/api/plugin/reload-failed: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return await self._run_json( + self.service.reload_failed_plugin, + log_label="/api/plugin/reload-failed", + ) async def reload_plugins(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - data = await request.get_json() - plugin_name = data.get("name", None) - try: - success, message = await self.plugin_manager.reload(plugin_name) - if not success: - return Response().error(message or "插件重载失败").__dict__ - await self._sync_skills_after_plugin_change() - return Response().ok(None, "重载成功。").__dict__ - except Exception as e: - logger.error(f"/api/plugin/reload: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return await self._run_json( + self.service.reload_plugin, + log_label="/api/plugin/reload", + ) async def get_online_plugins(self): - custom = request.args.get("custom_registry") - force_refresh = request.args.get("force_refresh", "false").lower() == "true" - - # 构建注册表源信息 - source = self._build_registry_source(custom) - - # 如果不是强制刷新,先检查缓存是否有效 - cached_data = None - if not force_refresh: - # 先检查MD5是否匹配,如果匹配则使用缓存 - if await self._is_cache_valid(source): - cached_data = self._load_plugin_cache(source.cache_file) - if cached_data: - logger.debug("缓存MD5匹配,使用缓存的插件市场数据") - return Response().ok(cached_data).__dict__ - - # 尝试获取远程数据 - remote_data = None - ssl_context = ssl.create_default_context(cafile=certifi.where()) - connector = aiohttp.TCPConnector(ssl=ssl_context) - - for url in source.urls: - try: - async with ( - aiohttp.ClientSession( - trust_env=True, - connector=connector, - ) as session, - session.get(url) as response, - ): - if response.status == 200: - try: - remote_data = await response.json() - except aiohttp.ContentTypeError: - remote_text = await response.text() - remote_data = json.loads(remote_text) - - # 检查远程数据是否为空 - if not remote_data or ( - isinstance(remote_data, dict) and len(remote_data) == 0 - ): - logger.warning(f"远程插件市场数据为空: {url}") - continue # 继续尝试其他URL或使用缓存 - - logger.info( - f"成功获取远程插件市场数据,包含 {len(remote_data)} 个插件" - ) - # 获取最新的MD5并保存到缓存 - current_md5 = await self._fetch_remote_md5(source.md5_url) - self._save_plugin_cache( - source.cache_file, - remote_data, - current_md5, - ) - return Response().ok(remote_data).__dict__ - logger.error(f"请求 {url} 失败,状态码:{response.status}") - except Exception as e: - logger.error(f"请求 {url} 失败,错误:{e}") - - # 如果远程获取失败,尝试使用缓存数据 - if not cached_data: - cached_data = self._load_plugin_cache(source.cache_file) - - if cached_data: - logger.warning("远程插件市场数据获取失败,使用缓存数据") - return Response().ok(cached_data, "使用缓存数据,可能不是最新版本").__dict__ - - return Response().error("获取插件列表失败,且没有可用的缓存数据").__dict__ - - def _build_registry_source(self, custom_url: str | None) -> RegistrySource: - """构建注册表源信息""" - data_dir = get_astrbot_data_path() - if custom_url: - # 对自定义URL生成一个安全的文件名 - url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8] - cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json") - - # 更安全的后缀处理方式 - if custom_url.endswith(".json"): - md5_url = custom_url[:-5] + "-md5.json" - else: - md5_url = custom_url + "-md5.json" - - urls = [custom_url] - else: - cache_file = os.path.join(data_dir, "plugins.json") - md5_url = "https://api.soulter.top/astrbot/plugins-md5" - urls = [ - "https://api.soulter.top/astrbot/plugins", - "https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json", - ] - return RegistrySource(urls=urls, cache_file=cache_file, md5_url=md5_url) - - def _load_cached_md5(self, cache_file: str) -> str | None: - """从缓存文件中加载MD5""" - if not os.path.exists(cache_file): - return None - - try: - with open(cache_file, encoding="utf-8") as f: - cache_data = json.load(f) - return cache_data.get("md5") - except Exception as e: - logger.warning(f"Failed to load cached MD5: {e}") - return None - - async def _fetch_remote_md5(self, md5_url: str | None) -> str | None: - """获取远程MD5""" - if not md5_url: - return None - - try: - ssl_context = ssl.create_default_context(cafile=certifi.where()) - connector = aiohttp.TCPConnector(ssl=ssl_context) - - async with ( - aiohttp.ClientSession( - trust_env=True, - connector=connector, - ) as session, - session.get(md5_url) as response, - ): - if response.status == 200: - data = await response.json() - return data.get("md5", "") - except Exception as e: - logger.debug(f"Failed to fetch remote MD5: {e}") - return None - - async def _is_cache_valid(self, source: RegistrySource) -> bool: - """检查缓存是否有效(基于MD5)""" - try: - cached_md5 = self._load_cached_md5(source.cache_file) - if not cached_md5: - logger.debug("MD5 not found in cache, treating cache as invalid") - return False - - remote_md5 = await self._fetch_remote_md5(source.md5_url) - if remote_md5 is None: - logger.warning( - "Cannot fetch remote MD5, using cache without validation" - ) - return True # 如果无法获取远程MD5,认为缓存有效 - - is_valid = cached_md5 == remote_md5 - logger.debug( - f"Plugin cache: local={cached_md5}, remote={remote_md5}, effective={is_valid}", - ) - return is_valid - - except Exception as e: - logger.warning(f"检查缓存有效性失败: {e}") - return False - - def _load_plugin_cache(self, cache_file: str): - """加载本地缓存的插件市场数据""" - try: - if os.path.exists(cache_file): - with open(cache_file, encoding="utf-8") as f: - cache_data = json.load(f) - # 检查缓存是否有效 - if "data" in cache_data and "timestamp" in cache_data: - logger.debug( - f"Loading cached file: {cache_file}, Cache time: {cache_data['timestamp']}", - ) - return cache_data["data"] - except Exception as e: - logger.warning(f"Failed to load plugin market cache: {e}") - return None - - def _save_plugin_cache(self, cache_file: str, data, md5: str | None = None) -> None: - """保存插件市场数据到本地缓存""" - try: - # 确保目录存在 - os.makedirs(os.path.dirname(cache_file), exist_ok=True) - - cache_data = { - "timestamp": datetime.now().isoformat(), - "data": data, - "md5": md5 or "", - } - - with open(cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f, ensure_ascii=False, indent=2) - logger.debug(f"Cached plugin market data: {cache_file}, MD5: {md5}") - except Exception as e: - logger.warning(f"Failed to save plugin market cache: {e}") - - async def get_plugin_logo_token(self, logo_path: str): - try: - if token := self._logo_cache.get(logo_path): - if not await file_token_service.check_token_expired(token): - return self._logo_cache[logo_path] - token = await file_token_service.register_file(logo_path, timeout=300) - self._logo_cache[logo_path] = token - return token - except Exception as e: - logger.warning(f"获取插件 Logo 失败: {e}") - return None - - def _resolve_plugin_dir(self, plugin) -> Path | None: - if not plugin.root_dir_name: - return None - - base_dir = Path( - self.plugin_manager.reserved_plugin_path - if plugin.reserved - else self.plugin_manager.plugin_store_path + return await self._run_service( + self.service.get_online_plugins_from_legacy_query( + custom_registry=request.args.get("custom_registry"), + force_refresh=request.args.get("force_refresh", "false"), + ), + log_label="/api/plugin/market_list", ) - plugin_dir = base_dir / plugin.root_dir_name - if not plugin_dir.is_dir(): - return None - return plugin_dir - - def _get_plugin_installed_at(self, plugin) -> str | None: - plugin_dir = self._resolve_plugin_dir(plugin) - if plugin_dir is None: - return None - - try: - return datetime.fromtimestamp( - plugin_dir.stat().st_mtime, - timezone.utc, - ).isoformat() - except OSError as exc: - logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}") - return None async def get_plugins(self): - _plugin_resp = [] - plugin_name = request.args.get("name") - - plugins = [ - p - for p in self.plugin_manager.context.get_all_stars() - if not (plugin_name and p.name != plugin_name) - ] - - async def process_plugin(plugin): - logo_url = None - if plugin.logo_path: - logo_url = await self.get_plugin_logo_token(plugin.logo_path) - pages = await self._discover_plugin_pages(plugin) - return plugin, logo_url, pages - - results = await asyncio.gather(*(process_plugin(p) for p in plugins)) - - for plugin, logo_url, pages in results: - _t = { - "name": plugin.name, - "marketplace_name": (plugin.name or "").replace("_", "-"), - "repo": "" if plugin.repo is None else str(plugin.repo), - "author": plugin.author, - "desc": plugin.desc, - "version": plugin.version, - "reserved": plugin.reserved, - "activated": plugin.activated, - "online_vesion": "", - "display_name": plugin.display_name, - "logo": f"/api/file/{logo_url}" if logo_url else None, - "support_platforms": plugin.support_platforms, - "astrbot_version": plugin.astrbot_version, - "installed_at": self._get_plugin_installed_at(plugin), - "i18n": plugin.i18n, - "pages": [p.name for p in pages], - } - # 检查是否为全空的幽灵插件 - if not any( - [ - plugin.name, - plugin.author, - plugin.desc, - plugin.version, - plugin.display_name, - ] - ): - continue - _plugin_resp.append(_t) - return ( - Response() - .ok(_plugin_resp, message=self.plugin_manager.failed_plugin_info) - .__dict__ + return await self._run_service( + self.service.list_plugins_from_legacy_query( + plugin_name=request.args.get("name"), + logo_token_resolver=self.service.get_plugin_logo_token, + installed_at_resolver=self.service.get_plugin_installed_at, + discover_pages=self.page_service.discover_plugin_pages, + ), + log_label="/api/plugin/get", ) async def get_plugin_detail(self): - plugin_name = request.args.get("name") - if not plugin_name: - return Response().error("缺少插件名").__dict__ - - for plugin in self.plugin_manager.context.get_all_stars(): - if plugin.name != plugin_name: - continue - - logo_url = None - if plugin.logo_path: - logo_url = await self.get_plugin_logo_token(plugin.logo_path) - - return ( - Response() - .ok( - { - "name": plugin.name, - "marketplace_name": (plugin.name or "").replace("_", "-"), - "repo": "" if plugin.repo is None else str(plugin.repo), - "author": plugin.author, - "desc": plugin.desc, - "version": plugin.version, - "reserved": plugin.reserved, - "activated": plugin.activated, - "online_vesion": "", - "components": await self.get_plugin_components_info(plugin), - "display_name": plugin.display_name, - "logo": f"/api/file/{logo_url}" if logo_url else None, - "support_platforms": plugin.support_platforms, - "astrbot_version": plugin.astrbot_version, - "installed_at": self._get_plugin_installed_at(plugin), - "i18n": plugin.i18n, - } - ) - .__dict__ - ) - - return Response().error("插件不存在").__dict__ - - async def get_failed_plugins(self): - """专门获取加载失败的插件列表(字典格式)""" - return Response().ok(self.plugin_manager.failed_plugin_dict).__dict__ - - async def get_plugin_components_info(self, plugin): - """Build plugin components for the dashboard.""" - page_components = await self.get_plugin_page_components(plugin) - handler_components = await self.get_plugin_handler_components( - plugin.star_handler_full_names, - ) - components = [ - *page_components, - *self.get_plugin_skill_components(plugin), - *handler_components, - ] - return sorted( - components, - key=lambda item: PLUGIN_COMPONENT_TYPE_ORDER.get(item["type"], 99), - ) - - async def get_plugin_page_components(self, plugin) -> list[dict]: - pages = await self._serialize_plugin_pages(plugin) - return [ - { - "type": "page", - "name": page["title"], - "title": page["title"], - "page_name": page["name"], - "i18n_key": page["i18n_key"], - "description": "Plugin Page entry", - "plugin_name": plugin.name, - "plugin_marketplace_name": (plugin.name or "").replace("_", "-"), - } - for page in pages - ] - - async def get_plugin_handler_components(self, handler_full_names: list[str]): - """Build behavior components from registered handlers.""" - components = [] - - for handler_full_name in handler_full_names: - info = {} - handler = star_handlers_registry.star_handlers_map.get( - handler_full_name, - None, - ) - if handler is None: - continue - info["event_type"] = handler.event_type.name - info["event_type_h"] = self.translated_event_type.get( - handler.event_type, - handler.event_type.name, - ) - info["handler_full_name"] = handler.handler_full_name - info["description"] = handler.desc or "无描述" - info["handler_name"] = handler.handler_name - - component_type = "hook" - component = None - if handler.event_type == EventType.AdapterMessageEvent: - # 处理平台适配器消息事件 - has_admin = False - for event_filter in ( - handler.event_filters - ): # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高 - if isinstance(event_filter, CommandFilter): - component_type = "command" - info["display_type"] = "指令" - info["cmd"] = self._get_command_filter_display_name( - event_filter - ) - component = self._build_command_filter_component( - event_filter, - handler.desc, - ) - elif isinstance(event_filter, CommandGroupFilter): - component_type = "command" - info["display_type"] = "指令组" - info["cmd"] = event_filter.get_complete_command_names()[0] - info["cmd"] = info["cmd"].strip() - component = self._build_command_group_component( - event_filter, - handler.desc, - ) - elif isinstance(event_filter, RegexFilter): - component_type = "command" - info["display_type"] = "正则匹配" - info["cmd"] = event_filter.regex_str - component = { - "type": "command", - "name": event_filter.regex_str, - "description": handler.desc or "无描述", - "match": "regex", - } - elif isinstance(event_filter, PermissionTypeFilter): - has_admin = True - info["has_admin"] = has_admin - if "cmd" not in info: - info["cmd"] = "未知" - if "display_type" not in info: - info["display_type"] = "事件监听器" - component_type = "listener" - else: - info["cmd"] = "自动触发" - info["display_type"] = "无" - if handler.event_type == EventType.OnCallingFuncToolEvent: - component_type = "llm_tool" - - if component is None: - component = { - "type": component_type, - "name": handler.handler_name or handler.event_type.name, - "description": handler.desc or "无描述", - } - else: - component["type"] = component_type - - if component_type == "command": - component["event_type"] = info["event_type"] - component["event_type_h"] = info["event_type_h"] - component["handler_name"] = info["handler_name"] - component["has_admin"] = info.get("has_admin", False) - if "display_type" in info: - component["display_type"] = info["display_type"] - if "cmd" in info: - component["command"] = info["cmd"] - else: - component.update(info) - components.append(component) - - return self._merge_command_components(components) - - def get_plugin_skill_components(self, plugin): - """Build skill components provided by this plugin.""" - plugin_names = { - str(name) - for name in (plugin.root_dir_name, plugin.name) - if str(name or "").strip() - } - if not plugin_names: - return [] - - try: - skills = SkillManager().list_skills( - active_only=False, - runtime="local", - show_sandbox_path=False, - ) - except Exception as exc: - logger.warning(f"获取插件 Skills 失败 {plugin.name}: {exc!s}") - return [] - - components = [] - for skill in skills: - if skill.source_type != "plugin" or skill.plugin_name not in plugin_names: - continue - components.append( - { - "type": "skill", - "name": skill.name, - "description": skill.description or "无描述", - "path": skill.path, - } - ) - return components - - def _get_command_filter_display_name(self, command_filter: CommandFilter) -> str: - return command_filter.get_complete_command_names()[0].strip() - - def _get_command_description( - self, - command_filter: CommandFilter | CommandGroupFilter, - fallback: str = "", - ) -> str: - handler_md = getattr(command_filter, "handler_md", None) - desc = getattr(handler_md, "desc", "") if handler_md else "" - return desc or fallback or "无描述" - - def _build_command_filter_component( - self, - command_filter: CommandFilter, - fallback_desc: str = "", - ) -> dict: - parts = self._get_command_filter_display_name(command_filter).split() - if not parts: - parts = [command_filter.command_name] - component = { - "type": "command", - "name": parts[-1], - "description": self._get_command_description( - command_filter, - fallback_desc, - ), - } - return self._wrap_command_component(parts[:-1], component) - - def _build_command_group_component( - self, - command_group_filter: CommandGroupFilter, - fallback_desc: str = "", - ) -> dict: - parts = command_group_filter.get_complete_command_names()[0].strip().split() - if not parts: - parts = [command_group_filter.group_name] - subcommands = [ - self._build_command_group_child(sub_filter) - for sub_filter in command_group_filter.sub_command_filters - ] - component = { - "type": "command", - "name": parts[-1], - "description": self._get_command_description( - command_group_filter, - fallback_desc, + return await self._run_service( + self.service.get_plugin_detail_from_legacy_query( + plugin_name=request.args.get("name"), + logo_token_resolver=self.service.get_plugin_logo_token, + installed_at_resolver=self.service.get_plugin_installed_at, + serialize_pages=self.page_service.serialize_plugin_pages, ), - } - if subcommands: - component["subcommands"] = subcommands - return self._wrap_command_component(parts[:-1], component) - - def _build_command_group_child( - self, - command_filter: CommandFilter | CommandGroupFilter, - ) -> dict: - if isinstance(command_filter, CommandGroupFilter): - component = { - "name": command_filter.group_name, - "description": self._get_command_description(command_filter), - } - subcommands = [ - self._build_command_group_child(sub_filter) - for sub_filter in command_filter.sub_command_filters - ] - if subcommands: - component["subcommands"] = subcommands - return component - - return { - "name": command_filter.command_name, - "description": self._get_command_description(command_filter), - } - - def _wrap_command_component(self, parent_names: list[str], component: dict) -> dict: - for parent_name in reversed(parent_names): - component = { - "type": "command", - "name": parent_name, - "description": "无描述", - "subcommands": [component], - } - return component - - def _merge_command_components(self, components: list[dict]) -> list[dict]: - merged: list[dict] = [] - for component in components: - if component.get("type") != "command": - merged.append(component) - continue - existing = next( - ( - item - for item in merged - if item.get("type") == "command" - and item.get("name") == component.get("name") - and item.get("match") == component.get("match") - ), - None, - ) - if existing is None: - merged.append(component) - continue - self._merge_command_component(existing, component) - return merged - - def _merge_command_component(self, target: dict, source: dict) -> None: - if target.get("description") == "无描述" and source.get("description"): - target["description"] = source["description"] - for key, value in source.items(): - if key in {"subcommands", "description"}: - continue - target.setdefault(key, value) + log_label="/api/plugin/detail", + ) - source_subcommands = source.get("subcommands") - if not isinstance(source_subcommands, list): - return - target_subcommands = target.setdefault("subcommands", []) - for source_subcommand in source_subcommands: - if not isinstance(source_subcommand, dict): - continue - existing = next( - ( - item - for item in target_subcommands - if isinstance(item, dict) - and item.get("name") == source_subcommand.get("name") - ), - None, - ) - if existing is None: - target_subcommands.append(source_subcommand) - continue - self._merge_command_component(existing, source_subcommand) + async def get_failed_plugins(self): + return await self._run_service(self.service.get_failed_plugins) async def install_plugin(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - post_data = await request.get_json() - repo_url = post_data["url"] - download_url = str(post_data.get("download_url") or "").strip() - ignore_version_check = bool(post_data.get("ignore_version_check", False)) - - proxy: str = post_data.get("proxy", None) - if proxy: - proxy = proxy.removesuffix("/") - - try: - logger.info(f"正在安装插件 {repo_url}") - plugin_info = await self.plugin_manager.install_plugin( - repo_url, - proxy, - ignore_version_check=ignore_version_check, - download_url=download_url, - ) - # self.core_lifecycle.restart() - await self._sync_skills_after_plugin_change() - logger.info(f"安装插件 {repo_url} 成功。") - return Response().ok(plugin_info, "安装成功。").__dict__ - except PluginVersionIncompatibleError as e: - return { - "status": "warning", - "message": str(e), - "data": { - "warning_type": "astrbot_version_incompatible", - "can_ignore": True, - }, - } - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run_json( + self.service.install_plugin, + log_label="/api/plugin/install", + ) async def install_plugin_upload(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - try: - file = await request.files - file = file["file"] + async def _operation(): + files = await request.files + file = files["file"] form_data = await request.form - ignore_version_check = ( - str(form_data.get("ignore_version_check", "false")).lower() == "true" - ) - logger.info(f"正在安装用户上传的插件 {file.filename}") - file_path = os.path.join( - get_astrbot_temp_path(), - f"plugin_upload_{file.filename}", - ) - await file.save(file_path) - plugin_info = await self.plugin_manager.install_plugin_from_file( - file_path, - ignore_version_check=ignore_version_check, + return await self.service.install_plugin_upload_from_legacy_form( + upload_file=file, + ignore_version_check=form_data.get("ignore_version_check", "false"), ) - # self.core_lifecycle.restart() - await self._sync_skills_after_plugin_change() - logger.info(f"安装插件 {file.filename} 成功") - return Response().ok(plugin_info, "安装成功。").__dict__ - except PluginVersionIncompatibleError as e: - return { - "status": "warning", - "message": str(e), - "data": { - "warning_type": "astrbot_version_incompatible", - "can_ignore": True, - }, - } - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - async def uninstall_plugin(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) + return await self._run_service( + _operation, + log_label="/api/plugin/install-upload", + ) - post_data = await request.get_json() - plugin_name = post_data["name"] - delete_config = post_data.get("delete_config", False) - delete_data = post_data.get("delete_data", False) - try: - logger.info(f"正在卸载插件 {plugin_name}") - await self.plugin_manager.uninstall_plugin( - plugin_name, - delete_config=delete_config, - delete_data=delete_data, - ) - await self._sync_skills_after_plugin_change() - logger.info(f"卸载插件 {plugin_name} 成功") - return Response().ok(None, "卸载成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + async def uninstall_plugin(self): + return await self._run_json( + self.service.uninstall_plugin, + log_label="/api/plugin/uninstall", + ) async def uninstall_failed_plugin(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - post_data = await request.get_json() - dir_name = post_data.get("dir_name", "") - delete_config = post_data.get("delete_config", False) - delete_data = post_data.get("delete_data", False) - if not dir_name: - return Response().error("缺少失败插件目录名").__dict__ - - try: - logger.info(f"正在卸载失败插件 {dir_name}") - await self.plugin_manager.uninstall_failed_plugin( - dir_name, - delete_config=delete_config, - delete_data=delete_data, - ) - await self._sync_skills_after_plugin_change() - logger.info(f"卸载失败插件 {dir_name} 成功") - return Response().ok(None, "卸载成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run_json( + self.service.uninstall_failed_plugin, + log_label="/api/plugin/uninstall-failed", + ) async def update_plugin(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - post_data = await request.get_json() - plugin_name = post_data["name"] - proxy: str = post_data.get("proxy", None) - download_url = str(post_data.get("download_url") or "").strip() - try: - logger.info(f"正在更新插件 {plugin_name}") - await self.plugin_manager.update_plugin( - plugin_name, proxy, download_url=download_url - ) - # self.core_lifecycle.restart() - await self.plugin_manager.reload(plugin_name) - await self._sync_skills_after_plugin_change() - logger.info(f"更新插件 {plugin_name} 成功。") - return Response().ok(None, "更新成功。").__dict__ - except Exception as e: - logger.error(f"/api/plugin/update: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ - - async def update_all_plugins(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - post_data = await request.get_json() - plugin_names: list[str] = post_data.get("names") or [] - proxy: str = post_data.get("proxy", "") - download_urls: dict[str, str] = post_data.get("download_urls") or {} - - if not isinstance(plugin_names, list) or not plugin_names: - return Response().error("插件列表不能为空").__dict__ - if not isinstance(download_urls, dict): - download_urls = {} - - results = [] - sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY) - - async def _update_one(name: str): - async with sem: - try: - logger.info(f"批量更新插件 {name}") - download_url = str(download_urls.get(name) or "").strip() - await self.plugin_manager.update_plugin( - name, proxy, download_url=download_url - ) - return {"name": name, "status": "ok", "message": "更新成功"} - except Exception as e: - logger.error( - f"/api/plugin/update-all: 更新插件 {name} 失败: {traceback.format_exc()}", - ) - return {"name": name, "status": "error", "message": str(e)} - - raw_results = await asyncio.gather( - *(_update_one(name) for name in plugin_names), - return_exceptions=True, + return await self._run_json( + self.service.update_plugin, + log_label="/api/plugin/update", ) - for name, result in zip(plugin_names, raw_results): - if isinstance(result, asyncio.CancelledError): - raise result - if isinstance(result, BaseException): - results.append( - {"name": name, "status": "error", "message": str(result)} - ) - else: - results.append(result) - failed = [r for r in results if r["status"] == "error"] - if len(failed) < len(results): - await self._sync_skills_after_plugin_change() - message = ( - "批量更新完成,全部成功。" - if not failed - else f"批量更新完成,其中 {len(failed)}/{len(results)} 个插件失败。" + async def update_all_plugins(self): + return await self._run_json( + self.service.update_all_plugins, + log_label="/api/plugin/update-all", ) - return Response().ok({"results": results}, message).__dict__ - async def off_plugin(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - post_data = await request.get_json() - plugin_name = post_data["name"] - try: - await self.plugin_manager.turn_off_plugin(plugin_name) - await self._sync_skills_after_plugin_change() - logger.info(f"停用插件 {plugin_name} 。") - return Response().ok(None, "停用成功。").__dict__ - except Exception as e: - logger.error(f"/api/plugin/off: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return await self._run_json( + lambda data: self.service.set_plugin_enabled(data, enabled=False), + log_label="/api/plugin/off", + ) async def on_plugin(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - post_data = await request.get_json() - plugin_name = post_data["name"] - try: - await self.plugin_manager.turn_on_plugin(plugin_name) - await self._sync_skills_after_plugin_change() - logger.info(f"启用插件 {plugin_name} 。") - return Response().ok(None, "启用成功。").__dict__ - except Exception as e: - logger.error(f"/api/plugin/on: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return await self._run_json( + lambda data: self.service.set_plugin_enabled(data, enabled=True), + log_label="/api/plugin/on", + ) async def get_plugin_readme(self): - plugin_name = request.args.get("name") - - if not plugin_name: - logger.warning("插件名称为空") - return Response().error("插件名称不能为空").__dict__ - - plugin_obj = None - for plugin in self.plugin_manager.context.get_all_stars(): - if plugin.name == plugin_name: - plugin_obj = plugin - break - - if not plugin_obj: - logger.warning(f"插件 {plugin_name} 不存在") - return Response().error(f"插件 {plugin_name} 不存在").__dict__ - - if not plugin_obj.root_dir_name: - logger.warning(f"插件 {plugin_name} 目录不存在") - return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - - if plugin_obj.reserved: - plugin_dir = os.path.join( - self.plugin_manager.reserved_plugin_path, - plugin_obj.root_dir_name, - ) - else: - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name, - ) - - if not os.path.isdir(plugin_dir): - logger.warning(f"无法找到插件目录: {plugin_dir}") - return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ - - readme_path = os.path.join(plugin_dir, "README.md") - - if not os.path.isfile(readme_path): - logger.warning(f"插件 {plugin_name} 没有README文件") - return Response().error(f"插件 {plugin_name} 没有README文件").__dict__ - - try: - with open(readme_path, encoding="utf-8") as f: - readme_content = f.read() - - return ( - Response() - .ok({"content": readme_content}, "成功获取README内容") - .__dict__ - ) - except Exception as e: - logger.error(f"/api/plugin/readme: {traceback.format_exc()}") - return Response().error(f"读取README文件失败: {e!s}").__dict__ + return await self._run_service( + lambda: self.service.get_plugin_readme_from_legacy_query( + request.args.get("name") + ), + log_label="/api/plugin/readme", + ) async def get_plugin_changelog(self): """获取插件更新日志 读取插件目录下的 CHANGELOG.md 文件内容。 """ - plugin_name = request.args.get("name") - logger.debug(f"正在获取插件 {plugin_name} 的更新日志") - - if not plugin_name: - logger.warning("插件名称为空") - return Response().error("插件名称不能为空").__dict__ - - # 查找插件 - plugin_obj = None - for plugin in self.plugin_manager.context.get_all_stars(): - if plugin.name == plugin_name: - plugin_obj = plugin - break - - if not plugin_obj: - logger.warning(f"插件 {plugin_name} 不存在") - return Response().error(f"插件 {plugin_name} 不存在").__dict__ - - if not plugin_obj.root_dir_name: - logger.warning(f"插件 {plugin_name} 目录不存在") - return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - - if plugin_obj.reserved: - plugin_dir = os.path.join( - self.plugin_manager.reserved_plugin_path, - plugin_obj.root_dir_name, - ) - else: - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name, - ) - - if not os.path.isdir(plugin_dir): - logger.warning(f"无法找到插件目录: {plugin_dir}") - return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ - - # 尝试多种可能的文件名 - changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"] - for name in changelog_names: - changelog_path = os.path.join(plugin_dir, name) - if os.path.isfile(changelog_path): - try: - with open(changelog_path, encoding="utf-8") as f: - changelog_content = f.read() - return ( - Response() - .ok({"content": changelog_content}, "成功获取更新日志") - .__dict__ - ) - except Exception as e: - logger.error(f"/api/plugin/changelog: {traceback.format_exc()}") - return Response().error(f"读取更新日志失败: {e!s}").__dict__ - - # 没有找到 changelog 文件,返回 ok 但 content 为 null - logger.warning(f"插件 {plugin_name} 没有更新日志文件") - return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__ + return await self._run_service( + lambda: self.service.get_plugin_changelog_from_legacy_query( + request.args.get("name") + ), + log_label="/api/plugin/changelog", + ) async def get_custom_source(self): """获取自定义插件源""" - sources = await sp.global_get("custom_plugin_sources", []) - return Response().ok(sources).__dict__ + return await self._run_service(self.service.get_custom_sources) async def save_custom_source(self): """保存自定义插件源""" - try: - data = await request.get_json() - sources = data.get("sources", []) - if not isinstance(sources, list): - return Response().error("sources fields must be a list").__dict__ - - await sp.global_put("custom_plugin_sources", sources) - return Response().ok(None, "保存成功").__dict__ - except Exception as e: - logger.error(f"/api/plugin/source/save: {traceback.format_exc()}") - return Response().error(str(e)).__dict__ + return await self._run_json( + self.service.save_custom_sources, + log_label="/api/plugin/source/save", + ) diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py index 53c6234439..707f5ab97f 100644 --- a/astrbot/dashboard/routes/route.py +++ b/astrbot/dashboard/routes/route.py @@ -1,14 +1,13 @@ from dataclasses import dataclass -from quart import Quart - from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.dashboard.fastapi_compat import FastAPIAppAdapter @dataclass class RouteContext: config: AstrBotConfig - app: Quart + app: FastAPIAppAdapter class Route: diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py index 688515f4e3..fa5f2741ce 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -1,25 +1,14 @@ -from quart import request -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import col, select - -from astrbot.core import logger, sp +from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase -from astrbot.core.db.po import ConversationV2, Preference -from astrbot.core.provider.entities import ProviderType -from astrbot.core.umo_alias import build_umo_alias_map, parse_umo, serialize_umo_alias +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.session_management_service import ( + SessionManagementService, + SessionManagementServiceError, +) from .route import Response, Route, RouteContext -AVAILABLE_SESSION_RULE_KEYS = [ - "session_service_config", - "session_plugin_config", - "kb_config", - f"provider_perf_{ProviderType.CHAT_COMPLETION.value}", - f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}", - f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}", -] - class SessionManagementRoute(Route): def __init__( @@ -29,7 +18,6 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.db_helper = db_helper self.routes = { "/session/list-rule": ("GET", self.list_session_rule), "/session/update-rule": ("POST", self.update_session_rule), @@ -39,927 +27,124 @@ def __init__( "/session/list-all-with-status": ("GET", self.list_all_umos_with_status), "/session/batch-update-service": ("POST", self.batch_update_service), "/session/batch-update-provider": ("POST", self.batch_update_provider), - # 分组管理 API "/session/groups": ("GET", self.list_groups), "/session/group/create": ("POST", self.create_group), "/session/group/update": ("POST", self.update_group), "/session/group/delete": ("POST", self.delete_group), } - self.conv_mgr = core_lifecycle.conversation_manager - self.core_lifecycle = core_lifecycle + self.service = SessionManagementService(core_lifecycle, db_helper) self.register_routes() @staticmethod - def _is_group_umo(umo: str) -> bool: - umo_lower = umo.lower() - return ":group:" in umo_lower or ":groupmessage:" in umo_lower + def _ok(data: dict | list | None = None) -> dict: + return Response().ok(data).__dict__ @staticmethod - def _is_private_umo(umo: str) -> bool: - umo_lower = umo.lower() - return ( - ":private:" in umo_lower - or ":friend:" in umo_lower - or ":friendmessage:" in umo_lower - ) - - async def _list_known_umos(self) -> list[str]: - async with self.db_helper.get_db() as session: - session: AsyncSession - result = await session.execute(select(ConversationV2.user_id).distinct()) - umos = {str(row[0]) for row in result.fetchall() if row[0]} - - aliases = await self.db_helper.get_umo_aliases() - umos.update(str(alias.umo) for alias in aliases if alias.umo) - return sorted(umos) - - async def _get_umo_alias_map(self, umos: list[str]) -> dict: - return build_umo_alias_map(await self.db_helper.get_umo_aliases(umos)) - - def _build_umo_info(self, umo: str | None, alias_map: dict) -> dict: - umo_str = umo or "" - return { - "umo": umo_str, - **parse_umo(umo_str), - **serialize_umo_alias(alias_map.get(umo_str), umo_str), - } - - async def _get_umos_by_scope( - self, - scope: str, - group_id: str = "", - ) -> list[str]: - if scope == "custom_group": - if not group_id: - raise ValueError("请指定分组 ID") - groups = self._get_groups() - if group_id not in groups: - raise ValueError(f"分组 '{group_id}' 不存在") - return groups[group_id].get("umos", []) - - all_umos = await self._list_known_umos() - if scope == "group": - return [umo for umo in all_umos if self._is_group_umo(umo)] - if scope == "private": - return [umo for umo in all_umos if self._is_private_umo(umo)] - if scope == "all": - return all_umos - return [] - - async def _get_umo_rules( - self, page: int = 1, page_size: int = 10, search: str = "" - ) -> tuple[dict, int]: - """获取所有带有自定义规则的 umo 及其规则内容(支持分页和搜索)。 - - 如果某个 umo 在 preference 中有以下字段,则表示有自定义规则: - - 1. session_service_config (包含了 是否启用这个umo, 这个umo是否启用 llm, 这个umo是否启用tts, umo自定义名称。) - 2. session_plugin_config (包含了 这个 umo 的 plugin set) - 3. provider_perf_{ProviderType.value} (包含了这个 umo 所选择使用的 provider 信息) - 4. kb_config (包含了这个 umo 的知识库相关配置) - - Args: - page: 页码,从 1 开始 - page_size: 每页数量 - search: 搜索关键词,匹配 umo 或 custom_name - - Returns: - tuple[dict, int]: (umo_rules, total) - 分页后的 umo 规则和总数 - """ - umo_rules = {} - async with self.db_helper.get_db() as session: - session: AsyncSession - result = await session.execute( - select(Preference).where( - col(Preference.scope) == "umo", - col(Preference.key).in_(AVAILABLE_SESSION_RULE_KEYS), - ) - ) - prefs = result.scalars().all() - for pref in prefs: - umo_id = pref.scope_id - if umo_id not in umo_rules: - umo_rules[umo_id] = {} - if pref.key == "session_plugin_config" and umo_id in pref.value["val"]: - umo_rules[umo_id][pref.key] = pref.value["val"][umo_id] - else: - umo_rules[umo_id][pref.key] = pref.value["val"] - - alias_map = await self._get_umo_alias_map(list(umo_rules.keys())) - - # 搜索过滤 - if search: - search_lower = search.lower() - filtered_rules = {} - for umo_id, rules in umo_rules.items(): - # 匹配 umo - if search_lower in umo_id.lower(): - filtered_rules[umo_id] = rules - continue - # 匹配 custom_name - svc_config = rules.get("session_service_config", {}) - custom_name = svc_config.get("custom_name", "") if svc_config else "" - if custom_name and search_lower in custom_name.lower(): - filtered_rules[umo_id] = rules - continue + def _error(message: str) -> dict: + return Response().error(message).__dict__ - alias_info = serialize_umo_alias(alias_map.get(umo_id), umo_id) - if any( - search_lower in alias_info[key].lower() - for key in ("auto_name", "user_alias", "display_name") - if alias_info.get(key) - ): - filtered_rules[umo_id] = rules - umo_rules = filtered_rules - - # 获取总数 - total = len(umo_rules) - - # 分页处理 - all_umo_ids = list(umo_rules.keys()) - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size - paginated_umo_ids = all_umo_ids[start_idx:end_idx] - - # 只返回分页后的数据 - paginated_rules = {umo_id: umo_rules[umo_id] for umo_id in paginated_umo_ids} - - return paginated_rules, total - - async def list_session_rule(self): - """获取所有自定义的规则(支持分页和搜索) - - 返回已配置规则的 umo 列表及其规则内容,以及可用的 personas 和 providers + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - Query 参数: - page: 页码,默认为 1 - page_size: 每页数量,默认为 10 - search: 搜索关键词,匹配 umo 或 custom_name - """ + async def _run(self, operation, *, label: str) -> dict: try: - # 获取分页和搜索参数 - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 10, type=int) - search = request.args.get("search", "", type=str).strip() - - # 参数校验 - if page < 1: - page = 1 - if page_size < 1: - page_size = 10 - if page_size > 100: - page_size = 100 - - umo_rules, total = await self._get_umo_rules( - page=page, page_size=page_size, search=search - ) - - # 构建规则列表 - rules_list = [] - alias_map = await self._get_umo_alias_map(list(umo_rules.keys())) - for umo, rules in umo_rules.items(): - rule_info = { - "rules": rules, - **self._build_umo_info(umo, alias_map), - } - rules_list.append(rule_info) - - # 获取可用的 providers 和 personas - provider_manager = self.core_lifecycle.provider_manager - persona_mgr = self.core_lifecycle.persona_mgr - - available_personas = [ - {"name": p["name"], "prompt": p.get("prompt", "")} - for p in persona_mgr.personas_v3 - ] - - available_chat_providers = [ - { - "id": p.meta().id, - "name": p.meta().id, - "model": p.meta().model, - } - for p in provider_manager.provider_insts - ] - - available_stt_providers = [ - { - "id": p.meta().id, - "name": p.meta().id, - "model": p.meta().model, - } - for p in provider_manager.stt_provider_insts - ] - - available_tts_providers = [ - { - "id": p.meta().id, - "name": p.meta().id, - "model": p.meta().model, - } - for p in provider_manager.tts_provider_insts - ] - - # 获取可用的插件列表(排除 reserved 的系统插件) - plugin_manager = self.core_lifecycle.plugin_manager - available_plugins = [ - { - "name": p.name, - "display_name": p.display_name or p.name, - "desc": p.desc, - } - for p in plugin_manager.context.get_all_stars() - if not p.reserved and p.name - ] + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result) + except SessionManagementServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error("%s: %s", label, exc, exc_info=True) + return self._error(f"{label}: {exc!s}") + + async def _run_json(self, operation, *, label: str) -> dict: + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run(invoke, label=label) - # 获取可用的知识库列表 - available_kbs = [] - kb_manager = self.core_lifecycle.kb_manager - if kb_manager: - try: - kbs = await kb_manager.list_kbs() - available_kbs = [ - { - "kb_id": kb.kb_id, - "kb_name": kb.kb_name, - "emoji": kb.emoji, - } - for kb in kbs - ] - except Exception as e: - logger.warning(f"获取知识库列表失败: {e!s}") - - return ( - Response() - .ok( - { - "rules": rules_list, - "total": total, - "page": page, - "page_size": page_size, - "available_personas": available_personas, - "available_chat_providers": available_chat_providers, - "available_stt_providers": available_stt_providers, - "available_tts_providers": available_tts_providers, - "available_plugins": available_plugins, - "available_kbs": available_kbs, - "available_rule_keys": AVAILABLE_SESSION_RULE_KEYS, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取规则列表失败: {e!s}") - return Response().error(f"获取规则列表失败: {e!s}").__dict__ + async def list_session_rule(self): + return await self._run( + self.service.list_session_rules_from_legacy_query( + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 10), + search=request.args.get("search", ""), + ), + label="获取规则列表失败", + ) async def update_session_rule(self): - """更新某个 umo 的自定义规则 - - 请求体: - { - "umo": "平台:消息类型:会话ID", - "rule_key": "session_service_config" | "session_plugin_config" | "kb_config" | "provider_perf_xxx", - "rule_value": {...} // 规则值,具体结构根据 rule_key 不同而不同 - } - """ - try: - data = await request.get_json() - umo = data.get("umo") - rule_key = data.get("rule_key") - rule_value = data.get("rule_value") - - if not umo: - return Response().error("缺少必要参数: umo").__dict__ - if not rule_key: - return Response().error("缺少必要参数: rule_key").__dict__ - if rule_key not in AVAILABLE_SESSION_RULE_KEYS: - return Response().error(f"不支持的规则键: {rule_key}").__dict__ - - if rule_key == "session_plugin_config": - rule_value = { - umo: rule_value, - } - - # 使用 shared preferences 更新规则 - await sp.session_put(umo, rule_key, rule_value) - - return ( - Response() - .ok({"message": f"规则 {rule_key} 已更新", "umo": umo}) - .__dict__ - ) - except Exception as e: - logger.error(f"更新会话规则失败: {e!s}") - return Response().error(f"更新会话规则失败: {e!s}").__dict__ + return await self._run_json( + self.service.update_session_rule, + label="更新会话规则失败", + ) async def delete_session_rule(self): - """删除某个 umo 的自定义规则 - - 请求体: - { - "umo": "平台:消息类型:会话ID", - "rule_key": "session_service_config" | "session_plugin_config" | ... (可选,不传则删除所有规则) - } - """ - try: - data = await request.get_json() - umo = data.get("umo") - rule_key = data.get("rule_key") - - if not umo: - return Response().error("缺少必要参数: umo").__dict__ - - if rule_key: - # 删除单个规则 - if rule_key not in AVAILABLE_SESSION_RULE_KEYS: - return Response().error(f"不支持的规则键: {rule_key}").__dict__ - await sp.session_remove(umo, rule_key) - return ( - Response() - .ok({"message": f"规则 {rule_key} 已删除", "umo": umo}) - .__dict__ - ) - else: - # 删除该 umo 的所有规则 - await sp.clear_async("umo", umo) - return Response().ok({"message": "所有规则已删除", "umo": umo}).__dict__ - except Exception as e: - logger.error(f"删除会话规则失败: {e!s}") - return Response().error(f"删除会话规则失败: {e!s}").__dict__ + return await self._run_json( + self.service.delete_session_rule, + label="删除会话规则失败", + ) async def batch_delete_session_rule(self): - """批量删除多个 umo 的自定义规则 - - 请求体: - { - "umos": ["平台:消息类型:会话ID", ...], // 可选 - "scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围 - "group_id": "分组ID", // 当 scope 为 custom_group 时必填 - "rule_key": "session_service_config" | ... (可选,不传则删除所有规则) - } - """ - - try: - data = await request.get_json() - umos = data.get("umos", []) - scope = data.get("scope", "") - group_id = data.get("group_id", "") - rule_key = data.get("rule_key") - - # 如果指定了 scope,获取符合条件的所有 umo - if scope and not umos: - try: - umos = await self._get_umos_by_scope(scope, group_id) - except ValueError as e: - return Response().error(str(e)).__dict__ - - if not umos: - return Response().error("缺少必要参数: umos 或有效的 scope").__dict__ - - if not isinstance(umos, list): - return Response().error("参数 umos 必须是数组").__dict__ - - if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS: - return Response().error(f"不支持的规则键: {rule_key}").__dict__ - - # 批量删除 - success_count = 0 - failed_umos = [] - for umo in umos: - try: - if rule_key: - await sp.session_remove(umo, rule_key) - else: - await sp.clear_async("umo", umo) - success_count += 1 - except Exception as e: - logger.error(f"删除 umo {umo} 的规则失败: {e!s}") - failed_umos.append(umo) - - message = f"已删除 {success_count} 条规则" - if rule_key: - message = f"已删除 {success_count} 条 {rule_key} 规则" - - if failed_umos: - return ( - Response() - .ok( - { - "message": f"{message},{len(failed_umos)} 条删除失败", - "success_count": success_count, - "failed_umos": failed_umos, - } - ) - .__dict__ - ) - else: - return ( - Response() - .ok( - { - "message": message, - "success_count": success_count, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"批量删除会话规则失败: {e!s}") - return Response().error(f"批量删除会话规则失败: {e!s}").__dict__ + return await self._run_json( + self.service.batch_delete_session_rule, + label="批量删除会话规则失败", + ) async def list_umos(self): - """List known UMOs from conversations and alias records. - - Returns both the legacy string list and structured display metadata. - """ - try: - umos = await self._list_known_umos() - alias_map = await self._get_umo_alias_map(umos) - umo_infos = [self._build_umo_info(umo, alias_map) for umo in umos] - - return Response().ok({"umos": umos, "umo_infos": umo_infos}).__dict__ - except Exception as e: - logger.error(f"获取 UMO 列表失败: {e!s}") - return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__ + return await self._run( + self.service.list_active_umos(), + label="获取 UMO 列表失败", + ) async def list_all_umos_with_status(self): - """获取所有有对话记录的 UMO 及其服务状态(支持分页、搜索、筛选) - - Query 参数: - page: 页码,默认为 1 - page_size: 每页数量,默认为 20 - search: 搜索关键词 - message_type: 筛选消息类型 (group/private/all) - platform: 筛选平台 - """ - try: - page = request.args.get("page", 1, type=int) - page_size = request.args.get("page_size", 20, type=int) - search = request.args.get("search", "", type=str).strip() - message_type = request.args.get("message_type", "all", type=str) - platform = request.args.get("platform", "", type=str) - - if page < 1: - page = 1 - if page_size < 1: - page_size = 20 - if page_size > 100: - page_size = 100 - - all_umos = await self._list_known_umos() - alias_map = await self._get_umo_alias_map(all_umos) - - # 获取所有 umo 的规则配置 - umo_rules, _ = await self._get_umo_rules(page=1, page_size=99999, search="") - - # 构建带状态的 umo 列表 - umos_with_status = [] - for umo in all_umos: - umo_info = self._build_umo_info(umo, alias_map) - umo_platform = umo_info["platform"] - umo_message_type = umo_info["message_type"] - - # 筛选消息类型 - if message_type != "all": - if message_type == "group" and umo_message_type not in [ - "group", - "GroupMessage", - ]: - continue - if message_type == "private" and umo_message_type not in [ - "private", - "FriendMessage", - "friend", - ]: - continue - - # 筛选平台 - if platform and umo_platform != platform: - continue - - # 获取服务配置 - rules = umo_rules.get(umo, {}) - svc_config = rules.get("session_service_config", {}) - - custom_name = svc_config.get("custom_name", "") if svc_config else "" - session_enabled = ( - svc_config.get("session_enabled", True) if svc_config else True - ) - llm_enabled = ( - svc_config.get("llm_enabled", True) if svc_config else True - ) - tts_enabled = ( - svc_config.get("tts_enabled", True) if svc_config else True - ) - - # 搜索过滤 - if search: - search_lower = search.lower() - search_targets = [ - umo, - custom_name, - umo_info["auto_name"], - umo_info["user_alias"], - umo_info["display_name"], - ] - if not any( - search_lower in target.lower() - for target in search_targets - if target - ): - continue - - # 获取 provider 配置 - chat_provider_key = ( - f"provider_perf_{ProviderType.CHAT_COMPLETION.value}" - ) - tts_provider_key = f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}" - stt_provider_key = f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}" - - umos_with_status.append( - { - **umo_info, - "custom_name": custom_name, - "session_enabled": session_enabled, - "llm_enabled": llm_enabled, - "tts_enabled": tts_enabled, - "has_rules": umo in umo_rules, - "chat_provider": rules.get(chat_provider_key), - "tts_provider": rules.get(tts_provider_key), - "stt_provider": rules.get(stt_provider_key), - } - ) - - # 分页 - total = len(umos_with_status) - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size - paginated = umos_with_status[start_idx:end_idx] - - # 获取可用的平台列表 - platforms = list({u["platform"] for u in umos_with_status}) - - # 获取可用的 providers - provider_manager = self.core_lifecycle.provider_manager - available_chat_providers = [ - {"id": p.meta().id, "name": p.meta().id, "model": p.meta().model} - for p in provider_manager.provider_insts - ] - available_tts_providers = [ - {"id": p.meta().id, "name": p.meta().id, "model": p.meta().model} - for p in provider_manager.tts_provider_insts - ] - available_stt_providers = [ - {"id": p.meta().id, "name": p.meta().id, "model": p.meta().model} - for p in provider_manager.stt_provider_insts - ] - - return ( - Response() - .ok( - { - "sessions": paginated, - "total": total, - "page": page, - "page_size": page_size, - "platforms": platforms, - "available_chat_providers": available_chat_providers, - "available_tts_providers": available_tts_providers, - "available_stt_providers": available_stt_providers, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"获取会话状态列表失败: {e!s}") - return Response().error(f"获取会话状态列表失败: {e!s}").__dict__ + return await self._run( + self.service.list_all_umos_with_status_from_legacy_query( + page=request.args.get("page", 1), + page_size=request.args.get("page_size", 20), + search=request.args.get("search", ""), + message_type=request.args.get("message_type", "all"), + platform=request.args.get("platform", ""), + ), + label="获取会话状态列表失败", + ) async def batch_update_service(self): - """批量更新多个 UMO 的服务状态 (LLM/TTS/Session) - - 请求体: - { - "umos": ["平台:消息类型:会话ID", ...], // 可选,如果不传则根据 scope 筛选 - "scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围 - "group_id": "分组ID", // 当 scope 为 custom_group 时必填 - "llm_enabled": true/false/null, // 可选,null表示不修改 - "tts_enabled": true/false/null, // 可选 - "session_enabled": true/false/null // 可选 - } - """ - try: - data = await request.get_json() - umos = data.get("umos", []) - scope = data.get("scope", "") - group_id = data.get("group_id", "") - llm_enabled = data.get("llm_enabled") - tts_enabled = data.get("tts_enabled") - session_enabled = data.get("session_enabled") - - # 如果没有任何修改 - if llm_enabled is None and tts_enabled is None and session_enabled is None: - return Response().error("至少需要指定一个要修改的状态").__dict__ - - # 如果指定了 scope,获取符合条件的所有 umo - if scope and not umos: - try: - umos = await self._get_umos_by_scope(scope, group_id) - except ValueError as e: - return Response().error(str(e)).__dict__ - - if not umos: - return Response().error("没有找到符合条件的会话").__dict__ - - # 批量更新 - success_count = 0 - failed_umos = [] - - for umo in umos: - try: - # 获取现有配置 - session_config = ( - sp.get("session_service_config", {}, scope="umo", scope_id=umo) - or {} - ) - - # 更新状态 - if llm_enabled is not None: - session_config["llm_enabled"] = llm_enabled - if tts_enabled is not None: - session_config["tts_enabled"] = tts_enabled - if session_enabled is not None: - session_config["session_enabled"] = session_enabled - - # 保存 - sp.put( - "session_service_config", - session_config, - scope="umo", - scope_id=umo, - ) - success_count += 1 - except Exception as e: - logger.error(f"更新 {umo} 服务状态失败: {e!s}") - failed_umos.append(umo) - - status_changes = [] - if llm_enabled is not None: - status_changes.append(f"LLM={'启用' if llm_enabled else '禁用'}") - if tts_enabled is not None: - status_changes.append(f"TTS={'启用' if tts_enabled else '禁用'}") - if session_enabled is not None: - status_changes.append(f"会话={'启用' if session_enabled else '禁用'}") - - return ( - Response() - .ok( - { - "message": f"已更新 {success_count} 个会话 ({', '.join(status_changes)})", - "success_count": success_count, - "failed_count": len(failed_umos), - "failed_umos": failed_umos, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"批量更新服务状态失败: {e!s}") - return Response().error(f"批量更新服务状态失败: {e!s}").__dict__ + return await self._run_json( + self.service.batch_update_service, + label="批量更新服务状态失败", + ) async def batch_update_provider(self): - """批量更新多个 UMO 的 Provider 配置 - - 请求体: - { - "umos": ["平台:消息类型:会话ID", ...], // 可选 - "scope": "all" | "group" | "private", // 可选 - "provider_type": "chat_completion" | "text_to_speech" | "speech_to_text", - "provider_id": "provider_id" - } - """ - try: - data = await request.get_json() - umos = data.get("umos", []) - scope = data.get("scope", "") - provider_type = data.get("provider_type") - provider_id = data.get("provider_id") - - if not provider_type or not provider_id: - return ( - Response() - .error("缺少必要参数: provider_type, provider_id") - .__dict__ - ) - - # 转换 provider_type - provider_type_map = { - "chat_completion": ProviderType.CHAT_COMPLETION, - "text_to_speech": ProviderType.TEXT_TO_SPEECH, - "speech_to_text": ProviderType.SPEECH_TO_TEXT, - } - if provider_type not in provider_type_map: - return ( - Response() - .error(f"不支持的 provider_type: {provider_type}") - .__dict__ - ) - - provider_type_enum = provider_type_map[provider_type] - - # 如果指定了 scope,获取符合条件的所有 umo - group_id = data.get("group_id", "") - if scope and not umos: - try: - umos = await self._get_umos_by_scope(scope, group_id) - except ValueError as e: - return Response().error(str(e)).__dict__ - - if not umos: - return Response().error("没有找到符合条件的会话").__dict__ - - # 批量更新 - success_count = 0 - failed_umos = [] - provider_manager = self.core_lifecycle.provider_manager - - for umo in umos: - try: - await provider_manager.set_provider( - provider_id=provider_id, - provider_type=provider_type_enum, - umo=umo, - ) - success_count += 1 - except Exception as e: - logger.error(f"更新 {umo} Provider 失败: {e!s}") - failed_umos.append(umo) - - return ( - Response() - .ok( - { - "message": f"已更新 {success_count} 个会话的 {provider_type} 为 {provider_id}", - "success_count": success_count, - "failed_count": len(failed_umos), - "failed_umos": failed_umos, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"批量更新 Provider 失败: {e!s}") - return Response().error(f"批量更新 Provider 失败: {e!s}").__dict__ - - # ==================== 分组管理 API ==================== - - def _get_groups(self) -> dict: - """获取所有分组""" - return sp.get("session_groups", {}) - - def _save_groups(self, groups: dict) -> None: - """保存分组""" - sp.put("session_groups", groups) + return await self._run_json( + self.service.batch_update_provider, + label="批量更新 Provider 失败", + ) async def list_groups(self): - """获取所有分组列表""" - try: - groups = self._get_groups() - # 转换为列表格式,方便前端使用 - groups_list = [] - for group_id, group_data in groups.items(): - groups_list.append( - { - "id": group_id, - "name": group_data.get("name", ""), - "umos": group_data.get("umos", []), - "umo_count": len(group_data.get("umos", [])), - } - ) - return Response().ok({"groups": groups_list}).__dict__ - except Exception as e: - logger.error(f"获取分组列表失败: {e!s}") - return Response().error(f"获取分组列表失败: {e!s}").__dict__ + return await self._run(self.service.list_groups, label="获取分组列表失败") async def create_group(self): - """创建新分组""" - try: - data = await request.json - name = data.get("name", "").strip() - umos = data.get("umos", []) - - if not name: - return Response().error("分组名称不能为空").__dict__ - - groups = self._get_groups() - - # 生成唯一 ID - import uuid - - group_id = str(uuid.uuid4())[:8] - - groups[group_id] = { - "name": name, - "umos": umos, - } - - self._save_groups(groups) - - return ( - Response() - .ok( - { - "message": f"分组 '{name}' 创建成功", - "group": { - "id": group_id, - "name": name, - "umos": umos, - "umo_count": len(umos), - }, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"创建分组失败: {e!s}") - return Response().error(f"创建分组失败: {e!s}").__dict__ + return await self._run_json( + self.service.create_group, + label="创建分组失败", + ) async def update_group(self): - """更新分组(改名、增删成员)""" - try: - data = await request.json - group_id = data.get("id") - name = data.get("name") - umos = data.get("umos") - add_umos = data.get("add_umos", []) - remove_umos = data.get("remove_umos", []) - - if not group_id: - return Response().error("分组 ID 不能为空").__dict__ - - groups = self._get_groups() - - if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ - - group = groups[group_id] - - # 更新名称 - if name is not None: - group["name"] = name.strip() - - # 直接设置 umos 列表 - if umos is not None: - group["umos"] = umos - else: - # 增量更新 - current_umos = set(group.get("umos", [])) - if add_umos: - current_umos.update(add_umos) - if remove_umos: - current_umos.difference_update(remove_umos) - group["umos"] = list(current_umos) - - self._save_groups(groups) - - return ( - Response() - .ok( - { - "message": f"分组 '{group['name']}' 更新成功", - "group": { - "id": group_id, - "name": group["name"], - "umos": group["umos"], - "umo_count": len(group["umos"]), - }, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(f"更新分组失败: {e!s}") - return Response().error(f"更新分组失败: {e!s}").__dict__ + return await self._run_json( + self.service.update_group, + label="更新分组失败", + ) async def delete_group(self): - """删除分组""" - try: - data = await request.json - group_id = data.get("id") - - if not group_id: - return Response().error("分组 ID 不能为空").__dict__ - - groups = self._get_groups() - - if group_id not in groups: - return Response().error(f"分组 '{group_id}' 不存在").__dict__ - - group_name = groups[group_id].get("name", group_id) - del groups[group_id] + return await self._run_json( + self.service.delete_group, + label="删除分组失败", + ) - self._save_groups(groups) - return Response().ok({"message": f"分组 '{group_name}' 已删除"}).__dict__ - except Exception as e: - logger.error(f"删除分组失败: {e!s}") - return Response().error(f"删除分组失败: {e!s}").__dict__ +__all__ = ["SessionManagementRoute"] diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index c86598212e..68fe3d0592 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -1,80 +1,18 @@ -import os -import re -import shutil -import traceback -from collections.abc import Awaitable, Callable -from pathlib import Path -from typing import Any - -from quart import request, send_file - -from astrbot.core import DEMO_MODE, logger -from astrbot.core.computer.computer_client import ( - _discover_bay_credentials, - sync_skills_to_active_sandboxes, +from astrbot.core import logger +from astrbot.dashboard.fastapi_compat import request, send_file +from astrbot.dashboard.services.skills_service import ( + SkillArchive, + SkillsOperationResult, + SkillsService, + SkillsServiceError, ) -from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager -from astrbot.core.skills.skill_manager import SkillManager -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .route import Response, Route, RouteContext -def _to_jsonable(value: Any) -> Any: - if isinstance(value, dict): - return {k: _to_jsonable(v) for k, v in value.items()} - if isinstance(value, list): - return [_to_jsonable(v) for v in value] - if hasattr(value, "model_dump"): - return _to_jsonable(value.model_dump()) - return value - - -def _to_bool(value: Any, default: bool = False) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - return bool(value) - - -_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") -_SKILL_FILE_MAX_BYTES = 512 * 1024 -_EDITABLE_SKILL_FILE_SUFFIXES = { - ".css", - ".html", - ".ini", - ".js", - ".json", - ".md", - ".py", - ".sh", - ".toml", - ".ts", - ".txt", - ".yaml", - ".yml", -} -_EDITABLE_SKILL_FILENAMES = {"Dockerfile", "Makefile"} - - -def _next_available_temp_path(temp_dir: str, filename: str) -> str: - stem = Path(filename).stem - suffix = Path(filename).suffix - candidate = filename - index = 1 - while os.path.exists(os.path.join(temp_dir, candidate)): - candidate = f"{stem}_{index}{suffix}" - index += 1 - return os.path.join(temp_dir, candidate) - - class SkillsRoute(Route): def __init__(self, context: RouteContext, core_lifecycle) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle self.routes = { "/skills": ("GET", self.get_skills), "/skills/upload": ("POST", self.upload_skill), @@ -97,864 +35,158 @@ def __init__(self, context: RouteContext, core_lifecycle) -> None: "/skills/neo/delete-candidate": ("POST", self.delete_neo_candidate), "/skills/neo/delete-release": ("POST", self.delete_neo_release), } + self.service = SkillsService(core_lifecycle) self.register_routes() - def _resolve_local_skill_dir(self, name: str) -> Path: - skill_name = str(name or "").strip() - if not skill_name: - raise ValueError("Missing skill name") - if not _SKILL_NAME_RE.match(skill_name): - raise ValueError("Invalid skill name") - - skill_mgr = SkillManager() - if skill_mgr.is_sandbox_only_skill(skill_name): - raise PermissionError( - "Sandbox preset skill cannot be opened from local skill files." - ) - - plugin_skill_dir = skill_mgr._get_plugin_skill_dir(skill_name) - if plugin_skill_dir is not None: - return plugin_skill_dir.resolve(strict=True) - - skills_root = Path(skill_mgr.skills_root).resolve(strict=True) - skill_dir = (skills_root / skill_name).resolve(strict=True) - if not skill_dir.is_relative_to(skills_root): - raise PermissionError("Invalid skill path") - if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists(): - raise FileNotFoundError("Local skill not found") - return skill_dir - - def _resolve_skill_relative_path( - self, - skill_dir: Path, - relative_path: str | None, - *, - expect_file: bool, - ) -> Path: - raw_path = str(relative_path or ".").strip() or "." - normalized = Path(raw_path.replace("\\", "/")) - if normalized.is_absolute() or ".." in normalized.parts: - raise ValueError("Invalid relative path") - - target = (skill_dir / normalized).resolve(strict=True) - if not target.is_relative_to(skill_dir): - raise PermissionError("Path escapes skill directory") - if expect_file and not target.is_file(): - raise FileNotFoundError("Skill file not found") - if not expect_file and not target.is_dir(): - raise FileNotFoundError("Skill directory not found") - return target + @staticmethod + def _ok(data: dict | list | None = None, message: str | None = None) -> dict: + return Response().ok(data, message).__dict__ @staticmethod - def _skill_relative_path(skill_dir: Path, target: Path) -> str: - rel = target.relative_to(skill_dir).as_posix() - return "" if rel == "." else rel + def _error(message: str, data: dict | list | None = None) -> dict: + response = Response().error(message) + if data is not None: + response.data = data + return response.__dict__ @staticmethod - def _is_editable_skill_file(path: Path) -> bool: - return ( - path.name in _EDITABLE_SKILL_FILENAMES - or path.suffix.lower() in _EDITABLE_SKILL_FILE_SUFFIXES - ) + def _result(result: SkillsOperationResult) -> dict: + if result.ok: + return SkillsRoute._ok(result.data, result.message) + return SkillsRoute._error(result.message or "", result.data) - def _serialize_skill_file_entry( - self, - skill_dir: Path, - path: Path, - *, - readonly: bool = False, - ) -> dict: - stat = path.stat() - is_dir = path.is_dir() - return { - "name": path.name, - "path": self._skill_relative_path(skill_dir, path), - "type": "directory" if is_dir else "file", - "size": 0 if is_dir else stat.st_size, - "editable": ( - not readonly - and (not is_dir) - and self._is_editable_skill_file(path) - and stat.st_size <= _SKILL_FILE_MAX_BYTES - ), - } + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - def _get_neo_client_config(self) -> tuple[str, str]: - provider_settings = self.core_lifecycle.astrbot_config.get( - "provider_settings", - {}, - ) - sandbox = provider_settings.get("sandbox", {}) - endpoint = sandbox.get("shipyard_neo_endpoint", "") - access_token = sandbox.get("shipyard_neo_access_token", "") - - # Auto-discover token from Bay's credentials.json if not configured - if not access_token and endpoint: - access_token = _discover_bay_credentials(endpoint) - - if not endpoint or not access_token: - raise ValueError( - "Shipyard Neo endpoint or access token not configured. " - "Set them in Dashboard or ensure Bay's credentials.json is accessible." - ) - return endpoint, access_token - - async def _delete_neo_release( - self, client: Any, release_id: str, reason: str | None - ): - return await client.skills.delete_release(release_id, reason=reason) - - async def _delete_neo_candidate( - self, client: Any, candidate_id: str, reason: str | None - ): - return await client.skills.delete_candidate(candidate_id, reason=reason) - - async def _with_neo_client( - self, - operation: Callable[[Any], Awaitable[dict]], - ) -> dict: + async def _run(self, operation, *, trace: bool = True): try: - endpoint, access_token = self._get_neo_client_config() - - from shipyard_neo import BayClient - - async with BayClient( - endpoint_url=endpoint, - access_token=access_token, - ) as client: - return await operation(client) - except ValueError as e: - # Config not ready — expected when Neo isn't set up yet - logger.debug("[Neo] %s", e) - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + if isinstance(result, SkillsOperationResult): + return self._result(result) + return self._ok(result) + except SkillsServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error(str(exc), exc_info=trace) + return self._error(str(exc)) + + async def _run_json(self, operation, *, trace: bool = True): + async def invoke(): + data = await self._json_body() + return operation(data) + + return await self._run(invoke, trace=trace) async def get_skills(self): - try: - provider_settings = self.core_lifecycle.astrbot_config.get( - "provider_settings", {} - ) - runtime = provider_settings.get("computer_use_runtime", "local") - skill_mgr = SkillManager() - skills = skill_mgr.list_skills( - active_only=False, runtime=runtime, show_sandbox_path=False - ) - return ( - Response() - .ok( - { - "skills": [skill.__dict__ for skill in skills], - "runtime": runtime, - "sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(), - } - ) - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run(self.service.get_skills) async def upload_skill(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - temp_path = None - try: + async def _operation(): files = await request.files - file = files.get("file") - if not file: - return Response().error("Missing file").__dict__ - filename = os.path.basename(file.filename or "skill.zip") - if not filename.lower().endswith(".zip"): - return Response().error("Only .zip files are supported").__dict__ - - temp_dir = get_astrbot_temp_path() - os.makedirs(temp_dir, exist_ok=True) - skill_mgr = SkillManager() - temp_path = _next_available_temp_path(temp_dir, filename) - await file.save(temp_path) - - try: - try: - skill_name = skill_mgr.install_skill_from_zip( - temp_path, overwrite=False, skill_name_hint=Path(filename).stem - ) - except TypeError: - # Backward compatibility for callers that do not accept skill_name_hint - skill_name = skill_mgr.install_skill_from_zip( - temp_path, overwrite=False - ) - except Exception: - # Keep behavior consistent with previous implementation - # and bubble up install errors (including duplicates). - raise - - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync uploaded skills to active sandboxes.") - - return ( - Response() - .ok({"name": skill_name}, "Skill uploaded successfully.") - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - finally: - if temp_path and os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception: - logger.warning(f"Failed to remove temp skill file: {temp_path}") + return await self.service.upload_skill(files.get("file")) - async def batch_upload_skills(self): - """批量上传多个 skill ZIP 文件""" - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) + return await self._run(_operation) - try: + async def batch_upload_skills(self): + async def _operation(): files = await request.files - file_list = files.getlist("files") - - if not file_list: - return Response().error("No files provided").__dict__ - - succeeded = [] - failed = [] - skipped = [] - skill_mgr = SkillManager() - temp_dir = get_astrbot_temp_path() - os.makedirs(temp_dir, exist_ok=True) - - for file in file_list: - filename = os.path.basename(file.filename or "unknown.zip") - temp_path = None - - try: - if not filename.lower().endswith(".zip"): - failed.append( - { - "filename": filename, - "error": "Only .zip files are supported", - } - ) - continue - - temp_path = _next_available_temp_path(temp_dir, filename) - await file.save(temp_path) - - try: - skill_name = skill_mgr.install_skill_from_zip( - temp_path, - overwrite=False, - skill_name_hint=Path(filename).stem, - ) - except TypeError: - # Backward compatibility for monkeypatched implementations in tests - try: - skill_name = skill_mgr.install_skill_from_zip( - temp_path, overwrite=False - ) - except FileExistsError: - skipped.append( - { - "filename": filename, - "name": Path(filename).stem, - "error": "Skill already exists.", - } - ) - skill_name = None - except FileExistsError: - skipped.append( - { - "filename": filename, - "name": Path(filename).stem, - "error": "Skill already exists.", - } - ) - skill_name = None - - if skill_name is None: - continue - succeeded.append({"filename": filename, "name": skill_name}) - - except Exception as e: - failed.append({"filename": filename, "error": str(e)}) - finally: - if temp_path and os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception: - pass - - if succeeded: - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning( - "Failed to sync uploaded skills to active sandboxes." - ) - - total = len(file_list) - success_count = len(succeeded) - skipped_count = len(skipped) - failed_count = len(failed) - - if failed_count == 0 and success_count == total: - message = f"All {total} skill(s) uploaded successfully." - return ( - Response() - .ok( - { - "total": total, - "succeeded": succeeded, - "failed": failed, - "skipped": skipped, - }, - message, - ) - .__dict__ - ) - if failed_count == 0 and success_count == 0: - message = f"All {total} file(s) were skipped." - return ( - Response() - .ok( - { - "total": total, - "succeeded": succeeded, - "failed": failed, - "skipped": skipped, - }, - message, - ) - .__dict__ - ) - if success_count == 0 and skipped_count == 0: - message = f"Upload failed for all {total} file(s)." - resp = Response().error(message) - resp.data = { - "total": total, - "succeeded": succeeded, - "failed": failed, - "skipped": skipped, - } - return resp.__dict__ - - message = f"Partial success: {success_count}/{total} skill(s) uploaded." - return ( - Response() - .ok( - { - "total": total, - "succeeded": succeeded, - "failed": failed, - "skipped": skipped, - }, - message, - ) - .__dict__ - ) + return await self.service.batch_upload_skills(files.getlist("files")) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run(_operation) async def download_skill(self): try: - name = str(request.args.get("name") or "").strip() - if not name: - return Response().error("Missing skill name").__dict__ - if not _SKILL_NAME_RE.match(name): - return Response().error("Invalid skill name").__dict__ - - skill_mgr = SkillManager() - if skill_mgr.is_sandbox_only_skill(name): - return ( - Response() - .error( - "Sandbox preset skill cannot be downloaded from local skill files." - ) - .__dict__ - ) - if skill_mgr.is_plugin_skill(name): - return ( - Response() - .error( - "Plugin-provided skill cannot be downloaded from local skill files." - ) - .__dict__ - ) - - skill_dir = Path(skill_mgr.skills_root) / name - skill_md = skill_dir / "SKILL.md" - if not skill_dir.is_dir() or not skill_md.exists(): - return Response().error("Local skill not found").__dict__ - - export_dir = Path(get_astrbot_temp_path()) / "skill_exports" - export_dir.mkdir(parents=True, exist_ok=True) - zip_base = export_dir / name - zip_path = zip_base.with_suffix(".zip") - if zip_path.exists(): - zip_path.unlink() - - shutil.make_archive( - str(zip_base), - "zip", - root_dir=str(skill_mgr.skills_root), - base_dir=name, + archive = self.service.prepare_skill_archive_from_legacy_query( + request.args.get("name") ) - + if not isinstance(archive, SkillArchive): + raise TypeError("Invalid skill archive result") return await send_file( - str(zip_path), + str(archive.path), as_attachment=True, - attachment_filename=f"{name}.zip", + attachment_filename=archive.filename, conditional=True, ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + except SkillsServiceError as exc: + return self._error(str(exc)) + except Exception as exc: + logger.error(str(exc), exc_info=True) + return self._error(str(exc)) async def list_skill_files(self): - try: - name = str(request.args.get("name") or "").strip() - relative_path = request.args.get("path", "") - readonly = SkillManager().is_plugin_skill(name) - skill_dir = self._resolve_local_skill_dir(name) - target_dir = self._resolve_skill_relative_path( - skill_dir, - relative_path, - expect_file=False, - ) - - entries = [] - for entry in sorted( - target_dir.iterdir(), - key=lambda item: (not item.is_dir(), item.name.lower()), - ): - try: - resolved = entry.resolve(strict=True) - except OSError: - continue - if not resolved.is_relative_to(skill_dir): - continue - if not resolved.is_dir() and not resolved.is_file(): - continue - entries.append( - self._serialize_skill_file_entry( - skill_dir, - resolved, - readonly=readonly, - ) - ) - - return ( - Response() - .ok( - { - "name": name, - "path": self._skill_relative_path(skill_dir, target_dir), - "entries": entries, - } - ) - .__dict__ + return await self._run( + lambda: self.service.list_skill_files_from_legacy_query( + name=request.args.get("name"), + relative_path=request.args.get("path", ""), ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + ) async def get_skill_file(self): - try: - name = str(request.args.get("name") or "").strip() - relative_path = request.args.get("path", "SKILL.md") - skill_dir = self._resolve_local_skill_dir(name) - target_file = self._resolve_skill_relative_path( - skill_dir, - relative_path, - expect_file=True, - ) - if not self._is_editable_skill_file(target_file): - return Response().error("Unsupported file type").__dict__ - - size = target_file.stat().st_size - if size > _SKILL_FILE_MAX_BYTES: - return Response().error("File is too large").__dict__ - - try: - content = target_file.read_text(encoding="utf-8") - except UnicodeDecodeError: - return Response().error("File is not valid UTF-8 text").__dict__ - - return ( - Response() - .ok( - { - "name": name, - "path": self._skill_relative_path(skill_dir, target_file), - "content": content, - "size": size, - "editable": not SkillManager().is_plugin_skill(name), - } - ) - .__dict__ + return await self._run( + lambda: self.service.get_skill_file_from_legacy_query( + name=request.args.get("name"), + relative_path=request.args.get("path", "SKILL.md"), ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + ) async def update_skill_file(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - try: - data = await request.get_json() - name = str(data.get("name") or "").strip() - relative_path = data.get("path", "SKILL.md") - content = data.get("content") - if not isinstance(content, str): - return Response().error("Missing file content").__dict__ - - encoded = content.encode("utf-8") - if len(encoded) > _SKILL_FILE_MAX_BYTES: - return Response().error("File content is too large").__dict__ - - skill_dir = self._resolve_local_skill_dir(name) - if SkillManager().is_plugin_skill(name): - return Response().error("Plugin-provided skill is read-only.").__dict__ - target_file = self._resolve_skill_relative_path( - skill_dir, - relative_path, - expect_file=True, - ) - if not self._is_editable_skill_file(target_file): - return Response().error("Unsupported file type").__dict__ - - target_file.write_text(content, encoding="utf-8") - - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync edited skills to active sandboxes.") - - return ( - Response() - .ok( - { - "name": name, - "path": self._skill_relative_path(skill_dir, target_file), - "size": len(encoded), - } - ) - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run_json(self.service.update_skill_file) async def update_skill(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - try: - data = await request.get_json() - name = data.get("name") - active = data.get("active", True) - if not name: - return Response().error("Missing skill name").__dict__ - SkillManager().set_skill_active(name, bool(active)) - return Response().ok({"name": name, "active": bool(active)}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run_json(self.service.update_skill) async def delete_skill(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - try: - data = await request.get_json() - name = data.get("name") - if not name: - return Response().error("Missing skill name").__dict__ - SkillManager().delete_skill(name) - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync deleted skills to active sandboxes.") - return Response().ok({"name": name}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ + return await self._run_json(self.service.delete_skill) async def get_neo_candidates(self): - logger.info("[Neo] GET /skills/neo/candidates requested.") - status = request.args.get("status") - skill_key = request.args.get("skill_key") - limit = int(request.args.get("limit", 100)) - offset = int(request.args.get("offset", 0)) - - async def _do(client): - candidates = await client.skills.list_candidates( - status=status, - skill_key=skill_key, - limit=limit, - offset=offset, + return await self._run( + self.service.get_neo_candidates_from_legacy_query( + status=request.args.get("status"), + skill_key=request.args.get("skill_key"), + limit=request.args.get("limit"), + offset=request.args.get("offset"), ) - result = _to_jsonable(candidates) - total = result.get("total", "?") if isinstance(result, dict) else "?" - logger.info(f"[Neo] Candidates fetched: total={total}") - return Response().ok(result).__dict__ - - return await self._with_neo_client(_do) + ) async def get_neo_releases(self): - logger.info("[Neo] GET /skills/neo/releases requested.") - skill_key = request.args.get("skill_key") - stage = request.args.get("stage") - active_only = _to_bool(request.args.get("active_only"), False) - limit = int(request.args.get("limit", 100)) - offset = int(request.args.get("offset", 0)) - - async def _do(client): - releases = await client.skills.list_releases( - skill_key=skill_key, - active_only=active_only, - stage=stage, - limit=limit, - offset=offset, + return await self._run( + self.service.get_neo_releases_from_legacy_query( + skill_key=request.args.get("skill_key"), + stage=request.args.get("stage"), + active_only=request.args.get("active_only"), + limit=request.args.get("limit"), + offset=request.args.get("offset"), ) - result = _to_jsonable(releases) - total = result.get("total", "?") if isinstance(result, dict) else "?" - logger.info(f"[Neo] Releases fetched: total={total}") - return Response().ok(result).__dict__ - - return await self._with_neo_client(_do) + ) async def get_neo_payload(self): - logger.info("[Neo] GET /skills/neo/payload requested.") - payload_ref = request.args.get("payload_ref", "") - if not payload_ref: - return Response().error("Missing payload_ref").__dict__ - - async def _do(client): - payload = await client.skills.get_payload(payload_ref) - logger.info(f"[Neo] Payload fetched: ref={payload_ref}") - return Response().ok(_to_jsonable(payload)).__dict__ - - return await self._with_neo_client(_do) - - async def evaluate_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/evaluate requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - passed_value = data.get("passed") - if not candidate_id or passed_value is None: - return Response().error("Missing candidate_id or passed").__dict__ - passed = _to_bool(passed_value, False) - - async def _do(client): - result = await client.skills.evaluate_candidate( - candidate_id, - passed=passed, - score=data.get("score"), - benchmark_id=data.get("benchmark_id"), - report=data.get("report"), + return await self._run( + self.service.get_neo_payload_from_legacy_query( + request.args.get("payload_ref") ) - logger.info( - f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}" - ) - return Response().ok(_to_jsonable(result)).__dict__ + ) - return await self._with_neo_client(_do) + async def evaluate_neo_candidate(self): + return await self._run_json(self.service.evaluate_neo_candidate) async def promote_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/promote requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - stage = data.get("stage", "canary") - sync_to_local = _to_bool(data.get("sync_to_local"), True) - if not candidate_id: - return Response().error("Missing candidate_id").__dict__ - if stage not in {"canary", "stable"}: - return Response().error("Invalid stage, must be canary/stable").__dict__ - - async def _do(client): - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.promote_with_optional_sync( - client, - candidate_id=candidate_id, - stage=stage, - sync_to_local=sync_to_local, - ) - release_json = result.get("release") - logger.info(f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}") - - sync_json = result.get("sync") - did_sync_to_local = bool(sync_json) - if did_sync_to_local: - logger.info( - f"[Neo] Stable release synced to local: skill={sync_json.get('local_skill_name', '')}" - ) - - if result.get("sync_error"): - resp = Response().error( - "Stable promote synced failed and has been rolled back. " - f"sync_error={result['sync_error']}" - ) - resp.data = { - "release": release_json, - "rollback": result.get("rollback"), - } - return resp.__dict__ - - # Try to push latest local skills to all active sandboxes. - if not did_sync_to_local: - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync skills to active sandboxes.") - - return Response().ok({"release": release_json, "sync": sync_json}).__dict__ - - return await self._with_neo_client(_do) + return await self._run_json(self.service.promote_neo_candidate) async def rollback_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/rollback requested.") - data = await request.get_json() - release_id = data.get("release_id") - if not release_id: - return Response().error("Missing release_id").__dict__ - - async def _do(client): - result = await client.skills.rollback_release(release_id) - logger.info(f"[Neo] Release rolled back: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) + return await self._run_json(self.service.rollback_neo_release) async def sync_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/sync requested.") - data = await request.get_json() - release_id = data.get("release_id") - skill_key = data.get("skill_key") - require_stable = _to_bool(data.get("require_stable"), True) - if not release_id and not skill_key: - return Response().error("Missing release_id or skill_key").__dict__ - - async def _do(client): - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.sync_release( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ) - logger.info( - f"[Neo] Release synced to local: skill={result.local_skill_name}, " - f"release_id={result.release_id}" - ) - return ( - Response() - .ok( - { - "skill_key": result.skill_key, - "local_skill_name": result.local_skill_name, - "release_id": result.release_id, - "candidate_id": result.candidate_id, - "payload_ref": result.payload_ref, - "map_path": result.map_path, - "synced_at": result.synced_at, - } - ) - .__dict__ - ) - - return await self._with_neo_client(_do) + return await self._run_json(self.service.sync_neo_release) async def delete_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/delete-candidate requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - reason = data.get("reason") - if not candidate_id: - return Response().error("Missing candidate_id").__dict__ - - async def _do(client): - result = await self._delete_neo_candidate(client, candidate_id, reason) - logger.info(f"[Neo] Candidate deleted: id={candidate_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) + return await self._run_json(self.service.delete_neo_candidate) async def delete_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/delete-release requested.") - data = await request.get_json() - release_id = data.get("release_id") - reason = data.get("reason") - if not release_id: - return Response().error("Missing release_id").__dict__ + return await self._run_json(self.service.delete_neo_release) - async def _do(client): - result = await self._delete_neo_release(client, release_id, reason) - logger.info(f"[Neo] Release deleted: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ - return await self._with_neo_client(_do) +__all__ = ["SkillsRoute"] diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 060e4c4e27..c4cca3c66d 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -1,38 +1,7 @@ -import asyncio -import os -import re -import threading -import time -import traceback -from collections import defaultdict -from datetime import datetime, timedelta, timezone -from functools import cmp_to_key -from pathlib import Path - -import aiohttp -import psutil -from quart import request -from sqlmodel import col, select - -from astrbot.core import DEMO_MODE, logger -from astrbot.core.config import VERSION from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase -from astrbot.core.db.migration.helper import check_migration_needed_v4 -from astrbot.core.db.po import ProviderStat -from astrbot.core.utils.astrbot_path import get_astrbot_path -from astrbot.core.utils.auth_password import ( - is_default_dashboard_password, - is_legacy_dashboard_password, -) -from astrbot.core.utils.io import get_dashboard_version -from astrbot.core.utils.storage_cleaner import StorageCleaner -from astrbot.core.utils.version_comparator import VersionComparator -from astrbot.dashboard.password_state import ( - get_dashboard_password_hash, - is_password_change_required, - is_password_storage_upgraded, -) +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.stat_service import StatService, StatServiceError from .route import Response, Route, RouteContext @@ -58,533 +27,79 @@ def __init__( "/stat/storage": ("GET", self.get_storage_status), "/stat/storage/cleanup": ("POST", self.cleanup_storage), } - self.db_helper = db_helper + self.service = StatService(db_helper, core_lifecycle, self.config) self.register_routes() - self.core_lifecycle = core_lifecycle - self.storage_cleaner = StorageCleaner(self.config) - async def restart_core(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) + @staticmethod + def _ok(data=None): + return Response().ok(data).__dict__ - await self.core_lifecycle.restart() - return Response().ok().__dict__ + @staticmethod + def _error(message: str): + return Response().error(message).__dict__ - def _get_running_time_components(self, total_seconds: int): - """将总秒数转换为时分秒组件""" - minutes, seconds = divmod(total_seconds, 60) - hours, minutes = divmod(minutes, 60) - return {"hours": hours, "minutes": minutes, "seconds": seconds} + async def _run(self, operation): + try: + return self._ok(await operation()) + except StatServiceError as exc: + return self._error(str(exc)) - async def is_default_cred(self): - password_change_required = await is_password_change_required( - self.db_helper, - self.config, - ) - if password_change_required: - return not DEMO_MODE + async def _run_sync(self, operation): + try: + return self._ok(operation()) + except StatServiceError as exc: + return self._error(str(exc)) - storage_upgraded = await is_password_storage_upgraded( - self.db_helper, - self.config, - ) - if not storage_upgraded: - return False + async def _run_json(self, operation, *, silent: bool = False): + payload = await request.get_json(silent=silent) + return await self._run(lambda: operation(payload)) - username = self.config["dashboard"]["username"] - password = get_dashboard_password_hash(self.config, upgraded=True) - return ( - username == "astrbot" and is_default_dashboard_password(password) - ) and not DEMO_MODE + async def restart_core(self): + return await self._run(self.service.restart_core) async def get_version(self): - need_migration = await check_migration_needed_v4(self.core_lifecycle.db) - storage_upgraded = await is_password_storage_upgraded( - self.db_helper, - self.config, - ) - password = get_dashboard_password_hash( - self.config, - upgraded=storage_upgraded, - ) - - return ( - Response() - .ok( - { - "version": VERSION, - "dashboard_version": await get_dashboard_version(), - "change_pwd_hint": await self.is_default_cred(), - "legacy_pwd_hint": is_legacy_dashboard_password(password), - "password_upgrade_required": not storage_upgraded, - "need_migration": need_migration, - }, - ) - .__dict__ - ) + return await self._run(self.service.get_version) async def get_start_time(self): - return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__ + return await self._run_sync(self.service.get_start_time) async def get_storage_status(self): - try: - status = await asyncio.to_thread(self.storage_cleaner.get_status) - return Response().ok(status).__dict__ - except Exception: - logger.error("获取存储占用失败", exc_info=True) - return ( - Response().error("获取存储占用失败,请查看后端日志了解详情。").__dict__ - ) + return await self._run(self.service.get_storage_status) async def cleanup_storage(self): - try: - data = await request.get_json(silent=True) - target = "all" - if isinstance(data, dict): - target = str(data.get("target", "all")) - - result = await asyncio.to_thread(self.storage_cleaner.cleanup, target) - return Response().ok(result).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception: - logger.error("清理存储失败", exc_info=True) - return Response().error("清理存储失败,请查看后端日志了解详情。").__dict__ + return await self._run_json( + self.service.cleanup_storage_from_legacy_payload, + silent=True, + ) async def get_stat(self): - offset_sec = request.args.get("offset_sec", 86400) - offset_sec = int(offset_sec) - try: - stat = self.db_helper.get_base_stats(offset_sec) - now = int(time.time()) - start_time = now - offset_sec - message_time_based_stats = [] - - idx = 0 - for bucket_end in range(start_time, now, 3600): - cnt = 0 - while ( - idx < len(stat.platform) - and stat.platform[idx].timestamp < bucket_end - ): - cnt += stat.platform[idx].count - idx += 1 - message_time_based_stats.append([bucket_end, cnt]) - - stat_dict = stat.__dict__ - - cpu_percent = psutil.cpu_percent(interval=0.5) - thread_count = threading.active_count() - - # 获取插件信息 - plugins = self.core_lifecycle.star_context.get_all_stars() - plugin_info = [] - for plugin in plugins: - info = { - "name": getattr(plugin, "name", plugin.__class__.__name__), - "version": getattr(plugin, "version", "1.0.0"), - "is_enabled": True, - } - plugin_info.append(info) - - # 计算运行时长组件 - running_time = self._get_running_time_components( - int(time.time()) - self.core_lifecycle.start_time, - ) - - stat_dict.update( - { - "platform": self.db_helper.get_grouped_base_stats( - offset_sec, - ).platform, - "message_count": self.db_helper.get_total_message_count() or 0, - "platform_count": len( - self.core_lifecycle.platform_manager.get_insts(), - ), - "plugin_count": len(plugins), - "plugins": plugin_info, - "message_time_series": message_time_based_stats, - "running": running_time, # 现在返回时间组件而不是格式化的字符串 - "memory": { - "process": psutil.Process().memory_info().rss >> 20, - "system": psutil.virtual_memory().total >> 20, - }, - "cpu_percent": round(cpu_percent, 1), - "thread_count": thread_count, - "start_time": self.core_lifecycle.start_time, - }, + return await self._run( + lambda: self.service.get_stat_from_legacy_query( + request.args.get("offset_sec", 86400) ) - - return Response().ok(stat_dict).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(e.__str__()).__dict__ - - @staticmethod - def _ensure_aware_utc(value: datetime) -> datetime: - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) + ) async def get_provider_token_stats(self): - try: - try: - days = int(request.args.get("days", 1)) - except (TypeError, ValueError): - days = 1 - if days not in (1, 3, 7): - days = 1 - - local_tz = datetime.now().astimezone().tzinfo or timezone.utc - now_local = datetime.now(local_tz) - range_start_local = (now_local - timedelta(days=days)).replace( - minute=0, second=0, microsecond=0 - ) - today_start_local = now_local.replace( - hour=0, minute=0, second=0, microsecond=0 - ) - query_start_local = min(range_start_local, today_start_local) - query_start_utc = query_start_local.astimezone(timezone.utc) - - async with self.db_helper.get_db() as session: - result = await session.execute( - select(ProviderStat) - .where( - ProviderStat.agent_type == "internal", - ProviderStat.created_at >= query_start_utc, - ) - .order_by(col(ProviderStat.created_at).asc()) - ) - records = result.scalars().all() - - bucket_timestamps: list[int] = [] - bucket_cursor = range_start_local - while bucket_cursor <= now_local: - bucket_timestamps.append(int(bucket_cursor.timestamp() * 1000)) - bucket_cursor += timedelta(hours=1) - - trend_by_provider: dict[str, dict[int, int]] = defaultdict( - lambda: defaultdict(int) - ) - total_by_provider: dict[str, int] = defaultdict(int) - total_by_umo: dict[str, int] = defaultdict(int) - total_by_bucket: dict[int, int] = defaultdict(int) - range_total_tokens = 0 - range_total_output_tokens = 0 - range_total_calls = 0 - range_success_calls = 0 - range_ttft_total_ms = 0.0 - range_ttft_samples = 0 - range_duration_total_ms = 0.0 - range_duration_samples = 0 - today_by_model: dict[str, int] = defaultdict(int) - today_by_provider: dict[str, int] = defaultdict(int) - today_total_tokens = 0 - today_total_calls = 0 - - for record in records: - created_at_utc = self._ensure_aware_utc(record.created_at) - created_at_local = created_at_utc.astimezone(local_tz) - token_total = ( - record.token_input_other - + record.token_input_cached - + record.token_output - ) - provider_id = record.provider_id or "unknown" - provider_model = record.provider_model or "Unknown" - - if created_at_local >= range_start_local: - bucket_local = created_at_local.replace( - minute=0, second=0, microsecond=0 - ) - bucket_ts = int(bucket_local.timestamp() * 1000) - trend_by_provider[provider_id][bucket_ts] += token_total - total_by_provider[provider_id] += token_total - total_by_umo[record.umo or "unknown"] += token_total - total_by_bucket[bucket_ts] += token_total - range_total_tokens += token_total - range_total_calls += 1 - if record.status != "error": - range_success_calls += 1 - if record.time_to_first_token > 0: - range_ttft_total_ms += record.time_to_first_token * 1000 - range_ttft_samples += 1 - if record.end_time > record.start_time: - range_duration_total_ms += ( - record.end_time - record.start_time - ) * 1000 - range_duration_samples += 1 - range_total_output_tokens += record.token_output - - if created_at_local >= today_start_local: - today_total_calls += 1 - today_total_tokens += token_total - today_by_model[provider_model] += token_total - today_by_provider[provider_id] += token_total - - sorted_provider_ids = sorted( - total_by_provider.keys(), - key=lambda item: total_by_provider[item], - reverse=True, + return await self._run( + lambda: self.service.get_provider_token_stats_from_legacy_query( + request.args.get("days", 1) ) - - series = [ - { - "name": provider_id, - "data": [ - [bucket_ts, trend_by_provider[provider_id].get(bucket_ts, 0)] - for bucket_ts in bucket_timestamps - ], - "total_tokens": total_by_provider[provider_id], - } - for provider_id in sorted_provider_ids - ] - - total_series = [ - [bucket_ts, total_by_bucket.get(bucket_ts, 0)] - for bucket_ts in bucket_timestamps - ] - - today_by_model_data = [ - {"provider_model": model_name, "tokens": tokens} - for model_name, tokens in sorted( - today_by_model.items(), - key=lambda item: item[1], - reverse=True, - ) - ] - today_by_provider_data = [ - {"provider_id": provider_id, "tokens": tokens} - for provider_id, tokens in sorted( - today_by_provider.items(), - key=lambda item: item[1], - reverse=True, - ) - ] - range_by_provider_data = [ - {"provider_id": provider_id, "tokens": tokens} - for provider_id, tokens in sorted( - total_by_provider.items(), - key=lambda item: item[1], - reverse=True, - ) - ] - range_by_umo_data = [ - {"umo": umo, "tokens": tokens} - for umo, tokens in sorted( - total_by_umo.items(), - key=lambda item: item[1], - reverse=True, - ) - ] - - return ( - Response() - .ok( - { - "days": days, - "trend": { - "series": series, - "total_series": total_series, - }, - "range_total_tokens": range_total_tokens, - "range_total_calls": range_total_calls, - "range_avg_ttft_ms": ( - range_ttft_total_ms / range_ttft_samples - if range_ttft_samples - else 0 - ), - "range_avg_duration_ms": ( - range_duration_total_ms / range_duration_samples - if range_duration_samples - else 0 - ), - "range_avg_tpm": ( - range_total_output_tokens - / (range_duration_total_ms / 1000 / 60) - if range_duration_total_ms > 0 - else 0 - ), - "range_success_rate": ( - range_success_calls / range_total_calls - if range_total_calls - else 0 - ), - "range_by_provider": range_by_provider_data, - "range_by_umo": range_by_umo_data, - "today_total_tokens": today_total_tokens, - "today_total_calls": today_total_calls, - "today_by_model": today_by_model_data, - "today_by_provider": today_by_provider_data, - } - ) - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + ) async def test_ghproxy_connection(self): - """测试 GitHub 代理连接是否可用。""" - try: - data = await request.get_json() - proxy_url: str = data.get("proxy_url") - - if not proxy_url: - return Response().error("proxy_url is required").__dict__ - - proxy_url = proxy_url.rstrip("/") - - test_url = f"{proxy_url}/https://github.com/AstrBotDevs/AstrBot/raw/refs/heads/master/.python-version" - start_time = time.time() - - async with ( - aiohttp.ClientSession() as session, - session.get( - test_url, - timeout=aiohttp.ClientTimeout(total=10), - ) as response, - ): - if response.status == 200: - end_time = time.time() - _ = await response.text() - ret = { - "latency": round((end_time - start_time) * 1000, 2), - } - return Response().ok(data=ret).__dict__ - return ( - Response().error(f"Failed. Status code: {response.status}").__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return await self._run_json( + self.service.test_ghproxy_connection_from_legacy_payload + ) async def get_changelog(self): - """获取指定版本的更新日志""" - try: - version = request.args.get("version") - if not version: - return Response().error("version parameter is required").__dict__ - - version = version.lstrip("v") - - # 防止路径遍历攻击 - if not re.match(r"^[a-zA-Z0-9._-]+$", version): - return Response().error("Invalid version format").__dict__ - if ".." in version or "/" in version or "\\" in version: - return Response().error("Invalid version format").__dict__ - - filename = f"v{version}.md" - project_path = get_astrbot_path() - changelogs_dir = os.path.join(project_path, "changelogs") - changelog_path = os.path.join(changelogs_dir, filename) - - # 规范化路径,防止符号链接攻击 - changelog_path = os.path.realpath(changelog_path) - changelogs_dir = os.path.realpath(changelogs_dir) - - # 验证最终路径在预期的 changelogs 目录内(防止路径遍历) - # 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件 - changelog_path_normalized = os.path.normpath(changelog_path) - changelogs_dir_normalized = os.path.normpath(changelogs_dir) - - # 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身) - expected_prefix = changelogs_dir_normalized + os.sep - if not changelog_path_normalized.startswith(expected_prefix): - logger.warning( - f"Path traversal attempt detected: {version} -> {changelog_path}", - ) - return Response().error("Invalid version format").__dict__ - - if not os.path.exists(changelog_path): - return ( - Response() - .error(f"Changelog for version {version} not found") - .__dict__ - ) - if not os.path.isfile(changelog_path): - return ( - Response() - .error(f"Changelog for version {version} not found") - .__dict__ - ) - - with open(changelog_path, encoding="utf-8") as f: - content = f.read() - - return Response().ok({"content": content, "version": version}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return await self._run_sync( + lambda: self.service.get_changelog(request.args.get("version")) + ) async def list_changelog_versions(self): - """获取所有可用的更新日志版本列表""" - try: - project_path = get_astrbot_path() - changelogs_dir = os.path.join(project_path, "changelogs") - - if not os.path.exists(changelogs_dir): - return Response().ok({"versions": []}).__dict__ - - versions = [] - for filename in os.listdir(changelogs_dir): - if filename.endswith(".md") and filename.startswith("v"): - # 提取版本号(去除 v 前缀和 .md 后缀) - version = filename[1:-3] # 去掉 "v" 和 ".md" - # 验证版本号格式 - if re.match(r"^[a-zA-Z0-9._-]+$", version): - versions.append(version) - - # 按版本号排序(降序,最新的在前) - # 使用项目中的 VersionComparator 进行语义化版本号排序 - versions.sort( - key=cmp_to_key( - lambda v1, v2: VersionComparator.compare_version(v2, v1), - ), - ) - - return Response().ok({"versions": versions}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return await self._run_sync(self.service.list_changelog_versions) async def get_first_notice(self): - """读取项目根目录 FIRST_NOTICE.md 内容。""" - try: - locale = (request.args.get("locale") or "").strip() - if not re.match(r"^[A-Za-z0-9_-]*$", locale): - locale = "" - - base_path = Path(get_astrbot_path()) - candidates: list[Path] = [] - - if locale: - candidates.append(base_path / f"FIRST_NOTICE.{locale}.md") - if locale.lower().startswith("zh"): - candidates.append(base_path / "FIRST_NOTICE.md") - candidates.append(base_path / "FIRST_NOTICE.zh-CN.md") - elif locale.lower().startswith("en"): - candidates.append(base_path / "FIRST_NOTICE.en-US.md") - - candidates.extend( - [ - base_path / "FIRST_NOTICE.md", - base_path / "FIRST_NOTICE.en-US.md", - ], - ) - - for notice_path in candidates: - if not notice_path.is_file(): - continue - content = notice_path.read_text(encoding="utf-8") - if content.strip(): - return Response().ok({"content": content}).__dict__ - - return Response().ok({"content": None}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Error: {e!s}").__dict__ + return await self._run_sync( + lambda: self.service.get_first_notice(request.args.get("locale")) + ) diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py index 3a18cf82f2..52cb7e11a9 100644 --- a/astrbot/dashboard/routes/static_file.py +++ b/astrbot/dashboard/routes/static_file.py @@ -1,37 +1,19 @@ +from astrbot.dashboard.services.static_file_service import StaticFileService + from .route import Route, RouteContext class StaticFileRoute(Route): def __init__(self, context: RouteContext) -> None: super().__init__(context) + self.service = StaticFileService() - index_ = [ - "/", - "/auth/login", - "/config", - "/logs", - "/extension", - "/dashboard/default", - "/alkaid", - "/alkaid/knowledge-base", - "/alkaid/long-term-memory", - "/alkaid/other", - "/console", - "/chat", - "/settings", - "/platforms", - "/providers", - "/about", - "/extension-marketplace", - "/conversation", - "/tool-use", - ] - for i in index_: + for i in self.service.list_index_routes(): self.app.add_url_rule(i, view_func=self.index) @self.app.errorhandler(404) async def page_not_found(e) -> str: - return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://docs.astrbot.app/faq.html。如果你正在测试回调地址可达性,显示这段文字说明测试成功了。" + return self.service.get_not_found_message() async def index(self): return await self.app.send_static_file("index.html") diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index e3d77f73ad..7fcbf573c6 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -1,10 +1,9 @@ -import traceback - -from quart import jsonify, request - -from astrbot.core import logger -from astrbot.core.agent.handoff import HandoffTool from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.dashboard.fastapi_compat import jsonify, request +from astrbot.dashboard.services.subagent_service import ( + SubAgentService, + SubAgentServiceError, +) from .route import Response, Route, RouteContext @@ -16,7 +15,7 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle + self.service = SubAgentService(core_lifecycle) # NOTE: dict cannot hold duplicate keys; use list form to register multiple # methods for the same path. self.routes = [ @@ -26,92 +25,35 @@ def __init__( ] self.register_routes() - async def get_config(self): - try: - cfg = self.core_lifecycle.astrbot_config - data = cfg.get("subagent_orchestrator") - - # First-time access: return a sane default instead of erroring. - if not isinstance(data, dict): - data = { - "main_enable": False, - "remove_main_duplicate_tools": False, - "agents": [], - } - - # Backward compatibility: older config used `enable`. - if ( - isinstance(data, dict) - and "main_enable" not in data - and "enable" in data - ): - data["main_enable"] = bool(data.get("enable", False)) - - # Ensure required keys exist. - data.setdefault("main_enable", False) - data.setdefault("remove_main_duplicate_tools", False) - data.setdefault("agents", []) + @staticmethod + def _response(data=None, message: str | None = None): + return jsonify(Response().ok(data=data, message=message).__dict__) - # Backward/forward compatibility: ensure each agent contains provider_id. - # None means follow global/default provider settings. - if isinstance(data.get("agents"), list): - for a in data["agents"]: - if isinstance(a, dict): - a.setdefault("provider_id", None) - a.setdefault("persona_id", None) - return jsonify(Response().ok(data=data).__dict__) - except Exception as e: - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"获取 subagent 配置失败: {e!s}").__dict__) + @staticmethod + def _error(message: str): + return jsonify(Response().error(message).__dict__) - async def update_config(self): + async def _run(self, operation, *, message: str | None = None): try: - data = await request.json - if not isinstance(data, dict): - return jsonify(Response().error("配置必须为 JSON 对象").__dict__) + return self._response(await operation(), message) + except SubAgentServiceError as exc: + return self._error(str(exc)) - cfg = self.core_lifecycle.astrbot_config - cfg["subagent_orchestrator"] = data + async def _run_sync(self, operation): + try: + return self._response(operation()) + except SubAgentServiceError as exc: + return self._error(str(exc)) - # Persist to cmd_config.json - # AstrBotConfigManager does not expose a `save()` method; persist via AstrBotConfig. - cfg.save_config() + async def _run_json(self, operation, *, message: str | None = None): + data = await request.json + return await self._run(lambda: operation(data), message=message) - # Reload dynamic handoff tools if orchestrator exists - orch = getattr(self.core_lifecycle, "subagent_orchestrator", None) - if orch is not None: - await orch.reload_from_config(data) + async def get_config(self): + return await self._run_sync(self.service.get_config) - return jsonify(Response().ok(message="保存成功").__dict__) - except Exception as e: - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"保存 subagent 配置失败: {e!s}").__dict__) + async def update_config(self): + return await self._run_json(self.service.update_config, message="保存成功") async def get_available_tools(self): - """Return all registered tools (name/description/parameters/active/origin). - - UI can use this to build a multi-select list for subagent tool assignment. - """ - try: - tool_mgr = self.core_lifecycle.provider_manager.llm_tools - tools_dict = [] - for tool in tool_mgr.func_list: - # Prevent recursive routing: subagents should not be able to select - # the handoff (transfer_to_*) tools as their own mounted tools. - if isinstance(tool, HandoffTool): - continue - if tool.handler_module_path == "core.subagent_orchestrator": - continue - tools_dict.append( - { - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "active": tool.active, - "handler_module_path": tool.handler_module_path, - } - ) - return jsonify(Response().ok(data=tools_dict).__dict__) - except Exception as e: - logger.error(traceback.format_exc()) - return jsonify(Response().error(f"获取可用工具失败: {e!s}").__dict__) + return await self._run_sync(self.service.get_available_tools) diff --git a/astrbot/dashboard/routes/t2i.py b/astrbot/dashboard/routes/t2i.py index 634828e955..31cc1571c4 100644 --- a/astrbot/dashboard/routes/t2i.py +++ b/astrbot/dashboard/routes/t2i.py @@ -2,11 +2,9 @@ from dataclasses import asdict -from quart import jsonify, request - -from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.utils.t2i.template_manager import TemplateManager +from astrbot.dashboard.fastapi_compat import jsonify, request +from astrbot.dashboard.services.t2i_service import T2iService, T2iServiceError from .route import Response, Route, RouteContext @@ -16,9 +14,7 @@ def __init__( self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle - self.config = core_lifecycle.astrbot_config - self.manager = TemplateManager() + self.service = T2iService(core_lifecycle) # 使用列表保证路由注册顺序,避免 / 路由优先匹配 /reset_default self.routes = [ ("/t2i/templates", ("GET", self.list_templates)), @@ -28,7 +24,7 @@ def __init__( ("/t2i/templates/set_active", ("POST", self.set_active_template)), # 动态路由应该在静态路由之后注册 ( - "/t2i/templates/", + "/t2i/templates/", [ ("GET", self.get_template), ("PUT", self.update_template), @@ -38,200 +34,94 @@ def __init__( ] self.register_routes() - async def _reload_all_pipeline_schedulers(self) -> None: - """热重载所有配置对应的 pipeline scheduler。""" - for conf_id in self.core_lifecycle.astrbot_config_mgr.confs: - await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - - async def _sync_active_template_to_all_configs(self, name: str) -> None: - """同步当前激活模板到所有配置文件,并热重载对应流水线。""" - for config in self.core_lifecycle.astrbot_config_mgr.confs.values(): - config["t2i_active_template"] = name - config.save_config() - await self._reload_all_pipeline_schedulers() + @staticmethod + def _ok(data=None, message: str | None = None, status_code: int = 200): + response = jsonify(asdict(Response().ok(data=data, message=message))) + response.status_code = status_code + return response + + @staticmethod + def _service_error(exc: T2iServiceError): + response = jsonify(asdict(Response().error(str(exc)))) + response.status_code = exc.status_code + return response + + @staticmethod + async def _request_data() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} + + async def _run( + self, + operation, + *, + message: str | None = None, + status_code: int = 200, + result_as_message: bool = False, + ): + try: + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + if isinstance(result, tuple): + payload, result_message = result + return self._ok(data=payload, message=result_message) + if result_as_message: + return self._ok(message=str(result), status_code=status_code) + return self._ok(data=result, message=message, status_code=status_code) + except T2iServiceError as exc: + return self._service_error(exc) + + async def _run_json(self, operation, **kwargs): + async def invoke(): + data = await self._request_data() + return operation(data) + + return await self._run(invoke, **kwargs) async def list_templates(self): """获取所有T2I模板列表""" - try: - templates = self.manager.list_templates() - return jsonify(asdict(Response().ok(data=templates))) - except Exception as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run(self.service.list_templates) async def get_active_template(self): """获取当前激活的T2I模板""" - try: - active_template = self.config.get("t2i_active_template", "base") - return jsonify( - asdict(Response().ok(data={"active_template": active_template})), - ) - except Exception as e: - logger.error("Error in get_active_template", exc_info=True) - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run(self.service.get_active_template) async def get_template(self, name: str): """获取指定名称的T2I模板内容""" - try: - content = self.manager.get_template(name) - return jsonify( - asdict(Response().ok(data={"name": name, "content": content})), - ) - except FileNotFoundError: - response = jsonify(asdict(Response().error("Template not found"))) - response.status_code = 404 - return response - except Exception as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run(lambda: self.service.get_template(name)) async def create_template(self): """创建一个新的T2I模板""" - try: - data = await request.json - name = data.get("name") - content = data.get("content") - if not name or not content: - response = jsonify( - asdict(Response().error("Name and content are required.")), - ) - response.status_code = 400 - return response - name = name.strip() - - self.manager.create_template(name, content) - response = jsonify( - asdict( - Response().ok( - data={"name": name}, - message="Template created successfully.", - ), - ), - ) - response.status_code = 201 - return response - except FileExistsError: - response = jsonify( - asdict(Response().error("Template with this name already exists.")), - ) - response.status_code = 409 - return response - except ValueError as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 400 - return response - except Exception as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run_json( + self.service.create_template_from_legacy_payload, + message="Template created successfully.", + status_code=201, + ) async def update_template(self, name: str): """更新一个已存在的T2I模板""" - try: - name = name.strip() - data = await request.json - content = data.get("content") - if content is None: - response = jsonify(asdict(Response().error("Content is required."))) - response.status_code = 400 - return response - - self.manager.update_template(name, content) - - # 检查更新的是否为当前激活的模板,如果是,则热重载 - active_template = self.config.get("t2i_active_template", "base") - if name == active_template: - await self._reload_all_pipeline_schedulers() - message = f"模板 '{name}' 已更新并重新加载。" - else: - message = f"模板 '{name}' 已更新。" - - return jsonify(asdict(Response().ok(data={"name": name}, message=message))) - except ValueError as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 400 - return response - except Exception as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run_json( + lambda data: self.service.update_template_from_legacy_payload(name, data) + ) async def delete_template(self, name: str): """删除一个T2I模板""" - try: - name = name.strip() - self.manager.delete_template(name) - return jsonify( - asdict(Response().ok(message="Template deleted successfully.")), - ) - except FileNotFoundError: - response = jsonify(asdict(Response().error("Template not found."))) - response.status_code = 404 - return response - except ValueError as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 400 - return response - except Exception as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run( + lambda: self.service.delete_template(name), + message="Template deleted successfully.", + ) async def set_active_template(self): """设置当前活动的T2I模板""" - try: - data = await request.json - name = data.get("name") - if not name: - response = jsonify(asdict(Response().error("模板名称(name)不能为空。"))) - response.status_code = 400 - return response - - # 验证模板文件是否存在 - self.manager.get_template(name) - - # 更新所有配置并热重载以应用更改 - await self._sync_active_template_to_all_configs(name) - - return jsonify(asdict(Response().ok(message=f"模板 '{name}' 已成功应用。"))) - - except FileNotFoundError: - response = jsonify( - asdict(Response().error(f"模板 '{name}' 不存在,无法应用。")), - ) - response.status_code = 404 - return response - except Exception as e: - logger.error("Error in set_active_template", exc_info=True) - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run_json( + self.service.set_active_template_from_legacy_payload, + result_as_message=True, + ) async def reset_default_template(self): """重置默认的'base'模板""" - try: - self.manager.reset_default_template() - - # 更新所有配置,将激活模板也重置为'base' - await self._sync_active_template_to_all_configs("base") - - return jsonify( - asdict( - Response().ok( - message="Default template has been reset and activated.", - ), - ), - ) - except FileNotFoundError as e: - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 404 - return response - except Exception as e: - logger.error("Error in reset_default_template", exc_info=True) - response = jsonify(asdict(Response().error(str(e)))) - response.status_code = 500 - return response + return await self._run( + self.service.reset_default_template(), + result_as_message=True, + ) diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 2ad80687ef..31099f72f8 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -1,43 +1,9 @@ -import traceback - -from quart import request - -from astrbot.core import logger, sp -from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.star import star_map -from astrbot.core.tools.registry import get_builtin_tool_config_statuses +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.tools_service import ToolsService, ToolsServiceError from .route import Response, Route, RouteContext -DEFAULT_MCP_CONFIG = {"mcpServers": {}} - - -class EmptyMcpServersError(ValueError): - """Raised when mcpServers is empty.""" - - pass - - -def _extract_mcp_server_config(mcp_servers_value: object) -> dict: - """Extract server configuration from user-submitted mcpServers field. - - Raises: - ValueError: Invalid configuration - """ - if not isinstance(mcp_servers_value, dict): - raise ValueError("mcpServers must be a JSON object") - if not mcp_servers_value: - raise EmptyMcpServersError("mcpServers configuration cannot be empty") - key_0 = next(iter(mcp_servers_value)) - extracted = mcp_servers_value[key_0] - if not isinstance(extracted, dict): - raise ValueError( - "Invalid mcpServers format. Ensure each key in mcpServers is a server name, " - "and each value is an object containing fields like command/url." - ) - return extracted - class ToolsRoute(Route): def __init__( @@ -46,7 +12,7 @@ def __init__( core_lifecycle: AstrBotCoreLifecycle, ) -> None: super().__init__(context) - self.core_lifecycle = core_lifecycle + self.service = ToolsService(core_lifecycle) self.routes = { "/tools/mcp/servers": ("GET", self.get_mcp_servers), "/tools/mcp/add": ("POST", self.add_mcp_server), @@ -59,586 +25,87 @@ def __init__( "/tools/mcp/sync-provider": ("POST", self.sync_provider), } self.register_routes() - self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools @staticmethod - def _get_tool_permission(tool_name: str) -> tuple[str, bool]: - """Return (effective_permission, configured) for a tool. + def _ok(data: dict | list | None = None, message: str | None = None) -> dict: + return Response().ok(data, message).__dict__ - ``configured`` is True when the permission was explicitly set via the - dashboard rather than being a fallback default. - """ - perms_store = sp.get("tool_permissions", {}, scope="global", scope_id="global") - defaults = ( - perms_store.get("_default", {}) if isinstance(perms_store, dict) else {} - ) - if tool_name in defaults: - return defaults[tool_name], True - return "member", False + @staticmethod + def _error(message: str) -> dict: + return Response().error(message).__dict__ - def _rollback_mcp_server(self, name: str) -> bool: - try: - rollback_config = self.tool_mgr.load_mcp_config() - if name in rollback_config["mcpServers"]: - rollback_config["mcpServers"].pop(name) - return self.tool_mgr.save_mcp_config(rollback_config) - return True - except Exception: - logger.error(traceback.format_exc()) - return False + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - async def get_mcp_servers(self): + async def _run(self, operation, *, message: str | None = None) -> dict: try: - config = self.tool_mgr.load_mcp_config() - servers = [] - mcp_servers = config.get("mcpServers", {}) - - if not isinstance(mcp_servers, dict): - logger.warning( - f"Invalid MCP server config type: {type(mcp_servers).__name__}. Expected object/dict; skipped all MCP servers." - ) - mcp_servers = {} - - # 获取所有服务器并添加它们的工具列表 - for name, server_config in mcp_servers.items(): - if not isinstance(server_config, dict): - logger.warning( - f"Invalid config for MCP server '{name}' (type: {type(server_config).__name__}); skipped." - ) - continue - - server_info = { - "name": name, - "active": server_config.get("active", True), - } - - # 复制所有配置字段 - for key, value in server_config.items(): - if key != "active": # active 已经处理 - server_info[key] = value - - # 如果MCP客户端已初始化,从客户端获取工具名称 - for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items(): - if name_key == name: - mcp_client = runtime.client - server_info["tools"] = [tool.name for tool in mcp_client.tools] - server_info["errlogs"] = mcp_client.server_errlogs - break - else: - server_info["tools"] = [] - - servers.append(server_info) + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._ok(result, message) + except ToolsServiceError as exc: + return self._error(str(exc)) + + async def _run_json( + self, + operation, + *, + message: str | None = None, + result_as_message: bool = False, + ) -> dict: + async def invoke(): + data = await self._json_body() + return operation(data) + + result = await self._run(invoke) + if result_as_message and result.get("status") == "ok": + return self._ok(None, result["data"]) + if message and result.get("status") == "ok": + return self._ok(result.get("data"), message) + return result - return Response().ok(servers).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to get MCP server list: {e!s}").__dict__ + async def get_mcp_servers(self): + return await self._run(self.service.get_mcp_servers) async def add_mcp_server(self): - try: - server_data = await request.json - - name = server_data.get("name", "") - - # 检查必填字段 - if not name: - return Response().error("Server name cannot be empty").__dict__ - - # 移除特殊字段并检查配置是否有效 - has_valid_config = False - server_config = {"active": server_data.get("active", True)} - - # 复制所有配置字段 - for key, value in server_data.items(): - if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段 - if key == "mcpServers": - try: - server_config = _extract_mcp_server_config( - server_data["mcpServers"] - ) - except ValueError as e: - return Response().error(f"{e!s}").__dict__ - else: - server_config[key] = value - has_valid_config = True - - if not has_valid_config: - return ( - Response() - .error("A valid server configuration is required") - .__dict__ - ) - - try: - validate_mcp_stdio_config(server_config) - except ValueError as e: - return Response().error(f"{e!s}").__dict__ - - config = self.tool_mgr.load_mcp_config() - - if name in config["mcpServers"]: - return Response().error(f"Server {name} already exists").__dict__ - - try: - await self.tool_mgr.test_mcp_server_connection(server_config) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"MCP connection test failed: {e!s}").__dict__ - - config["mcpServers"][name] = server_config - - if self.tool_mgr.save_mcp_config(config): - try: - await self.tool_mgr.enable_mcp_server( - name, - server_config, - timeout=30, - ) - except TimeoutError: - rollback_ok = self._rollback_mcp_server(name) - err_msg = f"Timed out while enabling MCP server {name}." - if not rollback_ok: - err_msg += " Configuration rollback failed. Please check the config manually." - return Response().error(err_msg).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - rollback_ok = self._rollback_mcp_server(name) - err_msg = f"Failed to enable MCP server {name}: {e!s}" - if not rollback_ok: - err_msg += " Configuration rollback failed. Please check the config manually." - return Response().error(err_msg).__dict__ - return ( - Response() - .ok(None, f"Successfully added MCP server {name}") - .__dict__ - ) - return Response().error("Failed to save configuration").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to add MCP server: {e!s}").__dict__ + return await self._run_json(self.service.add_mcp_server, result_as_message=True) async def update_mcp_server(self): - try: - server_data = await request.json - - name = server_data.get("name", "") - old_name = server_data.get("oldName") or name - - if not name: - return Response().error("Server name cannot be empty").__dict__ - - config = self.tool_mgr.load_mcp_config() - - if old_name not in config["mcpServers"]: - return Response().error(f"Server {old_name} does not exist").__dict__ - - is_rename = name != old_name - - if name in config["mcpServers"] and is_rename: - return Response().error(f"Server {name} already exists").__dict__ - - # 获取活动状态 - old_config = config["mcpServers"][old_name] - if isinstance(old_config, dict): - old_active = old_config.get("active", True) - else: - old_active = True - active = server_data.get("active", old_active) - - # 创建新的配置对象 - server_config = {"active": active} - - # 仅更新活动状态的特殊处理 - only_update_active = True - - # 复制所有配置字段 - for key, value in server_data.items(): - if key not in [ - "name", - "active", - "tools", - "errlogs", - "oldName", - ]: # 排除特殊字段 - if key == "mcpServers": - try: - server_config = _extract_mcp_server_config( - server_data["mcpServers"] - ) - except ValueError as e: - return Response().error(f"{e!s}").__dict__ - else: - server_config[key] = value - only_update_active = False - - # 如果只更新活动状态,保留原始配置 - if only_update_active and isinstance(old_config, dict): - for key, value in old_config.items(): - if key != "active": # 除了active之外的所有字段都保留 - server_config[key] = value - - try: - validate_mcp_stdio_config(server_config) - except ValueError as e: - return Response().error(f"{e!s}").__dict__ - - # config["mcpServers"][name] = server_config - if is_rename: - config["mcpServers"].pop(old_name) - config["mcpServers"][name] = server_config - else: - config["mcpServers"][name] = server_config - - if self.tool_mgr.save_mcp_config(config): - # 处理MCP客户端状态变化 - if active: - if ( - old_name in self.tool_mgr.mcp_server_runtime_view - or not only_update_active - or is_rename - ): - try: - await self.tool_mgr.disable_mcp_server(old_name, timeout=10) - except TimeoutError as e: - return ( - Response() - .error( - f"Timed out while disabling MCP server {old_name} before enabling: {e!s}" - ) - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return ( - Response() - .error( - f"Failed to disable MCP server {old_name} before enabling: {e!s}" - ) - .__dict__ - ) - try: - await self.tool_mgr.enable_mcp_server( - name, - config["mcpServers"][name], - timeout=30, - ) - except TimeoutError: - return ( - Response() - .error(f"Timed out while enabling MCP server {name}.") - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return ( - Response() - .error(f"Failed to enable MCP server {name}: {e!s}") - .__dict__ - ) - # 如果要停用服务器 - elif old_name in self.tool_mgr.mcp_server_runtime_view: - try: - await self.tool_mgr.disable_mcp_server(old_name, timeout=10) - except TimeoutError: - return ( - Response() - .error(f"Timed out while disabling MCP server {old_name}.") - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return ( - Response() - .error(f"Failed to disable MCP server {old_name}: {e!s}") - .__dict__ - ) - - return ( - Response() - .ok(None, f"Successfully updated MCP server {name}") - .__dict__ - ) - return Response().error("Failed to save configuration").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to update MCP server: {e!s}").__dict__ + return await self._run_json( + self.service.update_mcp_server, + result_as_message=True, + ) async def delete_mcp_server(self): - try: - server_data = await request.json - name = server_data.get("name", "") - - if not name: - return Response().error("Server name cannot be empty").__dict__ - - config = self.tool_mgr.load_mcp_config() - - if name not in config["mcpServers"]: - return Response().error(f"Server {name} does not exist").__dict__ - - del config["mcpServers"][name] - - if self.tool_mgr.save_mcp_config(config): - if name in self.tool_mgr.mcp_server_runtime_view: - try: - await self.tool_mgr.disable_mcp_server(name, timeout=10) - except TimeoutError: - return ( - Response() - .error(f"Timed out while disabling MCP server {name}.") - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return ( - Response() - .error(f"Failed to disable MCP server {name}: {e!s}") - .__dict__ - ) - return ( - Response() - .ok(None, f"Successfully deleted MCP server {name}") - .__dict__ - ) - return Response().error("Failed to save configuration").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to delete MCP server: {e!s}").__dict__ + return await self._run_json( + self.service.delete_mcp_server, + result_as_message=True, + ) async def test_mcp_connection(self): """Test MCP server connection.""" - try: - server_data = await request.json - config = server_data.get("mcp_server_config", None) - - if not isinstance(config, dict) or not config: - return Response().error("Invalid MCP server configuration").__dict__ - - if "mcpServers" in config: - mcp_servers = config["mcpServers"] - if isinstance(mcp_servers, dict) and len(mcp_servers) > 1: - return ( - Response() - .error( - "Only one MCP server configuration can be tested at a time" - ) - .__dict__ - ) - try: - config = _extract_mcp_server_config(mcp_servers) - except EmptyMcpServersError: - return ( - Response() - .error("MCP server configuration cannot be empty") - .__dict__ - ) - except ValueError as e: - return Response().error(f"{e!s}").__dict__ - elif not config: - return ( - Response() - .error("MCP server configuration cannot be empty") - .__dict__ - ) - - try: - validate_mcp_stdio_config(config) - except ValueError as e: - return Response().error(f"{e!s}").__dict__ - - tools_name = await self.tool_mgr.test_mcp_server_connection(config) - return ( - Response() - .ok(data=tools_name, message="🎉 MCP server is available!") - .__dict__ - ) - - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to test MCP connection: {e!s}").__dict__ + return await self._run_json( + self.service.test_mcp_connection, + message="🎉 MCP server is available!", + ) async def get_tool_list(self): """Get all registered tools.""" - try: - tools = list(self.tool_mgr.func_list) - existing_names = {tool.name for tool in tools} - for tool in self.tool_mgr.iter_builtin_tools(): - if tool.name not in existing_names: - tools.append(tool) - - conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list() - conf_name_map = {conf["id"]: conf["name"] for conf in conf_list} - config_entries = [] - for conf_id, conf in self.core_lifecycle.astrbot_config_mgr.confs.items(): - config_entries.append( - { - "conf_id": conf_id, - "conf_name": conf_name_map.get(conf_id, conf_id), - "config": conf, - } - ) - - tools_dict = [] - for tool in tools: - readonly = False - builtin_config_statuses = [] - builtin_config_tags = [] - if self.tool_mgr.is_builtin_tool(tool.name): - origin = "builtin" - origin_name = "AstrBot Core" - readonly = True - builtin_config_statuses = get_builtin_tool_config_statuses( - tool.name, - config_entries, - ) - builtin_config_tags = [ - status - for status in builtin_config_statuses - if status["enabled"] - ] - elif isinstance(tool, MCPTool): - origin = "mcp" - origin_name = tool.mcp_server_name - elif tool.handler_module_path and star_map.get( - tool.handler_module_path - ): - star = star_map[tool.handler_module_path] - origin = "plugin" - origin_name = star.name - else: - origin = "unknown" - origin_name = "unknown" - - tool_info = { - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "active": tool.active, - "origin": origin, - "origin_name": origin_name, - "readonly": readonly, - "builtin_config_statuses": builtin_config_statuses, - "builtin_config_tags": builtin_config_tags, - } - if not readonly: - perm, configured = self._get_tool_permission(tool.name) - tool_info["permission"] = perm - tool_info["permission_configured"] = configured - tools_dict.append(tool_info) - return Response().ok(data=tools_dict).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to get tool list: {e!s}").__dict__ + return await self._run(self.service.get_tool_list) async def toggle_tool(self): """Activate or deactivate a specified tool.""" - try: - data = await request.json - tool_name = data.get("name") - action = data.get("activate") # True or False - - if not tool_name or action is None: - return ( - Response() - .error("Missing required parameters: name or activate") - .__dict__ - ) - - if self.tool_mgr.is_builtin_tool(tool_name): - return ( - Response() - .error("Builtin tools are read-only and cannot be toggled.") - .__dict__ - ) - - if action: - try: - ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map) - except ValueError as e: - return Response().error(f"Failed to activate tool: {e!s}").__dict__ - else: - ok = self.tool_mgr.deactivate_llm_tool(tool_name) - - if ok: - return Response().ok(None, "Operation successful.").__dict__ - return ( - Response() - .error(f"Tool {tool_name} does not exist or the operation failed.") - .__dict__ - ) - - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to operate tool: {e!s}").__dict__ + return await self._run_json(self.service.toggle_tool, result_as_message=True) async def sync_provider(self): """Sync MCP provider configuration.""" - try: - data = await request.json - provider_name = data.get("name") # modelscope, or others - match provider_name: - case "modelscope": - access_token = data.get("access_token", "") - await self.tool_mgr.sync_modelscope_mcp_servers(access_token) - case _: - return ( - Response().error(f"Unknown provider: {provider_name}").__dict__ - ) - - return Response().ok(message="Sync completed").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Sync failed: {e!s}").__dict__ + return await self._run_json(self.service.sync_provider, result_as_message=True) async def update_tool_permission(self): """Set or remove the permission level of a registered tool.""" - try: - data = await request.json - tool_name = data.get("name") - permission = data.get("permission") # "admin" | "member" - - if not tool_name or permission not in ("admin", "member"): - return ( - Response() - .error("name and permission (admin or member) are required") - .__dict__ - ) - - if self.tool_mgr.is_builtin_tool(tool_name): - return ( - Response() - .error( - "Builtin tools do not support per-tool permission configuration." - ) - .__dict__ - ) - - # Verify the tool is known - if not any(t.name == tool_name for t in self.tool_mgr.func_list): - return Response().error(f"Tool '{tool_name}' not found").__dict__ - - perms_store = sp.get( - "tool_permissions", {}, scope="global", scope_id="global" - ) - if not isinstance(perms_store, dict): - perms_store = {} - defaults = perms_store.get("_default", {}) - if not isinstance(defaults, dict): - defaults = {} - defaults[tool_name] = permission - perms_store["_default"] = defaults - sp.put( - "tool_permissions", - perms_store, - scope="global", - scope_id="global", - ) - - return ( - Response() - .ok(None, f"Tool '{tool_name}' permission set to {permission}") - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"Failed to update tool permission: {e!s}").__dict__ + return await self._run_json( + self.service.update_tool_permission, + result_as_message=True, + ) diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 210eb21005..acb98819dc 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -1,17 +1,26 @@ -import traceback -import uuid - -from quart import request - -from astrbot.core import DEMO_MODE, logger, pip_installer -from astrbot.core.config.default import VERSION -from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.db.migration.helper import check_migration_needed_v4, do_migration_v4 -from astrbot.core.updator import AstrBotUpdator -from astrbot.core.utils.io import download_dashboard, get_dashboard_version +from __future__ import annotations + +from typing import TYPE_CHECKING + +from astrbot.dashboard.fastapi_compat import request +from astrbot.dashboard.services.update_service import ( + DEMO_MODE, + UpdateService, + UpdateServiceError, + UpdateServiceResult, + call_check_migration_needed_v4, + call_do_migration_v4, + call_download_dashboard, + call_get_dashboard_version, + call_pip_install, +) from .route import Response, Route, RouteContext +if TYPE_CHECKING: + from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + from astrbot.core.updator import AstrBotUpdator + CLEAR_SITE_DATA_HEADERS = {"Clear-Site-Data": '"cache"'} @@ -32,323 +41,82 @@ def __init__( "/update/pip-install": ("POST", self.install_pip_package), "/update/migration": ("POST", self.do_migration), } - self.astrbot_updator = astrbot_updator - self.core_lifecycle = core_lifecycle - self.update_progress: dict[str, dict] = {} + self.service = UpdateService( + astrbot_updator, + core_lifecycle, + download_dashboard_func=call_download_dashboard, + get_dashboard_version_func=call_get_dashboard_version, + pip_install_func=call_pip_install, + check_migration_needed_func=call_check_migration_needed_v4, + do_migration_func=call_do_migration_v4, + demo_mode=DEMO_MODE, + clear_site_data_headers=CLEAR_SITE_DATA_HEADERS, + ) self.register_routes() - def _init_update_progress(self, progress_id: str, version: str) -> None: - self.update_progress[progress_id] = { - "id": progress_id, - "status": "running", - "stage": "preparing", - "version": version or "latest", - "message": "正在准备更新...", - "overall_percent": 0, - "stages": { - "dashboard": self._empty_stage("pending"), - "core": self._empty_stage("pending"), - }, - } - @staticmethod - def _empty_stage(status: str = "pending") -> dict: - return { - "status": status, - "downloaded": 0, - "total": 0, - "percent": 0, - "speed": 0, - } + def _service_response(result: UpdateServiceResult): + if result.status == "success": + payload = Response( + status="success", + message=result.message, + data=result.data, + ).__dict__ + else: + payload = Response().ok(result.data, result.message).__dict__ - def _set_update_stage( - self, - progress_id: str, - stage: str, - status: str, - message: str, - overall_percent: int | None = None, - ) -> None: - progress = self.update_progress.get(progress_id) - if not progress: - return - progress["stage"] = stage - progress["message"] = message - progress["stages"].setdefault(stage, self._empty_stage()) - progress["stages"][stage]["status"] = status - if overall_percent is not None: - progress["overall_percent"] = overall_percent + if result.headers: + return payload, 200, result.headers + return payload @staticmethod - def _normalize_percent(value) -> int: + def _service_error(exc: UpdateServiceError): + return Response().error(str(exc)).__dict__ + + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} + + async def _run(self, operation): try: - percent = float(value or 0) - except (TypeError, ValueError): - return 0 - if percent <= 1: - percent *= 100 - return max(0, min(100, int(percent))) + result = operation() if callable(operation) else operation + while hasattr(result, "__await__"): + result = await result + return self._service_response(result) + except UpdateServiceError as exc: + return self._service_error(exc) - def _make_progress_callback( - self, - progress_id: str, - stage: str, - stage_start: int, - stage_weight: int, - ): - def _callback(payload: dict) -> None: - progress = self.update_progress.get(progress_id) - if not progress: - return - stage_percent = self._normalize_percent(payload.get("percent")) - progress["stage"] = stage - progress["stages"][stage] = { - "status": "running" if stage_percent < 100 else "done", - "downloaded": payload.get("downloaded", 0), - "total": payload.get("total", 0), - "percent": stage_percent, - "speed": payload.get("speed", 0), - } - progress["overall_percent"] = min( - 99, - stage_start + int(stage_percent * stage_weight / 100), - ) + async def _run_json(self, operation): + async def invoke(): + data = await self._json_body() + return operation(data) - return _callback + return await self._run(invoke) async def get_update_progress(self): - progress_id = request.args.get("id", "") - if not progress_id: - return Response().error("缺少参数 id。").__dict__ - progress = self.update_progress.get(progress_id) - if not progress: - return ( - Response() - .ok( - {"id": progress_id, "status": "idle"}, - "没有正在进行的更新。", - ) - .__dict__ + return await self._run( + lambda: self.service.get_update_progress_from_legacy_query( + request.args.get("id") ) - return Response().ok(progress).__dict__ + ) async def do_migration(self): - need_migration = await check_migration_needed_v4(self.core_lifecycle.db) - if not need_migration: - return Response().ok(None, "不需要进行迁移。").__dict__ - try: - data = await request.json - pim = data.get("platform_id_map", {}) - await do_migration_v4( - self.core_lifecycle.db, - pim, - self.core_lifecycle.astrbot_config, - ) - return Response().ok(None, "迁移成功。").__dict__ - except Exception as e: - logger.error(f"迁移失败: {traceback.format_exc()}") - return Response().error(f"迁移失败: {e!s}").__dict__ + return await self._run_json(self.service.do_migration_v4) async def check_update(self): - type_ = request.args.get("type", None) - - try: - dv = await get_dashboard_version() - if type_ == "dashboard": - return ( - Response() - .ok({"has_new_version": dv != f"v{VERSION}", "current_version": dv}) - .__dict__ - ) - ret = await self.astrbot_updator.check_update(None, None, False) - return Response( - status="success", - message=str(ret) if ret is not None else "已经是最新版本了。", - data={ - "version": f"v{VERSION}", - "has_new_version": ret is not None, - "dashboard_version": dv, - "dashboard_has_new_version": bool(dv and dv != f"v{VERSION}"), - }, - ).__dict__ - except Exception as e: - logger.warning(f"检查更新失败: {e!s} (不影响除项目更新外的正常使用)") - return Response().error(e.__str__()).__dict__ + return await self._run( + self.service.check_update_from_legacy_query(request.args.get("type")) + ) async def get_releases(self): - try: - ret = await self.astrbot_updator.get_releases() - return Response().ok(ret).__dict__ - except Exception as e: - logger.error(f"/api/update/releases: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return await self._run(self.service.get_releases()) async def update_project(self): - data = await request.json - version = data.get("version", "") - reboot = data.get("reboot", True) - progress_id = data.get("progress_id") or uuid.uuid4().hex - if version == "" or version == "latest": - latest = True - version = "" - else: - latest = False - - proxy: str = data.get("proxy", None) - if proxy: - proxy = proxy.removesuffix("/") - - self._init_update_progress(progress_id, version) - try: - self._set_update_stage( - progress_id, - "dashboard", - "running", - "正在下载 WebUI...", - 0, - ) - await download_dashboard( - latest=latest, - version=version, - proxy=proxy, - progress_callback=self._make_progress_callback( - progress_id, - "dashboard", - 0, - 45, - ), - ) - self._set_update_stage( - progress_id, - "dashboard", - "done", - "WebUI 下载完成。", - 45, - ) - - self._set_update_stage( - progress_id, - "core", - "running", - "正在下载 AstrBot 项目代码...", - 45, - ) - await self.astrbot_updator.update( - latest=latest, - version=version, - proxy=proxy, - progress_callback=self._make_progress_callback( - progress_id, - "core", - 45, - 45, - ), - ) - self._set_update_stage( - progress_id, - "core", - "done", - "项目代码下载完成。", - 90, - ) - - # pip 更新依赖 - self._set_update_stage( - progress_id, - "dependencies", - "running", - "正在更新依赖...", - 92, - ) - logger.info("更新依赖中...") - try: - await pip_installer.install(requirements_path="requirements.txt") - except Exception as e: - logger.error(f"更新依赖失败: {e}") - self._set_update_stage( - progress_id, - "dependencies", - "done", - "依赖更新完成。", - 96, - ) - - if reboot: - self._set_update_stage( - progress_id, - "restart", - "running", - "更新成功,正在准备重启...", - 98, - ) - await self.core_lifecycle.restart() - self.update_progress[progress_id].update( - { - "status": "success", - "stage": "done", - "message": "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。", - "overall_percent": 100, - }, - ) - ret = ( - Response() - .ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。") - .__dict__ - ) - return ret, 200, CLEAR_SITE_DATA_HEADERS - self.update_progress[progress_id].update( - { - "status": "success", - "stage": "done", - "message": "更新成功,AstrBot 将在下次启动时应用新的代码。", - "overall_percent": 100, - }, - ) - ret = ( - Response() - .ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。") - .__dict__ - ) - return ret, 200, CLEAR_SITE_DATA_HEADERS - except Exception as e: - self.update_progress[progress_id].update( - { - "status": "error", - "message": e.__str__(), - }, - ) - logger.error(f"/api/update_project: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return await self._run_json(self.service.update_project) async def update_dashboard(self): - try: - try: - await download_dashboard(version=f"v{VERSION}", latest=False) - except Exception as e: - logger.error(f"下载管理面板文件失败: {e}。") - return Response().error(f"下载管理面板文件失败: {e}").__dict__ - ret = Response().ok(None, "更新成功。刷新页面即可应用新版本面板。").__dict__ - return ret, 200, CLEAR_SITE_DATA_HEADERS - except Exception as e: - logger.error(f"/api/update_dashboard: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return await self._run(self.service.update_dashboard()) async def install_pip_package(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - data = await request.json - package = data.get("package", "") - mirror = data.get("mirror", None) - if not package: - return Response().error("缺少参数 package 或不合法。").__dict__ - try: - await pip_installer.install(package, mirror=mirror) - return Response().ok(None, "安装成功。").__dict__ - except Exception as e: - logger.error(f"/api/update_pip: {traceback.format_exc()}") - return Response().error(e.__str__()).__dict__ + return await self._run_json(self.service.install_pip_package) diff --git a/astrbot/dashboard/routes/util.py b/astrbot/dashboard/routes/util.py deleted file mode 100644 index d08af03eed..0000000000 --- a/astrbot/dashboard/routes/util.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Dashboard 路由工具集。 - -这里放一些 dashboard routes 可复用的小工具函数。 - -目前主要用于「配置文件上传(file 类型配置项)」功能: -- 清洗/规范化用户可控的文件名与相对路径 -- 将配置 key 映射到配置项独立子目录 -""" - -import os - - -def get_schema_item(schema: dict | None, key_path: str) -> dict | None: - """按 dot-path 获取 schema 的节点。 - - 同时支持: - - 扁平 schema(直接 key 命中) - - 嵌套 object schema({type: "object", items: {...}}) - - template_list schema(.templates. \ No newline at end of file + diff --git a/dashboard/src/views/stats/StatsPage.vue b/dashboard/src/views/stats/StatsPage.vue index 251971baf2..71133c0e2d 100644 --- a/dashboard/src/views/stats/StatsPage.vue +++ b/dashboard/src/views/stats/StatsPage.vue @@ -204,9 +204,9 @@ ', + encoding="utf-8", + ) + (static_folder / "favicon.svg").write_text("", encoding="utf-8") + (assets_folder / "index-demo.js").write_text( + "window.__astrbotStaticTest = true;", + encoding="utf-8", + ) + (tmp_path / "secret.txt").write_text("outside static root", encoding="utf-8") + + app = create_dashboard_asgi_app( + core_lifecycle=fake_core_lifecycle, + db=fake_db, + jwt_secret=JWT_SECRET, + static_folder=str(static_folder), + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + asset_response = await client.get("/assets/index-demo.js") + favicon_response = await client.get("/favicon.svg") + page_response = await client.get("/config") + missing_response = await client.get("/assets/missing.js") + traversal_response = await client.get("/assets/%2E%2E/%2E%2E/secret.txt") + api_response = await client.get("/api/not-found") + + assert asset_response.status_code == 200 + assert "window.__astrbotStaticTest" in asset_response.text + assert favicon_response.status_code == 200 + assert favicon_response.text == "" + assert page_response.status_code == 200 + assert "/assets/index-demo.js" in page_response.text + assert missing_response.status_code == 404 + assert traversal_response.status_code == 404 + assert api_response.status_code == 404 + + @pytest.mark.asyncio async def test_v1_openapi_uses_pydantic_request_bodies( asgi_client: httpx.AsyncClient, @@ -1397,7 +1464,10 @@ async def fake_list_models(_service, requested_source_id: str): assert config["provider"][-1]["enable"] is False assert embedding_response.status_code == 400 assert embedding_response.json()["status"] == "error" - assert "提供商适配器加载失败" in embedding_response.json()["message"] + assert embedding_response.json()["message"] in { + "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志", + "提供商不是 EmbeddingProvider 类型", + } assert source_models_response.status_code == 200 assert source_models_response.json()["data"]["provider_source_id"] == source_id assert source_providers_response.status_code == 200 @@ -1451,16 +1521,26 @@ async def test_v1_safe_bot_routes_accept_slash_ids( @pytest.mark.asyncio -async def test_v1_config_scope_accepts_api_key( +async def test_v1_bot_scope_accepts_api_key( asgi_client: httpx.AsyncClient, fake_db: FakeDb, ): - raw_key = "abk_fastapi_v1_config" - fake_db.add_api_key(raw_key, scopes=["config"]) + config_key = "abk_fastapi_v1_config" + fake_db.add_api_key(config_key, scopes=["config"]) + + config_response = await asgi_client.get( + "/api/v1/bots", + headers={"X-API-Key": config_key}, + ) + + assert config_response.status_code == 403 + + bot_key = "abk_fastapi_v1_bot" + fake_db.add_api_key(bot_key, scopes=["bot"]) response = await asgi_client.get( "/api/v1/bots", - headers={"X-API-Key": raw_key}, + headers={"X-API-Key": bot_key}, ) assert response.status_code == 200 @@ -1933,7 +2013,7 @@ async def test_v1_safe_mcp_routes_accept_slash_server_names( @pytest.mark.asyncio -async def test_v1_skills_accept_skill_scope( +async def test_v1_skills_reject_developer_api_key_scope( asgi_app: FastAPI, asgi_client: httpx.AsyncClient, fake_db: FakeDb, @@ -1952,10 +2032,10 @@ async def test_v1_skills_accept_skill_scope( headers={"X-API-Key": raw_key}, ) - assert response.status_code == 200 + assert response.status_code == 403 data = response.json() - assert data["status"] == "ok" - assert data["data"]["skills"] == [{"name": "demo_skill"}] + assert data["status"] == "error" + assert data["message"] == "Insufficient API key scope" @pytest.mark.asyncio From b64232fd83d1ff07729a1796be5b36ef0991d4fe Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 15:58:39 +0800 Subject: [PATCH 04/29] refactor: improve error handling and public messages in plugin services --- astrbot/dashboard/api/plugins.py | 19 +++--- .../dashboard/services/plugin_page_service.py | 9 ++- astrbot/dashboard/services/plugin_service.py | 64 +++++++++++++++---- tests/test_fastapi_v1_dashboard.py | 25 +++++++- 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index 438cce2a8e..e1be867903 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -37,6 +37,7 @@ PluginPageServiceError, ) from astrbot.dashboard.services.plugin_service import ( + PLUGIN_OPERATION_FAILED_MESSAGE, PluginService, PluginServiceError, PluginServiceWarning, @@ -110,17 +111,17 @@ async def _run_service(operation, *, log_label: str | None = None): except PluginServiceWarning as exc: return { "status": "warning", - "message": str(exc), + "message": exc.public_message, "data": exc.data, } except PluginServiceError as exc: - return {"status": "error", "message": str(exc), "data": {}} - except Exception as exc: + return {"status": "error", "message": exc.public_message, "data": {}} + except Exception: if log_label: - logger.error("%s: %s", log_label, exc, exc_info=True) + logger.error("%s failed", log_label, exc_info=True) else: - logger.error(str(exc), exc_info=True) - return {"status": "error", "message": str(exc), "data": {}} + logger.error("Plugin service operation failed", exc_info=True) + return {"status": "error", "message": PLUGIN_OPERATION_FAILED_MESSAGE, "data": {}} async def _run_json( @@ -253,7 +254,7 @@ async def _serve_plugin_page_content( theme=_get_request_theme(request), ) except PluginPageServiceError as exc: - return _plugin_page_error_response(exc.status_code, str(exc)) + return _plugin_page_error_response(exc.status_code, exc.public_message) return _plugin_page_payload_response(payload) @@ -269,7 +270,7 @@ async def _serve_plugin_page_bridge_sdk( theme=_get_request_theme(request), ) except PluginPageServiceError as exc: - return _plugin_page_error_response(exc.status_code, str(exc)) + return _plugin_page_error_response(exc.status_code, exc.public_message) return _plugin_page_payload_response(payload) @@ -291,7 +292,7 @@ async def _get_plugin_page_entry_config( ) ) except PluginPageServiceError as exc: - return {"status": "error", "message": str(exc), "data": {}} + return {"status": "error", "message": exc.public_message, "data": {}} async def _list_plugins( diff --git a/astrbot/dashboard/services/plugin_page_service.py b/astrbot/dashboard/services/plugin_page_service.py index e6a4e6408a..bb51749dcf 100644 --- a/astrbot/dashboard/services/plugin_page_service.py +++ b/astrbot/dashboard/services/plugin_page_service.py @@ -64,9 +64,16 @@ class PluginPageContentPayload: class PluginPageServiceError(Exception): - def __init__(self, message: str, status_code: int = 400) -> None: + def __init__( + self, + message: str, + status_code: int = 400, + *, + public_message: str | None = None, + ) -> None: super().__init__(message) self.status_code = status_code + self.public_message = public_message or message class PluginPageService: diff --git a/astrbot/dashboard/services/plugin_service.py b/astrbot/dashboard/services/plugin_service.py index c232678ee2..0e4d12176c 100644 --- a/astrbot/dashboard/services/plugin_service.py +++ b/astrbot/dashboard/services/plugin_service.py @@ -5,7 +5,6 @@ import json import os import ssl -import traceback from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime, timezone @@ -32,6 +31,8 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path PLUGIN_UPDATE_CONCURRENCY = 3 +PLUGIN_OPERATION_FAILED_MESSAGE = "插件操作失败,请查看服务端日志。" +PLUGIN_UPDATE_FAILED_MESSAGE = "更新失败,请查看服务端日志。" PLUGIN_COMPONENT_TYPE_ORDER = { "page": 0, "skill": 1, @@ -55,12 +56,26 @@ class RegistrySource: class PluginServiceError(Exception): - pass + def __init__( + self, + message: str, + *, + public_message: str | None = None, + ) -> None: + super().__init__(message) + self.public_message = public_message or message class PluginServiceWarning(Exception): - def __init__(self, message: str, data: dict[str, Any]) -> None: + def __init__( + self, + message: str, + data: dict[str, Any], + *, + public_message: str | None = None, + ) -> None: super().__init__(message) + self.public_message = public_message or message self.data = data @@ -123,7 +138,7 @@ async def reload_failed_plugin(self, data: object) -> tuple[None, str]: success, err = await self.plugin_manager.reload_failed_plugin(dir_name) if not success: - raise PluginServiceError(f"重载失败: {err}") + raise PluginServiceError(f"重载失败: {err}", public_message="重载失败") await self.sync_skills_after_plugin_change() return None, f"插件 {dir_name} 重载成功。" @@ -133,7 +148,10 @@ async def reload_plugin(self, data: object) -> tuple[None, str]: plugin_name = payload.get("name", None) success, message = await self.plugin_manager.reload(plugin_name) if not success: - raise PluginServiceError(message or "插件重载失败") + raise PluginServiceError( + message or "插件重载失败", + public_message="插件重载失败", + ) await self.sync_skills_after_plugin_change() return None, "重载成功。" @@ -843,6 +861,7 @@ async def install_plugin(self, data: object) -> tuple[dict, str]: "warning_type": "astrbot_version_unsupported", "can_ignore": True, }, + public_message="当前 AstrBot 版本不满足插件要求", ) from exc async def install_plugin_upload( @@ -873,6 +892,7 @@ async def install_plugin_upload( "warning_type": "astrbot_version_unsupported", "can_ignore": True, }, + public_message="当前 AstrBot 版本不满足插件要求", ) from exc async def install_plugin_upload_from_dashboard_form( @@ -960,11 +980,16 @@ async def _update_one(name: str): name, proxy, download_url=download_url ) return {"name": name, "status": "ok", "message": "更新成功"} - except Exception as exc: + except Exception: logger.error( - f"/api/plugin/update-all: 更新插件 {name} 失败: {traceback.format_exc()}", + f"/api/plugin/update-all: 更新插件 {name} 失败", + exc_info=True, ) - return {"name": name, "status": "error", "message": str(exc)} + return { + "name": name, + "status": "error", + "message": PLUGIN_UPDATE_FAILED_MESSAGE, + } raw_results = await asyncio.gather( *(_update_one(name) for name in plugin_names), @@ -974,8 +999,15 @@ async def _update_one(name: str): if isinstance(result, asyncio.CancelledError): raise result if isinstance(result, BaseException): + logger.error( + f"/api/plugin/update-all: 更新插件 {name} 任务失败: {result!r}" + ) results.append( - {"name": name, "status": "error", "message": str(result)} + { + "name": name, + "status": "error", + "message": PLUGIN_UPDATE_FAILED_MESSAGE, + } ) else: results.append(result) @@ -1053,7 +1085,11 @@ def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]: "content": readme_path.read_text(encoding="utf-8") }, "成功获取README内容" except Exception as exc: - raise PluginServiceError(f"读取README文件失败: {exc!s}") from exc + logger.warning(f"读取插件 {plugin_name} README 文件失败: {exc}") + raise PluginServiceError( + "读取README文件失败", + public_message="读取README文件失败", + ) from exc def get_plugin_readme_from_dashboard_query( self, @@ -1079,7 +1115,11 @@ def get_plugin_changelog(self, plugin_name: str | None) -> tuple[dict, str]: "成功获取更新日志", ) except Exception as exc: - raise PluginServiceError(f"读取更新日志失败: {exc!s}") from exc + logger.warning(f"读取插件 {plugin_name} 更新日志失败: {exc}") + raise PluginServiceError( + "读取更新日志失败", + public_message="读取更新日志失败", + ) from exc logger.warning(f"插件 {plugin_name} 没有更新日志文件") return {"content": None}, "该插件没有更新日志文件" @@ -1158,6 +1198,8 @@ def _to_bool(value: object, default: bool = False) -> bool: __all__ = [ "PLUGIN_UPDATE_CONCURRENCY", + "PLUGIN_OPERATION_FAILED_MESSAGE", + "PLUGIN_UPDATE_FAILED_MESSAGE", "PluginService", "PluginServiceError", "PluginServiceWarning", diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index cc36497b2e..a354613d86 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1661,7 +1661,30 @@ async def fake_install_plugin(payload): "ignore_version_check": True, } assert empty_body_response.status_code == 200 - assert empty_body_response.json()["status"] == "error" + empty_body_data = empty_body_response.json() + assert empty_body_data["status"] == "error" + assert empty_body_data["message"] == "插件操作失败,请查看服务端日志。" + assert "missing url" not in str(empty_body_data) + + +@pytest.mark.asyncio +async def test_v1_plugin_update_all_hides_internal_exceptions( + asgi_client: httpx.AsyncClient, +): + response = await asgi_client.post( + "/api/v1/plugins/update", + json={"plugin_ids": ["astrbot_plugin_demo"]}, + headers=_jwt_headers(), + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + result = data["data"]["results"][0] + assert result["status"] == "error" + assert result["message"] == "更新失败,请查看服务端日志。" + assert "AttributeError" not in str(data) + assert "update_plugin" not in str(data) @pytest.mark.asyncio From 5cc52833754c0e1c878d0441710788ae14010f5b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 17:10:59 +0800 Subject: [PATCH 05/29] feat(api): refactor API client integration and enhance request handling - Updated API client configuration to use a dedicated HTTP client. - Introduced utility functions for generating options, queries, and form data for API requests. - Refactored multiple API methods to utilize the new utility functions for improved consistency and readability. - Renamed types for clarity and updated import statements accordingly. feat(docs): add script to update OpenAPI JSON from YAML spec - Created a Python script to convert OpenAPI YAML specification to JSON format. - The script supports customizable input and output paths. - Ensured the script handles directory creation for output paths and validates the YAML structure. --- dashboard/package.json | 5 +- dashboard/pnpm-lock.yaml | 307 +- dashboard/scripts/generate_openapi_client.py | 377 -- dashboard/src/api/generated/openapi-v1.ts | 3311 ---------------- .../src/api/generated/openapi-v1/index.ts | 3 + .../src/api/generated/openapi-v1/sdk.gen.ts | 3051 +++++++++++++++ .../src/api/generated/openapi-v1/types.gen.ts | 3473 +++++++++++++++++ dashboard/src/api/v1.ts | 131 +- docs/scripts/update_openapi_json.py | 60 + 9 files changed, 6976 insertions(+), 3742 deletions(-) delete mode 100644 dashboard/scripts/generate_openapi_client.py delete mode 100644 dashboard/src/api/generated/openapi-v1.ts create mode 100644 dashboard/src/api/generated/openapi-v1/index.ts create mode 100644 dashboard/src/api/generated/openapi-v1/sdk.gen.ts create mode 100644 dashboard/src/api/generated/openapi-v1/types.gen.ts create mode 100644 docs/scripts/update_openapi_json.py diff --git a/dashboard/package.json b/dashboard/package.json index 22d76dcac8..0e06446c4d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -10,12 +10,14 @@ "build-stage": "node scripts/subset-mdi-font.mjs && vue-tsc --noEmit && vite build --base=/vue/free/stage/", "build-prod": "node scripts/subset-mdi-font.mjs && vue-tsc --noEmit && vite build --base=/vue/free/", "preview": "vite preview --port 5050", - "generate:api": "uv run python scripts/generate_openapi_client.py", + "generate:api": "rm -rf src/api/generated/openapi-v1 src/api/generated/openapi-v1.ts && openapi-ts -i ../openspec/openapi-v1.yaml -o src/api/generated/openapi-v1 -c @hey-api/client-axios", + "generate:docs:openapi": "uv run python ../docs/scripts/update_openapi_json.py", "typecheck": "vue-tsc --noEmit", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { "@guolao/vue-monaco-editor": "^1.5.4", + "@hey-api/client-axios": "0.2.12", "@tiptap/starter-kit": "2.1.7", "@tiptap/vue-3": "2.1.7", "apexcharts": "3.42.0", @@ -48,6 +50,7 @@ "yup": "1.2.0" }, "devDependencies": { + "@hey-api/openapi-ts": "0.60.0", "@mdi/font": "7.2.96", "@rushstack/eslint-patch": "1.3.3", "@types/chance": "1.1.3", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 9fb493b1c4..13607c36b4 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: '@guolao/vue-monaco-editor': specifier: ^1.5.4 version: 1.6.0(monaco-editor@0.52.2)(vue@3.3.4) + '@hey-api/client-axios': + specifier: 0.2.12 + version: 0.2.12(axios@1.13.5) '@tiptap/starter-kit': specifier: 2.1.7 version: 2.1.7(@tiptap/pm@2.27.2) @@ -83,7 +86,7 @@ importers: version: 4.11.3(vue@3.3.4) vite-plugin-vuetify: specifier: 2.1.3 - version: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11) + version: 2.1.3(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11) vue: specifier: 3.3.4 version: 3.3.4 @@ -106,6 +109,9 @@ importers: specifier: 1.2.0 version: 1.2.0 devDependencies: + '@hey-api/openapi-ts': + specifier: 0.60.0 + version: 0.60.0(typescript@5.1.6) '@mdi/font': specifier: 7.2.96 version: 7.2.96 @@ -126,7 +132,7 @@ importers: version: 20.19.32 '@vitejs/plugin-vue': specifier: 5.2.4 - version: 5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4) + version: 5.2.4(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4) '@vue/eslint-config-prettier': specifier: 8.0.0 version: 8.0.0(@types/eslint@9.6.1)(eslint@8.48.0)(prettier@3.0.2) @@ -159,10 +165,10 @@ importers: version: 5.1.6 vite: specifier: 6.4.1 - version: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + version: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0) vite-plugin-webfont-dl: specifier: ^3.12.0 - version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)) + version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)) vue-cli-plugin-vuetify: specifier: 2.5.8 version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0) @@ -178,6 +184,10 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@apidevtools/json-schema-ref-parser@11.7.3': + resolution: {integrity: sha512-WApSdLdXEBb/1FUPca2lteASewEfpjEYJ8oXZP+0gExK5qSfsEKBKcA+WjY6Q4wvXwyv0+W6Kvc372pSceib9w==} + engines: {node: '>= 16'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -419,6 +429,19 @@ packages: '@vue/composition-api': optional: true + '@hey-api/client-axios@0.2.12': + resolution: {integrity: sha512-lBehVhbnhvm41cFguZuy1FO+4x8NO3Qy/ooL0Jw4bdqTu21n7DmZMPsXEF0gL7/gNdTt4QkJGwaojy+8ExtE8w==} + deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts. + peerDependencies: + axios: '>= 1.0.0 < 2' + + '@hey-api/openapi-ts@0.60.0': + resolution: {integrity: sha512-6g7TuIQ40OluuKJOJGi4dVqa/EWGStcPMA1wGaKAWkEfzhepRINBR5FMlYrWB1bOeAQSrlZJyj0MHRQzY5D4sA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + typescript: ^5.x + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -466,6 +489,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -1274,6 +1300,14 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + c12@2.0.1: + resolution: {integrity: sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + cacheable@2.3.3: resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} @@ -1323,10 +1357,21 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} @@ -1348,6 +1393,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1368,6 +1417,10 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -1574,6 +1627,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -1585,6 +1641,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1603,6 +1662,10 @@ packages: resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} engines: {node: '>=20'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1822,6 +1885,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1845,6 +1912,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + giget@1.2.5: + resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1881,6 +1952,11 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + harfbuzzjs@0.4.15: resolution: {integrity: sha512-p1edvnlc+vpRe2kz7OKzcscf0gyFiDZpco+miDxAiiZ67cu1oNlbuOkmP/A/i1l/w938VrkF2FdZ8scNcnkPrQ==} @@ -2000,6 +2076,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2204,6 +2284,26 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2230,6 +2330,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -2246,6 +2349,14 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 + nypm@0.5.4: + resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + ohash@1.1.6: + resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2317,9 +2428,15 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2476,10 +2593,17 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + rechoir@0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} @@ -2720,6 +2844,11 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -2747,6 +2876,9 @@ packages: tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -2808,6 +2940,11 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3057,6 +3194,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3071,6 +3211,9 @@ packages: y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -3096,6 +3239,12 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@apidevtools/json-schema-ref-parser@11.7.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -3263,6 +3412,20 @@ snapshots: vue: 3.3.4 vue-demi: 0.14.10(vue@3.3.4) + '@hey-api/client-axios@0.2.12(axios@1.13.5)': + dependencies: + axios: 1.13.5 + + '@hey-api/openapi-ts@0.60.0(typescript@5.1.6)': + dependencies: + '@apidevtools/json-schema-ref-parser': 11.7.3 + c12: 2.0.1 + commander: 12.1.0 + handlebars: 4.7.8 + typescript: 5.1.6 + transitivePeerDependencies: + - magicast + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -3314,6 +3477,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.5.0 @@ -3863,9 +4028,9 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)': + '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)': dependencies: - vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vite: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0) vue: 3.3.4 '@volar/language-core@1.10.10': @@ -4214,6 +4379,21 @@ snapshots: buffer-from@1.1.2: {} + c12@2.0.1: + dependencies: + chokidar: 4.0.3 + confbox: 0.1.8 + defu: 6.1.7 + dotenv: 16.6.1 + giget: 1.2.5 + jiti: 2.7.0 + mlly: 1.8.0 + ohash: 1.1.6 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.3.1 + rc9: 2.1.2 + cacheable@2.3.3: dependencies: '@cacheable/memory': 2.0.8 @@ -4274,8 +4454,18 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@2.0.0: {} + chrome-trace-event@1.0.4: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + clean-css@5.3.3: dependencies: source-map: 0.6.1 @@ -4298,6 +4488,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -4310,6 +4502,8 @@ snapshots: confbox@0.1.8: {} + consola@3.4.2: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -4534,6 +4728,8 @@ snapshots: deep-is@0.1.4: {} + defu@6.1.7: {} + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -4542,6 +4738,8 @@ snapshots: dequal@2.0.3: {} + destr@2.0.5: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -4560,6 +4758,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4823,6 +5023,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4850,6 +5054,16 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + giget@1.2.5: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.5.4 + pathe: 2.0.3 + tar: 6.2.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4890,6 +5104,15 @@ snapshots: hachure-fill@0.5.2: {} + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + harfbuzzjs@0.4.15: {} has-flag@4.0.0: {} @@ -4992,6 +5215,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.7.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -5202,6 +5427,21 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -5223,6 +5463,8 @@ snapshots: neo-async@2.6.2: {} + node-fetch-native@1.6.7: {} + node-releases@2.0.36: {} normalize-path@3.0.0: {} @@ -5237,6 +5479,17 @@ snapshots: schema-utils: 3.3.0 webpack: 5.105.0 + nypm@0.5.4: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 1.3.1 + tinyexec: 0.3.2 + ufo: 1.6.3 + + ohash@1.1.6: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5300,8 +5553,12 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5483,10 +5740,17 @@ snapshots: queue-microtask@1.2.3: {} + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + rechoir@0.6.2: dependencies: resolve: 1.22.11 @@ -5740,6 +6004,15 @@ snapshots: tapable@2.3.0: {} + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + terser-webpack-plugin@5.4.0(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -5759,6 +6032,8 @@ snapshots: tiny-case@1.0.3: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -5803,6 +6078,9 @@ snapshots: ufo@1.6.3: {} + uglify-js@3.19.3: + optional: true + undici-types@6.21.0: {} unist-util-is@6.0.1: @@ -5860,28 +6138,28 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-vuetify@2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11): + vite-plugin-vuetify@2.1.3(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11): dependencies: '@vuetify/loader-shared': 2.1.2(vue@3.3.4)(vuetify@3.7.11) debug: 4.4.3 upath: 2.0.1 - vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vite: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0) vue: 3.3.4 vuetify: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@2.1.3)(vue@3.3.4) transitivePeerDependencies: - supports-color - vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)): + vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)): dependencies: axios: 1.13.5 clean-css: 5.3.3 flat-cache: 6.1.20 picocolors: 1.1.1 - vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vite: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0) transitivePeerDependencies: - debug - vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0): + vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -5892,6 +6170,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.32 fsevents: 2.3.3 + jiti: 2.7.0 sass: 1.66.1 terser: 5.46.0 @@ -6002,7 +6281,7 @@ snapshots: vue: 3.3.4 optionalDependencies: typescript: 5.1.6 - vite-plugin-vuetify: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11) + vite-plugin-vuetify: 2.1.3(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11) w3c-keyname@2.2.8: {} @@ -6061,6 +6340,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -6073,6 +6354,8 @@ snapshots: y18n@4.0.3: {} + yallist@4.0.0: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 diff --git a/dashboard/scripts/generate_openapi_client.py b/dashboard/scripts/generate_openapi_client.py deleted file mode 100644 index f71fa05748..0000000000 --- a/dashboard/scripts/generate_openapi_client.py +++ /dev/null @@ -1,377 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import re -import urllib.request -from pathlib import Path -from typing import Any - -import yaml - -ROOT_DIR = Path(__file__).resolve().parents[2] -DASHBOARD_DIR = Path(__file__).resolve().parents[1] -DEFAULT_SPEC = ROOT_DIR / "openspec" / "openapi-v1.yaml" -DEFAULT_OUTPUT = DASHBOARD_DIR / "src" / "api" / "generated" / "openapi-v1.ts" - -HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"} -API_V1_PREFIX = "/api/v1" - - -def load_spec(source: str) -> dict[str, Any]: - if source.startswith(("http://", "https://")): - with urllib.request.urlopen(source, timeout=10) as response: - return json.loads(response.read().decode("utf-8")) - - spec_path = Path(source) - if not spec_path.is_absolute(): - spec_path = (ROOT_DIR / spec_path).resolve() - text = spec_path.read_text(encoding="utf-8") - if spec_path.suffix.lower() == ".json": - return json.loads(text) - return yaml.safe_load(text) - - -def pascal_case(value: str) -> str: - words = re.split(r"[^a-zA-Z0-9]+", value) - return "".join(word[:1].upper() + word[1:] for word in words if word) - - -def camel_case(value: str) -> str: - pascal = pascal_case(value) - return pascal[:1].lower() + pascal[1:] - - -def quote(value: str) -> str: - return json.dumps(value, ensure_ascii=True) - - -def property_name(name: str) -> str: - if re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", name): - return name - return quote(name) - - -def ref_name(ref: str) -> str: - return ref.rsplit("/", 1)[-1] - - -def client_path(path: str) -> str: - if path == API_V1_PREFIX: - return "/" - if path.startswith(f"{API_V1_PREFIX}/"): - return path.removeprefix(API_V1_PREFIX) - return path - - -class TypeScriptGenerator: - def __init__(self, spec: dict[str, Any]) -> None: - self.spec = spec - self.components = spec.get("components", {}) - - def resolve_ref(self, obj: dict[str, Any]) -> dict[str, Any]: - ref = obj.get("$ref") - if not ref: - return obj - if not ref.startswith("#/"): - raise ValueError(f"Unsupported external ref: {ref}") - current: Any = self.spec - for part in ref.removeprefix("#/").split("/"): - current = current[part] - return current - - def schema_to_ts(self, schema: dict[str, Any] | None) -> str: - if not schema: - return "unknown" - if "$ref" in schema: - return ref_name(schema["$ref"]) - - if "allOf" in schema: - parts = [self.schema_to_ts(item) for item in schema["allOf"]] - return " & ".join(parts) or "unknown" - if "oneOf" in schema: - parts = [self.schema_to_ts(item) for item in schema["oneOf"]] - return " | ".join(parts) or "unknown" - if "anyOf" in schema: - parts = [self.schema_to_ts(item) for item in schema["anyOf"]] - return " | ".join(parts) or "unknown" - - if "const" in schema: - return quote(str(schema["const"])) - if "enum" in schema: - values = schema.get("enum") or [] - return " | ".join(quote(str(value)) for value in values) or "string" - - schema_type = schema.get("type") - if isinstance(schema_type, list): - return " | ".join( - self.schema_to_ts({**schema, "type": item}) for item in schema_type - ) - - if schema_type == "string": - if schema.get("format") == "binary": - return "Blob | File" - return "string" - if schema_type in {"integer", "number"}: - return "number" - if schema_type == "boolean": - return "boolean" - if schema_type == "array": - item_type = self.schema_to_ts(schema.get("items")) - if " | " in item_type or " & " in item_type: - item_type = f"({item_type})" - return f"{item_type}[]" - if schema_type == "object" or "properties" in schema: - properties = schema.get("properties") or {} - additional = schema.get("additionalProperties") - if not properties: - if isinstance(additional, dict): - return f"Record" - return "Record" - - required = set(schema.get("required") or []) - fields = [] - for name, prop_schema in properties.items(): - optional = "" if name in required else "?" - fields.append( - f"{property_name(name)}{optional}: {self.schema_to_ts(prop_schema)};" - ) - if additional is True: - fields.append("[key: string]: unknown;") - elif isinstance(additional, dict): - fields.append(f"[key: string]: {self.schema_to_ts(additional)};") - return "{ " + " ".join(fields) + " }" - - return "unknown" - - def component_declarations(self) -> list[str]: - declarations = [] - schemas = self.components.get("schemas") or {} - for name, schema in schemas.items(): - if ( - schema.get("type") == "object" - and "properties" in schema - and "allOf" not in schema - and "oneOf" not in schema - and "anyOf" not in schema - ): - declarations.append(self.object_interface(name, schema)) - else: - declarations.append( - f"export type {name} = {self.schema_to_ts(schema)};" - ) - return declarations - - def object_interface(self, name: str, schema: dict[str, Any]) -> str: - required = set(schema.get("required") or []) - lines = [f"export interface {name} {{"] - for prop_name, prop_schema in (schema.get("properties") or {}).items(): - optional = "" if prop_name in required else "?" - lines.append( - f" {property_name(prop_name)}{optional}: " - f"{self.schema_to_ts(prop_schema)};" - ) - additional = schema.get("additionalProperties") - if additional is True: - lines.append(" [key: string]: unknown;") - elif isinstance(additional, dict): - lines.append(f" [key: string]: {self.schema_to_ts(additional)};") - lines.append("}") - return "\n".join(lines) - - def resolve_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]: - if "$ref" not in parameter: - return parameter - name = ref_name(parameter["$ref"]) - return self.components["parameters"][name] - - def request_body_type(self, request_body: dict[str, Any] | None) -> str | None: - if not request_body: - return None - if "$ref" in request_body: - request_body = self.resolve_ref(request_body) - content = request_body.get("content") or {} - if "multipart/form-data" in content: - return "FormData" - if "application/octet-stream" in content: - return "Blob | ArrayBuffer | string" - media = content.get("application/json") or next(iter(content.values()), None) - if not media: - return "unknown" - return self.schema_to_ts(media.get("schema")) - - def response_type(self, operation: dict[str, Any]) -> str: - responses = operation.get("responses") or {} - response = responses.get("200") or responses.get("201") or responses.get("101") - if not response: - return "unknown" - if "$ref" in response: - response = self.resolve_ref(response) - content = response.get("content") or {} - if "application/json" in content: - return self.schema_to_ts(content["application/json"].get("schema")) - if "text/plain" in content or "text/html" in content: - return "string" - return "unknown" - - def operation_parameters( - self, - operation: dict[str, Any], - path_item: dict[str, Any], - operation_id: str, - ) -> tuple[list[dict[str, Any]], list[str]]: - path_params: list[dict[str, Any]] = [] - query_params: list[dict[str, Any]] = [] - declarations: list[str] = [] - parameters = [ - *(path_item.get("parameters") or []), - *(operation.get("parameters") or []), - ] - - for raw_parameter in parameters: - parameter = self.resolve_parameter(raw_parameter) - target = path_params if parameter.get("in") == "path" else query_params - if parameter.get("in") in {"path", "query"}: - target.append(parameter) - - def emit_params(name_suffix: str, params: list[dict[str, Any]]) -> str | None: - if not params: - return None - type_name = f"{pascal_case(operation_id)}{name_suffix}" - lines = [f"export interface {type_name} {{"] - for param in params: - required = bool(param.get("required")) - optional = "" if required else "?" - lines.append( - f" {property_name(param['name'])}{optional}: " - f"{self.schema_to_ts(param.get('schema'))};" - ) - lines.append("}") - declarations.append("\n".join(lines)) - return type_name - - path_type = emit_params("Path", path_params) - query_type = emit_params("Query", query_params) - return declarations, [path_type or "undefined", query_type or "undefined"] - - def operation_declaration( - self, - path: str, - method: str, - path_item: dict[str, Any], - operation: dict[str, Any], - ) -> tuple[list[str], str]: - operation_id = operation.get("operationId") or camel_case(f"{method}_{path}") - operation_name = camel_case(operation_id) - declarations, [path_type, query_type] = self.operation_parameters( - operation, - path_item, - operation_id, - ) - body_type = self.request_body_type(operation.get("requestBody")) or "undefined" - response_type = self.response_type(operation) - args_type_name = f"{pascal_case(operation_id)}Args" - - members: list[str] = [] - if path_type != "undefined": - members.append(f"path: {path_type};") - if query_type != "undefined": - members.append(f"query?: {query_type};") - if body_type != "undefined": - required = bool((operation.get("requestBody") or {}).get("required")) - optional = "" if required else "?" - members.append(f"body{optional}: {body_type};") - - if members: - declarations.append( - "export interface " - + args_type_name - + " {\n " - + "\n ".join(members) - + "\n}" - ) - args_signature = f"args: {args_type_name}" - args_value = "args" - else: - args_signature = "args?: undefined" - args_value = "args" - - function = ( - f" {operation_name}({args_signature}, config?: AxiosRequestConfig) {{\n" - f" return request<{response_type}>(" - f"{quote(method.upper())}, {quote(client_path(path))}, " - f"{args_value}, config" - f");\n" - f" }}" - ) - return declarations, function - - def generate(self) -> str: - declarations = self.component_declarations() - operation_functions = [] - - for path, path_item in sorted((self.spec.get("paths") or {}).items()): - for method, operation in path_item.items(): - if method not in HTTP_METHODS: - continue - operation_declarations, operation_function = self.operation_declaration( - path, - method, - path_item, - operation, - ) - declarations.extend(operation_declarations) - operation_functions.append(operation_function) - - return ( - "\n\n".join( - [ - "/* eslint-disable */", - "// This file is auto-generated by dashboard/scripts/generate_openapi_client.py.", - "// Do not edit it manually; update openspec/openapi-v1.yaml and regenerate instead.", - "import type { AxiosRequestConfig, AxiosResponse } from 'axios';", - "import { apiV1Client } from '../http';", - "type RequestArgs = { path?: object; query?: object; body?: unknown } | undefined;", - "function encodePathValue(value: unknown): string {\n return encodeURIComponent(String(value));\n}", - "function applyPathParams(path: string, params?: object): string {\n if (!params) return path;\n const values = params as Record;\n return path.replace(/\\{([^}:]+)(?::path)?\\}/g, (_match, key) => encodePathValue(values[key]));\n}", - "function request(method: string, path: string, args?: RequestArgs, config?: AxiosRequestConfig): Promise> {\n return apiV1Client.request({\n ...config,\n method,\n url: applyPathParams(path, args?.path),\n params: args?.query,\n data: args?.body,\n });\n}", - *declarations, - "export const openApiV1 = {\n" - + ",\n".join(operation_functions) - + "\n};", - ] - ) - + "\n" - ) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Generate the dashboard OpenAPI v1 client." - ) - parser.add_argument( - "--spec", - default=str(DEFAULT_SPEC), - help="OpenAPI source URL or file path. Defaults to openspec/openapi-v1.yaml.", - ) - parser.add_argument( - "--out", - default=str(DEFAULT_OUTPUT), - help="Generated TypeScript output path.", - ) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - spec = load_spec(args.spec) - output = Path(args.out) - if not output.is_absolute(): - output = (DASHBOARD_DIR / output).resolve() - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(TypeScriptGenerator(spec).generate(), encoding="utf-8") - print(f"Generated {output.relative_to(ROOT_DIR)}") - - -if __name__ == "__main__": - main() diff --git a/dashboard/src/api/generated/openapi-v1.ts b/dashboard/src/api/generated/openapi-v1.ts deleted file mode 100644 index 835df8547b..0000000000 --- a/dashboard/src/api/generated/openapi-v1.ts +++ /dev/null @@ -1,3311 +0,0 @@ -/* eslint-disable */ - -// This file is auto-generated by dashboard/scripts/generate_openapi_client.py. - -// Do not edit it manually; update openspec/openapi-v1.yaml and regenerate instead. - -import type { AxiosRequestConfig, AxiosResponse } from 'axios'; - -import { apiV1Client } from '../http'; - -type RequestArgs = { path?: object; query?: object; body?: unknown } | undefined; - -function encodePathValue(value: unknown): string { - return encodeURIComponent(String(value)); -} - -function applyPathParams(path: string, params?: object): string { - if (!params) return path; - const values = params as Record; - return path.replace(/\{([^}:]+)(?::path)?\}/g, (_match, key) => encodePathValue(values[key])); -} - -function request(method: string, path: string, args?: RequestArgs, config?: AxiosRequestConfig): Promise> { - return apiV1Client.request({ - ...config, - method, - url: applyPathParams(path, args?.path), - params: args?.query, - data: args?.body, - }); -} - -export interface SuccessEnvelope { - status: "ok"; - message?: string; - data: unknown; -} - -export interface ErrorEnvelope { - status: "error"; - message: string; - data?: unknown; -} - -export type DynamicConfig = Record; - -export type JsonSchema = Record; - -export type ProviderCapability = "chat" | "agent" | "stt" | "tts" | "embedding" | "rerank"; - -export interface LoginRequest { - username: string; - password: string; - code?: string; - trust_device_flag?: boolean; -} - -export interface SetupAuthRequest { - username: string; - password: string; - confirm_password: string; -} - -export interface UpdateAccountRequest { - password: string; - new_password?: string; - confirm_password?: string; - new_username?: string; -} - -export interface TotpSetupRequest { - code?: string; - secret?: string; -} - -export interface CreateApiKeyRequest { - name: string; - scopes?: ("bot" | "provider" | "persona" | "im" | "config" | "chat" | "plugin")[]; - expires_at?: string; - expires_in_days?: number; -} - -export interface CreateConfigProfileRequest { - name?: string; - config?: DynamicConfig; -} - -export interface RenameRequest { - name: string; -} - -export interface NameRequest { - name: string; -} - -export interface ConfigRoutesReplaceRequest { - routing: Record; -} - -export interface ConfigRouteUpsertRequest { - config_id: string; -} - -export interface BotConfigRequest { - id?: string; - name?: string; - type: string; - enabled?: boolean; - config: DynamicConfig; -} - -export interface BotRegistrationRequest { - action: "start" | "poll"; - platform_config?: DynamicConfig; - registration_code?: string; - device_code?: string; - qrcode?: string; - [key: string]: unknown; -} - -export interface EnabledPatch { - enabled: boolean; -} - -export interface ProviderSourceConfigRequest { - id?: string; - config: DynamicConfig; -} - -export interface ProviderConfigRequest { - id?: string; - provider_source_id?: string; - capability?: ProviderCapability; - enabled?: boolean; - config: DynamicConfig; -} - -export interface ChatRequest { - username?: string; - session_id?: string; - conversation_id?: string; - message: string | MessagePart[]; - config_id?: string; - config_name?: string; - selected_provider?: string; - selected_model?: string; - enable_streaming?: boolean; - _skip_user_history?: boolean; - _llm_checkpoint_id?: string; - _platform_history_id?: string; - _thread_selected_text?: string; -} - -export interface ChatSessionBatchDeleteRequest { - session_ids: string[]; -} - -export interface ChatSessionPatchRequest { - display_name?: string; -} - -export interface ChatMessagePatchRequest { - content: Record; -} - -export interface ChatMessageRegenerateRequest { - selected_provider?: string; - selected_model?: string; - enable_streaming?: boolean; -} - -export interface ChatThreadCreateRequest { - session_id: string; - parent_message_id: string | number; - selected_text: string; -} - -export interface ChatThreadMessageRequest { - message: string | MessagePart[]; - selected_provider?: string; - selected_model?: string; - enable_streaming?: boolean; -} - -export interface ChatProjectRequest { - title?: string; - emoji?: string; - description?: string; -} - -export interface MessagePart { - type: "text" | "plain" | "image" | "file" | "audio" | "record" | "video" | "reply"; - text?: string; - attachment_id?: string; - url?: string; - filename?: string; - mime_type?: string; - [key: string]: unknown; -} - -export interface ImMessageRequest { - umo: string; - message: string | MessagePart[]; -} - -export interface FileUploadRequest { - file: Blob | File; -} - -export interface PluginUpdateRequest { - reinstall?: boolean; -} - -export interface PluginBatchUpdateRequest { - plugin_id?: string; - plugin_ids?: string[]; - reinstall?: boolean; - update_all?: boolean; - [key: string]: unknown; -} - -export interface PluginVersionSupportRequest { - astrbot_version?: string; -} - -export interface PluginGithubInstallRequest { - repository: string; - ref?: string; - download_url?: string; - proxy?: string; - ignore_version_check?: boolean; -} - -export interface PluginUrlInstallRequest { - url: string; - download_url?: string; - proxy?: string; - ignore_version_check?: boolean; -} - -export interface PluginUploadInstallRequest { - file: Blob | File; -} - -export interface PluginConfigFileDeleteRequest { - path: string; -} - -export interface PluginSourceRequest { - id?: string; - name?: string; - url: string; -} - -export interface CommandPatchRequest { - enabled?: boolean; - alias?: string; - aliases?: string[]; - permission_group?: string; -} - -export interface McpServerConfig { - name: string; - enabled?: boolean; - transport?: "stdio" | "sse" | "streamable_http"; - command?: string; - args?: string[]; - url?: string; - headers?: Record; - timeout?: number; - [key: string]: unknown; -} - -export interface ModelScopeSyncRequest { - access_token?: string; -} - -export interface SkillUploadRequest { - file: Blob | File; - overwrite?: boolean; -} - -export interface SkillPatchRequest { - enabled?: boolean; - display_name?: string; - description?: string; -} - -export interface NeoCandidateActionRequest { - candidate_id: string; - [key: string]: unknown; -} - -export interface NeoReleaseActionRequest { - release_id: string; - [key: string]: unknown; -} - -export interface KnowledgeBaseRequest { - name: string; - description?: string; - embedding_provider_id?: string; - rerank_provider_id?: string; - chunking?: DynamicConfig; - metadata?: DynamicConfig; -} - -export interface KnowledgeDocumentUploadRequest { - file: Blob | File; - parser?: string; -} - -export interface KnowledgeDocumentImportRequest { - paths: string[]; - parser?: string; -} - -export interface KnowledgeDocumentUrlImportRequest { - url: string; - parser?: string; -} - -export interface KnowledgeRetrieveRequest { - query: string; - top_k?: number; - score_threshold?: number; -} - -export interface PersonaRequest { - persona_id: string; - system_prompt: string; - begin_dialogs?: string[]; - folder_id?: string; - tools?: string[]; - skills?: string[]; - custom_error_message?: string; - [key: string]: unknown; -} - -export interface PersonaFolderRequest { - name?: string; - parent_id?: string; - description?: string; - [key: string]: unknown; -} - -export interface PersonaMoveRequest { - persona_id: string; - folder_id?: string; - [key: string]: unknown; -} - -export interface ReorderRequest { - items: ({ id: string; type: "persona" | "folder"; sort_order: number; })[]; -} - -export interface SessionRuleRequest { - umo: string; - rule_key: string; - rule_value?: DynamicConfig; - [key: string]: unknown; -} - -export interface UmoListRequest { - umo?: string; - umos?: string[]; - scope?: "all" | "group" | "private" | "custom_group"; - group_id?: string; - rule_key?: string; -} - -export type BatchSessionProviderRequest = UmoListRequest & { provider_id: string; provider_type: "chat_completion" | "speech_to_text" | "text_to_speech"; }; - -export type BatchSessionServiceRequest = UmoListRequest & { session_enabled?: boolean; llm_enabled?: boolean; tts_enabled?: boolean; }; - -export interface SessionGroupRequest { - name?: string; - umos?: string[]; - add_umos?: string[]; - remove_umos?: string[]; -} - -export interface ConversationPatchRequest { - title?: string; - persona_id?: string; -} - -export interface ConversationMessagesReplaceRequest { - user_id?: string; - messages?: Record[]; - history?: Record[]; -} - -export interface ConversationRef { - user_id: string; - cid: string; -} - -export interface ConversationBatchDeleteRequest { - conversations: ConversationRef[]; -} - -export interface ConversationExportRequest { - conversations?: ConversationRef[]; - conversation_ids?: string[]; - format?: "json" | "markdown"; -} - -export interface CronJobRequest { - name?: string; - cron_expression?: string; - timezone?: string; - session?: string; - note?: string; - description?: string; - persona_id?: string; - provider_id?: string; - enabled?: boolean; - run_once?: boolean; - run_at?: string; - payload?: Record; - [key: string]: unknown; -} - -export type CronJobPatchRequest = CronJobRequest; - -export interface BackupExportRequest { - include?: string[]; - exclude?: string[]; -} - -export interface BackupUploadRequest { - file: Blob | File; -} - -export interface BackupUploadInitRequest { - filename: string; - total_size: number; -} - -export interface BackupUploadSessionRequest { - upload_id: string; -} - -export interface BackupChunkUploadRequest { - upload_id: string; - chunk_index: number; - chunk: Blob | File; -} - -export interface BackupRenameRequest { - new_name: string; -} - -export interface BackupImportRequest { - confirmed?: boolean; -} - -export interface UpdateRequest { - version?: string; - proxy?: string; - reboot?: boolean; - progress_id?: string; -} - -export interface PipInstallRequest { - package: string; - mirror?: string; -} - -export interface MigrationRequest { - platform_id_map?: Record; - [key: string]: unknown; -} - -export interface GhproxyTestRequest { - proxy_url: string; -} - -export interface TraceSettingsRequest { - enabled?: boolean; - level?: string; - [key: string]: unknown; -} - -export interface T2iTemplateRequest { - name: string; - content: string; - [key: string]: unknown; -} - -export interface T2iTemplateContentRequest { - content: string; - [key: string]: unknown; -} - -export interface CreateApiKeyArgs { - body: CreateApiKeyRequest; -} - -export interface DeleteApiKeyPath { - key_id: string; -} - -export interface DeleteApiKeyArgs { - path: DeleteApiKeyPath; -} - -export interface RevokeApiKeyPath { - key_id: string; -} - -export interface RevokeApiKeyArgs { - path: RevokeApiKeyPath; -} - -export interface UpdateAuthAccountArgs { - body: UpdateAccountRequest; -} - -export interface LoginArgs { - body: LoginRequest; -} - -export interface SetupAuthArgs { - body: SetupAuthRequest; -} - -export interface SetupTotpArgs { - body?: TotpSetupRequest; -} - -export interface ListBackupsQuery { - page?: number; - page_size?: number; -} - -export interface ListBackupsArgs { - query?: ListBackupsQuery; -} - -export interface CreateBackupArgs { - body?: BackupExportRequest; -} - -export interface GetBackupProgressPath { - task_id: string; -} - -export interface GetBackupProgressArgs { - path: GetBackupProgressPath; -} - -export interface UploadBackupArgs { - body: FormData; -} - -export interface AbortBackupUploadArgs { - body: BackupUploadSessionRequest; -} - -export interface UploadBackupChunkArgs { - body: FormData; -} - -export interface CompleteBackupUploadArgs { - body: BackupUploadSessionRequest; -} - -export interface InitBackupUploadArgs { - body: BackupUploadInitRequest; -} - -export interface DownloadBackupPath { - filename: string; -} - -export interface DownloadBackupArgs { - path: DownloadBackupPath; -} - -export interface RenameBackupPath { - filename: string; -} - -export interface RenameBackupArgs { - path: RenameBackupPath; - body: BackupRenameRequest; -} - -export interface DeleteBackupPath { - filename: string; -} - -export interface DeleteBackupArgs { - path: DeleteBackupPath; -} - -export interface CheckBackupPath { - filename: string; -} - -export interface CheckBackupArgs { - path: CheckBackupPath; -} - -export interface ImportBackupPath { - filename: string; -} - -export interface ImportBackupArgs { - path: ImportBackupPath; - body?: BackupImportRequest; -} - -export interface RegisterBotTypePath { - bot_type: string; -} - -export interface RegisterBotTypeArgs { - path: RegisterBotTypePath; - body: BotRegistrationRequest; -} - -export interface ListBotsQuery { - enabled?: boolean; - type?: string; -} - -export interface ListBotsArgs { - query?: ListBotsQuery; -} - -export interface CreateBotArgs { - body: BotConfigRequest; -} - -export interface GetBotByIdQuery { - bot_id: string; -} - -export interface GetBotByIdArgs { - query?: GetBotByIdQuery; -} - -export interface UpdateBotByIdArgs { - body: { bot_id: string; config: DynamicConfig; }; -} - -export interface DeleteBotByIdQuery { - bot_id: string; -} - -export interface DeleteBotByIdArgs { - query?: DeleteBotByIdQuery; -} - -export interface SetBotEnabledByIdArgs { - body: { bot_id: string; enabled: boolean; }; -} - -export interface TestBotByIdArgs { - body: { bot_id: string; }; -} - -export interface GetBotPath { - bot_id: string; -} - -export interface GetBotArgs { - path: GetBotPath; -} - -export interface UpdateBotPath { - bot_id: string; -} - -export interface UpdateBotArgs { - path: UpdateBotPath; - body: BotConfigRequest; -} - -export interface DeleteBotPath { - bot_id: string; -} - -export interface DeleteBotArgs { - path: DeleteBotPath; -} - -export interface SetBotEnabledPath { - bot_id: string; -} - -export interface SetBotEnabledArgs { - path: SetBotEnabledPath; - body: EnabledPatch; -} - -export interface TestBotPath { - bot_id: string; -} - -export interface TestBotArgs { - path: TestBotPath; -} - -export interface GetChangelogPath { - version: string; -} - -export interface GetChangelogArgs { - path: GetChangelogPath; -} - -export interface SendChatMessageArgs { - body: ChatRequest; -} - -export interface CreateChatProjectArgs { - body: ChatProjectRequest; -} - -export interface RemoveChatProjectSessionPath { - session_id: string; -} - -export interface RemoveChatProjectSessionArgs { - path: RemoveChatProjectSessionPath; -} - -export interface GetChatProjectPath { - project_id: string; -} - -export interface GetChatProjectArgs { - path: GetChatProjectPath; -} - -export interface UpdateChatProjectPath { - project_id: string; -} - -export interface UpdateChatProjectArgs { - path: UpdateChatProjectPath; - body: ChatProjectRequest; -} - -export interface DeleteChatProjectPath { - project_id: string; -} - -export interface DeleteChatProjectArgs { - path: DeleteChatProjectPath; -} - -export interface ListChatProjectSessionsPath { - project_id: string; -} - -export interface ListChatProjectSessionsArgs { - path: ListChatProjectSessionsPath; -} - -export interface AddChatProjectSessionPath { - project_id: string; - session_id: string; -} - -export interface AddChatProjectSessionArgs { - path: AddChatProjectSessionPath; -} - -export interface ListChatSessionsQuery { - page?: number; - page_size?: number; - username?: string; -} - -export interface ListChatSessionsArgs { - query?: ListChatSessionsQuery; -} - -export interface BatchDeleteChatSessionsArgs { - body: ChatSessionBatchDeleteRequest; -} - -export interface CreateChatSessionQuery { - platform_id?: string; -} - -export interface CreateChatSessionArgs { - query?: CreateChatSessionQuery; -} - -export interface GetChatSessionPath { - session_id: string; -} - -export interface GetChatSessionArgs { - path: GetChatSessionPath; -} - -export interface UpdateChatSessionPath { - session_id: string; -} - -export interface UpdateChatSessionArgs { - path: UpdateChatSessionPath; - body: ChatSessionPatchRequest; -} - -export interface DeleteChatSessionPath { - session_id: string; -} - -export interface DeleteChatSessionArgs { - path: DeleteChatSessionPath; -} - -export interface UpdateChatMessagePath { - session_id: string; - message_id: string; -} - -export interface UpdateChatMessageArgs { - path: UpdateChatMessagePath; - body: ChatMessagePatchRequest; -} - -export interface RegenerateChatMessagePath { - session_id: string; - message_id: string; -} - -export interface RegenerateChatMessageArgs { - path: RegenerateChatMessagePath; - body?: ChatMessageRegenerateRequest; -} - -export interface StopChatSessionPath { - session_id: string; -} - -export interface StopChatSessionArgs { - path: StopChatSessionPath; -} - -export interface CreateChatThreadArgs { - body: ChatThreadCreateRequest; -} - -export interface GetChatThreadPath { - thread_id: string; -} - -export interface GetChatThreadArgs { - path: GetChatThreadPath; -} - -export interface DeleteChatThreadPath { - thread_id: string; -} - -export interface DeleteChatThreadArgs { - path: DeleteChatThreadPath; -} - -export interface SendChatThreadMessagePath { - thread_id: string; -} - -export interface SendChatThreadMessageArgs { - path: SendChatThreadMessagePath; - body: ChatThreadMessageRequest; -} - -export interface OpenChatWebSocketQuery { - api_key?: string; - key?: string; -} - -export interface OpenChatWebSocketArgs { - query?: OpenChatWebSocketQuery; -} - -export interface ListCommandsQuery { - config_id?: string; -} - -export interface ListCommandsArgs { - query?: ListCommandsQuery; -} - -export interface UpdateCommandPath { - command_id: string; -} - -export interface UpdateCommandArgs { - path: UpdateCommandPath; - body: CommandPatchRequest; -} - -export interface CreateConfigProfileArgs { - body: CreateConfigProfileRequest; -} - -export interface GetConfigProfilePath { - config_id: string; -} - -export interface GetConfigProfileArgs { - path: GetConfigProfilePath; -} - -export interface UpdateConfigProfileContentPath { - config_id: string; -} - -export interface UpdateConfigProfileContentArgs { - path: UpdateConfigProfileContentPath; - body: DynamicConfig; -} - -export interface RenameConfigProfilePath { - config_id: string; -} - -export interface RenameConfigProfileArgs { - path: RenameConfigProfilePath; - body: RenameRequest; -} - -export interface DeleteConfigProfilePath { - config_id: string; -} - -export interface DeleteConfigProfileArgs { - path: DeleteConfigProfilePath; -} - -export interface ReplaceConfigRoutesArgs { - body: ConfigRoutesReplaceRequest; -} - -export interface UpsertConfigRoutePath { - umo: string; -} - -export interface UpsertConfigRouteArgs { - path: UpsertConfigRoutePath; - body: ConfigRouteUpsertRequest; -} - -export interface DeleteConfigRoutePath { - umo: string; -} - -export interface DeleteConfigRouteArgs { - path: DeleteConfigRoutePath; -} - -export interface ListConversationsQuery { - page?: number; - page_size?: number; - platform_id?: string; - user_id?: string; - search?: string; - platforms?: string; - message_types?: string; - exclude_ids?: string; - exclude_platforms?: string; -} - -export interface ListConversationsArgs { - query?: ListConversationsQuery; -} - -export interface BatchDeleteConversationsArgs { - body: ConversationBatchDeleteRequest; -} - -export interface ExportConversationsArgs { - body: ConversationExportRequest; -} - -export interface GetConversationPath { - conversation_id: string; -} - -export interface GetConversationQuery { - user_id: string; -} - -export interface GetConversationArgs { - path: GetConversationPath; - query?: GetConversationQuery; -} - -export interface UpdateConversationPath { - conversation_id: string; -} - -export interface UpdateConversationQuery { - user_id: string; -} - -export interface UpdateConversationArgs { - path: UpdateConversationPath; - query?: UpdateConversationQuery; - body: ConversationPatchRequest; -} - -export interface DeleteConversationPath { - conversation_id: string; -} - -export interface DeleteConversationQuery { - user_id: string; -} - -export interface DeleteConversationArgs { - path: DeleteConversationPath; - query?: DeleteConversationQuery; -} - -export interface ReplaceConversationMessagesPath { - conversation_id: string; -} - -export interface ReplaceConversationMessagesQuery { - user_id: string; -} - -export interface ReplaceConversationMessagesArgs { - path: ReplaceConversationMessagesPath; - query?: ReplaceConversationMessagesQuery; - body: ConversationMessagesReplaceRequest; -} - -export interface ListCronJobsQuery { - type?: string; -} - -export interface ListCronJobsArgs { - query?: ListCronJobsQuery; -} - -export interface CreateCronJobArgs { - body: CronJobRequest; -} - -export interface UpdateCronJobPath { - job_id: string; -} - -export interface UpdateCronJobArgs { - path: UpdateCronJobPath; - body: CronJobPatchRequest; -} - -export interface DeleteCronJobPath { - job_id: string; -} - -export interface DeleteCronJobArgs { - path: DeleteCronJobPath; -} - -export interface RunCronJobPath { - job_id: string; -} - -export interface RunCronJobArgs { - path: RunCronJobPath; -} - -export interface UploadFileArgs { - body: FormData; -} - -export interface GetFileByNameQuery { - filename: string; -} - -export interface GetFileByNameArgs { - query?: GetFileByNameQuery; -} - -export interface GetTokenFilePath { - file_token: string; -} - -export interface GetTokenFileArgs { - path: GetTokenFilePath; -} - -export interface GetAttachmentPath { - attachment_id: string; -} - -export interface GetAttachmentArgs { - path: GetAttachmentPath; -} - -export interface DeleteAttachmentPath { - attachment_id: string; -} - -export interface DeleteAttachmentArgs { - path: DeleteAttachmentPath; -} - -export interface DownloadAttachmentPath { - attachment_id: string; -} - -export interface DownloadAttachmentArgs { - path: DownloadAttachmentPath; -} - -export interface SendImMessageArgs { - body: ImMessageRequest; -} - -export interface ListKnowledgeBasesQuery { - page?: number; - page_size?: number; - refresh_stats?: boolean; -} - -export interface ListKnowledgeBasesArgs { - query?: ListKnowledgeBasesQuery; -} - -export interface CreateKnowledgeBaseArgs { - body: KnowledgeBaseRequest; -} - -export interface GetKnowledgeTaskPath { - task_id: string; -} - -export interface GetKnowledgeTaskArgs { - path: GetKnowledgeTaskPath; -} - -export interface GetKnowledgeBasePath { - kb_id: string; -} - -export interface GetKnowledgeBaseArgs { - path: GetKnowledgeBasePath; -} - -export interface UpdateKnowledgeBasePath { - kb_id: string; -} - -export interface UpdateKnowledgeBaseArgs { - path: UpdateKnowledgeBasePath; - body: KnowledgeBaseRequest; -} - -export interface DeleteKnowledgeBasePath { - kb_id: string; -} - -export interface DeleteKnowledgeBaseArgs { - path: DeleteKnowledgeBasePath; -} - -export interface ListKnowledgeChunksPath { - kb_id: string; -} - -export interface ListKnowledgeChunksQuery { - document_id?: string; - page?: number; - page_size?: number; -} - -export interface ListKnowledgeChunksArgs { - path: ListKnowledgeChunksPath; - query?: ListKnowledgeChunksQuery; -} - -export interface DeleteKnowledgeChunkPath { - kb_id: string; - chunk_id: string; -} - -export interface DeleteKnowledgeChunkQuery { - document_id: string; -} - -export interface DeleteKnowledgeChunkArgs { - path: DeleteKnowledgeChunkPath; - query?: DeleteKnowledgeChunkQuery; -} - -export interface ListKnowledgeDocumentsPath { - kb_id: string; -} - -export interface ListKnowledgeDocumentsQuery { - page?: number; - page_size?: number; -} - -export interface ListKnowledgeDocumentsArgs { - path: ListKnowledgeDocumentsPath; - query?: ListKnowledgeDocumentsQuery; -} - -export interface UploadKnowledgeDocumentPath { - kb_id: string; -} - -export interface UploadKnowledgeDocumentArgs { - path: UploadKnowledgeDocumentPath; - body: FormData; -} - -export interface ImportKnowledgeDocumentsPath { - kb_id: string; -} - -export interface ImportKnowledgeDocumentsArgs { - path: ImportKnowledgeDocumentsPath; - body: KnowledgeDocumentImportRequest; -} - -export interface ImportKnowledgeDocumentFromUrlPath { - kb_id: string; -} - -export interface ImportKnowledgeDocumentFromUrlArgs { - path: ImportKnowledgeDocumentFromUrlPath; - body: KnowledgeDocumentUrlImportRequest; -} - -export interface GetKnowledgeDocumentPath { - kb_id: string; - document_id: string; -} - -export interface GetKnowledgeDocumentArgs { - path: GetKnowledgeDocumentPath; -} - -export interface DeleteKnowledgeDocumentPath { - kb_id: string; - document_id: string; -} - -export interface DeleteKnowledgeDocumentArgs { - path: DeleteKnowledgeDocumentPath; -} - -export interface RetrieveKnowledgeBasePath { - kb_id: string; -} - -export interface RetrieveKnowledgeBaseArgs { - path: RetrieveKnowledgeBasePath; - body: KnowledgeRetrieveRequest; -} - -export interface GetKnowledgeBaseStatsPath { - kb_id: string; -} - -export interface GetKnowledgeBaseStatsArgs { - path: GetKnowledgeBaseStatsPath; -} - -export interface OpenLiveChatWebSocketQuery { - token: string; -} - -export interface OpenLiveChatWebSocketArgs { - query?: OpenLiveChatWebSocketQuery; -} - -export interface SyncModelScopeMcpServersArgs { - body?: ModelScopeSyncRequest; -} - -export interface CreateMcpServerArgs { - body: McpServerConfig; -} - -export interface UpdateMcpServerByNameArgs { - body: { server_name: string; config?: DynamicConfig; enabled?: boolean; [key: string]: unknown; }; -} - -export interface DeleteMcpServerByNameQuery { - server_name: string; -} - -export interface DeleteMcpServerByNameArgs { - query?: DeleteMcpServerByNameQuery; -} - -export interface SetMcpServerEnabledByNameArgs { - body: { server_name: string; enabled: boolean; }; -} - -export interface TestMcpServerByNameArgs { - body: { server_name: string; mcp_server_config?: DynamicConfig; config?: DynamicConfig; [key: string]: unknown; }; -} - -export interface UpdateMcpServerPath { - server_name: string; -} - -export interface UpdateMcpServerArgs { - path: UpdateMcpServerPath; - body: McpServerConfig; -} - -export interface DeleteMcpServerPath { - server_name: string; -} - -export interface DeleteMcpServerArgs { - path: DeleteMcpServerPath; -} - -export interface SetMcpServerEnabledPath { - server_name: string; -} - -export interface SetMcpServerEnabledArgs { - path: SetMcpServerEnabledPath; - body: EnabledPatch; -} - -export interface TestMcpServerPath { - server_name: string; -} - -export interface TestMcpServerArgs { - path: TestMcpServerPath; - body?: { mcp_server_config?: Record; [key: string]: unknown; }; -} - -export interface RunMigrationsArgs { - body?: MigrationRequest; -} - -export interface ListPersonaFoldersQuery { - parent_id?: string; -} - -export interface ListPersonaFoldersArgs { - query?: ListPersonaFoldersQuery; -} - -export interface CreatePersonaFolderArgs { - body: PersonaFolderRequest; -} - -export interface UpdatePersonaFolderPath { - folder_id: string; -} - -export interface UpdatePersonaFolderArgs { - path: UpdatePersonaFolderPath; - body: PersonaFolderRequest; -} - -export interface DeletePersonaFolderPath { - folder_id: string; -} - -export interface DeletePersonaFolderArgs { - path: DeletePersonaFolderPath; -} - -export interface ListPersonasQuery { - folder_id?: string; -} - -export interface ListPersonasArgs { - query?: ListPersonasQuery; -} - -export interface CreatePersonaArgs { - body: PersonaRequest; -} - -export interface GetPersonaByIdQuery { - persona_id: string; -} - -export interface GetPersonaByIdArgs { - query?: GetPersonaByIdQuery; -} - -export interface UpdatePersonaByIdArgs { - body: { persona_id: string; [key: string]: unknown; }; -} - -export interface DeletePersonaByIdQuery { - persona_id: string; -} - -export interface DeletePersonaByIdArgs { - query?: DeletePersonaByIdQuery; -} - -export interface MovePersonaItemArgs { - body: PersonaMoveRequest; -} - -export interface ReorderPersonaItemsArgs { - body: ReorderRequest; -} - -export interface GetPersonaPath { - persona_id: string; -} - -export interface GetPersonaArgs { - path: GetPersonaPath; -} - -export interface UpdatePersonaPath { - persona_id: string; -} - -export interface UpdatePersonaArgs { - path: UpdatePersonaPath; - body: PersonaRequest; -} - -export interface DeletePersonaPath { - persona_id: string; -} - -export interface DeletePersonaArgs { - path: DeletePersonaPath; -} - -export interface InstallPipPackageArgs { - body: PipInstallRequest; -} - -export interface CreatePluginSourceArgs { - body: PluginSourceRequest; -} - -export interface ReplacePluginSourcesArgs { - body: { sources: PluginSourceRequest[]; }; -} - -export interface DeletePluginSourceByIdQuery { - source_id: string; -} - -export interface DeletePluginSourceByIdArgs { - query?: DeletePluginSourceByIdQuery; -} - -export interface DeletePluginSourcePath { - source_id: string; -} - -export interface DeletePluginSourceArgs { - path: DeletePluginSourcePath; -} - -export interface ListPluginsQuery { - include_reserved?: boolean; - enabled?: boolean; -} - -export interface ListPluginsArgs { - query?: ListPluginsQuery; -} - -export interface GetPluginByIdQuery { - plugin_id: string; -} - -export interface GetPluginByIdArgs { - query?: GetPluginByIdQuery; -} - -export interface UninstallPluginByIdQuery { - plugin_id: string; -} - -export interface UninstallPluginByIdArgs { - query?: UninstallPluginByIdQuery; - body?: { delete_config?: boolean; delete_data?: boolean; [key: string]: unknown; }; -} - -export interface GetPluginChangelogByIdQuery { - plugin_id: string; -} - -export interface GetPluginChangelogByIdArgs { - query?: GetPluginChangelogByIdQuery; -} - -export interface GetPluginConfigByIdQuery { - plugin_id: string; -} - -export interface GetPluginConfigByIdArgs { - query?: GetPluginConfigByIdQuery; -} - -export interface UpdatePluginConfigByIdArgs { - body: { plugin_id: string; config: DynamicConfig; }; -} - -export interface ListPluginConfigFilesByIdQuery { - plugin_id: string; - config_key: string; -} - -export interface ListPluginConfigFilesByIdArgs { - query?: ListPluginConfigFilesByIdQuery; -} - -export interface UploadPluginConfigFilesByIdQuery { - plugin_id: string; - config_key: string; -} - -export interface UploadPluginConfigFilesByIdArgs { - query?: UploadPluginConfigFilesByIdQuery; - body: FormData; -} - -export interface DeletePluginConfigFileByIdQuery { - plugin_id: string; -} - -export interface DeletePluginConfigFileByIdArgs { - query?: DeletePluginConfigFileByIdQuery; - body: PluginConfigFileDeleteRequest; -} - -export interface GetPluginConfigSchemaByIdQuery { - plugin_id: string; -} - -export interface GetPluginConfigSchemaByIdArgs { - query?: GetPluginConfigSchemaByIdQuery; -} - -export interface SetPluginEnabledByIdArgs { - body: { plugin_id: string; enabled: boolean; }; -} - -export interface GetPluginExtensionRoutePath { - plugin_path: string; -} - -export interface GetPluginExtensionRouteArgs { - path: GetPluginExtensionRoutePath; -} - -export interface PostPluginExtensionRoutePath { - plugin_path: string; -} - -export interface PostPluginExtensionRouteArgs { - path: PostPluginExtensionRoutePath; - body?: FormData; -} - -export interface PutPluginExtensionRoutePath { - plugin_path: string; -} - -export interface PutPluginExtensionRouteArgs { - path: PutPluginExtensionRoutePath; - body?: DynamicConfig; -} - -export interface PatchPluginExtensionRoutePath { - plugin_path: string; -} - -export interface PatchPluginExtensionRouteArgs { - path: PatchPluginExtensionRoutePath; - body?: DynamicConfig; -} - -export interface DeletePluginExtensionRoutePath { - plugin_path: string; -} - -export interface DeletePluginExtensionRouteArgs { - path: DeletePluginExtensionRoutePath; -} - -export interface UninstallFailedPluginPath { - plugin_id: string; -} - -export interface UninstallFailedPluginArgs { - path: UninstallFailedPluginPath; - body?: { delete_config?: boolean; delete_data?: boolean; [key: string]: unknown; }; -} - -export interface ReloadFailedPluginPath { - plugin_id: string; -} - -export interface ReloadFailedPluginArgs { - path: ReloadFailedPluginPath; -} - -export interface InstallPluginFromGithubArgs { - body: PluginGithubInstallRequest; -} - -export interface InstallPluginFromUploadArgs { - body: FormData; -} - -export interface InstallPluginFromUrlArgs { - body: PluginUrlInstallRequest; -} - -export interface ListPluginMarketQuery { - page?: number; - page_size?: number; - category?: string; - sort?: "recommended" | "downloads" | "updated" | "name"; - keyword?: string; - force_refresh?: boolean; - custom_registry?: string; -} - -export interface ListPluginMarketArgs { - query?: ListPluginMarketQuery; -} - -export interface GetPluginPageByIdQuery { - plugin_id: string; - page_name: string; -} - -export interface GetPluginPageByIdArgs { - query?: GetPluginPageByIdQuery; -} - -export interface GetPluginPageAssetByIdQuery { - plugin_id: string; - page_name: string; - asset_path: string; -} - -export interface GetPluginPageAssetByIdArgs { - query?: GetPluginPageAssetByIdQuery; -} - -export interface ListPluginPagesByIdQuery { - plugin_id: string; -} - -export interface ListPluginPagesByIdArgs { - query?: ListPluginPagesByIdQuery; -} - -export interface GetPluginReadmeByIdQuery { - plugin_id: string; -} - -export interface GetPluginReadmeByIdArgs { - query?: GetPluginReadmeByIdQuery; -} - -export interface ReloadPluginByIdArgs { - body: { plugin_id: string; }; -} - -export interface UpdatePluginsArgs { - body: PluginBatchUpdateRequest; -} - -export interface CheckPluginVersionSupportArgs { - body: PluginVersionSupportRequest; -} - -export interface GetPluginPath { - plugin_id: string; -} - -export interface GetPluginArgs { - path: GetPluginPath; - body?: { delete_config?: boolean; delete_data?: boolean; [key: string]: unknown; }; -} - -export interface UninstallPluginPath { - plugin_id: string; -} - -export interface UninstallPluginArgs { - path: UninstallPluginPath; - body?: { delete_config?: boolean; delete_data?: boolean; [key: string]: unknown; }; -} - -export interface GetPluginChangelogPath { - plugin_id: string; -} - -export interface GetPluginChangelogArgs { - path: GetPluginChangelogPath; -} - -export interface GetPluginConfigPath { - plugin_id: string; -} - -export interface GetPluginConfigArgs { - path: GetPluginConfigPath; -} - -export interface UpdatePluginConfigPath { - plugin_id: string; -} - -export interface UpdatePluginConfigArgs { - path: UpdatePluginConfigPath; - body: DynamicConfig; -} - -export interface DeletePluginConfigFilePath { - plugin_id: string; -} - -export interface DeletePluginConfigFileArgs { - path: DeletePluginConfigFilePath; - body: PluginConfigFileDeleteRequest; -} - -export interface ListPluginConfigFilesPath { - plugin_id: string; - config_key: string; -} - -export interface ListPluginConfigFilesArgs { - path: ListPluginConfigFilesPath; -} - -export interface UploadPluginConfigFilesPath { - plugin_id: string; - config_key: string; -} - -export interface UploadPluginConfigFilesArgs { - path: UploadPluginConfigFilesPath; - body: FormData; -} - -export interface GetPluginConfigSchemaPath { - plugin_id: string; -} - -export interface GetPluginConfigSchemaArgs { - path: GetPluginConfigSchemaPath; -} - -export interface SetPluginEnabledPath { - plugin_id: string; -} - -export interface SetPluginEnabledArgs { - path: SetPluginEnabledPath; - body: EnabledPatch; -} - -export interface ListPluginPagesPath { - plugin_id: string; -} - -export interface ListPluginPagesArgs { - path: ListPluginPagesPath; -} - -export interface GetPluginPagePath { - plugin_id: string; - page_name: string; -} - -export interface GetPluginPageArgs { - path: GetPluginPagePath; -} - -export interface GetPluginPageAssetPath { - plugin_id: string; - page_name: string; - asset_path: string; -} - -export interface GetPluginPageAssetArgs { - path: GetPluginPageAssetPath; -} - -export interface GetPluginReadmePath { - plugin_id: string; -} - -export interface GetPluginReadmeArgs { - path: GetPluginReadmePath; -} - -export interface ReloadPluginPath { - plugin_id: string; -} - -export interface ReloadPluginArgs { - path: ReloadPluginPath; -} - -export interface UpdatePluginPath { - plugin_id: string; -} - -export interface UpdatePluginArgs { - path: UpdatePluginPath; - body?: PluginUpdateRequest; -} - -export interface CreateProviderSourceArgs { - body: ProviderSourceConfigRequest; -} - -export interface GetProviderSourceByIdQuery { - source_id: string; -} - -export interface GetProviderSourceByIdArgs { - query?: GetProviderSourceByIdQuery; -} - -export interface UpsertProviderSourceByIdArgs { - body: { source_id: string; config: DynamicConfig; }; -} - -export interface DeleteProviderSourceByIdQuery { - source_id: string; -} - -export interface DeleteProviderSourceByIdArgs { - query?: DeleteProviderSourceByIdQuery; -} - -export interface ListProviderSourceModelsByIdQuery { - source_id: string; - capability?: ProviderCapability; -} - -export interface ListProviderSourceModelsByIdArgs { - query?: ListProviderSourceModelsByIdQuery; -} - -export interface ListProvidersBySourceIdQuery { - source_id: string; - capability?: ProviderCapability; -} - -export interface ListProvidersBySourceIdArgs { - query?: ListProvidersBySourceIdQuery; -} - -export interface CreateProviderInSourceByIdArgs { - body: { source_id: string; config: DynamicConfig; }; -} - -export interface GetProviderSourcePath { - source_id: string; -} - -export interface GetProviderSourceArgs { - path: GetProviderSourcePath; -} - -export interface UpsertProviderSourcePath { - source_id: string; -} - -export interface UpsertProviderSourceArgs { - path: UpsertProviderSourcePath; - body: ProviderSourceConfigRequest; -} - -export interface DeleteProviderSourcePath { - source_id: string; -} - -export interface DeleteProviderSourceArgs { - path: DeleteProviderSourcePath; -} - -export interface ListProviderSourceModelsPath { - source_id: string; -} - -export interface ListProviderSourceModelsQuery { - capability?: ProviderCapability; -} - -export interface ListProviderSourceModelsArgs { - path: ListProviderSourceModelsPath; - query?: ListProviderSourceModelsQuery; -} - -export interface ListProvidersBySourcePath { - source_id: string; -} - -export interface ListProvidersBySourceQuery { - capability?: ProviderCapability; -} - -export interface ListProvidersBySourceArgs { - path: ListProvidersBySourcePath; - query?: ListProvidersBySourceQuery; -} - -export interface CreateProviderInSourcePath { - source_id: string; -} - -export interface CreateProviderInSourceArgs { - path: CreateProviderInSourcePath; - body: ProviderConfigRequest; -} - -export interface ListProvidersQuery { - capability?: ProviderCapability; - source_id?: string; - enabled?: boolean; -} - -export interface ListProvidersArgs { - query?: ListProvidersQuery; -} - -export interface CreateProviderArgs { - body: ProviderConfigRequest; -} - -export interface GetProviderByIdQuery { - provider_id: string; - merged?: boolean; -} - -export interface GetProviderByIdArgs { - query?: GetProviderByIdQuery; -} - -export interface UpdateProviderByIdArgs { - body: { provider_id: string; config: DynamicConfig; }; -} - -export interface DeleteProviderByIdQuery { - provider_id: string; -} - -export interface DeleteProviderByIdArgs { - query?: DeleteProviderByIdQuery; -} - -export interface GetProviderEmbeddingDimensionByIdArgs { - body: { provider_id: string; provider_config?: DynamicConfig; }; -} - -export interface SetProviderEnabledByIdArgs { - body: { provider_id: string; enabled: boolean; }; -} - -export interface TestProviderByIdArgs { - body: { provider_id: string; }; -} - -export interface GetProviderPath { - provider_id: string; -} - -export interface GetProviderQuery { - merged?: boolean; -} - -export interface GetProviderArgs { - path: GetProviderPath; - query?: GetProviderQuery; -} - -export interface UpdateProviderPath { - provider_id: string; -} - -export interface UpdateProviderArgs { - path: UpdateProviderPath; - body: ProviderConfigRequest; -} - -export interface DeleteProviderPath { - provider_id: string; -} - -export interface DeleteProviderArgs { - path: DeleteProviderPath; - body?: DynamicConfig; -} - -export interface GetProviderEmbeddingDimensionPath { - provider_id: string; -} - -export interface GetProviderEmbeddingDimensionArgs { - path: GetProviderEmbeddingDimensionPath; - body?: DynamicConfig; -} - -export interface SetProviderEnabledPath { - provider_id: string; -} - -export interface SetProviderEnabledArgs { - path: SetProviderEnabledPath; - body: EnabledPatch; -} - -export interface TestProviderPath { - provider_id: string; -} - -export interface TestProviderArgs { - path: TestProviderPath; -} - -export interface CreateSessionGroupArgs { - body: SessionGroupRequest; -} - -export interface UpdateSessionGroupPath { - group_id: string; -} - -export interface UpdateSessionGroupArgs { - path: UpdateSessionGroupPath; - body: SessionGroupRequest; -} - -export interface DeleteSessionGroupPath { - group_id: string; -} - -export interface DeleteSessionGroupArgs { - path: DeleteSessionGroupPath; -} - -export interface ListSessionsQuery { - page?: number; - page_size?: number; - search?: string; - platform?: string; - message_type?: "all" | "group" | "private"; -} - -export interface ListSessionsArgs { - query?: ListSessionsQuery; -} - -export interface BatchUpdateSessionProviderArgs { - body: BatchSessionProviderRequest; -} - -export interface ListSessionRulesQuery { - page?: number; - page_size?: number; - search?: string; -} - -export interface ListSessionRulesArgs { - query?: ListSessionRulesQuery; -} - -export interface UpsertSessionRuleArgs { - body: SessionRuleRequest; -} - -export interface DeleteSessionRulesArgs { - body: UmoListRequest; -} - -export interface BatchUpdateSessionServiceArgs { - body: BatchSessionServiceRequest; -} - -export interface ListSkillsQuery { - enabled?: boolean; - source?: string; -} - -export interface ListSkillsArgs { - query?: ListSkillsQuery; -} - -export interface UploadSkillArgs { - body: FormData; -} - -export interface DownloadSkillByNameQuery { - skill_name: string; -} - -export interface DownloadSkillByNameArgs { - query?: DownloadSkillByNameQuery; -} - -export interface UploadSkillsBatchArgs { - body: FormData; -} - -export interface UpdateSkillByNameArgs { - body: { skill_name: string; enabled?: boolean; active?: boolean; [key: string]: unknown; }; -} - -export interface DeleteSkillByNameQuery { - skill_name: string; -} - -export interface DeleteSkillByNameArgs { - query?: DeleteSkillByNameQuery; -} - -export interface GetSkillFileByNameQuery { - skill_name: string; - path: string; -} - -export interface GetSkillFileByNameArgs { - query?: GetSkillFileByNameQuery; -} - -export interface UpdateSkillFileByNameArgs { - body: { skill_name: string; path: string; content: string; }; -} - -export interface ListSkillFilesByNameQuery { - skill_name: string; - path?: string; -} - -export interface ListSkillFilesByNameArgs { - query?: ListSkillFilesByNameQuery; -} - -export interface ListNeoSkillCandidatesQuery { - skill_key?: string; - status?: string; -} - -export interface ListNeoSkillCandidatesArgs { - query?: ListNeoSkillCandidatesQuery; -} - -export interface DeleteNeoSkillCandidateArgs { - body: NeoCandidateActionRequest; -} - -export interface EvaluateNeoSkillCandidateArgs { - body: NeoCandidateActionRequest; -} - -export interface GetNeoSkillPayloadQuery { - payload_ref: string; -} - -export interface GetNeoSkillPayloadArgs { - query?: GetNeoSkillPayloadQuery; -} - -export interface PromoteNeoSkillCandidateArgs { - body: NeoCandidateActionRequest; -} - -export interface ListNeoSkillReleasesQuery { - skill_key?: string; - stage?: string; -} - -export interface ListNeoSkillReleasesArgs { - query?: ListNeoSkillReleasesQuery; -} - -export interface DeleteNeoSkillReleaseArgs { - body: NeoReleaseActionRequest; -} - -export interface RollbackNeoSkillReleaseArgs { - body: NeoReleaseActionRequest; -} - -export interface SyncNeoSkillReleaseArgs { - body: NeoReleaseActionRequest; -} - -export interface UpdateSkillPath { - skill_name: string; -} - -export interface UpdateSkillArgs { - path: UpdateSkillPath; - body: SkillPatchRequest; -} - -export interface DeleteSkillPath { - skill_name: string; -} - -export interface DeleteSkillArgs { - path: DeleteSkillPath; -} - -export interface DownloadSkillPath { - skill_name: string; -} - -export interface DownloadSkillArgs { - path: DownloadSkillPath; -} - -export interface ListSkillFilesPath { - skill_name: string; -} - -export interface ListSkillFilesQuery { - path?: string; -} - -export interface ListSkillFilesArgs { - path: ListSkillFilesPath; - query?: ListSkillFilesQuery; -} - -export interface GetSkillFilePath { - skill_name: string; - file_path: string; -} - -export interface GetSkillFileArgs { - path: GetSkillFilePath; -} - -export interface UpdateSkillFilePath { - skill_name: string; - file_path: string; -} - -export interface UpdateSkillFileArgs { - path: UpdateSkillFilePath; - body: string; -} - -export interface GetStatsQuery { - offset_sec?: number; -} - -export interface GetStatsArgs { - query?: GetStatsQuery; -} - -export interface GetFirstNoticeQuery { - locale?: string; -} - -export interface GetFirstNoticeArgs { - query?: GetFirstNoticeQuery; -} - -export interface TestGhproxyConnectionArgs { - body: GhproxyTestRequest; -} - -export interface GetProviderTokenStatsQuery { - days?: number; -} - -export interface GetProviderTokenStatsArgs { - query?: GetProviderTokenStatsQuery; -} - -export interface CleanupStorageArgs { - body?: DynamicConfig; -} - -export interface UpdateSubagentConfigArgs { - body: DynamicConfig; -} - -export interface UpdateSystemConfigArgs { - body: DynamicConfig; -} - -export interface CreateT2iTemplateArgs { - body: T2iTemplateRequest; -} - -export interface SetActiveT2iTemplateArgs { - body: NameRequest; -} - -export interface GetT2iTemplatePath { - name: string; -} - -export interface GetT2iTemplateArgs { - path: GetT2iTemplatePath; -} - -export interface UpdateT2iTemplatePath { - name: string; -} - -export interface UpdateT2iTemplateArgs { - path: UpdateT2iTemplatePath; - body: T2iTemplateContentRequest; -} - -export interface DeleteT2iTemplatePath { - name: string; -} - -export interface DeleteT2iTemplateArgs { - path: DeleteT2iTemplatePath; -} - -export interface ListToolsQuery { - origin?: "builtin" | "plugin" | "mcp"; - enabled?: boolean; -} - -export interface ListToolsArgs { - query?: ListToolsQuery; -} - -export interface SetToolEnabledPath { - tool_id: string; -} - -export interface SetToolEnabledArgs { - path: SetToolEnabledPath; - body: EnabledPatch; -} - -export interface UpdateTraceSettingsArgs { - body: TraceSettingsRequest; -} - -export interface OpenUnifiedChatWebSocketQuery { - token: string; -} - -export interface OpenUnifiedChatWebSocketArgs { - query?: OpenUnifiedChatWebSocketQuery; -} - -export interface UpdateCoreArgs { - body?: UpdateRequest; -} - -export interface UpdateDashboardArgs { - body?: UpdateRequest; -} - -export interface GetUpdateProgressPath { - task_id: string; -} - -export interface GetUpdateProgressArgs { - path: GetUpdateProgressPath; -} - -export interface ListReleasesQuery { - type?: "core" | "dashboard"; -} - -export interface ListReleasesArgs { - query?: ListReleasesQuery; -} - -export interface VerifyPlatformWebhookPath { - webhook_uuid: string; -} - -export interface VerifyPlatformWebhookArgs { - path: VerifyPlatformWebhookPath; -} - -export interface ReceivePlatformWebhookPath { - webhook_uuid: string; -} - -export interface ReceivePlatformWebhookArgs { - path: ReceivePlatformWebhookPath; - body?: Blob | ArrayBuffer | string; -} - -export const openApiV1 = { - listApiKeys(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/api-keys", args, config); - }, - createApiKey(args: CreateApiKeyArgs, config?: AxiosRequestConfig) { - return request("POST", "/api-keys", args, config); - }, - deleteApiKey(args: DeleteApiKeyArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/api-keys/{key_id}", args, config); - }, - revokeApiKey(args: RevokeApiKeyArgs, config?: AxiosRequestConfig) { - return request("POST", "/api-keys/{key_id}/revoke", args, config); - }, - updateAuthAccount(args: UpdateAuthAccountArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/auth/account", args, config); - }, - login(args: LoginArgs, config?: AxiosRequestConfig) { - return request("POST", "/auth/login", args, config); - }, - logout(args?: undefined, config?: AxiosRequestConfig) { - return request("POST", "/auth/logout", args, config); - }, - setupAuth(args: SetupAuthArgs, config?: AxiosRequestConfig) { - return request("POST", "/auth/setup", args, config); - }, - getAuthSetupStatus(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/auth/setup-status", args, config); - }, - recoverTotp(args?: undefined, config?: AxiosRequestConfig) { - return request("POST", "/auth/totp/recovery", args, config); - }, - setupTotp(args: SetupTotpArgs, config?: AxiosRequestConfig) { - return request("POST", "/auth/totp/setup", args, config); - }, - listBackups(args: ListBackupsArgs, config?: AxiosRequestConfig) { - return request("GET", "/backups", args, config); - }, - createBackup(args: CreateBackupArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups", args, config); - }, - getBackupProgress(args: GetBackupProgressArgs, config?: AxiosRequestConfig) { - return request("GET", "/backups/tasks/{task_id}", args, config); - }, - uploadBackup(args: UploadBackupArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/upload", args, config); - }, - abortBackupUpload(args: AbortBackupUploadArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/upload/abort", args, config); - }, - uploadBackupChunk(args: UploadBackupChunkArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/upload/chunk", args, config); - }, - completeBackupUpload(args: CompleteBackupUploadArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/upload/complete", args, config); - }, - initBackupUpload(args: InitBackupUploadArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/upload/init", args, config); - }, - downloadBackup(args: DownloadBackupArgs, config?: AxiosRequestConfig) { - return request("GET", "/backups/{filename}", args, config); - }, - renameBackup(args: RenameBackupArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/backups/{filename}", args, config); - }, - deleteBackup(args: DeleteBackupArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/backups/{filename}", args, config); - }, - checkBackup(args: CheckBackupArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/{filename}/check", args, config); - }, - importBackup(args: ImportBackupArgs, config?: AxiosRequestConfig) { - return request("POST", "/backups/{filename}/import", args, config); - }, - listBotTypes(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/bot-types", args, config); - }, - registerBotType(args: RegisterBotTypeArgs, config?: AxiosRequestConfig) { - return request("POST", "/bot-types/{bot_type}/registration", args, config); - }, - listBots(args: ListBotsArgs, config?: AxiosRequestConfig) { - return request("GET", "/bots", args, config); - }, - createBot(args: CreateBotArgs, config?: AxiosRequestConfig) { - return request("POST", "/bots", args, config); - }, - getBotById(args: GetBotByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/bots/by-id", args, config); - }, - updateBotById(args: UpdateBotByIdArgs, config?: AxiosRequestConfig) { - return request("PUT", "/bots/by-id", args, config); - }, - deleteBotById(args: DeleteBotByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/bots/by-id", args, config); - }, - setBotEnabledById(args: SetBotEnabledByIdArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/bots/enabled", args, config); - }, - listBotStats(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/bots/stats", args, config); - }, - testBotById(args: TestBotByIdArgs, config?: AxiosRequestConfig) { - return request("POST", "/bots/test", args, config); - }, - getBot(args: GetBotArgs, config?: AxiosRequestConfig) { - return request("GET", "/bots/{bot_id}", args, config); - }, - updateBot(args: UpdateBotArgs, config?: AxiosRequestConfig) { - return request("PUT", "/bots/{bot_id}", args, config); - }, - deleteBot(args: DeleteBotArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/bots/{bot_id}", args, config); - }, - setBotEnabled(args: SetBotEnabledArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/bots/{bot_id}/enabled", args, config); - }, - testBot(args: TestBotArgs, config?: AxiosRequestConfig) { - return request("POST", "/bots/{bot_id}/test", args, config); - }, - listChangelogVersions(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/changelogs", args, config); - }, - getChangelog(args: GetChangelogArgs, config?: AxiosRequestConfig) { - return request("GET", "/changelogs/{version}", args, config); - }, - sendChatMessage(args: SendChatMessageArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat", args, config); - }, - listChatConfigs(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/chat/configs", args, config); - }, - listChatProjects(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/chat/projects", args, config); - }, - createChatProject(args: CreateChatProjectArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/projects", args, config); - }, - removeChatProjectSession(args: RemoveChatProjectSessionArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/chat/projects/sessions/{session_id}", args, config); - }, - getChatProject(args: GetChatProjectArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/projects/{project_id}", args, config); - }, - updateChatProject(args: UpdateChatProjectArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/chat/projects/{project_id}", args, config); - }, - deleteChatProject(args: DeleteChatProjectArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/chat/projects/{project_id}", args, config); - }, - listChatProjectSessions(args: ListChatProjectSessionsArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/projects/{project_id}/sessions", args, config); - }, - addChatProjectSession(args: AddChatProjectSessionArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/projects/{project_id}/sessions/{session_id}", args, config); - }, - listChatSessions(args: ListChatSessionsArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/sessions", args, config); - }, - batchDeleteChatSessions(args: BatchDeleteChatSessionsArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/sessions/batch-delete", args, config); - }, - createChatSession(args: CreateChatSessionArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/sessions/new", args, config); - }, - getChatSession(args: GetChatSessionArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/sessions/{session_id}", args, config); - }, - updateChatSession(args: UpdateChatSessionArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/chat/sessions/{session_id}", args, config); - }, - deleteChatSession(args: DeleteChatSessionArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/chat/sessions/{session_id}", args, config); - }, - updateChatMessage(args: UpdateChatMessageArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/chat/sessions/{session_id}/messages/{message_id}", args, config); - }, - regenerateChatMessage(args: RegenerateChatMessageArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/sessions/{session_id}/messages/{message_id}/regenerate", args, config); - }, - stopChatSession(args: StopChatSessionArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/sessions/{session_id}/stop", args, config); - }, - createChatThread(args: CreateChatThreadArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/threads", args, config); - }, - getChatThread(args: GetChatThreadArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/threads/{thread_id}", args, config); - }, - deleteChatThread(args: DeleteChatThreadArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/chat/threads/{thread_id}", args, config); - }, - sendChatThreadMessage(args: SendChatThreadMessageArgs, config?: AxiosRequestConfig) { - return request("POST", "/chat/threads/{thread_id}/messages", args, config); - }, - openChatWebSocket(args: OpenChatWebSocketArgs, config?: AxiosRequestConfig) { - return request("GET", "/chat/ws", args, config); - }, - listCommands(args: ListCommandsArgs, config?: AxiosRequestConfig) { - return request("GET", "/commands", args, config); - }, - listCommandConflicts(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/commands/conflicts", args, config); - }, - updateCommand(args: UpdateCommandArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/commands/{command_id}", args, config); - }, - listConfigProfiles(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/config-profiles", args, config); - }, - createConfigProfile(args: CreateConfigProfileArgs, config?: AxiosRequestConfig) { - return request("POST", "/config-profiles", args, config); - }, - getConfigProfileSchema(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/config-profiles/schema", args, config); - }, - getConfigProfile(args: GetConfigProfileArgs, config?: AxiosRequestConfig) { - return request("GET", "/config-profiles/{config_id}", args, config); - }, - updateConfigProfileContent(args: UpdateConfigProfileContentArgs, config?: AxiosRequestConfig) { - return request("PUT", "/config-profiles/{config_id}", args, config); - }, - renameConfigProfile(args: RenameConfigProfileArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/config-profiles/{config_id}", args, config); - }, - deleteConfigProfile(args: DeleteConfigProfileArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/config-profiles/{config_id}", args, config); - }, - listConfigRoutes(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/config-routes", args, config); - }, - replaceConfigRoutes(args: ReplaceConfigRoutesArgs, config?: AxiosRequestConfig) { - return request("PUT", "/config-routes", args, config); - }, - upsertConfigRoute(args: UpsertConfigRouteArgs, config?: AxiosRequestConfig) { - return request("PUT", "/config-routes/{umo}", args, config); - }, - deleteConfigRoute(args: DeleteConfigRouteArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/config-routes/{umo}", args, config); - }, - listConversations(args: ListConversationsArgs, config?: AxiosRequestConfig) { - return request("GET", "/conversations", args, config); - }, - batchDeleteConversations(args: BatchDeleteConversationsArgs, config?: AxiosRequestConfig) { - return request("POST", "/conversations/batch-delete", args, config); - }, - exportConversations(args: ExportConversationsArgs, config?: AxiosRequestConfig) { - return request("POST", "/conversations/export", args, config); - }, - getConversation(args: GetConversationArgs, config?: AxiosRequestConfig) { - return request("GET", "/conversations/{conversation_id}", args, config); - }, - updateConversation(args: UpdateConversationArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/conversations/{conversation_id}", args, config); - }, - deleteConversation(args: DeleteConversationArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/conversations/{conversation_id}", args, config); - }, - replaceConversationMessages(args: ReplaceConversationMessagesArgs, config?: AxiosRequestConfig) { - return request("PUT", "/conversations/{conversation_id}/messages", args, config); - }, - listCronJobs(args: ListCronJobsArgs, config?: AxiosRequestConfig) { - return request("GET", "/cron/jobs", args, config); - }, - createCronJob(args: CreateCronJobArgs, config?: AxiosRequestConfig) { - return request("POST", "/cron/jobs", args, config); - }, - updateCronJob(args: UpdateCronJobArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/cron/jobs/{job_id}", args, config); - }, - deleteCronJob(args: DeleteCronJobArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/cron/jobs/{job_id}", args, config); - }, - runCronJob(args: RunCronJobArgs, config?: AxiosRequestConfig) { - return request("POST", "/cron/jobs/{job_id}/run", args, config); - }, - uploadFile(args: UploadFileArgs, config?: AxiosRequestConfig) { - return request("POST", "/files", args, config); - }, - getFileByName(args: GetFileByNameArgs, config?: AxiosRequestConfig) { - return request("GET", "/files/content", args, config); - }, - getTokenFile(args: GetTokenFileArgs, config?: AxiosRequestConfig) { - return request("GET", "/files/tokens/{file_token}", args, config); - }, - getAttachment(args: GetAttachmentArgs, config?: AxiosRequestConfig) { - return request("GET", "/files/{attachment_id}", args, config); - }, - deleteAttachment(args: DeleteAttachmentArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/files/{attachment_id}", args, config); - }, - downloadAttachment(args: DownloadAttachmentArgs, config?: AxiosRequestConfig) { - return request("GET", "/files/{attachment_id}/content", args, config); - }, - listImBots(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/im/bots", args, config); - }, - sendImMessage(args: SendImMessageArgs, config?: AxiosRequestConfig) { - return request("POST", "/im/messages", args, config); - }, - listKnowledgeBases(args: ListKnowledgeBasesArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases", args, config); - }, - createKnowledgeBase(args: CreateKnowledgeBaseArgs, config?: AxiosRequestConfig) { - return request("POST", "/knowledge-bases", args, config); - }, - getKnowledgeTask(args: GetKnowledgeTaskArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases/tasks/{task_id}", args, config); - }, - getKnowledgeBase(args: GetKnowledgeBaseArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases/{kb_id}", args, config); - }, - updateKnowledgeBase(args: UpdateKnowledgeBaseArgs, config?: AxiosRequestConfig) { - return request("PUT", "/knowledge-bases/{kb_id}", args, config); - }, - deleteKnowledgeBase(args: DeleteKnowledgeBaseArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/knowledge-bases/{kb_id}", args, config); - }, - listKnowledgeChunks(args: ListKnowledgeChunksArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases/{kb_id}/chunks", args, config); - }, - deleteKnowledgeChunk(args: DeleteKnowledgeChunkArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/knowledge-bases/{kb_id}/chunks/{chunk_id}", args, config); - }, - listKnowledgeDocuments(args: ListKnowledgeDocumentsArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases/{kb_id}/documents", args, config); - }, - uploadKnowledgeDocument(args: UploadKnowledgeDocumentArgs, config?: AxiosRequestConfig) { - return request("POST", "/knowledge-bases/{kb_id}/documents", args, config); - }, - importKnowledgeDocuments(args: ImportKnowledgeDocumentsArgs, config?: AxiosRequestConfig) { - return request("POST", "/knowledge-bases/{kb_id}/documents/import", args, config); - }, - importKnowledgeDocumentFromUrl(args: ImportKnowledgeDocumentFromUrlArgs, config?: AxiosRequestConfig) { - return request("POST", "/knowledge-bases/{kb_id}/documents/import-url", args, config); - }, - getKnowledgeDocument(args: GetKnowledgeDocumentArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases/{kb_id}/documents/{document_id}", args, config); - }, - deleteKnowledgeDocument(args: DeleteKnowledgeDocumentArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/knowledge-bases/{kb_id}/documents/{document_id}", args, config); - }, - retrieveKnowledgeBase(args: RetrieveKnowledgeBaseArgs, config?: AxiosRequestConfig) { - return request("POST", "/knowledge-bases/{kb_id}/retrieve", args, config); - }, - getKnowledgeBaseStats(args: GetKnowledgeBaseStatsArgs, config?: AxiosRequestConfig) { - return request("GET", "/knowledge-bases/{kb_id}/stats", args, config); - }, - openLiveChatWebSocket(args: OpenLiveChatWebSocketArgs, config?: AxiosRequestConfig) { - return request("GET", "/live-chat/ws", args, config); - }, - getLogHistory(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/logs/history", args, config); - }, - streamLiveLogs(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/logs/live", args, config); - }, - syncModelScopeMcpServers(args: SyncModelScopeMcpServersArgs, config?: AxiosRequestConfig) { - return request("POST", "/mcp/providers/modelscope/sync", args, config); - }, - listMcpServers(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/mcp/servers", args, config); - }, - createMcpServer(args: CreateMcpServerArgs, config?: AxiosRequestConfig) { - return request("POST", "/mcp/servers", args, config); - }, - updateMcpServerByName(args: UpdateMcpServerByNameArgs, config?: AxiosRequestConfig) { - return request("PUT", "/mcp/servers/by-name", args, config); - }, - deleteMcpServerByName(args: DeleteMcpServerByNameArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/mcp/servers/by-name", args, config); - }, - setMcpServerEnabledByName(args: SetMcpServerEnabledByNameArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/mcp/servers/enabled", args, config); - }, - testMcpServerByName(args: TestMcpServerByNameArgs, config?: AxiosRequestConfig) { - return request("POST", "/mcp/servers/test", args, config); - }, - updateMcpServer(args: UpdateMcpServerArgs, config?: AxiosRequestConfig) { - return request("PUT", "/mcp/servers/{server_name}", args, config); - }, - deleteMcpServer(args: DeleteMcpServerArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/mcp/servers/{server_name}", args, config); - }, - setMcpServerEnabled(args: SetMcpServerEnabledArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/mcp/servers/{server_name}/enabled", args, config); - }, - testMcpServer(args: TestMcpServerArgs, config?: AxiosRequestConfig) { - return request("POST", "/mcp/servers/{server_name}/test", args, config); - }, - runMigrations(args: RunMigrationsArgs, config?: AxiosRequestConfig) { - return request("POST", "/migrations", args, config); - }, - listPersonaFolders(args: ListPersonaFoldersArgs, config?: AxiosRequestConfig) { - return request("GET", "/persona-folders", args, config); - }, - createPersonaFolder(args: CreatePersonaFolderArgs, config?: AxiosRequestConfig) { - return request("POST", "/persona-folders", args, config); - }, - updatePersonaFolder(args: UpdatePersonaFolderArgs, config?: AxiosRequestConfig) { - return request("PUT", "/persona-folders/{folder_id}", args, config); - }, - deletePersonaFolder(args: DeletePersonaFolderArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/persona-folders/{folder_id}", args, config); - }, - listPersonas(args: ListPersonasArgs, config?: AxiosRequestConfig) { - return request("GET", "/personas", args, config); - }, - createPersona(args: CreatePersonaArgs, config?: AxiosRequestConfig) { - return request("POST", "/personas", args, config); - }, - getPersonaById(args: GetPersonaByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/personas/by-id", args, config); - }, - updatePersonaById(args: UpdatePersonaByIdArgs, config?: AxiosRequestConfig) { - return request("PUT", "/personas/by-id", args, config); - }, - deletePersonaById(args: DeletePersonaByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/personas/by-id", args, config); - }, - movePersonaItem(args: MovePersonaItemArgs, config?: AxiosRequestConfig) { - return request("POST", "/personas/move", args, config); - }, - reorderPersonaItems(args: ReorderPersonaItemsArgs, config?: AxiosRequestConfig) { - return request("POST", "/personas/reorder", args, config); - }, - getPersonaTree(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/personas/tree", args, config); - }, - getPersona(args: GetPersonaArgs, config?: AxiosRequestConfig) { - return request("GET", "/personas/{persona_id}", args, config); - }, - updatePersona(args: UpdatePersonaArgs, config?: AxiosRequestConfig) { - return request("PUT", "/personas/{persona_id}", args, config); - }, - deletePersona(args: DeletePersonaArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/personas/{persona_id}", args, config); - }, - installPipPackage(args: InstallPipPackageArgs, config?: AxiosRequestConfig) { - return request("POST", "/pip/install", args, config); - }, - listPluginSources(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/plugin-sources", args, config); - }, - createPluginSource(args: CreatePluginSourceArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugin-sources", args, config); - }, - replacePluginSources(args: ReplacePluginSourcesArgs, config?: AxiosRequestConfig) { - return request("PUT", "/plugin-sources", args, config); - }, - deletePluginSourceById(args: DeletePluginSourceByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugin-sources/by-id", args, config); - }, - deletePluginSource(args: DeletePluginSourceArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugin-sources/{source_id}", args, config); - }, - listPlugins(args: ListPluginsArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins", args, config); - }, - getPluginById(args: GetPluginByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/by-id", args, config); - }, - uninstallPluginById(args: UninstallPluginByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugins/by-id", args, config); - }, - getPluginChangelogById(args: GetPluginChangelogByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/changelog", args, config); - }, - getPluginConfigById(args: GetPluginConfigByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/config", args, config); - }, - updatePluginConfigById(args: UpdatePluginConfigByIdArgs, config?: AxiosRequestConfig) { - return request("PUT", "/plugins/config", args, config); - }, - listPluginConfigFilesById(args: ListPluginConfigFilesByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/config-files", args, config); - }, - uploadPluginConfigFilesById(args: UploadPluginConfigFilesByIdArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/config-files", args, config); - }, - deletePluginConfigFileById(args: DeletePluginConfigFileByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugins/config-files", args, config); - }, - getPluginConfigSchemaById(args: GetPluginConfigSchemaByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/config/schema", args, config); - }, - setPluginEnabledById(args: SetPluginEnabledByIdArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/plugins/enabled", args, config); - }, - getPluginExtensionRoute(args: GetPluginExtensionRouteArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/extensions/{plugin_path}", args, config); - }, - postPluginExtensionRoute(args: PostPluginExtensionRouteArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/extensions/{plugin_path}", args, config); - }, - putPluginExtensionRoute(args: PutPluginExtensionRouteArgs, config?: AxiosRequestConfig) { - return request("PUT", "/plugins/extensions/{plugin_path}", args, config); - }, - patchPluginExtensionRoute(args: PatchPluginExtensionRouteArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/plugins/extensions/{plugin_path}", args, config); - }, - deletePluginExtensionRoute(args: DeletePluginExtensionRouteArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugins/extensions/{plugin_path}", args, config); - }, - listFailedPlugins(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/plugins/failed", args, config); - }, - uninstallFailedPlugin(args: UninstallFailedPluginArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugins/failed/{plugin_id}", args, config); - }, - reloadFailedPlugin(args: ReloadFailedPluginArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/failed/{plugin_id}/reload", args, config); - }, - installPluginFromGithub(args: InstallPluginFromGithubArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/install/github", args, config); - }, - installPluginFromUpload(args: InstallPluginFromUploadArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/install/upload", args, config); - }, - installPluginFromUrl(args: InstallPluginFromUrlArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/install/url", args, config); - }, - listPluginMarket(args: ListPluginMarketArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/market", args, config); - }, - listPluginMarketCategories(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/plugins/market/categories", args, config); - }, - getPluginPageById(args: GetPluginPageByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/page", args, config); - }, - getPluginPageBridgeSdk(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/plugins/page-bridge-sdk.js", args, config); - }, - getPluginPageAssetById(args: GetPluginPageAssetByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/page/assets", args, config); - }, - listPluginPagesById(args: ListPluginPagesByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/pages", args, config); - }, - getPluginReadmeById(args: GetPluginReadmeByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/readme", args, config); - }, - reloadPluginById(args: ReloadPluginByIdArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/reload", args, config); - }, - updatePlugins(args: UpdatePluginsArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/update", args, config); - }, - checkPluginVersionSupport(args: CheckPluginVersionSupportArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/version-support/check", args, config); - }, - getPlugin(args: GetPluginArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}", args, config); - }, - uninstallPlugin(args: UninstallPluginArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugins/{plugin_id}", args, config); - }, - getPluginChangelog(args: GetPluginChangelogArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/changelog", args, config); - }, - getPluginConfig(args: GetPluginConfigArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/config", args, config); - }, - updatePluginConfig(args: UpdatePluginConfigArgs, config?: AxiosRequestConfig) { - return request("PUT", "/plugins/{plugin_id}/config", args, config); - }, - deletePluginConfigFile(args: DeletePluginConfigFileArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/plugins/{plugin_id}/config-files", args, config); - }, - listPluginConfigFiles(args: ListPluginConfigFilesArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/config-files/{config_key}", args, config); - }, - uploadPluginConfigFiles(args: UploadPluginConfigFilesArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/{plugin_id}/config-files/{config_key}", args, config); - }, - getPluginConfigSchema(args: GetPluginConfigSchemaArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/config/schema", args, config); - }, - setPluginEnabled(args: SetPluginEnabledArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/plugins/{plugin_id}/enabled", args, config); - }, - listPluginPages(args: ListPluginPagesArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/pages", args, config); - }, - getPluginPage(args: GetPluginPageArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/pages/{page_name}", args, config); - }, - getPluginPageAsset(args: GetPluginPageAssetArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}", args, config); - }, - getPluginReadme(args: GetPluginReadmeArgs, config?: AxiosRequestConfig) { - return request("GET", "/plugins/{plugin_id}/readme", args, config); - }, - reloadPlugin(args: ReloadPluginArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/{plugin_id}/reload", args, config); - }, - updatePlugin(args: UpdatePluginArgs, config?: AxiosRequestConfig) { - return request("POST", "/plugins/{plugin_id}/update", args, config); - }, - listProviderSources(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources", args, config); - }, - createProviderSource(args: CreateProviderSourceArgs, config?: AxiosRequestConfig) { - return request("POST", "/provider-sources", args, config); - }, - getProviderSourceById(args: GetProviderSourceByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources/by-id", args, config); - }, - upsertProviderSourceById(args: UpsertProviderSourceByIdArgs, config?: AxiosRequestConfig) { - return request("PUT", "/provider-sources/by-id", args, config); - }, - deleteProviderSourceById(args: DeleteProviderSourceByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/provider-sources/by-id", args, config); - }, - listProviderSourceModelsById(args: ListProviderSourceModelsByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources/models", args, config); - }, - listProvidersBySourceId(args: ListProvidersBySourceIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources/providers", args, config); - }, - createProviderInSourceById(args: CreateProviderInSourceByIdArgs, config?: AxiosRequestConfig) { - return request("POST", "/provider-sources/providers", args, config); - }, - getProviderSource(args: GetProviderSourceArgs, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources/{source_id}", args, config); - }, - upsertProviderSource(args: UpsertProviderSourceArgs, config?: AxiosRequestConfig) { - return request("PUT", "/provider-sources/{source_id}", args, config); - }, - deleteProviderSource(args: DeleteProviderSourceArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/provider-sources/{source_id}", args, config); - }, - listProviderSourceModels(args: ListProviderSourceModelsArgs, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources/{source_id}/models", args, config); - }, - listProvidersBySource(args: ListProvidersBySourceArgs, config?: AxiosRequestConfig) { - return request("GET", "/provider-sources/{source_id}/providers", args, config); - }, - createProviderInSource(args: CreateProviderInSourceArgs, config?: AxiosRequestConfig) { - return request("POST", "/provider-sources/{source_id}/providers", args, config); - }, - listProviders(args: ListProvidersArgs, config?: AxiosRequestConfig) { - return request("GET", "/providers", args, config); - }, - createProvider(args: CreateProviderArgs, config?: AxiosRequestConfig) { - return request("POST", "/providers", args, config); - }, - getProviderById(args: GetProviderByIdArgs, config?: AxiosRequestConfig) { - return request("GET", "/providers/by-id", args, config); - }, - updateProviderById(args: UpdateProviderByIdArgs, config?: AxiosRequestConfig) { - return request("PUT", "/providers/by-id", args, config); - }, - deleteProviderById(args: DeleteProviderByIdArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/providers/by-id", args, config); - }, - getProviderEmbeddingDimensionById(args: GetProviderEmbeddingDimensionByIdArgs, config?: AxiosRequestConfig) { - return request("POST", "/providers/embedding-dimension", args, config); - }, - setProviderEnabledById(args: SetProviderEnabledByIdArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/providers/enabled", args, config); - }, - getProviderSchema(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/providers/schema", args, config); - }, - testProviderById(args: TestProviderByIdArgs, config?: AxiosRequestConfig) { - return request("POST", "/providers/test", args, config); - }, - getProvider(args: GetProviderArgs, config?: AxiosRequestConfig) { - return request("GET", "/providers/{provider_id}", args, config); - }, - updateProvider(args: UpdateProviderArgs, config?: AxiosRequestConfig) { - return request("PUT", "/providers/{provider_id}", args, config); - }, - deleteProvider(args: DeleteProviderArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/providers/{provider_id}", args, config); - }, - getProviderEmbeddingDimension(args: GetProviderEmbeddingDimensionArgs, config?: AxiosRequestConfig) { - return request("POST", "/providers/{provider_id}/embedding-dimension", args, config); - }, - setProviderEnabled(args: SetProviderEnabledArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/providers/{provider_id}/enabled", args, config); - }, - testProvider(args: TestProviderArgs, config?: AxiosRequestConfig) { - return request("POST", "/providers/{provider_id}/test", args, config); - }, - listSessionGroups(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/session-groups", args, config); - }, - createSessionGroup(args: CreateSessionGroupArgs, config?: AxiosRequestConfig) { - return request("POST", "/session-groups", args, config); - }, - updateSessionGroup(args: UpdateSessionGroupArgs, config?: AxiosRequestConfig) { - return request("PUT", "/session-groups/{group_id}", args, config); - }, - deleteSessionGroup(args: DeleteSessionGroupArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/session-groups/{group_id}", args, config); - }, - listSessions(args: ListSessionsArgs, config?: AxiosRequestConfig) { - return request("GET", "/sessions", args, config); - }, - listActiveUmos(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/sessions/active-umos", args, config); - }, - batchUpdateSessionProvider(args: BatchUpdateSessionProviderArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/sessions/provider", args, config); - }, - listSessionRules(args: ListSessionRulesArgs, config?: AxiosRequestConfig) { - return request("GET", "/sessions/rules", args, config); - }, - upsertSessionRule(args: UpsertSessionRuleArgs, config?: AxiosRequestConfig) { - return request("POST", "/sessions/rules", args, config); - }, - deleteSessionRules(args: DeleteSessionRulesArgs, config?: AxiosRequestConfig) { - return request("POST", "/sessions/rules/delete", args, config); - }, - batchUpdateSessionService(args: BatchUpdateSessionServiceArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/sessions/service", args, config); - }, - listSkills(args: ListSkillsArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills", args, config); - }, - uploadSkill(args: UploadSkillArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills", args, config); - }, - downloadSkillByName(args: DownloadSkillByNameArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/archive", args, config); - }, - uploadSkillsBatch(args: UploadSkillsBatchArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/batch", args, config); - }, - updateSkillByName(args: UpdateSkillByNameArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/skills/by-name", args, config); - }, - deleteSkillByName(args: DeleteSkillByNameArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/skills/by-name", args, config); - }, - getSkillFileByName(args: GetSkillFileByNameArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/file", args, config); - }, - updateSkillFileByName(args: UpdateSkillFileByNameArgs, config?: AxiosRequestConfig) { - return request("PUT", "/skills/file", args, config); - }, - listSkillFilesByName(args: ListSkillFilesByNameArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/files", args, config); - }, - listNeoSkillCandidates(args: ListNeoSkillCandidatesArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/neo/candidates", args, config); - }, - deleteNeoSkillCandidate(args: DeleteNeoSkillCandidateArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/neo/candidates/delete", args, config); - }, - evaluateNeoSkillCandidate(args: EvaluateNeoSkillCandidateArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/neo/evaluate", args, config); - }, - getNeoSkillPayload(args: GetNeoSkillPayloadArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/neo/payload", args, config); - }, - promoteNeoSkillCandidate(args: PromoteNeoSkillCandidateArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/neo/promote", args, config); - }, - listNeoSkillReleases(args: ListNeoSkillReleasesArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/neo/releases", args, config); - }, - deleteNeoSkillRelease(args: DeleteNeoSkillReleaseArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/neo/releases/delete", args, config); - }, - rollbackNeoSkillRelease(args: RollbackNeoSkillReleaseArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/neo/rollback", args, config); - }, - syncNeoSkillRelease(args: SyncNeoSkillReleaseArgs, config?: AxiosRequestConfig) { - return request("POST", "/skills/neo/sync", args, config); - }, - updateSkill(args: UpdateSkillArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/skills/{skill_name}", args, config); - }, - deleteSkill(args: DeleteSkillArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/skills/{skill_name}", args, config); - }, - downloadSkill(args: DownloadSkillArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/{skill_name}/archive", args, config); - }, - listSkillFiles(args: ListSkillFilesArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/{skill_name}/files", args, config); - }, - getSkillFile(args: GetSkillFileArgs, config?: AxiosRequestConfig) { - return request("GET", "/skills/{skill_name}/files/{file_path}", args, config); - }, - updateSkillFile(args: UpdateSkillFileArgs, config?: AxiosRequestConfig) { - return request("PUT", "/skills/{skill_name}/files/{file_path}", args, config); - }, - getStats(args: GetStatsArgs, config?: AxiosRequestConfig) { - return request("GET", "/stats", args, config); - }, - getFirstNotice(args: GetFirstNoticeArgs, config?: AxiosRequestConfig) { - return request("GET", "/stats/first-notice", args, config); - }, - testGhproxyConnection(args: TestGhproxyConnectionArgs, config?: AxiosRequestConfig) { - return request("POST", "/stats/ghproxy/test", args, config); - }, - getProviderTokenStats(args: GetProviderTokenStatsArgs, config?: AxiosRequestConfig) { - return request("GET", "/stats/provider-tokens", args, config); - }, - getStartTime(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/stats/start-time", args, config); - }, - getStorageStatus(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/stats/storage", args, config); - }, - cleanupStorage(args: CleanupStorageArgs, config?: AxiosRequestConfig) { - return request("POST", "/stats/storage/cleanup", args, config); - }, - getVersion(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/stats/version", args, config); - }, - listSubagentAvailableTools(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/subagents/available-tools", args, config); - }, - getSubagentConfig(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/subagents/config", args, config); - }, - updateSubagentConfig(args: UpdateSubagentConfigArgs, config?: AxiosRequestConfig) { - return request("PUT", "/subagents/config", args, config); - }, - getSystemConfig(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/system-config", args, config); - }, - updateSystemConfig(args: UpdateSystemConfigArgs, config?: AxiosRequestConfig) { - return request("PUT", "/system-config", args, config); - }, - getSystemConfigRuntime(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/system-config/runtime", args, config); - }, - getSystemConfigSchema(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/system-config/schema", args, config); - }, - restartCore(args?: undefined, config?: AxiosRequestConfig) { - return request("POST", "/system/restart", args, config); - }, - listT2iTemplates(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/t2i/templates", args, config); - }, - createT2iTemplate(args: CreateT2iTemplateArgs, config?: AxiosRequestConfig) { - return request("POST", "/t2i/templates", args, config); - }, - getActiveT2iTemplate(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/t2i/templates/active", args, config); - }, - setActiveT2iTemplate(args: SetActiveT2iTemplateArgs, config?: AxiosRequestConfig) { - return request("PUT", "/t2i/templates/active", args, config); - }, - resetDefaultT2iTemplate(args?: undefined, config?: AxiosRequestConfig) { - return request("POST", "/t2i/templates/default/reset", args, config); - }, - getT2iTemplate(args: GetT2iTemplateArgs, config?: AxiosRequestConfig) { - return request("GET", "/t2i/templates/{name}", args, config); - }, - updateT2iTemplate(args: UpdateT2iTemplateArgs, config?: AxiosRequestConfig) { - return request("PUT", "/t2i/templates/{name}", args, config); - }, - deleteT2iTemplate(args: DeleteT2iTemplateArgs, config?: AxiosRequestConfig) { - return request("DELETE", "/t2i/templates/{name}", args, config); - }, - listTools(args: ListToolsArgs, config?: AxiosRequestConfig) { - return request("GET", "/tools", args, config); - }, - setToolEnabled(args: SetToolEnabledArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/tools/{tool_id}/enabled", args, config); - }, - getTraceSettings(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/trace/settings", args, config); - }, - updateTraceSettings(args: UpdateTraceSettingsArgs, config?: AxiosRequestConfig) { - return request("PUT", "/trace/settings", args, config); - }, - openUnifiedChatWebSocket(args: OpenUnifiedChatWebSocketArgs, config?: AxiosRequestConfig) { - return request("GET", "/unified-chat/ws", args, config); - }, - checkUpdate(args?: undefined, config?: AxiosRequestConfig) { - return request("GET", "/updates/check", args, config); - }, - updateCore(args: UpdateCoreArgs, config?: AxiosRequestConfig) { - return request("POST", "/updates/core", args, config); - }, - updateDashboard(args: UpdateDashboardArgs, config?: AxiosRequestConfig) { - return request("POST", "/updates/dashboard", args, config); - }, - getUpdateProgress(args: GetUpdateProgressArgs, config?: AxiosRequestConfig) { - return request("GET", "/updates/progress/{task_id}", args, config); - }, - listReleases(args: ListReleasesArgs, config?: AxiosRequestConfig) { - return request("GET", "/updates/releases", args, config); - }, - verifyPlatformWebhook(args: VerifyPlatformWebhookArgs, config?: AxiosRequestConfig) { - return request("GET", "/webhooks/platforms/{webhook_uuid}", args, config); - }, - receivePlatformWebhook(args: ReceivePlatformWebhookArgs, config?: AxiosRequestConfig) { - return request("POST", "/webhooks/platforms/{webhook_uuid}", args, config); - } -}; diff --git a/dashboard/src/api/generated/openapi-v1/index.ts b/dashboard/src/api/generated/openapi-v1/index.ts new file mode 100644 index 0000000000..81abc8221c --- /dev/null +++ b/dashboard/src/api/generated/openapi-v1/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './sdk.gen'; +export * from './types.gen'; \ No newline at end of file diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts new file mode 100644 index 0000000000..736f7f8e51 --- /dev/null +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -0,0 +1,3051 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios'; +import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, RunMigrationsData, RunMigrationsError, RunMigrationsResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; + +export const client = createClient(createConfig()); + +/** + * Login to the dashboard API + */ +export const login = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/auth/login' + }); +}; + +/** + * Logout from the dashboard API + */ +export const logout = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/auth/logout' + }); +}; + +/** + * Get first-run setup status + */ +export const getAuthSetupStatus = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/auth/setup-status' + }); +}; + +/** + * Complete first-run dashboard account setup + */ +export const setupAuth = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/auth/setup' + }); +}; + +/** + * Start or refresh TOTP setup + */ +export const setupTotp = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/auth/totp/setup' + }); +}; + +/** + * Generate or rotate TOTP recovery codes + */ +export const recoverTotp = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/auth/totp/recovery' + }); +}; + +/** + * Update dashboard account credentials + */ +export const updateAuthAccount = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/auth/account' + }); +}; + +/** + * List OpenAPI keys + */ +export const listApiKeys = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/api-keys' + }); +}; + +/** + * Create an OpenAPI key + */ +export const createApiKey = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/api-keys' + }); +}; + +/** + * Revoke an OpenAPI key + */ +export const revokeApiKey = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/api-keys/{key_id}/revoke' + }); +}; + +/** + * Delete an OpenAPI key + */ +export const deleteApiKey = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/api-keys/{key_id}' + }); +}; + +/** + * Get the system configuration schema + */ +export const getSystemConfigSchema = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/system-config/schema' + }); +}; + +/** + * Get the system configuration + */ +export const getSystemConfig = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/system-config' + }); +}; + +/** + * Replace the system configuration + */ +export const updateSystemConfig = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/system-config' + }); +}; + +/** + * Get runtime-enriched system configuration metadata + */ +export const getSystemConfigRuntime = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/system-config/runtime' + }); +}; + +/** + * Get the configuration profile schema + */ +export const getConfigProfileSchema = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/config-profiles/schema' + }); +}; + +/** + * List configuration profiles + */ +export const listConfigProfiles = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/config-profiles' + }); +}; + +/** + * Create a configuration profile + */ +export const createConfigProfile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/config-profiles' + }); +}; + +/** + * Get a configuration profile + */ +export const getConfigProfile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/config-profiles/{config_id}' + }); +}; + +/** + * Replace a configuration profile + */ +export const updateConfigProfileContent = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/config-profiles/{config_id}' + }); +}; + +/** + * Rename a configuration profile + */ +export const renameConfigProfile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/config-profiles/{config_id}' + }); +}; + +/** + * Delete a configuration profile + */ +export const deleteConfigProfile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/config-profiles/{config_id}' + }); +}; + +/** + * List UMO to configuration profile bindings + */ +export const listConfigRoutes = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/config-routes' + }); +}; + +/** + * Replace all UMO to configuration profile bindings + */ +export const replaceConfigRoutes = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/config-routes' + }); +}; + +/** + * Bind a UMO to a configuration profile + */ +export const upsertConfigRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/config-routes/{umo}' + }); +}; + +/** + * Remove a UMO configuration profile binding + */ +export const deleteConfigRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/config-routes/{umo}' + }); +}; + +/** + * List configurable bot types and their schemas + */ +export const listBotTypes = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/bot-types' + }); +}; + +/** + * Run a bot type registration flow action + */ +export const registerBotType = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/bot-types/{bot_type}/registration' + }); +}; + +/** + * List created bots and their configurations + */ +export const listBots = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/bots' + }); +}; + +/** + * Create a bot from configuration + */ +export const createBot = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/bots' + }); +}; + +/** + * List runtime status for all bots + */ +export const listBotStats = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/bots/stats' + }); +}; + +/** + * Get a bot configuration by query ID + */ +export const getBotById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/bots/by-id' + }); +}; + +/** + * Replace a bot configuration by body ID + */ +export const updateBotById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/bots/by-id' + }); +}; + +/** + * Delete a bot by query ID + */ +export const deleteBotById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/bots/by-id' + }); +}; + +/** + * Enable or disable a bot by body ID + */ +export const setBotEnabledById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/bots/enabled' + }); +}; + +/** + * Test a bot registration or connection by body ID + */ +export const testBotById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/bots/test' + }); +}; + +/** + * Get a bot configuration + */ +export const getBot = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/bots/{bot_id}' + }); +}; + +/** + * Replace a bot configuration + */ +export const updateBot = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/bots/{bot_id}' + }); +}; + +/** + * Delete a bot + */ +export const deleteBot = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/bots/{bot_id}' + }); +}; + +/** + * Enable or disable a bot + */ +export const setBotEnabled = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/bots/{bot_id}/enabled' + }); +}; + +/** + * Test a bot registration or connection + */ +export const testBot = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/bots/{bot_id}/test' + }); +}; + +/** + * Get the overall provider configuration schema + */ +export const getProviderSchema = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/providers/schema' + }); +}; + +/** + * List provider sources + */ +export const listProviderSources = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources' + }); +}; + +/** + * Create a provider source + */ +export const createProviderSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/provider-sources' + }); +}; + +/** + * Get a provider source by query ID + */ +export const getProviderSourceById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources/by-id' + }); +}; + +/** + * Update a provider source by body ID + */ +export const upsertProviderSourceById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/provider-sources/by-id' + }); +}; + +/** + * Delete a provider source by query ID + */ +export const deleteProviderSourceById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/provider-sources/by-id' + }); +}; + +/** + * List models available from a provider source by query ID + */ +export const listProviderSourceModelsById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources/models' + }); +}; + +/** + * List providers under a provider source by query ID + */ +export const listProvidersBySourceId = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources/providers' + }); +}; + +/** + * Create a provider under a provider source by body ID + */ +export const createProviderInSourceById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/provider-sources/providers' + }); +}; + +/** + * Get a provider source + */ +export const getProviderSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources/{source_id}' + }); +}; + +/** + * Update a provider source or create it if missing + */ +export const upsertProviderSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/provider-sources/{source_id}' + }); +}; + +/** + * Delete a provider source and providers that reference it + */ +export const deleteProviderSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/provider-sources/{source_id}' + }); +}; + +/** + * List models available from a provider source + */ +export const listProviderSourceModels = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources/{source_id}/models' + }); +}; + +/** + * List providers under a provider source + */ +export const listProvidersBySource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/provider-sources/{source_id}/providers' + }); +}; + +/** + * Create a provider under a provider source + */ +export const createProviderInSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/provider-sources/{source_id}/providers' + }); +}; + +/** + * List providers by capability or source + */ +export const listProviders = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/providers' + }); +}; + +/** + * Create a standalone provider + */ +export const createProvider = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/providers' + }); +}; + +/** + * Get a provider configuration by query ID + */ +export const getProviderById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/providers/by-id' + }); +}; + +/** + * Replace a provider configuration by body ID + */ +export const updateProviderById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/providers/by-id' + }); +}; + +/** + * Delete a provider by query ID + */ +export const deleteProviderById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/providers/by-id' + }); +}; + +/** + * Enable or disable a provider by body ID + */ +export const setProviderEnabledById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/providers/enabled' + }); +}; + +/** + * Test a provider by body ID + */ +export const testProviderById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/providers/test' + }); +}; + +/** + * Probe embedding dimension for a provider by body ID + */ +export const getProviderEmbeddingDimensionById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/providers/embedding-dimension' + }); +}; + +/** + * Get a provider configuration + */ +export const getProvider = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/providers/{provider_id}' + }); +}; + +/** + * Replace a provider configuration + */ +export const updateProvider = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/providers/{provider_id}' + }); +}; + +/** + * Delete a provider + */ +export const deleteProvider = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/providers/{provider_id}' + }); +}; + +/** + * Enable or disable a provider + */ +export const setProviderEnabled = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/providers/{provider_id}/enabled' + }); +}; + +/** + * Test a provider + */ +export const testProvider = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/providers/{provider_id}/test' + }); +}; + +/** + * Probe embedding dimension for a provider + */ +export const getProviderEmbeddingDimension = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/providers/{provider_id}/embedding-dimension' + }); +}; + +/** + * Send a webchat message + */ +export const sendChatMessage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat' + }); +}; + +/** + * Open a streaming chat WebSocket + */ +export const openChatWebSocket = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/ws' + }); +}; + +/** + * Open the live voice chat WebSocket + */ +export const openLiveChatWebSocket = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/live-chat/ws' + }); +}; + +/** + * Open the unified live/chat WebSocket + */ +export const openUnifiedChatWebSocket = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/unified-chat/ws' + }); +}; + +/** + * List webchat sessions + */ +export const listChatSessions = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/sessions' + }); +}; + +/** + * Create a webchat session + */ +export const createChatSession = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/sessions/new' + }); +}; + +/** + * Delete multiple webchat sessions + */ +export const batchDeleteChatSessions = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/sessions/batch-delete' + }); +}; + +/** + * Get a webchat session and its messages + */ +export const getChatSession = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/sessions/{session_id}' + }); +}; + +/** + * Update a webchat session + */ +export const updateChatSession = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/chat/sessions/{session_id}' + }); +}; + +/** + * Delete a webchat session + */ +export const deleteChatSession = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/chat/sessions/{session_id}' + }); +}; + +/** + * Stop active webchat work for a session + */ +export const stopChatSession = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/sessions/{session_id}/stop' + }); +}; + +/** + * Update the latest user message in a webchat session + */ +export const updateChatMessage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/chat/sessions/{session_id}/messages/{message_id}' + }); +}; + +/** + * Regenerate a bot message in a webchat session + */ +export const regenerateChatMessage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/sessions/{session_id}/messages/{message_id}/regenerate' + }); +}; + +/** + * List chat-selectable configuration profiles + */ +export const listChatConfigs = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/configs' + }); +}; + +/** + * Create or reuse a side thread from a webchat message + */ +export const createChatThread = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/threads' + }); +}; + +/** + * Get a webchat side thread + */ +export const getChatThread = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/threads/{thread_id}' + }); +}; + +/** + * Delete a webchat side thread + */ +export const deleteChatThread = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/chat/threads/{thread_id}' + }); +}; + +/** + * Send a message inside a webchat side thread + */ +export const sendChatThreadMessage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/threads/{thread_id}/messages' + }); +}; + +/** + * List ChatUI projects + */ +export const listChatProjects = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/projects' + }); +}; + +/** + * Create a ChatUI project + */ +export const createChatProject = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/projects' + }); +}; + +/** + * Get a ChatUI project + */ +export const getChatProject = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/projects/{project_id}' + }); +}; + +/** + * Update a ChatUI project + */ +export const updateChatProject = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/chat/projects/{project_id}' + }); +}; + +/** + * Delete a ChatUI project + */ +export const deleteChatProject = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/chat/projects/{project_id}' + }); +}; + +/** + * List sessions in a ChatUI project + */ +export const listChatProjectSessions = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/projects/{project_id}/sessions' + }); +}; + +/** + * Add a session to a ChatUI project + */ +export const addChatProjectSession = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/chat/projects/{project_id}/sessions/{session_id}' + }); +}; + +/** + * Remove a session from its ChatUI project + */ +export const removeChatProjectSession = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/chat/projects/sessions/{session_id}' + }); +}; + +/** + * Send a message to a UMO + */ +export const sendImMessage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/im/messages' + }); +}; + +/** + * List active IM bot IDs + */ +export const listImBots = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/im/bots' + }); +}; + +/** + * Upload a file + */ +export const uploadFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/files' + }); +}; + +/** + * Get an uploaded file by stored filename + */ +export const getFileByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/files/content' + }); +}; + +/** + * Get a tokenized public file + */ +export const getTokenFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/files/tokens/{file_token}' + }); +}; + +/** + * Get attachment metadata + */ +export const getAttachment = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/files/{attachment_id}' + }); +}; + +/** + * Delete an attachment + */ +export const deleteAttachment = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/files/{attachment_id}' + }); +}; + +/** + * Download attachment content + */ +export const downloadAttachment = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/files/{attachment_id}/content' + }); +}; + +/** + * List installed plugins + */ +export const listPlugins = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins' + }); +}; + +/** + * Get plugin details by query ID + */ +export const getPluginById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/by-id' + }); +}; + +/** + * Uninstall a plugin by query ID + */ +export const uninstallPluginById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugins/by-id' + }); +}; + +/** + * Get plugin configuration by query ID + */ +export const getPluginConfigById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/config' + }); +}; + +/** + * Save plugin configuration by body ID + */ +export const updatePluginConfigById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/plugins/config' + }); +}; + +/** + * Get plugin configuration schema by query ID + */ +export const getPluginConfigSchemaById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/config/schema' + }); +}; + +/** + * List uploaded files for a plugin file configuration item by query ID + */ +export const listPluginConfigFilesById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/config-files' + }); +}; + +/** + * Upload files for a plugin file configuration item by query ID + */ +export const uploadPluginConfigFilesById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/plugins/config-files' + }); +}; + +/** + * Delete a plugin file configuration upload by query ID + */ +export const deletePluginConfigFileById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugins/config-files' + }); +}; + +/** + * Get plugin README content by query ID + */ +export const getPluginReadmeById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/readme' + }); +}; + +/** + * Get plugin CHANGELOG content by query ID + */ +export const getPluginChangelogById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/changelog' + }); +}; + +/** + * Reload a plugin by body ID + */ +export const reloadPluginById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/reload' + }); +}; + +/** + * Enable or disable a plugin by body ID + */ +export const setPluginEnabledById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/plugins/enabled' + }); +}; + +/** + * List plugin pages by query ID + */ +export const listPluginPagesById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/pages' + }); +}; + +/** + * Get plugin page entry HTML by query ID + */ +export const getPluginPageById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/page' + }); +}; + +/** + * Get a plugin page asset by query ID + */ +export const getPluginPageAssetById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/page/assets' + }); +}; + +/** + * Get plugin details + */ +export const getPlugin = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}' + }); +}; + +/** + * Uninstall a plugin + */ +export const uninstallPlugin = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugins/{plugin_id}' + }); +}; + +/** + * Get plugin configuration + */ +export const getPluginConfig = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/config' + }); +}; + +/** + * Save plugin configuration + */ +export const updatePluginConfig = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/plugins/{plugin_id}/config' + }); +}; + +/** + * Get plugin configuration schema + */ +export const getPluginConfigSchema = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/config/schema' + }); +}; + +/** + * List uploaded files for a plugin file configuration item + */ +export const listPluginConfigFiles = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/config-files/{config_key}' + }); +}; + +/** + * Upload files for a plugin file configuration item + */ +export const uploadPluginConfigFiles = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/plugins/{plugin_id}/config-files/{config_key}' + }); +}; + +/** + * Delete a plugin file configuration upload + */ +export const deletePluginConfigFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugins/{plugin_id}/config-files' + }); +}; + +/** + * Get plugin README content + */ +export const getPluginReadme = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/readme' + }); +}; + +/** + * Get plugin CHANGELOG content + */ +export const getPluginChangelog = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/changelog' + }); +}; + +/** + * Reload a plugin + */ +export const reloadPlugin = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/{plugin_id}/reload' + }); +}; + +/** + * Enable or disable a plugin + */ +export const setPluginEnabled = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/plugins/{plugin_id}/enabled' + }); +}; + +/** + * Update or reinstall a plugin + */ +export const updatePlugin = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/{plugin_id}/update' + }); +}; + +/** + * Update multiple plugins + */ +export const updatePlugins = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/update' + }); +}; + +/** + * Check whether a plugin version constraint is supported + */ +export const checkPluginVersionSupport = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/version-support/check' + }); +}; + +/** + * List failed plugins and errors + */ +export const listFailedPlugins = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/failed' + }); +}; + +/** + * Uninstall a failed plugin + */ +export const uninstallFailedPlugin = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugins/failed/{plugin_id}' + }); +}; + +/** + * Reload a failed plugin + */ +export const reloadFailedPlugin = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/failed/{plugin_id}/reload' + }); +}; + +/** + * Install a plugin from GitHub + */ +export const installPluginFromGithub = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/install/github' + }); +}; + +/** + * Install a plugin from a downloadable ZIP URL + */ +export const installPluginFromUrl = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/install/url' + }); +}; + +/** + * Install a plugin from an uploaded ZIP file + */ +export const installPluginFromUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/plugins/install/upload' + }); +}; + +/** + * List plugin marketplace entries + */ +export const listPluginMarket = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/market' + }); +}; + +/** + * List plugin marketplace categories + */ +export const listPluginMarketCategories = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/market/categories' + }); +}; + +/** + * List plugin sources + */ +export const listPluginSources = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugin-sources' + }); +}; + +/** + * Add a plugin source + */ +export const createPluginSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugin-sources' + }); +}; + +/** + * Replace plugin sources + */ +export const replacePluginSources = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/plugin-sources' + }); +}; + +/** + * Delete a plugin source + */ +export const deletePluginSource = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugin-sources/{source_id}' + }); +}; + +/** + * Delete a plugin source by query ID + */ +export const deletePluginSourceById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugin-sources/by-id' + }); +}; + +/** + * List plugin pages + */ +export const listPluginPages = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/pages' + }); +}; + +/** + * Get plugin page entry HTML + */ +export const getPluginPage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/pages/{page_name}' + }); +}; + +/** + * Get a plugin page asset + */ +export const getPluginPageAsset = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path}' + }); +}; + +/** + * Get the plugin page bridge SDK + */ +export const getPluginPageBridgeSdk = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/page-bridge-sdk.js' + }); +}; + +/** + * Proxy a plugin extension GET route + */ +export const getPluginExtensionRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/plugins/extensions/{plugin_path}' + }); +}; + +/** + * Proxy a plugin extension POST route + */ +export const postPluginExtensionRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/plugins/extensions/{plugin_path}' + }); +}; + +/** + * Proxy a plugin extension PUT route + */ +export const putPluginExtensionRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/plugins/extensions/{plugin_path}' + }); +}; + +/** + * Proxy a plugin extension PATCH route + */ +export const patchPluginExtensionRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/plugins/extensions/{plugin_path}' + }); +}; + +/** + * Proxy a plugin extension DELETE route + */ +export const deletePluginExtensionRoute = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/plugins/extensions/{plugin_path}' + }); +}; + +/** + * List plugin commands + */ +export const listCommands = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/commands' + }); +}; + +/** + * Update command enabled state, alias, or permission group + */ +export const updateCommand = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/commands/{command_id}' + }); +}; + +/** + * List command conflicts + */ +export const listCommandConflicts = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/commands/conflicts' + }); +}; + +/** + * List LLM tools + */ +export const listTools = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/tools' + }); +}; + +/** + * Enable or disable an LLM tool + */ +export const setToolEnabled = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/tools/{tool_id}/enabled' + }); +}; + +/** + * Update an LLM tool permission + */ +export const setToolPermission = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/tools/{tool_id}/permission' + }); +}; + +/** + * List MCP servers + */ +export const listMcpServers = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/mcp/servers' + }); +}; + +/** + * Add an MCP server + */ +export const createMcpServer = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/mcp/servers' + }); +}; + +/** + * Update an MCP server by body name + */ +export const updateMcpServerByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/mcp/servers/by-name' + }); +}; + +/** + * Delete an MCP server by query name + */ +export const deleteMcpServerByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/mcp/servers/by-name' + }); +}; + +/** + * Enable or disable an MCP server by body name + */ +export const setMcpServerEnabledByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/mcp/servers/enabled' + }); +}; + +/** + * Test an MCP server connection by body name + */ +export const testMcpServerByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/mcp/servers/test' + }); +}; + +/** + * Update an MCP server + */ +export const updateMcpServer = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/mcp/servers/{server_name}' + }); +}; + +/** + * Delete an MCP server + */ +export const deleteMcpServer = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/mcp/servers/{server_name}' + }); +}; + +/** + * Enable or disable an MCP server + */ +export const setMcpServerEnabled = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/mcp/servers/{server_name}/enabled' + }); +}; + +/** + * Test an MCP server connection + */ +export const testMcpServer = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/mcp/servers/{server_name}/test' + }); +}; + +/** + * Sync MCP servers from ModelScope + */ +export const syncModelScopeMcpServers = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/mcp/providers/modelscope/sync' + }); +}; + +/** + * List skills + */ +export const listSkills = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills' + }); +}; + +/** + * Upload or import a skill archive + */ +export const uploadSkill = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/skills' + }); +}; + +/** + * Upload multiple skill archives + */ +export const uploadSkillsBatch = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/skills/batch' + }); +}; + +/** + * Update skill metadata or enabled state by body name + */ +export const updateSkillByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/skills/by-name' + }); +}; + +/** + * Delete a skill by query name + */ +export const deleteSkillByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/skills/by-name' + }); +}; + +/** + * Download a skill archive by query name + */ +export const downloadSkillByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/archive' + }); +}; + +/** + * List files in a skill by query name + */ +export const listSkillFilesByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/files' + }); +}; + +/** + * Get skill file content by query name and path + */ +export const getSkillFileByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/file' + }); +}; + +/** + * Update skill file content by body name and path + */ +export const updateSkillFileByName = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/skills/file' + }); +}; + +/** + * Update skill metadata or enabled state + */ +export const updateSkill = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/skills/{skill_name}' + }); +}; + +/** + * Delete a skill + */ +export const deleteSkill = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/skills/{skill_name}' + }); +}; + +/** + * Download a skill archive + */ +export const downloadSkill = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/{skill_name}/archive' + }); +}; + +/** + * List files in a skill + */ +export const listSkillFiles = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/{skill_name}/files' + }); +}; + +/** + * Get skill file content + */ +export const getSkillFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/{skill_name}/files/{file_path}' + }); +}; + +/** + * Update skill file content + */ +export const updateSkillFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/skills/{skill_name}/files/{file_path}' + }); +}; + +/** + * List Shipyard Neo skill candidates + */ +export const listNeoSkillCandidates = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/neo/candidates' + }); +}; + +/** + * List Shipyard Neo skill releases + */ +export const listNeoSkillReleases = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/neo/releases' + }); +}; + +/** + * Get a Shipyard Neo skill payload + */ +export const getNeoSkillPayload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/skills/neo/payload' + }); +}; + +/** + * Evaluate a Shipyard Neo skill candidate + */ +export const evaluateNeoSkillCandidate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/skills/neo/evaluate' + }); +}; + +/** + * Promote a Shipyard Neo skill candidate + */ +export const promoteNeoSkillCandidate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/skills/neo/promote' + }); +}; + +/** + * Roll back a Shipyard Neo skill release + */ +export const rollbackNeoSkillRelease = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/skills/neo/rollback' + }); +}; + +/** + * Sync a Shipyard Neo skill release + */ +export const syncNeoSkillRelease = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/skills/neo/sync' + }); +}; + +/** + * Delete a Shipyard Neo skill candidate + */ +export const deleteNeoSkillCandidate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/skills/neo/candidates/delete' + }); +}; + +/** + * Delete a Shipyard Neo skill release + */ +export const deleteNeoSkillRelease = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/skills/neo/releases/delete' + }); +}; + +/** + * List knowledge bases + */ +export const listKnowledgeBases = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases' + }); +}; + +/** + * Create a knowledge base + */ +export const createKnowledgeBase = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/knowledge-bases' + }); +}; + +/** + * Get a knowledge base + */ +export const getKnowledgeBase = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}' + }); +}; + +/** + * Update a knowledge base + */ +export const updateKnowledgeBase = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}' + }); +}; + +/** + * Delete a knowledge base + */ +export const deleteKnowledgeBase = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}' + }); +}; + +/** + * Get knowledge base stats + */ +export const getKnowledgeBaseStats = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/stats' + }); +}; + +/** + * List knowledge base documents + */ +export const listKnowledgeDocuments = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/documents' + }); +}; + +/** + * Upload a document to a knowledge base + */ +export const uploadKnowledgeDocument = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/knowledge-bases/{kb_id}/documents' + }); +}; + +/** + * Import documents already available on the server + */ +export const importKnowledgeDocuments = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/documents/import' + }); +}; + +/** + * Import a document from URL + */ +export const importKnowledgeDocumentFromUrl = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/documents/import-url' + }); +}; + +/** + * Get a knowledge base document + */ +export const getKnowledgeDocument = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/documents/{document_id}' + }); +}; + +/** + * Delete a knowledge base document + */ +export const deleteKnowledgeDocument = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/documents/{document_id}' + }); +}; + +/** + * List document chunks + */ +export const listKnowledgeChunks = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/chunks' + }); +}; + +/** + * Delete a document chunk + */ +export const deleteKnowledgeChunk = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/chunks/{chunk_id}' + }); +}; + +/** + * Retrieve knowledge base chunks + */ +export const retrieveKnowledgeBase = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/knowledge-bases/{kb_id}/retrieve' + }); +}; + +/** + * Get knowledge base import task progress + */ +export const getKnowledgeTask = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/knowledge-bases/tasks/{task_id}' + }); +}; + +/** + * Get persona folder tree + */ +export const getPersonaTree = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/personas/tree' + }); +}; + +/** + * List personas + */ +export const listPersonas = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/personas' + }); +}; + +/** + * Create a persona + */ +export const createPersona = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/personas' + }); +}; + +/** + * Get a persona by query ID + */ +export const getPersonaById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/personas/by-id' + }); +}; + +/** + * Update a persona by body ID + */ +export const updatePersonaById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/personas/by-id' + }); +}; + +/** + * Delete a persona by query ID + */ +export const deletePersonaById = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/personas/by-id' + }); +}; + +/** + * Get a persona + */ +export const getPersona = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/personas/{persona_id}' + }); +}; + +/** + * Update a persona + */ +export const updatePersona = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/personas/{persona_id}' + }); +}; + +/** + * Delete a persona + */ +export const deletePersona = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/personas/{persona_id}' + }); +}; + +/** + * List persona folders + */ +export const listPersonaFolders = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/persona-folders' + }); +}; + +/** + * Create a persona folder + */ +export const createPersonaFolder = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/persona-folders' + }); +}; + +/** + * Update a persona folder + */ +export const updatePersonaFolder = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/persona-folders/{folder_id}' + }); +}; + +/** + * Delete a persona folder + */ +export const deletePersonaFolder = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/persona-folders/{folder_id}' + }); +}; + +/** + * Move a persona or folder + */ +export const movePersonaItem = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/personas/move' + }); +}; + +/** + * Reorder personas or folders + */ +export const reorderPersonaItems = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/personas/reorder' + }); +}; + +/** + * List active UMOs and session status + */ +export const listSessions = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/sessions' + }); +}; + +/** + * List active and known UMOs + */ +export const listActiveUmos = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/sessions/active-umos' + }); +}; + +/** + * List session rules + */ +export const listSessionRules = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/sessions/rules' + }); +}; + +/** + * Update or create a session rule + */ +export const upsertSessionRule = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/sessions/rules' + }); +}; + +/** + * Delete one or more session rules + */ +export const deleteSessionRules = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/sessions/rules/delete' + }); +}; + +/** + * Batch update session provider selection + */ +export const batchUpdateSessionProvider = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/sessions/provider' + }); +}; + +/** + * Batch update session service settings + */ +export const batchUpdateSessionService = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/sessions/service' + }); +}; + +/** + * List session groups + */ +export const listSessionGroups = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/session-groups' + }); +}; + +/** + * Create a session group + */ +export const createSessionGroup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/session-groups' + }); +}; + +/** + * Update a session group + */ +export const updateSessionGroup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/session-groups/{group_id}' + }); +}; + +/** + * Delete a session group + */ +export const deleteSessionGroup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/session-groups/{group_id}' + }); +}; + +/** + * List conversation history data + */ +export const listConversations = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/conversations' + }); +}; + +/** + * Delete multiple conversations + */ +export const batchDeleteConversations = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/conversations/batch-delete' + }); +}; + +/** + * Get conversation details and messages + */ +export const getConversation = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/conversations/{conversation_id}' + }); +}; + +/** + * Update conversation metadata + */ +export const updateConversation = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/conversations/{conversation_id}' + }); +}; + +/** + * Delete a conversation + */ +export const deleteConversation = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/conversations/{conversation_id}' + }); +}; + +/** + * Replace conversation message history + */ +export const replaceConversationMessages = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/conversations/{conversation_id}/messages' + }); +}; + +/** + * Export conversations + */ +export const exportConversations = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/conversations/export' + }); +}; + +/** + * Get runtime statistics + */ +export const getStats = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/stats' + }); +}; + +/** + * Get provider token usage statistics + */ +export const getProviderTokenStats = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/stats/provider-tokens' + }); +}; + +/** + * Get AstrBot version + */ +export const getVersion = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/stats/version' + }); +}; + +/** + * Get first-run dashboard notice content + */ +export const getFirstNotice = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/stats/first-notice' + }); +}; + +/** + * Test a GitHub proxy endpoint + */ +export const testGhproxyConnection = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/stats/ghproxy/test' + }); +}; + +/** + * List available changelog versions + */ +export const listChangelogVersions = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/changelogs' + }); +}; + +/** + * Get changelog content for a version + */ +export const getChangelog = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/changelogs/{version}' + }); +}; + +/** + * Get runtime start time + */ +export const getStartTime = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/stats/start-time' + }); +}; + +/** + * Get storage status + */ +export const getStorageStatus = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/stats/storage' + }); +}; + +/** + * Clean storage + */ +export const cleanupStorage = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/stats/storage/cleanup' + }); +}; + +/** + * Restart AstrBot core + */ +export const restartCore = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/system/restart' + }); +}; + +/** + * List backups + */ +export const listBackups = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/backups' + }); +}; + +/** + * Export a backup + */ +export const createBackup = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups' + }); +}; + +/** + * Upload a backup file + */ +export const uploadBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/backups/upload' + }); +}; + +/** + * Initialize chunked backup upload + */ +export const initBackupUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups/upload/init' + }); +}; + +/** + * Upload a backup chunk + */ +export const uploadBackupChunk = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + ...formDataBodySerializer, + headers: { + 'Content-Type': null, + ...options?.headers + }, + url: '/api/v1/backups/upload/chunk' + }); +}; + +/** + * Complete chunked backup upload + */ +export const completeBackupUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups/upload/complete' + }); +}; + +/** + * Abort chunked backup upload + */ +export const abortBackupUpload = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups/upload/abort' + }); +}; + +/** + * Get backup task progress + */ +export const getBackupProgress = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/backups/tasks/{task_id}' + }); +}; + +/** + * Download a backup + */ +export const downloadBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/backups/{filename}' + }); +}; + +/** + * Rename a backup + */ +export const renameBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/backups/{filename}' + }); +}; + +/** + * Delete a backup + */ +export const deleteBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/backups/{filename}' + }); +}; + +/** + * Check a backup before import + */ +export const checkBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups/{filename}/check' + }); +}; + +/** + * Import a backup + */ +export const importBackup = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/backups/{filename}/import' + }); +}; + +/** + * Check for updates + */ +export const checkUpdate = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/updates/check' + }); +}; + +/** + * List releases + */ +export const listReleases = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/updates/releases' + }); +}; + +/** + * Update AstrBot core + */ +export const updateCore = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/updates/core' + }); +}; + +/** + * Update dashboard assets + */ +export const updateDashboard = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/updates/dashboard' + }); +}; + +/** + * Get update progress + */ +export const getUpdateProgress = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/updates/progress/{task_id}' + }); +}; + +/** + * Install a Python package + */ +export const installPipPackage = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/pip/install' + }); +}; + +/** + * Run pending migrations + */ +export const runMigrations = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/migrations' + }); +}; + +/** + * List cron jobs + */ +export const listCronJobs = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/cron/jobs' + }); +}; + +/** + * Create a cron job + */ +export const createCronJob = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/cron/jobs' + }); +}; + +/** + * Update a cron job + */ +export const updateCronJob = (options: OptionsLegacyParser) => { + return (options?.client ?? client).patch({ + ...options, + url: '/api/v1/cron/jobs/{job_id}' + }); +}; + +/** + * Delete a cron job + */ +export const deleteCronJob = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/cron/jobs/{job_id}' + }); +}; + +/** + * Run a cron job immediately + */ +export const runCronJob = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/cron/jobs/{job_id}/run' + }); +}; + +/** + * Stream live logs + */ +export const streamLiveLogs = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/logs/live' + }); +}; + +/** + * Get log history + */ +export const getLogHistory = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/logs/history' + }); +}; + +/** + * Get trace settings + */ +export const getTraceSettings = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/trace/settings' + }); +}; + +/** + * Update trace settings + */ +export const updateTraceSettings = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/trace/settings' + }); +}; + +/** + * List text-to-image templates + */ +export const listT2iTemplates = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/t2i/templates' + }); +}; + +/** + * Create a text-to-image template + */ +export const createT2iTemplate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/t2i/templates' + }); +}; + +/** + * Get active text-to-image template + */ +export const getActiveT2iTemplate = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/t2i/templates/active' + }); +}; + +/** + * Set active text-to-image template + */ +export const setActiveT2iTemplate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/t2i/templates/active' + }); +}; + +/** + * Reset the default text-to-image template + */ +export const resetDefaultT2iTemplate = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/t2i/templates/default/reset' + }); +}; + +/** + * Get a text-to-image template + */ +export const getT2iTemplate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/t2i/templates/{name}' + }); +}; + +/** + * Update a text-to-image template + */ +export const updateT2iTemplate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/t2i/templates/{name}' + }); +}; + +/** + * Delete a text-to-image template + */ +export const deleteT2iTemplate = (options: OptionsLegacyParser) => { + return (options?.client ?? client).delete({ + ...options, + url: '/api/v1/t2i/templates/{name}' + }); +}; + +/** + * Get subagent orchestrator configuration + */ +export const getSubagentConfig = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/subagents/config' + }); +}; + +/** + * Update subagent orchestrator configuration + */ +export const updateSubagentConfig = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/subagents/config' + }); +}; + +/** + * List tools available to subagents + */ +export const listSubagentAvailableTools = (options?: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/subagents/available-tools' + }); +}; + +/** + * Verify a platform webhook + */ +export const verifyPlatformWebhook = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/webhooks/platforms/{webhook_uuid}' + }); +}; + +/** + * Receive a platform webhook + */ +export const receivePlatformWebhook = (options: OptionsLegacyParser) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/v1/webhooks/platforms/{webhook_uuid}' + }); +}; \ No newline at end of file diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts new file mode 100644 index 0000000000..d0808145a7 --- /dev/null +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -0,0 +1,3473 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type BackupChunkUploadRequest = { + upload_id: string; + chunk_index: number; + chunk: (Blob | File); +}; + +export type BackupExportRequest = { + include?: Array<(string)>; + exclude?: Array<(string)>; +}; + +export type BackupImportRequest = { + confirmed?: boolean; +}; + +export type BackupRenameRequest = { + new_name: string; +}; + +export type BackupUploadInitRequest = { + filename: string; + total_size: number; +}; + +export type BackupUploadRequest = { + file: (Blob | File); +}; + +export type BackupUploadSessionRequest = { + upload_id: string; +}; + +export type BatchSessionProviderRequest = UmoListRequest & { + provider_id: string; + provider_type: 'chat_completion' | 'speech_to_text' | 'text_to_speech'; +}; + +export type provider_type = 'chat_completion' | 'speech_to_text' | 'text_to_speech'; + +export type BatchSessionServiceRequest = UmoListRequest & { + session_enabled?: boolean; + llm_enabled?: boolean; + tts_enabled?: boolean; +}; + +export type BotConfigRequest = { + id?: string; + name?: string; + /** + * Platform adapter type, such as aiocqhttp, telegram, lark. + */ + type: string; + enabled?: boolean; + config: DynamicConfig; +}; + +export type BotRegistrationRequest = { + action: 'start' | 'poll'; + platform_config?: DynamicConfig; + registration_code?: string; + device_code?: string; + qrcode?: string; + [key: string]: unknown | string | DynamicConfig; +}; + +export type action = 'start' | 'poll'; + +export type ChatMessagePatchRequest = { + content: { + [key: string]: unknown; + }; +}; + +export type ChatMessageRegenerateRequest = { + selected_provider?: string; + selected_model?: string; + enable_streaming?: boolean; +}; + +export type ChatProjectRequest = { + title?: string; + emoji?: string; + description?: string; +}; + +export type ChatRequest = { + username?: string; + session_id?: string; + /** + * Deprecated alias for session_id. + */ + conversation_id?: string; + message: (string | Array); + config_id?: string; + config_name?: string; + selected_provider?: string; + selected_model?: string; + enable_streaming?: boolean; + /** + * Internal WebUI flag for edit/regenerate flows. + */ + _skip_user_history?: boolean; + /** + * Internal WebUI checkpoint override. + */ + _llm_checkpoint_id?: string; + /** + * Internal WebUI platform history override. + */ + _platform_history_id?: string; + /** + * Internal WebUI side-thread context. + */ + _thread_selected_text?: string; +}; + +export type ChatSessionBatchDeleteRequest = { + session_ids: Array<(string)>; +}; + +export type ChatSessionPatchRequest = { + display_name?: string; +}; + +export type ChatThreadCreateRequest = { + session_id: string; + parent_message_id: (string | number); + selected_text: string; +}; + +export type ChatThreadMessageRequest = { + message: (string | Array); + selected_provider?: string; + selected_model?: string; + enable_streaming?: boolean; +}; + +export type CommandPatchRequest = { + enabled?: boolean; + alias?: string; + aliases?: Array<(string)>; + permission_group?: string; +}; + +export type ConfigRoutesReplaceRequest = { + routing: { + [key: string]: (string); + }; +}; + +export type ConfigRouteUpsertRequest = { + /** + * Use "default" to remove a custom route and fall back. + */ + config_id: string; +}; + +export type ConversationBatchDeleteRequest = { + conversations: Array; +}; + +export type ConversationExportRequest = { + conversations?: Array; + conversation_ids?: Array<(string)>; + format?: 'json' | 'markdown'; +}; + +export type format = 'json' | 'markdown'; + +export type ConversationMessagesReplaceRequest = { + user_id?: string; + messages?: Array<{ + [key: string]: unknown; + }>; + history?: Array<{ + [key: string]: unknown; + }>; +}; + +export type ConversationPatchRequest = { + title?: string; + persona_id?: string; +}; + +export type ConversationRef = { + user_id: string; + cid: string; +}; + +export type CreateApiKeyRequest = { + name: string; + scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'plugin')>; + expires_at?: string; + expires_in_days?: number; +}; + +export type CreateConfigProfileRequest = { + name?: string; + config?: DynamicConfig; +}; + +export type CronJobPatchRequest = CronJobRequest; + +export type CronJobRequest = { + name?: string; + cron_expression?: string; + timezone?: string; + session?: string; + note?: string; + description?: string; + persona_id?: string; + provider_id?: string; + enabled?: boolean; + run_once?: boolean; + run_at?: string; + payload?: { + [key: string]: unknown; + }; + [key: string]: unknown | string | boolean; +}; + +export type DynamicConfig = { + [key: string]: unknown; +}; + +export type EnabledPatch = { + enabled: boolean; +}; + +export type ErrorEnvelope = { + status: "error"; + message: string; + data?: unknown; +}; + +export type FileUploadRequest = { + file: (Blob | File); +}; + +export type GhproxyTestRequest = { + proxy_url: string; +}; + +export type ImMessageRequest = { + umo: string; + message: (string | Array); +}; + +export type JsonSchema = { + [key: string]: unknown; +}; + +export type KnowledgeBaseRequest = { + name: string; + description?: string; + embedding_provider_id?: string; + rerank_provider_id?: string; + chunking?: DynamicConfig; + metadata?: DynamicConfig; +}; + +export type KnowledgeDocumentImportRequest = { + paths: Array<(string)>; + parser?: string; +}; + +export type KnowledgeDocumentUploadRequest = { + file: (Blob | File); + parser?: string; +}; + +export type KnowledgeDocumentUrlImportRequest = { + url: string; + parser?: string; +}; + +export type KnowledgeRetrieveRequest = { + query: string; + top_k?: number; + score_threshold?: number; +}; + +export type LoginRequest = { + username: string; + password: string; + /** + * TOTP code or recovery code when two-factor authentication is required. + */ + code?: string; + trust_device_flag?: boolean; +}; + +export type McpServerConfig = { + name: string; + enabled?: boolean; + transport?: 'stdio' | 'sse' | 'streamable_http'; + command?: string; + args?: Array<(string)>; + url?: string; + headers?: { + [key: string]: (string); + }; + timeout?: number; + [key: string]: unknown | string | boolean | number; +}; + +export type transport = 'stdio' | 'sse' | 'streamable_http'; + +export type MessagePart = { + type: 'text' | 'plain' | 'image' | 'file' | 'audio' | 'record' | 'video' | 'reply'; + text?: string; + attachment_id?: string; + url?: string; + filename?: string; + mime_type?: string; + [key: string]: unknown | string; +}; + +export type type = 'text' | 'plain' | 'image' | 'file' | 'audio' | 'record' | 'video' | 'reply'; + +export type MigrationRequest = { + platform_id_map?: { + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +export type ModelScopeSyncRequest = { + access_token?: string; +}; + +export type NameRequest = { + name: string; +}; + +export type NeoCandidateActionRequest = { + candidate_id: string; + [key: string]: unknown | string; +}; + +export type NeoReleaseActionRequest = { + release_id: string; + [key: string]: unknown | string; +}; + +export type ParameterAttachmentId = string; + +export type ParameterBotId = string; + +export type ParameterChunkId = string; + +/** + * URL-encoded command handler full name. + */ +export type ParameterCommandId = string; + +export type ParameterConfigId = string; + +export type ParameterConversationId = string; + +export type ParameterDocumentId = string; + +export type ParameterFilename = string; + +/** + * URL-encoded relative file path. + */ +export type ParameterFilePath = string; + +export type ParameterFolderId = string; + +export type ParameterGroupId = string; + +export type ParameterKbId = string; + +export type ParameterKeyId = string; + +export type ParameterName = string; + +export type ParameterPage = number; + +export type ParameterPageSize = number; + +export type ParameterPersonaId = string; + +export type ParameterPluginId = string; + +export type ParameterProviderId = string; + +export type ParameterServerName = string; + +export type ParameterSessionId = string; + +export type ParameterSkillName = string; + +export type ParameterSourceId = string; + +export type ParameterTaskId = string; + +export type ParameterToolId = string; + +/** + * URL-encoded unified message origin. + */ +export type ParameterUmo = string; + +export type ParameterWebhookUuid = string; + +export type PersonaFolderRequest = { + name?: string; + parent_id?: string; + description?: string; + [key: string]: unknown | string; +}; + +export type PersonaMoveRequest = { + persona_id: string; + folder_id?: string; + [key: string]: unknown | string; +}; + +export type PersonaRequest = { + persona_id: string; + system_prompt: string; + begin_dialogs?: Array<(string)>; + folder_id?: string; + tools?: Array<(string)>; + skills?: Array<(string)>; + custom_error_message?: string; + [key: string]: unknown | string; +}; + +export type PipInstallRequest = { + package: string; + mirror?: string; +}; + +export type PluginBatchUpdateRequest = { + /** + * When set, update this single plugin instead of a batch. + */ + plugin_id?: string; + plugin_ids?: Array<(string)>; + reinstall?: boolean; + update_all?: boolean; + [key: string]: unknown | string | boolean; +}; + +export type PluginConfigFileDeleteRequest = { + path: string; +}; + +export type PluginGithubInstallRequest = { + /** + * GitHub URL or owner/repository slug. + */ + repository: string; + ref?: string; + /** + * Optional downloadable ZIP URL to use instead of GitHub archive resolution. + */ + download_url?: string; + proxy?: string; + ignore_version_check?: boolean; +}; + +export type PluginSourceRequest = { + id?: string; + name?: string; + url: string; +}; + +export type PluginUpdateRequest = { + reinstall?: boolean; +}; + +export type PluginUploadInstallRequest = { + file: (Blob | File); +}; + +export type PluginUrlInstallRequest = { + url: string; + /** + * Optional downloadable ZIP URL when url is the plugin source page or repository. + */ + download_url?: string; + proxy?: string; + ignore_version_check?: boolean; +}; + +export type PluginVersionSupportRequest = { + astrbot_version?: string; +}; + +export type ProviderCapability = 'chat' | 'agent' | 'stt' | 'tts' | 'embedding' | 'rerank'; + +export type ProviderConfigRequest = { + id?: string; + provider_source_id?: string; + capability?: ProviderCapability; + enabled?: boolean; + config: DynamicConfig; +}; + +export type ProviderSourceConfigRequest = { + id?: string; + config: DynamicConfig; +}; + +export type RenameRequest = { + name: string; +}; + +export type ReorderRequest = { + items: Array<{ + id: string; + type: 'persona' | 'folder'; + sort_order: number; + }>; +}; + +export type SessionGroupRequest = { + name?: string; + umos?: Array<(string)>; + add_umos?: Array<(string)>; + remove_umos?: Array<(string)>; +}; + +export type SessionRuleRequest = { + umo: string; + rule_key: string; + rule_value?: DynamicConfig; + [key: string]: unknown | string | DynamicConfig; +}; + +export type SetupAuthRequest = { + username: string; + password: string; + confirm_password: string; +}; + +export type SkillPatchRequest = { + enabled?: boolean; + display_name?: string; + description?: string; +}; + +export type SkillUploadRequest = { + file: (Blob | File); + overwrite?: boolean; +}; + +export type SuccessEnvelope = { + status: "ok"; + message?: string; + data: unknown; +}; + +export type T2iTemplateContentRequest = { + content: string; + [key: string]: unknown | string; +}; + +export type T2iTemplateRequest = { + name: string; + content: string; + [key: string]: unknown | string; +}; + +export type ToolPermissionPatch = { + permission: 'admin' | 'member'; +}; + +export type permission = 'admin' | 'member'; + +export type TotpSetupRequest = { + code?: string; + secret?: string; +}; + +export type TraceSettingsRequest = { + enabled?: boolean; + level?: string; + [key: string]: unknown | boolean | string; +}; + +export type UmoListRequest = { + umo?: string; + umos?: Array<(string)>; + scope?: 'all' | 'group' | 'private' | 'custom_group'; + group_id?: string; + rule_key?: string; +}; + +export type scope = 'all' | 'group' | 'private' | 'custom_group'; + +export type UpdateAccountRequest = { + password: string; + new_password?: string; + confirm_password?: string; + new_username?: string; +}; + +export type UpdateRequest = { + version?: string; + proxy?: string; + reboot?: boolean; + progress_id?: string; +}; + +export type LoginData = { + body: LoginRequest; +}; + +export type LoginResponse = (SuccessEnvelope); + +export type LoginError = (ErrorEnvelope); + +export type LogoutResponse = (SuccessEnvelope); + +export type LogoutError = unknown; + +export type GetAuthSetupStatusResponse = (SuccessEnvelope); + +export type GetAuthSetupStatusError = unknown; + +export type SetupAuthData = { + body: SetupAuthRequest; +}; + +export type SetupAuthResponse = (SuccessEnvelope); + +export type SetupAuthError = unknown; + +export type SetupTotpData = { + body?: TotpSetupRequest; +}; + +export type SetupTotpResponse = (SuccessEnvelope); + +export type SetupTotpError = unknown; + +export type RecoverTotpResponse = (SuccessEnvelope); + +export type RecoverTotpError = unknown; + +export type UpdateAuthAccountData = { + body: UpdateAccountRequest; +}; + +export type UpdateAuthAccountResponse = (SuccessEnvelope); + +export type UpdateAuthAccountError = unknown; + +export type ListApiKeysResponse = (SuccessEnvelope); + +export type ListApiKeysError = unknown; + +export type CreateApiKeyData = { + body: CreateApiKeyRequest; +}; + +export type CreateApiKeyResponse = (SuccessEnvelope); + +export type CreateApiKeyError = unknown; + +export type RevokeApiKeyData = { + path: { + key_id: string; + }; +}; + +export type RevokeApiKeyResponse = (SuccessEnvelope); + +export type RevokeApiKeyError = unknown; + +export type DeleteApiKeyData = { + path: { + key_id: string; + }; +}; + +export type DeleteApiKeyResponse = (SuccessEnvelope); + +export type DeleteApiKeyError = unknown; + +export type GetSystemConfigSchemaResponse = (SuccessEnvelope); + +export type GetSystemConfigSchemaError = unknown; + +export type GetSystemConfigResponse = (SuccessEnvelope); + +export type GetSystemConfigError = unknown; + +export type UpdateSystemConfigData = { + body: DynamicConfig; +}; + +export type UpdateSystemConfigResponse = (SuccessEnvelope); + +export type UpdateSystemConfigError = unknown; + +export type GetSystemConfigRuntimeResponse = (SuccessEnvelope); + +export type GetSystemConfigRuntimeError = unknown; + +export type GetConfigProfileSchemaResponse = (SuccessEnvelope); + +export type GetConfigProfileSchemaError = unknown; + +export type ListConfigProfilesResponse = (SuccessEnvelope); + +export type ListConfigProfilesError = unknown; + +export type CreateConfigProfileData = { + body: CreateConfigProfileRequest; +}; + +export type CreateConfigProfileResponse = (SuccessEnvelope); + +export type CreateConfigProfileError = unknown; + +export type GetConfigProfileData = { + path: { + config_id: string; + }; +}; + +export type GetConfigProfileResponse = (SuccessEnvelope); + +export type GetConfigProfileError = unknown; + +export type UpdateConfigProfileContentData = { + body: DynamicConfig; + path: { + config_id: string; + }; +}; + +export type UpdateConfigProfileContentResponse = (SuccessEnvelope); + +export type UpdateConfigProfileContentError = unknown; + +export type RenameConfigProfileData = { + body: RenameRequest; + path: { + config_id: string; + }; +}; + +export type RenameConfigProfileResponse = (SuccessEnvelope); + +export type RenameConfigProfileError = unknown; + +export type DeleteConfigProfileData = { + path: { + config_id: string; + }; +}; + +export type DeleteConfigProfileResponse = (SuccessEnvelope); + +export type DeleteConfigProfileError = unknown; + +export type ListConfigRoutesResponse = (SuccessEnvelope); + +export type ListConfigRoutesError = unknown; + +export type ReplaceConfigRoutesData = { + body: ConfigRoutesReplaceRequest; +}; + +export type ReplaceConfigRoutesResponse = (SuccessEnvelope); + +export type ReplaceConfigRoutesError = unknown; + +export type UpsertConfigRouteData = { + body: ConfigRouteUpsertRequest; + path: { + /** + * URL-encoded unified message origin. + */ + umo: string; + }; +}; + +export type UpsertConfigRouteResponse = (SuccessEnvelope); + +export type UpsertConfigRouteError = unknown; + +export type DeleteConfigRouteData = { + path: { + /** + * URL-encoded unified message origin. + */ + umo: string; + }; +}; + +export type DeleteConfigRouteResponse = (SuccessEnvelope); + +export type DeleteConfigRouteError = unknown; + +export type ListBotTypesResponse = (SuccessEnvelope); + +export type ListBotTypesError = unknown; + +export type RegisterBotTypeData = { + body: BotRegistrationRequest; + path: { + bot_type: string; + }; +}; + +export type RegisterBotTypeResponse = (SuccessEnvelope); + +export type RegisterBotTypeError = unknown; + +export type ListBotsData = { + query?: { + enabled?: boolean; + type?: string; + }; +}; + +export type ListBotsResponse = (SuccessEnvelope); + +export type ListBotsError = unknown; + +export type CreateBotData = { + body: BotConfigRequest; +}; + +export type CreateBotResponse = (SuccessEnvelope); + +export type CreateBotError = unknown; + +export type ListBotStatsResponse = (SuccessEnvelope); + +export type ListBotStatsError = unknown; + +export type GetBotByIdData = { + query: { + bot_id: string; + }; +}; + +export type GetBotByIdResponse = (SuccessEnvelope); + +export type GetBotByIdError = unknown; + +export type UpdateBotByIdData = { + body: { + bot_id: string; + config: DynamicConfig; + }; +}; + +export type UpdateBotByIdResponse = (SuccessEnvelope); + +export type UpdateBotByIdError = unknown; + +export type DeleteBotByIdData = { + query: { + bot_id: string; + }; +}; + +export type DeleteBotByIdResponse = (SuccessEnvelope); + +export type DeleteBotByIdError = unknown; + +export type SetBotEnabledByIdData = { + body: { + bot_id: string; + enabled: boolean; + }; +}; + +export type SetBotEnabledByIdResponse = (SuccessEnvelope); + +export type SetBotEnabledByIdError = unknown; + +export type TestBotByIdData = { + body: { + bot_id: string; + }; +}; + +export type TestBotByIdResponse = (SuccessEnvelope); + +export type TestBotByIdError = unknown; + +export type GetBotData = { + path: { + bot_id: string; + }; +}; + +export type GetBotResponse = (SuccessEnvelope); + +export type GetBotError = unknown; + +export type UpdateBotData = { + body: BotConfigRequest; + path: { + bot_id: string; + }; +}; + +export type UpdateBotResponse = (SuccessEnvelope); + +export type UpdateBotError = unknown; + +export type DeleteBotData = { + path: { + bot_id: string; + }; +}; + +export type DeleteBotResponse = (SuccessEnvelope); + +export type DeleteBotError = unknown; + +export type SetBotEnabledData = { + body: EnabledPatch; + path: { + bot_id: string; + }; +}; + +export type SetBotEnabledResponse = (SuccessEnvelope); + +export type SetBotEnabledError = unknown; + +export type TestBotData = { + path: { + bot_id: string; + }; +}; + +export type TestBotResponse = (SuccessEnvelope); + +export type TestBotError = unknown; + +export type GetProviderSchemaResponse = (SuccessEnvelope); + +export type GetProviderSchemaError = unknown; + +export type ListProviderSourcesResponse = (SuccessEnvelope); + +export type ListProviderSourcesError = unknown; + +export type CreateProviderSourceData = { + body: ProviderSourceConfigRequest; +}; + +export type CreateProviderSourceResponse = (SuccessEnvelope); + +export type CreateProviderSourceError = unknown; + +export type GetProviderSourceByIdData = { + query: { + source_id: string; + }; +}; + +export type GetProviderSourceByIdResponse = (SuccessEnvelope); + +export type GetProviderSourceByIdError = unknown; + +export type UpsertProviderSourceByIdData = { + body: { + source_id: string; + config: DynamicConfig; + }; +}; + +export type UpsertProviderSourceByIdResponse = (SuccessEnvelope); + +export type UpsertProviderSourceByIdError = unknown; + +export type DeleteProviderSourceByIdData = { + query: { + source_id: string; + }; +}; + +export type DeleteProviderSourceByIdResponse = (SuccessEnvelope); + +export type DeleteProviderSourceByIdError = unknown; + +export type ListProviderSourceModelsByIdData = { + query: { + capability?: ProviderCapability; + source_id: string; + }; +}; + +export type ListProviderSourceModelsByIdResponse = (SuccessEnvelope); + +export type ListProviderSourceModelsByIdError = unknown; + +export type ListProvidersBySourceIdData = { + query: { + capability?: ProviderCapability; + source_id: string; + }; +}; + +export type ListProvidersBySourceIdResponse = (SuccessEnvelope); + +export type ListProvidersBySourceIdError = unknown; + +export type CreateProviderInSourceByIdData = { + body: { + source_id: string; + config: DynamicConfig; + }; +}; + +export type CreateProviderInSourceByIdResponse = (SuccessEnvelope); + +export type CreateProviderInSourceByIdError = unknown; + +export type GetProviderSourceData = { + path: { + source_id: string; + }; +}; + +export type GetProviderSourceResponse = (SuccessEnvelope); + +export type GetProviderSourceError = unknown; + +export type UpsertProviderSourceData = { + body: ProviderSourceConfigRequest; + path: { + source_id: string; + }; +}; + +export type UpsertProviderSourceResponse = (SuccessEnvelope); + +export type UpsertProviderSourceError = unknown; + +export type DeleteProviderSourceData = { + path: { + source_id: string; + }; +}; + +export type DeleteProviderSourceResponse = (SuccessEnvelope); + +export type DeleteProviderSourceError = unknown; + +export type ListProviderSourceModelsData = { + path: { + source_id: string; + }; + query?: { + capability?: ProviderCapability; + }; +}; + +export type ListProviderSourceModelsResponse = (SuccessEnvelope); + +export type ListProviderSourceModelsError = unknown; + +export type ListProvidersBySourceData = { + path: { + source_id: string; + }; + query?: { + capability?: ProviderCapability; + }; +}; + +export type ListProvidersBySourceResponse = (SuccessEnvelope); + +export type ListProvidersBySourceError = unknown; + +export type CreateProviderInSourceData = { + body: ProviderConfigRequest; + path: { + source_id: string; + }; +}; + +export type CreateProviderInSourceResponse = (SuccessEnvelope); + +export type CreateProviderInSourceError = unknown; + +export type ListProvidersData = { + query?: { + capability?: ProviderCapability; + enabled?: boolean; + source_id?: string; + }; +}; + +export type ListProvidersResponse = (SuccessEnvelope); + +export type ListProvidersError = unknown; + +export type CreateProviderData = { + body: ProviderConfigRequest; +}; + +export type CreateProviderResponse = (SuccessEnvelope); + +export type CreateProviderError = unknown; + +export type GetProviderByIdData = { + query: { + merged?: boolean; + provider_id: string; + }; +}; + +export type GetProviderByIdResponse = (SuccessEnvelope); + +export type GetProviderByIdError = unknown; + +export type UpdateProviderByIdData = { + body: { + provider_id: string; + config: DynamicConfig; + }; +}; + +export type UpdateProviderByIdResponse = (SuccessEnvelope); + +export type UpdateProviderByIdError = unknown; + +export type DeleteProviderByIdData = { + query: { + provider_id: string; + }; +}; + +export type DeleteProviderByIdResponse = (SuccessEnvelope); + +export type DeleteProviderByIdError = unknown; + +export type SetProviderEnabledByIdData = { + body: { + provider_id: string; + enabled: boolean; + }; +}; + +export type SetProviderEnabledByIdResponse = (SuccessEnvelope); + +export type SetProviderEnabledByIdError = unknown; + +export type TestProviderByIdData = { + body: { + provider_id: string; + }; +}; + +export type TestProviderByIdResponse = (SuccessEnvelope); + +export type TestProviderByIdError = unknown; + +export type GetProviderEmbeddingDimensionByIdData = { + body: { + provider_id: string; + provider_config?: DynamicConfig; + }; +}; + +export type GetProviderEmbeddingDimensionByIdResponse = (SuccessEnvelope); + +export type GetProviderEmbeddingDimensionByIdError = unknown; + +export type GetProviderData = { + path: { + provider_id: string; + }; + query?: { + merged?: boolean; + }; +}; + +export type GetProviderResponse = (SuccessEnvelope); + +export type GetProviderError = unknown; + +export type UpdateProviderData = { + body: ProviderConfigRequest; + path: { + provider_id: string; + }; +}; + +export type UpdateProviderResponse = (SuccessEnvelope); + +export type UpdateProviderError = unknown; + +export type DeleteProviderData = { + body?: DynamicConfig; + path: { + provider_id: string; + }; +}; + +export type DeleteProviderResponse = (SuccessEnvelope); + +export type DeleteProviderError = unknown; + +export type SetProviderEnabledData = { + body: EnabledPatch; + path: { + provider_id: string; + }; +}; + +export type SetProviderEnabledResponse = (SuccessEnvelope); + +export type SetProviderEnabledError = unknown; + +export type TestProviderData = { + path: { + provider_id: string; + }; +}; + +export type TestProviderResponse = (SuccessEnvelope); + +export type TestProviderError = unknown; + +export type GetProviderEmbeddingDimensionData = { + body?: DynamicConfig; + path: { + provider_id: string; + }; +}; + +export type GetProviderEmbeddingDimensionResponse = (SuccessEnvelope); + +export type GetProviderEmbeddingDimensionError = unknown; + +export type SendChatMessageData = { + body: ChatRequest; +}; + +export type SendChatMessageResponse = (SuccessEnvelope); + +export type SendChatMessageError = unknown; + +export type OpenChatWebSocketData = { + query?: { + api_key?: string; + key?: string; + }; +}; + +export type OpenLiveChatWebSocketData = { + query: { + token: string; + }; +}; + +export type OpenUnifiedChatWebSocketData = { + query: { + token: string; + }; +}; + +export type ListChatSessionsData = { + query?: { + page?: number; + page_size?: number; + username?: string; + }; +}; + +export type ListChatSessionsResponse = (SuccessEnvelope); + +export type ListChatSessionsError = unknown; + +export type CreateChatSessionData = { + query?: { + platform_id?: string; + }; +}; + +export type CreateChatSessionResponse = (SuccessEnvelope); + +export type CreateChatSessionError = unknown; + +export type BatchDeleteChatSessionsData = { + body: ChatSessionBatchDeleteRequest; +}; + +export type BatchDeleteChatSessionsResponse = (SuccessEnvelope); + +export type BatchDeleteChatSessionsError = unknown; + +export type GetChatSessionData = { + path: { + session_id: string; + }; +}; + +export type GetChatSessionResponse = (SuccessEnvelope); + +export type GetChatSessionError = unknown; + +export type UpdateChatSessionData = { + body: ChatSessionPatchRequest; + path: { + session_id: string; + }; +}; + +export type UpdateChatSessionResponse = (SuccessEnvelope); + +export type UpdateChatSessionError = unknown; + +export type DeleteChatSessionData = { + path: { + session_id: string; + }; +}; + +export type DeleteChatSessionResponse = (SuccessEnvelope); + +export type DeleteChatSessionError = unknown; + +export type StopChatSessionData = { + path: { + session_id: string; + }; +}; + +export type StopChatSessionResponse = (SuccessEnvelope); + +export type StopChatSessionError = unknown; + +export type UpdateChatMessageData = { + body: ChatMessagePatchRequest; + path: { + message_id: string; + session_id: string; + }; +}; + +export type UpdateChatMessageResponse = (SuccessEnvelope); + +export type UpdateChatMessageError = unknown; + +export type RegenerateChatMessageData = { + body?: ChatMessageRegenerateRequest; + path: { + message_id: string; + session_id: string; + }; +}; + +export type RegenerateChatMessageResponse = (unknown); + +export type RegenerateChatMessageError = unknown; + +export type ListChatConfigsResponse = (SuccessEnvelope); + +export type ListChatConfigsError = unknown; + +export type CreateChatThreadData = { + body: ChatThreadCreateRequest; +}; + +export type CreateChatThreadResponse = (SuccessEnvelope); + +export type CreateChatThreadError = unknown; + +export type GetChatThreadData = { + path: { + thread_id: string; + }; +}; + +export type GetChatThreadResponse = (SuccessEnvelope); + +export type GetChatThreadError = unknown; + +export type DeleteChatThreadData = { + path: { + thread_id: string; + }; +}; + +export type DeleteChatThreadResponse = (SuccessEnvelope); + +export type DeleteChatThreadError = unknown; + +export type SendChatThreadMessageData = { + body: ChatThreadMessageRequest; + path: { + thread_id: string; + }; +}; + +export type SendChatThreadMessageResponse = (unknown); + +export type SendChatThreadMessageError = unknown; + +export type ListChatProjectsResponse = (SuccessEnvelope); + +export type ListChatProjectsError = unknown; + +export type CreateChatProjectData = { + body: ChatProjectRequest; +}; + +export type CreateChatProjectResponse = (SuccessEnvelope); + +export type CreateChatProjectError = unknown; + +export type GetChatProjectData = { + path: { + project_id: string; + }; +}; + +export type GetChatProjectResponse = (SuccessEnvelope); + +export type GetChatProjectError = unknown; + +export type UpdateChatProjectData = { + body: ChatProjectRequest; + path: { + project_id: string; + }; +}; + +export type UpdateChatProjectResponse = (SuccessEnvelope); + +export type UpdateChatProjectError = unknown; + +export type DeleteChatProjectData = { + path: { + project_id: string; + }; +}; + +export type DeleteChatProjectResponse = (SuccessEnvelope); + +export type DeleteChatProjectError = unknown; + +export type ListChatProjectSessionsData = { + path: { + project_id: string; + }; +}; + +export type ListChatProjectSessionsResponse = (SuccessEnvelope); + +export type ListChatProjectSessionsError = unknown; + +export type AddChatProjectSessionData = { + path: { + project_id: string; + session_id: string; + }; +}; + +export type AddChatProjectSessionResponse = (SuccessEnvelope); + +export type AddChatProjectSessionError = unknown; + +export type RemoveChatProjectSessionData = { + path: { + session_id: string; + }; +}; + +export type RemoveChatProjectSessionResponse = (SuccessEnvelope); + +export type RemoveChatProjectSessionError = unknown; + +export type SendImMessageData = { + body: ImMessageRequest; +}; + +export type SendImMessageResponse = (SuccessEnvelope); + +export type SendImMessageError = unknown; + +export type ListImBotsResponse = (SuccessEnvelope); + +export type ListImBotsError = unknown; + +export type UploadFileData = { + body: FileUploadRequest; +}; + +export type UploadFileResponse = (SuccessEnvelope); + +export type UploadFileError = unknown; + +export type GetFileByNameData = { + query: { + filename: string; + }; +}; + +export type GetFileByNameResponse = ((Blob | File)); + +export type GetFileByNameError = unknown; + +export type GetTokenFileData = { + path: { + file_token: string; + }; +}; + +export type GetTokenFileResponse = ((Blob | File)); + +export type GetTokenFileError = unknown; + +export type GetAttachmentData = { + path: { + attachment_id: string; + }; +}; + +export type GetAttachmentResponse = (SuccessEnvelope); + +export type GetAttachmentError = unknown; + +export type DeleteAttachmentData = { + path: { + attachment_id: string; + }; +}; + +export type DeleteAttachmentResponse = (SuccessEnvelope); + +export type DeleteAttachmentError = unknown; + +export type DownloadAttachmentData = { + path: { + attachment_id: string; + }; +}; + +export type DownloadAttachmentResponse = ((Blob | File)); + +export type DownloadAttachmentError = unknown; + +export type ListPluginsData = { + query?: { + enabled?: boolean; + include_reserved?: boolean; + }; +}; + +export type ListPluginsResponse = (SuccessEnvelope); + +export type ListPluginsError = unknown; + +export type GetPluginByIdData = { + query: { + plugin_id: string; + }; +}; + +export type GetPluginByIdResponse = (SuccessEnvelope); + +export type GetPluginByIdError = unknown; + +export type UninstallPluginByIdData = { + body?: { + delete_config?: boolean; + delete_data?: boolean; + [key: string]: unknown | boolean; + }; + query: { + plugin_id: string; + }; +}; + +export type UninstallPluginByIdResponse = (SuccessEnvelope); + +export type UninstallPluginByIdError = unknown; + +export type GetPluginConfigByIdData = { + query: { + plugin_id: string; + }; +}; + +export type GetPluginConfigByIdResponse = (SuccessEnvelope); + +export type GetPluginConfigByIdError = unknown; + +export type UpdatePluginConfigByIdData = { + body: { + plugin_id: string; + config: DynamicConfig; + }; +}; + +export type UpdatePluginConfigByIdResponse = (SuccessEnvelope); + +export type UpdatePluginConfigByIdError = unknown; + +export type GetPluginConfigSchemaByIdData = { + query: { + plugin_id: string; + }; +}; + +export type GetPluginConfigSchemaByIdResponse = (SuccessEnvelope); + +export type GetPluginConfigSchemaByIdError = unknown; + +export type ListPluginConfigFilesByIdData = { + query: { + config_key: string; + plugin_id: string; + }; +}; + +export type ListPluginConfigFilesByIdResponse = (SuccessEnvelope); + +export type ListPluginConfigFilesByIdError = unknown; + +export type UploadPluginConfigFilesByIdData = { + body: { + [key: string]: unknown; + }; + query: { + config_key: string; + plugin_id: string; + }; +}; + +export type UploadPluginConfigFilesByIdResponse = (SuccessEnvelope); + +export type UploadPluginConfigFilesByIdError = unknown; + +export type DeletePluginConfigFileByIdData = { + body: PluginConfigFileDeleteRequest; + query: { + plugin_id: string; + }; +}; + +export type DeletePluginConfigFileByIdResponse = (SuccessEnvelope); + +export type DeletePluginConfigFileByIdError = unknown; + +export type GetPluginReadmeByIdData = { + query: { + plugin_id: string; + }; +}; + +export type GetPluginReadmeByIdResponse = (string); + +export type GetPluginReadmeByIdError = unknown; + +export type GetPluginChangelogByIdData = { + query: { + plugin_id: string; + }; +}; + +export type GetPluginChangelogByIdResponse = (string); + +export type GetPluginChangelogByIdError = unknown; + +export type ReloadPluginByIdData = { + body: { + plugin_id: string; + }; +}; + +export type ReloadPluginByIdResponse = (SuccessEnvelope); + +export type ReloadPluginByIdError = unknown; + +export type SetPluginEnabledByIdData = { + body: { + plugin_id: string; + enabled: boolean; + }; +}; + +export type SetPluginEnabledByIdResponse = (SuccessEnvelope); + +export type SetPluginEnabledByIdError = unknown; + +export type ListPluginPagesByIdData = { + query: { + plugin_id: string; + }; +}; + +export type ListPluginPagesByIdResponse = (SuccessEnvelope); + +export type ListPluginPagesByIdError = unknown; + +export type GetPluginPageByIdData = { + query: { + page_name: string; + plugin_id: string; + }; +}; + +export type GetPluginPageByIdResponse = (string); + +export type GetPluginPageByIdError = unknown; + +export type GetPluginPageAssetByIdData = { + query: { + asset_path: string; + page_name: string; + plugin_id: string; + }; +}; + +export type GetPluginPageAssetByIdResponse = (unknown); + +export type GetPluginPageAssetByIdError = unknown; + +export type GetPluginData = { + body?: { + delete_config?: boolean; + delete_data?: boolean; + [key: string]: unknown | boolean; + }; + path: { + plugin_id: string; + }; +}; + +export type GetPluginResponse = (SuccessEnvelope); + +export type GetPluginError = unknown; + +export type UninstallPluginData = { + body?: { + delete_config?: boolean; + delete_data?: boolean; + [key: string]: unknown | boolean; + }; + path: { + plugin_id: string; + }; +}; + +export type UninstallPluginResponse = (SuccessEnvelope); + +export type UninstallPluginError = unknown; + +export type GetPluginConfigData = { + path: { + plugin_id: string; + }; +}; + +export type GetPluginConfigResponse = (SuccessEnvelope); + +export type GetPluginConfigError = unknown; + +export type UpdatePluginConfigData = { + body: DynamicConfig; + path: { + plugin_id: string; + }; +}; + +export type UpdatePluginConfigResponse = (SuccessEnvelope); + +export type UpdatePluginConfigError = unknown; + +export type GetPluginConfigSchemaData = { + path: { + plugin_id: string; + }; +}; + +export type GetPluginConfigSchemaResponse = (SuccessEnvelope); + +export type GetPluginConfigSchemaError = unknown; + +export type ListPluginConfigFilesData = { + path: { + /** + * URL-encoded configuration key path. + */ + config_key: string; + plugin_id: string; + }; +}; + +export type ListPluginConfigFilesResponse = (SuccessEnvelope); + +export type ListPluginConfigFilesError = unknown; + +export type UploadPluginConfigFilesData = { + body: { + [key: string]: unknown; + }; + path: { + /** + * URL-encoded configuration key path. + */ + config_key: string; + plugin_id: string; + }; +}; + +export type UploadPluginConfigFilesResponse = (SuccessEnvelope); + +export type UploadPluginConfigFilesError = unknown; + +export type DeletePluginConfigFileData = { + body: PluginConfigFileDeleteRequest; + path: { + plugin_id: string; + }; +}; + +export type DeletePluginConfigFileResponse = (SuccessEnvelope); + +export type DeletePluginConfigFileError = unknown; + +export type GetPluginReadmeData = { + path: { + plugin_id: string; + }; +}; + +export type GetPluginReadmeResponse = (string); + +export type GetPluginReadmeError = unknown; + +export type GetPluginChangelogData = { + path: { + plugin_id: string; + }; +}; + +export type GetPluginChangelogResponse = (string); + +export type GetPluginChangelogError = unknown; + +export type ReloadPluginData = { + path: { + plugin_id: string; + }; +}; + +export type ReloadPluginResponse = (SuccessEnvelope); + +export type ReloadPluginError = unknown; + +export type SetPluginEnabledData = { + body: EnabledPatch; + path: { + plugin_id: string; + }; +}; + +export type SetPluginEnabledResponse = (SuccessEnvelope); + +export type SetPluginEnabledError = unknown; + +export type UpdatePluginData = { + body?: PluginUpdateRequest; + path: { + plugin_id: string; + }; +}; + +export type UpdatePluginResponse = (SuccessEnvelope); + +export type UpdatePluginError = unknown; + +export type UpdatePluginsData = { + body: PluginBatchUpdateRequest; +}; + +export type UpdatePluginsResponse = (SuccessEnvelope); + +export type UpdatePluginsError = unknown; + +export type CheckPluginVersionSupportData = { + body: PluginVersionSupportRequest; +}; + +export type CheckPluginVersionSupportResponse = (SuccessEnvelope); + +export type CheckPluginVersionSupportError = unknown; + +export type ListFailedPluginsResponse = (SuccessEnvelope); + +export type ListFailedPluginsError = unknown; + +export type UninstallFailedPluginData = { + body?: { + delete_config?: boolean; + delete_data?: boolean; + [key: string]: unknown | boolean; + }; + path: { + plugin_id: string; + }; +}; + +export type UninstallFailedPluginResponse = (SuccessEnvelope); + +export type UninstallFailedPluginError = unknown; + +export type ReloadFailedPluginData = { + path: { + plugin_id: string; + }; +}; + +export type ReloadFailedPluginResponse = (SuccessEnvelope); + +export type ReloadFailedPluginError = unknown; + +export type InstallPluginFromGithubData = { + body: PluginGithubInstallRequest; +}; + +export type InstallPluginFromGithubResponse = (SuccessEnvelope); + +export type InstallPluginFromGithubError = unknown; + +export type InstallPluginFromUrlData = { + body: PluginUrlInstallRequest; +}; + +export type InstallPluginFromUrlResponse = (SuccessEnvelope); + +export type InstallPluginFromUrlError = unknown; + +export type InstallPluginFromUploadData = { + body: PluginUploadInstallRequest; +}; + +export type InstallPluginFromUploadResponse = (SuccessEnvelope); + +export type InstallPluginFromUploadError = unknown; + +export type ListPluginMarketData = { + query?: { + category?: string; + custom_registry?: string; + force_refresh?: boolean; + keyword?: string; + page?: number; + page_size?: number; + sort?: 'recommended' | 'downloads' | 'updated' | 'name'; + }; +}; + +export type ListPluginMarketResponse = (SuccessEnvelope); + +export type ListPluginMarketError = unknown; + +export type ListPluginMarketCategoriesResponse = (SuccessEnvelope); + +export type ListPluginMarketCategoriesError = unknown; + +export type ListPluginSourcesResponse = (SuccessEnvelope); + +export type ListPluginSourcesError = unknown; + +export type CreatePluginSourceData = { + body: PluginSourceRequest; +}; + +export type CreatePluginSourceResponse = (SuccessEnvelope); + +export type CreatePluginSourceError = unknown; + +export type ReplacePluginSourcesData = { + body: { + sources: Array; + }; +}; + +export type ReplacePluginSourcesResponse = (SuccessEnvelope); + +export type ReplacePluginSourcesError = unknown; + +export type DeletePluginSourceData = { + path: { + source_id: string; + }; +}; + +export type DeletePluginSourceResponse = (SuccessEnvelope); + +export type DeletePluginSourceError = unknown; + +export type DeletePluginSourceByIdData = { + query: { + source_id: string; + }; +}; + +export type DeletePluginSourceByIdResponse = (SuccessEnvelope); + +export type DeletePluginSourceByIdError = unknown; + +export type ListPluginPagesData = { + path: { + plugin_id: string; + }; +}; + +export type ListPluginPagesResponse = (SuccessEnvelope); + +export type ListPluginPagesError = unknown; + +export type GetPluginPageData = { + path: { + page_name: string; + plugin_id: string; + }; +}; + +export type GetPluginPageResponse = (string); + +export type GetPluginPageError = unknown; + +export type GetPluginPageAssetData = { + path: { + /** + * URL-encoded relative asset path. + */ + asset_path: string; + page_name: string; + plugin_id: string; + }; +}; + +export type GetPluginPageAssetResponse = (unknown); + +export type GetPluginPageAssetError = unknown; + +export type GetPluginPageBridgeSdkResponse = (string); + +export type GetPluginPageBridgeSdkError = unknown; + +export type GetPluginExtensionRouteData = { + path: { + /** + * Plugin extension path after /api/plug/. It may contain slash-separated segments. + */ + plugin_path: string; + }; +}; + +export type GetPluginExtensionRouteResponse = (SuccessEnvelope); + +export type GetPluginExtensionRouteError = unknown; + +export type PostPluginExtensionRouteData = { + body?: DynamicConfig; + path: { + /** + * Plugin extension path after /api/plug/. It may contain slash-separated segments. + */ + plugin_path: string; + }; +}; + +export type PostPluginExtensionRouteResponse = (SuccessEnvelope); + +export type PostPluginExtensionRouteError = unknown; + +export type PutPluginExtensionRouteData = { + body?: DynamicConfig; + path: { + plugin_path: string; + }; +}; + +export type PutPluginExtensionRouteResponse = (SuccessEnvelope); + +export type PutPluginExtensionRouteError = unknown; + +export type PatchPluginExtensionRouteData = { + body?: DynamicConfig; + path: { + plugin_path: string; + }; +}; + +export type PatchPluginExtensionRouteResponse = (SuccessEnvelope); + +export type PatchPluginExtensionRouteError = unknown; + +export type DeletePluginExtensionRouteData = { + path: { + plugin_path: string; + }; +}; + +export type DeletePluginExtensionRouteResponse = (SuccessEnvelope); + +export type DeletePluginExtensionRouteError = unknown; + +export type ListCommandsData = { + query?: { + config_id?: string; + }; +}; + +export type ListCommandsResponse = (SuccessEnvelope); + +export type ListCommandsError = unknown; + +export type UpdateCommandData = { + body: CommandPatchRequest; + path: { + /** + * URL-encoded command handler full name. + */ + command_id: string; + }; +}; + +export type UpdateCommandResponse = (SuccessEnvelope); + +export type UpdateCommandError = unknown; + +export type ListCommandConflictsResponse = (SuccessEnvelope); + +export type ListCommandConflictsError = unknown; + +export type ListToolsData = { + query?: { + enabled?: boolean; + origin?: 'builtin' | 'plugin' | 'mcp'; + }; +}; + +export type ListToolsResponse = (SuccessEnvelope); + +export type ListToolsError = unknown; + +export type SetToolEnabledData = { + body: EnabledPatch; + path: { + tool_id: string; + }; +}; + +export type SetToolEnabledResponse = (SuccessEnvelope); + +export type SetToolEnabledError = unknown; + +export type SetToolPermissionData = { + body: ToolPermissionPatch; + path: { + tool_id: string; + }; +}; + +export type SetToolPermissionResponse = (SuccessEnvelope); + +export type SetToolPermissionError = unknown; + +export type ListMcpServersResponse = (SuccessEnvelope); + +export type ListMcpServersError = unknown; + +export type CreateMcpServerData = { + body: McpServerConfig; +}; + +export type CreateMcpServerResponse = (SuccessEnvelope); + +export type CreateMcpServerError = unknown; + +export type UpdateMcpServerByNameData = { + body: { + server_name: string; + config?: DynamicConfig; + enabled?: boolean; + [key: string]: unknown | string | DynamicConfig | boolean; + }; +}; + +export type UpdateMcpServerByNameResponse = (SuccessEnvelope); + +export type UpdateMcpServerByNameError = unknown; + +export type DeleteMcpServerByNameData = { + query: { + server_name: string; + }; +}; + +export type DeleteMcpServerByNameResponse = (SuccessEnvelope); + +export type DeleteMcpServerByNameError = unknown; + +export type SetMcpServerEnabledByNameData = { + body: { + server_name: string; + enabled: boolean; + }; +}; + +export type SetMcpServerEnabledByNameResponse = (SuccessEnvelope); + +export type SetMcpServerEnabledByNameError = unknown; + +export type TestMcpServerByNameData = { + body: { + server_name: string; + mcp_server_config?: DynamicConfig; + config?: DynamicConfig; + [key: string]: unknown | string | DynamicConfig; + }; +}; + +export type TestMcpServerByNameResponse = (SuccessEnvelope); + +export type TestMcpServerByNameError = unknown; + +export type UpdateMcpServerData = { + body: McpServerConfig; + path: { + server_name: string; + }; +}; + +export type UpdateMcpServerResponse = (SuccessEnvelope); + +export type UpdateMcpServerError = unknown; + +export type DeleteMcpServerData = { + path: { + server_name: string; + }; +}; + +export type DeleteMcpServerResponse = (SuccessEnvelope); + +export type DeleteMcpServerError = unknown; + +export type SetMcpServerEnabledData = { + body: EnabledPatch; + path: { + server_name: string; + }; +}; + +export type SetMcpServerEnabledResponse = (SuccessEnvelope); + +export type SetMcpServerEnabledError = unknown; + +export type TestMcpServerData = { + body?: { + mcp_server_config?: { + [key: string]: unknown; + }; + [key: string]: unknown; + }; + path: { + server_name: string; + }; +}; + +export type TestMcpServerResponse = (SuccessEnvelope); + +export type TestMcpServerError = unknown; + +export type SyncModelScopeMcpServersData = { + body?: ModelScopeSyncRequest; +}; + +export type SyncModelScopeMcpServersResponse = (SuccessEnvelope); + +export type SyncModelScopeMcpServersError = unknown; + +export type ListSkillsData = { + query?: { + enabled?: boolean; + source?: string; + }; +}; + +export type ListSkillsResponse = (SuccessEnvelope); + +export type ListSkillsError = unknown; + +export type UploadSkillData = { + body: SkillUploadRequest; +}; + +export type UploadSkillResponse = (SuccessEnvelope); + +export type UploadSkillError = unknown; + +export type UploadSkillsBatchData = { + body: { + files: Array<((Blob | File))>; + }; +}; + +export type UploadSkillsBatchResponse = (SuccessEnvelope); + +export type UploadSkillsBatchError = unknown; + +export type UpdateSkillByNameData = { + body: { + skill_name: string; + enabled?: boolean; + active?: boolean; + [key: string]: unknown | string | boolean; + }; +}; + +export type UpdateSkillByNameResponse = (SuccessEnvelope); + +export type UpdateSkillByNameError = unknown; + +export type DeleteSkillByNameData = { + query: { + skill_name: string; + }; +}; + +export type DeleteSkillByNameResponse = (SuccessEnvelope); + +export type DeleteSkillByNameError = unknown; + +export type DownloadSkillByNameData = { + query: { + skill_name: string; + }; +}; + +export type DownloadSkillByNameResponse = ((Blob | File)); + +export type DownloadSkillByNameError = unknown; + +export type ListSkillFilesByNameData = { + query: { + path?: string; + skill_name: string; + }; +}; + +export type ListSkillFilesByNameResponse = (SuccessEnvelope); + +export type ListSkillFilesByNameError = unknown; + +export type GetSkillFileByNameData = { + query: { + path: string; + skill_name: string; + }; +}; + +export type GetSkillFileByNameResponse = (string); + +export type GetSkillFileByNameError = unknown; + +export type UpdateSkillFileByNameData = { + body: { + skill_name: string; + path: string; + content: string; + }; +}; + +export type UpdateSkillFileByNameResponse = (SuccessEnvelope); + +export type UpdateSkillFileByNameError = unknown; + +export type UpdateSkillData = { + body: SkillPatchRequest; + path: { + skill_name: string; + }; +}; + +export type UpdateSkillResponse = (SuccessEnvelope); + +export type UpdateSkillError = unknown; + +export type DeleteSkillData = { + path: { + skill_name: string; + }; +}; + +export type DeleteSkillResponse = (SuccessEnvelope); + +export type DeleteSkillError = unknown; + +export type DownloadSkillData = { + path: { + skill_name: string; + }; +}; + +export type DownloadSkillResponse = ((Blob | File)); + +export type DownloadSkillError = unknown; + +export type ListSkillFilesData = { + path: { + skill_name: string; + }; + query?: { + path?: string; + }; +}; + +export type ListSkillFilesResponse = (SuccessEnvelope); + +export type ListSkillFilesError = unknown; + +export type GetSkillFileData = { + path: { + /** + * URL-encoded relative file path. + */ + file_path: string; + skill_name: string; + }; +}; + +export type GetSkillFileResponse = (string); + +export type GetSkillFileError = unknown; + +export type UpdateSkillFileData = { + body: string; + path: { + /** + * URL-encoded relative file path. + */ + file_path: string; + skill_name: string; + }; +}; + +export type UpdateSkillFileResponse = (SuccessEnvelope); + +export type UpdateSkillFileError = unknown; + +export type ListNeoSkillCandidatesData = { + query?: { + skill_key?: string; + status?: string; + }; +}; + +export type ListNeoSkillCandidatesResponse = (SuccessEnvelope); + +export type ListNeoSkillCandidatesError = unknown; + +export type ListNeoSkillReleasesData = { + query?: { + skill_key?: string; + stage?: string; + }; +}; + +export type ListNeoSkillReleasesResponse = (SuccessEnvelope); + +export type ListNeoSkillReleasesError = unknown; + +export type GetNeoSkillPayloadData = { + query: { + payload_ref: string; + }; +}; + +export type GetNeoSkillPayloadResponse = (SuccessEnvelope); + +export type GetNeoSkillPayloadError = unknown; + +export type EvaluateNeoSkillCandidateData = { + body: NeoCandidateActionRequest; +}; + +export type EvaluateNeoSkillCandidateResponse = (SuccessEnvelope); + +export type EvaluateNeoSkillCandidateError = unknown; + +export type PromoteNeoSkillCandidateData = { + body: NeoCandidateActionRequest; +}; + +export type PromoteNeoSkillCandidateResponse = (SuccessEnvelope); + +export type PromoteNeoSkillCandidateError = unknown; + +export type RollbackNeoSkillReleaseData = { + body: NeoReleaseActionRequest; +}; + +export type RollbackNeoSkillReleaseResponse = (SuccessEnvelope); + +export type RollbackNeoSkillReleaseError = unknown; + +export type SyncNeoSkillReleaseData = { + body: NeoReleaseActionRequest; +}; + +export type SyncNeoSkillReleaseResponse = (SuccessEnvelope); + +export type SyncNeoSkillReleaseError = unknown; + +export type DeleteNeoSkillCandidateData = { + body: NeoCandidateActionRequest; +}; + +export type DeleteNeoSkillCandidateResponse = (SuccessEnvelope); + +export type DeleteNeoSkillCandidateError = unknown; + +export type DeleteNeoSkillReleaseData = { + body: NeoReleaseActionRequest; +}; + +export type DeleteNeoSkillReleaseResponse = (SuccessEnvelope); + +export type DeleteNeoSkillReleaseError = unknown; + +export type ListKnowledgeBasesData = { + query?: { + page?: number; + page_size?: number; + refresh_stats?: boolean; + }; +}; + +export type ListKnowledgeBasesResponse = (SuccessEnvelope); + +export type ListKnowledgeBasesError = unknown; + +export type CreateKnowledgeBaseData = { + body: KnowledgeBaseRequest; +}; + +export type CreateKnowledgeBaseResponse = (SuccessEnvelope); + +export type CreateKnowledgeBaseError = unknown; + +export type GetKnowledgeBaseData = { + path: { + kb_id: string; + }; +}; + +export type GetKnowledgeBaseResponse = (SuccessEnvelope); + +export type GetKnowledgeBaseError = unknown; + +export type UpdateKnowledgeBaseData = { + body: KnowledgeBaseRequest; + path: { + kb_id: string; + }; +}; + +export type UpdateKnowledgeBaseResponse = (SuccessEnvelope); + +export type UpdateKnowledgeBaseError = unknown; + +export type DeleteKnowledgeBaseData = { + path: { + kb_id: string; + }; +}; + +export type DeleteKnowledgeBaseResponse = (SuccessEnvelope); + +export type DeleteKnowledgeBaseError = unknown; + +export type GetKnowledgeBaseStatsData = { + path: { + kb_id: string; + }; +}; + +export type GetKnowledgeBaseStatsResponse = (SuccessEnvelope); + +export type GetKnowledgeBaseStatsError = unknown; + +export type ListKnowledgeDocumentsData = { + path: { + kb_id: string; + }; + query?: { + page?: number; + page_size?: number; + }; +}; + +export type ListKnowledgeDocumentsResponse = (SuccessEnvelope); + +export type ListKnowledgeDocumentsError = unknown; + +export type UploadKnowledgeDocumentData = { + body: KnowledgeDocumentUploadRequest; + path: { + kb_id: string; + }; +}; + +export type UploadKnowledgeDocumentResponse = (SuccessEnvelope); + +export type UploadKnowledgeDocumentError = unknown; + +export type ImportKnowledgeDocumentsData = { + body: KnowledgeDocumentImportRequest; + path: { + kb_id: string; + }; +}; + +export type ImportKnowledgeDocumentsResponse = (SuccessEnvelope); + +export type ImportKnowledgeDocumentsError = unknown; + +export type ImportKnowledgeDocumentFromUrlData = { + body: KnowledgeDocumentUrlImportRequest; + path: { + kb_id: string; + }; +}; + +export type ImportKnowledgeDocumentFromUrlResponse = (SuccessEnvelope); + +export type ImportKnowledgeDocumentFromUrlError = unknown; + +export type GetKnowledgeDocumentData = { + path: { + document_id: string; + kb_id: string; + }; +}; + +export type GetKnowledgeDocumentResponse = (SuccessEnvelope); + +export type GetKnowledgeDocumentError = unknown; + +export type DeleteKnowledgeDocumentData = { + path: { + document_id: string; + kb_id: string; + }; +}; + +export type DeleteKnowledgeDocumentResponse = (SuccessEnvelope); + +export type DeleteKnowledgeDocumentError = unknown; + +export type ListKnowledgeChunksData = { + path: { + kb_id: string; + }; + query?: { + document_id?: string; + page?: number; + page_size?: number; + }; +}; + +export type ListKnowledgeChunksResponse = (SuccessEnvelope); + +export type ListKnowledgeChunksError = unknown; + +export type DeleteKnowledgeChunkData = { + path: { + chunk_id: string; + kb_id: string; + }; + query: { + document_id: string; + }; +}; + +export type DeleteKnowledgeChunkResponse = (SuccessEnvelope); + +export type DeleteKnowledgeChunkError = unknown; + +export type RetrieveKnowledgeBaseData = { + body: KnowledgeRetrieveRequest; + path: { + kb_id: string; + }; +}; + +export type RetrieveKnowledgeBaseResponse = (SuccessEnvelope); + +export type RetrieveKnowledgeBaseError = unknown; + +export type GetKnowledgeTaskData = { + path: { + task_id: string; + }; +}; + +export type GetKnowledgeTaskResponse = (SuccessEnvelope); + +export type GetKnowledgeTaskError = unknown; + +export type GetPersonaTreeResponse = (SuccessEnvelope); + +export type GetPersonaTreeError = unknown; + +export type ListPersonasData = { + query?: { + folder_id?: string; + }; +}; + +export type ListPersonasResponse = (SuccessEnvelope); + +export type ListPersonasError = unknown; + +export type CreatePersonaData = { + body: PersonaRequest; +}; + +export type CreatePersonaResponse = (SuccessEnvelope); + +export type CreatePersonaError = unknown; + +export type GetPersonaByIdData = { + query: { + persona_id: string; + }; +}; + +export type GetPersonaByIdResponse = (SuccessEnvelope); + +export type GetPersonaByIdError = unknown; + +export type UpdatePersonaByIdData = { + body: { + persona_id: string; + [key: string]: unknown | string; + }; +}; + +export type UpdatePersonaByIdResponse = (SuccessEnvelope); + +export type UpdatePersonaByIdError = unknown; + +export type DeletePersonaByIdData = { + query: { + persona_id: string; + }; +}; + +export type DeletePersonaByIdResponse = (SuccessEnvelope); + +export type DeletePersonaByIdError = unknown; + +export type GetPersonaData = { + path: { + persona_id: string; + }; +}; + +export type GetPersonaResponse = (SuccessEnvelope); + +export type GetPersonaError = unknown; + +export type UpdatePersonaData = { + body: PersonaRequest; + path: { + persona_id: string; + }; +}; + +export type UpdatePersonaResponse = (SuccessEnvelope); + +export type UpdatePersonaError = unknown; + +export type DeletePersonaData = { + path: { + persona_id: string; + }; +}; + +export type DeletePersonaResponse = (SuccessEnvelope); + +export type DeletePersonaError = unknown; + +export type ListPersonaFoldersData = { + query?: { + parent_id?: string; + }; +}; + +export type ListPersonaFoldersResponse = (SuccessEnvelope); + +export type ListPersonaFoldersError = unknown; + +export type CreatePersonaFolderData = { + body: PersonaFolderRequest; +}; + +export type CreatePersonaFolderResponse = (SuccessEnvelope); + +export type CreatePersonaFolderError = unknown; + +export type UpdatePersonaFolderData = { + body: PersonaFolderRequest; + path: { + folder_id: string; + }; +}; + +export type UpdatePersonaFolderResponse = (SuccessEnvelope); + +export type UpdatePersonaFolderError = unknown; + +export type DeletePersonaFolderData = { + path: { + folder_id: string; + }; +}; + +export type DeletePersonaFolderResponse = (SuccessEnvelope); + +export type DeletePersonaFolderError = unknown; + +export type MovePersonaItemData = { + body: PersonaMoveRequest; +}; + +export type MovePersonaItemResponse = (SuccessEnvelope); + +export type MovePersonaItemError = unknown; + +export type ReorderPersonaItemsData = { + body: ReorderRequest; +}; + +export type ReorderPersonaItemsResponse = (SuccessEnvelope); + +export type ReorderPersonaItemsError = unknown; + +export type ListSessionsData = { + query?: { + message_type?: 'all' | 'group' | 'private'; + page?: number; + page_size?: number; + platform?: string; + search?: string; + }; +}; + +export type ListSessionsResponse = (SuccessEnvelope); + +export type ListSessionsError = unknown; + +export type ListActiveUmosResponse = (SuccessEnvelope); + +export type ListActiveUmosError = unknown; + +export type ListSessionRulesData = { + query?: { + page?: number; + page_size?: number; + search?: string; + }; +}; + +export type ListSessionRulesResponse = (SuccessEnvelope); + +export type ListSessionRulesError = unknown; + +export type UpsertSessionRuleData = { + body: SessionRuleRequest; +}; + +export type UpsertSessionRuleResponse = (SuccessEnvelope); + +export type UpsertSessionRuleError = unknown; + +export type DeleteSessionRulesData = { + body: UmoListRequest; +}; + +export type DeleteSessionRulesResponse = (SuccessEnvelope); + +export type DeleteSessionRulesError = unknown; + +export type BatchUpdateSessionProviderData = { + body: BatchSessionProviderRequest; +}; + +export type BatchUpdateSessionProviderResponse = (SuccessEnvelope); + +export type BatchUpdateSessionProviderError = unknown; + +export type BatchUpdateSessionServiceData = { + body: BatchSessionServiceRequest; +}; + +export type BatchUpdateSessionServiceResponse = (SuccessEnvelope); + +export type BatchUpdateSessionServiceError = unknown; + +export type ListSessionGroupsResponse = (SuccessEnvelope); + +export type ListSessionGroupsError = unknown; + +export type CreateSessionGroupData = { + body: SessionGroupRequest; +}; + +export type CreateSessionGroupResponse = (SuccessEnvelope); + +export type CreateSessionGroupError = unknown; + +export type UpdateSessionGroupData = { + body: SessionGroupRequest; + path: { + group_id: string; + }; +}; + +export type UpdateSessionGroupResponse = (SuccessEnvelope); + +export type UpdateSessionGroupError = unknown; + +export type DeleteSessionGroupData = { + path: { + group_id: string; + }; +}; + +export type DeleteSessionGroupResponse = (SuccessEnvelope); + +export type DeleteSessionGroupError = unknown; + +export type ListConversationsData = { + query?: { + /** + * Comma-separated user IDs to exclude. + */ + exclude_ids?: string; + /** + * Comma-separated platforms to exclude. + */ + exclude_platforms?: string; + /** + * Comma-separated message types. + */ + message_types?: string; + page?: number; + page_size?: number; + platform_id?: string; + /** + * Comma-separated platform IDs. + */ + platforms?: string; + search?: string; + user_id?: string; + }; +}; + +export type ListConversationsResponse = (SuccessEnvelope); + +export type ListConversationsError = unknown; + +export type BatchDeleteConversationsData = { + body: ConversationBatchDeleteRequest; +}; + +export type BatchDeleteConversationsResponse = (SuccessEnvelope); + +export type BatchDeleteConversationsError = unknown; + +export type GetConversationData = { + path: { + conversation_id: string; + }; + query: { + user_id: string; + }; +}; + +export type GetConversationResponse = (SuccessEnvelope); + +export type GetConversationError = unknown; + +export type UpdateConversationData = { + body: ConversationPatchRequest; + path: { + conversation_id: string; + }; + query: { + user_id: string; + }; +}; + +export type UpdateConversationResponse = (SuccessEnvelope); + +export type UpdateConversationError = unknown; + +export type DeleteConversationData = { + path: { + conversation_id: string; + }; + query: { + user_id: string; + }; +}; + +export type DeleteConversationResponse = (SuccessEnvelope); + +export type DeleteConversationError = unknown; + +export type ReplaceConversationMessagesData = { + body: ConversationMessagesReplaceRequest; + path: { + conversation_id: string; + }; + query: { + user_id: string; + }; +}; + +export type ReplaceConversationMessagesResponse = (SuccessEnvelope); + +export type ReplaceConversationMessagesError = unknown; + +export type ExportConversationsData = { + body: ConversationExportRequest; +}; + +export type ExportConversationsResponse = (unknown); + +export type ExportConversationsError = unknown; + +export type GetStatsData = { + query?: { + offset_sec?: number; + }; +}; + +export type GetStatsResponse = (SuccessEnvelope); + +export type GetStatsError = unknown; + +export type GetProviderTokenStatsData = { + query?: { + days?: number; + }; +}; + +export type GetProviderTokenStatsResponse = (SuccessEnvelope); + +export type GetProviderTokenStatsError = unknown; + +export type GetVersionResponse = (SuccessEnvelope); + +export type GetVersionError = unknown; + +export type GetFirstNoticeData = { + query?: { + locale?: string; + }; +}; + +export type GetFirstNoticeResponse = (SuccessEnvelope); + +export type GetFirstNoticeError = unknown; + +export type TestGhproxyConnectionData = { + body: GhproxyTestRequest; +}; + +export type TestGhproxyConnectionResponse = (SuccessEnvelope); + +export type TestGhproxyConnectionError = unknown; + +export type ListChangelogVersionsResponse = (SuccessEnvelope); + +export type ListChangelogVersionsError = unknown; + +export type GetChangelogData = { + path: { + version: string; + }; +}; + +export type GetChangelogResponse = (SuccessEnvelope); + +export type GetChangelogError = (ErrorEnvelope); + +export type GetStartTimeResponse = (SuccessEnvelope); + +export type GetStartTimeError = unknown; + +export type GetStorageStatusResponse = (SuccessEnvelope); + +export type GetStorageStatusError = unknown; + +export type CleanupStorageData = { + body?: DynamicConfig; +}; + +export type CleanupStorageResponse = (SuccessEnvelope); + +export type CleanupStorageError = unknown; + +export type RestartCoreResponse = (SuccessEnvelope); + +export type RestartCoreError = unknown; + +export type ListBackupsData = { + query?: { + page?: number; + page_size?: number; + }; +}; + +export type ListBackupsResponse = (SuccessEnvelope); + +export type ListBackupsError = unknown; + +export type CreateBackupData = { + body?: BackupExportRequest; +}; + +export type CreateBackupResponse = (SuccessEnvelope); + +export type CreateBackupError = unknown; + +export type UploadBackupData = { + body: BackupUploadRequest; +}; + +export type UploadBackupResponse = (SuccessEnvelope); + +export type UploadBackupError = unknown; + +export type InitBackupUploadData = { + body: BackupUploadInitRequest; +}; + +export type InitBackupUploadResponse = (SuccessEnvelope); + +export type InitBackupUploadError = unknown; + +export type UploadBackupChunkData = { + body: BackupChunkUploadRequest; +}; + +export type UploadBackupChunkResponse = (SuccessEnvelope); + +export type UploadBackupChunkError = unknown; + +export type CompleteBackupUploadData = { + body: BackupUploadSessionRequest; +}; + +export type CompleteBackupUploadResponse = (SuccessEnvelope); + +export type CompleteBackupUploadError = unknown; + +export type AbortBackupUploadData = { + body: BackupUploadSessionRequest; +}; + +export type AbortBackupUploadResponse = (SuccessEnvelope); + +export type AbortBackupUploadError = unknown; + +export type GetBackupProgressData = { + path: { + task_id: string; + }; +}; + +export type GetBackupProgressResponse = (SuccessEnvelope); + +export type GetBackupProgressError = unknown; + +export type DownloadBackupData = { + path: { + filename: string; + }; +}; + +export type DownloadBackupResponse = (unknown); + +export type DownloadBackupError = unknown; + +export type RenameBackupData = { + body: BackupRenameRequest; + path: { + filename: string; + }; +}; + +export type RenameBackupResponse = (SuccessEnvelope); + +export type RenameBackupError = unknown; + +export type DeleteBackupData = { + path: { + filename: string; + }; +}; + +export type DeleteBackupResponse = (SuccessEnvelope); + +export type DeleteBackupError = unknown; + +export type CheckBackupData = { + path: { + filename: string; + }; +}; + +export type CheckBackupResponse = (SuccessEnvelope); + +export type CheckBackupError = unknown; + +export type ImportBackupData = { + body?: BackupImportRequest; + path: { + filename: string; + }; +}; + +export type ImportBackupResponse = (SuccessEnvelope); + +export type ImportBackupError = unknown; + +export type CheckUpdateResponse = (SuccessEnvelope); + +export type CheckUpdateError = unknown; + +export type ListReleasesData = { + query?: { + type?: 'core' | 'dashboard'; + }; +}; + +export type ListReleasesResponse = (SuccessEnvelope); + +export type ListReleasesError = unknown; + +export type UpdateCoreData = { + body?: UpdateRequest; +}; + +export type UpdateCoreResponse = (SuccessEnvelope); + +export type UpdateCoreError = unknown; + +export type UpdateDashboardData = { + body?: UpdateRequest; +}; + +export type UpdateDashboardResponse = (SuccessEnvelope); + +export type UpdateDashboardError = unknown; + +export type GetUpdateProgressData = { + path: { + task_id: string; + }; +}; + +export type GetUpdateProgressResponse = (SuccessEnvelope); + +export type GetUpdateProgressError = unknown; + +export type InstallPipPackageData = { + body: PipInstallRequest; +}; + +export type InstallPipPackageResponse = (SuccessEnvelope); + +export type InstallPipPackageError = unknown; + +export type RunMigrationsData = { + body?: MigrationRequest; +}; + +export type RunMigrationsResponse = (SuccessEnvelope); + +export type RunMigrationsError = unknown; + +export type ListCronJobsData = { + query?: { + type?: string; + }; +}; + +export type ListCronJobsResponse = (SuccessEnvelope); + +export type ListCronJobsError = unknown; + +export type CreateCronJobData = { + body: CronJobRequest; +}; + +export type CreateCronJobResponse = (SuccessEnvelope); + +export type CreateCronJobError = unknown; + +export type UpdateCronJobData = { + body: CronJobPatchRequest; + path: { + job_id: string; + }; +}; + +export type UpdateCronJobResponse = (SuccessEnvelope); + +export type UpdateCronJobError = unknown; + +export type DeleteCronJobData = { + path: { + job_id: string; + }; +}; + +export type DeleteCronJobResponse = (SuccessEnvelope); + +export type DeleteCronJobError = unknown; + +export type RunCronJobData = { + path: { + job_id: string; + }; +}; + +export type RunCronJobResponse = (SuccessEnvelope); + +export type RunCronJobError = unknown; + +export type StreamLiveLogsResponse = (string); + +export type StreamLiveLogsError = unknown; + +export type GetLogHistoryResponse = (string); + +export type GetLogHistoryError = unknown; + +export type GetTraceSettingsResponse = (SuccessEnvelope); + +export type GetTraceSettingsError = unknown; + +export type UpdateTraceSettingsData = { + body: TraceSettingsRequest; +}; + +export type UpdateTraceSettingsResponse = (SuccessEnvelope); + +export type UpdateTraceSettingsError = unknown; + +export type ListT2iTemplatesResponse = (SuccessEnvelope); + +export type ListT2iTemplatesError = unknown; + +export type CreateT2iTemplateData = { + body: T2iTemplateRequest; +}; + +export type CreateT2iTemplateResponse = (SuccessEnvelope); + +export type CreateT2iTemplateError = unknown; + +export type GetActiveT2iTemplateResponse = (SuccessEnvelope); + +export type GetActiveT2iTemplateError = unknown; + +export type SetActiveT2iTemplateData = { + body: NameRequest; +}; + +export type SetActiveT2iTemplateResponse = (SuccessEnvelope); + +export type SetActiveT2iTemplateError = unknown; + +export type ResetDefaultT2iTemplateResponse = (SuccessEnvelope); + +export type ResetDefaultT2iTemplateError = unknown; + +export type GetT2iTemplateData = { + path: { + name: string; + }; +}; + +export type GetT2iTemplateResponse = (SuccessEnvelope); + +export type GetT2iTemplateError = unknown; + +export type UpdateT2iTemplateData = { + body: T2iTemplateContentRequest; + path: { + name: string; + }; +}; + +export type UpdateT2iTemplateResponse = (SuccessEnvelope); + +export type UpdateT2iTemplateError = unknown; + +export type DeleteT2iTemplateData = { + path: { + name: string; + }; +}; + +export type DeleteT2iTemplateResponse = (SuccessEnvelope); + +export type DeleteT2iTemplateError = unknown; + +export type GetSubagentConfigResponse = (SuccessEnvelope); + +export type GetSubagentConfigError = unknown; + +export type UpdateSubagentConfigData = { + body: DynamicConfig; +}; + +export type UpdateSubagentConfigResponse = (SuccessEnvelope); + +export type UpdateSubagentConfigError = unknown; + +export type ListSubagentAvailableToolsResponse = (SuccessEnvelope); + +export type ListSubagentAvailableToolsError = unknown; + +export type VerifyPlatformWebhookData = { + path: { + webhook_uuid: string; + }; +}; + +export type VerifyPlatformWebhookResponse = (unknown); + +export type VerifyPlatformWebhookError = unknown; + +export type ReceivePlatformWebhookData = { + body?: DynamicConfig; + path: { + webhook_uuid: string; + }; +}; + +export type ReceivePlatformWebhookResponse = (unknown); + +export type ReceivePlatformWebhookError = unknown; \ No newline at end of file diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 77856d7b3e..0930c6f77b 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -1,7 +1,8 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import * as openApiV1 from './generated/openapi-v1'; import { - openApiV1, + client as openApiV1Client, type BackupExportRequest, type BackupRenameRequest, type BackupUploadInitRequest, @@ -30,7 +31,7 @@ import { type EnabledPatch, type GhproxyTestRequest, type LoginRequest, - type ListConversationsQuery, + type ListConversationsData, type McpServerConfig, type MigrationRequest, type ModelScopeSyncRequest, @@ -51,7 +52,12 @@ import { type UpdateAccountRequest, type UpdateRequest, } from './generated/openapi-v1'; -import { apiV1Client } from './http'; +import { apiV1Client, httpClient } from './http'; + +openApiV1Client.setConfig({ + axios: httpClient, + throwOnError: true, +}); export interface ApiEnvelope { status: 'ok' | 'error'; @@ -164,13 +170,29 @@ const PROVIDER_TYPE_TO_CAPABILITY: Record = { }; type V1Response = Promise>>; +type ListConversationsQuery = NonNullable; -function typed( - response: Promise>, -): V1Response { +function typed(response: Promise): V1Response { return response as unknown as V1Response; } +function generatedOptions( + options: Record, + requestConfig?: AxiosRequestConfig, +) { + return { ...options, ...(requestConfig || {}) } as any; +} + +function generatedQuery( + params?: T, +): (T & Record) | undefined { + return params as (T & Record) | undefined; +} + +function generatedFormData(formData: FormData) { + return formData as any; +} + function botConfig(config: OpenConfig): BotConfigRequest { return { id: typeof config.id === 'string' ? config.id : undefined, @@ -234,10 +256,15 @@ export const configProfileApi = { requestConfig?: AxiosRequestConfig, ) { return typed( - openApiV1.updateConfigProfileContent({ - path: { config_id: configId }, - body: config, - }, requestConfig), + openApiV1.updateConfigProfileContent( + generatedOptions( + { + path: { config_id: configId }, + body: config, + }, + requestConfig, + ), + ), ); }, rename(configId: string, name: string | null) { @@ -267,7 +294,9 @@ export const systemConfigApi = { }, update(config: OpenConfig, requestConfig?: AxiosRequestConfig) { return typed( - openApiV1.updateSystemConfig({ body: config }, requestConfig), + openApiV1.updateSystemConfig( + generatedOptions({ body: config }, requestConfig), + ), ); }, }; @@ -297,7 +326,7 @@ export const botApi = { }, list(params?: BotListParams) { return typed<{ bots: OpenConfig[] }>( - openApiV1.listBots({ query: params }), + openApiV1.listBots({ query: generatedQuery(params) }), ); }, stats() { @@ -370,7 +399,7 @@ export const providerApi = { }, list(params?: ProviderListParams) { return typed<{ providers: OpenConfig[] }>( - openApiV1.listProviders({ query: params }), + openApiV1.listProviders({ query: generatedQuery(params) }), ); }, async listByProviderType(providerType: string): Promise>> { @@ -552,7 +581,7 @@ export const updatesApi = { export const backupApi = { list(params?: BackupListParams) { - return typed(openApiV1.listBackups({ query: params })); + return typed(openApiV1.listBackups({ query: generatedQuery(params) })); }, create(payload?: BackupExportRequest) { return typed(openApiV1.createBackup({ body: payload })); @@ -563,13 +592,17 @@ export const backupApi = { ); }, upload(formData: FormData) { - return typed(openApiV1.uploadBackup({ body: formData })); + return typed( + openApiV1.uploadBackup({ body: generatedFormData(formData) }), + ); }, initUpload(payload: BackupUploadInitRequest) { return typed(openApiV1.initBackupUpload({ body: payload })); }, uploadChunk(formData: FormData) { - return typed(openApiV1.uploadBackupChunk({ body: formData })); + return typed( + openApiV1.uploadBackupChunk({ body: generatedFormData(formData) }), + ); }, completeUpload(payload: BackupUploadSessionRequest) { return typed(openApiV1.completeBackupUpload({ body: payload })); @@ -621,7 +654,9 @@ export const chatApi = { return `${protocol}//${host}/api/v1/unified-chat/ws?token=${encodeURIComponent(token)}`; }, listSessions(params?: ChatSessionListParams) { - return typed(openApiV1.listChatSessions({ query: params })); + return typed( + openApiV1.listChatSessions({ query: generatedQuery(params) }), + ); }, createSession(platformId?: string) { return typed( @@ -752,13 +787,15 @@ export const chatApi = { export const fileApi = { upload(formData: FormData) { - return typed(openApiV1.uploadFile({ body: formData })); + return typed( + openApiV1.uploadFile({ body: generatedFormData(formData) }), + ); }, getByName(filename: string) { - return openApiV1.getFileByName( - { query: { filename } }, - { responseType: 'blob' }, - ) as Promise>; + return openApiV1.getFileByName({ + query: { filename }, + responseType: 'blob', + }) as Promise>; }, byNameUrl(filename: string) { return `/api/v1/files/content?filename=${encodeURIComponent(filename)}`; @@ -773,13 +810,15 @@ export const fileApi = { export const sessionApi = { list(params?: SessionListParams) { - return typed(openApiV1.listSessions({ query: params })); + return typed(openApiV1.listSessions({ query: generatedQuery(params) })); }, activeUmos() { return typed(openApiV1.listActiveUmos()); }, listRules(params?: SessionRuleListParams) { - return typed(openApiV1.listSessionRules({ query: params })); + return typed( + openApiV1.listSessionRules({ query: generatedQuery(params) }), + ); }, upsertRule(payload: SessionRuleRequest) { return typed(openApiV1.upsertSessionRule({ body: payload })); @@ -820,7 +859,7 @@ export const sessionApi = { export const cronApi = { list(params?: CronJobListParams) { - return typed(openApiV1.listCronJobs({ query: params })); + return typed(openApiV1.listCronJobs({ query: generatedQuery(params) })); }, create(payload: CronJobRequest) { return typed(openApiV1.createCronJob({ body: payload })); @@ -875,7 +914,7 @@ export const commandApi = { export const toolApi = { list(params?: ToolListParams) { - return typed(openApiV1.listTools({ query: params })); + return typed(openApiV1.listTools({ query: generatedQuery(params) })); }, setEnabled(toolId: string, enabled: boolean) { return typed( @@ -887,8 +926,9 @@ export const toolApi = { }, setPermission(toolId: string, permission: 'admin' | 'member') { return typed( - apiV1Client.patch(`/tools/${encodeURIComponent(toolId)}/permission`, { - permission, + openApiV1.setToolPermission({ + path: { tool_id: toolId }, + body: { permission }, }), ); }, @@ -1077,7 +1117,7 @@ export const pluginApi = { return typed( openApiV1.uploadPluginConfigFilesById({ query: { plugin_id: pluginId, config_key: configKey }, - body: formData, + body: generatedFormData(formData), }), ); }, @@ -1120,7 +1160,9 @@ export const pluginApi = { }, installUpload(formData: FormData) { return typed( - openApiV1.installPluginFromUpload({ body: formData }), + openApiV1.installPluginFromUpload({ + body: generatedFormData(formData), + }), ); }, installGithub(body: OpenConfig) { @@ -1221,7 +1263,7 @@ export const knowledgeApi = { return typed( openApiV1.uploadKnowledgeDocument({ path: { kb_id: kbId }, - body: formData, + body: generatedFormData(formData), }), ); }, @@ -1286,7 +1328,9 @@ export const skillApi = { return typed(openApiV1.listSkills({ query: params })); }, uploadBatch(formData: FormData) { - return typed(openApiV1.uploadSkillsBatch({ body: formData })); + return typed( + openApiV1.uploadSkillsBatch({ body: generatedFormData(formData) }), + ); }, setEnabled(skillName: string, enabled: boolean) { return typed( @@ -1301,10 +1345,10 @@ export const skillApi = { ); }, download(skillName: string) { - return openApiV1.downloadSkillByName( - { query: { skill_name: skillName } }, - { responseType: 'blob' }, - ); + return openApiV1.downloadSkillByName({ + query: { skill_name: skillName }, + responseType: 'blob', + }); }, listFiles(skillName: string, path = '') { return typed( @@ -1447,7 +1491,12 @@ export const personaApi = { export const conversationApi = { list(params?: ListConversationsQuery, requestConfig?: AxiosRequestConfig) { return typed( - openApiV1.listConversations({ query: params }, requestConfig), + openApiV1.listConversations( + generatedOptions( + { query: generatedQuery(params) }, + requestConfig, + ), + ), ); }, get(userId: string, cid: string) { @@ -1492,10 +1541,10 @@ export const conversationApi = { return typed(openApiV1.batchDeleteConversations({ body: payload })); }, export(payload: ConversationExportRequest) { - return openApiV1.exportConversations( - { body: payload }, - { responseType: 'blob' }, - ) as Promise>; + return openApiV1.exportConversations({ + body: payload, + responseType: 'blob', + }) as Promise>; }, }; diff --git a/docs/scripts/update_openapi_json.py b/docs/scripts/update_openapi_json.py new file mode 100644 index 0000000000..ded32a7df1 --- /dev/null +++ b/docs/scripts/update_openapi_json.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_SPEC = REPO_ROOT / "openspec" / "openapi-v1.yaml" +DEFAULT_OUTPUT = REPO_ROOT / "docs" / "public" / "openapi.json" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Update the public OpenAPI JSON document from the v1 YAML spec." + ) + parser.add_argument( + "--spec", + type=Path, + default=DEFAULT_SPEC, + help=f"OpenAPI YAML source path. Default: {DEFAULT_SPEC}", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"OpenAPI JSON output path. Default: {DEFAULT_OUTPUT}", + ) + return parser.parse_args() + + +def load_yaml(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Expected OpenAPI object in {path}") + return data + + +def main() -> int: + args = parse_args() + spec_path = args.spec.resolve() + output_path = args.output.resolve() + + spec = load_yaml(spec_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(spec, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print( + f"Updated {output_path.relative_to(REPO_ROOT)} from {spec_path.relative_to(REPO_ROOT)}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 961d27f3e677d306ea7036b5e5810e52bfed2d1b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 17:25:10 +0800 Subject: [PATCH 06/29] fix --- astrbot/dashboard/api/plugins.py | 6 +- tests/test_cli_service.py | 319 ------------------------------- 2 files changed, 5 insertions(+), 320 deletions(-) delete mode 100644 tests/test_cli_service.py diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index e1be867903..88b8dda6ea 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -121,7 +121,11 @@ async def _run_service(operation, *, log_label: str | None = None): logger.error("%s failed", log_label, exc_info=True) else: logger.error("Plugin service operation failed", exc_info=True) - return {"status": "error", "message": PLUGIN_OPERATION_FAILED_MESSAGE, "data": {}} + return { + "status": "error", + "message": PLUGIN_OPERATION_FAILED_MESSAGE, + "data": {}, + } async def _run_json( diff --git a/tests/test_cli_service.py b/tests/test_cli_service.py deleted file mode 100644 index 166becd26b..0000000000 --- a/tests/test_cli_service.py +++ /dev/null @@ -1,319 +0,0 @@ -import json -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from threading import Thread - -from click.testing import CliRunner - -from astrbot.cli.__main__ import cli -from astrbot.cli.commands import cmd_service -from astrbot.cli.commands.cmd_service import ( - ServiceState, - WebUIStatus, - _build_launchd_plist, - _build_systemd_unit, - _build_windows_task_xml, - _check_webui, - _get_app_log_config, - _health_label, - _load_dashboard_port, - _load_or_init_config, - service, -) - - -class _HealthyHandler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b"ok") - - def log_message(self, *_args): - return - - -def test_service_command_is_registered(): - result = CliRunner().invoke(cli, ["help", "service"]) - - assert result.exit_code == 0 - assert "install" in result.output - assert "logs" in result.output - assert "restart" in result.output - assert "status" in result.output - assert "start" in result.output - assert "stop" in result.output - assert "uninstall" in result.output - - -def test_service_logs_group_exposes_log_file_controls(): - result = CliRunner().invoke(service, ["logs", "--help"]) - - assert result.exit_code == 0 - assert "enable" in result.output - assert "disable" in result.output - assert "status" in result.output - - -def test_service_install_requires_initialized_root(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - - result = CliRunner().invoke(service, ["install", "--executable", "astrbot"]) - - assert result.exit_code == 1 - assert "Use 'astrbot init' before installing the service" in result.output - - -def test_systemd_unit_uses_astrbot_executable_and_working_directory(): - unit = _build_systemd_unit( - "astrbot", - Path("/home/astrbot/.local/bin/astrbot"), - Path("/home/astrbot/AstrBot Root"), - ) - - assert 'WorkingDirectory="/home/astrbot/AstrBot Root"' in unit - assert "ExecStart=/home/astrbot/.local/bin/astrbot run" in unit - assert "Environment=PYTHONUNBUFFERED=1" in unit - - -def test_launchd_plist_uses_astrbot_executable_and_working_directory(): - plist = _build_launchd_plist( - "astrbot", - Path("/Users/astrbot/.local/bin/astrbot"), - Path("/Users/astrbot/AstrBot"), - Path("/Users/astrbot/Library/Logs/AstrBot"), - ) - - assert plist["Label"] == "app.astrbot.astrbot" - assert plist["ProgramArguments"] == ["/Users/astrbot/.local/bin/astrbot", "run"] - assert plist["WorkingDirectory"] == "/Users/astrbot/AstrBot" - assert plist["EnvironmentVariables"] == {"PYTHONUNBUFFERED": "1"} - - -def test_launch_agent_start_waits_until_loaded_before_kickstart(monkeypatch, tmp_path): - plist_path = tmp_path / "app.astrbot.astrbot.plist" - plist_path.touch() - events = [] - loaded_states = [False, False, True] - - monkeypatch.setattr(cmd_service.shutil, "which", lambda name: "/bin/launchctl") - monkeypatch.setattr(cmd_service, "_launch_agent_path", lambda _name: plist_path) - monkeypatch.setattr(cmd_service.time, "sleep", lambda _seconds: None) - - def fake_run_capture(command): - if command[1] == "print": - events.append("print") - loaded = loaded_states.pop(0) if loaded_states else True - return cmd_service.subprocess.CompletedProcess( - command, - 0 if loaded else 113, - stdout="", - stderr="not loaded", - ) - if command[1] == "kickstart": - events.append("kickstart") - return cmd_service.subprocess.CompletedProcess(command, 0) - raise AssertionError(f"Unexpected capture command: {command}") - - def fake_run_checked(command, _failure_message): - events.append(command[1]) - - monkeypatch.setattr(cmd_service, "_run_capture", fake_run_capture) - monkeypatch.setattr(cmd_service, "_run_checked", fake_run_checked) - - cmd_service._start_launch_agent("astrbot") - - assert "bootstrap" in events - assert "enable" in events - assert "kickstart" in events - assert events.index("bootstrap") < events.index("kickstart") - - -def test_windows_task_xml_uses_astrbot_executable_and_working_directory(): - task_xml = _build_windows_task_xml( - "astrbot", - Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"), - Path("C:\\Users\\astrbot\\AstrBot"), - ).decode("utf-16") - - assert "cmd.exe" in task_xml - assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in task_xml - assert "run" in task_xml - assert "astrbot.out.log" in task_xml - assert "astrbot.err.log" in task_xml - assert "C:\\Users\\astrbot\\AstrBot" in task_xml - - -def test_load_dashboard_port_reads_cmd_config(tmp_path): - config_path = tmp_path / "data" / "cmd_config.json" - config_path.parent.mkdir() - config_path.write_text( - json.dumps({"dashboard": {"port": 7788}}), - encoding="utf-8-sig", - ) - - dashboard_port = _load_dashboard_port(tmp_path) - - assert dashboard_port.port == 7788 - assert dashboard_port.detail is None - - -def test_check_webui_reports_accessible_http_response(): - server = ThreadingHTTPServer(("127.0.0.1", 0), _HealthyHandler) - thread = Thread(target=server.serve_forever, daemon=True) - thread.start() - - try: - webui_status = _check_webui(server.server_port, timeout=1.0) - finally: - server.shutdown() - server.server_close() - thread.join(timeout=1) - - assert webui_status.accessible is True - assert webui_status.status_code == 200 - - -def test_health_label_requires_service_and_webui(): - active = ServiceState(manager="systemd --user", installed=True, state="active") - inactive = ServiceState(manager="systemd --user", installed=True, state="inactive") - reachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=True) - unreachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=False) - - assert _health_label(active, reachable) == "healthy" - assert _health_label(active, unreachable) == "degraded" - assert _health_label(inactive, reachable) == "degraded" - assert _health_label(inactive, unreachable) == "unhealthy" - - -def test_service_status_reports_port_and_webui_health(monkeypatch, tmp_path): - (tmp_path / ".astrbot").touch() - config_path = tmp_path / "data" / "cmd_config.json" - config_path.parent.mkdir() - config_path.write_text( - json.dumps({"dashboard": {"port": 7788}}), - encoding="utf-8-sig", - ) - - monkeypatch.setattr( - cmd_service, - "_get_service_state", - lambda _name: ServiceState( - manager="systemd --user", - installed=True, - state="active", - ), - ) - monkeypatch.setattr( - cmd_service, - "_check_webui", - lambda port, _timeout: WebUIStatus( - url=f"http://127.0.0.1:{port}/", - accessible=True, - status_code=200, - detail="HTTP 200", - ), - ) - - result = CliRunner().invoke(service, ["status", "--workdir", str(tmp_path)]) - - assert result.exit_code == 0 - assert "Health: healthy" in result.output - assert "Dashboard port: 7788" in result.output - assert "WebUI accessible: yes" in result.output - assert "WebUI HTTP status: 200" in result.output - - -def test_service_start_dispatches_to_platform_control(monkeypatch): - calls = [] - monkeypatch.setattr( - cmd_service, - "_control_service", - lambda name, action: calls.append((name, action)), - ) - - result = CliRunner().invoke(service, ["start", "--name", "astrbot-test"]) - - assert result.exit_code == 0 - assert calls == [("astrbot-test", "start")] - assert "Started service: astrbot-test" in result.output - - -def test_service_uninstall_requires_confirmation(monkeypatch): - calls = [] - monkeypatch.setattr( - cmd_service, - "_uninstall_service", - lambda name: calls.append(name) or name, - ) - - result = CliRunner().invoke(service, ["uninstall"], input="n\n") - - assert result.exit_code == 1 - assert calls == [] - - -def test_service_logs_source_app_reads_application_log(monkeypatch, tmp_path): - (tmp_path / ".astrbot").touch() - log_path = tmp_path / "data" / "logs" / "astrbot.log" - log_path.parent.mkdir(parents=True) - log_path.write_text("first\nsecond\nthird\n", encoding="utf-8") - - result = CliRunner().invoke( - service, - ["logs", "--source", "app", "--workdir", str(tmp_path), "--lines", "2"], - ) - - assert result.exit_code == 0 - assert "first" not in result.output - assert "second" in result.output - assert "third" in result.output - - -def test_service_logs_hides_stderr_by_default(monkeypatch, tmp_path): - out_log = tmp_path / "astrbot.out.log" - err_log = tmp_path / "astrbot.err.log" - out_log.write_text("normal output\n", encoding="utf-8") - err_log.write_text("stderr output\n", encoding="utf-8") - - monkeypatch.setattr(cmd_service.platform, "system", lambda: "Darwin") - monkeypatch.setattr( - cmd_service, - "_service_log_paths", - lambda _name: (out_log, err_log), - ) - - default_result = CliRunner().invoke(service, ["logs", "--lines", "10"]) - stderr_result = CliRunner().invoke( - service, - ["logs", "--lines", "10", "--include-stderr"], - ) - - assert default_result.exit_code == 0 - assert "normal output" in default_result.output - assert "stderr output" not in default_result.output - assert stderr_result.exit_code == 0 - assert "normal output" in stderr_result.output - assert "stderr output" in stderr_result.output - - -def test_service_app_log_enable_updates_config(tmp_path): - (tmp_path / ".astrbot").touch() - - result = CliRunner().invoke( - service, - [ - "logs", - "enable", - "--workdir", - str(tmp_path), - "--path", - "logs/custom.log", - ], - ) - - assert result.exit_code == 0 - config = _load_or_init_config(tmp_path) - log_config = _get_app_log_config(tmp_path, config) - assert log_config.enabled is True - assert log_config.configured_path == "logs/custom.log" - assert log_config.path == tmp_path / "data" / "logs" / "custom.log" From d0be71db32a4f2cee042f054b76da0c57d11ceb0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 17:34:46 +0800 Subject: [PATCH 07/29] feat(auth): implement rate limiting for v1 login endpoint and enhance request handling --- astrbot/dashboard/server.py | 61 ++++++++++++++++++++++--------------- tests/test_dashboard.py | 40 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index b6cdabc237..f00511da40 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -39,7 +39,9 @@ { "/api/config/astrbot/update", "/api/auth/totp/setup", + "/api/v1/auth/totp/setup", "/api/auth/login", + "/api/v1/auth/login", } ) @@ -230,34 +232,12 @@ async def auth_middleware(self, current_request: Request): path = current_request.url.path if not path.startswith("/api"): return None + rate_limit_response = await self._apply_auth_rate_limit(current_request, path) + if rate_limit_response is not None: + return rate_limit_response if path.startswith("/api/v1"): return None - if ( - os.environ.get("ASTRBOT_TEST_MODE") != "true" - and path in _RATE_LIMITED_ENDPOINTS - ): - rl_config = self.config.get("dashboard", {}).get("auth_rate_limit", {}) - rl_enabled = rl_config.get("enable", True) - if rl_enabled: - average_interval = float(rl_config.get("average_interval", 1.0)) - max_burst = int(rl_config.get("max_burst", 3)) - if average_interval <= 0: - average_interval = 1.0 - if max_burst <= 0: - max_burst = 3 - refill_rate = 1.0 / average_interval - client_ip = self._get_request_client_ip(current_request) - limiter = self._rate_limiter_registry.get_or_create( - client_ip, capacity=max_burst, refill_rate=refill_rate - ) - if not await limiter.acquire(): - r = JSONResponse( - error("验证尝试过于频繁,系统可能正在遭受暴力破解") - ) - r.status_code = 429 - return r - allowed_exact_endpoints = { "/api/auth/login", "/api/auth/logout", @@ -308,6 +288,37 @@ async def auth_middleware(self, current_request: Request): r.status_code = 401 return r + async def _apply_auth_rate_limit( + self, + current_request: Request, + path: str, + ) -> JSONResponse | None: + if ( + os.environ.get("ASTRBOT_TEST_MODE") != "true" + and path in _RATE_LIMITED_ENDPOINTS + ): + rl_config = self.config.get("dashboard", {}).get("auth_rate_limit", {}) + rl_enabled = rl_config.get("enable", True) + if rl_enabled: + average_interval = float(rl_config.get("average_interval", 1.0)) + max_burst = int(rl_config.get("max_burst", 3)) + if average_interval <= 0: + average_interval = 1.0 + if max_burst <= 0: + max_burst = 3 + refill_rate = 1.0 / average_interval + client_ip = self._get_request_client_ip(current_request) + limiter = self._rate_limiter_registry.get_or_create( + client_ip, capacity=max_burst, refill_rate=refill_rate + ) + if not await limiter.acquire(): + r = JSONResponse( + error("验证尝试过于频繁,系统可能正在遭受暴力破解") + ) + r.status_code = 429 + return r + return None + def _get_request_client_ip(self, current_request) -> str: if bool(self.config.get("dashboard", {}).get("trust_proxy_headers", False)): forwarded_for = str( diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b3cb346f58..75f6471e98 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -453,6 +453,46 @@ async def test_auth_rate_limit_separates_different_client_ips( cfg["trust_proxy_headers"] = tp_original +@pytest.mark.asyncio +async def test_auth_rate_limit_applies_to_v1_login( + app: FastAPIAppAdapter, + core_lifecycle_td: AstrBotCoreLifecycle, + monkeypatch: pytest.MonkeyPatch, +): + """The v1 login endpoint uses the same token-bucket limiter as legacy login.""" + monkeypatch.setenv("ASTRBOT_TEST_MODE", "false") + app._dashboard_server._rate_limiter_registry.clear() + cfg = core_lifecycle_td.astrbot_config["dashboard"] + rl_original = cfg.get("auth_rate_limit", {}) + tp_original = cfg.get("trust_proxy_headers", False) + cfg["auth_rate_limit"] = { + "enable": True, + "average_interval": 3600.0, + "max_burst": 1, + } + cfg["trust_proxy_headers"] = True + + try: + client = app.test_client() + headers = {"X-Forwarded-For": "198.51.100.12"} + first = await client.post( + "/api/v1/auth/login", + json={"username": "u", "password": "p"}, + headers=headers, + ) + assert first.status_code != 429 + + second = await client.post( + "/api/v1/auth/login", + json={"username": "u", "password": "p"}, + headers=headers, + ) + assert second.status_code == 429, "v1 login should be rate limited" + finally: + cfg["auth_rate_limit"] = rl_original + cfg["trust_proxy_headers"] = tp_original + + @pytest.mark.asyncio async def test_auth_rate_limit_ignores_proxy_headers_by_default( app: FastAPIAppAdapter, From dc009ff15a25d95f624bd9e272fe7e201e537aab Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 18:15:13 +0800 Subject: [PATCH 08/29] Refactor dashboard API routers to use legacy_router for backward compatibility - Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime. - Updated route definitions to ensure existing endpoints remain functional under the new router structure. - Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins. - Added a test case to validate the functionality of the new Quart request context handling in plugin extensions. --- astrbot/dashboard/api/api_keys.py | 10 +- astrbot/dashboard/api/app.py | 101 ++++++++++---------- astrbot/dashboard/api/auth.py | 18 ++-- astrbot/dashboard/api/backups.py | 28 +++--- astrbot/dashboard/api/bots.py | 10 +- astrbot/dashboard/api/chat.py | 36 ++++---- astrbot/dashboard/api/chat_projects.py | 18 ++-- astrbot/dashboard/api/config_profiles.py | 34 +++---- astrbot/dashboard/api/conversations.py | 14 +-- astrbot/dashboard/api/cron.py | 12 +-- astrbot/dashboard/api/extensions.py | 12 +-- astrbot/dashboard/api/files.py | 4 +- astrbot/dashboard/api/knowledge_bases.py | 34 +++---- astrbot/dashboard/api/live_chat.py | 6 +- astrbot/dashboard/api/logs.py | 10 +- astrbot/dashboard/api/personas.py | 28 +++--- astrbot/dashboard/api/platform.py | 8 +- astrbot/dashboard/api/plugins.py | 62 +++++++------ astrbot/dashboard/api/providers.py | 24 ++--- astrbot/dashboard/api/sessions.py | 26 +++--- astrbot/dashboard/api/skills.py | 38 ++++---- astrbot/dashboard/api/stats.py | 24 ++--- astrbot/dashboard/api/subagents.py | 8 +- astrbot/dashboard/api/t2i.py | 18 ++-- astrbot/dashboard/api/tools.py | 20 ++-- astrbot/dashboard/api/updates.py | 16 ++-- astrbot/dashboard/asgi_runtime.py | 113 ++++++++++++++++++++++- tests/test_fastapi_v1_dashboard.py | 48 ++++++++++ 28 files changed, 471 insertions(+), 309 deletions(-) diff --git a/astrbot/dashboard/api/api_keys.py b/astrbot/dashboard/api/api_keys.py index ad795bd1cd..06b8e4bc90 100644 --- a/astrbot/dashboard/api/api_keys.py +++ b/astrbot/dashboard/api/api_keys.py @@ -12,7 +12,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["API Keys"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/apikey", tags=["Dashboard API Keys"], include_in_schema=False, @@ -112,7 +112,7 @@ async def delete_api_key( return await _delete_api_key(key_id, service) -@dashboard_router.get("/list") +@legacy_router.get("/list") async def list_dashboard_api_keys( _username: str = Depends(require_dashboard_user), service: ApiKeyService = Depends(get_service), @@ -120,7 +120,7 @@ async def list_dashboard_api_keys( return await _list_api_keys(service) -@dashboard_router.post("/create") +@legacy_router.post("/create") async def create_dashboard_api_key( payload: ApiKeyCreateRequest, username: str = Depends(require_dashboard_user), @@ -129,7 +129,7 @@ async def create_dashboard_api_key( return await _create_api_key(payload, created_by=username, service=service) -@dashboard_router.post("/revoke") +@legacy_router.post("/revoke") async def revoke_dashboard_api_key( payload: ApiKeyIdRequest, _username: str = Depends(require_dashboard_user), @@ -138,7 +138,7 @@ async def revoke_dashboard_api_key( return await _revoke_api_key(payload.key_id, service) -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def delete_dashboard_api_key( payload: ApiKeyIdRequest, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py index 660f333c9c..f0b35819a7 100644 --- a/astrbot/dashboard/api/app.py +++ b/astrbot/dashboard/api/app.py @@ -52,33 +52,33 @@ call_pip_install, ) -from .api_keys import dashboard_router as dashboard_api_keys_router -from .auth import dashboard_router as dashboard_auth_router -from .backups import dashboard_router as dashboard_backups_router -from .bots import dashboard_router as dashboard_bots_router -from .chat import dashboard_router as dashboard_chat_router -from .chat_projects import dashboard_router as dashboard_chat_projects_router -from .config_profiles import dashboard_router as dashboard_config_profiles_router -from .conversations import dashboard_router as dashboard_conversations_router -from .cron import dashboard_router as dashboard_cron_router -from .extensions import dashboard_router as dashboard_extensions_router -from .files import dashboard_router as dashboard_files_router -from .knowledge_bases import dashboard_router as dashboard_knowledge_bases_router -from .live_chat import dashboard_router as dashboard_live_chat_router -from .logs import dashboard_router as dashboard_logs_router -from .personas import dashboard_router as dashboard_personas_router -from .platform import dashboard_router as dashboard_platform_router -from .plugins import dashboard_router as dashboard_plugins_router -from .providers import dashboard_router as dashboard_providers_router +from .api_keys import legacy_router as legacy_api_keys_router +from .auth import legacy_router as legacy_auth_router +from .backups import legacy_router as legacy_backups_router +from .bots import legacy_router as legacy_bots_router +from .chat import legacy_router as legacy_chat_router +from .chat_projects import legacy_router as legacy_chat_projects_router +from .config_profiles import legacy_router as legacy_config_profiles_router +from .conversations import legacy_router as legacy_conversations_router +from .cron import legacy_router as legacy_cron_router +from .extensions import legacy_router as legacy_extensions_router +from .files import legacy_router as legacy_files_router +from .knowledge_bases import legacy_router as legacy_knowledge_bases_router +from .live_chat import legacy_router as legacy_live_chat_router +from .logs import legacy_router as legacy_logs_router +from .personas import legacy_router as legacy_personas_router +from .platform import legacy_router as legacy_platform_router +from .plugins import legacy_router as legacy_plugins_router +from .providers import legacy_router as legacy_providers_router from .router import API_V1_PREFIX, build_api_router -from .sessions import dashboard_router as dashboard_sessions_router -from .skills import dashboard_router as dashboard_skills_router +from .sessions import legacy_router as legacy_sessions_router +from .skills import legacy_router as legacy_skills_router from .static_files import router as static_files_router -from .stats import dashboard_router as dashboard_stats_router -from .subagents import dashboard_router as dashboard_subagents_router -from .t2i import dashboard_router as dashboard_t2i_router -from .tools import dashboard_router as dashboard_tools_router -from .updates import dashboard_router as dashboard_updates_router +from .stats import legacy_router as legacy_stats_router +from .subagents import legacy_router as legacy_subagents_router +from .t2i import legacy_router as legacy_t2i_router +from .tools import legacy_router as legacy_tools_router +from .updates import legacy_router as legacy_updates_router CLEAR_SITE_DATA_HEADERS = {"Clear-Site-Data": '"cache"'} @@ -164,31 +164,32 @@ async def http_error_handler(_request: Request, exc: HTTPException): detail = exc.detail if isinstance(exc.detail, str) else "Request failed" return JSONResponse(error(detail), status_code=exc.status_code) - app.include_router(dashboard_api_keys_router) - app.include_router(dashboard_auth_router) - app.include_router(dashboard_backups_router) - app.include_router(dashboard_config_profiles_router) - app.include_router(dashboard_bots_router) - app.include_router(dashboard_providers_router) - app.include_router(dashboard_chat_router) - app.include_router(dashboard_chat_projects_router) - app.include_router(dashboard_conversations_router) - app.include_router(dashboard_cron_router) - app.include_router(dashboard_extensions_router) - app.include_router(dashboard_files_router) - app.include_router(dashboard_knowledge_bases_router) - app.include_router(dashboard_live_chat_router) - app.include_router(dashboard_logs_router) - app.include_router(dashboard_sessions_router) - app.include_router(dashboard_skills_router) - app.include_router(dashboard_stats_router) - app.include_router(dashboard_subagents_router) - app.include_router(dashboard_tools_router) - app.include_router(dashboard_platform_router) - app.include_router(dashboard_plugins_router) - app.include_router(dashboard_t2i_router) - app.include_router(dashboard_personas_router) - app.include_router(dashboard_updates_router) + # Legacy dashboard routes keep old /api/* callers working without entering OpenAPI. + app.include_router(legacy_api_keys_router) + app.include_router(legacy_auth_router) + app.include_router(legacy_backups_router) + app.include_router(legacy_config_profiles_router) + app.include_router(legacy_bots_router) + app.include_router(legacy_providers_router) + app.include_router(legacy_chat_router) + app.include_router(legacy_chat_projects_router) + app.include_router(legacy_conversations_router) + app.include_router(legacy_cron_router) + app.include_router(legacy_extensions_router) + app.include_router(legacy_files_router) + app.include_router(legacy_knowledge_bases_router) + app.include_router(legacy_live_chat_router) + app.include_router(legacy_logs_router) + app.include_router(legacy_sessions_router) + app.include_router(legacy_skills_router) + app.include_router(legacy_stats_router) + app.include_router(legacy_subagents_router) + app.include_router(legacy_tools_router) + app.include_router(legacy_platform_router) + app.include_router(legacy_plugins_router) + app.include_router(legacy_t2i_router) + app.include_router(legacy_personas_router) + app.include_router(legacy_updates_router) app.include_router(build_api_router()) app.include_router(static_files_router) return app diff --git a/astrbot/dashboard/api/auth.py b/astrbot/dashboard/api/auth.py index 6c7bfc987f..d79134e9ee 100644 --- a/astrbot/dashboard/api/auth.py +++ b/astrbot/dashboard/api/auth.py @@ -25,7 +25,7 @@ ) router = APIRouter(tags=["Auth"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/auth", tags=["Dashboard Auth"], include_in_schema=False, @@ -357,7 +357,7 @@ async def login( return await _login(request, payload, service) -@dashboard_router.post("/login") +@legacy_router.post("/login") async def dashboard_login( request: Request, payload: LoginRequest, @@ -376,7 +376,7 @@ async def logout(request: Request): return response -@dashboard_router.post("/logout") +@legacy_router.post("/logout") async def dashboard_logout(request: Request): return await logout(request) @@ -388,7 +388,7 @@ async def setup_status( return _auth_service_response_from_result(await service.setup_status()) -@dashboard_router.get("/setup-status") +@legacy_router.get("/setup-status") async def dashboard_setup_status( service: AuthService = Depends(get_auth_service), ): @@ -405,7 +405,7 @@ async def setup( return await _setup(request, payload, service, auth) -@dashboard_router.post("/setup") +@legacy_router.post("/setup") async def dashboard_setup( request: Request, payload: AuthSetupRequest, @@ -414,7 +414,7 @@ async def dashboard_setup( return await _setup(request, payload, service, None) -@dashboard_router.post("/setup-authenticated") +@legacy_router.post("/setup-authenticated") async def dashboard_setup_authenticated( request: Request, payload: AuthSetupRequest, @@ -435,7 +435,7 @@ async def totp_setup( return await _totp_setup(request, payload, service) -@dashboard_router.post("/totp/setup") +@legacy_router.post("/totp/setup") async def dashboard_totp_setup( request: Request, payload: TotpSetupRequest, @@ -454,7 +454,7 @@ async def totp_recovery( return await _totp_recovery(request, service) -@dashboard_router.post("/totp/recovery") +@legacy_router.post("/totp/recovery") async def dashboard_totp_recovery( request: Request, _username: str = Depends(require_dashboard_user), @@ -473,7 +473,7 @@ async def update_account( return await _update_account(request, payload, service) -@dashboard_router.post("/account/edit") +@legacy_router.post("/account/edit") async def dashboard_update_account( request: Request, payload: AccountUpdateRequest, diff --git a/astrbot/dashboard/api/backups.py b/astrbot/dashboard/api/backups.py index 3b80a74b71..0e04a1f92d 100644 --- a/astrbot/dashboard/api/backups.py +++ b/astrbot/dashboard/api/backups.py @@ -20,7 +20,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Backups"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/backup", tags=["Dashboard Backups"], include_in_schema=False, @@ -107,7 +107,7 @@ async def list_backups( ) -@dashboard_router.get("/list") +@legacy_router.get("/list") async def list_dashboard_backups( page: int = Query(default=1), page_size: int = Query(default=20), @@ -128,7 +128,7 @@ async def create_backup( return await _run(service.export_backup, prefix="创建备份失败") -@dashboard_router.post("/export") +@legacy_router.post("/export") async def export_dashboard_backup( _username: str = Depends(require_dashboard_user), service: BackupService = Depends(get_service), @@ -145,7 +145,7 @@ async def upload_backup( return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败") -@dashboard_router.post("/upload") +@legacy_router.post("/upload") async def upload_dashboard_backup( file: UploadFile = File(...), _username: str = Depends(require_dashboard_user), @@ -166,7 +166,7 @@ async def init_backup_upload( ) -@dashboard_router.post("/upload/init") +@legacy_router.post("/upload/init") async def init_dashboard_backup_upload( payload: BackupUploadInitRequest, _username: str = Depends(require_dashboard_user), @@ -196,7 +196,7 @@ async def upload_backup_chunk( ) -@dashboard_router.post("/upload/chunk") +@legacy_router.post("/upload/chunk") async def upload_dashboard_backup_chunk( upload_id: str = Form(...), chunk_index: str = Form(...), @@ -226,7 +226,7 @@ async def complete_backup_upload( ) -@dashboard_router.post("/upload/complete") +@legacy_router.post("/upload/complete") async def complete_dashboard_backup_upload( payload: BackupUploadSessionRequest, _username: str = Depends(require_dashboard_user), @@ -250,7 +250,7 @@ async def abort_backup_upload( ) -@dashboard_router.post("/upload/abort") +@legacy_router.post("/upload/abort") async def abort_dashboard_backup_upload( payload: BackupUploadSessionRequest, _username: str = Depends(require_dashboard_user), @@ -271,7 +271,7 @@ async def get_backup_progress( return await _run(lambda: service.get_progress(task_id), prefix="获取任务进度失败") -@dashboard_router.get("/progress") +@legacy_router.get("/progress") async def get_dashboard_backup_progress( task_id: str | None = Query(default=None), _username: str = Depends(require_dashboard_user), @@ -292,7 +292,7 @@ async def download_backup( return _download_backup(filename=filename, token=token, service=service) -@dashboard_router.get("/download") +@legacy_router.get("/download") async def download_dashboard_backup( filename: str | None = Query(default=None), token: str | None = Query(default=None), @@ -314,7 +314,7 @@ async def rename_backup( ) -@dashboard_router.post("/rename") +@legacy_router.post("/rename") async def rename_dashboard_backup( payload: BackupRenameRequest, filename: str | None = Query(default=None), @@ -339,7 +339,7 @@ async def delete_backup( ) -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def delete_dashboard_backup( request: Request, filename: str | None = Query(default=None), @@ -365,7 +365,7 @@ async def check_backup( ) -@dashboard_router.post("/check") +@legacy_router.post("/check") async def check_dashboard_backup( request: Request, filename: str | None = Query(default=None), @@ -392,7 +392,7 @@ async def import_backup( ) -@dashboard_router.post("/import") +@legacy_router.post("/import") async def import_dashboard_backup( request: Request, filename: str | None = Query(default=None), diff --git a/astrbot/dashboard/api/bots.py b/astrbot/dashboard/api/bots.py index 373dd74c57..b96c8f3789 100644 --- a/astrbot/dashboard/api/bots.py +++ b/astrbot/dashboard/api/bots.py @@ -9,7 +9,7 @@ from .auth import AuthContext, require_scope router = APIRouter(tags=["Bots"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/config/platform", tags=["Dashboard Bots"], include_in_schema=False, @@ -192,7 +192,7 @@ async def delete_bot( return ok(message="删除平台配置成功~") -@dashboard_router.get("/list") +@legacy_router.get("/list") async def list_dashboard_alias_platforms( _auth: AuthContext = Depends(require_bot_scope), service: BotConfigService = Depends(get_service), @@ -200,7 +200,7 @@ async def list_dashboard_alias_platforms( return ok({"platforms": service.list_bots()["bots"]}) -@dashboard_router.post("/new") +@legacy_router.post("/new") async def create_dashboard_alias_platform( payload: BotConfigRequest, _auth: AuthContext = Depends(require_bot_scope), @@ -213,7 +213,7 @@ async def create_dashboard_alias_platform( return _alias_error(str(exc)) -@dashboard_router.post("/update") +@legacy_router.post("/update") async def update_dashboard_alias_platform( request: Request, _auth: AuthContext = Depends(require_bot_scope), @@ -236,7 +236,7 @@ async def update_dashboard_alias_platform( return _alias_error(str(exc)) -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def delete_dashboard_alias_platform( request: Request, _auth: AuthContext = Depends(require_bot_scope), diff --git a/astrbot/dashboard/api/chat.py b/astrbot/dashboard/api/chat.py index 2aff4af465..15e17cece2 100644 --- a/astrbot/dashboard/api/chat.py +++ b/astrbot/dashboard/api/chat.py @@ -24,7 +24,7 @@ from .multipart import single_upload router = APIRouter(tags=["Chat"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/chat", tags=["Dashboard Chat"], include_in_schema=False, @@ -283,7 +283,7 @@ async def send_chat_thread_message( ) -@dashboard_router.post("/send") +@legacy_router.post("/send") async def dashboard_send_chat( request: Request, username: str = Depends(require_dashboard_user), @@ -292,7 +292,7 @@ async def dashboard_send_chat( return await _send_chat(request=request, username=username, service=service) -@dashboard_router.get("/new_session") +@legacy_router.get("/new_session") async def dashboard_new_session( request: Request, username: str = Depends(require_dashboard_user), @@ -306,7 +306,7 @@ async def dashboard_new_session( ) -@dashboard_router.get("/sessions") +@legacy_router.get("/sessions") async def dashboard_get_sessions( request: Request, username: str = Depends(require_dashboard_user), @@ -317,7 +317,7 @@ async def dashboard_get_sessions( ) -@dashboard_router.get("/get_session") +@legacy_router.get("/get_session") async def dashboard_get_session( request: Request, username: str = Depends(require_dashboard_user), @@ -331,7 +331,7 @@ async def dashboard_get_session( ) -@dashboard_router.post("/stop") +@legacy_router.post("/stop") async def dashboard_stop_session( request: Request, username: str = Depends(require_dashboard_user), @@ -343,7 +343,7 @@ async def dashboard_stop_session( ) -@dashboard_router.get("/delete_session") +@legacy_router.get("/delete_session") async def dashboard_delete_session( request: Request, username: str = Depends(require_dashboard_user), @@ -357,7 +357,7 @@ async def dashboard_delete_session( ) -@dashboard_router.post("/batch_delete_sessions") +@legacy_router.post("/batch_delete_sessions") async def dashboard_batch_delete_sessions( request: Request, username: str = Depends(require_dashboard_user), @@ -369,7 +369,7 @@ async def dashboard_batch_delete_sessions( ) -@dashboard_router.post("/update_session_display_name") +@legacy_router.post("/update_session_display_name") async def dashboard_update_session_display_name( request: Request, username: str = Depends(require_dashboard_user), @@ -384,7 +384,7 @@ async def dashboard_update_session_display_name( ) -@dashboard_router.post("/message/edit") +@legacy_router.post("/message/edit") async def dashboard_update_message( request: Request, username: str = Depends(require_dashboard_user), @@ -394,7 +394,7 @@ async def dashboard_update_message( return await _run(lambda: service.update_message(username, body)) -@dashboard_router.post("/message/regenerate") +@legacy_router.post("/message/regenerate") async def dashboard_regenerate_message( request: Request, username: str = Depends(require_dashboard_user), @@ -417,7 +417,7 @@ async def dashboard_regenerate_message( ) -@dashboard_router.post("/thread/create") +@legacy_router.post("/thread/create") async def dashboard_create_thread( request: Request, username: str = Depends(require_dashboard_user), @@ -427,7 +427,7 @@ async def dashboard_create_thread( return await _run(lambda: service.create_thread(username, body)) -@dashboard_router.get("/thread/get") +@legacy_router.get("/thread/get") async def dashboard_get_thread( request: Request, username: str = Depends(require_dashboard_user), @@ -441,7 +441,7 @@ async def dashboard_get_thread( ) -@dashboard_router.post("/thread/send") +@legacy_router.post("/thread/send") async def dashboard_send_thread_message( request: Request, username: str = Depends(require_dashboard_user), @@ -462,7 +462,7 @@ async def dashboard_send_thread_message( ) -@dashboard_router.post("/thread/delete") +@legacy_router.post("/thread/delete") async def dashboard_delete_thread( request: Request, username: str = Depends(require_dashboard_user), @@ -474,7 +474,7 @@ async def dashboard_delete_thread( ) -@dashboard_router.get("/get_file") +@legacy_router.get("/get_file") async def dashboard_get_file( request: Request, _username: str = Depends(require_dashboard_user), @@ -491,7 +491,7 @@ async def dashboard_get_file( return JSONResponse(error("File access error")) -@dashboard_router.get("/get_attachment") +@legacy_router.get("/get_attachment") async def dashboard_get_attachment( request: Request, _username: str = Depends(require_dashboard_user), @@ -511,7 +511,7 @@ async def dashboard_get_attachment( return JSONResponse(error("File access error")) -@dashboard_router.post("/post_file") +@legacy_router.post("/post_file") async def dashboard_post_file( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/chat_projects.py b/astrbot/dashboard/api/chat_projects.py index 5aac93d4d6..a8d4ba4485 100644 --- a/astrbot/dashboard/api/chat_projects.py +++ b/astrbot/dashboard/api/chat_projects.py @@ -13,7 +13,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Chat Projects"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/chatui_project", tags=["Dashboard Chat Projects"], include_in_schema=False, @@ -56,7 +56,7 @@ async def list_chat_projects( return await _run(lambda: service.list_projects(auth.username)) -@dashboard_router.get("/list") +@legacy_router.get("/list") async def list_dashboard_chat_projects( username: str = Depends(require_dashboard_user), service: ChatUIProjectService = Depends(get_service), @@ -75,7 +75,7 @@ async def create_chat_project( ) -@dashboard_router.post("/create") +@legacy_router.post("/create") async def create_dashboard_chat_project( request: Request, username: str = Depends(require_dashboard_user), @@ -94,7 +94,7 @@ async def get_chat_project( return await _run(lambda: service.get_project(auth.username, project_id)) -@dashboard_router.get("/get") +@legacy_router.get("/get") async def get_dashboard_chat_project( project_id: str | None = Query(default=None), username: str = Depends(require_dashboard_user), @@ -118,7 +118,7 @@ async def update_chat_project( ) -@dashboard_router.post("/update") +@legacy_router.post("/update") async def update_dashboard_chat_project( request: Request, username: str = Depends(require_dashboard_user), @@ -137,7 +137,7 @@ async def delete_chat_project( return await _run(lambda: service.delete_project(auth.username, project_id)) -@dashboard_router.get("/delete") +@legacy_router.get("/delete") async def delete_dashboard_chat_project( project_id: str | None = Query(default=None), username: str = Depends(require_dashboard_user), @@ -155,7 +155,7 @@ async def list_chat_project_sessions( return await _run(lambda: service.get_project_sessions(auth.username, project_id)) -@dashboard_router.get("/get_sessions") +@legacy_router.get("/get_sessions") async def list_dashboard_chat_project_sessions( project_id: str | None = Query(default=None), username: str = Depends(require_dashboard_user), @@ -181,7 +181,7 @@ async def add_chat_project_session( ) -@dashboard_router.post("/add_session") +@legacy_router.post("/add_session") async def add_dashboard_chat_project_session( request: Request, username: str = Depends(require_dashboard_user), @@ -205,7 +205,7 @@ async def remove_chat_project_session( ) -@dashboard_router.post("/remove_session") +@legacy_router.post("/remove_session") async def remove_dashboard_chat_project_session( request: Request, username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/config_profiles.py b/astrbot/dashboard/api/config_profiles.py index 8b3f65b8ff..943cc7d7e8 100644 --- a/astrbot/dashboard/api/config_profiles.py +++ b/astrbot/dashboard/api/config_profiles.py @@ -23,7 +23,7 @@ from .multipart import multipart_parts router = APIRouter(tags=["Config Profiles"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/config", tags=["Dashboard Config"], include_in_schema=False, @@ -215,7 +215,7 @@ async def delete_config_route( return ok(message="删除成功") -@dashboard_router.get("/default") +@legacy_router.get("/default") async def get_dashboard_alias_default_config( _auth: AuthContext = Depends(require_config_scope), service: ConfigProfileService = Depends(get_service), @@ -223,7 +223,7 @@ async def get_dashboard_alias_default_config( return ok(service.get_profile_schema()) -@dashboard_router.get("/abconfs") +@legacy_router.get("/abconfs") async def list_dashboard_alias_config_profiles( _auth: AuthContext = Depends(require_config_scope), service: ConfigProfileService = Depends(get_service), @@ -231,7 +231,7 @@ async def list_dashboard_alias_config_profiles( return ok(service.list_profiles()) -@dashboard_router.post("/abconf/new") +@legacy_router.post("/abconf/new") async def create_dashboard_alias_config_profile( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -250,7 +250,7 @@ async def create_dashboard_alias_config_profile( return _alias_error(str(exc)) -@dashboard_router.get("/abconf") +@legacy_router.get("/abconf") async def get_dashboard_alias_config_profile( id: str | None = Query(default=None), system_config: str = Query(default="0"), @@ -267,7 +267,7 @@ async def get_dashboard_alias_config_profile( return _alias_error(str(exc)) -@dashboard_router.post("/abconf/delete") +@legacy_router.post("/abconf/delete") async def delete_dashboard_alias_config_profile( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -284,7 +284,7 @@ async def delete_dashboard_alias_config_profile( return _alias_error(str(exc)) -@dashboard_router.post("/abconf/update") +@legacy_router.post("/abconf/update") async def rename_dashboard_alias_config_profile( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -301,7 +301,7 @@ async def rename_dashboard_alias_config_profile( return _alias_error(str(exc)) -@dashboard_router.post("/astrbot/update") +@legacy_router.post("/astrbot/update") async def update_dashboard_alias_astrbot_config( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -325,7 +325,7 @@ async def update_dashboard_alias_astrbot_config( return _alias_error(str(exc)) -@dashboard_router.get("/get") +@legacy_router.get("/get") async def get_dashboard_alias_configs( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -337,7 +337,7 @@ async def get_dashboard_alias_configs( return _alias_error(str(exc)) -@dashboard_router.post("/plugin/update") +@legacy_router.post("/plugin/update") async def update_dashboard_alias_plugin_configs( request: Request, plugin_name: str = Query(default="unknown"), @@ -355,7 +355,7 @@ async def update_dashboard_alias_plugin_configs( return _alias_error(str(exc)) -@dashboard_router.post("/file/upload") +@legacy_router.post("/file/upload") async def upload_dashboard_alias_config_file( request: Request, scope: str | None = Query(default=None), @@ -378,7 +378,7 @@ async def upload_dashboard_alias_config_file( return _alias_error(str(exc)) -@dashboard_router.post("/file/delete") +@legacy_router.post("/file/delete") async def delete_dashboard_alias_config_file( request: Request, scope: str | None = Query(default=None), @@ -398,7 +398,7 @@ async def delete_dashboard_alias_config_file( return _alias_error(str(exc)) -@dashboard_router.get("/file/get") +@legacy_router.get("/file/get") async def list_dashboard_alias_config_files( scope: str | None = Query(default=None), name: str | None = Query(default=None), @@ -418,7 +418,7 @@ async def list_dashboard_alias_config_files( return _alias_error(str(exc)) -@dashboard_router.get("/umo_abconf_routes") +@legacy_router.get("/umo_abconf_routes") async def get_dashboard_alias_config_routes( _auth: AuthContext = Depends(require_config_scope), service: ConfigRoutingService = Depends(get_routing_service), @@ -426,7 +426,7 @@ async def get_dashboard_alias_config_routes( return ok(service.list_routes()) -@dashboard_router.post("/umo_abconf_route/update_all") +@legacy_router.post("/umo_abconf_route/update_all") async def update_dashboard_alias_config_routes( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -440,7 +440,7 @@ async def update_dashboard_alias_config_routes( return ok(message="更新成功") -@dashboard_router.post("/umo_abconf_route/update") +@legacy_router.post("/umo_abconf_route/update") async def upsert_dashboard_alias_config_route( request: Request, _auth: AuthContext = Depends(require_config_scope), @@ -454,7 +454,7 @@ async def upsert_dashboard_alias_config_route( return ok(message="更新成功") -@dashboard_router.post("/umo_abconf_route/delete") +@legacy_router.post("/umo_abconf_route/delete") async def delete_dashboard_alias_config_route( request: Request, _auth: AuthContext = Depends(require_config_scope), diff --git a/astrbot/dashboard/api/conversations.py b/astrbot/dashboard/api/conversations.py index 40d3c886c8..f2355d835c 100644 --- a/astrbot/dashboard/api/conversations.py +++ b/astrbot/dashboard/api/conversations.py @@ -22,7 +22,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Conversations"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/conversation", tags=["Dashboard Conversations"], include_in_schema=False, @@ -210,7 +210,7 @@ async def delete_conversation( ) -@dashboard_router.get("/list") +@legacy_router.get("/list") async def list_dashboard_conversations( page: int = Query(default=1), page_size: int = Query(default=20), @@ -234,7 +234,7 @@ async def list_dashboard_conversations( ) -@dashboard_router.post("/detail") +@legacy_router.post("/detail") async def get_dashboard_conversation_detail( request: Request, _username: str = Depends(require_dashboard_user), @@ -244,7 +244,7 @@ async def get_dashboard_conversation_detail( return await _run(lambda: service.get_conversation_detail(body)) -@dashboard_router.post("/update") +@legacy_router.post("/update") async def update_dashboard_conversation( request: Request, _username: str = Depends(require_dashboard_user), @@ -254,7 +254,7 @@ async def update_dashboard_conversation( return await _run(lambda: service.update_conversation(body)) -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def delete_dashboard_conversation( request: Request, _username: str = Depends(require_dashboard_user), @@ -264,7 +264,7 @@ async def delete_dashboard_conversation( return await _run(lambda: service.delete_conversation(body)) -@dashboard_router.post("/update_history") +@legacy_router.post("/update_history") async def update_dashboard_conversation_history( request: Request, _username: str = Depends(require_dashboard_user), @@ -274,7 +274,7 @@ async def update_dashboard_conversation_history( return await _run(lambda: service.update_history(body)) -@dashboard_router.post("/export") +@legacy_router.post("/export") async def export_dashboard_conversations( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/cron.py b/astrbot/dashboard/api/cron.py index 16ca26002b..1b1f9cc432 100644 --- a/astrbot/dashboard/api/cron.py +++ b/astrbot/dashboard/api/cron.py @@ -9,7 +9,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Cron"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/cron", tags=["Dashboard Cron"], include_in_schema=False, @@ -115,7 +115,7 @@ async def run_cron_job( return await _run_job(job_id, service) -@dashboard_router.get("/jobs") +@legacy_router.get("/jobs") async def list_dashboard_cron_jobs( job_type: str | None = Query(default=None, alias="type"), _username: str = Depends(require_dashboard_user), @@ -124,7 +124,7 @@ async def list_dashboard_cron_jobs( return await _list_jobs(job_type, service) -@dashboard_router.post("/jobs") +@legacy_router.post("/jobs") async def create_dashboard_cron_job( payload: CronJobRequest, _username: str = Depends(require_dashboard_user), @@ -133,7 +133,7 @@ async def create_dashboard_cron_job( return await _create_job(payload, service) -@dashboard_router.patch("/jobs/{job_id}") +@legacy_router.patch("/jobs/{job_id}") async def update_dashboard_cron_job( job_id: str, payload: CronJobRequest, @@ -143,7 +143,7 @@ async def update_dashboard_cron_job( return await _update_job(job_id, payload, service) -@dashboard_router.delete("/jobs/{job_id}") +@legacy_router.delete("/jobs/{job_id}") async def delete_dashboard_cron_job( job_id: str, _username: str = Depends(require_dashboard_user), @@ -152,7 +152,7 @@ async def delete_dashboard_cron_job( return await _delete_job(job_id, service) -@dashboard_router.post("/jobs/{job_id}/run") +@legacy_router.post("/jobs/{job_id}/run") async def run_dashboard_cron_job( job_id: str, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/extensions.py b/astrbot/dashboard/api/extensions.py index 4c874a2bd3..2c88a66ccc 100644 --- a/astrbot/dashboard/api/extensions.py +++ b/astrbot/dashboard/api/extensions.py @@ -17,7 +17,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Extension Components"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api", tags=["Dashboard Extension Components"], include_in_schema=False, @@ -136,7 +136,7 @@ async def update_command( ) -@dashboard_router.get("/commands") +@legacy_router.get("/commands") async def list_dashboard_commands( config_id: str | None = None, _username: str = Depends(require_dashboard_user), @@ -145,7 +145,7 @@ async def list_dashboard_commands( return await _list_commands(config_id, service) -@dashboard_router.get("/commands/conflicts") +@legacy_router.get("/commands/conflicts") async def list_dashboard_command_conflicts( _username: str = Depends(require_dashboard_user), service: CommandService = Depends(get_command_service), @@ -153,7 +153,7 @@ async def list_dashboard_command_conflicts( return await _list_command_conflicts(service) -@dashboard_router.post("/commands/toggle") +@legacy_router.post("/commands/toggle") async def toggle_dashboard_command( payload: CommandToggleRequest, _username: str = Depends(require_dashboard_user), @@ -162,7 +162,7 @@ async def toggle_dashboard_command( return await _toggle_command(payload, service) -@dashboard_router.post("/commands/rename") +@legacy_router.post("/commands/rename") async def rename_dashboard_command( payload: CommandRenameRequest, _username: str = Depends(require_dashboard_user), @@ -171,7 +171,7 @@ async def rename_dashboard_command( return await _rename_command(payload, service) -@dashboard_router.post("/commands/permission") +@legacy_router.post("/commands/permission") async def update_dashboard_command_permission( payload: CommandPermissionRequest, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/files.py b/astrbot/dashboard/api/files.py index 1179a9a780..86ab241c6d 100644 --- a/astrbot/dashboard/api/files.py +++ b/astrbot/dashboard/api/files.py @@ -13,7 +13,7 @@ from .auth import AuthContext, require_scope router = APIRouter(tags=["Files"]) -dashboard_router = APIRouter(prefix="/api", include_in_schema=False) +legacy_router = APIRouter(prefix="/api", include_in_schema=False) def get_service(request: Request) -> FileService: @@ -122,7 +122,7 @@ async def delete_file( return ok({"attachment_id": attachment_id}) -@dashboard_router.get("/file/{file_token}") +@legacy_router.get("/file/{file_token}") async def get_dashboard_token_file( file_token: str, service: FileService = Depends(get_service), diff --git a/astrbot/dashboard/api/knowledge_bases.py b/astrbot/dashboard/api/knowledge_bases.py index 706e175f79..c6f62235dd 100644 --- a/astrbot/dashboard/api/knowledge_bases.py +++ b/astrbot/dashboard/api/knowledge_bases.py @@ -23,7 +23,7 @@ from .multipart import multipart_parts router = APIRouter(tags=["Knowledge Bases"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/kb", tags=["Dashboard Knowledge Bases"], include_in_schema=False, @@ -313,7 +313,7 @@ async def retrieve_knowledge_base( ) -@dashboard_router.get("/list") +@legacy_router.get("/list") async def dashboard_list_kbs( request: Request, _username: str = Depends(require_dashboard_user), @@ -328,7 +328,7 @@ async def dashboard_list_kbs( ) -@dashboard_router.post("/create") +@legacy_router.post("/create") async def dashboard_create_kb( request: Request, _username: str = Depends(require_dashboard_user), @@ -337,7 +337,7 @@ async def dashboard_create_kb( return await _run_json(request, service.create_kb, prefix="创建知识库失败") -@dashboard_router.get("/get") +@legacy_router.get("/get") async def dashboard_get_kb( request: Request, _username: str = Depends(require_dashboard_user), @@ -349,7 +349,7 @@ async def dashboard_get_kb( ) -@dashboard_router.post("/update") +@legacy_router.post("/update") async def dashboard_update_kb( request: Request, _username: str = Depends(require_dashboard_user), @@ -358,7 +358,7 @@ async def dashboard_update_kb( return await _run_json(request, service.update_kb, prefix="更新知识库失败") -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def dashboard_delete_kb( request: Request, _username: str = Depends(require_dashboard_user), @@ -367,7 +367,7 @@ async def dashboard_delete_kb( return await _run_json(request, service.delete_kb, prefix="删除知识库失败") -@dashboard_router.get("/stats") +@legacy_router.get("/stats") async def dashboard_get_kb_stats( request: Request, _username: str = Depends(require_dashboard_user), @@ -379,7 +379,7 @@ async def dashboard_get_kb_stats( ) -@dashboard_router.get("/document/list") +@legacy_router.get("/document/list") async def dashboard_list_documents( request: Request, _username: str = Depends(require_dashboard_user), @@ -395,7 +395,7 @@ async def dashboard_list_documents( ) -@dashboard_router.post("/document/upload") +@legacy_router.post("/document/upload") async def dashboard_upload_document( request: Request, _username: str = Depends(require_dashboard_user), @@ -412,7 +412,7 @@ async def _operation(): return await _run(_operation, prefix="上传文档失败") -@dashboard_router.post("/document/import") +@legacy_router.post("/document/import") async def dashboard_import_documents( request: Request, _username: str = Depends(require_dashboard_user), @@ -421,7 +421,7 @@ async def dashboard_import_documents( return await _run_json(request, service.import_documents, prefix="导入文档失败") -@dashboard_router.post("/document/upload/url") +@legacy_router.post("/document/upload/url") async def dashboard_upload_document_from_url( request: Request, _username: str = Depends(require_dashboard_user), @@ -434,7 +434,7 @@ async def dashboard_upload_document_from_url( ) -@dashboard_router.get("/document/upload/progress") +@legacy_router.get("/document/upload/progress") async def dashboard_get_upload_progress( request: Request, _username: str = Depends(require_dashboard_user), @@ -446,7 +446,7 @@ async def dashboard_get_upload_progress( ) -@dashboard_router.get("/document/get") +@legacy_router.get("/document/get") async def dashboard_get_document( request: Request, _username: str = Depends(require_dashboard_user), @@ -461,7 +461,7 @@ async def dashboard_get_document( ) -@dashboard_router.post("/document/delete") +@legacy_router.post("/document/delete") async def dashboard_delete_document( request: Request, _username: str = Depends(require_dashboard_user), @@ -470,7 +470,7 @@ async def dashboard_delete_document( return await _run_json(request, service.delete_document, prefix="删除文档失败") -@dashboard_router.get("/chunk/list") +@legacy_router.get("/chunk/list") async def dashboard_list_chunks( request: Request, _username: str = Depends(require_dashboard_user), @@ -487,7 +487,7 @@ async def dashboard_list_chunks( ) -@dashboard_router.post("/chunk/delete") +@legacy_router.post("/chunk/delete") async def dashboard_delete_chunk( request: Request, _username: str = Depends(require_dashboard_user), @@ -496,7 +496,7 @@ async def dashboard_delete_chunk( return await _run_json(request, service.delete_chunk, prefix="删除文本块失败") -@dashboard_router.post("/retrieve") +@legacy_router.post("/retrieve") async def dashboard_retrieve( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/live_chat.py b/astrbot/dashboard/api/live_chat.py index 746cc6c704..470e6abf56 100644 --- a/astrbot/dashboard/api/live_chat.py +++ b/astrbot/dashboard/api/live_chat.py @@ -5,7 +5,7 @@ from astrbot.dashboard.services.live_chat_service import LiveChatService router = APIRouter(tags=["Live Chat"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api", tags=["Dashboard Live Chat"], include_in_schema=False, @@ -42,11 +42,11 @@ async def unified_chat_ws(websocket: WebSocket) -> None: await _run_live_chat_ws(websocket, force_ct=None) -@dashboard_router.websocket("/live_chat/ws") +@legacy_router.websocket("/live_chat/ws") async def dashboard_live_chat_ws(websocket: WebSocket) -> None: await _run_live_chat_ws(websocket, force_ct="live") -@dashboard_router.websocket("/unified_chat/ws") +@legacy_router.websocket("/unified_chat/ws") async def dashboard_unified_chat_ws(websocket: WebSocket) -> None: await _run_live_chat_ws(websocket, force_ct=None) diff --git a/astrbot/dashboard/api/logs.py b/astrbot/dashboard/api/logs.py index 8b29578adc..de5e6b5729 100644 --- a/astrbot/dashboard/api/logs.py +++ b/astrbot/dashboard/api/logs.py @@ -10,7 +10,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Logs"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api", tags=["Dashboard Logs"], include_in_schema=False, @@ -97,7 +97,7 @@ async def update_trace_settings( return _update_trace_settings(payload, service) -@dashboard_router.get("/log-history") +@legacy_router.get("/log-history") async def get_dashboard_log_history( _username: str = Depends(require_dashboard_user), service: LogService = Depends(get_service), @@ -105,7 +105,7 @@ async def get_dashboard_log_history( return _get_log_history(service) -@dashboard_router.get("/live-log") +@legacy_router.get("/live-log") async def get_dashboard_live_logs( last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), _username: str = Depends(require_dashboard_user), @@ -114,7 +114,7 @@ async def get_dashboard_live_logs( return _log_stream_response(last_event_id, service) -@dashboard_router.get("/trace/settings") +@legacy_router.get("/trace/settings") async def get_dashboard_trace_settings( _username: str = Depends(require_dashboard_user), service: LogService = Depends(get_service), @@ -122,7 +122,7 @@ async def get_dashboard_trace_settings( return _get_trace_settings(service) -@dashboard_router.post("/trace/settings") +@legacy_router.post("/trace/settings") async def update_dashboard_trace_settings( payload: TraceSettingsRequest, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/personas.py b/astrbot/dashboard/api/personas.py index b1ce090f02..ae83e5452b 100644 --- a/astrbot/dashboard/api/personas.py +++ b/astrbot/dashboard/api/personas.py @@ -21,7 +21,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Personas"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/persona", tags=["Dashboard Personas"], include_in_schema=False, @@ -205,7 +205,7 @@ async def delete_persona( return await _run(lambda: service.delete_persona({"persona_id": persona_id})) -@dashboard_router.get("/list") +@legacy_router.get("/list") async def list_dashboard_personas( request: Request, folder_id: str | None = Query(default=None), @@ -217,7 +217,7 @@ async def list_dashboard_personas( ) -@dashboard_router.post("/detail") +@legacy_router.post("/detail") async def get_dashboard_persona_detail( request: Request, _username: str = Depends(require_dashboard_user), @@ -227,7 +227,7 @@ async def get_dashboard_persona_detail( return await _run(lambda: service.get_persona_detail(body)) -@dashboard_router.post("/create") +@legacy_router.post("/create") async def create_dashboard_persona( request: Request, _username: str = Depends(require_dashboard_user), @@ -237,7 +237,7 @@ async def create_dashboard_persona( return await _run(lambda: service.create_persona(body)) -@dashboard_router.post("/update") +@legacy_router.post("/update") async def update_dashboard_persona( request: Request, _username: str = Depends(require_dashboard_user), @@ -247,7 +247,7 @@ async def update_dashboard_persona( return await _run(lambda: service.update_persona(body)) -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def delete_dashboard_persona( request: Request, _username: str = Depends(require_dashboard_user), @@ -257,7 +257,7 @@ async def delete_dashboard_persona( return await _run(lambda: service.delete_persona(body)) -@dashboard_router.post("/move") +@legacy_router.post("/move") async def move_dashboard_persona( request: Request, _username: str = Depends(require_dashboard_user), @@ -267,7 +267,7 @@ async def move_dashboard_persona( return await _run(lambda: service.move_persona(body)) -@dashboard_router.post("/reorder") +@legacy_router.post("/reorder") async def reorder_dashboard_personas( request: Request, _username: str = Depends(require_dashboard_user), @@ -277,7 +277,7 @@ async def reorder_dashboard_personas( return await _run(lambda: service.reorder_items(body)) -@dashboard_router.get("/folder/list") +@legacy_router.get("/folder/list") async def list_dashboard_persona_folders( parent_id: str | None = Query(default=None), _username: str = Depends(require_dashboard_user), @@ -286,7 +286,7 @@ async def list_dashboard_persona_folders( return await _run(lambda: service.list_folders(parent_id)) -@dashboard_router.get("/folder/tree") +@legacy_router.get("/folder/tree") async def get_dashboard_persona_folder_tree( _username: str = Depends(require_dashboard_user), service: PersonaService = Depends(get_service), @@ -294,7 +294,7 @@ async def get_dashboard_persona_folder_tree( return await _run(service.get_folder_tree) -@dashboard_router.post("/folder/detail") +@legacy_router.post("/folder/detail") async def get_dashboard_persona_folder_detail( request: Request, _username: str = Depends(require_dashboard_user), @@ -304,7 +304,7 @@ async def get_dashboard_persona_folder_detail( return await _run(lambda: service.get_folder_detail(body)) -@dashboard_router.post("/folder/create") +@legacy_router.post("/folder/create") async def create_dashboard_persona_folder( request: Request, _username: str = Depends(require_dashboard_user), @@ -314,7 +314,7 @@ async def create_dashboard_persona_folder( return await _run(lambda: service.create_folder(body)) -@dashboard_router.post("/folder/update") +@legacy_router.post("/folder/update") async def update_dashboard_persona_folder( request: Request, _username: str = Depends(require_dashboard_user), @@ -324,7 +324,7 @@ async def update_dashboard_persona_folder( return await _run(lambda: service.update_folder(body)) -@dashboard_router.post("/folder/delete") +@legacy_router.post("/folder/delete") async def delete_dashboard_persona_folder( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/platform.py b/astrbot/dashboard/api/platform.py index 011a682a2c..c6a6f7f551 100644 --- a/astrbot/dashboard/api/platform.py +++ b/astrbot/dashboard/api/platform.py @@ -16,7 +16,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Platforms"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/platform", tags=["Dashboard Platforms"], include_in_schema=False, @@ -89,7 +89,7 @@ async def receive_platform_webhook( ) -@dashboard_router.api_route("/webhook/{webhook_uuid}", methods=["GET", "POST"]) +@legacy_router.api_route("/webhook/{webhook_uuid}", methods=["GET", "POST"]) async def dashboard_platform_webhook( webhook_uuid: str, request: Request, @@ -100,7 +100,7 @@ async def dashboard_platform_webhook( ) -@dashboard_router.get("/stats") +@legacy_router.get("/stats") async def get_dashboard_platform_stats( _username: str = Depends(require_dashboard_user), service: PlatformService = Depends(get_service), @@ -108,7 +108,7 @@ async def get_dashboard_platform_stats( return await _run(service.get_platform_stats) -@dashboard_router.post("/registration/{platform_type}") +@legacy_router.post("/registration/{platform_type}") async def handle_dashboard_platform_registration( platform_type: str, request: Request, diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index 88b8dda6ea..551309e883 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -3,6 +3,7 @@ import re from collections.abc import Callable from typing import Any +from urllib.parse import quote from fastapi import APIRouter, Body, Depends, Query, Request from fastapi.responses import PlainTextResponse, Response @@ -47,7 +48,7 @@ from .multipart import multipart_parts router = APIRouter(tags=["Plugins"]) -dashboard_router = APIRouter(tags=["Dashboard Plugins"], include_in_schema=False) +legacy_router = APIRouter(tags=["Dashboard Plugins"], include_in_schema=False) async def require_plugin_scope(request: Request) -> AuthContext: @@ -174,6 +175,14 @@ def _match_registered_web_api(registered_web_apis, subpath: str, method: str): return None +def _plugin_extension_legacy_path(plugin_path: str, request: Request) -> str: + encoded_path = quote(plugin_path.lstrip("/"), safe="/:@!$&'()*+,;=-._~") + path = f"/api/plug/{encoded_path}" + if request.url.query: + return f"{path}?{request.url.query}" + return path + + async def _call_plugin_extension( plugin_path: str, request: Request, @@ -203,6 +212,7 @@ async def _call_plugin_extension( view_handler, path_values, g_obj=g_obj, + quart_compat_path=_plugin_extension_legacy_path(plugin_path, request), ) @@ -1098,7 +1108,7 @@ async def get_plugin_page_asset( ) -@dashboard_router.get("/api/plugin/get") +@legacy_router.get("/api/plugin/get") async def dashboard_list_plugins( request: Request, _username: str = Depends(require_dashboard_user), @@ -1112,7 +1122,7 @@ async def dashboard_list_plugins( ) -@dashboard_router.get("/api/plugin/detail") +@legacy_router.get("/api/plugin/detail") async def dashboard_get_plugin_detail( request: Request, _username: str = Depends(require_dashboard_user), @@ -1126,7 +1136,7 @@ async def dashboard_get_plugin_detail( ) -@dashboard_router.post("/api/plugin/check-compat") +@legacy_router.post("/api/plugin/check-compat") async def dashboard_check_plugin_version_support( request: Request, _username: str = Depends(require_dashboard_user), @@ -1135,7 +1145,7 @@ async def dashboard_check_plugin_version_support( return await _check_plugin_version_support_request(request, service) -@dashboard_router.get("/api/plugin/page/entry") +@legacy_router.get("/api/plugin/page/entry") async def dashboard_get_plugin_page_entry_config( request: Request, username: str = Depends(require_dashboard_user), @@ -1150,7 +1160,7 @@ async def dashboard_get_plugin_page_entry_config( ) -@dashboard_router.post("/api/plugin/install") +@legacy_router.post("/api/plugin/install") async def dashboard_install_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1163,7 +1173,7 @@ async def dashboard_install_plugin( ) -@dashboard_router.post("/api/plugin/install-upload") +@legacy_router.post("/api/plugin/install-upload") async def dashboard_install_plugin_upload( request: Request, _username: str = Depends(require_dashboard_user), @@ -1176,7 +1186,7 @@ async def dashboard_install_plugin_upload( ) -@dashboard_router.post("/api/plugin/update") +@legacy_router.post("/api/plugin/update") async def dashboard_update_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1189,7 +1199,7 @@ async def dashboard_update_plugin( ) -@dashboard_router.post("/api/plugin/update-all") +@legacy_router.post("/api/plugin/update-all") async def dashboard_update_all_plugins( request: Request, _username: str = Depends(require_dashboard_user), @@ -1202,7 +1212,7 @@ async def dashboard_update_all_plugins( ) -@dashboard_router.post("/api/plugin/uninstall") +@legacy_router.post("/api/plugin/uninstall") async def dashboard_uninstall_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1215,7 +1225,7 @@ async def dashboard_uninstall_plugin( ) -@dashboard_router.post("/api/plugin/uninstall-failed") +@legacy_router.post("/api/plugin/uninstall-failed") async def dashboard_uninstall_failed_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1228,7 +1238,7 @@ async def dashboard_uninstall_failed_plugin( ) -@dashboard_router.get("/api/plugin/market_list") +@legacy_router.get("/api/plugin/market_list") async def dashboard_list_plugin_market( request: Request, _username: str = Depends(require_dashboard_user), @@ -1243,7 +1253,7 @@ async def dashboard_list_plugin_market( ) -@dashboard_router.post("/api/plugin/off") +@legacy_router.post("/api/plugin/off") async def dashboard_disable_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1256,7 +1266,7 @@ async def dashboard_disable_plugin( ) -@dashboard_router.post("/api/plugin/on") +@legacy_router.post("/api/plugin/on") async def dashboard_enable_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1269,7 +1279,7 @@ async def dashboard_enable_plugin( ) -@dashboard_router.post("/api/plugin/reload-failed") +@legacy_router.post("/api/plugin/reload-failed") async def dashboard_reload_failed_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1282,7 +1292,7 @@ async def dashboard_reload_failed_plugin( ) -@dashboard_router.post("/api/plugin/reload") +@legacy_router.post("/api/plugin/reload") async def dashboard_reload_plugin( request: Request, _username: str = Depends(require_dashboard_user), @@ -1295,7 +1305,7 @@ async def dashboard_reload_plugin( ) -@dashboard_router.get("/api/plugin/readme") +@legacy_router.get("/api/plugin/readme") async def dashboard_get_plugin_readme( request: Request, _username: str = Depends(require_dashboard_user), @@ -1307,7 +1317,7 @@ async def dashboard_get_plugin_readme( ) -@dashboard_router.get("/api/plugin/changelog") +@legacy_router.get("/api/plugin/changelog") async def dashboard_get_plugin_changelog( request: Request, _username: str = Depends(require_dashboard_user), @@ -1319,7 +1329,7 @@ async def dashboard_get_plugin_changelog( ) -@dashboard_router.get("/api/plugin/source/get") +@legacy_router.get("/api/plugin/source/get") async def dashboard_get_custom_source( _username: str = Depends(require_dashboard_user), service: PluginService = Depends(get_service), @@ -1327,7 +1337,7 @@ async def dashboard_get_custom_source( return await _run_service(service.get_custom_sources) -@dashboard_router.post("/api/plugin/source/save") +@legacy_router.post("/api/plugin/source/save") async def dashboard_save_custom_source( request: Request, _username: str = Depends(require_dashboard_user), @@ -1340,7 +1350,7 @@ async def dashboard_save_custom_source( ) -@dashboard_router.get("/api/plugin/source/get-failed-plugins") +@legacy_router.get("/api/plugin/source/get-failed-plugins") async def dashboard_get_failed_plugins( _username: str = Depends(require_dashboard_user), service: PluginService = Depends(get_service), @@ -1348,7 +1358,7 @@ async def dashboard_get_failed_plugins( return await _run_service(service.get_failed_plugins) -@dashboard_router.get("/api/plugin/page/bridge-sdk.js") +@legacy_router.get("/api/plugin/page/bridge-sdk.js") async def dashboard_get_plugin_page_bridge_sdk( request: Request, _username: str = Depends(require_dashboard_user), @@ -1360,7 +1370,7 @@ async def dashboard_get_plugin_page_bridge_sdk( ) -@dashboard_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/") +@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/") async def dashboard_get_plugin_page_entry( plugin_id: str, page_name: str, @@ -1378,9 +1388,7 @@ async def dashboard_get_plugin_page_entry( ) -@dashboard_router.get( - "/api/plugin/page/content/{plugin_id}/{page_name}/{asset_path:path}" -) +@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/{asset_path:path}") async def dashboard_get_plugin_page_asset( plugin_id: str, page_name: str, @@ -1399,7 +1407,7 @@ async def dashboard_get_plugin_page_asset( ) -@dashboard_router.api_route("/api/plug/{plugin_path:path}", methods=["GET", "POST"]) +@legacy_router.api_route("/api/plug/{plugin_path:path}", methods=["GET", "POST"]) async def dashboard_plugin_extension_route( plugin_path: str, request: Request, diff --git a/astrbot/dashboard/api/providers.py b/astrbot/dashboard/api/providers.py index dadd67a755..c47ab489c9 100644 --- a/astrbot/dashboard/api/providers.py +++ b/astrbot/dashboard/api/providers.py @@ -13,7 +13,7 @@ from .auth import AuthContext, require_scope router = APIRouter(tags=["Providers"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/config", tags=["Dashboard Providers"], include_in_schema=False, @@ -415,7 +415,7 @@ async def delete_provider( return ok(message="删除成功,已经实时生效。") -@dashboard_router.get("/provider/template") +@legacy_router.get("/provider/template") async def get_dashboard_alias_provider_template( _auth: AuthContext = Depends(require_provider_scope), service: ProviderConfigService = Depends(get_service), @@ -423,7 +423,7 @@ async def get_dashboard_alias_provider_template( return ok(service.get_provider_schema()) -@dashboard_router.get("/provider/list") +@legacy_router.get("/provider/list") async def list_dashboard_alias_providers( provider_type: str | None = Query(default=None), _auth: AuthContext = Depends(require_provider_scope), @@ -443,7 +443,7 @@ async def list_dashboard_alias_providers( return ok(providers) -@dashboard_router.post("/provider/new") +@legacy_router.post("/provider/new") async def create_dashboard_alias_provider( payload: ProviderConfigRequest, _auth: AuthContext = Depends(require_provider_scope), @@ -456,7 +456,7 @@ async def create_dashboard_alias_provider( return _alias_error(str(exc)) -@dashboard_router.post("/provider/update") +@legacy_router.post("/provider/update") async def update_dashboard_alias_provider( request: Request, _auth: AuthContext = Depends(require_provider_scope), @@ -479,7 +479,7 @@ async def update_dashboard_alias_provider( return _alias_error(str(exc)) -@dashboard_router.post("/provider/delete") +@legacy_router.post("/provider/delete") async def delete_dashboard_alias_provider( request: Request, _auth: AuthContext = Depends(require_provider_scope), @@ -496,7 +496,7 @@ async def delete_dashboard_alias_provider( return _alias_error(str(exc)) -@dashboard_router.get("/provider/check_one") +@legacy_router.get("/provider/check_one") async def check_dashboard_alias_provider( id: str | None = Query(default=None), _auth: AuthContext = Depends(require_provider_scope), @@ -510,7 +510,7 @@ async def check_dashboard_alias_provider( return _alias_error(str(exc)) -@dashboard_router.get("/provider/model_list") +@legacy_router.get("/provider/model_list") async def list_dashboard_alias_provider_models( provider_id: str | None = Query(default=None), _auth: AuthContext = Depends(require_provider_scope), @@ -522,7 +522,7 @@ async def list_dashboard_alias_provider_models( return _alias_error(str(exc)) -@dashboard_router.post("/provider/get_embedding_dim") +@legacy_router.post("/provider/get_embedding_dim") async def get_dashboard_alias_provider_embedding_dimension( request: Request, _auth: AuthContext = Depends(require_provider_scope), @@ -535,7 +535,7 @@ async def get_dashboard_alias_provider_embedding_dimension( return _alias_error(str(exc)) -@dashboard_router.get("/provider_sources/models") +@legacy_router.get("/provider_sources/models") async def list_dashboard_alias_provider_source_models( source_id: str | None = Query(default=None), _auth: AuthContext = Depends(require_provider_scope), @@ -551,7 +551,7 @@ async def list_dashboard_alias_provider_source_models( return _alias_error(str(exc)) -@dashboard_router.post("/provider_sources/update") +@legacy_router.post("/provider_sources/update") async def update_dashboard_alias_provider_source( request: Request, _auth: AuthContext = Depends(require_provider_scope), @@ -576,7 +576,7 @@ async def update_dashboard_alias_provider_source( return _alias_error(str(exc)) -@dashboard_router.post("/provider_sources/delete") +@legacy_router.post("/provider_sources/delete") async def delete_dashboard_alias_provider_source( request: Request, _auth: AuthContext = Depends(require_provider_scope), diff --git a/astrbot/dashboard/api/sessions.py b/astrbot/dashboard/api/sessions.py index 4f2a3bd8c0..42ede8ea5e 100644 --- a/astrbot/dashboard/api/sessions.py +++ b/astrbot/dashboard/api/sessions.py @@ -20,7 +20,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Sessions"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/session", tags=["Dashboard Sessions"], include_in_schema=False, @@ -254,7 +254,7 @@ async def delete_session_group( return _unexpected_error("删除分组失败", exc) -@dashboard_router.get("/list-rule") +@legacy_router.get("/list-rule") async def list_dashboard_session_rules( page: int = Query(1), page_size: int = Query(10), @@ -272,7 +272,7 @@ async def list_dashboard_session_rules( ) -@dashboard_router.post("/update-rule") +@legacy_router.post("/update-rule") async def update_dashboard_session_rule( request: Request, _username: str = Depends(require_dashboard_user), @@ -285,7 +285,7 @@ async def update_dashboard_session_rule( ) -@dashboard_router.post("/delete-rule") +@legacy_router.post("/delete-rule") async def delete_dashboard_session_rule( request: Request, _username: str = Depends(require_dashboard_user), @@ -298,7 +298,7 @@ async def delete_dashboard_session_rule( ) -@dashboard_router.post("/batch-delete-rule") +@legacy_router.post("/batch-delete-rule") async def batch_delete_dashboard_session_rule( request: Request, _username: str = Depends(require_dashboard_user), @@ -311,7 +311,7 @@ async def batch_delete_dashboard_session_rule( ) -@dashboard_router.get("/active-umos") +@legacy_router.get("/active-umos") async def list_dashboard_active_umos( _username: str = Depends(require_dashboard_user), service: SessionManagementService = Depends(get_service), @@ -319,7 +319,7 @@ async def list_dashboard_active_umos( return await _run(service.list_active_umos, label="获取 UMO 列表失败") -@dashboard_router.get("/list-all-with-status") +@legacy_router.get("/list-all-with-status") async def list_dashboard_umos_with_status( page: int = Query(1), page_size: int = Query(20), @@ -341,7 +341,7 @@ async def list_dashboard_umos_with_status( ) -@dashboard_router.post("/batch-update-service") +@legacy_router.post("/batch-update-service") async def batch_update_dashboard_session_service( request: Request, _username: str = Depends(require_dashboard_user), @@ -354,7 +354,7 @@ async def batch_update_dashboard_session_service( ) -@dashboard_router.post("/batch-update-provider") +@legacy_router.post("/batch-update-provider") async def batch_update_dashboard_session_provider( request: Request, _username: str = Depends(require_dashboard_user), @@ -367,7 +367,7 @@ async def batch_update_dashboard_session_provider( ) -@dashboard_router.get("/groups") +@legacy_router.get("/groups") async def list_dashboard_session_groups( _username: str = Depends(require_dashboard_user), service: SessionManagementService = Depends(get_service), @@ -375,7 +375,7 @@ async def list_dashboard_session_groups( return await _run(service.list_groups, label="获取分组列表失败") -@dashboard_router.post("/group/create") +@legacy_router.post("/group/create") async def create_dashboard_session_group( request: Request, _username: str = Depends(require_dashboard_user), @@ -388,7 +388,7 @@ async def create_dashboard_session_group( ) -@dashboard_router.post("/group/update") +@legacy_router.post("/group/update") async def update_dashboard_session_group( request: Request, _username: str = Depends(require_dashboard_user), @@ -401,7 +401,7 @@ async def update_dashboard_session_group( ) -@dashboard_router.post("/group/delete") +@legacy_router.post("/group/delete") async def delete_dashboard_session_group( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/skills.py b/astrbot/dashboard/api/skills.py index 464708aef0..c8925c8e4a 100644 --- a/astrbot/dashboard/api/skills.py +++ b/astrbot/dashboard/api/skills.py @@ -25,7 +25,7 @@ from .multipart import multipart_parts, single_upload router = APIRouter(tags=["Skills"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/skills", tags=["Dashboard Skills"], include_in_schema=False, @@ -377,7 +377,7 @@ async def delete_neo_skill_release( return await _run(lambda: service.delete_neo_release(_model_dict(payload))) -@dashboard_router.get("") +@legacy_router.get("") async def list_dashboard_skills( _username: str = Depends(require_dashboard_user), service: SkillsService = Depends(get_service), @@ -385,7 +385,7 @@ async def list_dashboard_skills( return await _run(service.get_skills) -@dashboard_router.post("/upload") +@legacy_router.post("/upload") async def upload_dashboard_skill( request: Request, _username: str = Depends(require_dashboard_user), @@ -397,7 +397,7 @@ async def _operation(): return await _run(_operation) -@dashboard_router.post("/batch-upload") +@legacy_router.post("/batch-upload") async def batch_upload_dashboard_skills( request: Request, _username: str = Depends(require_dashboard_user), @@ -410,7 +410,7 @@ async def _operation(): return await _run(_operation) -@dashboard_router.get("/download") +@legacy_router.get("/download") async def download_dashboard_skill( name: str, _username: str = Depends(require_dashboard_user), @@ -419,7 +419,7 @@ async def download_dashboard_skill( return await _download_skill(service, name) -@dashboard_router.get("/files") +@legacy_router.get("/files") async def list_dashboard_skill_files( request: Request, name: str, @@ -431,7 +431,7 @@ async def list_dashboard_skill_files( ) -@dashboard_router.get("/file") +@legacy_router.get("/file") async def get_dashboard_skill_file( name: str, path: str = "SKILL.md", @@ -441,7 +441,7 @@ async def get_dashboard_skill_file( return await _run(lambda: service.get_skill_file(name, path)) -@dashboard_router.post("/file") +@legacy_router.post("/file") async def update_dashboard_skill_file( request: Request, _username: str = Depends(require_dashboard_user), @@ -451,7 +451,7 @@ async def update_dashboard_skill_file( return await _run(lambda: service.update_skill_file(body)) -@dashboard_router.post("/update") +@legacy_router.post("/update") async def update_dashboard_skill( request: Request, _username: str = Depends(require_dashboard_user), @@ -461,7 +461,7 @@ async def update_dashboard_skill( return await _run(lambda: service.update_skill(body)) -@dashboard_router.post("/delete") +@legacy_router.post("/delete") async def delete_dashboard_skill( request: Request, _username: str = Depends(require_dashboard_user), @@ -471,7 +471,7 @@ async def delete_dashboard_skill( return await _run(lambda: service.delete_skill(body)) -@dashboard_router.get("/neo/candidates") +@legacy_router.get("/neo/candidates") async def list_dashboard_neo_skill_candidates( request: Request, _username: str = Depends(require_dashboard_user), @@ -480,7 +480,7 @@ async def list_dashboard_neo_skill_candidates( return await _run(service.get_neo_candidates(dict(request.query_params))) -@dashboard_router.get("/neo/releases") +@legacy_router.get("/neo/releases") async def list_dashboard_neo_skill_releases( request: Request, _username: str = Depends(require_dashboard_user), @@ -489,7 +489,7 @@ async def list_dashboard_neo_skill_releases( return await _run(service.get_neo_releases(dict(request.query_params))) -@dashboard_router.get("/neo/payload") +@legacy_router.get("/neo/payload") async def get_dashboard_neo_skill_payload( request: Request, _username: str = Depends(require_dashboard_user), @@ -498,7 +498,7 @@ async def get_dashboard_neo_skill_payload( return await _run(service.get_neo_payload(dict(request.query_params))) -@dashboard_router.post("/neo/evaluate") +@legacy_router.post("/neo/evaluate") async def evaluate_dashboard_neo_skill_candidate( request: Request, _username: str = Depends(require_dashboard_user), @@ -508,7 +508,7 @@ async def evaluate_dashboard_neo_skill_candidate( return await _run(lambda: service.evaluate_neo_candidate(body)) -@dashboard_router.post("/neo/promote") +@legacy_router.post("/neo/promote") async def promote_dashboard_neo_skill_candidate( request: Request, _username: str = Depends(require_dashboard_user), @@ -518,7 +518,7 @@ async def promote_dashboard_neo_skill_candidate( return await _run(lambda: service.promote_neo_candidate(body)) -@dashboard_router.post("/neo/rollback") +@legacy_router.post("/neo/rollback") async def rollback_dashboard_neo_skill_release( request: Request, _username: str = Depends(require_dashboard_user), @@ -528,7 +528,7 @@ async def rollback_dashboard_neo_skill_release( return await _run(lambda: service.rollback_neo_release(body)) -@dashboard_router.post("/neo/sync") +@legacy_router.post("/neo/sync") async def sync_dashboard_neo_skill_release( request: Request, _username: str = Depends(require_dashboard_user), @@ -538,7 +538,7 @@ async def sync_dashboard_neo_skill_release( return await _run(lambda: service.sync_neo_release(body)) -@dashboard_router.post("/neo/delete-candidate") +@legacy_router.post("/neo/delete-candidate") async def delete_dashboard_neo_skill_candidate( request: Request, _username: str = Depends(require_dashboard_user), @@ -548,7 +548,7 @@ async def delete_dashboard_neo_skill_candidate( return await _run(lambda: service.delete_neo_candidate(body)) -@dashboard_router.post("/neo/delete-release") +@legacy_router.post("/neo/delete-release") async def delete_dashboard_neo_skill_release( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/stats.py b/astrbot/dashboard/api/stats.py index 5c1652e6b4..6bb87817b0 100644 --- a/astrbot/dashboard/api/stats.py +++ b/astrbot/dashboard/api/stats.py @@ -10,7 +10,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["System Stats"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/stat", tags=["Dashboard System Stats"], include_in_schema=False, @@ -141,7 +141,7 @@ async def restart_system( return await _run(service.restart_core()) -@dashboard_router.get("/get") +@legacy_router.get("/get") async def get_dashboard_stats( offset_sec: int | None = Query(default=86400), _username: str = Depends(require_dashboard_user), @@ -150,7 +150,7 @@ async def get_dashboard_stats( return await _run(service.get_stat(_parse_int(offset_sec, 86400, "offset_sec"))) -@dashboard_router.get("/provider-tokens") +@legacy_router.get("/provider-tokens") async def get_dashboard_provider_token_stats( days: int | None = Query(default=1), _username: str = Depends(require_dashboard_user), @@ -159,7 +159,7 @@ async def get_dashboard_provider_token_stats( return await _run(service.get_provider_token_stats(_parse_int(days, 1, "days"))) -@dashboard_router.get("/version") +@legacy_router.get("/version") async def get_dashboard_version( _username: str = Depends(require_dashboard_user), service: StatService = Depends(get_service), @@ -167,14 +167,14 @@ async def get_dashboard_version( return await _run(service.get_version()) -@dashboard_router.get("/start-time") +@legacy_router.get("/start-time") async def get_dashboard_start_time( service: StatService = Depends(get_service), ): return await _run(service.get_start_time) -@dashboard_router.post("/restart-core") +@legacy_router.post("/restart-core") async def restart_dashboard_core( _username: str = Depends(require_dashboard_user), service: StatService = Depends(get_service), @@ -182,7 +182,7 @@ async def restart_dashboard_core( return await _run(service.restart_core()) -@dashboard_router.post("/test-ghproxy-connection") +@legacy_router.post("/test-ghproxy-connection") async def test_dashboard_ghproxy_connection( payload: GhProxyTestRequest, _username: str = Depends(require_dashboard_user), @@ -191,7 +191,7 @@ async def test_dashboard_ghproxy_connection( return await _run(service.test_ghproxy_connection(payload.proxy_url)) -@dashboard_router.get("/changelog") +@legacy_router.get("/changelog") async def get_dashboard_changelog( version: str | None = None, _username: str = Depends(require_dashboard_user), @@ -200,7 +200,7 @@ async def get_dashboard_changelog( return await _run(lambda: service.get_changelog(version)) -@dashboard_router.get("/changelog/list") +@legacy_router.get("/changelog/list") async def list_dashboard_changelog_versions( _username: str = Depends(require_dashboard_user), service: StatService = Depends(get_service), @@ -208,7 +208,7 @@ async def list_dashboard_changelog_versions( return await _run(service.list_changelog_versions) -@dashboard_router.get("/first-notice") +@legacy_router.get("/first-notice") async def get_dashboard_first_notice( locale: str | None = None, _username: str = Depends(require_dashboard_user), @@ -217,7 +217,7 @@ async def get_dashboard_first_notice( return await _run(lambda: service.get_first_notice(locale)) -@dashboard_router.get("/storage") +@legacy_router.get("/storage") async def get_dashboard_storage_status( _username: str = Depends(require_dashboard_user), service: StatService = Depends(get_service), @@ -225,7 +225,7 @@ async def get_dashboard_storage_status( return await _run(service.get_storage_status()) -@dashboard_router.post("/storage/cleanup") +@legacy_router.post("/storage/cleanup") async def cleanup_dashboard_storage( payload: StorageCleanupRequest | None = None, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/subagents.py b/astrbot/dashboard/api/subagents.py index d7e0b2e6bb..c26bf4ad57 100644 --- a/astrbot/dashboard/api/subagents.py +++ b/astrbot/dashboard/api/subagents.py @@ -12,7 +12,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Subagents"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/subagent", tags=["Dashboard Subagents"], include_in_schema=False, @@ -82,7 +82,7 @@ async def get_subagent_tools( return await _get_available_tools(service) -@dashboard_router.get("/config") +@legacy_router.get("/config") async def get_dashboard_subagent_config( _username: str = Depends(require_dashboard_user), service: SubAgentService = Depends(get_service), @@ -90,7 +90,7 @@ async def get_dashboard_subagent_config( return await _get_config(service) -@dashboard_router.post("/config") +@legacy_router.post("/config") async def update_dashboard_subagent_config( payload: SubAgentConfigRequest, _username: str = Depends(require_dashboard_user), @@ -99,7 +99,7 @@ async def update_dashboard_subagent_config( return await _update_config(payload, service) -@dashboard_router.get("/available-tools") +@legacy_router.get("/available-tools") async def get_dashboard_subagent_tools( _username: str = Depends(require_dashboard_user), service: SubAgentService = Depends(get_service), diff --git a/astrbot/dashboard/api/t2i.py b/astrbot/dashboard/api/t2i.py index 29f8403810..3edc66fd73 100644 --- a/astrbot/dashboard/api/t2i.py +++ b/astrbot/dashboard/api/t2i.py @@ -11,7 +11,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Text To Image"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/t2i", tags=["Dashboard Text To Image"], include_in_schema=False, @@ -152,7 +152,7 @@ async def delete_t2i_template( ) -@dashboard_router.get("/templates") +@legacy_router.get("/templates") async def list_dashboard_t2i_templates( _username: str = Depends(require_dashboard_user), service: T2iService = Depends(get_service), @@ -160,7 +160,7 @@ async def list_dashboard_t2i_templates( return await _run(service.list_templates) -@dashboard_router.get("/templates/active") +@legacy_router.get("/templates/active") async def get_dashboard_active_t2i_template( _username: str = Depends(require_dashboard_user), service: T2iService = Depends(get_service), @@ -168,7 +168,7 @@ async def get_dashboard_active_t2i_template( return await _run(service.get_active_template) -@dashboard_router.post("/templates/create") +@legacy_router.post("/templates/create") async def create_dashboard_t2i_template( request: Request, _username: str = Depends(require_dashboard_user), @@ -182,7 +182,7 @@ async def create_dashboard_t2i_template( ) -@dashboard_router.post("/templates/reset_default") +@legacy_router.post("/templates/reset_default") async def reset_dashboard_default_t2i_template( _username: str = Depends(require_dashboard_user), service: T2iService = Depends(get_service), @@ -190,7 +190,7 @@ async def reset_dashboard_default_t2i_template( return await _run(service.reset_default_template, result_as_message=True) -@dashboard_router.post("/templates/set_active") +@legacy_router.post("/templates/set_active") async def set_dashboard_active_t2i_template( request: Request, _username: str = Depends(require_dashboard_user), @@ -203,7 +203,7 @@ async def set_dashboard_active_t2i_template( ) -@dashboard_router.get("/templates/{name:path}") +@legacy_router.get("/templates/{name:path}") async def get_dashboard_t2i_template( name: str, _username: str = Depends(require_dashboard_user), @@ -212,7 +212,7 @@ async def get_dashboard_t2i_template( return await _run(lambda: service.get_template(name)) -@dashboard_router.put("/templates/{name:path}") +@legacy_router.put("/templates/{name:path}") async def update_dashboard_t2i_template( name: str, request: Request, @@ -223,7 +223,7 @@ async def update_dashboard_t2i_template( return await _run(lambda: service.update_template(name, body.get("content"))) -@dashboard_router.delete("/templates/{name:path}") +@legacy_router.delete("/templates/{name:path}") async def delete_dashboard_t2i_template( name: str, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/tools.py b/astrbot/dashboard/api/tools.py index 1a3c94fdc4..74a6e3d510 100644 --- a/astrbot/dashboard/api/tools.py +++ b/astrbot/dashboard/api/tools.py @@ -18,7 +18,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Extension Components"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api", tags=["Dashboard Extension Components"], include_in_schema=False, @@ -317,7 +317,7 @@ async def sync_modelscope_mcp_servers( return await _sync_modelscope_mcp_servers(access_token or "", service) -@dashboard_router.get("/tools/list") +@legacy_router.get("/tools/list") async def list_dashboard_tools( _username: str = Depends(require_dashboard_user), service: ToolsService = Depends(get_service), @@ -325,7 +325,7 @@ async def list_dashboard_tools( return await _run(service.get_tool_list) -@dashboard_router.post("/tools/toggle-tool") +@legacy_router.post("/tools/toggle-tool") async def toggle_dashboard_tool( request: Request, _username: str = Depends(require_dashboard_user), @@ -336,7 +336,7 @@ async def toggle_dashboard_tool( return await _toggle_tool(tool_id, bool(body.get("activate")), service) -@dashboard_router.post("/tools/permission") +@legacy_router.post("/tools/permission") async def update_dashboard_tool_permission( request: Request, _username: str = Depends(require_dashboard_user), @@ -352,7 +352,7 @@ async def update_dashboard_tool_permission( ) -@dashboard_router.get("/tools/mcp/servers") +@legacy_router.get("/tools/mcp/servers") async def list_dashboard_mcp_servers( _username: str = Depends(require_dashboard_user), service: ToolsService = Depends(get_service), @@ -360,7 +360,7 @@ async def list_dashboard_mcp_servers( return await _run(service.get_mcp_servers) -@dashboard_router.post("/tools/mcp/add") +@legacy_router.post("/tools/mcp/add") async def add_dashboard_mcp_server( request: Request, _username: str = Depends(require_dashboard_user), @@ -369,7 +369,7 @@ async def add_dashboard_mcp_server( return await _create_mcp_server(await _json_or_empty(request), service) -@dashboard_router.post("/tools/mcp/update") +@legacy_router.post("/tools/mcp/update") async def update_dashboard_mcp_server( request: Request, _username: str = Depends(require_dashboard_user), @@ -379,7 +379,7 @@ async def update_dashboard_mcp_server( return await _update_mcp_server(_server_name_from_body(body), body, service) -@dashboard_router.post("/tools/mcp/delete") +@legacy_router.post("/tools/mcp/delete") async def delete_dashboard_mcp_server( request: Request, _username: str = Depends(require_dashboard_user), @@ -389,7 +389,7 @@ async def delete_dashboard_mcp_server( return await _delete_mcp_server(_required_text(body.get("name"), "name"), service) -@dashboard_router.post("/tools/mcp/test") +@legacy_router.post("/tools/mcp/test") async def test_dashboard_mcp_connection( request: Request, _username: str = Depends(require_dashboard_user), @@ -409,7 +409,7 @@ async def test_dashboard_mcp_connection( ) -@dashboard_router.post("/tools/mcp/sync-provider") +@legacy_router.post("/tools/mcp/sync-provider") async def sync_dashboard_mcp_provider( request: Request, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/api/updates.py b/astrbot/dashboard/api/updates.py index 80911afd6e..05b8a4d251 100644 --- a/astrbot/dashboard/api/updates.py +++ b/astrbot/dashboard/api/updates.py @@ -14,7 +14,7 @@ from .auth import AuthContext, require_dashboard_user, require_scope router = APIRouter(tags=["Updates"]) -dashboard_router = APIRouter( +legacy_router = APIRouter( prefix="/api/update", tags=["Dashboard Updates"], include_in_schema=False, @@ -79,7 +79,7 @@ async def check_updates( return await _run(lambda: service.check_update(update_type)) -@dashboard_router.get("/check") +@legacy_router.get("/check") async def check_dashboard_updates( update_type: str | None = Query(default=None, alias="type"), _username: str = Depends(require_dashboard_user), @@ -96,7 +96,7 @@ async def update_releases( return await _run(service.get_releases) -@dashboard_router.get("/releases") +@legacy_router.get("/releases") async def dashboard_update_releases( _username: str = Depends(require_dashboard_user), service: UpdateService = Depends(get_service), @@ -113,7 +113,7 @@ async def update_progress( return await _run(lambda: service.get_update_progress(task_id)) -@dashboard_router.get("/progress") +@legacy_router.get("/progress") async def dashboard_update_progress( progress_id: str | None = Query(default=None, alias="id"), _username: str = Depends(require_dashboard_user), @@ -131,7 +131,7 @@ async def update_core( return await _run(lambda: service.update_project(_model_dict(payload))) -@dashboard_router.post("/do") +@legacy_router.post("/do") async def update_dashboard_core( payload: UpdateRequest, _username: str = Depends(require_dashboard_user), @@ -148,7 +148,7 @@ async def update_dashboard( return await _run(service.update_dashboard) -@dashboard_router.post("/dashboard") +@legacy_router.post("/dashboard") async def update_dashboard_assets( _username: str = Depends(require_dashboard_user), service: UpdateService = Depends(get_service), @@ -165,7 +165,7 @@ async def install_pip_package( return await _run(lambda: service.install_pip_package(_model_dict(payload))) -@dashboard_router.post("/pip-install") +@legacy_router.post("/pip-install") async def install_dashboard_pip_package( payload: PipInstallRequest, _username: str = Depends(require_dashboard_user), @@ -184,7 +184,7 @@ async def run_migration( return await _run(lambda: service.do_migration_v4(body)) -@dashboard_router.post("/migration") +@legacy_router.post("/migration") async def run_dashboard_migration( payload: MigrationRequest | None = None, _username: str = Depends(require_dashboard_user), diff --git a/astrbot/dashboard/asgi_runtime.py b/astrbot/dashboard/asgi_runtime.py index 326e9a1572..d985ec4b82 100644 --- a/astrbot/dashboard/asgi_runtime.py +++ b/astrbot/dashboard/asgi_runtime.py @@ -4,7 +4,7 @@ import inspect import re from collections.abc import Callable, Iterable -from contextlib import contextmanager +from contextlib import asynccontextmanager, contextmanager from pathlib import Path from typing import Any @@ -463,12 +463,14 @@ async def _call_view(view_func: Callable, path_params: dict[str, Any]): result = view_func(**path_params) if inspect.isawaitable(result): result = await result - return _coerce_view_result(result) + return await _coerce_view_result(result) -def _coerce_view_result(result: Any): +async def _coerce_view_result(result: Any): if isinstance(result, Response): return result + if _is_quart_response(result): + return await _quart_response_to_starlette(result) if isinstance(result, tuple): content = result[0] if result else None @@ -482,6 +484,12 @@ def _coerce_view_result(result: Any): if headers: content.headers.update(headers) return content + if _is_quart_response(content): + return await _quart_response_to_starlette( + content, + status_code=status_code, + extra_headers=headers, + ) return _response_from_content(content, status_code=status_code, headers=headers) if isinstance(result, dict | list): @@ -508,15 +516,103 @@ def _response_from_content( ) +def _is_quart_response(value: Any) -> bool: + return ( + hasattr(value, "get_data") + and inspect.iscoroutinefunction(value.get_data) + and hasattr(value, "headers") + and hasattr(value, "status_code") + ) + + +def _response_header_pairs(headers: Any) -> list[tuple[str, str]]: + if headers is None: + return [] + if hasattr(headers, "to_wsgi_list"): + return [(str(key), str(value)) for key, value in headers.to_wsgi_list()] + if hasattr(headers, "items"): + return [(str(key), str(value)) for key, value in headers.items()] + return [(str(key), str(value)) for key, value in headers] + + +async def _quart_response_to_starlette( + quart_response: Any, + *, + status_code: int | None = None, + extra_headers: dict[str, str] | None = None, +) -> Response: + content = await quart_response.get_data() + response = Response( + content=content, + status_code=status_code or int(quart_response.status_code), + ) + pairs = _response_header_pairs(quart_response.headers) + if extra_headers: + pairs.extend((str(key), str(value)) for key, value in extra_headers.items()) + response.raw_headers = [ + (key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in pairs + ] + return response + + +@asynccontextmanager +async def bind_quart_request_context( + request_: Request, + app: FastAPIAppAdapter, + *, + path: str | None = None, + g_obj: DashboardRequestState | None = None, +): + try: + from quart import g as quart_g + except ImportError: + yield + return + + quart_app = app.get_quart_compat_app() + headers = { + key.decode("latin-1"): value.decode("latin-1") + for key, value in request_.scope.get("headers", []) + } + body = await request_.body() + request_path = path or str(request_.url.path) + if "?" not in request_path and request_.url.query: + request_path = f"{request_path}?{request_.url.query}" + + async with quart_app.test_request_context( + request_path, + method=request_.method, + headers=headers, + data=body, + scheme=request_.url.scheme, + root_path=request_.scope.get("root_path", ""), + scope_base={ + "client": request_.scope.get("client"), + "server": request_.scope.get("server"), + }, + ): + if g_obj is not None: + for key, value in getattr(g_obj, "_values", {}).items(): + setattr(quart_g, key, value) + yield + + async def call_request_view( request_: Request, app: FastAPIAppAdapter, view_func: Callable, path_params: dict[str, Any] | None = None, g_obj: DashboardRequestState | None = None, + quart_compat_path: str | None = None, ): with bind_request_context(request_, app, g_obj): - return await _call_view(view_func, path_params or {}) + async with bind_quart_request_context( + request_, + app, + path=quart_compat_path, + g_obj=g_obj, + ): + return await _call_view(view_func, path_params or {}) async def call_websocket_view( @@ -542,6 +638,15 @@ def __init__(self, app: FastAPI, static_folder: str | None = None) -> None: self.debug = False self.testing = False self.name = "dashboard" + self._quart_compat_app: Any | None = None + + def get_quart_compat_app(self): + if self._quart_compat_app is None: + from quart import Quart + + self._quart_compat_app = Quart("astrbot_dashboard_plugin_compat") + self._quart_compat_app.json.sort_keys = False + return self._quart_compat_app def add_url_rule( self, diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index a354613d86..f723e1a93a 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1708,6 +1708,54 @@ async def test_v1_plugin_extension_maps_nested_plugin_path( } +@pytest.mark.asyncio +async def test_v1_plugin_extension_supports_quart_request_context( + asgi_client: httpx.AsyncClient, + fake_core_lifecycle, +): + from quart import g as quart_g + from quart import jsonify as quart_jsonify + from quart import request as quart_request + + async def quart_plugin_extension(item_id: str): + return quart_jsonify( + { + "status": "ok", + "data": { + "item_id": item_id, + "path": quart_request.path, + "method": quart_request.method, + "source": quart_request.args.get("source"), + "payload": await quart_request.get_json(), + "username": quart_g.username, + }, + } + ) + + fake_core_lifecycle.star_context.registered_web_apis = [ + ("/quart/", quart_plugin_extension, ["POST"], "quart") + ] + + response = await asgi_client.post( + "/api/v1/plugins/extensions/quart/demo-item?source=v1", + json={"value": "demo"}, + headers=_jwt_headers(), + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + data = response.json() + assert data["status"] == "ok" + assert data["data"] == { + "item_id": "demo-item", + "path": "/api/plug/quart/demo-item", + "method": "POST", + "source": "v1", + "payload": {"value": "demo"}, + "username": "fastapi-v1-test", + } + + @pytest.mark.asyncio async def test_v1_plugin_config_file_routes_reach_service_layer( asgi_client: httpx.AsyncClient, From 18465916cd575ff142f665481c7f519b071113eb Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 18:26:08 +0800 Subject: [PATCH 09/29] chore: remove cli test --- tests/test_cli_command_aliases.py | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 tests/test_cli_command_aliases.py diff --git a/tests/test_cli_command_aliases.py b/tests/test_cli_command_aliases.py deleted file mode 100644 index 998219e552..0000000000 --- a/tests/test_cli_command_aliases.py +++ /dev/null @@ -1,25 +0,0 @@ -from click.testing import CliRunner - -from astrbot.cli.__main__ import cli - - -def test_top_level_help_uses_product_command_names(): - result = CliRunner().invoke(cli, ["help"]) - - assert result.exit_code == 0 - assert "config" in result.output - assert "plugin" in result.output - assert " conf " not in result.output - assert " plug " not in result.output - - -def test_legacy_config_and_plugin_aliases_still_work(): - runner = CliRunner() - - config_result = runner.invoke(cli, ["help", "conf"]) - plugin_result = runner.invoke(cli, ["help", "plug"]) - - assert config_result.exit_code == 0 - assert "Configuration management commands" in config_result.output - assert plugin_result.exit_code == 0 - assert "Plugin management" in plugin_result.output From 9f92e5b0b15aa46a723a779b50b7e34f2a4de311 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 11:22:35 +0800 Subject: [PATCH 10/29] fix: update dashboard tests for fastapi migration --- astrbot/dashboard/services/tools_service.py | 4 +- tests/test_dashboard.py | 17 ++- tests/unit/test_tool_permission.py | 143 +++++++------------- 3 files changed, 63 insertions(+), 101 deletions(-) diff --git a/astrbot/dashboard/services/tools_service.py b/astrbot/dashboard/services/tools_service.py index 0976d0d3b8..efd487185d 100644 --- a/astrbot/dashboard/services/tools_service.py +++ b/astrbot/dashboard/services/tools_service.py @@ -568,9 +568,7 @@ def _serialize_tool(self, tool, config_entries: list[dict]) -> dict: scope_id="global", ) defaults = ( - perms_store.get("_default", {}) - if isinstance(perms_store, dict) - else {} + perms_store.get("_default", {}) if isinstance(perms_store, dict) else {} ) configured = tool.name in defaults permission = defaults[tool.name] if configured else "member" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 75f6471e98..8c28663227 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -64,6 +64,15 @@ def _strip_query(url: str) -> str: return urlunsplit(("", "", parsed.path, "", parsed.fragment)) +def _assert_cookie_samesite_strict(cookie_header: str) -> None: + """Assert that a cookie header carries a strict SameSite attribute. + + Args: + cookie_header: The raw Set-Cookie header value to inspect. + """ + assert "samesite=strict" in cookie_header.lower() + + @pytest.fixture def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch): plugin_root = ( @@ -337,7 +346,7 @@ async def test_auth_login( ) assert jwt_cookie_header assert "HttpOnly" in jwt_cookie_header - assert "SameSite=Strict" in jwt_cookie_header + _assert_cookie_samesite_strict(jwt_cookie_header) assert "Secure" not in jwt_cookie_header @@ -366,7 +375,7 @@ async def test_auth_login_secure_cookie_override( ) assert jwt_cookie_header assert "Secure" in jwt_cookie_header - assert "SameSite=Strict" in jwt_cookie_header + _assert_cookie_samesite_strict(jwt_cookie_header) @pytest.mark.asyncio @@ -724,7 +733,7 @@ async def test_auth_login_sets_trusted_device_cookie_when_flag_true( ) assert trusted_cookie_header assert "HttpOnly" in trusted_cookie_header - assert "SameSite=Strict" in trusted_cookie_header + _assert_cookie_samesite_strict(trusted_cookie_header) assert "Path=/api/auth" in trusted_cookie_header finally: await _restore_dashboard_password_state( @@ -1878,7 +1887,7 @@ async def test_logout_clears_cookie_for_plugin_page( assert clear_cookie_header assert f"{DASHBOARD_JWT_COOKIE_NAME}=;" in clear_cookie_header assert "Max-Age=0" in clear_cookie_header - assert "SameSite=Strict" in clear_cookie_header + _assert_cookie_samesite_strict(clear_cookie_header) response = await test_client.get( f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/" diff --git a/tests/unit/test_tool_permission.py b/tests/unit/test_tool_permission.py index 1a3a8a376b..fbfc115ee3 100644 --- a/tests/unit/test_tool_permission.py +++ b/tests/unit/test_tool_permission.py @@ -1,36 +1,20 @@ """Tests for per-tool permission management.""" -import asyncio -import json -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import pytest from astrbot.core import sp -from astrbot.core.agent.tool import FunctionTool, ToolSet +from astrbot.core.agent.tool import FunctionTool from astrbot.core.provider.func_tool_manager import ( FunctionToolManager, _PermissionGuardedTool, ) -from astrbot.core.tools.computer_tools.shell import ExecuteShellTool - +from astrbot.dashboard.services.tools_service import ToolsService, ToolsServiceError # ── helpers ────────────────────────────────────────────────────────── -def _make_coro(value: object): - """Return a fresh coroutine object that resolves to ``value``. - - Used to mock Quart's ``await request.json`` where ``json`` is an - async property — assigning a coroutine lets ``await`` work directly.""" - - async def _inner(): - return value - - return _inner() - - def _make_context(role: str = "member", sender_id: str = "user_123"): """Return a mock context object suitable for tool permission checks.""" @@ -70,6 +54,26 @@ def _clear_tool_permissions() -> None: sp.put("tool_permissions", {}, scope="global", scope_id="global") +def _make_tools_service( + tool_mgr: FunctionToolManager | None = None, +) -> ToolsService: + """Create a minimal tools service for permission unit tests. + + Args: + tool_mgr: Optional tool manager to attach to the service. + + Returns: + A ToolsService with mocked lifecycle config access. + """ + service = ToolsService.__new__(ToolsService) + service.core_lifecycle = MagicMock() + service.core_lifecycle.astrbot_config_mgr = MagicMock() + service.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = [] + service.core_lifecycle.astrbot_config_mgr.confs = {} + service.tool_mgr = tool_mgr or FunctionToolManager() + return service + + # ── _default_permission ────────────────────────────────────────────── @@ -309,16 +313,7 @@ def test_get_full_tool_set_wraps_non_builtin(): class TestGetToolListPermission: @pytest.mark.asyncio async def test_list_includes_permission_fields_for_non_builtin(self): - from astrbot.dashboard.routes.tools import ToolsRoute - - # Minimal stubs to avoid full core lifecycle init - route = ToolsRoute.__new__(ToolsRoute) - route.core_lifecycle = MagicMock() - route.core_lifecycle.astrbot_config_mgr = MagicMock() - route.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = [] - route.core_lifecycle.astrbot_config_mgr.confs = {} - route.tool_mgr = FunctionToolManager() - + service = _make_tools_service() sp.put( "tool_permissions", {"_default": {"my_plugin_tool": "admin"}}, @@ -326,10 +321,8 @@ async def test_list_includes_permission_fields_for_non_builtin(self): scope_id="global", ) try: - route.tool_mgr.func_list.append(_dummy_tool("my_plugin_tool")) - resp = await route.get_tool_list() - data = json.loads(json.dumps(resp)) # simulate json serialisation - tools = data["data"] + service.tool_mgr.func_list.append(_dummy_tool("my_plugin_tool")) + tools = service.get_tool_list() target = next(t for t in tools if t["name"] == "my_plugin_tool") assert target["permission"] == "admin" @@ -340,18 +333,8 @@ async def test_list_includes_permission_fields_for_non_builtin(self): @pytest.mark.asyncio async def test_list_no_permission_fields_for_builtin(self): - from astrbot.dashboard.routes.tools import ToolsRoute - - route = ToolsRoute.__new__(ToolsRoute) - route.core_lifecycle = MagicMock() - route.core_lifecycle.astrbot_config_mgr = MagicMock() - route.core_lifecycle.astrbot_config_mgr.get_conf_list.return_value = [] - route.core_lifecycle.astrbot_config_mgr.confs = {} - route.tool_mgr = FunctionToolManager() - - resp = await route.get_tool_list() - data = json.loads(json.dumps(resp)) - tools = data["data"] + service = _make_tools_service() + tools = service.get_tool_list() target = next(t for t in tools if t["name"] == "astrbot_execute_shell") assert "permission" not in target @@ -365,20 +348,14 @@ async def test_list_no_permission_fields_for_builtin(self): class TestUpdateToolPermission: @pytest.mark.asyncio async def test_set_admin_permission(self): - from astrbot.dashboard.routes.tools import ToolsRoute - - route = ToolsRoute.__new__(ToolsRoute) - route.core_lifecycle = MagicMock() - route.tool_mgr = FunctionToolManager() - route.tool_mgr.func_list.append(_dummy_tool("target_tool")) + service = _make_tools_service() + service.tool_mgr.func_list.append(_dummy_tool("target_tool")) _clear_tool_permissions() - mock_req = MagicMock() - mock_req.json = _make_coro({"name": "target_tool", "permission": "admin"}) - with patch("astrbot.dashboard.routes.tools.request", mock_req): - resp = await route.update_tool_permission() - data = json.loads(json.dumps(resp)) - assert data["status"] == "ok" + message = service.update_tool_permission( + {"name": "target_tool", "permission": "admin"} + ) + assert "target_tool" in message stored = sp.get( "tool_permissions", {}, scope="global", scope_id="global" @@ -387,48 +364,26 @@ async def test_set_admin_permission(self): @pytest.mark.asyncio async def test_reject_builtin_tool(self): - from astrbot.dashboard.routes.tools import ToolsRoute - - route = ToolsRoute.__new__(ToolsRoute) - route.core_lifecycle = MagicMock() - route.tool_mgr = FunctionToolManager() + service = _make_tools_service() - mock_req = MagicMock() - mock_req.json = _make_coro({"name": "astrbot_execute_shell", "permission": "admin"}) - with patch("astrbot.dashboard.routes.tools.request", mock_req): - resp = await route.update_tool_permission() - data = json.loads(json.dumps(resp)) - assert data["status"] == "error" - assert "builtin" in str(data["message"]).lower() + with pytest.raises(ToolsServiceError, match="Builtin"): + service.update_tool_permission( + {"name": "astrbot_execute_shell", "permission": "admin"} + ) @pytest.mark.asyncio async def test_reject_unknown_tool(self): - from astrbot.dashboard.routes.tools import ToolsRoute + service = _make_tools_service() - route = ToolsRoute.__new__(ToolsRoute) - route.core_lifecycle = MagicMock() - route.tool_mgr = FunctionToolManager() - - mock_req = MagicMock() - mock_req.json = _make_coro({"name": "ghost_tool", "permission": "admin"}) - with patch("astrbot.dashboard.routes.tools.request", mock_req): - resp = await route.update_tool_permission() - data = json.loads(json.dumps(resp)) - assert data["status"] == "error" - assert "not found" in str(data["message"]).lower() + with pytest.raises(ToolsServiceError, match="not found"): + service.update_tool_permission({"name": "ghost_tool", "permission": "admin"}) @pytest.mark.asyncio async def test_reject_invalid_permission_value(self): - from astrbot.dashboard.routes.tools import ToolsRoute - - route = ToolsRoute.__new__(ToolsRoute) - route.core_lifecycle = MagicMock() - route.tool_mgr = FunctionToolManager() - route.tool_mgr.func_list.append(_dummy_tool("target_tool")) - - mock_req = MagicMock() - mock_req.json = _make_coro({"name": "target_tool", "permission": "everyone"}) - with patch("astrbot.dashboard.routes.tools.request", mock_req): - resp = await route.update_tool_permission() - data = json.loads(json.dumps(resp)) - assert data["status"] == "error" + service = _make_tools_service() + service.tool_mgr.func_list.append(_dummy_tool("target_tool")) + + with pytest.raises(ToolsServiceError, match="admin or member"): + service.update_tool_permission( + {"name": "target_tool", "permission": "everyone"} + ) From eb94c09102f7ec153b297e7e8e2102f2e1372223 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 11:28:37 +0800 Subject: [PATCH 11/29] chore: satisfy ruff checks --- astrbot/api/all.py | 3 +- astrbot/core/utils/t2i/local_strategy.py | 30 +++++--- docs/tests/test_sync_docs_to_wiki.py | 4 +- tests/agent/test_token_counter.py | 79 ++++++++++++++------ tests/conftest.py | 2 - tests/test_computer_config.py | 17 +++-- tests/test_conversation_commands.py | 72 ++++++++++-------- tests/test_kook/shared.py | 11 ++- tests/test_kook/test_kook_event.py | 11 ++- tests/test_kook/test_kook_types.py | 2 +- tests/test_main.py | 1 - tests/test_profile_aware_tools.py | 9 +-- tests/test_shipyard_neo_booter.py | 27 ++++--- tests/test_skill_manager_sandbox_cache.py | 1 - tests/unit/test_aiocqhttp_reply.py | 9 +-- tests/unit/test_cli_plugin_utils.py | 6 +- tests/unit/test_cron_manager.py | 32 ++++++-- tests/unit/test_group_chat_context_wiring.py | 4 +- tests/unit/test_session_lock.py | 8 +- tests/unit/test_star_base.py | 14 ++-- tests/unit/test_tool_conflict_resolution.py | 2 - tests/unit/test_tool_permission.py | 14 ++-- 22 files changed, 211 insertions(+), 147 deletions(-) diff --git a/astrbot/api/all.py b/astrbot/api/all.py index df3e1170fb..7fd5504ca3 100644 --- a/astrbot/api/all.py +++ b/astrbot/api/all.py @@ -1,3 +1,4 @@ +# ruff: noqa: F401, F403, F811, I001 from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot import logger from astrbot.core import html_renderer @@ -51,4 +52,4 @@ from astrbot.core.platform.register import register_platform_adapter -from .message_components import * \ No newline at end of file +from .message_components import * diff --git a/astrbot/core/utils/t2i/local_strategy.py b/astrbot/core/utils/t2i/local_strategy.py index 2fa2351291..3d394dccc9 100644 --- a/astrbot/core/utils/t2i/local_strategy.py +++ b/astrbot/core/utils/t2i/local_strategy.py @@ -1,17 +1,18 @@ -import re import os -import aiohttp +import re import ssl -import certifi -from io import BytesIO -from typing import List, Tuple from abc import ABC, abstractmethod +from io import BytesIO + +import aiohttp +import certifi +from PIL import Image, ImageDraw, ImageFont + from astrbot.core.config import VERSION +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.io import save_temp_img from . import RenderStrategy -from PIL import ImageFont, Image, ImageDraw -from astrbot.core.utils.io import save_temp_img -from astrbot.core.utils.astrbot_path import get_astrbot_data_path class FontManager: @@ -20,7 +21,7 @@ class FontManager: _font_cache = {} @classmethod - def get_font(cls, size: int) -> ImageFont.FreeTypeFont|ImageFont.ImageFont: + def get_font(cls, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: """获取指定大小的字体,优先从缓存获取""" if size in cls._font_cache: return cls._font_cache[size] @@ -66,7 +67,9 @@ class TextMeasurer: """测量文本尺寸的工具类""" @staticmethod - def get_text_size(text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont) -> tuple[int, int]: + def get_text_size( + text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont + ) -> tuple[int, int]: """获取文本的尺寸""" # 依赖库Pillow>=11.2.1,不再需要考虑<9.0.0 @@ -75,7 +78,7 @@ def get_text_size(text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont) - @staticmethod def split_text_to_fit_width( - text: str, font: ImageFont.FreeTypeFont|ImageFont.ImageFont, max_width: int + text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_width: int ) -> list[str]: """将文本拆分为多行,确保每行不超过指定宽度""" lines = [] @@ -293,7 +296,10 @@ def render( # 倾斜变换,使用仿射变换实现斜体效果 # 变换矩阵: [1, 0.2, 0, 0, 1, 0] italic_img = text_img.transform( - text_img.size, Image.Transform.AFFINE, (1, 0.2, 0, 0, 1, 0), Image.Resampling.BICUBIC + text_img.size, + Image.Transform.AFFINE, + (1, 0.2, 0, 0, 1, 0), + Image.Resampling.BICUBIC, ) # 粘贴到原图像 diff --git a/docs/tests/test_sync_docs_to_wiki.py b/docs/tests/test_sync_docs_to_wiki.py index 4bcecc0073..b6cf35e807 100644 --- a/docs/tests/test_sync_docs_to_wiki.py +++ b/docs/tests/test_sync_docs_to_wiki.py @@ -1,8 +1,8 @@ +import sys +import unittest from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path -import sys from tempfile import TemporaryDirectory -import unittest def load_sync_module(): diff --git a/tests/agent/test_token_counter.py b/tests/agent/test_token_counter.py index c68b056e66..49d44a93d4 100644 --- a/tests/agent/test_token_counter.py +++ b/tests/agent/test_token_counter.py @@ -13,7 +13,6 @@ ThinkPart, ) - counter = EstimateTokenCounter() @@ -39,16 +38,26 @@ def test_text_part(self): class TestMultimodalCounting: def test_image_counted(self): - msg = _msg("user", [ - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,abc")), - ]) + msg = _msg( + "user", + [ + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,abc") + ), + ], + ) tokens = counter.count_tokens([msg]) assert tokens == IMAGE_TOKEN_ESTIMATE def test_audio_counted(self): - msg = _msg("user", [ - AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://x.com/a.mp3")), - ]) + msg = _msg( + "user", + [ + AudioURLPart( + audio_url=AudioURLPart.AudioURL(url="https://x.com/a.mp3") + ), + ], + ) tokens = counter.count_tokens([msg]) assert tokens == AUDIO_TOKEN_ESTIMATE @@ -60,21 +69,35 @@ def test_think_counted(self): def test_mixed_content(self): """文本 + 图片的多模态消息,token 数 = 文本 token + 图片估算。""" text_only = _msg("user", [TextPart(text="describe this image")]) - mixed = _msg("user", [ - TextPart(text="describe this image"), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,x")), - ]) + mixed = _msg( + "user", + [ + TextPart(text="describe this image"), + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,x") + ), + ], + ) text_tokens = counter.count_tokens([text_only]) mixed_tokens = counter.count_tokens([mixed]) assert mixed_tokens == text_tokens + IMAGE_TOKEN_ESTIMATE def test_multiple_images(self): """多张图片应该各自计算。""" - msg = _msg("user", [ - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,a")), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,b")), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,c")), - ]) + msg = _msg( + "user", + [ + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,a") + ), + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,b") + ), + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,c") + ), + ], + ) tokens = counter.count_tokens([msg]) assert tokens == IMAGE_TOKEN_ESTIMATE * 3 @@ -82,10 +105,15 @@ def test_multiple_images(self): class TestTrustedUsage: def test_trusted_overrides(self): """如果 API 返回了 token 数,直接用它不做估算。""" - msg = _msg("user", [ - TextPart(text="hello"), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,x")), - ]) + msg = _msg( + "user", + [ + TextPart(text="hello"), + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,x") + ), + ], + ) tokens = counter.count_tokens([msg], trusted_token_usage=42) assert tokens == 42 @@ -95,7 +123,16 @@ def test_tool_calls_counted(self): msg = Message( role="assistant", content="calling tool", - tool_calls=[{"type": "function", "id": "1", "function": {"name": "get_weather", "arguments": '{"city": "Beijing"}'}}], + tool_calls=[ + { + "type": "function", + "id": "1", + "function": { + "name": "get_weather", + "arguments": '{"city": "Beijing"}', + }, + } + ], ) tokens = counter.count_tokens([msg]) # 文本 + tool call JSON 都应被计算 diff --git a/tests/conftest.py b/tests/conftest.py index b9807c1ded..91a6421497 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,14 +9,12 @@ import sys from asyncio import Queue from pathlib import Path -from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest import pytest_asyncio # 使用 tests/fixtures/helpers.py 中的共享工具函数,避免重复定义 -from tests.fixtures.helpers import create_mock_llm_response, create_mock_message_component # 将项目根目录添加到 sys.path PROJECT_ROOT = Path(__file__).parent.parent diff --git a/tests/test_computer_config.py b/tests/test_computer_config.py index cadaef53a0..7f59958323 100644 --- a/tests/test_computer_config.py +++ b/tests/test_computer_config.py @@ -182,7 +182,10 @@ def test_logs_runtime_change(self, mock_logger) -> None: mock_logger.info.assert_called() call_args = [str(c) for c in mock_logger.info.call_args_list] - assert any("computer_use_runtime" in c and "none" in c and "sandbox" in c for c in call_args) + assert any( + "computer_use_runtime" in c and "none" in c and "sandbox" in c + for c in call_args + ) @patch("astrbot.dashboard.services.config_service.logger") def test_no_log_when_runtime_unchanged(self, mock_logger) -> None: @@ -212,7 +215,9 @@ def test_logs_sandbox_key_change(self, mock_logger) -> None: assert args[3] == "shipyard_neo" found = True break - assert found, f"Expected booter change in log calls: {mock_logger.info.call_args_list}" + assert found, ( + f"Expected booter change in log calls: {mock_logger.info.call_args_list}" + ) @patch("astrbot.dashboard.services.config_service.logger") def test_masks_token_values(self, mock_logger) -> None: @@ -235,9 +240,7 @@ def test_masks_token_values(self, mock_logger) -> None: def test_masks_empty_token_as_empty_label(self, mock_logger) -> None: """Empty token values show as '(empty)' not '***'.""" old = { - "provider_settings": { - "sandbox": {"shipyard_neo_access_token": "old-key"} - } + "provider_settings": {"sandbox": {"shipyard_neo_access_token": "old-key"}} } new = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}} @@ -311,9 +314,7 @@ def test_detects_removed_sandbox_key(self, mock_logger) -> None: def test_secret_key_masked(self, mock_logger) -> None: """Any key containing 'secret' is also masked.""" old = {"provider_settings": {"sandbox": {"my_secret_key": ""}}} - new = { - "provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}} - } + new = {"provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}}} _log_computer_config_changes(old, new) diff --git a/tests/test_conversation_commands.py b/tests/test_conversation_commands.py index 08d11b916b..3e56b6acf3 100644 --- a/tests/test_conversation_commands.py +++ b/tests/test_conversation_commands.py @@ -32,18 +32,22 @@ async def fake_remove_async(*args, **kwargs): context = SimpleNamespace( get_config=lambda **kwargs: { - "provider_settings": {"deerflow_agent_runner_provider_id": "deerflow-runner"} + "provider_settings": { + "deerflow_agent_runner_provider_id": "deerflow-runner" + } }, provider_manager=SimpleNamespace( - get_provider_config_by_id=lambda provider_id, merged=False: { - "id": provider_id, - "deerflow_api_base": "http://127.0.0.1:2026", - "deerflow_api_key": "token", - "deerflow_auth_header": "", - "proxy": "", - } - if merged - else {"id": provider_id}, + get_provider_config_by_id=lambda provider_id, merged=False: ( + { + "id": provider_id, + "deerflow_api_base": "http://127.0.0.1:2026", + "deerflow_api_key": "token", + "deerflow_auth_header": "", + "proxy": "", + } + if merged + else {"id": provider_id} + ), ), ) @@ -95,18 +99,22 @@ async def fake_remove_async(*args, **kwargs): context = SimpleNamespace( get_config=lambda **kwargs: { - "provider_settings": {"deerflow_agent_runner_provider_id": "deerflow-runner"} + "provider_settings": { + "deerflow_agent_runner_provider_id": "deerflow-runner" + } }, provider_manager=SimpleNamespace( - get_provider_config_by_id=lambda provider_id, merged=False: { - "id": provider_id, - "deerflow_api_base": "http://127.0.0.1:2026", - "deerflow_api_key": "", - "deerflow_auth_header": "", - "proxy": "", - } - if merged - else {"id": provider_id}, + get_provider_config_by_id=lambda provider_id, merged=False: ( + { + "id": provider_id, + "deerflow_api_base": "http://127.0.0.1:2026", + "deerflow_api_key": "", + "deerflow_auth_header": "", + "proxy": "", + } + if merged + else {"id": provider_id} + ), ), ) @@ -148,18 +156,22 @@ async def fake_remove_async(*args, **kwargs): context = SimpleNamespace( get_config=lambda **kwargs: { - "provider_settings": {"deerflow_agent_runner_provider_id": "deerflow-runner"} + "provider_settings": { + "deerflow_agent_runner_provider_id": "deerflow-runner" + } }, provider_manager=SimpleNamespace( - get_provider_config_by_id=lambda provider_id, merged=False: { - "id": provider_id, - "deerflow_api_base": "http://127.0.0.1:2026", - "deerflow_api_key": "", - "deerflow_auth_header": "", - "proxy": "", - } - if merged - else {"id": provider_id}, + get_provider_config_by_id=lambda provider_id, merged=False: ( + { + "id": provider_id, + "deerflow_api_base": "http://127.0.0.1:2026", + "deerflow_api_key": "", + "deerflow_auth_header": "", + "proxy": "", + } + if merged + else {"id": provider_id} + ), ), ) diff --git a/tests/test_kook/shared.py b/tests/test_kook/shared.py index 366b608db3..fac70a267f 100644 --- a/tests/test_kook/shared.py +++ b/tests/test_kook/shared.py @@ -1,18 +1,15 @@ import json +from pathlib import Path from unittest.mock import AsyncMock, MagicMock -from unittest.mock import AsyncMock - import aiohttp + from astrbot.api.platform import AstrBotMessage, MessageType from astrbot.core.message.components import ( File, Record, ) - -from pathlib import Path - CURRENT_DIR = Path(__file__).parent TEST_DATA_DIR = CURRENT_DIR / "data" @@ -32,7 +29,9 @@ class KookEventDataPath: RECONNECT_ERR = TEST_DATA_DIR / "kook_ws_event_reconnect_err.json" RESUME_ACK = TEST_DATA_DIR / "kook_ws_event_resume_ack.json" RESUME = TEST_DATA_DIR / "kook_ws_event_resume.json" - GROUP_SYSTEM_MESSAGE_UPDATE_ROLE = TEST_DATA_DIR / "kook_ws_event_group_system_message_update_role.json" + GROUP_SYSTEM_MESSAGE_UPDATE_ROLE = ( + TEST_DATA_DIR / "kook_ws_event_group_system_message_update_role.json" + ) class KookApiDataPath: diff --git a/tests/test_kook/test_kook_event.py b/tests/test_kook/test_kook_event.py index 616d596c86..dc81ebafad 100644 --- a/tests/test_kook/test_kook_event.py +++ b/tests/test_kook/test_kook_event.py @@ -1,19 +1,18 @@ import json import pytest + from astrbot.api.platform import PlatformMetadata, Unknown from astrbot.core.message.components import ( - Image, - Plain, - Video, At, AtAll, BaseMessageComponent, + Image, Json, + Plain, Reply, + Video, ) - - from astrbot.core.platform.sources.kook.kook_event import KookEvent from astrbot.core.platform.sources.kook.kook_types import KookMessageType, OrderMessage from tests.test_kook.shared import ( @@ -162,7 +161,7 @@ async def test_kook_event_warp_message( try: expected_output_text = json.loads(expected_output_text) is_json_text = True - except: + except (TypeError, json.JSONDecodeError): pass if is_json_text: diff --git a/tests/test_kook/test_kook_types.py b/tests/test_kook/test_kook_types.py index 89568168a9..5cfef4b79e 100644 --- a/tests/test_kook/test_kook_types.py +++ b/tests/test_kook/test_kook_types.py @@ -17,6 +17,7 @@ KmarkdownElement, KookApiResponseBase, KookCardMessage, + KookCardMessageContainer, KookMessageSignal, KookModuleType, KookUserMeResponse, @@ -25,7 +26,6 @@ ParagraphStructure, PlainTextElement, SectionModule, - KookCardMessageContainer, ) from tests.test_kook.shared import TEST_DATA_DIR, KookApiDataPath, KookEventDataPath diff --git a/tests/test_main.py b/tests/test_main.py index f7fa205dca..ae60366315 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -180,7 +180,6 @@ async def test_check_dashboard_files_exists_but_version_mismatch(monkeypatch): with mock.patch( "main.get_dashboard_version", mock.AsyncMock(return_value="v0.0.1") ): - with mock.patch("main.logger.warning") as mock_logger_warning: await check_dashboard_files() mock_logger_warning.assert_called_once() diff --git a/tests/test_profile_aware_tools.py b/tests/test_profile_aware_tools.py index 86468c3451..8d5db3a551 100644 --- a/tests/test_profile_aware_tools.py +++ b/tests/test_profile_aware_tools.py @@ -7,7 +7,6 @@ import pytest - # ═══════════════════════════════════════════════════════════════ # ShipyardNeoBooter.capabilities # ═══════════════════════════════════════════════════════════════ @@ -93,9 +92,7 @@ def test_no_session_registers_all(self): config = _make_config("shipyard_neo") req = _make_req() - with patch( - "astrbot.core.computer.computer_client.session_booter", {} - ): + with patch("astrbot.core.computer.computer_client.session_booter", {}): fn(config, req, "session-1") names = self._tool_names(req) @@ -126,9 +123,7 @@ def test_without_browser_capability(self): fn = _import_apply_sandbox_tools() config = _make_config("shipyard_neo") req = _make_req() - fake_booter = SimpleNamespace( - capabilities=["python", "shell", "filesystem"] - ) + fake_booter = SimpleNamespace(capabilities=["python", "shell", "filesystem"]) with patch( "astrbot.core.computer.computer_client.session_booter", diff --git a/tests/test_shipyard_neo_booter.py b/tests/test_shipyard_neo_booter.py index b0d7ecc01d..598a2aefe7 100644 --- a/tests/test_shipyard_neo_booter.py +++ b/tests/test_shipyard_neo_booter.py @@ -8,7 +8,6 @@ import pytest - # ═══════════════════════════════════════════════════════════════ # _wait_until_ready # ═══════════════════════════════════════════════════════════════ @@ -284,11 +283,12 @@ async def _fake_boot(_self, _sid): _self._fs = SimpleNamespace() # type: ignore[assignment] _self._python = SimpleNamespace() # type: ignore[assignment] - with patch.object( - ShipyardNeoBooter, "boot", _fake_boot - ), patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), + with ( + patch.object(ShipyardNeoBooter, "boot", _fake_boot), + patch( + "astrbot.core.computer.computer_client._sync_skills_to_sandbox", + AsyncMock(), + ), ): await get_booter(ctx, "session-1") @@ -327,12 +327,15 @@ async def boot(self, _sid): async def shutdown(self, **kwargs): pass - with patch( - "astrbot.core.computer.booters.shipyard.ShipyardBooter", - _FakeShipyardBooter, - ), patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), + with ( + patch( + "astrbot.core.computer.booters.shipyard.ShipyardBooter", + _FakeShipyardBooter, + ), + patch( + "astrbot.core.computer.computer_client._sync_skills_to_sandbox", + AsyncMock(), + ), ): from astrbot.core.computer.computer_client import get_booter diff --git a/tests/test_skill_manager_sandbox_cache.py b/tests/test_skill_manager_sandbox_cache.py index 35fb608118..0369d6b0a0 100644 --- a/tests/test_skill_manager_sandbox_cache.py +++ b/tests/test_skill_manager_sandbox_cache.py @@ -154,4 +154,3 @@ def test_sandbox_and_local_path_resolution_with_show_sandbox_path_false( assert local_skill_path.is_relative_to(skills_root) assert local_skill_path == skills_root / "custom-local" / "SKILL.md" assert by_name["python-sandbox"].path == "/app/skills/python-sandbox/SKILL.md" - diff --git a/tests/unit/test_aiocqhttp_reply.py b/tests/unit/test_aiocqhttp_reply.py index 0e3aeada4f..ac32bee6d3 100644 --- a/tests/unit/test_aiocqhttp_reply.py +++ b/tests/unit/test_aiocqhttp_reply.py @@ -16,12 +16,13 @@ import astrbot.core.message.components as Comp from astrbot.core.message.message_event_result import MessageChain -from astrbot.core.pipeline.respond.stage import RespondStage # noqa: F401 — 预加载避免循环导入 +from astrbot.core.pipeline.respond.stage import ( + RespondStage, # noqa: F401 — 预加载避免循环导入 +) from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import ( AiocqhttpMessageEvent, ) - # ============================================================ # Reply.toDict() 输出格式测试 # ============================================================ @@ -193,7 +194,5 @@ def test_reply_to_dict_matches_onebot_v11_format(): actual = reply.toDict() assert actual == expected, ( - f"Reply.toDict() 输出不符合 OneBot V11 标准。\n" - f"期望: {expected}\n" - f"实际: {actual}" + f"Reply.toDict() 输出不符合 OneBot V11 标准。\n期望: {expected}\n实际: {actual}" ) diff --git a/tests/unit/test_cli_plugin_utils.py b/tests/unit/test_cli_plugin_utils.py index 5d9007b876..8cf8673ade 100644 --- a/tests/unit/test_cli_plugin_utils.py +++ b/tests/unit/test_cli_plugin_utils.py @@ -86,7 +86,7 @@ def test_build_plug_list_local_version_equal_or_newer(monkeypatch, tmp_path): # 1. test if local version == remote version dir_equal = tmp_path / "dir_equal" write_metadata(dir_equal / "local-plugin", "local-plugin", "2.0.0") - + plugins_equal = build_plug_list(dir_equal) plugins_equal_by_name = {p["name"]: p for p in plugins_equal} assert plugins_equal_by_name["local-plugin"]["status"] == PluginStatus.INSTALLED @@ -94,7 +94,7 @@ def test_build_plug_list_local_version_equal_or_newer(monkeypatch, tmp_path): # 2. test if local version > remote version dir_newer = tmp_path / "dir_newer" write_metadata(dir_newer / "local-plugin", "local-plugin", "3.0.0") - + plugins_newer = build_plug_list(dir_newer) plugins_newer_by_name = {p["name"]: p for p in plugins_newer} assert plugins_newer_by_name["local-plugin"]["status"] == PluginStatus.INSTALLED @@ -108,4 +108,4 @@ def test_build_plug_list_non_existent_path(monkeypatch, tmp_path): plugins = build_plug_list(non_existent_dir) assert [plugin["name"] for plugin in plugins] == ["local-plugin", "remote-only"] - assert all(plugin["status"] == PluginStatus.NOT_INSTALLED for plugin in plugins) \ No newline at end of file + assert all(plugin["status"] == PluginStatus.NOT_INSTALLED for plugin in plugins) diff --git a/tests/unit/test_cron_manager.py b/tests/unit/test_cron_manager.py index 9dd3fc34dc..5596973133 100644 --- a/tests/unit/test_cron_manager.py +++ b/tests/unit/test_cron_manager.py @@ -152,7 +152,9 @@ async def test_add_basic_job_disabled(self, cron_manager, mock_db, sample_cron_j assert sample_cron_job.job_id in cron_manager._basic_handlers @pytest.mark.asyncio - async def test_add_basic_job_with_timezone(self, cron_manager, mock_db, sample_cron_job): + async def test_add_basic_job_with_timezone( + self, cron_manager, mock_db, sample_cron_job + ): """Test adding a basic job with timezone.""" mock_db.create_cron_job.return_value = sample_cron_job @@ -189,7 +191,9 @@ async def test_add_active_job(self, cron_manager, mock_db, sample_cron_job): mock_db.create_cron_job.assert_called_once() @pytest.mark.asyncio - async def test_add_active_job_run_once(self, cron_manager, mock_db, sample_cron_job): + async def test_add_active_job_run_once( + self, cron_manager, mock_db, sample_cron_job + ): """Test adding a run-once active job with an invalid returned job.""" sample_cron_job.job_type = "active_agent" sample_cron_job.run_once = True @@ -292,7 +296,9 @@ async def test_sync_from_db_empty(self, cron_manager, mock_db): mock_db.list_cron_jobs.assert_called_once() @pytest.mark.asyncio - async def test_sync_from_db_skips_disabled(self, cron_manager, mock_db, sample_cron_job): + async def test_sync_from_db_skips_disabled( + self, cron_manager, mock_db, sample_cron_job + ): """Test that sync skips disabled jobs.""" sample_cron_job.enabled = False mock_db.list_cron_jobs.return_value = [sample_cron_job] @@ -304,7 +310,9 @@ async def test_sync_from_db_skips_disabled(self, cron_manager, mock_db, sample_c mock_schedule.assert_not_called() @pytest.mark.asyncio - async def test_sync_from_db_skips_non_persistent(self, cron_manager, mock_db, sample_cron_job): + async def test_sync_from_db_skips_non_persistent( + self, cron_manager, mock_db, sample_cron_job + ): """Test that sync skips non-persistent jobs.""" sample_cron_job.persistent = False mock_db.list_cron_jobs.return_value = [sample_cron_job] @@ -362,7 +370,9 @@ class TestScheduleJob: """Tests for _schedule_job method.""" @pytest.mark.asyncio - async def test_schedule_job_basic(self, cron_manager, sample_cron_job, mock_context): + async def test_schedule_job_basic( + self, cron_manager, sample_cron_job, mock_context + ): """Test scheduling a basic job.""" mock_db = cron_manager.db mock_db.list_cron_jobs = AsyncMock(return_value=[]) @@ -374,7 +384,9 @@ async def test_schedule_job_basic(self, cron_manager, sample_cron_job, mock_cont assert cron_manager.scheduler.get_job("test-job-id") is not None @pytest.mark.asyncio - async def test_schedule_job_with_timezone(self, cron_manager, sample_cron_job, mock_context): + async def test_schedule_job_with_timezone( + self, cron_manager, sample_cron_job, mock_context + ): """Test scheduling a job with timezone.""" sample_cron_job.timezone = "America/New_York" mock_db = cron_manager.db @@ -386,7 +398,9 @@ async def test_schedule_job_with_timezone(self, cron_manager, sample_cron_job, m assert cron_manager.scheduler.get_job("test-job-id") is not None @pytest.mark.asyncio - async def test_schedule_job_invalid_timezone(self, cron_manager, sample_cron_job, mock_context): + async def test_schedule_job_invalid_timezone( + self, cron_manager, sample_cron_job, mock_context + ): """Test scheduling a job with invalid timezone.""" sample_cron_job.timezone = "Invalid/Timezone" mock_db = cron_manager.db @@ -486,7 +500,9 @@ class TestGetNextRunTime: """Tests for _get_next_run_time method.""" @pytest.mark.asyncio - async def test_get_next_run_time_existing_job(self, cron_manager, sample_cron_job, mock_context): + async def test_get_next_run_time_existing_job( + self, cron_manager, sample_cron_job, mock_context + ): """Test getting next run time for existing job.""" mock_db = cron_manager.db mock_db.list_cron_jobs = AsyncMock(return_value=[]) diff --git a/tests/unit/test_group_chat_context_wiring.py b/tests/unit/test_group_chat_context_wiring.py index 7d63e8de54..db6142dc02 100644 --- a/tests/unit/test_group_chat_context_wiring.py +++ b/tests/unit/test_group_chat_context_wiring.py @@ -157,7 +157,9 @@ async def test_on_message_skips_recording_when_command_handler_matched(): handle_message=AsyncMock(), ) event = make_event( - handlers_parsed_params={"astrbot.builtin_stars.builtin_commands.main_reset": {}}, + handlers_parsed_params={ + "astrbot.builtin_stars.builtin_commands.main_reset": {} + }, ) async for _ in main.on_message(event): diff --git a/tests/unit/test_session_lock.py b/tests/unit/test_session_lock.py index fea686b11a..a050540510 100644 --- a/tests/unit/test_session_lock.py +++ b/tests/unit/test_session_lock.py @@ -194,7 +194,9 @@ async def get_mgr(): loop.close() asyncio.set_event_loop(None) - threads = [threading.Thread(target=create_loop_and_get_manager) for _ in range(10)] + threads = [ + threading.Thread(target=create_loop_and_get_manager) for _ in range(10) + ] for t in threads: t.start() for t in threads: @@ -417,7 +419,9 @@ async def acquire(): asyncio.set_event_loop(None) # Start 3 threads nearly simultaneously - threads = [threading.Thread(target=acquire_lock_in_loop, args=(i,)) for i in range(3)] + threads = [ + threading.Thread(target=acquire_lock_in_loop, args=(i,)) for i in range(3) + ] start_time = time.time() for t in threads: diff --git a/tests/unit/test_star_base.py b/tests/unit/test_star_base.py index d78737943a..77e35ba2c0 100644 --- a/tests/unit/test_star_base.py +++ b/tests/unit/test_star_base.py @@ -1,8 +1,9 @@ """Tests for astrbot.core.star.base module.""" -import pytest from unittest.mock import AsyncMock, MagicMock, patch +import pytest + class TestStarBase: """Test cases for the Star base class.""" @@ -146,14 +147,11 @@ async def terminate(self) -> None: def test_star_metadata_registration(self): """Test that Star subclass is automatically registered.""" - from astrbot.core.star import star_map, star_registry - from astrbot.core.star.star import StarMetadata - - # Clear any previous registration for this test module - module_path = __name__ + from astrbot.core.star import star_registry class UniqueTestStar: """Not a Star subclass, should not be registered.""" + pass # Verify Star subclass gets registered @@ -181,11 +179,9 @@ def test_import_pipeline_module(self): def test_import_both_modules(self): """Test that both modules can be imported together.""" - import astrbot.core.pipeline - import astrbot.core.star # Verify key exports are available - from astrbot.core.star import Context, Star, PluginManager + from astrbot.core.star import Context, PluginManager, Star assert Context is not None assert Star is not None diff --git a/tests/unit/test_tool_conflict_resolution.py b/tests/unit/test_tool_conflict_resolution.py index 8146ad8874..706ec8e2a7 100644 --- a/tests/unit/test_tool_conflict_resolution.py +++ b/tests/unit/test_tool_conflict_resolution.py @@ -4,8 +4,6 @@ with a disabled built-in tool, the MCP tool should not be removed as collateral damage. """ -import pytest - from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.provider.func_tool_manager import FunctionToolManager diff --git a/tests/unit/test_tool_permission.py b/tests/unit/test_tool_permission.py index fbfc115ee3..9e69609e6b 100644 --- a/tests/unit/test_tool_permission.py +++ b/tests/unit/test_tool_permission.py @@ -302,9 +302,9 @@ def test_get_full_tool_set_wraps_non_builtin(): plugin_tools = [t for t in tool_set.tools if t.name == "my_plugin_tool"] assert plugin_tools - assert isinstance( - plugin_tools[0], _PermissionGuardedTool - ), "non-builtin tools must be wrapped" + assert isinstance(plugin_tools[0], _PermissionGuardedTool), ( + "non-builtin tools must be wrapped" + ) # ── API: get_tool_list permission fields ────────────────────────────── @@ -357,9 +357,7 @@ async def test_set_admin_permission(self): ) assert "target_tool" in message - stored = sp.get( - "tool_permissions", {}, scope="global", scope_id="global" - ) + stored = sp.get("tool_permissions", {}, scope="global", scope_id="global") assert stored["_default"]["target_tool"] == "admin" @pytest.mark.asyncio @@ -376,7 +374,9 @@ async def test_reject_unknown_tool(self): service = _make_tools_service() with pytest.raises(ToolsServiceError, match="not found"): - service.update_tool_permission({"name": "ghost_tool", "permission": "admin"}) + service.update_tool_permission( + {"name": "ghost_tool", "permission": "admin"} + ) @pytest.mark.asyncio async def test_reject_invalid_permission_value(self): From 528ac165e964ecc03d06564e1ee39f85c5efc907 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 11:59:54 +0800 Subject: [PATCH 12/29] fix: update openapi api key scopes --- astrbot/dashboard/api/auth.py | 10 +- astrbot/dashboard/api/skills.py | 2 +- astrbot/dashboard/api/tools.py | 26 +++-- astrbot/dashboard/services/api_key_service.py | 4 +- astrbot/dashboard/services/auth_service.py | 6 + .../src/api/generated/openapi-v1/types.gen.ts | 2 +- dashboard/src/views/Settings.vue | 33 +++++- openspec/openapi-v1.yaml | 104 +++++++++++------- tests/test_api_key_open_api.py | 30 +++++ tests/test_fastapi_v1_dashboard.py | 56 ++++++++-- 10 files changed, 207 insertions(+), 66 deletions(-) diff --git a/astrbot/dashboard/api/auth.py b/astrbot/dashboard/api/auth.py index d79134e9ee..7adf894b74 100644 --- a/astrbot/dashboard/api/auth.py +++ b/astrbot/dashboard/api/auth.py @@ -18,6 +18,7 @@ ALL_OPEN_API_SCOPES, DASHBOARD_JWT_COOKIE_MAX_AGE, DASHBOARD_JWT_COOKIE_NAME, + OPEN_API_SCOPE_INCLUDES, TOTP_TRUSTED_DEVICE_COOKIE_NAME, TOTP_TRUSTED_DEVICE_MAX_AGE, AuthService, @@ -123,7 +124,14 @@ async def _require_api_key_scope( if isinstance(api_key.scopes, list) else [str(scope) for scope in ALL_OPEN_API_SCOPES] ) - if "*" not in scopes and scope not in scopes: + if ( + "*" not in scopes + and scope not in scopes + and not any( + scope in OPEN_API_SCOPE_INCLUDES.get(api_key_scope, ()) + for api_key_scope in scopes + ) + ): raise ApiError("Insufficient API key scope", status_code=403) await request.app.state.db.touch_api_key(api_key.key_id) return AuthContext( diff --git a/astrbot/dashboard/api/skills.py b/astrbot/dashboard/api/skills.py index c8925c8e4a..67d9c1b3fe 100644 --- a/astrbot/dashboard/api/skills.py +++ b/astrbot/dashboard/api/skills.py @@ -37,7 +37,7 @@ def get_service(request: Request) -> SkillsService: async def require_skill_scope(request: Request) -> AuthContext: - return await require_scope(request, "skill") + return await require_scope(request, "skills") async def _json_or_empty(request: Request) -> dict[str, Any]: diff --git a/astrbot/dashboard/api/tools.py b/astrbot/dashboard/api/tools.py index 74a6e3d510..2a253225df 100644 --- a/astrbot/dashboard/api/tools.py +++ b/astrbot/dashboard/api/tools.py @@ -33,6 +33,10 @@ async def require_tool_scope(request: Request) -> AuthContext: return await require_scope(request, "tool") +async def require_mcp_scope(request: Request) -> AuthContext: + return await require_scope(request, "mcp") + + async def _json_or_empty(request: Request) -> dict[str, Any]: try: data = await request.json() @@ -208,7 +212,7 @@ async def set_tool_permission( @router.get("/mcp/servers") async def list_mcp_servers( - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): return await _run(service.get_mcp_servers) @@ -217,7 +221,7 @@ async def list_mcp_servers( @router.post("/mcp/servers") async def create_mcp_server( payload: McpServerRequest, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): return await _create_mcp_server(_model_dict(payload), service) @@ -226,7 +230,7 @@ async def create_mcp_server( @router.put("/mcp/servers/by-name") async def update_mcp_server_by_name( payload: McpServerByNameRequest, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): body = _model_dict(payload) @@ -236,7 +240,7 @@ async def update_mcp_server_by_name( @router.delete("/mcp/servers/by-name") async def delete_mcp_server_by_name( server_name: str = Query(...), - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): return await _delete_mcp_server(server_name, service) @@ -245,7 +249,7 @@ async def delete_mcp_server_by_name( @router.patch("/mcp/servers/enabled") async def set_mcp_server_enabled_by_name( payload: McpServerByNameRequest, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): body = _model_dict(payload) @@ -255,7 +259,7 @@ async def set_mcp_server_enabled_by_name( @router.post("/mcp/servers/test") async def test_mcp_server_by_name( payload: McpServerByNameRequest, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): body = _model_dict(payload) @@ -266,7 +270,7 @@ async def test_mcp_server_by_name( async def set_mcp_server_enabled( server_name: str, payload: ToolEnabledRequest, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): return await _update_mcp_server( @@ -280,7 +284,7 @@ async def set_mcp_server_enabled( async def test_mcp_server( server_name: str, payload: McpServerRequest | None = None, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): body = _model_dict(payload) if payload is not None else {} @@ -291,7 +295,7 @@ async def test_mcp_server( async def update_mcp_server( server_name: str, payload: McpServerRequest, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): body = _model_dict(payload) @@ -301,7 +305,7 @@ async def update_mcp_server( @router.delete("/mcp/servers/{server_name:path}") async def delete_mcp_server( server_name: str, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): return await _delete_mcp_server(server_name, service) @@ -310,7 +314,7 @@ async def delete_mcp_server( @router.post("/mcp/providers/modelscope/sync") async def sync_modelscope_mcp_servers( payload: ModelScopeSyncRequest | None = None, - _auth: AuthContext = Depends(require_tool_scope), + _auth: AuthContext = Depends(require_mcp_scope), service: ToolsService = Depends(get_service), ): access_token = payload.access_token if payload is not None else "" diff --git a/astrbot/dashboard/services/api_key_service.py b/astrbot/dashboard/services/api_key_service.py index d8e486d2f5..3a8a8968b1 100644 --- a/astrbot/dashboard/services/api_key_service.py +++ b/astrbot/dashboard/services/api_key_service.py @@ -8,7 +8,7 @@ from astrbot.core.db import BaseDatabase from astrbot.core.utils.datetime_utils import normalize_datetime_utc -from .auth_service import ALL_OPEN_API_SCOPES +from .auth_service import ALL_OPEN_API_SCOPES, OPEN_API_SCOPE_INCLUDES class ApiKeyServiceError(Exception): @@ -73,6 +73,8 @@ def _normalize_scopes(raw_scopes: Any) -> list[str]: invalid_scopes.append(str(scope)) if invalid_scopes: raise ApiKeyServiceError(f"Invalid scopes: {', '.join(invalid_scopes)}") + for scope in tuple(scopes): + scopes.extend(OPEN_API_SCOPE_INCLUDES.get(scope, ())) normalized_scopes = list(dict.fromkeys(scopes)) if not normalized_scopes: raise ApiKeyServiceError("At least one valid scope is required") diff --git a/astrbot/dashboard/services/auth_service.py b/astrbot/dashboard/services/auth_service.py index 70e50d1e10..40e278c016 100644 --- a/astrbot/dashboard/services/auth_service.py +++ b/astrbot/dashboard/services/auth_service.py @@ -55,8 +55,14 @@ "config", "chat", "plugin", + "mcp", + "skills", ) +OPEN_API_SCOPE_INCLUDES = { + "config": ("bot", "provider"), +} + DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt" DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index d0808145a7..54e38f196c 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -191,7 +191,7 @@ export type ConversationRef = { export type CreateApiKeyRequest = { name: string; - scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'plugin')>; + scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'plugin' | 'mcp' | 'skills')>; expires_at?: string; expires_in_days?: number; }; diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index fbf7587ca7..35c0b24471 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -294,7 +294,7 @@ const apiKeys = ref([]); const apiKeyCreating = ref(false); const newApiKeyName = ref(''); const newApiKeyExpiresInDays = ref(30); -const newApiKeyScopes = ref(['chat', 'config', 'im']); +const newApiKeyScopes = ref(['bot', 'provider', 'im', 'config', 'chat']); const createdApiKeyPlaintext = ref(''); const apiKeyExpiryOptions = computed(() => [ { title: tm('apiKey.expiryOptions.day1'), value: 1 }, @@ -311,9 +311,30 @@ const availableScopes = [ { value: 'im', label: 'im' }, { value: 'config', label: 'config' }, { value: 'chat', label: 'chat' }, - { value: 'plugin', label: 'plugin' } + { value: 'plugin', label: 'plugin' }, + { value: 'mcp', label: 'mcp' }, + { value: 'skills', label: 'skills' } ]; +const configIncludedScopes = ['bot', 'provider']; + +watch( + newApiKeyScopes, + (scopes) => { + if (!scopes.includes('config')) return; + const selectedScopes = new Set(scopes); + for (const scope of configIncludedScopes) { + selectedScopes.add(scope); + } + if (selectedScopes.size !== scopes.length) { + newApiKeyScopes.value = availableScopes + .map((scope) => scope.value) + .filter((scope) => selectedScopes.has(scope)); + } + }, + { deep: true, immediate: true } +); + const showToast = (message, color = 'success') => { toastStore.add({ message, @@ -353,9 +374,15 @@ const copyCreatedApiKey = async () => { }; const createApiKey = async () => { + const selectedScopeSet = new Set(newApiKeyScopes.value); + if (selectedScopeSet.has('config')) { + for (const scope of configIncludedScopes) { + selectedScopeSet.add(scope); + } + } const selectedScopes = availableScopes .map((scope) => scope.value) - .filter((scope) => newApiKeyScopes.value.includes(scope)); + .filter((scope) => selectedScopeSet.has(scope)); if (selectedScopes.length === 0) { showToast(tm('apiKey.messages.scopeRequired'), 'warning'); diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index 79c9e30999..91d2609ae3 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -8,7 +8,8 @@ info: JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, - persona, im, config, chat, plugin. + persona, im, config, chat, plugin, mcp, skills. The config scope also + grants bot and provider access. servers: - url: http://localhost:6185 description: Local AstrBot server @@ -2653,12 +2654,30 @@ paths: "200": $ref: "#/components/responses/Ok" + /api/v1/tools/{tool_id}/permission: + patch: + tags: [Tools] + summary: Update an LLM tool permission + operationId: setToolPermission + x-astrbot-scope: tool + parameters: + - $ref: "#/components/parameters/ToolId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ToolPermissionPatch" + responses: + "200": + $ref: "#/components/responses/Ok" + /api/v1/mcp/servers: get: tags: [MCP] summary: List MCP servers operationId: listMcpServers - x-astrbot-scope: tool + x-astrbot-scope: mcp responses: "200": $ref: "#/components/responses/Ok" @@ -2666,7 +2685,7 @@ paths: tags: [MCP] summary: Add an MCP server operationId: createMcpServer - x-astrbot-scope: tool + x-astrbot-scope: mcp requestBody: required: true content: @@ -2682,7 +2701,7 @@ paths: tags: [MCP] summary: Update an MCP server by body name operationId: updateMcpServerByName - x-astrbot-scope: tool + x-astrbot-scope: mcp requestBody: required: true content: @@ -2705,7 +2724,7 @@ paths: tags: [MCP] summary: Delete an MCP server by query name operationId: deleteMcpServerByName - x-astrbot-scope: tool + x-astrbot-scope: mcp parameters: - name: server_name in: query @@ -2721,7 +2740,7 @@ paths: tags: [MCP] summary: Enable or disable an MCP server by body name operationId: setMcpServerEnabledByName - x-astrbot-scope: tool + x-astrbot-scope: mcp requestBody: required: true content: @@ -2744,7 +2763,7 @@ paths: tags: [MCP] summary: Test an MCP server connection by body name operationId: testMcpServerByName - x-astrbot-scope: tool + x-astrbot-scope: mcp requestBody: required: true content: @@ -2769,7 +2788,7 @@ paths: tags: [MCP] summary: Update an MCP server operationId: updateMcpServer - x-astrbot-scope: tool + x-astrbot-scope: mcp parameters: - $ref: "#/components/parameters/ServerName" requestBody: @@ -2785,7 +2804,7 @@ paths: tags: [MCP] summary: Delete an MCP server operationId: deleteMcpServer - x-astrbot-scope: tool + x-astrbot-scope: mcp parameters: - $ref: "#/components/parameters/ServerName" responses: @@ -2797,7 +2816,7 @@ paths: tags: [MCP] summary: Enable or disable an MCP server operationId: setMcpServerEnabled - x-astrbot-scope: tool + x-astrbot-scope: mcp parameters: - $ref: "#/components/parameters/ServerName" requestBody: @@ -2815,7 +2834,7 @@ paths: tags: [MCP] summary: Test an MCP server connection operationId: testMcpServer - x-astrbot-scope: tool + x-astrbot-scope: mcp parameters: - $ref: "#/components/parameters/ServerName" requestBody: @@ -2838,7 +2857,7 @@ paths: tags: [MCP] summary: Sync MCP servers from ModelScope operationId: syncModelScopeMcpServers - x-astrbot-scope: tool + x-astrbot-scope: mcp requestBody: required: false content: @@ -2854,7 +2873,7 @@ paths: tags: [Skills] summary: List skills operationId: listSkills - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: enabled in: query @@ -2871,7 +2890,7 @@ paths: tags: [Skills] summary: Upload or import a skill archive operationId: uploadSkill - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -2887,7 +2906,7 @@ paths: tags: [Skills] summary: Upload multiple skill archives operationId: uploadSkillsBatch - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -2910,7 +2929,7 @@ paths: tags: [Skills] summary: Update skill metadata or enabled state by body name operationId: updateSkillByName - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -2933,7 +2952,7 @@ paths: tags: [Skills] summary: Delete a skill by query name operationId: deleteSkillByName - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: skill_name in: query @@ -2949,7 +2968,7 @@ paths: tags: [Skills] summary: Download a skill archive by query name operationId: downloadSkillByName - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: skill_name in: query @@ -2970,7 +2989,7 @@ paths: tags: [Skills] summary: List files in a skill by query name operationId: listSkillFilesByName - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: skill_name in: query @@ -2990,7 +3009,7 @@ paths: tags: [Skills] summary: Get skill file content by query name and path operationId: getSkillFileByName - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: skill_name in: query @@ -3009,7 +3028,7 @@ paths: tags: [Skills] summary: Update skill file content by body name and path operationId: updateSkillFileByName - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -3034,7 +3053,7 @@ paths: tags: [Skills] summary: Update skill metadata or enabled state operationId: updateSkill - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - $ref: "#/components/parameters/SkillName" requestBody: @@ -3050,7 +3069,7 @@ paths: tags: [Skills] summary: Delete a skill operationId: deleteSkill - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - $ref: "#/components/parameters/SkillName" responses: @@ -3062,7 +3081,7 @@ paths: tags: [Skills] summary: Download a skill archive operationId: downloadSkill - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - $ref: "#/components/parameters/SkillName" responses: @@ -3079,7 +3098,7 @@ paths: tags: [Skills] summary: List files in a skill operationId: listSkillFiles - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - $ref: "#/components/parameters/SkillName" - name: path @@ -3095,7 +3114,7 @@ paths: tags: [Skills] summary: Get skill file content operationId: getSkillFile - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - $ref: "#/components/parameters/SkillName" - $ref: "#/components/parameters/FilePath" @@ -3106,7 +3125,7 @@ paths: tags: [Skills] summary: Update skill file content operationId: updateSkillFile - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - $ref: "#/components/parameters/SkillName" - $ref: "#/components/parameters/FilePath" @@ -3125,7 +3144,7 @@ paths: tags: [Skills] summary: List Shipyard Neo skill candidates operationId: listNeoSkillCandidates - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: skill_key in: query @@ -3144,7 +3163,7 @@ paths: tags: [Skills] summary: List Shipyard Neo skill releases operationId: listNeoSkillReleases - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: skill_key in: query @@ -3163,7 +3182,7 @@ paths: tags: [Skills] summary: Get a Shipyard Neo skill payload operationId: getNeoSkillPayload - x-astrbot-scope: skill + x-astrbot-scope: skills parameters: - name: payload_ref in: query @@ -3179,7 +3198,7 @@ paths: tags: [Skills] summary: Evaluate a Shipyard Neo skill candidate operationId: evaluateNeoSkillCandidate - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -3195,7 +3214,7 @@ paths: tags: [Skills] summary: Promote a Shipyard Neo skill candidate operationId: promoteNeoSkillCandidate - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -3211,7 +3230,7 @@ paths: tags: [Skills] summary: Roll back a Shipyard Neo skill release operationId: rollbackNeoSkillRelease - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -3227,7 +3246,7 @@ paths: tags: [Skills] summary: Sync a Shipyard Neo skill release operationId: syncNeoSkillRelease - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -3243,7 +3262,7 @@ paths: tags: [Skills] summary: Delete a Shipyard Neo skill candidate operationId: deleteNeoSkillCandidate - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -3259,7 +3278,7 @@ paths: tags: [Skills] summary: Delete a Shipyard Neo skill release operationId: deleteNeoSkillRelease - x-astrbot-scope: skill + x-astrbot-scope: skills requestBody: required: true content: @@ -5086,8 +5105,8 @@ components: type: array items: type: string - enum: [bot, provider, persona, im, config, chat, plugin] - example: [bot, provider, persona, im, config, chat, plugin] + enum: [bot, provider, persona, im, config, chat, plugin, mcp, skills] + example: [bot, provider, persona, im, config, chat, plugin, mcp, skills] expires_at: type: string format: date-time @@ -5183,6 +5202,15 @@ components: type: boolean additionalProperties: false + ToolPermissionPatch: + type: object + required: [permission] + properties: + permission: + type: string + enum: [admin, member] + additionalProperties: false + ProviderSourceConfigRequest: type: object required: [config] diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py index c8ea7ab32e..8377c825b0 100644 --- a/tests/test_api_key_open_api.py +++ b/tests/test_api_key_open_api.py @@ -756,6 +756,36 @@ async def test_open_send_message_error_paths( ) +@pytest.mark.asyncio +async def test_open_api_key_scope_normalization( + app: FastAPIAppAdapter, + authenticated_header: dict, +): + test_client = app.test_client() + + config_res = await test_client.post( + "/api/apikey/create", + json={"name": "config-contained-scopes-key", "scopes": ["config"]}, + headers=authenticated_header, + ) + config_data = await config_res.get_json() + + assert config_res.status_code == 200 + assert config_data["status"] == "ok" + assert set(config_data["data"]["scopes"]) == {"config", "bot", "provider"} + + extra_scope_res = await test_client.post( + "/api/apikey/create", + json={"name": "mcp-skills-scope-key", "scopes": ["mcp", "skills"]}, + headers=authenticated_header, + ) + extra_scope_data = await extra_scope_res.get_json() + + assert extra_scope_res.status_code == 200 + assert extra_scope_data["status"] == "ok" + assert set(extra_scope_data["data"]["scopes"]) == {"mcp", "skills"} + + @pytest.mark.asyncio async def test_file_scope_is_not_available_for_developer_api_key( app: FastAPIAppAdapter, diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index f723e1a93a..2d40439495 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1521,19 +1521,24 @@ async def test_v1_safe_bot_routes_accept_slash_ids( @pytest.mark.asyncio -async def test_v1_bot_scope_accepts_api_key( +async def test_v1_config_scope_includes_bot_and_provider( asgi_client: httpx.AsyncClient, fake_db: FakeDb, ): config_key = "abk_fastapi_v1_config" fake_db.add_api_key(config_key, scopes=["config"]) - config_response = await asgi_client.get( + bot_response = await asgi_client.get( "/api/v1/bots", headers={"X-API-Key": config_key}, ) + provider_response = await asgi_client.get( + "/api/v1/providers/schema", + headers={"X-API-Key": config_key}, + ) - assert config_response.status_code == 403 + assert bot_response.status_code == 200 + assert provider_response.status_code == 200 bot_key = "abk_fastapi_v1_bot" fake_db.add_api_key(bot_key, scopes=["bot"]) @@ -1547,7 +1552,7 @@ async def test_v1_bot_scope_accepts_api_key( data = response.json() assert data["status"] == "ok" assert isinstance(data["data"]["bots"], list) - assert fake_db.touched_key_ids == ["config-key"] + assert fake_db.touched_key_ids == ["config-key", "config-key", "config-key"] @pytest.mark.asyncio @@ -2084,29 +2089,60 @@ async def test_v1_safe_mcp_routes_accept_slash_server_names( @pytest.mark.asyncio -async def test_v1_skills_reject_developer_api_key_scope( +async def test_v1_mcp_scope_accepts_api_key( + asgi_client: httpx.AsyncClient, + fake_db: FakeDb, +): + raw_key = "abk_fastapi_v1_mcp" + fake_db.add_api_key(raw_key, scopes=["mcp"]) + + response = await asgi_client.get( + "/api/v1/mcp/servers", + headers={"X-API-Key": raw_key}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert any(server["name"] == "demo-server" for server in data["data"]) + + +@pytest.mark.asyncio +async def test_v1_skills_scope_accepts_api_key_and_rejects_legacy_singular_scope( asgi_app: FastAPI, asgi_client: httpx.AsyncClient, fake_db: FakeDb, monkeypatch: pytest.MonkeyPatch, ): - raw_key = "abk_fastapi_v1_skill" - fake_db.add_api_key(raw_key, scopes=["skill"]) monkeypatch.setattr( asgi_app.state.services.skills, "get_skills", lambda: {"skills": [{"name": "demo_skill"}]}, ) + legacy_key = "abk_fastapi_v1_skill" + fake_db.add_api_key(legacy_key, scopes=["skill"]) + legacy_response = await asgi_client.get( + "/api/v1/skills", + headers={"X-API-Key": legacy_key}, + ) + + assert legacy_response.status_code == 403 + data = legacy_response.json() + assert data["status"] == "error" + assert data["message"] == "Insufficient API key scope" + + raw_key = "abk_fastapi_v1_skills" + fake_db.add_api_key(raw_key, scopes=["skills"]) response = await asgi_client.get( "/api/v1/skills", headers={"X-API-Key": raw_key}, ) - assert response.status_code == 403 + assert response.status_code == 200 data = response.json() - assert data["status"] == "error" - assert data["message"] == "Insufficient API key scope" + assert data["status"] == "ok" + assert data["data"]["skills"] == [{"name": "demo_skill"}] @pytest.mark.asyncio From a4379d5338e711f64f290fc0f5de93cc0f33bfb8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:05:38 +0800 Subject: [PATCH 13/29] fix: sync config scope chip selection --- dashboard/src/views/Settings.vue | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 35c0b24471..8a9d170261 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -317,20 +317,38 @@ const availableScopes = [ ]; const configIncludedScopes = ['bot', 'provider']; +const previousApiKeyScopes = ref([...newApiKeyScopes.value]); watch( newApiKeyScopes, (scopes) => { - if (!scopes.includes('config')) return; + let nextScopes = scopes; const selectedScopes = new Set(scopes); - for (const scope of configIncludedScopes) { - selectedScopes.add(scope); - } - if (selectedScopes.size !== scopes.length) { - newApiKeyScopes.value = availableScopes - .map((scope) => scope.value) + if (selectedScopes.has('config')) { + const previousScopes = new Set(previousApiKeyScopes.value); + const includedScopeRemoved = configIncludedScopes.some( + (scope) => previousScopes.has(scope) && !selectedScopes.has(scope) + ); + + if (includedScopeRemoved) { + selectedScopes.delete('config'); + } else { + for (const scope of configIncludedScopes) { + selectedScopes.add(scope); + } + } + + nextScopes = availableScopes + .map((scopeOption) => scopeOption.value) .filter((scope) => selectedScopes.has(scope)); + if ( + nextScopes.length !== scopes.length || + nextScopes.some((scope, index) => scope !== scopes[index]) + ) { + newApiKeyScopes.value = nextScopes; + } } + previousApiKeyScopes.value = [...nextScopes]; }, { deep: true, immediate: true } ); From 64bc6eff08a3ffbb8627d67a9ac76317807ee1db Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:16:44 +0800 Subject: [PATCH 14/29] fix: restore quart dependency --- pyproject.toml | 1 + requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2bc44eca8c..0bd3a6bf75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "pydub>=0.25.1", "pyjwt>=2.10.1", "qrcode>=8.2", + "quart>=0.20.0", "python-telegram-bot>=22.6", "qq-botpy>=1.2.1", "silk-python>=0.2.6", diff --git a/requirements.txt b/requirements.txt index ef4585e540..1cf2fdc19e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,5 +53,6 @@ shipyard-python-sdk>=0.2.4 shipyard-neo-sdk>=0.2.0 packaging>=24.2 qrcode>=8.2 +quart>=0.20.0 python-ripgrep==0.0.8 pyotp>=2.9.0 From b13c32df3ae4673a56a2c339968abe402c9df5dc Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:21:05 +0800 Subject: [PATCH 15/29] docs: clarify quart plugin api compatibility --- docs/en/dev/star/guides/plugin-pages.md | 4 +++- docs/zh/dev/star/guides/plugin-pages.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md index 9b3eb9c880..badbd0bc98 100644 --- a/docs/en/dev/star/guides/plugin-pages.md +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -70,8 +70,8 @@ When the frontend calls `bridge.apiGet("ping")`, the Dashboard forwards it to: The registered Web API route must include the plugin name as a prefix: ```python +from quart import jsonify from astrbot.api.star import Context, Star -from astrbot.dashboard.fastapi_compat import jsonify PLUGIN_NAME = "astrbot_plugin_page_demo" @@ -90,6 +90,8 @@ class MyPlugin(Star): return jsonify({"message": "pong"}) ``` +Although the Dashboard runtime uses FastAPI/ASGI, plugin Web APIs registered through `context.register_web_api()` still run with a Quart-compatible request context for backward compatibility. Existing plugins can continue to import and use `quart.request`, `quart.g`, and `quart.jsonify`; AstrBot keeps `quart` as a direct dependency for this compatibility layer. + ## Bridge API Inside a plugin Page, use `window.AstrBotPluginPage` directly: diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md index 665077331d..0fb300bf7f 100644 --- a/docs/zh/dev/star/guides/plugin-pages.md +++ b/docs/zh/dev/star/guides/plugin-pages.md @@ -70,8 +70,8 @@ document.getElementById("ping").addEventListener("click", async () => { 因此注册 Web API 时,路由必须带上插件名作为前缀: ```python +from quart import jsonify from astrbot.api.star import Context, Star -from astrbot.dashboard.fastapi_compat import jsonify PLUGIN_NAME = "astrbot_plugin_page_demo" @@ -90,6 +90,8 @@ class MyPlugin(Star): return jsonify({"message": "pong"}) ``` +虽然 Dashboard 运行时已经迁移到 FastAPI/ASGI,但通过 `context.register_web_api()` 注册的插件 Web API 仍会进入 Quart 兼容请求上下文,以保持旧插件兼容。已有插件可以继续导入并使用 `quart.request`、`quart.g` 和 `quart.jsonify`;AstrBot 会继续把 `quart` 作为直接依赖保留,用于这层兼容能力。 + ## Bridge API 插件 Page 中可直接使用 `window.AstrBotPluginPage`: From e037ab8884f17e2a42902449efc499db64b7afd4 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:26:31 +0800 Subject: [PATCH 16/29] docs: update openapi scope documentation --- docs/en/dev/openapi.md | 12 +++- docs/public/openapi.json | 126 +++++++++++++++++++++++++++------------ docs/zh/dev/openapi.md | 12 +++- 3 files changed, 106 insertions(+), 44 deletions(-) diff --git a/docs/en/dev/openapi.md b/docs/en/dev/openapi.md index 5bd3e85ea1..4f5d66a043 100644 --- a/docs/en/dev/openapi.md +++ b/docs/en/dev/openapi.md @@ -38,13 +38,17 @@ When creating an API Key, you can configure `scopes`. Each scope controls the ra | `provider` | Manage model providers and provider sources | `GET/POST /api/v1/providers`, `GET/PUT/DELETE /api/v1/provider-sources/by-id` | | `persona` | Manage personas and persona folders | `GET/POST /api/v1/personas`, `GET/POST /api/v1/persona-folders` | | `im` | Send proactive IM messages and query bot/platform list | `POST /api/v1/im/message`, `GET /api/v1/im/bots` | -| `config` | Manage config profiles, system config, and shared configuration | `GET /api/v1/configs`, `GET/PUT /api/v1/system-config`, `GET/POST /api/v1/config-profiles` | +| `config` | Manage config profiles, system config, and shared configuration. This scope also includes `bot` and `provider` access. | `GET /api/v1/configs`, `GET/PUT /api/v1/system-config`, `GET/POST /api/v1/config-profiles` | | `chat` | Access chat capabilities and query sessions | `POST /api/v1/chat`, `GET /api/v1/chat/sessions` | | `plugin` | Manage plugins, plugin config, plugin sources, and marketplace entries | `GET /api/v1/plugins`, `GET/PUT /api/v1/plugins/config`, `POST /api/v1/plugins/install/url` | +| `mcp` | Manage MCP server configurations and provider sync | `GET/POST /api/v1/mcp/servers`, `PATCH /api/v1/mcp/servers/{server_name}/enabled`, `POST /api/v1/mcp/providers/modelscope/sync` | +| `skills` | Manage skills, skill archives, skill files, and Shipyard Neo skill workflows | `GET/POST /api/v1/skills`, `PUT /api/v1/skills/{skill_name}/files/{file_path}`, `POST /api/v1/skills/neo/sync` | If the API Key does not include the required scope for the target endpoint, the request will return `403 Insufficient API key scope`. -Developer API keys currently support only the 7 scopes listed above. `file`, `tool`, `skill`, `kb`, `data`, and `system` are not valid developer API key scopes. Related endpoints may still appear in the `/api/v1` reference, but they are not available to developer API keys. +`config` is a broad management scope. When an API key is created with `config`, AstrBot grants the key `config`, `bot`, and `provider` access together. The WebUI mirrors this dependency: selecting `config` selects `bot` and `provider`; deselecting `bot` or `provider` removes `config`. + +Developer API keys currently support only the 9 scopes listed above. `file`, `tool`, `skill`, `kb`, `data`, and `system` are not valid developer API key scopes. Use the plural `skills` scope for `/api/v1/skills/*` endpoints. Related endpoints may still appear in the `/api/v1` reference, but they are not available to developer API keys unless their scope is one of the supported scopes above. ## Common Endpoints @@ -63,10 +67,12 @@ Interact with AstrBot's built-in Agent. Supports plugin calls, tool calls, and o - `GET /api/v1/providers`: list model provider configurations - `GET /api/v1/provider-sources`: list provider source configurations -**Personas and Plugins** +**Personas, Plugins, MCP, and Skills** - `GET /api/v1/personas`: list personas - `GET /api/v1/plugins`: list plugins +- `GET /api/v1/mcp/servers`: list MCP servers +- `GET /api/v1/skills`: list skills **Proactive IM Messages** diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 0f56e64feb..e740880835 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "AstrBot OpenAPI v1", "version": "0.1.0", - "description": "Target REST contract for migrating AstrBot HTTP APIs to /api/v1. Dynamic AstrBot configuration payloads are intentionally modeled as open JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, persona, im, config, chat, plugin.\n" + "description": "Target REST contract for migrating AstrBot HTTP APIs to /api/v1. Dynamic AstrBot configuration payloads are intentionally modeled as open JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, persona, im, config, chat, plugin, mcp, skills. The config scope also grants bot and provider access.\n" }, "servers": [ { @@ -4360,6 +4360,36 @@ } } }, + "/api/v1/tools/{tool_id}/permission": { + "patch": { + "tags": [ + "Tools" + ], + "summary": "Update an LLM tool permission", + "operationId": "setToolPermission", + "x-astrbot-scope": "tool", + "parameters": [ + { + "$ref": "#/components/parameters/ToolId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolPermissionPatch" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Ok" + } + } + } + }, "/api/v1/mcp/servers": { "get": { "tags": [ @@ -4367,7 +4397,7 @@ ], "summary": "List MCP servers", "operationId": "listMcpServers", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "responses": { "200": { "$ref": "#/components/responses/Ok" @@ -4380,7 +4410,7 @@ ], "summary": "Add an MCP server", "operationId": "createMcpServer", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "requestBody": { "required": true, "content": { @@ -4405,7 +4435,7 @@ ], "summary": "Update an MCP server by body name", "operationId": "updateMcpServerByName", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "requestBody": { "required": true, "content": { @@ -4443,7 +4473,7 @@ ], "summary": "Delete an MCP server by query name", "operationId": "deleteMcpServerByName", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "parameters": [ { "name": "server_name", @@ -4468,7 +4498,7 @@ ], "summary": "Enable or disable an MCP server by body name", "operationId": "setMcpServerEnabledByName", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "requestBody": { "required": true, "content": { @@ -4506,7 +4536,7 @@ ], "summary": "Test an MCP server connection by body name", "operationId": "testMcpServerByName", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "requestBody": { "required": true, "content": { @@ -4546,7 +4576,7 @@ ], "summary": "Update an MCP server", "operationId": "updateMcpServer", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "parameters": [ { "$ref": "#/components/parameters/ServerName" @@ -4574,7 +4604,7 @@ ], "summary": "Delete an MCP server", "operationId": "deleteMcpServer", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "parameters": [ { "$ref": "#/components/parameters/ServerName" @@ -4594,7 +4624,7 @@ ], "summary": "Enable or disable an MCP server", "operationId": "setMcpServerEnabled", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "parameters": [ { "$ref": "#/components/parameters/ServerName" @@ -4624,7 +4654,7 @@ ], "summary": "Test an MCP server connection", "operationId": "testMcpServer", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "parameters": [ { "$ref": "#/components/parameters/ServerName" @@ -4661,7 +4691,7 @@ ], "summary": "Sync MCP servers from ModelScope", "operationId": "syncModelScopeMcpServers", - "x-astrbot-scope": "tool", + "x-astrbot-scope": "mcp", "requestBody": { "required": false, "content": { @@ -4686,7 +4716,7 @@ ], "summary": "List skills", "operationId": "listSkills", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "enabled", @@ -4715,7 +4745,7 @@ ], "summary": "Upload or import a skill archive", "operationId": "uploadSkill", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -4740,7 +4770,7 @@ ], "summary": "Upload multiple skill archives", "operationId": "uploadSkillsBatch", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -4777,7 +4807,7 @@ ], "summary": "Update skill metadata or enabled state by body name", "operationId": "updateSkillByName", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -4815,7 +4845,7 @@ ], "summary": "Delete a skill by query name", "operationId": "deleteSkillByName", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "skill_name", @@ -4840,7 +4870,7 @@ ], "summary": "Download a skill archive by query name", "operationId": "downloadSkillByName", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "skill_name", @@ -4873,7 +4903,7 @@ ], "summary": "List files in a skill by query name", "operationId": "listSkillFilesByName", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "skill_name", @@ -4905,7 +4935,7 @@ ], "summary": "Get skill file content by query name and path", "operationId": "getSkillFileByName", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "skill_name", @@ -4936,7 +4966,7 @@ ], "summary": "Update skill file content by body name and path", "operationId": "updateSkillFileByName", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -4978,7 +5008,7 @@ ], "summary": "Update skill metadata or enabled state", "operationId": "updateSkill", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5006,7 +5036,7 @@ ], "summary": "Delete a skill", "operationId": "deleteSkill", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5026,7 +5056,7 @@ ], "summary": "Download a skill archive", "operationId": "downloadSkill", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5054,7 +5084,7 @@ ], "summary": "List files in a skill", "operationId": "listSkillFiles", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5081,7 +5111,7 @@ ], "summary": "Get skill file content", "operationId": "getSkillFile", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5102,7 +5132,7 @@ ], "summary": "Update skill file content", "operationId": "updateSkillFile", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5135,7 +5165,7 @@ ], "summary": "List Shipyard Neo skill candidates", "operationId": "listNeoSkillCandidates", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "skill_key", @@ -5166,7 +5196,7 @@ ], "summary": "List Shipyard Neo skill releases", "operationId": "listNeoSkillReleases", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "skill_key", @@ -5197,7 +5227,7 @@ ], "summary": "Get a Shipyard Neo skill payload", "operationId": "getNeoSkillPayload", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "parameters": [ { "name": "payload_ref", @@ -5222,7 +5252,7 @@ ], "summary": "Evaluate a Shipyard Neo skill candidate", "operationId": "evaluateNeoSkillCandidate", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -5247,7 +5277,7 @@ ], "summary": "Promote a Shipyard Neo skill candidate", "operationId": "promoteNeoSkillCandidate", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -5272,7 +5302,7 @@ ], "summary": "Roll back a Shipyard Neo skill release", "operationId": "rollbackNeoSkillRelease", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -5297,7 +5327,7 @@ ], "summary": "Sync a Shipyard Neo skill release", "operationId": "syncNeoSkillRelease", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -5322,7 +5352,7 @@ ], "summary": "Delete a Shipyard Neo skill candidate", "operationId": "deleteNeoSkillCandidate", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -5347,7 +5377,7 @@ ], "summary": "Delete a Shipyard Neo skill release", "operationId": "deleteNeoSkillRelease", - "x-astrbot-scope": "skill", + "x-astrbot-scope": "skills", "requestBody": { "required": true, "content": { @@ -8269,7 +8299,9 @@ "im", "config", "chat", - "plugin" + "plugin", + "mcp", + "skills" ] }, "example": [ @@ -8279,7 +8311,9 @@ "im", "config", "chat", - "plugin" + "plugin", + "mcp", + "skills" ] }, "expires_at": { @@ -8424,6 +8458,22 @@ }, "additionalProperties": false }, + "ToolPermissionPatch": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "admin", + "member" + ] + } + }, + "additionalProperties": false + }, "ProviderSourceConfigRequest": { "type": "object", "required": [ diff --git a/docs/zh/dev/openapi.md b/docs/zh/dev/openapi.md index 810e7895f9..9dcb943926 100644 --- a/docs/zh/dev/openapi.md +++ b/docs/zh/dev/openapi.md @@ -38,13 +38,17 @@ X-API-Key: abk_xxx | `provider` | 管理模型提供商和提供商源 | `GET/POST /api/v1/providers`、`GET/PUT/DELETE /api/v1/provider-sources/by-id` | | `persona` | 管理人格和人格文件夹 | `GET/POST /api/v1/personas`、`GET/POST /api/v1/persona-folders` | | `im` | 主动发 IM 消息、查询 bot/platform 列表 | `POST /api/v1/im/message`、`GET /api/v1/im/bots` | -| `config` | 管理配置文件、系统配置和通用配置 | `GET /api/v1/configs`、`GET/PUT /api/v1/system-config`、`GET/POST /api/v1/config-profiles` | +| `config` | 管理配置文件、系统配置和通用配置。该 scope 同时包含 `bot` 和 `provider` 访问权限。 | `GET /api/v1/configs`、`GET/PUT /api/v1/system-config`、`GET/POST /api/v1/config-profiles` | | `chat` | 调用对话能力、查询对话会话 | `POST /api/v1/chat`、`GET /api/v1/chat/sessions` | | `plugin` | 管理插件、插件配置、插件源和插件市场 | `GET /api/v1/plugins`、`GET/PUT /api/v1/plugins/config`、`POST /api/v1/plugins/install/url` | +| `mcp` | 管理 MCP 服务器配置和服务端同步 | `GET/POST /api/v1/mcp/servers`、`PATCH /api/v1/mcp/servers/{server_name}/enabled`、`POST /api/v1/mcp/providers/modelscope/sync` | +| `skills` | 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程 | `GET/POST /api/v1/skills`、`PUT /api/v1/skills/{skill_name}/files/{file_path}`、`POST /api/v1/skills/neo/sync` | 如果 API Key 未包含目标接口所需 scope,请求会返回 `403 Insufficient API key scope`。 -当前开发者 API Key 仅开放以上 7 个 scope。`file`、`tool`、`skill`、`kb`、`data`、`system` 暂不支持作为开发者 API Key scope;相关接口可能仍出现在 `/api/v1` 文档中,但不对开发者 API Key 开放。 +`config` 是较大的管理 scope。创建 API Key 时如果包含 `config`,AstrBot 会同时授予该 Key `config`、`bot` 和 `provider` 访问权限。WebUI 的勾选逻辑也会体现这个依赖关系:选中 `config` 会同时选中 `bot` 和 `provider`;取消选中 `bot` 或 `provider` 时,会同步取消 `config`。 + +当前开发者 API Key 仅开放以上 9 个 scope。`file`、`tool`、`skill`、`kb`、`data`、`system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用复数 `skills` scope,不使用单数 `skill`。相关接口可能仍出现在 `/api/v1` 文档中,但只有 scope 属于上表支持范围时才对开发者 API Key 开放。 ## 常用接口 @@ -63,10 +67,12 @@ X-API-Key: abk_xxx - `GET /api/v1/providers`:获取模型提供商配置列表 - `GET /api/v1/provider-sources`:获取提供商源配置列表 -**人格和插件** +**人格、插件、MCP 和 Skills** - `GET /api/v1/personas`:获取人格列表 - `GET /api/v1/plugins`:获取插件列表 +- `GET /api/v1/mcp/servers`:获取 MCP 服务器列表 +- `GET /api/v1/skills`:获取 Skills 列表 **IM 消息发送** From bed3db1fe9deacf35a59692c906cddb515ba9504 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:29:46 +0800 Subject: [PATCH 17/29] fix: use singular skill openapi scope --- astrbot/dashboard/api/skills.py | 2 +- astrbot/dashboard/services/auth_service.py | 2 +- .../src/api/generated/openapi-v1/types.gen.ts | 2 +- dashboard/src/views/Settings.vue | 2 +- docs/en/dev/openapi.md | 4 +- docs/public/openapi.json | 54 +++++++++---------- docs/zh/dev/openapi.md | 4 +- openspec/openapi-v1.yaml | 54 +++++++++---------- tests/test_api_key_open_api.py | 4 +- tests/test_fastapi_v1_dashboard.py | 18 +++---- 10 files changed, 73 insertions(+), 73 deletions(-) diff --git a/astrbot/dashboard/api/skills.py b/astrbot/dashboard/api/skills.py index 67d9c1b3fe..c8925c8e4a 100644 --- a/astrbot/dashboard/api/skills.py +++ b/astrbot/dashboard/api/skills.py @@ -37,7 +37,7 @@ def get_service(request: Request) -> SkillsService: async def require_skill_scope(request: Request) -> AuthContext: - return await require_scope(request, "skills") + return await require_scope(request, "skill") async def _json_or_empty(request: Request) -> dict[str, Any]: diff --git a/astrbot/dashboard/services/auth_service.py b/astrbot/dashboard/services/auth_service.py index 40e278c016..d02b602ad8 100644 --- a/astrbot/dashboard/services/auth_service.py +++ b/astrbot/dashboard/services/auth_service.py @@ -56,7 +56,7 @@ "chat", "plugin", "mcp", - "skills", + "skill", ) OPEN_API_SCOPE_INCLUDES = { diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 54e38f196c..e0938bb915 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -191,7 +191,7 @@ export type ConversationRef = { export type CreateApiKeyRequest = { name: string; - scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'plugin' | 'mcp' | 'skills')>; + scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'plugin' | 'mcp' | 'skill')>; expires_at?: string; expires_in_days?: number; }; diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 8a9d170261..cbacacd39e 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -313,7 +313,7 @@ const availableScopes = [ { value: 'chat', label: 'chat' }, { value: 'plugin', label: 'plugin' }, { value: 'mcp', label: 'mcp' }, - { value: 'skills', label: 'skills' } + { value: 'skill', label: 'skill' } ]; const configIncludedScopes = ['bot', 'provider']; diff --git a/docs/en/dev/openapi.md b/docs/en/dev/openapi.md index 4f5d66a043..a08d6236ce 100644 --- a/docs/en/dev/openapi.md +++ b/docs/en/dev/openapi.md @@ -42,13 +42,13 @@ When creating an API Key, you can configure `scopes`. Each scope controls the ra | `chat` | Access chat capabilities and query sessions | `POST /api/v1/chat`, `GET /api/v1/chat/sessions` | | `plugin` | Manage plugins, plugin config, plugin sources, and marketplace entries | `GET /api/v1/plugins`, `GET/PUT /api/v1/plugins/config`, `POST /api/v1/plugins/install/url` | | `mcp` | Manage MCP server configurations and provider sync | `GET/POST /api/v1/mcp/servers`, `PATCH /api/v1/mcp/servers/{server_name}/enabled`, `POST /api/v1/mcp/providers/modelscope/sync` | -| `skills` | Manage skills, skill archives, skill files, and Shipyard Neo skill workflows | `GET/POST /api/v1/skills`, `PUT /api/v1/skills/{skill_name}/files/{file_path}`, `POST /api/v1/skills/neo/sync` | +| `skill` | Manage skills, skill archives, skill files, and Shipyard Neo skill workflows | `GET/POST /api/v1/skills`, `PUT /api/v1/skills/{skill_name}/files/{file_path}`, `POST /api/v1/skills/neo/sync` | If the API Key does not include the required scope for the target endpoint, the request will return `403 Insufficient API key scope`. `config` is a broad management scope. When an API key is created with `config`, AstrBot grants the key `config`, `bot`, and `provider` access together. The WebUI mirrors this dependency: selecting `config` selects `bot` and `provider`; deselecting `bot` or `provider` removes `config`. -Developer API keys currently support only the 9 scopes listed above. `file`, `tool`, `skill`, `kb`, `data`, and `system` are not valid developer API key scopes. Use the plural `skills` scope for `/api/v1/skills/*` endpoints. Related endpoints may still appear in the `/api/v1` reference, but they are not available to developer API keys unless their scope is one of the supported scopes above. +Developer API keys currently support only the 9 scopes listed above. `file`, `tool`, `skills`, `kb`, `data`, and `system` are not valid developer API key scopes. Use the singular `skill` scope for `/api/v1/skills/*` endpoints. Related endpoints may still appear in the `/api/v1` reference, but they are not available to developer API keys unless their scope is one of the supported scopes above. ## Common Endpoints diff --git a/docs/public/openapi.json b/docs/public/openapi.json index e740880835..457fbac48a 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "AstrBot OpenAPI v1", "version": "0.1.0", - "description": "Target REST contract for migrating AstrBot HTTP APIs to /api/v1. Dynamic AstrBot configuration payloads are intentionally modeled as open JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, persona, im, config, chat, plugin, mcp, skills. The config scope also grants bot and provider access.\n" + "description": "Target REST contract for migrating AstrBot HTTP APIs to /api/v1. Dynamic AstrBot configuration payloads are intentionally modeled as open JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, persona, im, config, chat, plugin, mcp, skill. The config scope also grants bot and provider access.\n" }, "servers": [ { @@ -4716,7 +4716,7 @@ ], "summary": "List skills", "operationId": "listSkills", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "enabled", @@ -4745,7 +4745,7 @@ ], "summary": "Upload or import a skill archive", "operationId": "uploadSkill", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -4770,7 +4770,7 @@ ], "summary": "Upload multiple skill archives", "operationId": "uploadSkillsBatch", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -4807,7 +4807,7 @@ ], "summary": "Update skill metadata or enabled state by body name", "operationId": "updateSkillByName", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -4845,7 +4845,7 @@ ], "summary": "Delete a skill by query name", "operationId": "deleteSkillByName", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "skill_name", @@ -4870,7 +4870,7 @@ ], "summary": "Download a skill archive by query name", "operationId": "downloadSkillByName", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "skill_name", @@ -4903,7 +4903,7 @@ ], "summary": "List files in a skill by query name", "operationId": "listSkillFilesByName", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "skill_name", @@ -4935,7 +4935,7 @@ ], "summary": "Get skill file content by query name and path", "operationId": "getSkillFileByName", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "skill_name", @@ -4966,7 +4966,7 @@ ], "summary": "Update skill file content by body name and path", "operationId": "updateSkillFileByName", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -5008,7 +5008,7 @@ ], "summary": "Update skill metadata or enabled state", "operationId": "updateSkill", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5036,7 +5036,7 @@ ], "summary": "Delete a skill", "operationId": "deleteSkill", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5056,7 +5056,7 @@ ], "summary": "Download a skill archive", "operationId": "downloadSkill", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5084,7 +5084,7 @@ ], "summary": "List files in a skill", "operationId": "listSkillFiles", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5111,7 +5111,7 @@ ], "summary": "Get skill file content", "operationId": "getSkillFile", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5132,7 +5132,7 @@ ], "summary": "Update skill file content", "operationId": "updateSkillFile", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "$ref": "#/components/parameters/SkillName" @@ -5165,7 +5165,7 @@ ], "summary": "List Shipyard Neo skill candidates", "operationId": "listNeoSkillCandidates", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "skill_key", @@ -5196,7 +5196,7 @@ ], "summary": "List Shipyard Neo skill releases", "operationId": "listNeoSkillReleases", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "skill_key", @@ -5227,7 +5227,7 @@ ], "summary": "Get a Shipyard Neo skill payload", "operationId": "getNeoSkillPayload", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "parameters": [ { "name": "payload_ref", @@ -5252,7 +5252,7 @@ ], "summary": "Evaluate a Shipyard Neo skill candidate", "operationId": "evaluateNeoSkillCandidate", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -5277,7 +5277,7 @@ ], "summary": "Promote a Shipyard Neo skill candidate", "operationId": "promoteNeoSkillCandidate", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -5302,7 +5302,7 @@ ], "summary": "Roll back a Shipyard Neo skill release", "operationId": "rollbackNeoSkillRelease", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -5327,7 +5327,7 @@ ], "summary": "Sync a Shipyard Neo skill release", "operationId": "syncNeoSkillRelease", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -5352,7 +5352,7 @@ ], "summary": "Delete a Shipyard Neo skill candidate", "operationId": "deleteNeoSkillCandidate", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -5377,7 +5377,7 @@ ], "summary": "Delete a Shipyard Neo skill release", "operationId": "deleteNeoSkillRelease", - "x-astrbot-scope": "skills", + "x-astrbot-scope": "skill", "requestBody": { "required": true, "content": { @@ -8301,7 +8301,7 @@ "chat", "plugin", "mcp", - "skills" + "skill" ] }, "example": [ @@ -8313,7 +8313,7 @@ "chat", "plugin", "mcp", - "skills" + "skill" ] }, "expires_at": { diff --git a/docs/zh/dev/openapi.md b/docs/zh/dev/openapi.md index 9dcb943926..5ff5ffccf5 100644 --- a/docs/zh/dev/openapi.md +++ b/docs/zh/dev/openapi.md @@ -42,13 +42,13 @@ X-API-Key: abk_xxx | `chat` | 调用对话能力、查询对话会话 | `POST /api/v1/chat`、`GET /api/v1/chat/sessions` | | `plugin` | 管理插件、插件配置、插件源和插件市场 | `GET /api/v1/plugins`、`GET/PUT /api/v1/plugins/config`、`POST /api/v1/plugins/install/url` | | `mcp` | 管理 MCP 服务器配置和服务端同步 | `GET/POST /api/v1/mcp/servers`、`PATCH /api/v1/mcp/servers/{server_name}/enabled`、`POST /api/v1/mcp/providers/modelscope/sync` | -| `skills` | 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程 | `GET/POST /api/v1/skills`、`PUT /api/v1/skills/{skill_name}/files/{file_path}`、`POST /api/v1/skills/neo/sync` | +| `skill` | 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程 | `GET/POST /api/v1/skills`、`PUT /api/v1/skills/{skill_name}/files/{file_path}`、`POST /api/v1/skills/neo/sync` | 如果 API Key 未包含目标接口所需 scope,请求会返回 `403 Insufficient API key scope`。 `config` 是较大的管理 scope。创建 API Key 时如果包含 `config`,AstrBot 会同时授予该 Key `config`、`bot` 和 `provider` 访问权限。WebUI 的勾选逻辑也会体现这个依赖关系:选中 `config` 会同时选中 `bot` 和 `provider`;取消选中 `bot` 或 `provider` 时,会同步取消 `config`。 -当前开发者 API Key 仅开放以上 9 个 scope。`file`、`tool`、`skill`、`kb`、`data`、`system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用复数 `skills` scope,不使用单数 `skill`。相关接口可能仍出现在 `/api/v1` 文档中,但只有 scope 属于上表支持范围时才对开发者 API Key 开放。 +当前开发者 API Key 仅开放以上 9 个 scope。`file`、`tool`、`skills`、`kb`、`data`、`system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用单数 `skill` scope,不使用复数 `skills`。相关接口可能仍出现在 `/api/v1` 文档中,但只有 scope 属于上表支持范围时才对开发者 API Key 开放。 ## 常用接口 diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index 91d2609ae3..fb6ad80f65 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -8,7 +8,7 @@ info: JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, - persona, im, config, chat, plugin, mcp, skills. The config scope also + persona, im, config, chat, plugin, mcp, skill. The config scope also grants bot and provider access. servers: - url: http://localhost:6185 @@ -2873,7 +2873,7 @@ paths: tags: [Skills] summary: List skills operationId: listSkills - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: enabled in: query @@ -2890,7 +2890,7 @@ paths: tags: [Skills] summary: Upload or import a skill archive operationId: uploadSkill - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -2906,7 +2906,7 @@ paths: tags: [Skills] summary: Upload multiple skill archives operationId: uploadSkillsBatch - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -2929,7 +2929,7 @@ paths: tags: [Skills] summary: Update skill metadata or enabled state by body name operationId: updateSkillByName - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -2952,7 +2952,7 @@ paths: tags: [Skills] summary: Delete a skill by query name operationId: deleteSkillByName - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: skill_name in: query @@ -2968,7 +2968,7 @@ paths: tags: [Skills] summary: Download a skill archive by query name operationId: downloadSkillByName - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: skill_name in: query @@ -2989,7 +2989,7 @@ paths: tags: [Skills] summary: List files in a skill by query name operationId: listSkillFilesByName - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: skill_name in: query @@ -3009,7 +3009,7 @@ paths: tags: [Skills] summary: Get skill file content by query name and path operationId: getSkillFileByName - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: skill_name in: query @@ -3028,7 +3028,7 @@ paths: tags: [Skills] summary: Update skill file content by body name and path operationId: updateSkillFileByName - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -3053,7 +3053,7 @@ paths: tags: [Skills] summary: Update skill metadata or enabled state operationId: updateSkill - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - $ref: "#/components/parameters/SkillName" requestBody: @@ -3069,7 +3069,7 @@ paths: tags: [Skills] summary: Delete a skill operationId: deleteSkill - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - $ref: "#/components/parameters/SkillName" responses: @@ -3081,7 +3081,7 @@ paths: tags: [Skills] summary: Download a skill archive operationId: downloadSkill - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - $ref: "#/components/parameters/SkillName" responses: @@ -3098,7 +3098,7 @@ paths: tags: [Skills] summary: List files in a skill operationId: listSkillFiles - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - $ref: "#/components/parameters/SkillName" - name: path @@ -3114,7 +3114,7 @@ paths: tags: [Skills] summary: Get skill file content operationId: getSkillFile - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - $ref: "#/components/parameters/SkillName" - $ref: "#/components/parameters/FilePath" @@ -3125,7 +3125,7 @@ paths: tags: [Skills] summary: Update skill file content operationId: updateSkillFile - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - $ref: "#/components/parameters/SkillName" - $ref: "#/components/parameters/FilePath" @@ -3144,7 +3144,7 @@ paths: tags: [Skills] summary: List Shipyard Neo skill candidates operationId: listNeoSkillCandidates - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: skill_key in: query @@ -3163,7 +3163,7 @@ paths: tags: [Skills] summary: List Shipyard Neo skill releases operationId: listNeoSkillReleases - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: skill_key in: query @@ -3182,7 +3182,7 @@ paths: tags: [Skills] summary: Get a Shipyard Neo skill payload operationId: getNeoSkillPayload - x-astrbot-scope: skills + x-astrbot-scope: skill parameters: - name: payload_ref in: query @@ -3198,7 +3198,7 @@ paths: tags: [Skills] summary: Evaluate a Shipyard Neo skill candidate operationId: evaluateNeoSkillCandidate - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -3214,7 +3214,7 @@ paths: tags: [Skills] summary: Promote a Shipyard Neo skill candidate operationId: promoteNeoSkillCandidate - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -3230,7 +3230,7 @@ paths: tags: [Skills] summary: Roll back a Shipyard Neo skill release operationId: rollbackNeoSkillRelease - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -3246,7 +3246,7 @@ paths: tags: [Skills] summary: Sync a Shipyard Neo skill release operationId: syncNeoSkillRelease - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -3262,7 +3262,7 @@ paths: tags: [Skills] summary: Delete a Shipyard Neo skill candidate operationId: deleteNeoSkillCandidate - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -3278,7 +3278,7 @@ paths: tags: [Skills] summary: Delete a Shipyard Neo skill release operationId: deleteNeoSkillRelease - x-astrbot-scope: skills + x-astrbot-scope: skill requestBody: required: true content: @@ -5105,8 +5105,8 @@ components: type: array items: type: string - enum: [bot, provider, persona, im, config, chat, plugin, mcp, skills] - example: [bot, provider, persona, im, config, chat, plugin, mcp, skills] + enum: [bot, provider, persona, im, config, chat, plugin, mcp, skill] + example: [bot, provider, persona, im, config, chat, plugin, mcp, skill] expires_at: type: string format: date-time diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py index 8377c825b0..82837e1b75 100644 --- a/tests/test_api_key_open_api.py +++ b/tests/test_api_key_open_api.py @@ -776,14 +776,14 @@ async def test_open_api_key_scope_normalization( extra_scope_res = await test_client.post( "/api/apikey/create", - json={"name": "mcp-skills-scope-key", "scopes": ["mcp", "skills"]}, + json={"name": "mcp-skill-scope-key", "scopes": ["mcp", "skill"]}, headers=authenticated_header, ) extra_scope_data = await extra_scope_res.get_json() assert extra_scope_res.status_code == 200 assert extra_scope_data["status"] == "ok" - assert set(extra_scope_data["data"]["scopes"]) == {"mcp", "skills"} + assert set(extra_scope_data["data"]["scopes"]) == {"mcp", "skill"} @pytest.mark.asyncio diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 2d40439495..69f9bcf630 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -2108,7 +2108,7 @@ async def test_v1_mcp_scope_accepts_api_key( @pytest.mark.asyncio -async def test_v1_skills_scope_accepts_api_key_and_rejects_legacy_singular_scope( +async def test_v1_skill_scope_accepts_api_key_and_rejects_plural_scope( asgi_app: FastAPI, asgi_client: httpx.AsyncClient, fake_db: FakeDb, @@ -2120,20 +2120,20 @@ async def test_v1_skills_scope_accepts_api_key_and_rejects_legacy_singular_scope lambda: {"skills": [{"name": "demo_skill"}]}, ) - legacy_key = "abk_fastapi_v1_skill" - fake_db.add_api_key(legacy_key, scopes=["skill"]) - legacy_response = await asgi_client.get( + plural_key = "abk_fastapi_v1_skills" + fake_db.add_api_key(plural_key, scopes=["skills"]) + plural_response = await asgi_client.get( "/api/v1/skills", - headers={"X-API-Key": legacy_key}, + headers={"X-API-Key": plural_key}, ) - assert legacy_response.status_code == 403 - data = legacy_response.json() + assert plural_response.status_code == 403 + data = plural_response.json() assert data["status"] == "error" assert data["message"] == "Insufficient API key scope" - raw_key = "abk_fastapi_v1_skills" - fake_db.add_api_key(raw_key, scopes=["skills"]) + raw_key = "abk_fastapi_v1_skill" + fake_db.add_api_key(raw_key, scopes=["skill"]) response = await asgi_client.get( "/api/v1/skills", headers={"X-API-Key": raw_key}, From 1721e548c9d0244b181522fc4fc3414ab6d152c5 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:39:23 +0800 Subject: [PATCH 18/29] fix: hide update service exception details --- astrbot/dashboard/api/updates.py | 4 +- astrbot/dashboard/services/update_service.py | 2 +- tests/test_dashboard.py | 45 +++++++++++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/astrbot/dashboard/api/updates.py b/astrbot/dashboard/api/updates.py index 05b8a4d251..1f2041a5bf 100644 --- a/astrbot/dashboard/api/updates.py +++ b/astrbot/dashboard/api/updates.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse +from astrbot.core import logger from astrbot.dashboard.async_utils import run_maybe_async from astrbot.dashboard.schemas import MigrationRequest, PipInstallRequest, UpdateRequest from astrbot.dashboard.services.update_service import ( @@ -56,8 +57,9 @@ def _service_response(result: UpdateServiceResult) -> JSONResponse: def _service_error(exc: UpdateServiceError) -> JSONResponse: + logger.error(f"Dashboard update operation failed: {exc}", exc_info=True) return JSONResponse( - {"status": "error", "message": str(exc), "data": None}, + {"status": "error", "message": "An internal error has occurred.", "data": None}, status_code=200, ) diff --git a/astrbot/dashboard/services/update_service.py b/astrbot/dashboard/services/update_service.py index db40c1b93e..cd203bec42 100644 --- a/astrbot/dashboard/services/update_service.py +++ b/astrbot/dashboard/services/update_service.py @@ -279,7 +279,7 @@ async def update_project(self, data: object) -> UpdateServiceResult: self.update_progress[progress_id].update( { "status": "error", - "message": exc.__str__(), + "message": "更新失败,请查看服务端日志。", }, ) logger.error(f"/api/update_project: {traceback.format_exc()}") diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 8c28663227..ef3222ccb2 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2622,7 +2622,48 @@ async def mock_pip_install(*args, **kwargs): @pytest.mark.asyncio -async def test_install_pip_package_returns_pip_install_error_message( +async def test_do_update_hides_internal_error_message_in_response_and_progress( + app: FastAPIAppAdapter, + authenticated_header: dict, + monkeypatch, +): + test_client = app.test_client() + + async def mock_download_dashboard(*args, **kwargs): + del args, kwargs + raise RuntimeError("secret stack trace") + + monkeypatch.setattr( + "astrbot.dashboard.services.update_service.download_dashboard", + mock_download_dashboard, + ) + + response = await test_client.post( + "/api/update/do", + headers=authenticated_header, + json={"version": "v3.4.0", "reboot": False, "progress_id": "failed-progress"}, + ) + data = await response.get_json() + + assert response.status_code == 200 + assert data["status"] == "error" + assert data["message"] == "An internal error has occurred." + assert "secret stack trace" not in str(data) + + progress_response = await test_client.get( + "/api/update/progress?id=failed-progress", + headers=authenticated_header, + ) + progress_data = await progress_response.get_json() + + assert progress_data["status"] == "ok" + assert progress_data["data"]["status"] == "error" + assert progress_data["data"]["message"] == "更新失败,请查看服务端日志。" + assert "secret stack trace" not in str(progress_data) + + +@pytest.mark.asyncio +async def test_install_pip_package_returns_generic_error_message( app: FastAPIAppAdapter, authenticated_header: dict, monkeypatch, @@ -2647,7 +2688,7 @@ async def mock_pip_install(*args, **kwargs): assert response.status_code == 200 data = await response.get_json() assert data["status"] == "error" - assert data["message"] == "install failed" + assert data["message"] == "An internal error has occurred." class _FakeNeoSkills: From 6fb7dc3a44191c79bfe9cd48131b46dbc21399cd Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 12:47:59 +0800 Subject: [PATCH 19/29] fix: address fastapi review comments --- astrbot/core/platform/webhook_server.py | 4 +++- astrbot/dashboard/api/auth.py | 16 ++++++++------ astrbot/dashboard/api/open_api.py | 6 ++++- astrbot/dashboard/asgi_runtime.py | 1 + tests/test_fastapi_v1_dashboard.py | 29 +++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/astrbot/core/platform/webhook_server.py b/astrbot/core/platform/webhook_server.py index 8c9efb7fa1..57fe52dfc8 100644 --- a/astrbot/core/platform/webhook_server.py +++ b/astrbot/core/platform/webhook_server.py @@ -68,8 +68,10 @@ def add_url_rule( view_func: Callable, methods: list[str] | None = None, ) -> None: + has_params = bool(inspect.signature(view_func).parameters) + async def endpoint(request: Request): - if inspect.signature(view_func).parameters: + if has_params: result = view_func(WebhookRequest(request)) else: result = view_func() diff --git a/astrbot/dashboard/api/auth.py b/astrbot/dashboard/api/auth.py index 7adf894b74..fda03f4a32 100644 --- a/astrbot/dashboard/api/auth.py +++ b/astrbot/dashboard/api/auth.py @@ -147,10 +147,9 @@ async def require_scope(request: Request, scope: str) -> AuthContext: if raw_key: return await _require_api_key_scope(request, raw_key, scope) - auth_header = request.headers.get("Authorization", "").strip() - if not auth_header.startswith("Bearer "): + token = _extract_dashboard_jwt(request) + if not token: raise ApiError("Missing API key", status_code=401) - token = auth_header.removeprefix("Bearer ").strip() try: payload = jwt.decode( token, @@ -160,10 +159,13 @@ async def require_scope(request: Request, scope: str) -> AuthContext: except jwt.ExpiredSignatureError as exc: raise ApiError("Token expired", status_code=401) from exc except jwt.InvalidTokenError as exc: - try: - return await _require_api_key_scope(request, token, scope) - except ApiError as api_key_exc: - raise api_key_exc from exc + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + try: + return await _require_api_key_scope(request, token, scope) + except ApiError as api_key_exc: + raise api_key_exc from exc + raise ApiError("Invalid token", status_code=401) from exc username = payload.get("username") if not isinstance(username, str) or not username.strip(): diff --git a/astrbot/dashboard/api/open_api.py b/astrbot/dashboard/api/open_api.py index cca8dfaf78..b6eb41ccef 100644 --- a/astrbot/dashboard/api/open_api.py +++ b/astrbot/dashboard/api/open_api.py @@ -267,11 +267,15 @@ async def chat_ws(websocket: WebSocket) -> None: await websocket.accept() service: OpenApiService = websocket.app.state.services.open_api chat_service: ChatService = websocket.app.state.services.chat + + async def close_ws(code: int, reason: str) -> None: + await websocket.close(code=code, reason=reason) + await service.run_chat_websocket( raw_api_key=_extract_ws_api_key(websocket), receive_json=websocket.receive_json, send_json=websocket.send_json, - close=lambda code, reason: websocket.close(code=code, reason=reason), + close=close_ws, conf_list=_get_chat_config_list(service), chat_bridge=_build_chat_ws_bridge(service, chat_service), ) diff --git a/astrbot/dashboard/asgi_runtime.py b/astrbot/dashboard/asgi_runtime.py index d985ec4b82..b2dbef020d 100644 --- a/astrbot/dashboard/asgi_runtime.py +++ b/astrbot/dashboard/asgi_runtime.py @@ -632,6 +632,7 @@ async def call_websocket_view( class FastAPIAppAdapter: def __init__(self, app: FastAPI, static_folder: str | None = None) -> None: self._app = app + app.state.dashboard_app_adapter = self self.static_folder = static_folder self._dashboard_server: Any | None = None self.config: dict[str, Any] = {} diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 69f9bcf630..2357a8f636 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -22,6 +22,7 @@ ) from astrbot.dashboard.responses import ok from astrbot.dashboard.services.api_key_service import ApiKeyService +from astrbot.dashboard.services.auth_service import DASHBOARD_JWT_COOKIE_NAME from astrbot.dashboard.services.skills_service import SkillArchive JWT_SECRET = "fastapi-v1-test-secret-with-32-bytes" @@ -974,6 +975,34 @@ def _jwt_headers() -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +def test_fastapi_app_adapter_registers_on_app_state(): + app = FastAPI() + adapter = FastAPIAppAdapter(app) + + assert app.state.dashboard_app_adapter is adapter + + +@pytest.mark.asyncio +async def test_v1_scope_dependencies_accept_dashboard_cookie( + asgi_client: httpx.AsyncClient, +): + token = jwt.encode( + {"username": "fastapi-v1-cookie-test"}, + JWT_SECRET, + algorithm="HS256", + ) + + response = await asgi_client.get( + "/api/v1/bots", + headers={"Cookie": f"{DASHBOARD_JWT_COOKIE_NAME}={token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert isinstance(data["data"]["bots"], list) + + @pytest.mark.asyncio async def test_v1_openapi_is_served_by_fastapi(asgi_client: httpx.AsyncClient): response = await asgi_client.get("/api/v1/openapi.json") From 575edfb67ca619236668a4eec6f8a392a6dd2348 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 13:21:30 +0800 Subject: [PATCH 20/29] fix: address dashboard review findings --- astrbot/dashboard/api/backups.py | 21 +++++++-- astrbot/dashboard/api/conversations.py | 15 ++++-- astrbot/dashboard/api/files.py | 13 +---- .../dashboard/services/static_file_service.py | 3 ++ tests/test_fastapi_v1_dashboard.py | 47 +++++++++++++++++++ 5 files changed, 78 insertions(+), 21 deletions(-) diff --git a/astrbot/dashboard/api/backups.py b/astrbot/dashboard/api/backups.py index 0e04a1f92d..c741359d35 100644 --- a/astrbot/dashboard/api/backups.py +++ b/astrbot/dashboard/api/backups.py @@ -46,6 +46,14 @@ def _ok_result(result): return ok(result) +def _safe_backup_filename(filename: str | None) -> str: + if not filename: + raise BackupServiceError("缺少参数 filename") + if ".." in filename or "/" in filename or "\\" in filename: + raise BackupServiceError("文件名包含非法路径字符") + return filename + + async def _json_or_empty(request: Request) -> dict: try: data = await request.json() @@ -80,6 +88,7 @@ def _download_backup( service: BackupService, ): try: + filename = _safe_backup_filename(filename) return _download_response( service.prepare_download( filename=filename, @@ -309,7 +318,9 @@ async def rename_backup( service: BackupService = Depends(get_service), ): return await _run( - lambda: service.rename_backup({"filename": filename, **_model_dict(payload)}), + lambda: service.rename_backup( + {"filename": _safe_backup_filename(filename), **_model_dict(payload)} + ), prefix="重命名备份失败", ) @@ -334,7 +345,7 @@ async def delete_backup( service: BackupService = Depends(get_service), ): return await _run( - lambda: service.delete_backup({"filename": filename}), + lambda: service.delete_backup({"filename": _safe_backup_filename(filename)}), prefix="删除备份失败", ) @@ -360,7 +371,7 @@ async def check_backup( service: BackupService = Depends(get_service), ): return await _run( - lambda: service.check_backup({"filename": filename}), + lambda: service.check_backup({"filename": _safe_backup_filename(filename)}), prefix="预检查备份文件失败", ) @@ -387,7 +398,9 @@ async def import_backup( service: BackupService = Depends(get_service), ): return await _run( - lambda: service.import_backup({"filename": filename, **_model_dict(payload)}), + lambda: service.import_backup( + {"filename": _safe_backup_filename(filename), **_model_dict(payload)} + ), prefix="导入备份失败", ) diff --git a/astrbot/dashboard/api/conversations.py b/astrbot/dashboard/api/conversations.py index f2355d835c..50edc5fc6b 100644 --- a/astrbot/dashboard/api/conversations.py +++ b/astrbot/dashboard/api/conversations.py @@ -63,8 +63,13 @@ async def _run(operation): def _export_response(export: ConversationExport) -> StreamingResponse: export.file_obj.seek(0) + + def iter_file(): + while chunk := export.file_obj.read(8192): + yield chunk + return StreamingResponse( - export.file_obj, + iter_file(), media_type=export.mimetype, headers={"Content-Disposition": f'attachment; filename="{export.filename}"'}, ) @@ -150,7 +155,7 @@ async def batch_delete_conversations( async def replace_conversation_messages( conversation_id: str, payload: ConversationMessagesReplaceRequest, - user_id: str | None = Query(default=None), + user_id: str = Query(...), _auth: AuthContext = Depends(require_data_scope), service: ConversationService = Depends(get_service), ): @@ -168,7 +173,7 @@ async def replace_conversation_messages( @router.get("/conversations/{conversation_id:path}") async def get_conversation( conversation_id: str, - user_id: str | None = Query(default=None), + user_id: str = Query(...), _auth: AuthContext = Depends(require_data_scope), service: ConversationService = Depends(get_service), ): @@ -183,7 +188,7 @@ async def get_conversation( async def update_conversation( conversation_id: str, payload: ConversationPatchRequest, - user_id: str | None = Query(default=None), + user_id: str = Query(...), _auth: AuthContext = Depends(require_data_scope), service: ConversationService = Depends(get_service), ): @@ -199,7 +204,7 @@ async def update_conversation( @router.delete("/conversations/{conversation_id:path}") async def delete_conversation( conversation_id: str, - user_id: str | None = Query(default=None), + user_id: str = Query(...), _auth: AuthContext = Depends(require_data_scope), service: ConversationService = Depends(get_service), ): diff --git a/astrbot/dashboard/api/files.py b/astrbot/dashboard/api/files.py index 86ab241c6d..46ae76a697 100644 --- a/astrbot/dashboard/api/files.py +++ b/astrbot/dashboard/api/files.py @@ -1,7 +1,5 @@ from __future__ import annotations -from pathlib import Path - from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse @@ -11,6 +9,7 @@ from astrbot.dashboard.services.file_service import FileService, FileServiceError from .auth import AuthContext, require_scope +from .multipart import UploadFileAdapter router = APIRouter(tags=["Files"]) legacy_router = APIRouter(prefix="/api", include_in_schema=False) @@ -28,16 +27,6 @@ async def require_file_scope(request: Request) -> AuthContext: return await require_scope(request, "file") -class UploadFileAdapter: - def __init__(self, file: UploadFile) -> None: - self.file = file - self.filename = file.filename - self.content_type = file.content_type - - async def save(self, target_path: str) -> None: - Path(target_path).write_bytes(await self.file.read()) - - async def _serve_token_file(file_token: str, service: FileService): try: return FileResponse(await service.resolve_token_file(file_token)) diff --git a/astrbot/dashboard/services/static_file_service.py b/astrbot/dashboard/services/static_file_service.py index bc0dfbb1b6..b907ff467d 100644 --- a/astrbot/dashboard/services/static_file_service.py +++ b/astrbot/dashboard/services/static_file_service.py @@ -54,6 +54,9 @@ def resolve_static_file( return None if requested_path.startswith("api/"): return None + path_parts = requested_path.replace("\\", "/").split("/") + if requested_path.startswith(("/", "\\")) or ".." in path_parts: + return None static_root = Path(static_folder).resolve() target_file = (static_root / requested_path).resolve() diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 2357a8f636..d0246e527e 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1085,6 +1085,25 @@ async def test_dashboard_static_dist_files_are_served( assert api_response.status_code == 404 +@pytest.mark.asyncio +async def test_v1_backup_path_rejects_traversal(asgi_client: httpx.AsyncClient): + download_response = await asgi_client.get( + "/api/v1/backups/%2E%2E/secret.zip", + params={"token": "demo"}, + ) + delete_response = await asgi_client.delete( + "/api/v1/backups/%2E%2E/secret.zip", + headers=_jwt_headers(), + ) + + assert download_response.status_code == 200 + assert delete_response.status_code == 200 + assert download_response.json()["status"] == "error" + assert delete_response.json()["status"] == "error" + assert "非法路径" in download_response.json()["message"] + assert "非法路径" in delete_response.json()["message"] + + @pytest.mark.asyncio async def test_v1_openapi_uses_pydantic_request_bodies( asgi_client: httpx.AsyncClient, @@ -1130,6 +1149,18 @@ async def test_v1_conversation_path_id_allows_slash(asgi_client: httpx.AsyncClie assert payload["data"]["cid"] == "conversation/with/slash" +@pytest.mark.asyncio +async def test_v1_conversation_detail_requires_user_id( + asgi_client: httpx.AsyncClient, +): + response = await asgi_client.get( + "/api/v1/conversations/conversation%2Fwith%2Fslash", + headers=_jwt_headers(), + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio async def test_dashboard_alias_conversation_detail_uses_fastapi_service( asgi_client: httpx.AsyncClient, @@ -1790,6 +1821,22 @@ async def quart_plugin_extension(item_id: str): } +@pytest.mark.asyncio +async def test_multipart_parts_preserves_duplicate_form_values(): + from starlette.datastructures import FormData + + from astrbot.dashboard.api.multipart import multipart_parts + + class FakeRequest: + async def form(self): + return FormData([("tag", "one"), ("tag", "two")]) + + form, files = await multipart_parts(FakeRequest()) + + assert form.getlist("tag") == ["one", "two"] + assert not files + + @pytest.mark.asyncio async def test_v1_plugin_config_file_routes_reach_service_layer( asgi_client: httpx.AsyncClient, From defdc53ff8ba914d7a5ebf29470b832151007245 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 13:32:51 +0800 Subject: [PATCH 21/29] docs: revert unrelated package deployment changes --- docs/en/deploy/astrbot/package.md | 68 +------------------------------ docs/zh/deploy/astrbot/package.md | 65 ----------------------------- 2 files changed, 1 insertion(+), 132 deletions(-) diff --git a/docs/en/deploy/astrbot/package.md b/docs/en/deploy/astrbot/package.md index ceda780a2f..c921f209d9 100644 --- a/docs/en/deploy/astrbot/package.md +++ b/docs/en/deploy/astrbot/package.md @@ -20,71 +20,5 @@ AstrBot requires Python 3.12 or later. Use `--python 3.12` to ensure that `uv` c ```bash uv tool install astrbot --python 3.12 -astrbot init # Only required for the first deployment -astrbot run -``` - -## Install as a System Service - -After initialization, install AstrBot as a user-level service so it starts with the user session: - -```bash -astrbot service install --now -``` - -The command uses the `astrbot` executable found on `PATH` (usually generated by `uv tool install`) and uses the current directory as the AstrBot working directory. Each platform uses its native user-level service mechanism: - -- Linux: `systemd --user` -- macOS: `LaunchAgent` -- Windows: Task Scheduler - -To specify the AstrBot working directory or executable path explicitly: - -```bash -astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now -``` - -To inspect the service state and WebUI health: - -```bash -astrbot service status -``` - -The status output includes the service manager state, AstrBot working directory, Dashboard port, WebUI URL, WebUI accessibility, and the overall health state. - -You can also manage the service lifecycle with: - -```bash -astrbot service start -astrbot service stop -astrbot service restart -astrbot service uninstall -``` - -To view service logs: - -```bash -astrbot service logs -astrbot service logs -f -``` - -On macOS and Windows, this shows stdout logs by default. To include stderr: - -```bash -astrbot service logs --include-stderr -``` - -To read the AstrBot application log file at `data/logs/astrbot.log`, enable application file logging first and restart the service: - -```bash -astrbot service logs enable -astrbot service restart -astrbot service logs --source app -``` - -To inspect or disable application file logging: - -```bash -astrbot service logs status -astrbot service logs disable +astrbot ``` diff --git a/docs/zh/deploy/astrbot/package.md b/docs/zh/deploy/astrbot/package.md index 6ad7c4df74..96a50f55a3 100644 --- a/docs/zh/deploy/astrbot/package.md +++ b/docs/zh/deploy/astrbot/package.md @@ -22,68 +22,3 @@ uv tool install astrbot --python 3.12 astrbot init # 只需要在第一次部署时执行,后续启动不需要执行 astrbot run ``` - -## 安装为系统服务 - -初始化完成后,可以安装用户级服务,让 AstrBot 随用户会话自动启动: - -```bash -astrbot service install --now -``` - -该命令会自动使用当前 `PATH` 中的 `astrbot` 可执行文件(通常由 `uv tool install` 生成),并将当前目录作为 AstrBot 工作目录。不同系统会使用对应的用户级服务机制: - -- Linux:`systemd --user` -- macOS:`LaunchAgent` -- Windows:任务计划程序 - -如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用: - -```bash -astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now -``` - -查看服务状态和 WebUI 健康状态: - -```bash -astrbot service status -``` - -状态输出会包含服务管理器状态、AstrBot 工作目录、Dashboard 端口、WebUI URL、WebUI 是否可访问,以及整体健康状态。 - -也可以使用以下命令管理服务生命周期: - -```bash -astrbot service start -astrbot service stop -astrbot service restart -astrbot service uninstall -``` - -查看服务日志: - -```bash -astrbot service logs -astrbot service logs -f -``` - -macOS 和 Windows 下默认只显示标准输出日志;如需同时查看 stderr: - -```bash -astrbot service logs --include-stderr -``` - -如果需要查看 AstrBot 应用日志文件 `data/logs/astrbot.log`,先启用应用日志文件并重启服务: - -```bash -astrbot service logs enable -astrbot service restart -astrbot service logs --source app -``` - -查看或关闭应用日志文件: - -```bash -astrbot service logs status -astrbot service logs disable -``` From 399885edf0e51115237996ed1bcddd83980595a6 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 13:36:35 +0800 Subject: [PATCH 22/29] docs: update agent api generation guidance --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bc1e7079f4..e3e2a64842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ ruff check . 4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`. 5. Use English for all new comments. 6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory. +7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`. ### No Unnecessary Helpers @@ -96,4 +97,4 @@ def calculate_metrics(user_id: int, force_refresh: bool = False) -> dict: 1. Replace current version name to specific version name. 2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit. 3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0` -4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0` \ No newline at end of file +4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0` From e431bdf6896127367059ced6f97905bafca1642c Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:09:18 +0800 Subject: [PATCH 23/29] feat: add plugin page web api helpers --- astrbot/api/web.py | 346 +++++++++++++ astrbot/dashboard/api/plugins.py | 28 +- dashboard/src/views/PluginPagePage.vue | 15 +- docs/en/dev/star/guides/plugin-pages.md | 630 +++++++++++++++-------- docs/zh/dev/star/guides/plugin-pages.md | 632 ++++++++++++++++-------- tests/test_fastapi_v1_dashboard.py | 100 ++++ 6 files changed, 1314 insertions(+), 437 deletions(-) create mode 100644 astrbot/api/web.py diff --git a/astrbot/api/web.py b/astrbot/api/web.py new file mode 100644 index 0000000000..01e61f91ed --- /dev/null +++ b/astrbot/api/web.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import contextvars +from collections.abc import Callable +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from fastapi.encoders import jsonable_encoder +from fastapi.responses import FileResponse, JSONResponse +from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.responses import StreamingResponse + + +class PluginMultiDict: + """Dictionary-like request values that preserves duplicate keys.""" + + def __init__(self, pairs: list[tuple[str, Any]]) -> None: + self._pairs = pairs + + def get(self, key: str, default: Any = None, type: Callable | None = None): + """Return the last value for a key. + + Args: + key: Value key to read. + default: Value returned when the key is missing or conversion fails. + type: Optional callable used to convert the value. + + Returns: + The matching value, converted value, or default. + """ + for item_key, item_value in reversed(self._pairs): + if item_key != key: + continue + if type is None: + return item_value + try: + return type(item_value) + except (TypeError, ValueError): + return default + return default + + def getlist(self, key: str) -> list[Any]: + """Return all values for a key. + + Args: + key: Value key to read. + + Returns: + Values in request order. + """ + return [item_value for item_key, item_value in self._pairs if item_key == key] + + def keys(self): + return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() + + def values(self): + return [self[key] for key in self.keys()] + + def items(self): + return [(key, self[key]) for key in self.keys()] + + def __contains__(self, key: str) -> bool: + return any(item_key == key for item_key, _ in self._pairs) + + def __getitem__(self, key: str): + value = self.get(key) + if value is None and key not in self: + raise KeyError(key) + return value + + def __bool__(self) -> bool: + return bool(self._pairs) + + +class PluginUploadFile: + """Uploaded file wrapper exposed to plugin Web API handlers.""" + + def __init__(self, upload_file: StarletteUploadFile) -> None: + self._upload_file = upload_file + self.filename = upload_file.filename + self.content_type = upload_file.content_type + self.headers = upload_file.headers + self.content_length = self._resolve_content_length() + + def _resolve_content_length(self) -> int | None: + try: + raw = self.headers.get("content-length") + return int(raw) if raw else None + except (TypeError, ValueError): + return None + + async def save(self, destination: str | Path) -> None: + """Save the uploaded file to disk. + + Args: + destination: Destination file path. + """ + path = Path(destination) + try: + await self._upload_file.seek(0) + except Exception: + pass + with path.open("wb") as output: + while True: + chunk = await self._upload_file.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + + def __getattr__(self, key: str): + return getattr(self._upload_file, key) + + +class PluginRequest: + """Request object exposed to plugin Web API handlers.""" + + def __init__( + self, + request_: Any, + *, + path_params: dict[str, Any] | None = None, + plugin_name: str | None = None, + username: str | None = None, + ) -> None: + self._request = request_ + self.method = request_.method + self.path = request_.url.path + self.headers = request_.headers + self.cookies = request_.cookies + self.content_type = request_.headers.get("content-type") + self.client_host = request_.client.host if request_.client else None + self.path_params = path_params or {} + self.plugin_name = plugin_name + self.username = username + self.query = PluginMultiDict(list(request_.query_params.multi_items())) + self._form_cache: PluginMultiDict | None = None + self._files_cache: PluginMultiDict | None = None + + async def body(self) -> bytes: + """Read the raw request body. + + Returns: + Raw request body bytes. + """ + return await self._request.body() + + async def json(self, default: Any = None): + """Read the JSON request body. + + Args: + default: Value returned when the request body cannot be parsed as JSON. + + Returns: + Parsed JSON data or default. + """ + try: + return await self._request.json() + except Exception: + return default + + async def _load_form_parts(self) -> None: + if self._form_cache is not None and self._files_cache is not None: + return + form = await self._request.form() + form_pairs: list[tuple[str, Any]] = [] + file_pairs: list[tuple[str, Any]] = [] + for key, value in form.multi_items(): + if isinstance(value, StarletteUploadFile): + file_pairs.append((key, PluginUploadFile(value))) + else: + form_pairs.append((key, value)) + self._form_cache = PluginMultiDict(form_pairs) + self._files_cache = PluginMultiDict(file_pairs) + + async def form(self) -> PluginMultiDict: + """Read form fields from a multipart or form-urlencoded request. + + Returns: + Form values without uploaded files. + """ + await self._load_form_parts() + assert self._form_cache is not None + return self._form_cache + + async def files(self) -> PluginMultiDict: + """Read uploaded files from a multipart request. + + Returns: + Uploaded file values. + """ + await self._load_form_parts() + assert self._files_cache is not None + return self._files_cache + + +_request_var: contextvars.ContextVar[PluginRequest] = contextvars.ContextVar( + "astrbot_plugin_web_request" +) + + +class _RequestProxy: + def _get_current(self) -> PluginRequest: + try: + return _request_var.get() + except LookupError as exc: + raise RuntimeError( + "astrbot.api.web.request is only available inside a plugin Web API " + "handler." + ) from exc + + def __getattr__(self, key: str): + return getattr(self._get_current(), key) + + +request = _RequestProxy() + + +@contextmanager +def bind_request_context(request_: PluginRequest): + """Bind a plugin Web request for the current async context. + + Args: + request_: Request object exposed through the module-level request proxy. + + Yields: + The bound request object. + """ + token = _request_var.set(request_) + try: + yield request_ + finally: + _request_var.reset(token) + + +def json_response( + data: Any = None, + *, + status_code: int = 200, + headers: dict[str, str] | None = None, +) -> JSONResponse: + """Build a JSON response for plugin Web API handlers. + + Args: + data: JSON-serializable response body. + status_code: HTTP status code. + headers: Optional response headers. + + Returns: + A Starlette/FastAPI JSON response. + """ + return JSONResponse( + jsonable_encoder({} if data is None else data), + status_code=status_code, + headers=headers, + ) + + +def error_response( + message: str, + *, + status_code: int = 400, + data: Any = None, + headers: dict[str, str] | None = None, +) -> JSONResponse: + """Build a standard error response for plugin bridge calls. + + Args: + message: Public error message. + status_code: HTTP status code. + data: Optional error details that are safe to expose. + headers: Optional response headers. + + Returns: + A JSON response with the AstrBot error envelope. + """ + return json_response( + {"status": "error", "message": message, "data": data}, + status_code=status_code, + headers=headers, + ) + + +def file_response( + path: str | Path, + *, + filename: str | None = None, + content_type: str | None = None, + headers: dict[str, str] | None = None, +) -> FileResponse: + """Build a file download response for plugin Web API handlers. + + Args: + path: File path to send. + filename: Optional download filename. + content_type: Optional response media type. + headers: Optional response headers. + + Returns: + A Starlette/FastAPI file response. + """ + return FileResponse( + path, + filename=filename, + media_type=content_type, + headers=headers, + ) + + +def stream_response( + content: Any, + *, + content_type: str = "text/event-stream", + status_code: int = 200, + headers: dict[str, str] | None = None, +) -> StreamingResponse: + """Build a streaming response for plugin Web API handlers. + + Args: + content: Sync or async iterable that yields response chunks. + content_type: Response media type. + status_code: HTTP status code. + headers: Optional response headers. + + Returns: + A Starlette/FastAPI streaming response. + """ + return StreamingResponse( + content, + media_type=content_type, + status_code=status_code, + headers=headers, + ) + + +__all__ = [ + "PluginMultiDict", + "PluginRequest", + "PluginUploadFile", + "bind_request_context", + "error_response", + "file_response", + "json_response", + "request", + "stream_response", +] diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index 551309e883..7c4dadaeb7 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, Depends, Query, Request from fastapi.responses import PlainTextResponse, Response +from astrbot.api.web import PluginRequest, bind_request_context from astrbot.core import logger from astrbot.dashboard.asgi_runtime import ( DashboardRequestState, @@ -200,20 +201,29 @@ async def _call_plugin_extension( return {"status": "error", "message": "未找到该路由", "data": {}} view_handler, path_values = matched_api + plugin_name = plugin_path.strip("/").split("/", 1)[0].strip() or None + plugin_request = PluginRequest( + request, + path_params=path_values, + plugin_name=plugin_name, + username=username, + ) app_adapter = getattr(request.app.state, "dashboard_app_adapter", None) if app_adapter is None: - return await run_maybe_async(lambda: view_handler(**path_values)) + with bind_request_context(plugin_request): + return await run_maybe_async(lambda: view_handler(**path_values)) g_obj = DashboardRequestState() g_obj.username = username - return await call_request_view( - request, - app_adapter, - view_handler, - path_values, - g_obj=g_obj, - quart_compat_path=_plugin_extension_legacy_path(plugin_path, request), - ) + with bind_request_context(plugin_request): + return await call_request_view( + request, + app_adapter, + view_handler, + path_values, + g_obj=g_obj, + quart_compat_path=_plugin_extension_legacy_path(plugin_path, request), + ) def _get_request_locale(request: Request, default: str = "zh-CN") -> str: diff --git a/dashboard/src/views/PluginPagePage.vue b/dashboard/src/views/PluginPagePage.vue index 3ae65c1b76..566a70540b 100644 --- a/dashboard/src/views/PluginPagePage.vue +++ b/dashboard/src/views/PluginPagePage.vue @@ -176,6 +176,19 @@ const sendBridgeResponse = (requestId, ok, payload) => { }); }; +const getBridgeErrorMessage = (error, fallback) => { + const responseData = error?.response?.data; + if (responseData && typeof responseData === "object") { + if (typeof responseData.message === "string" && responseData.message) { + return responseData.message; + } + if (typeof responseData.error === "string" && responseData.error) { + return responseData.error; + } + } + return error?.message || fallback; +}; + const closeSSEConnection = (subscriptionId) => { const eventSource = sseConnections.get(subscriptionId); if (eventSource) { @@ -335,7 +348,7 @@ const handleBridgeRequest = async (message) => { sendBridgeResponse( requestId, false, - error?.message || "Plugin bridge request failed.", + getBridgeErrorMessage(error, "Plugin bridge request failed."), ); } }; diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md index badbd0bc98..84341aeca0 100644 --- a/docs/en/dev/star/guides/plugin-pages.md +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -1,6 +1,12 @@ # Plugin Pages -AstrBot lets a plugin expose Dashboard pages by placing static assets under `pages/`. Each direct child directory is one Page: +Plugin Pages let a plugin provide its own pages inside the AstrBot WebUI. Page files live under the plugin's `pages/` directory and are loaded by the Dashboard in a restricted iframe. Page scripts communicate with the Dashboard through the `window.AstrBotPluginPage` bridge, and the Dashboard forwards backend calls to Web APIs registered by the plugin. + +If you only need a small set of editable settings, prefer [`_conf_schema.json`](./plugin-config.md). Pages are a better fit for complex forms, runtime dashboards, logs, file upload/download, SSE streams, charts, and other custom workflows. + +## Directory Layout + +Each direct child directory under `pages/` is one Page. AstrBot only discovers `pages//index.html`; directories without `index.html` are ignored. ```text astrbot_plugin_page_demo/ @@ -16,13 +22,65 @@ astrbot_plugin_page_demo/ └─ index.html ``` -AstrBot scans `pages//index.html`; directories without `index.html` are ignored. +Use simple directory names for `page_name`, such as `settings` or `bridge-demo`. Do not use an empty name, `.`, `..`, a name starting with `.`, or a name containing `/` or `\`. + +Users open Pages from the plugin detail page in the WebUI. + +## Development Flow + +1. Create `pages//index.html` in the plugin directory. +2. Use the `window.AstrBotPluginPage` bridge from the Page. +3. Register backend APIs with `context.register_web_api()` in `main.py`. +4. Read requests and return responses with `astrbot.api.web`. +5. Reload the plugin after adding or removing Page directories; refreshing the Page is usually enough for static asset edits. + +## Minimal Complete Example + +### Backend + +Plugin backend code should use `astrbot.api.web`. Avoid exposing raw FastAPI, Starlette, or Quart request objects as the public API for your plugin business logic. + +```python +from astrbot.api.star import Context, Star +from astrbot.api.web import error_response, json_response, request + +PLUGIN_NAME = "astrbot_plugin_page_demo" + + +class MyPlugin(Star): + def __init__(self, context: Context): + super().__init__(context) + context.register_web_api( + f"/{PLUGIN_NAME}/ping", + self.page_ping, + ["GET"], + "Page ping", + ) + context.register_web_api( + f"/{PLUGIN_NAME}/settings/save", + self.save_settings, + ["POST"], + "Save Page settings", + ) -If you only need a few editable settings, prefer [`_conf_schema.json`](./plugin-config.md). Plugin Pages are more suitable for complex forms, dashboards, logs, file transfer, SSE, and custom interaction flows. + async def page_ping(self): + limit = request.query.get("limit", 20, type=int) + return json_response( + { + "message": "pong", + "limit": limit, + "username": request.username, + } + ) -Once Pages are registered, users can open the AstrBot WebUI Plugins page, click the plugin card to enter the plugin detail page, and then view and open the registered Pages from that detail page. + async def save_settings(self): + payload = await request.json(default={}) + if not isinstance(payload.get("enabled"), bool): + return error_response("enabled must be a boolean") + return json_response({"saved": True}) +``` -## Minimal Frontend Example +### Frontend `pages/bridge-demo/index.html` @@ -52,230 +110,215 @@ const context = await bridge.ready(); output.textContent = JSON.stringify(context, null, 2); document.getElementById("ping").addEventListener("click", async () => { - const result = await bridge.apiGet("ping"); + const result = await bridge.apiGet("ping", { limit: 20 }); output.textContent = JSON.stringify(result, null, 2); }); ``` -You do not need to import the bridge SDK manually. AstrBot injects `/api/plugin/page/bridge-sdk.js` into returned HTML. - -## Register Backend APIs - -When the frontend calls `bridge.apiGet("ping")`, the Dashboard forwards it to: +You do not need to import the bridge SDK manually. AstrBot injects `/api/plugin/page/bridge-sdk.js` into returned HTML. If an inline script must access `window.AstrBotPluginPage` synchronously, move it to an external module file or explicitly include the SDK before your script: -```text -/api/plug//ping +```html + ``` -The registered Web API route must include the plugin name as a prefix: +## Backend Web APIs -```python -from quart import jsonify -from astrbot.api.star import Context, Star +### Route Registration -PLUGIN_NAME = "astrbot_plugin_page_demo" +Register plugin APIs with `context.register_web_api(route, view_handler, methods, desc)`. - -class MyPlugin(Star): - def __init__(self, context: Context): - super().__init__(context) - context.register_web_api( - f"/{PLUGIN_NAME}/ping", - self.page_ping, - ["GET"], - "Page ping", - ) - - async def page_ping(self): - return jsonify({"message": "pong"}) +```python +context.register_web_api( + f"/{PLUGIN_NAME}/items/", + self.get_item, + ["GET"], + "Get item", +) ``` -Although the Dashboard runtime uses FastAPI/ASGI, plugin Web APIs registered through `context.register_web_api()` still run with a Quart-compatible request context for backward compatibility. Existing plugins can continue to import and use `quart.request`, `quart.g`, and `quart.jsonify`; AstrBot keeps `quart` as a direct dependency for this compatibility layer. - -## Bridge API - -Inside a plugin Page, use `window.AstrBotPluginPage` directly: +The registered route must include the plugin name prefix. The bridge endpoint used by the Page does not include the plugin name: -- `ready()`: Wait until the bridge is ready and return the context -- `getContext()`: Read the current context -- `getLocale()`: Read the current WebUI locale -- `getI18n()`: Read the current plugin i18n resources -- `t(key, fallback)`: Read text from plugin i18n resources by key, returning fallback when missing -- `onContext(handler)`: Listen for context changes, such as rerendering after the WebUI locale changes -- `apiGet(endpoint, params)`: Send a GET request -- `apiPost(endpoint, body)`: Send a POST request -- `upload(endpoint, file)`: Upload one file as `multipart/form-data` -- `download(endpoint, params, filename)`: Download a backend response -- `subscribeSSE(endpoint, handlers, params)`: Subscribe to SSE -- `unsubscribeSSE(subscriptionId)`: Cancel an SSE subscription - -The current `ready()` context looks like this: - -```json -{ - "pluginName": "astrbot_plugin_page_demo", - "displayName": "Plugin Page Demo", - "pageName": "bridge-demo", - "pageTitle": "Bridge Demo", - "locale": "en-US", - "i18n": {}, - "isDark": false -} +```js +await bridge.apiGet("items/123"); ``` -`endpoint` must be a plugin-local path. It must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain `.` / `..` path segments. +The Dashboard forwards it to: -## Page Internationalization +```text +/api/v1/plugins/extensions//items/123 +``` -Plugin Pages reuse plugin i18n resource files. Add `pages.` to `.astrbot-plugin/i18n/.json`: +The registered route `//items/` matches the request, and `item_id` is passed to the handler as a keyword argument: -```json -{ - "pages": { - "bridge-demo": { - "title": "Bridge Demo", - "description": "Shows how a plugin page reads the WebUI locale and translations.", - "heading": "Plugin Page", - "refresh": "Render again" - } - } -} +```python +async def get_item(self, item_id: str): + return json_response({"item_id": item_id}) ``` -`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page. +Supported dynamic segments: -Inside the Page, render text with `t()` and react to language changes with `onContext()`: +- ``: matches one path segment. +- ``: matches the remaining multi-segment path. -```js -const bridge = window.AstrBotPluginPage; +### Request Object -function render() { - document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo"); - document.getElementById("heading").textContent = bridge.t( - "pages.bridge-demo.heading", - "Plugin Page", - ); - document.getElementById("locale").textContent = bridge.getLocale(); -} +Recommended import: -await bridge.ready(); -render(); -bridge.onContext(render); +```python +from astrbot.api.web import request ``` -After the WebUI locale changes, the Dashboard sends the new `locale` and plugin i18n resources to the iframe through the bridge. If the Page listens with `onContext()`, it usually does not need a refresh. - -If an inline script needs to access `window.AstrBotPluginPage` synchronously, move the code to an external module file or explicitly include the bridge SDK before your script: +`request` is a context proxy for the current request and is only available while a plugin Web API handler is running. Common fields and methods: + +| API | Description | +| --- | --- | +| `request.method` | HTTP method, such as `GET` or `POST` | +| `request.path` | Current Dashboard API path | +| `request.plugin_name` | Plugin name parsed from the extension path | +| `request.username` | Current Dashboard username, possibly `None` | +| `request.headers` | Request headers | +| `request.cookies` | Request cookies | +| `request.content_type` | Request Content-Type | +| `request.client_host` | Client address | +| `request.path_params` | Dynamic route parameters | +| `request.query` | Query parameters with `get()` and `getlist()` | +| `await request.body()` | Raw request body bytes | +| `await request.json(default={})` | JSON body, returning default on parse failure | +| `await request.form()` | Form fields without uploaded files | +| `await request.files()` | Uploaded files | + +Query example: -```html - +```python +limit = request.query.get("limit", 20, type=int) +tags = request.query.getlist("tag") ``` -## Light/Dark Theme Adaptation +JSON example: -When the user toggles light/dark mode in AstrBot, the theme state is automatically synced to Plugin Pages. The bridge SDK maintains a `data-theme` attribute on the `` element: +```python +payload = await request.json(default={}) +enabled = bool(payload.get("enabled")) +``` -- Light mode: `` -- Dark mode: `` +Upload example: -> When **Follow System** is selected, AstrBot automatically resolves the theme based on the system's color scheme. The `data-theme` value received by the plugin page is always either `light` or `dark` — no extra handling needed. +```python +from pathlib import Path + +from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path +from astrbot.api.web import error_response, json_response, request + + +async def import_file(self): + form = await request.form() + files = await request.files() + upload = files.get("file") + if upload is None: + return error_response("missing file") + + target_dir = ( + Path(get_astrbot_plugin_data_path()) + / (request.plugin_name or "unknown_plugin") + / "imports" + ) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / Path(upload.filename).name + await upload.save(target) + return json_response( + { + "filename": upload.filename, + "content_type": upload.content_type, + "tag": form.get("tag"), + } + ) +``` -### CSS Adaptation +`request.form()` and `request.files()` cache the parsed multipart data, so calling both in the same handler is fine. -CSS variables are the recommended approach: +### Responses -```css -:root { - --bg: #ffffff; - --text: #1a1a1a; -} - -[data-theme="dark"] { - --bg: #1a1a1a; - --text: #e0e0e0; -} +Recommended response helpers: -body { - background: var(--bg); - color: var(--text); -} +```python +from astrbot.api.web import ( + error_response, + file_response, + json_response, + stream_response, +) ``` -AstrBot injects the `data-theme` attribute on the `` tag server-side when serving HTML, so there is no initial flash. +JSON response: -### Responding to Theme Changes in JavaScript +```python +return json_response({"saved": True}) +``` -The `isDark` field in the context indicates whether dark mode is active. Use `onContext()` to listen for theme changes: +Error response: -```js -const bridge = window.AstrBotPluginPage; +```python +return error_response("invalid threshold", status_code=400) +``` -function render() { - if (bridge.getContext()?.isDark) { - // JS logic for dark mode (e.g. chart filters) - } -} +File download response: -await bridge.ready(); -render(); -bridge.onContext(render); +```python +return file_response( + export_path, + filename="export.json", + content_type="application/json", +) ``` -When the theme is toggled, the Dashboard sends the updated context to the iframe via the bridge. As long as the Page listens with `onContext()`, it will be notified. +SSE response: -## Asset Path Rules +```python +import json -AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Write normal relative paths and do not hardcode `/api/plugin/page/content/...` yourself. +from astrbot.api.web import stream_response -AstrBot rewrites: -- HTML `src` and `href` -- CSS `url(...)` -- JavaScript `import` -- JavaScript `export ... from` -- JavaScript dynamic `import()` +async def stream_events(self): + async def events(): + yield f"data: {json.dumps({'state': 'started'})}\n\n" + yield f"data: {json.dumps({'state': 'done'})}\n\n" -Keep static assets on relative paths such as `./style.css` and `./assets/logo.svg`. Do not manually append `asset_token`, and do not rely on `..` to escape the Page root directory. + return stream_response(events()) +``` -If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires an actual file to exist at that path. +Returning a `dict`, `list`, `(body, status_code)`, or a lower-level Response object still works. New plugins should prefer `astrbot.api.web` helpers so plugin code remains decoupled from the Dashboard's internal web framework. -## Security Constraints +### Quart Compatibility -Plugin Pages run inside a restricted iframe: +For backward compatibility, handlers registered through `context.register_web_api()` still run inside a Quart-compatible request context. Existing plugins can continue to use: -```text -allow-scripts allow-forms allow-downloads +```python +from quart import jsonify, request ``` -The page cannot directly access Dashboard cookies, LocalStorage, or same-origin DOM, and it cannot bypass the bridge to reuse Dashboard auth directly. - -AstrBot also adds security headers to asset responses, including: - -- `X-Frame-Options: SAMEORIGIN` -- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'` -- `Cache-Control: no-store` +New plugins and new documentation should use: -## Debugging Tips +```python +from astrbot.api.web import json_response, request +``` -- Reload the plugin after adding or removing a Page directory -- For most edits under `pages//`, refreshing the Page is enough -- If a Page does not appear, check that `pages//index.html` exists and the plugin is enabled +Do not mix the two `request` proxies in the same handler. Migrate one handler at a time. -## Appendix: Bridge API Details +## Bridge API -Start page scripts by keeping a bridge reference: +The Page iframe cannot directly access Dashboard cookies, LocalStorage, or the parent DOM. Page scripts must use `window.AstrBotPluginPage` to call backend APIs and read context. ```js const bridge = window.AstrBotPluginPage; ``` -### `ready()` +### Context -Waits for the parent page to send the initial context and returns `Promise`. Page initialization should wait for this before reading context-dependent values. +`ready()` waits for the parent page to send the initial context and returns `Promise`. Wait for it during page initialization. ```js const context = await bridge.ready(); -console.log(context.pluginName, context.pageName, context.locale); ``` The context usually contains: @@ -292,93 +335,111 @@ The context usually contains: } ``` -### `getContext()` +Context APIs: + +| API | Returns | Description | +| --- | --- | --- | +| `ready()` | `Promise` | Waits until the bridge is ready and returns the initial context | +| `getContext()` | `context \| null` | Synchronously reads the latest context | +| `getLocale()` | `string` | Current WebUI locale, defaulting to `zh-CN` | +| `getI18n()` | `object` | Current plugin i18n resources | +| `t(key, fallback)` | `string` | Reads a dot-separated translation key, returning fallback when missing | +| `onContext(handler)` | `() => void` | Listens for context changes and returns an unsubscribe function | -Synchronously reads the latest context. Use it after `ready()` or inside an `onContext()` callback. +Respond to locale or theme changes: ```js -function renderHeader() { - const context = bridge.getContext(); - document.getElementById("title").textContent = context.pageTitle; +function render() { + document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo"); + document.getElementById("locale").textContent = bridge.getLocale(); } -``` -### `getLocale()` - -Synchronously reads the current WebUI locale. It returns `zh-CN` when no context exists yet. +await bridge.ready(); +render(); -```js -document.documentElement.lang = bridge.getLocale(); +const off = bridge.onContext(render); +window.addEventListener("beforeunload", off); ``` -### `getI18n()` +### Request and Return Rules + +The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` is a plugin-local relative path, such as `stats`, `settings/save`, or `files/export`. Prefer not to start it with `/`; the current bridge strips leading `/` for compatibility. + +`endpoint` must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain empty, `.`, or `..` path segments. -Synchronously reads the full plugin i18n resource object. Prefer `t()` for normal rendering; use this for custom traversal or debugging. +Do not append query strings to endpoint: ```js -console.log(Object.keys(bridge.getI18n())); +await bridge.apiGet("stats", { limit: 20 }); ``` -### `t(key, fallback)` +Bridge JSON calls use this compatibility rule: -Reads text from plugin i18n by dot-separated key. If the current locale is missing, the bridge tries fallbacks; if still missing, it returns `fallback`. +- If the backend returns `{ "status": "ok", "data": value }`, the Promise resolves to `value`. +- If the backend returns plain JSON, such as `{ "message": "pong" }`, the Promise resolves to that full JSON body. +- If the backend returns `{ "status": "error", "message": "..." }`, or the HTTP request fails, the Promise rejects with `Error`. -```js -saveButton.textContent = bridge.t("pages.settings.save", "Save"); +For Page-only APIs, prefer returning plain business JSON: + +```python +return json_response({"message": "pong"}) ``` -### `onContext(handler)` +Use this for errors: -Listens for context changes and returns an unsubscribe function. The callback runs when the WebUI locale changes, so pages that need live language switching should rerender here. +```python +return error_response("missing file", status_code=400) +``` + +Handle errors on the Page: ```js -function render() { - document.title = bridge.t("pages.settings.title", "Settings"); +try { + await bridge.apiPost("settings/save", { enabled: true }); +} catch (error) { + console.error(error.message); } - -await bridge.ready(); -render(); - -const off = bridge.onContext(render); - -window.addEventListener("beforeunload", () => { - off(); -}); ``` ### `apiGet(endpoint, params)` -Sends a GET request to the plugin backend and returns `Promise`. `endpoint` is a plugin-local relative path; the Dashboard forwards it to `/api/plug//`. +Sends a GET request. `params` are sent as query parameters. ```js -const stats = await bridge.apiGet("stats", { limit: 20 }); +const stats = await bridge.apiGet("stats", { limit: 20, tag: "today" }); ``` -Backend registration example: +Backend: ```python -context.register_web_api( - f"/{PLUGIN_NAME}/stats", - self.get_stats, - ["GET"], - "Get stats", -) +async def stats(self): + limit = request.query.get("limit", 20, type=int) + tag = request.query.get("tag") + return json_response({"limit": limit, "tag": tag}) ``` ### `apiPost(endpoint, body)` -Sends a POST request to the plugin backend. `body` is sent as JSON and the method returns `Promise`. +Sends a POST JSON request. ```js -await bridge.apiPost("settings/save", { +const result = await bridge.apiPost("settings/save", { enabled: true, threshold: 0.8, }); ``` +Backend: + +```python +async def save_settings(self): + payload = await request.json(default={}) + return json_response({"saved": True, "enabled": payload.get("enabled")}) +``` + ### `upload(endpoint, file)` -Uploads one file as `multipart/form-data` with the field name `file`, returning `Promise`. +Uploads one file as `multipart/form-data`. The field name is always `file`. ```js const input = document.querySelector("input[type=file]"); @@ -386,14 +447,45 @@ const file = input.files[0]; const result = await bridge.upload("files/import", file); ``` +Backend: + +```python +async def import_file(self): + files = await request.files() + upload = files.get("file") + if upload is None: + return error_response("missing file", status_code=400) + return json_response({"filename": upload.filename}) +``` + +If you need extra structured fields, send them through a separate `apiPost` call or use query parameters to select import behavior. The current `upload()` bridge method sends one file. + ### `download(endpoint, params, filename)` -Requests a plugin backend file endpoint and triggers a browser download. `params` are sent as query parameters. `filename` is optional; when omitted, the bridge tries to use the response filename header. +Requests a plugin backend file endpoint and triggers a browser download. `params` are sent as query parameters. `filename` is optional; when omitted, the bridge tries to read it from response headers. ```js await bridge.download("files/export", { format: "json" }, "export.json"); ``` +Backend: + +```python +async def export_file(self): + fmt = request.query.get("format", "json") + return file_response( + export_path, + filename=f"export.{fmt}", + content_type="application/json", + ) +``` + +`download()` resolves to: + +```json +{ "filename": "export.json" } +``` + ### `subscribeSSE(endpoint, handlers, params)` Subscribes to plugin backend SSE and returns `Promise`. `handlers` may include `onOpen`, `onMessage`, and `onError`. @@ -416,17 +508,25 @@ const subscriptionId = await bridge.subscribeSSE( ); ``` -`event.parsed` is automatically parsed when the message is a JSON string; otherwise it equals the raw string. +`event.raw` is the raw string. If the message is a JSON string, `event.parsed` is parsed automatically; otherwise it equals the raw string. -### `unsubscribeSSE(subscriptionId)` +The backend must return `text/event-stream`: -Cancels an SSE subscription. +```python +async def events(self): + async def stream(): + yield 'data: {"message": "ready"}\n\n' + + return stream_response(stream()) +``` + +Unsubscribe: ```js await bridge.unsubscribeSSE(subscriptionId); ``` -Clean up subscriptions when the page unloads: +Clean up on unload: ```js window.addEventListener("beforeunload", () => { @@ -434,11 +534,115 @@ window.addEventListener("beforeunload", () => { }); ``` -### endpoint Rules +## Page Internationalization -The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` must be a plugin-local relative path: +Plugin Pages reuse plugin i18n resource files. Add `pages.` to `.astrbot-plugin/i18n/.json`: -- Allowed: `"stats"`, `"settings/save"`, `"files/export"` -- Not allowed: empty string, `"/stats"`, `"../stats"`, `"https://example.com"`, `"stats?x=1"`, `"stats#top"` +```json +{ + "pages": { + "bridge-demo": { + "title": "Bridge Demo", + "description": "Shows how a plugin page reads the WebUI locale and translations.", + "heading": "Plugin Page", + "refresh": "Render again" + } + } +} +``` + +`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description. Inside the Page, render text with `bridge.t()` and react to locale changes with `onContext()`. + +```js +function render() { + document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo"); + document.getElementById("heading").textContent = bridge.t( + "pages.bridge-demo.heading", + "Plugin Page", + ); +} + +await bridge.ready(); +render(); +bridge.onContext(render); +``` + +## Light/Dark Theme + +AstrBot syncs the current theme to Plugin Pages. The bridge SDK maintains a `data-theme` attribute on ``: + +- Light mode: `` +- Dark mode: `` + +When **Follow System** is selected, the Page still receives either `light` or `dark`. + +CSS variables are recommended: + +```css +:root { + --bg: #ffffff; + --text: #1a1a1a; +} + +[data-theme="dark"] { + --bg: #1a1a1a; + --text: #e0e0e0; +} + +body { + background: var(--bg); + color: var(--text); +} +``` + +The server injects `data-theme` into returned HTML to reduce initial flashing. If JavaScript needs to react to theme changes, read `bridge.getContext()?.isDark` and listen with `onContext()`. + +## Static Asset Paths + +Use normal relative paths: + +```html + + + +``` + +AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Do not hardcode `/api/plugin/page/content/...`, append `asset_token` yourself, or rely on `..` to escape the Page root. + +AstrBot rewrites: + +- HTML `src` and `href` +- CSS `url(...)` +- JavaScript `import` +- JavaScript `export ... from` +- JavaScript dynamic `import()` + +If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires a real file at that path. + +## Security Constraints + +Plugin Pages run inside a restricted iframe: + +```text +allow-scripts allow-forms allow-downloads +``` + +The Page cannot directly access Dashboard cookies, LocalStorage, or the parent DOM, and it cannot bypass the bridge to reuse Dashboard auth. All operations that need Dashboard identity should go through the bridge. + +Asset responses include security headers such as: + +- `X-Frame-Options: SAMEORIGIN` +- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'` +- `Cache-Control: no-store` +- `X-Content-Type-Options: nosniff` + +Backend handlers must still validate input. Do not trust paths, filenames, formats, or numeric ranges sent by the Page. Store files only in safe directories and prefer whitelisted or regenerated filenames. + +## Debugging Tips -Pass query parameters through `params`; do not append them to `endpoint`. +- Page is missing: check that `pages//index.html` exists, the plugin is enabled, and the plugin detail page has been refreshed. +- Bridge is missing: make sure your script runs after the bridge SDK is injected; external `type="module"` scripts are recommended. +- API is not matched: make sure the registered route includes the plugin name prefix, such as `/{PLUGIN_NAME}/stats`, while the Page endpoint is `stats`. +- Query or JSON is empty: pass GET values through `apiGet(endpoint, params)` and POST JSON through `apiPost(endpoint, body)`. +- Upload is empty: `upload()` always uses the field name `file`; read it with `(await request.files()).get("file")`. +- SSE has no messages: make sure the backend response is `text/event-stream` and each message ends with a blank line, such as `data: ...\n\n`. diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md index 0fb300bf7f..c02044379e 100644 --- a/docs/zh/dev/star/guides/plugin-pages.md +++ b/docs/zh/dev/star/guides/plugin-pages.md @@ -1,6 +1,12 @@ # 插件 Pages -AstrBot 支持插件通过 `pages/` 目录暴露 Dashboard 页面。`pages/` 下的每个一级子目录都是一个独立 Page: +插件 Pages 允许插件在 AstrBot WebUI 中提供自己的页面。页面文件放在插件目录的 `pages/` 下,由 Dashboard 以受限 iframe 的方式加载;页面里的脚本通过 `window.AstrBotPluginPage` bridge 和 Dashboard 通信,再由 Dashboard 转发到插件注册的后端 Web API。 + +如果只是让用户填写少量配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。Pages 更适合复杂表单、运行状态面板、日志查看、文件上传下载、SSE 实时流、图表和其他需要自定义交互的场景。 + +## 目录结构 + +`pages/` 下的每个一级子目录是一个独立 Page。AstrBot 只扫描 `pages//index.html`,没有 `index.html` 的目录会被忽略。 ```text astrbot_plugin_page_demo/ @@ -16,13 +22,65 @@ astrbot_plugin_page_demo/ └─ index.html ``` -AstrBot 会扫描 `pages//index.html`;没有 `index.html` 的目录会被忽略。 +`page_name` 应使用简单目录名,例如 `settings`、`bridge-demo`。不要使用空目录名、`.`、`..`、以 `.` 开头的目录名,或包含 `/`、`\` 的名称。 + +用户可以在 WebUI 的插件页点击插件卡片进入插件详情页,然后打开插件声明的 Pages。 + +## 开发流程 + +1. 在插件目录下创建 `pages//index.html`。 +2. 在 Page 中通过 `window.AstrBotPluginPage` bridge 调用后端能力。 +3. 在 `main.py` 中使用 `context.register_web_api()` 注册插件后端 API。 +4. 后端 handler 使用 `astrbot.api.web` 读取请求并返回响应。 +5. 新增或删除 Page 目录后重载插件;修改静态资源通常刷新 Page 即可。 + +## 最小完整示例 + +### 后端 + +插件后端推荐使用 `astrbot.api.web`,不要把 FastAPI、Starlette 或 Quart 的原始请求对象作为插件公共 API 暴露给自己的业务代码。 + +```python +from astrbot.api.star import Context, Star +from astrbot.api.web import error_response, json_response, request + +PLUGIN_NAME = "astrbot_plugin_page_demo" + + +class MyPlugin(Star): + def __init__(self, context: Context): + super().__init__(context) + context.register_web_api( + f"/{PLUGIN_NAME}/ping", + self.page_ping, + ["GET"], + "Page ping", + ) + context.register_web_api( + f"/{PLUGIN_NAME}/settings/save", + self.save_settings, + ["POST"], + "Save Page settings", + ) -如果只是让用户填写几个配置项,优先使用 [`_conf_schema.json`](./plugin-config.md)。插件 Pages 更适合复杂表单、Dashboard、日志、文件上传下载、SSE 和自定义交互流程。 + async def page_ping(self): + limit = request.query.get("limit", 20, type=int) + return json_response( + { + "message": "pong", + "limit": limit, + "username": request.username, + } + ) -一旦注册了 Pages,用户可以在:AstrBot WebUI 插件页中的插件卡片中,点击插件卡片进入插件详细页面,在插件详细页面中可以看到并进入注册的 Pages。 + async def save_settings(self): + payload = await request.json(default={}) + if not isinstance(payload.get("enabled"), bool): + return error_response("enabled must be a boolean") + return json_response({"saved": True}) +``` -## 最小前端示例 +### 前端 `pages/bridge-demo/index.html` @@ -52,230 +110,215 @@ const context = await bridge.ready(); output.textContent = JSON.stringify(context, null, 2); document.getElementById("ping").addEventListener("click", async () => { - const result = await bridge.apiGet("ping"); + const result = await bridge.apiGet("ping", { limit: 20 }); output.textContent = JSON.stringify(result, null, 2); }); ``` -这里不需要手动引入 bridge SDK。AstrBot 会在返回的 HTML 里自动插入 `/api/plugin/page/bridge-sdk.js`。 - -## 注册后端 API - -前端调用 `bridge.apiGet("ping")` 时,Dashboard 会转发到: +不需要手动引入 bridge SDK。AstrBot 返回 HTML 时会自动插入 `/api/plugin/page/bridge-sdk.js`。如果内联脚本必须同步访问 `window.AstrBotPluginPage`,请把脚本改成外部 module 文件,或在自己的脚本前显式引入: -```text -/api/plug//ping +```html + ``` -因此注册 Web API 时,路由必须带上插件名作为前缀: +## 后端 Web API -```python -from quart import jsonify -from astrbot.api.star import Context, Star +### 路由注册 -PLUGIN_NAME = "astrbot_plugin_page_demo" +使用 `context.register_web_api(route, view_handler, methods, desc)` 注册插件 API。 - -class MyPlugin(Star): - def __init__(self, context: Context): - super().__init__(context) - context.register_web_api( - f"/{PLUGIN_NAME}/ping", - self.page_ping, - ["GET"], - "Page ping", - ) - - async def page_ping(self): - return jsonify({"message": "pong"}) +```python +context.register_web_api( + f"/{PLUGIN_NAME}/items/", + self.get_item, + ["GET"], + "Get item", +) ``` -虽然 Dashboard 运行时已经迁移到 FastAPI/ASGI,但通过 `context.register_web_api()` 注册的插件 Web API 仍会进入 Quart 兼容请求上下文,以保持旧插件兼容。已有插件可以继续导入并使用 `quart.request`、`quart.g` 和 `quart.jsonify`;AstrBot 会继续把 `quart` 作为直接依赖保留,用于这层兼容能力。 - -## Bridge API - -插件 Page 中可直接使用 `window.AstrBotPluginPage`: +路由需要包含插件名作为前缀。Page 端的 bridge endpoint 不需要包含插件名: -- `ready()`: 等待 bridge 就绪并返回上下文 -- `getContext()`: 读取当前上下文 -- `getLocale()`: 读取当前 WebUI 语言 -- `getI18n()`: 读取当前插件的 i18n 资源 -- `t(key, fallback)`: 从插件 i18n 资源中按 key 获取文案,缺失时返回 fallback -- `onContext(handler)`: 监听上下文变化,例如 WebUI 切换语言后重新渲染页面 -- `apiGet(endpoint, params)`: 发送 GET 请求 -- `apiPost(endpoint, body)`: 发送 POST 请求 -- `upload(endpoint, file)`: 以 `multipart/form-data` 上传单个文件 -- `download(endpoint, params, filename)`: 下载后端响应 -- `subscribeSSE(endpoint, handlers, params)`: 订阅 SSE -- `unsubscribeSSE(subscriptionId)`: 取消 SSE 订阅 - -当前 `ready()` 上下文类似: - -```json -{ - "pluginName": "astrbot_plugin_page_demo", - "displayName": "Plugin Page Demo", - "pageName": "bridge-demo", - "pageTitle": "Bridge Demo", - "locale": "zh-CN", - "i18n": {}, - "isDark": false -} +```js +await bridge.apiGet("items/123"); ``` -`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash,也不能包含 `.` 或 `..` 路径片段。 +Dashboard 会把它转发到: -## Page 国际化 +```text +/api/v1/plugins/extensions//items/123 +``` -插件 Page 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/.json` 增加 `pages.` 即可: +注册路由 `//items/` 会匹配该请求,`item_id` 作为 handler 的关键字参数传入: -```json -{ - "pages": { - "bridge-demo": { - "title": "Bridge 演示页", - "description": "演示插件页面如何读取 WebUI 语言和翻译资源。", - "heading": "插件页面", - "refresh": "重新渲染" - } - } -} +```python +async def get_item(self, item_id: str): + return json_response({"item_id": item_id}) ``` -`title` 会用于 WebUI 外壳标题和插件详情页的 Page 组件名称,`description` 会用于插件详情页的 Page 组件描述。 +支持的动态片段: -在 Page 内部使用 `t()` 渲染文案,并用 `onContext()` 响应语言切换: +- ``:匹配单个路径片段。 +- ``:匹配后续多级路径。 -```js -const bridge = window.AstrBotPluginPage; +### 请求对象 -function render() { - document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo"); - document.getElementById("heading").textContent = bridge.t( - "pages.bridge-demo.heading", - "Plugin Page", - ); - document.getElementById("locale").textContent = bridge.getLocale(); -} +推荐导入: -await bridge.ready(); -render(); -bridge.onContext(render); +```python +from astrbot.api.web import request ``` -切换 WebUI 语言后,Dashboard 会把新的 `locale` 和插件 i18n 资源通过 bridge 发送给 iframe;只要 Page 监听了 `onContext()`,通常不需要刷新页面。 +`request` 是当前请求的上下文代理,只能在插件 Web API handler 执行期间访问。常用字段和方法: + +| API | 说明 | +| --- | --- | +| `request.method` | HTTP 方法,例如 `GET`、`POST` | +| `request.path` | 当前 Dashboard API 路径 | +| `request.plugin_name` | 从扩展路径解析出的插件名 | +| `request.username` | 当前 Dashboard 用户名,可能为 `None` | +| `request.headers` | 请求头 | +| `request.cookies` | 请求 cookies | +| `request.content_type` | 请求 Content-Type | +| `request.client_host` | 客户端地址 | +| `request.path_params` | 路由动态参数字典 | +| `request.query` | query 参数,支持 `get()` 和 `getlist()` | +| `await request.body()` | 原始请求体 bytes | +| `await request.json(default={})` | JSON 请求体,解析失败返回 default | +| `await request.form()` | 表单字段,不含上传文件 | +| `await request.files()` | 上传文件 | + +query 参数使用示例: -如果你的内联脚本需要同步访问 `window.AstrBotPluginPage`,请把脚本放到外部 module 文件中,或在自己的脚本前显式引入: - -```html - +```python +limit = request.query.get("limit", 20, type=int) +tags = request.query.getlist("tag") ``` -## 亮暗主题适配 +JSON 请求体使用示例: -AstrBot 切换亮色/暗色模式时,会自动将主题状态同步给插件 Page。bridge SDK 会在 `` 元素上维护 `data-theme` 属性: - -- 亮色模式:`` -- 暗色模式:`` +```python +payload = await request.json(default={}) +enabled = bool(payload.get("enabled")) +``` -> 选择「跟随系统」时,AstrBot 会根据系统当前色彩模式自动决定主题,插件 Page 收到的 `data-theme` 值始终为 `light` 或 `dark`,无需额外处理。 +文件上传使用示例: -### CSS 适配 +```python +from pathlib import Path + +from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path +from astrbot.api.web import error_response, json_response, request + + +async def import_file(self): + form = await request.form() + files = await request.files() + upload = files.get("file") + if upload is None: + return error_response("missing file") + + target_dir = ( + Path(get_astrbot_plugin_data_path()) + / (request.plugin_name or "unknown_plugin") + / "imports" + ) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / Path(upload.filename).name + await upload.save(target) + return json_response( + { + "filename": upload.filename, + "content_type": upload.content_type, + "tag": form.get("tag"), + } + ) +``` -推荐使用 CSS 变量: +`request.form()` 和 `request.files()` 会缓存解析结果,可以在同一个 handler 中各调用一次。 -```css -:root { - --bg: #ffffff; - --text: #1a1a1a; -} +### 响应对象 -[data-theme="dark"] { - --bg: #1a1a1a; - --text: #e0e0e0; -} +推荐从 `astrbot.api.web` 导入响应 helper: -body { - background: var(--bg); - color: var(--text); -} +```python +from astrbot.api.web import ( + error_response, + file_response, + json_response, + stream_response, +) ``` -AstrBot 在服务端返回 HTML 时,会自动在 `` 标签上注入 `data-theme` 属性,不会出现初始闪烁。 +JSON 响应: -### JavaScript 响应主题变化 +```python +return json_response({"saved": True}) +``` -上下文中的 `isDark` 字段表示当前是否为暗色模式。通过 `onContext()` 可以监听主题切换: +错误响应: -```js -const bridge = window.AstrBotPluginPage; +```python +return error_response("invalid threshold", status_code=400) +``` -function render() { - if (bridge.getContext()?.isDark) { - // 暗色模式下的 JS 逻辑(如图表滤镜等) - } -} +文件下载响应: -await bridge.ready(); -render(); -bridge.onContext(render); +```python +return file_response( + export_path, + filename="export.json", + content_type="application/json", +) ``` -切换亮暗模式时,Dashboard 会通过 bridge 将更新后的上下文发送给 iframe;只要 Page 监听了 `onContext()`,就会收到通知。 +SSE 响应: -## 静态资源路径规则 +```python +import json -AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`。 +from astrbot.api.web import stream_response -AstrBot 会重写: -- HTML `src` 和 `href` -- CSS `url(...)` -- JavaScript `import` -- JavaScript `export ... from` -- JavaScript 动态 `import()` +async def stream_events(self): + async def events(): + yield f"data: {json.dumps({'state': 'started'})}\n\n" + yield f"data: {json.dumps({'state': 'done'})}\n\n" -建议把静态资源写成 `./style.css`、`./assets/logo.svg` 这类相对路径。不要手动追加 `asset_token`,也不要依赖 `..` 逃逸 Page 根目录。 + return stream_response(events()) +``` -如果你构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。 +直接返回 `dict`、`list`、`(body, status_code)` 或底层 Response 对象仍然可用;文档和新插件推荐优先使用 `astrbot.api.web` helper,让插件代码和 Dashboard 内部框架解耦。 -## 安全约束 +### Quart 兼容 -插件 Pages 运行在受限 iframe 中: +为了兼容旧插件,通过 `context.register_web_api()` 注册的 handler 仍会进入 Quart 兼容请求上下文。旧代码可以继续使用: -```text -allow-scripts allow-forms allow-downloads +```python +from quart import jsonify, request ``` -Page 不能直接访问 Dashboard cookies、LocalStorage 或同源 DOM,也不能绕过 bridge 复用 Dashboard auth。 - -AstrBot 还会给资源响应添加安全头,包括: - -- `X-Frame-Options: SAMEORIGIN` -- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'` -- `Cache-Control: no-store` +新插件和新文档推荐使用: -## 调试建议 +```python +from astrbot.api.web import json_response, request +``` -- 新增或删除 Page 目录后重载插件 -- 修改 `pages//` 下的大多数静态资源后,刷新 Page 即可 -- 如果 Page 没出现,检查 `pages//index.html` 是否存在,以及插件是否启用 +不要在同一个 handler 中混用两个 `request` 代理,迁移时按 handler 逐步替换即可。 -## 附录:Bridge API 详解 +## Bridge API -建议在页面脚本开始处保存 bridge 引用: +Page iframe 不能直接访问 Dashboard cookies、LocalStorage 或父页面 DOM。页面脚本必须通过 `window.AstrBotPluginPage` bridge 调用后端和读取上下文。 ```js const bridge = window.AstrBotPluginPage; ``` -### `ready()` +### 上下文 -等待父页面发送初始上下文,返回 `Promise`。页面初始化时应先等待它,避免过早读取空上下文。 +`ready()` 等待父页面发送初始上下文,返回 `Promise`。页面初始化时应先等待它。 ```js const context = await bridge.ready(); -console.log(context.pluginName, context.pageName, context.locale); ``` 上下文通常包含: @@ -292,93 +335,111 @@ console.log(context.pluginName, context.pageName, context.locale); } ``` -### `getContext()` +上下文相关 API: + +| API | 返回值 | 说明 | +| --- | --- | --- | +| `ready()` | `Promise` | 等待 bridge 就绪并返回初始上下文 | +| `getContext()` | `context \| null` | 同步读取最近一次上下文 | +| `getLocale()` | `string` | 当前 WebUI 语言,默认 `zh-CN` | +| `getI18n()` | `object` | 当前插件 i18n 资源 | +| `t(key, fallback)` | `string` | 按点分隔 key 读取翻译,缺失时返回 fallback | +| `onContext(handler)` | `() => void` | 监听上下文变化,返回取消监听函数 | -同步读取最近一次上下文。适合在 `ready()` 之后或 `onContext()` 回调中使用。 +监听语言或主题变化: ```js -function renderHeader() { - const context = bridge.getContext(); - document.getElementById("title").textContent = context.pageTitle; +function render() { + document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo"); + document.getElementById("locale").textContent = bridge.getLocale(); } -``` -### `getLocale()` - -同步读取当前 WebUI 语言。没有上下文时默认返回 `zh-CN`。 +await bridge.ready(); +render(); -```js -document.documentElement.lang = bridge.getLocale(); +const off = bridge.onContext(render); +window.addEventListener("beforeunload", off); ``` -### `getI18n()` +### 请求和返回值规则 + +`apiGet`、`apiPost`、`upload`、`download`、`subscribeSSE` 的 `endpoint` 都是插件内相对路径,例如 `stats`、`settings/save`、`files/export`。推荐不要以 `/` 开头;当前 bridge 会为了兼容旧写法去掉开头的 `/`。 + +`endpoint` 不能是空字符串,不能包含 `\`、URL scheme、query、hash,也不能包含空路径片段、`.` 或 `..`。 -同步读取当前插件的完整 i18n 资源对象。一般优先用 `t()`,只有需要自定义遍历或调试时才直接读取。 +不要把 query string 拼进 endpoint: ```js -console.log(Object.keys(bridge.getI18n())); +await bridge.apiGet("stats", { limit: 20 }); ``` -### `t(key, fallback)` +bridge 对 JSON 类请求的返回值有一个兼容规则: -按点分隔 key 从插件 i18n 中取文案。当前语言缺失时会尝试回退,仍缺失则返回 `fallback`。 +- 如果后端返回 `{ "status": "ok", "data": value }`,Promise resolve 为 `value`。 +- 如果后端返回普通 JSON,例如 `{ "message": "pong" }`,Promise resolve 为完整 JSON。 +- 如果后端返回 `{ "status": "error", "message": "..." }`,或 HTTP 请求失败,Promise reject 为 `Error`。 -```js -saveButton.textContent = bridge.t("pages.settings.save", "Save"); +因此 Page-only API 推荐直接返回业务 JSON: + +```python +return json_response({"message": "pong"}) ``` -### `onContext(handler)` +需要表达错误时使用: + +```python +return error_response("missing file", status_code=400) +``` -监听上下文变化,返回取消监听函数。WebUI 切换语言时会触发该回调,所以需要响应语言切换的页面应在这里重新渲染。 +Page 端统一捕获错误: ```js -function render() { - document.title = bridge.t("pages.settings.title", "Settings"); +try { + await bridge.apiPost("settings/save", { enabled: true }); +} catch (error) { + console.error(error.message); } - -await bridge.ready(); -render(); - -const off = bridge.onContext(render); - -window.addEventListener("beforeunload", () => { - off(); -}); ``` ### `apiGet(endpoint, params)` -向插件后端发送 GET 请求,返回 `Promise`。`endpoint` 是插件内相对路径,Dashboard 会转发到 `/api/plug//`。 +发送 GET 请求。`params` 会作为 query 参数传递。 ```js -const stats = await bridge.apiGet("stats", { limit: 20 }); +const stats = await bridge.apiGet("stats", { limit: 20, tag: "today" }); ``` -后端注册示例: +后端读取: ```python -context.register_web_api( - f"/{PLUGIN_NAME}/stats", - self.get_stats, - ["GET"], - "Get stats", -) +async def stats(self): + limit = request.query.get("limit", 20, type=int) + tag = request.query.get("tag") + return json_response({"limit": limit, "tag": tag}) ``` ### `apiPost(endpoint, body)` -向插件后端发送 POST 请求,`body` 会作为 JSON 请求体发送,返回 `Promise`。 +发送 POST JSON 请求。 ```js -await bridge.apiPost("settings/save", { +const result = await bridge.apiPost("settings/save", { enabled: true, threshold: 0.8, }); ``` +后端读取: + +```python +async def save_settings(self): + payload = await request.json(default={}) + return json_response({"saved": True, "enabled": payload.get("enabled")}) +``` + ### `upload(endpoint, file)` -以 `multipart/form-data` 上传单个文件,字段名为 `file`,返回 `Promise`。 +以 `multipart/form-data` 上传单个文件,字段名固定为 `file`。 ```js const input = document.querySelector("input[type=file]"); @@ -386,17 +447,48 @@ const file = input.files[0]; const result = await bridge.upload("files/import", file); ``` +后端读取: + +```python +async def import_file(self): + files = await request.files() + upload = files.get("file") + if upload is None: + return error_response("missing file", status_code=400) + return json_response({"filename": upload.filename}) +``` + +如果还需要普通字段,请单独使用 `apiPost` 传配置,或在后端根据 query 参数区分导入行为。当前 bridge 的 `upload()` 只发送一个文件。 + ### `download(endpoint, params, filename)` -请求插件后端文件接口并触发浏览器下载。`params` 会作为 query string,`filename` 可选;不传时会尝试使用响应头里的文件名。 +请求插件后端文件接口并触发浏览器下载。`params` 会作为 query 参数发送;`filename` 可选,缺省时 bridge 会尝试从响应头读取文件名。 ```js await bridge.download("files/export", { format: "json" }, "export.json"); ``` +后端返回文件: + +```python +async def export_file(self): + fmt = request.query.get("format", "json") + return file_response( + export_path, + filename=f"export.{fmt}", + content_type="application/json", + ) +``` + +`download()` resolve 为: + +```json +{ "filename": "export.json" } +``` + ### `subscribeSSE(endpoint, handlers, params)` -订阅插件后端 SSE,返回 `Promise`。`handlers` 可包含 `onOpen`、`onMessage`、`onError`。 +订阅插件后端 SSE,返回 `Promise`。`handlers` 可以包含 `onOpen`、`onMessage`、`onError`。 ```js const subscriptionId = await bridge.subscribeSSE( @@ -416,17 +508,25 @@ const subscriptionId = await bridge.subscribeSSE( ); ``` -`event.parsed` 会在消息内容是 JSON 字符串时自动解析,否则等于原始字符串。 +`event.raw` 是原始字符串;如果内容是 JSON 字符串,`event.parsed` 会自动解析,否则等于原始字符串。 -### `unsubscribeSSE(subscriptionId)` +后端必须返回 `text/event-stream`: -取消 SSE 订阅。 +```python +async def events(self): + async def stream(): + yield 'data: {"message": "ready"}\n\n' + + return stream_response(stream()) +``` + +取消订阅: ```js await bridge.unsubscribeSSE(subscriptionId); ``` -页面卸载时建议清理订阅: +页面卸载时建议清理: ```js window.addEventListener("beforeunload", () => { @@ -434,11 +534,115 @@ window.addEventListener("beforeunload", () => { }); ``` -### endpoint 规则 +## Page 国际化 + +插件 Pages 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/.json` 增加 `pages.`: + +```json +{ + "pages": { + "bridge-demo": { + "title": "Bridge 演示页", + "description": "演示插件页面如何读取 WebUI 语言和翻译资源。", + "heading": "插件页面", + "refresh": "重新渲染" + } + } +} +``` + +`title` 用于 WebUI 外壳标题和插件详情页的 Page 组件名称;`description` 用于插件详情页的 Page 组件描述。Page 内部使用 `bridge.t()` 渲染文案,并通过 `onContext()` 响应语言切换。 + +```js +function render() { + document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo"); + document.getElementById("heading").textContent = bridge.t( + "pages.bridge-demo.heading", + "Plugin Page", + ); +} + +await bridge.ready(); +render(); +bridge.onContext(render); +``` + +## 亮暗主题 + +AstrBot 会把当前主题同步给插件 Page。bridge SDK 会维护 `` 的 `data-theme` 属性: + +- 亮色模式:`` +- 暗色模式:`` + +选择“跟随系统”时,Page 收到的值仍然是 `light` 或 `dark`。 + +推荐使用 CSS 变量: + +```css +:root { + --bg: #ffffff; + --text: #1a1a1a; +} + +[data-theme="dark"] { + --bg: #1a1a1a; + --text: #e0e0e0; +} + +body { + background: var(--bg); + color: var(--text); +} +``` + +服务端返回 HTML 时会预先注入 `data-theme`,减少初始闪烁。需要在 JavaScript 中响应主题变化时,读取 `bridge.getContext()?.isDark` 并监听 `onContext()`。 -`apiGet`、`apiPost`、`upload`、`download`、`subscribeSSE` 的 `endpoint` 必须是插件内相对路径: +## 静态资源路径 -- 允许:`"stats"`、`"settings/save"`、`"files/export"` -- 不允许:空字符串、`"/stats"`、`"../stats"`、`"https://example.com"`、`"stats?x=1"`、`"stats#top"` +正常使用相对路径即可: + +```html + + + +``` + +AstrBot 会重写相对资源路径并追加短期 `asset_token`。不要手动拼接 `/api/plugin/page/content/...`,不要自行追加 `asset_token`,也不要依赖 `..` 逃逸 Page 根目录。 + +会被重写的资源引用包括: + +- HTML `src` 和 `href` +- CSS `url(...)` +- JavaScript `import` +- JavaScript `export ... from` +- JavaScript 动态 `import()` + +如果构建 SPA,建议使用 hash routing。静态资源服务按真实文件路径解析;history routing 刷新页面时需要对应路径上真的存在文件。 + +## 安全约束 + +插件 Pages 运行在受限 iframe 中: + +```text +allow-scripts allow-forms allow-downloads +``` + +Page 不能直接访问 Dashboard cookies、LocalStorage 或父页面 DOM,也不能绕过 bridge 复用 Dashboard auth。所有需要 Dashboard 身份的操作都应该走 bridge。 + +资源响应会带上安全头,包括: + +- `X-Frame-Options: SAMEORIGIN` +- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'` +- `Cache-Control: no-store` +- `X-Content-Type-Options: nosniff` + +后端 handler 仍然要验证输入。不要信任 Page 传来的路径、文件名、格式或数值范围;文件落盘时应使用安全目录,并对文件名做白名单或重新命名。 + +## 调试建议 -query 参数请通过 `params` 传递,不要拼进 `endpoint`。 +- Page 没出现:检查 `pages//index.html` 是否存在、插件是否启用、插件详情页是否已刷新。 +- bridge 不存在:确认脚本在 bridge SDK 注入之后运行;推荐使用外部 `type="module"` 脚本。 +- API 未匹配:确认注册路由包含插件名前缀,例如 `/{PLUGIN_NAME}/stats`,而 Page 端 endpoint 是 `stats`。 +- query 或 JSON 为空:GET 参数放到 `apiGet(endpoint, params)`,POST JSON 放到 `apiPost(endpoint, body)`。 +- 文件上传为空:`upload()` 字段名固定为 `file`,后端用 `(await request.files()).get("file")` 读取。 +- SSE 没消息:确认后端响应是 `text/event-stream`,每条消息以空行结尾,例如 `data: ...\n\n`。 diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index d0246e527e..77119a528d 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1773,6 +1773,106 @@ async def test_v1_plugin_extension_maps_nested_plugin_path( } +@pytest.mark.asyncio +async def test_v1_plugin_extension_supports_astrbot_web_api( + asgi_client: httpx.AsyncClient, + fake_core_lifecycle, +): + from astrbot.api.web import json_response + from astrbot.api.web import request as plugin_request + + async def astrbot_web_plugin_extension(item_id: str): + return json_response( + { + "item_id": item_id, + "path_value": plugin_request.path_params["item_id"], + "path": plugin_request.path, + "method": plugin_request.method, + "limit": plugin_request.query.get("limit", 20, type=int), + "tags": plugin_request.query.getlist("tag"), + "payload": await plugin_request.json(default={}), + "username": plugin_request.username, + "plugin_name": plugin_request.plugin_name, + }, + status_code=201, + ) + + fake_core_lifecycle.star_context.registered_web_apis = [ + ("/web/", astrbot_web_plugin_extension, ["POST"], "web") + ] + + response = await asgi_client.post( + "/api/v1/plugins/extensions/web/demo-item?limit=7&tag=one&tag=two", + json={"value": "demo"}, + headers=_jwt_headers(), + ) + + assert response.status_code == 201 + data = response.json() + assert data == { + "item_id": "demo-item", + "path_value": "demo-item", + "path": "/api/v1/plugins/extensions/web/demo-item", + "method": "POST", + "limit": 7, + "tags": ["one", "two"], + "payload": {"value": "demo"}, + "username": "fastapi-v1-test", + "plugin_name": "web", + } + + +@pytest.mark.asyncio +async def test_v1_plugin_extension_astrbot_web_api_reads_form_and_files( + asgi_client: httpx.AsyncClient, + fake_core_lifecycle, +): + from astrbot.api.web import json_response + from astrbot.api.web import request as plugin_request + + async def astrbot_web_upload_extension(): + form = await plugin_request.form() + files = await plugin_request.files() + upload = files.get("file") + return json_response( + { + "tags": form.getlist("tag"), + "filename": upload.filename, + "content_type": upload.content_type, + "content": (await upload.read()).decode("utf-8"), + } + ) + + fake_core_lifecycle.star_context.registered_web_apis = [ + ("/upload", astrbot_web_upload_extension, ["POST"], "upload") + ] + + response = await asgi_client.post( + "/api/v1/plugins/extensions/upload", + files=[ + ("tag", (None, "one")), + ("tag", (None, "two")), + ("file", ("demo.txt", b"hello", "text/plain")), + ], + headers=_jwt_headers(), + ) + + assert response.status_code == 200 + assert response.json() == { + "tags": ["one", "two"], + "filename": "demo.txt", + "content_type": "text/plain", + "content": "hello", + } + + +def test_astrbot_web_request_requires_plugin_context(): + from astrbot.api.web import request as plugin_request + + with pytest.raises(RuntimeError, match="plugin Web API handler"): + _ = plugin_request.method + + @pytest.mark.asyncio async def test_v1_plugin_extension_supports_quart_request_context( asgi_client: httpx.AsyncClient, From 28a8d13af453cf2b0d27034eac122652b6ebc672 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:18:38 +0800 Subject: [PATCH 24/29] docs: add plugin page bridge demo --- docs/en/dev/star/guides/plugin-pages.md | 2 + .../.astrbot-plugin/i18n/en-US.json | 40 +++ .../.astrbot-plugin/i18n/zh-CN.json | 40 +++ .../astrbot_plugin_page_demo/README.md | 31 +++ .../examples/astrbot_plugin_page_demo/main.py | 207 ++++++++++++++ .../astrbot_plugin_page_demo/metadata.yaml | 7 + .../pages/bridge-lab/app.js | 183 +++++++++++++ .../pages/bridge-lab/index.html | 121 +++++++++ .../pages/bridge-lab/style.css | 257 ++++++++++++++++++ docs/zh/dev/star/guides/plugin-pages.md | 2 + 10 files changed, 890 insertions(+) create mode 100644 docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json create mode 100644 docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json create mode 100644 docs/examples/astrbot_plugin_page_demo/README.md create mode 100644 docs/examples/astrbot_plugin_page_demo/main.py create mode 100644 docs/examples/astrbot_plugin_page_demo/metadata.yaml create mode 100644 docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js create mode 100644 docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html create mode 100644 docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md index 84341aeca0..73382a8753 100644 --- a/docs/en/dev/star/guides/plugin-pages.md +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -26,6 +26,8 @@ Use simple directory names for `page_name`, such as `settings` or `bridge-demo`. Users open Pages from the plugin detail page in the WebUI. +See the complete runnable example at [`docs/examples/astrbot_plugin_page_demo`](../../../../examples/astrbot_plugin_page_demo/). + ## Development Flow 1. Create `pages//index.html` in the plugin directory. diff --git a/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json b/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json new file mode 100644 index 0000000000..14cf2bea83 --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json @@ -0,0 +1,40 @@ +{ + "metadata": { + "display_name": "Plugin Page Demo", + "short_desc": "Reproduces Plugin Page bridge, i18n, theme, and backend APIs.", + "desc": "Demo plugin for validating AstrBot Plugin Pages and bridge APIs." + }, + "pages": { + "bridge-lab": { + "title": "Bridge Lab", + "description": "Reproduces ready, context, i18n, theme, GET, POST, upload, download, and SSE.", + "heading": "Plugin Page Bridge Lab", + "subtitle": "This page follows WebUI locale and theme changes live.", + "context": "Context", + "bridge": "Bridge APIs", + "backend": "Backend Requests", + "events": "SSE Events", + "output": "Output", + "locale": "Locale", + "theme": "Theme", + "light": "Light", + "dark": "Dark", + "refreshContext": "Refresh Context", + "callGet": "Call GET", + "callPost": "Call POST", + "upload": "Upload File", + "download": "Download File", + "startSse": "Subscribe SSE", + "stopSse": "Unsubscribe SSE", + "callError": "Trigger Error", + "enabled": "Enabled", + "threshold": "Threshold", + "note": "Note", + "chooseFile": "Choose a file before testing upload().", + "noFile": "Choose a file first.", + "ready": "bridge.ready() completed", + "contextChanged": "Context updated", + "sseClosed": "SSE closed" + } + } +} diff --git a/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json b/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json new file mode 100644 index 0000000000..f5dedec8f8 --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json @@ -0,0 +1,40 @@ +{ + "metadata": { + "display_name": "Plugin Page 演示", + "short_desc": "复现 Plugin Page bridge、i18n、主题和后端 API。", + "desc": "用于验证 AstrBot 插件 Pages 与 bridge API 的完整演示插件。" + }, + "pages": { + "bridge-lab": { + "title": "Bridge 实验台", + "description": "复现 ready、context、i18n、theme、GET、POST、upload、download 和 SSE。", + "heading": "Plugin Page Bridge 实验台", + "subtitle": "当前页面会跟随 WebUI 语言和主题实时更新。", + "context": "上下文", + "bridge": "Bridge 接口", + "backend": "后端请求", + "events": "SSE 事件", + "output": "输出", + "locale": "语言", + "theme": "主题", + "light": "亮色", + "dark": "暗色", + "refreshContext": "刷新上下文", + "callGet": "调用 GET", + "callPost": "调用 POST", + "upload": "上传文件", + "download": "下载文件", + "startSse": "订阅 SSE", + "stopSse": "取消 SSE", + "callError": "触发错误", + "enabled": "启用", + "threshold": "阈值", + "note": "备注", + "chooseFile": "选择一个文件后测试 upload()", + "noFile": "请先选择文件。", + "ready": "bridge.ready() 已完成", + "contextChanged": "上下文已更新", + "sseClosed": "SSE 已关闭" + } + } +} diff --git a/docs/examples/astrbot_plugin_page_demo/README.md b/docs/examples/astrbot_plugin_page_demo/README.md new file mode 100644 index 0000000000..abbb46db48 --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/README.md @@ -0,0 +1,31 @@ +# AstrBot Plugin Page Demo + +This demo plugin reproduces the Plugin Page bridge APIs and the recommended `astrbot.api.web` backend helpers. + +Covered bridge APIs: + +- `ready()` +- `getContext()` +- `getLocale()` +- `getI18n()` +- `t(key, fallback)` +- `onContext(handler)` +- `apiGet(endpoint, params)` +- `apiPost(endpoint, body)` +- `upload(endpoint, file)` +- `download(endpoint, params, filename)` +- `subscribeSSE(endpoint, handlers, params)` +- `unsubscribeSSE(subscriptionId)` + +Covered backend helpers: + +- `request.query` +- `await request.json(default={})` +- `await request.form()` +- `await request.files()` +- `json_response()` +- `error_response()` +- `file_response()` +- `stream_response()` + +For local manual testing, place this directory under `data/plugins/astrbot_plugin_page_demo`, restart or reload plugins, then open the `Bridge Lab` Page from the plugin detail page. diff --git a/docs/examples/astrbot_plugin_page_demo/main.py b/docs/examples/astrbot_plugin_page_demo/main.py new file mode 100644 index 0000000000..81e8a697ec --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/main.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path +from typing import Any + +from astrbot.api.star import Context, Star +from astrbot.api.web import ( + error_response, + file_response, + json_response, + request, + stream_response, +) +from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path + +PLUGIN_NAME = "astrbot_plugin_page_demo" + + +class PageDemoPlugin(Star): + """Demo plugin that exercises Plugin Page bridge and backend APIs.""" + + def __init__(self, context: Context) -> None: + super().__init__(context) + self.data_dir = Path(get_astrbot_plugin_data_path()) / PLUGIN_NAME + self.upload_dir = self.data_dir / "uploads" + self.last_settings: dict[str, Any] = { + "enabled": True, + "threshold": 0.8, + "note": "Hello from AstrBot Plugin Pages", + } + for route, handler, methods, desc in ( + ( + f"/{PLUGIN_NAME}/echo/", + self.page_echo, + ["GET"], + "Echo query and request context", + ), + ( + f"/{PLUGIN_NAME}/settings/save", + self.save_settings, + ["POST"], + "Save demo settings", + ), + ( + f"/{PLUGIN_NAME}/files/import", + self.import_file, + ["POST"], + "Import uploaded file", + ), + ( + f"/{PLUGIN_NAME}/files/export", + self.export_file, + ["GET"], + "Export demo JSON file", + ), + ( + f"/{PLUGIN_NAME}/events", + self.stream_events, + ["GET"], + "Stream demo SSE events", + ), + ( + f"/{PLUGIN_NAME}/error", + self.demo_error, + ["GET"], + "Return a demo bridge error", + ), + ): + context.register_web_api(route, handler, methods, desc) + + async def page_echo(self, item_id: str): + """Return request context and query values for bridge.apiGet(). + + Args: + item_id: Dynamic route parameter. + + Returns: + JSON response containing request context details. + """ + return json_response( + { + "item_id": item_id, + "path_params": request.path_params, + "query": { + "limit": request.query.get("limit", 20, type=int), + "tag": request.query.get("tag", ""), + "all_tags": request.query.getlist("tag"), + }, + "request": { + "method": request.method, + "path": request.path, + "plugin_name": request.plugin_name, + "username": request.username, + "content_type": request.content_type, + "client_host": request.client_host, + }, + "server_time": int(time.time()), + "last_settings": self.last_settings, + } + ) + + async def save_settings(self): + """Read bridge.apiPost() JSON and keep it in memory. + + Returns: + JSON response containing the saved payload. + """ + payload = await request.json(default={}) + if not isinstance(payload, dict): + return error_response("settings payload must be a JSON object") + + enabled = payload.get("enabled") + threshold = payload.get("threshold") + if not isinstance(enabled, bool): + return error_response("enabled must be a boolean") + if not isinstance(threshold, int | float): + return error_response("threshold must be a number") + + self.last_settings = { + "enabled": enabled, + "threshold": float(threshold), + "note": str(payload.get("note") or ""), + "saved_at": int(time.time()), + } + return json_response({"saved": True, "settings": self.last_settings}) + + async def import_file(self): + """Read bridge.upload() multipart data and save the uploaded file. + + Returns: + JSON response containing uploaded file metadata. + """ + form = await request.form() + files = await request.files() + upload = files.get("file") + if upload is None: + return error_response("missing file", status_code=400) + + self.upload_dir.mkdir(parents=True, exist_ok=True) + filename = Path(upload.filename or "upload.bin").name + saved_path = self.upload_dir / filename + await upload.save(saved_path) + return json_response( + { + "filename": filename, + "content_type": upload.content_type, + "content_length": upload.content_length, + "saved_path": str(saved_path), + "form_fields": dict(form.items()), + } + ) + + async def export_file(self): + """Create and return a JSON file for bridge.download(). + + Returns: + File download response. + """ + self.data_dir.mkdir(parents=True, exist_ok=True) + export_path = self.data_dir / "plugin-page-demo-export.json" + export_payload = { + "plugin": PLUGIN_NAME, + "generated_at": int(time.time()), + "format": request.query.get("format", "json"), + "settings": self.last_settings, + } + export_path.write_text( + json.dumps(export_payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return file_response( + export_path, + filename="plugin-page-demo-export.json", + content_type="application/json", + ) + + async def stream_events(self): + """Stream Server-Sent Events for bridge.subscribeSSE(). + + Returns: + Streaming response using the SSE media type. + """ + count = max(1, min(request.query.get("count", 5, type=int), 20)) + delay_ms = max(50, min(request.query.get("delay_ms", 500, type=int), 5000)) + + async def events(): + for index in range(count): + payload = { + "index": index + 1, + "count": count, + "time": int(time.time()), + } + yield f"id: {index + 1}\ndata: {json.dumps(payload)}\n\n" + await asyncio.sleep(delay_ms / 1000) + + return stream_response(events()) + + async def demo_error(self): + """Return a predictable error for bridge error handling. + + Returns: + Standard AstrBot error response. + """ + return error_response("demo error from plugin backend", status_code=400) diff --git a/docs/examples/astrbot_plugin_page_demo/metadata.yaml b/docs/examples/astrbot_plugin_page_demo/metadata.yaml new file mode 100644 index 0000000000..5281776401 --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/metadata.yaml @@ -0,0 +1,7 @@ +name: astrbot_plugin_page_demo +display_name: Plugin Page Demo +desc: Demo plugin for AstrBot Plugin Pages and bridge APIs. +short_desc: Reproduces Plugin Page bridge, i18n, theme, and backend APIs. +version: 0.1.0 +author: AstrBot Team +repo: https://github.com/AstrBotDevs/AstrBot diff --git a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js new file mode 100644 index 0000000000..a4e3536897 --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js @@ -0,0 +1,183 @@ +const bridge = window.AstrBotPluginPage; +const state = { + context: null, + sseId: null, +}; + +const $ = (selector) => document.querySelector(selector); +const output = $("#output"); +const eventList = $("#event-list"); + +function text(key, fallback) { + return bridge.t(key, fallback); +} + +function writeOutput(label, value) { + output.textContent = JSON.stringify( + { + label, + value, + context: bridge.getContext(), + locale: bridge.getLocale(), + i18nLocales: Object.keys(bridge.getI18n()), + }, + null, + 2, + ); +} + +function renderContext() { + const context = bridge.getContext() || {}; + state.context = context; + document.title = text("pages.bridge-lab.title", "Bridge Lab"); + document.documentElement.lang = bridge.getLocale(); + $("#locale-chip").textContent = `${text("pages.bridge-lab.locale", "Locale")}: ${bridge.getLocale()}`; + $("#theme-chip").textContent = `${text("pages.bridge-lab.theme", "Theme")}: ${ + context.isDark + ? text("pages.bridge-lab.dark", "Dark") + : text("pages.bridge-lab.light", "Light") + }`; + $("#plugin-name").textContent = context.pluginName || "-"; + $("#page-name").textContent = context.pageName || "-"; + $("#display-name").textContent = context.displayName || "-"; + $("#page-title").textContent = context.pageTitle || "-"; + + document.querySelectorAll("[data-i18n]").forEach((node) => { + const key = node.getAttribute("data-i18n"); + node.textContent = text(key, node.textContent); + }); +} + +async function callGet() { + const result = await bridge.apiGet("echo/demo-item", { + limit: $("#limit-input").value, + tag: $("#tag-input").value, + }); + writeOutput("apiGet", result); +} + +async function callPost() { + const result = await bridge.apiPost("settings/save", { + enabled: $("#enabled-input").checked, + threshold: Number($("#threshold-input").value), + note: $("#note-input").value, + }); + writeOutput("apiPost", result); +} + +async function uploadFile() { + const file = $("#file-input").files?.[0]; + if (!file) { + writeOutput("upload", { + error: text("pages.bridge-lab.noFile", "Choose a file first."), + }); + return; + } + const result = await bridge.upload("files/import", file); + writeOutput("upload", result); +} + +async function downloadFile() { + const result = await bridge.download( + "files/export", + { format: "json" }, + "plugin-page-demo-export.json", + ); + writeOutput("download", result); +} + +async function startSse() { + if (state.sseId) { + await bridge.unsubscribeSSE(state.sseId); + state.sseId = null; + } + eventList.replaceChildren(); + state.sseId = await bridge.subscribeSSE( + "events", + { + onOpen() { + appendEvent("open", {}); + }, + onMessage(event) { + appendEvent("message", { + raw: event.raw, + parsed: event.parsed, + lastEventId: event.lastEventId, + }); + }, + onError() { + appendEvent("error", {}); + }, + }, + { count: 5, delay_ms: 400 }, + ); + writeOutput("subscribeSSE", { subscriptionId: state.sseId }); +} + +async function stopSse() { + if (!state.sseId) { + return; + } + const closedId = state.sseId; + await bridge.unsubscribeSSE(closedId); + state.sseId = null; + appendEvent("closed", { subscriptionId: closedId }); + writeOutput("unsubscribeSSE", { + subscriptionId: closedId, + message: text("pages.bridge-lab.sseClosed", "SSE closed"), + }); +} + +async function callError() { + try { + await bridge.apiGet("error"); + } catch (error) { + writeOutput("error", { + message: error.message, + }); + } +} + +function appendEvent(type, value) { + const item = document.createElement("li"); + const code = document.createElement("code"); + code.textContent = JSON.stringify({ type, value }); + item.append(code); + eventList.append(item); +} + +function bindActions() { + $("#refresh-context").addEventListener("click", () => { + renderContext(); + writeOutput("getContext", { + message: text("pages.bridge-lab.contextChanged", "Context updated"), + context: bridge.getContext(), + }); + }); + $("#call-get").addEventListener("click", () => void callGet()); + $("#call-post").addEventListener("click", () => void callPost()); + $("#upload-file").addEventListener("click", () => void uploadFile()); + $("#download-file").addEventListener("click", () => void downloadFile()); + $("#start-sse").addEventListener("click", () => void startSse()); + $("#stop-sse").addEventListener("click", () => void stopSse()); + $("#call-error").addEventListener("click", () => void callError()); +} + +window.addEventListener("beforeunload", () => { + if (state.sseId) { + void bridge.unsubscribeSSE(state.sseId); + } +}); + +await bridge.ready(); +bindActions(); +renderContext(); +bridge.onContext(() => { + renderContext(); + writeOutput("onContext", { + message: text("pages.bridge-lab.contextChanged", "Context updated"), + }); +}); +writeOutput("ready", { + message: text("pages.bridge-lab.ready", "bridge.ready() completed"), +}); diff --git a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html new file mode 100644 index 0000000000..af3688833f --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html @@ -0,0 +1,121 @@ + + + + + + Bridge Lab + + + +
+ + +
+
+
+

Context

+ +
+
+
+
pluginName
+
-
+
+
+
pageName
+
-
+
+
+
displayName
+
-
+
+
+
pageTitle
+
-
+
+
+
+ +
+
+

Backend Requests

+
+
+ + + + + +
+
+ + + +
+
+ +
+
+

Bridge APIs

+
+
+ + Choose a file before testing upload(). +
+
+ + +
+
+ +
+
+

SSE Events

+
+
+ + +
+
    +
    +
    + +
    +
    +

    Output

    +
    +
    {}
    +
    +
    + + + + diff --git a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css new file mode 100644 index 0000000000..c8902e797e --- /dev/null +++ b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css @@ -0,0 +1,257 @@ +:root { + color-scheme: light; + --bg: #f7f8fb; + --panel: #ffffff; + --text: #172033; + --muted: #5e6b80; + --line: #dce2ea; + --accent: #246bfe; + --accent-contrast: #ffffff; + --danger: #b42318; + --shadow: 0 10px 24px rgba(23, 32, 51, 0.08); +} + +[data-theme="dark"] { + color-scheme: dark; + --bg: #111827; + --panel: #1f2937; + --text: #f3f6fb; + --muted: #b7c0ce; + --line: #344154; + --accent: #7cc4ff; + --accent-contrast: #0e1726; + --danger: #ff9b91; + --shadow: 0 10px 24px rgba(0, 0, 0, 0.24); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + letter-spacing: 0; +} + +button, +input { + font: inherit; +} + +button { + min-height: 36px; + border: 1px solid var(--accent); + border-radius: 6px; + padding: 0 12px; + background: var(--accent); + color: var(--accent-contrast); + cursor: pointer; +} + +button:hover { + filter: brightness(1.05); +} + +input { + width: 100%; + min-height: 36px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 10px; + background: var(--panel); + color: var(--text); +} + +input[type="checkbox"] { + width: 18px; + min-height: 18px; +} + +.page-shell { + width: min(1180px, 100%); + margin: 0 auto; + padding: 24px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} + +.page-header h1 { + margin: 0; + font-size: 30px; + line-height: 1.2; +} + +.page-header p { + margin: 8px 0 0; + color: var(--muted); +} + +.status-strip { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.status-strip span { + min-height: 30px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 10px; + background: var(--panel); + color: var(--muted); + white-space: nowrap; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.panel, +.output-panel { + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.output-panel { + margin-top: 16px; +} + +.panel-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.panel-title h2 { + margin: 0; + font-size: 18px; + line-height: 1.25; +} + +.context-list { + display: grid; + gap: 10px; + margin: 0; +} + +.context-list div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 12px; +} + +.context-list dt { + color: var(--muted); +} + +.context-list dd { + margin: 0; + overflow-wrap: anywhere; +} + +.control-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.control-grid label { + display: grid; + gap: 6px; + color: var(--muted); +} + +.control-grid .wide { + grid-column: 1 / -1; +} + +.button-row, +.file-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 14px; +} + +.file-row input { + max-width: 280px; +} + +.file-row span { + color: var(--muted); +} + +.event-list { + display: grid; + max-height: 180px; + margin: 0; + padding-left: 22px; + overflow: auto; + gap: 8px; +} + +pre, +code { + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; +} + +pre { + min-height: 220px; + max-height: 420px; + margin: 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + background: color-mix(in srgb, var(--panel), var(--bg) 52%); + color: var(--text); + white-space: pre-wrap; +} + +@media (max-width: 760px) { + .page-shell { + padding: 16px; + } + + .page-header, + .panel-title { + flex-direction: column; + align-items: stretch; + } + + .status-strip { + justify-content: flex-start; + } + + .grid, + .control-grid { + grid-template-columns: 1fr; + } + + .context-list div { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md index c02044379e..61df93259d 100644 --- a/docs/zh/dev/star/guides/plugin-pages.md +++ b/docs/zh/dev/star/guides/plugin-pages.md @@ -26,6 +26,8 @@ astrbot_plugin_page_demo/ 用户可以在 WebUI 的插件页点击插件卡片进入插件详情页,然后打开插件声明的 Pages。 +完整可运行示例见 [`docs/examples/astrbot_plugin_page_demo`](../../../../examples/astrbot_plugin_page_demo/)。 + ## 开发流程 1. 在插件目录下创建 `pages//index.html`。 From f3ad696e988b2a2b17a7cb6715fa78813fd79236 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:30:48 +0800 Subject: [PATCH 25/29] fix: type plugin upload files --- astrbot/api/web.py | 46 +++++++++++++------ docs/en/dev/star/guides/plugin-pages.md | 13 ++++-- .../examples/astrbot_plugin_page_demo/main.py | 5 +- docs/zh/dev/star/guides/plugin-pages.md | 13 ++++-- tests/test_fastapi_v1_dashboard.py | 5 +- 5 files changed, 54 insertions(+), 28 deletions(-) diff --git a/astrbot/api/web.py b/astrbot/api/web.py index 01e61f91ed..234503f104 100644 --- a/astrbot/api/web.py +++ b/astrbot/api/web.py @@ -4,20 +4,38 @@ from collections.abc import Callable from contextlib import contextmanager from pathlib import Path -from typing import Any +from typing import Any, Generic, TypeVar, overload from fastapi.encoders import jsonable_encoder from fastapi.responses import FileResponse, JSONResponse from starlette.datastructures import UploadFile as StarletteUploadFile from starlette.responses import StreamingResponse +ValueT = TypeVar("ValueT") +DefaultT = TypeVar("DefaultT") +ConvertedT = TypeVar("ConvertedT") -class PluginMultiDict: + +class PluginMultiDict(Generic[ValueT]): """Dictionary-like request values that preserves duplicate keys.""" - def __init__(self, pairs: list[tuple[str, Any]]) -> None: + def __init__(self, pairs: list[tuple[str, ValueT]]) -> None: self._pairs = pairs + @overload + def get(self, key: str) -> ValueT | None: ... + + @overload + def get(self, key: str, default: DefaultT) -> ValueT | DefaultT: ... + + @overload + def get( + self, + key: str, + default: DefaultT, + type: Callable[[ValueT], ConvertedT], + ) -> ConvertedT | DefaultT: ... + def get(self, key: str, default: Any = None, type: Callable | None = None): """Return the last value for a key. @@ -40,7 +58,7 @@ def get(self, key: str, default: Any = None, type: Callable | None = None): return default return default - def getlist(self, key: str) -> list[Any]: + def getlist(self, key: str) -> list[ValueT]: """Return all values for a key. Args: @@ -54,16 +72,16 @@ def getlist(self, key: str) -> list[Any]: def keys(self): return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() - def values(self): + def values(self) -> list[ValueT]: return [self[key] for key in self.keys()] - def items(self): + def items(self) -> list[tuple[str, ValueT]]: return [(key, self[key]) for key in self.keys()] def __contains__(self, key: str) -> bool: return any(item_key == key for item_key, _ in self._pairs) - def __getitem__(self, key: str): + def __getitem__(self, key: str) -> ValueT: value = self.get(key) if value is None and key not in self: raise KeyError(key) @@ -133,9 +151,9 @@ def __init__( self.path_params = path_params or {} self.plugin_name = plugin_name self.username = username - self.query = PluginMultiDict(list(request_.query_params.multi_items())) - self._form_cache: PluginMultiDict | None = None - self._files_cache: PluginMultiDict | None = None + self.query = PluginMultiDict[str](list(request_.query_params.multi_items())) + self._form_cache: PluginMultiDict[str] | None = None + self._files_cache: PluginMultiDict[PluginUploadFile] | None = None async def body(self) -> bytes: """Read the raw request body. @@ -163,8 +181,8 @@ async def _load_form_parts(self) -> None: if self._form_cache is not None and self._files_cache is not None: return form = await self._request.form() - form_pairs: list[tuple[str, Any]] = [] - file_pairs: list[tuple[str, Any]] = [] + form_pairs: list[tuple[str, str]] = [] + file_pairs: list[tuple[str, PluginUploadFile]] = [] for key, value in form.multi_items(): if isinstance(value, StarletteUploadFile): file_pairs.append((key, PluginUploadFile(value))) @@ -173,7 +191,7 @@ async def _load_form_parts(self) -> None: self._form_cache = PluginMultiDict(form_pairs) self._files_cache = PluginMultiDict(file_pairs) - async def form(self) -> PluginMultiDict: + async def form(self) -> PluginMultiDict[str]: """Read form fields from a multipart or form-urlencoded request. Returns: @@ -183,7 +201,7 @@ async def form(self) -> PluginMultiDict: assert self._form_cache is not None return self._form_cache - async def files(self) -> PluginMultiDict: + async def files(self) -> PluginMultiDict[PluginUploadFile]: """Read uploaded files from a multipart request. Returns: diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md index 73382a8753..0396ff372e 100644 --- a/docs/en/dev/star/guides/plugin-pages.md +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -209,14 +209,14 @@ Upload example: from pathlib import Path from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path -from astrbot.api.web import error_response, json_response, request +from astrbot.api.web import PluginUploadFile, error_response, json_response, request async def import_file(self): form = await request.form() files = await request.files() - upload = files.get("file") - if upload is None: + upload: PluginUploadFile | None = files.get("file") + if not isinstance(upload, PluginUploadFile): return error_response("missing file") target_dir = ( @@ -452,10 +452,13 @@ const result = await bridge.upload("files/import", file); Backend: ```python +from astrbot.api.web import PluginUploadFile, error_response, json_response, request + + async def import_file(self): files = await request.files() - upload = files.get("file") - if upload is None: + upload: PluginUploadFile | None = files.get("file") + if not isinstance(upload, PluginUploadFile): return error_response("missing file", status_code=400) return json_response({"filename": upload.filename}) ``` diff --git a/docs/examples/astrbot_plugin_page_demo/main.py b/docs/examples/astrbot_plugin_page_demo/main.py index 81e8a697ec..b9fe9afb82 100644 --- a/docs/examples/astrbot_plugin_page_demo/main.py +++ b/docs/examples/astrbot_plugin_page_demo/main.py @@ -8,6 +8,7 @@ from astrbot.api.star import Context, Star from astrbot.api.web import ( + PluginUploadFile, error_response, file_response, json_response, @@ -135,8 +136,8 @@ async def import_file(self): """ form = await request.form() files = await request.files() - upload = files.get("file") - if upload is None: + upload: PluginUploadFile | None = files.get("file") + if not isinstance(upload, PluginUploadFile): return error_response("missing file", status_code=400) self.upload_dir.mkdir(parents=True, exist_ok=True) diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md index 61df93259d..8a4fcbd903 100644 --- a/docs/zh/dev/star/guides/plugin-pages.md +++ b/docs/zh/dev/star/guides/plugin-pages.md @@ -209,14 +209,14 @@ enabled = bool(payload.get("enabled")) from pathlib import Path from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path -from astrbot.api.web import error_response, json_response, request +from astrbot.api.web import PluginUploadFile, error_response, json_response, request async def import_file(self): form = await request.form() files = await request.files() - upload = files.get("file") - if upload is None: + upload: PluginUploadFile | None = files.get("file") + if not isinstance(upload, PluginUploadFile): return error_response("missing file") target_dir = ( @@ -452,10 +452,13 @@ const result = await bridge.upload("files/import", file); 后端读取: ```python +from astrbot.api.web import PluginUploadFile, error_response, json_response, request + + async def import_file(self): files = await request.files() - upload = files.get("file") - if upload is None: + upload: PluginUploadFile | None = files.get("file") + if not isinstance(upload, PluginUploadFile): return error_response("missing file", status_code=400) return json_response({"filename": upload.filename}) ``` diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 77119a528d..5edbff7aae 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1827,13 +1827,14 @@ async def test_v1_plugin_extension_astrbot_web_api_reads_form_and_files( asgi_client: httpx.AsyncClient, fake_core_lifecycle, ): - from astrbot.api.web import json_response + from astrbot.api.web import PluginUploadFile, json_response from astrbot.api.web import request as plugin_request async def astrbot_web_upload_extension(): form = await plugin_request.form() files = await plugin_request.files() - upload = files.get("file") + upload: PluginUploadFile | None = files.get("file") + assert isinstance(upload, PluginUploadFile) return json_response( { "tags": form.getlist("tag"), From 10bb3fba791c758ac5b8f8169e6432280869f22b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:36:07 +0800 Subject: [PATCH 26/29] fix: stabilize plugin page uploads --- astrbot/dashboard/plugin_page_bridge.js | 42 +++++++++----- dashboard/src/views/PluginPagePage.vue | 74 +++++++++++++++++++------ 2 files changed, 87 insertions(+), 29 deletions(-) diff --git a/astrbot/dashboard/plugin_page_bridge.js b/astrbot/dashboard/plugin_page_bridge.js index 650e30e442..a2383c6911 100644 --- a/astrbot/dashboard/plugin_page_bridge.js +++ b/astrbot/dashboard/plugin_page_bridge.js @@ -40,7 +40,7 @@ return origin === SELF_ORIGIN; } - function send(kind, payload) { + function send(kind, payload, transfer) { window.parent.postMessage( { channel: CHANNEL, @@ -48,19 +48,24 @@ ...(payload || {}), }, getTargetOrigin(), + transfer || [], ); } - function makeRequest(action, payload) { + function makeRequest(action, payload, transfer) { return new Promise((resolve, reject) => { requestCounter += 1; const requestId = `plugin_req_${requestCounter}`; pendingRequests.set(requestId, { resolve, reject }); - send("request", { - requestId, - action, - ...(payload || {}), - }); + send( + "request", + { + requestId, + action, + ...(payload || {}), + }, + transfer, + ); }); } @@ -235,12 +240,23 @@ apiPost(endpoint, body) { return makeRequest("api:post", { endpoint, body }); }, - upload(endpoint, file) { - return makeRequest("files:upload", { - endpoint, - file, - fileName: file?.name || "upload.bin", - }); + async upload(endpoint, file) { + if (!file || typeof file.arrayBuffer !== "function") { + throw new Error("Missing uploaded file payload."); + } + const fileBuffer = await file.arrayBuffer(); + return makeRequest( + "files:upload", + { + endpoint, + fileName: file?.name || "upload.bin", + fileType: file?.type || "application/octet-stream", + fileLastModified: + typeof file?.lastModified === "number" ? file.lastModified : null, + fileBuffer, + }, + [fileBuffer], + ); }, download(endpoint, params, filename) { return makeRequest("files:download", { endpoint, params, filename }); diff --git a/dashboard/src/views/PluginPagePage.vue b/dashboard/src/views/PluginPagePage.vue index 566a70540b..d3284d0dfd 100644 --- a/dashboard/src/views/PluginPagePage.vue +++ b/dashboard/src/views/PluginPagePage.vue @@ -127,6 +127,9 @@ const isBridgeUploadFile = (value) => { if (!value || typeof value !== "object") { return false; } + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return true; + } if (typeof File !== "undefined" && value instanceof File) { return true; } @@ -142,29 +145,64 @@ const isBridgeUploadFile = (value) => { ); }; -const coerceBridgeUploadFile = async (value, fileName) => { +const createBridgeUploadBlob = (parts, fileName, fileType, lastModified) => { + const normalizedType = + typeof fileType === "string" && fileType + ? fileType + : "application/octet-stream"; + if (typeof File !== "undefined") { + return new File(parts, fileName, { + type: normalizedType, + lastModified: + typeof lastModified === "number" ? lastModified : Date.now(), + }); + } + return new Blob(parts, { type: normalizedType }); +}; + +const coerceBridgeUploadFile = async ( + value, + fileName, + fileType, + lastModified, +) => { if (!isBridgeUploadFile(value)) { throw new Error("Missing uploaded file payload."); } + if (value instanceof ArrayBuffer) { + return createBridgeUploadBlob([value], fileName, fileType, lastModified); + } + if (ArrayBuffer.isView(value)) { + const viewBuffer = value.buffer.slice( + value.byteOffset, + value.byteOffset + value.byteLength, + ); + return createBridgeUploadBlob( + [viewBuffer], + fileName, + fileType, + lastModified, + ); + } if (typeof Blob !== "undefined" && value instanceof Blob) { return value; } const buffer = await value.arrayBuffer(); - const fileType = + const fallbackType = typeof value.type === "string" && value.type ? value.type : "application/octet-stream"; - if (typeof File !== "undefined") { - return new File([buffer], fileName, { - type: fileType, - lastModified: - typeof value.lastModified === "number" - ? value.lastModified - : Date.now(), - }); - } - return new Blob([buffer], { type: fileType }); + const normalizedType = + typeof fileType === "string" && fileType + ? fileType + : fallbackType; + return createBridgeUploadBlob( + [buffer], + fileName, + normalizedType, + typeof lastModified === "number" ? lastModified : value.lastModified, + ); }; const sendBridgeResponse = (requestId, ok, payload) => { @@ -247,13 +285,17 @@ const handleBridgeRequest = async (message) => { if (action === "files:upload") { const formData = new FormData(); - const uploadFile = await coerceBridgeUploadFile( - message.file, + const fileName = typeof message.fileName === "string" && message.fileName ? message.fileName - : "upload.bin", + : "upload.bin"; + const uploadFile = await coerceBridgeUploadFile( + message.fileBuffer || message.file, + fileName, + message.fileType, + message.fileLastModified, ); - formData.append("file", uploadFile); + formData.append("file", uploadFile, fileName); const response = await axios.post( buildPluginApiPath(message.endpoint), formData, From 36a89c8be954993ddfe13aa27eaad0ae137500aa Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:42:10 +0800 Subject: [PATCH 27/29] fix: type plugin web request proxy --- astrbot/api/web.py | 135 ++++++++++++++++++++++++----- tests/test_fastapi_v1_dashboard.py | 17 ++++ 2 files changed, 129 insertions(+), 23 deletions(-) diff --git a/astrbot/api/web.py b/astrbot/api/web.py index 234503f104..6fb46e69d4 100644 --- a/astrbot/api/web.py +++ b/astrbot/api/web.py @@ -1,13 +1,14 @@ from __future__ import annotations import contextvars -from collections.abc import Callable +from collections.abc import Callable, KeysView from contextlib import contextmanager from pathlib import Path from typing import Any, Generic, TypeVar, overload from fastapi.encoders import jsonable_encoder from fastapi.responses import FileResponse, JSONResponse +from starlette.datastructures import Headers from starlette.datastructures import UploadFile as StarletteUploadFile from starlette.responses import StreamingResponse @@ -69,7 +70,7 @@ def getlist(self, key: str) -> list[ValueT]: """ return [item_value for item_key, item_value in self._pairs if item_key == key] - def keys(self): + def keys(self) -> KeysView[str]: return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() def values(self) -> list[ValueT]: @@ -95,11 +96,11 @@ class PluginUploadFile: """Uploaded file wrapper exposed to plugin Web API handlers.""" def __init__(self, upload_file: StarletteUploadFile) -> None: - self._upload_file = upload_file - self.filename = upload_file.filename - self.content_type = upload_file.content_type - self.headers = upload_file.headers - self.content_length = self._resolve_content_length() + self._upload_file: StarletteUploadFile = upload_file + self.filename: str | None = upload_file.filename + self.content_type: str | None = upload_file.content_type + self.headers: Headers = upload_file.headers + self.content_length: int | None = self._resolve_content_length() def _resolve_content_length(self) -> int | None: try: @@ -126,7 +127,38 @@ async def save(self, destination: str | Path) -> None: break output.write(chunk) - def __getattr__(self, key: str): + async def read(self, size: int = -1) -> bytes: + """Read bytes from the uploaded file. + + Args: + size: Maximum number of bytes to read. Use -1 to read all bytes. + + Returns: + File bytes. + """ + return await self._upload_file.read(size) + + async def write(self, data: bytes) -> None: + """Write bytes to the uploaded file object. + + Args: + data: Bytes to write. + """ + await self._upload_file.write(data) + + async def seek(self, offset: int) -> None: + """Move the uploaded file cursor. + + Args: + offset: Absolute byte offset. + """ + await self._upload_file.seek(offset) + + async def close(self) -> None: + """Close the uploaded file.""" + await self._upload_file.close() + + def __getattr__(self, key: str) -> Any: return getattr(self._upload_file, key) @@ -141,17 +173,19 @@ def __init__( plugin_name: str | None = None, username: str | None = None, ) -> None: - self._request = request_ - self.method = request_.method - self.path = request_.url.path - self.headers = request_.headers - self.cookies = request_.cookies - self.content_type = request_.headers.get("content-type") - self.client_host = request_.client.host if request_.client else None - self.path_params = path_params or {} - self.plugin_name = plugin_name - self.username = username - self.query = PluginMultiDict[str](list(request_.query_params.multi_items())) + self._request: Any = request_ + self.method: str = request_.method + self.path: str = request_.url.path + self.headers: Headers = request_.headers + self.cookies: dict[str, str] = request_.cookies + self.content_type: str | None = request_.headers.get("content-type") + self.client_host: str | None = request_.client.host if request_.client else None + self.path_params: dict[str, Any] = path_params or {} + self.plugin_name: str | None = plugin_name + self.username: str | None = username + self.query: PluginMultiDict[str] = PluginMultiDict[str]( + list(request_.query_params.multi_items()) + ) self._form_cache: PluginMultiDict[str] | None = None self._files_cache: PluginMultiDict[PluginUploadFile] | None = None @@ -163,7 +197,7 @@ async def body(self) -> bytes: """ return await self._request.body() - async def json(self, default: Any = None): + async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None: """Read the JSON request body. Args: @@ -217,7 +251,9 @@ async def files(self) -> PluginMultiDict[PluginUploadFile]: ) -class _RequestProxy: +class PluginRequestProxy: + """Typed proxy for the request bound to the current plugin Web handler.""" + def _get_current(self) -> PluginRequest: try: return _request_var.get() @@ -227,11 +263,63 @@ def _get_current(self) -> PluginRequest: "handler." ) from exc - def __getattr__(self, key: str): + @property + def method(self) -> str: + return self._get_current().method + + @property + def path(self) -> str: + return self._get_current().path + + @property + def headers(self) -> Headers: + return self._get_current().headers + + @property + def cookies(self) -> dict[str, str]: + return self._get_current().cookies + + @property + def content_type(self) -> str | None: + return self._get_current().content_type + + @property + def client_host(self) -> str | None: + return self._get_current().client_host + + @property + def path_params(self) -> dict[str, Any]: + return self._get_current().path_params + + @property + def plugin_name(self) -> str | None: + return self._get_current().plugin_name + + @property + def username(self) -> str | None: + return self._get_current().username + + @property + def query(self) -> PluginMultiDict[str]: + return self._get_current().query + + async def body(self) -> bytes: + return await self._get_current().body() + + async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None: + return await self._get_current().json(default=default) + + async def form(self) -> PluginMultiDict[str]: + return await self._get_current().form() + + async def files(self) -> PluginMultiDict[PluginUploadFile]: + return await self._get_current().files() + + def __getattr__(self, key: str) -> Any: return getattr(self._get_current(), key) -request = _RequestProxy() +request: PluginRequestProxy = PluginRequestProxy() @contextmanager @@ -354,6 +442,7 @@ def stream_response( __all__ = [ "PluginMultiDict", "PluginRequest", + "PluginRequestProxy", "PluginUploadFile", "bind_request_context", "error_response", diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 5edbff7aae..3aa24abfdb 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1874,6 +1874,23 @@ def test_astrbot_web_request_requires_plugin_context(): _ = plugin_request.method +def test_astrbot_web_request_proxy_exposes_typed_methods(): + from typing import get_type_hints + + from astrbot.api.web import ( + PluginMultiDict, + PluginRequestProxy, + PluginUploadFile, + ) + from astrbot.api.web import request as plugin_request + + assert isinstance(plugin_request, PluginRequestProxy) + assert get_type_hints(type(plugin_request).form)["return"] == PluginMultiDict[str] + assert get_type_hints(type(plugin_request).files)["return"] == PluginMultiDict[ + PluginUploadFile + ] + + @pytest.mark.asyncio async def test_v1_plugin_extension_supports_quart_request_context( asgi_client: httpx.AsyncClient, From 77210f7d0557c660a106fa9e9d82fc95f819557d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:42:36 +0800 Subject: [PATCH 28/29] docs: remove plugin page docs example --- docs/en/dev/star/guides/plugin-pages.md | 2 - .../.astrbot-plugin/i18n/en-US.json | 40 --- .../.astrbot-plugin/i18n/zh-CN.json | 40 --- .../astrbot_plugin_page_demo/README.md | 31 --- .../examples/astrbot_plugin_page_demo/main.py | 208 -------------- .../astrbot_plugin_page_demo/metadata.yaml | 7 - .../pages/bridge-lab/app.js | 183 ------------- .../pages/bridge-lab/index.html | 121 --------- .../pages/bridge-lab/style.css | 257 ------------------ docs/zh/dev/star/guides/plugin-pages.md | 2 - 10 files changed, 891 deletions(-) delete mode 100644 docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json delete mode 100644 docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json delete mode 100644 docs/examples/astrbot_plugin_page_demo/README.md delete mode 100644 docs/examples/astrbot_plugin_page_demo/main.py delete mode 100644 docs/examples/astrbot_plugin_page_demo/metadata.yaml delete mode 100644 docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js delete mode 100644 docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html delete mode 100644 docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md index 0396ff372e..5160ec3a23 100644 --- a/docs/en/dev/star/guides/plugin-pages.md +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -26,8 +26,6 @@ Use simple directory names for `page_name`, such as `settings` or `bridge-demo`. Users open Pages from the plugin detail page in the WebUI. -See the complete runnable example at [`docs/examples/astrbot_plugin_page_demo`](../../../../examples/astrbot_plugin_page_demo/). - ## Development Flow 1. Create `pages//index.html` in the plugin directory. diff --git a/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json b/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json deleted file mode 100644 index 14cf2bea83..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/en-US.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "metadata": { - "display_name": "Plugin Page Demo", - "short_desc": "Reproduces Plugin Page bridge, i18n, theme, and backend APIs.", - "desc": "Demo plugin for validating AstrBot Plugin Pages and bridge APIs." - }, - "pages": { - "bridge-lab": { - "title": "Bridge Lab", - "description": "Reproduces ready, context, i18n, theme, GET, POST, upload, download, and SSE.", - "heading": "Plugin Page Bridge Lab", - "subtitle": "This page follows WebUI locale and theme changes live.", - "context": "Context", - "bridge": "Bridge APIs", - "backend": "Backend Requests", - "events": "SSE Events", - "output": "Output", - "locale": "Locale", - "theme": "Theme", - "light": "Light", - "dark": "Dark", - "refreshContext": "Refresh Context", - "callGet": "Call GET", - "callPost": "Call POST", - "upload": "Upload File", - "download": "Download File", - "startSse": "Subscribe SSE", - "stopSse": "Unsubscribe SSE", - "callError": "Trigger Error", - "enabled": "Enabled", - "threshold": "Threshold", - "note": "Note", - "chooseFile": "Choose a file before testing upload().", - "noFile": "Choose a file first.", - "ready": "bridge.ready() completed", - "contextChanged": "Context updated", - "sseClosed": "SSE closed" - } - } -} diff --git a/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json b/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json deleted file mode 100644 index f5dedec8f8..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/.astrbot-plugin/i18n/zh-CN.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "metadata": { - "display_name": "Plugin Page 演示", - "short_desc": "复现 Plugin Page bridge、i18n、主题和后端 API。", - "desc": "用于验证 AstrBot 插件 Pages 与 bridge API 的完整演示插件。" - }, - "pages": { - "bridge-lab": { - "title": "Bridge 实验台", - "description": "复现 ready、context、i18n、theme、GET、POST、upload、download 和 SSE。", - "heading": "Plugin Page Bridge 实验台", - "subtitle": "当前页面会跟随 WebUI 语言和主题实时更新。", - "context": "上下文", - "bridge": "Bridge 接口", - "backend": "后端请求", - "events": "SSE 事件", - "output": "输出", - "locale": "语言", - "theme": "主题", - "light": "亮色", - "dark": "暗色", - "refreshContext": "刷新上下文", - "callGet": "调用 GET", - "callPost": "调用 POST", - "upload": "上传文件", - "download": "下载文件", - "startSse": "订阅 SSE", - "stopSse": "取消 SSE", - "callError": "触发错误", - "enabled": "启用", - "threshold": "阈值", - "note": "备注", - "chooseFile": "选择一个文件后测试 upload()", - "noFile": "请先选择文件。", - "ready": "bridge.ready() 已完成", - "contextChanged": "上下文已更新", - "sseClosed": "SSE 已关闭" - } - } -} diff --git a/docs/examples/astrbot_plugin_page_demo/README.md b/docs/examples/astrbot_plugin_page_demo/README.md deleted file mode 100644 index abbb46db48..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# AstrBot Plugin Page Demo - -This demo plugin reproduces the Plugin Page bridge APIs and the recommended `astrbot.api.web` backend helpers. - -Covered bridge APIs: - -- `ready()` -- `getContext()` -- `getLocale()` -- `getI18n()` -- `t(key, fallback)` -- `onContext(handler)` -- `apiGet(endpoint, params)` -- `apiPost(endpoint, body)` -- `upload(endpoint, file)` -- `download(endpoint, params, filename)` -- `subscribeSSE(endpoint, handlers, params)` -- `unsubscribeSSE(subscriptionId)` - -Covered backend helpers: - -- `request.query` -- `await request.json(default={})` -- `await request.form()` -- `await request.files()` -- `json_response()` -- `error_response()` -- `file_response()` -- `stream_response()` - -For local manual testing, place this directory under `data/plugins/astrbot_plugin_page_demo`, restart or reload plugins, then open the `Bridge Lab` Page from the plugin detail page. diff --git a/docs/examples/astrbot_plugin_page_demo/main.py b/docs/examples/astrbot_plugin_page_demo/main.py deleted file mode 100644 index b9fe9afb82..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/main.py +++ /dev/null @@ -1,208 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import time -from pathlib import Path -from typing import Any - -from astrbot.api.star import Context, Star -from astrbot.api.web import ( - PluginUploadFile, - error_response, - file_response, - json_response, - request, - stream_response, -) -from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path - -PLUGIN_NAME = "astrbot_plugin_page_demo" - - -class PageDemoPlugin(Star): - """Demo plugin that exercises Plugin Page bridge and backend APIs.""" - - def __init__(self, context: Context) -> None: - super().__init__(context) - self.data_dir = Path(get_astrbot_plugin_data_path()) / PLUGIN_NAME - self.upload_dir = self.data_dir / "uploads" - self.last_settings: dict[str, Any] = { - "enabled": True, - "threshold": 0.8, - "note": "Hello from AstrBot Plugin Pages", - } - for route, handler, methods, desc in ( - ( - f"/{PLUGIN_NAME}/echo/", - self.page_echo, - ["GET"], - "Echo query and request context", - ), - ( - f"/{PLUGIN_NAME}/settings/save", - self.save_settings, - ["POST"], - "Save demo settings", - ), - ( - f"/{PLUGIN_NAME}/files/import", - self.import_file, - ["POST"], - "Import uploaded file", - ), - ( - f"/{PLUGIN_NAME}/files/export", - self.export_file, - ["GET"], - "Export demo JSON file", - ), - ( - f"/{PLUGIN_NAME}/events", - self.stream_events, - ["GET"], - "Stream demo SSE events", - ), - ( - f"/{PLUGIN_NAME}/error", - self.demo_error, - ["GET"], - "Return a demo bridge error", - ), - ): - context.register_web_api(route, handler, methods, desc) - - async def page_echo(self, item_id: str): - """Return request context and query values for bridge.apiGet(). - - Args: - item_id: Dynamic route parameter. - - Returns: - JSON response containing request context details. - """ - return json_response( - { - "item_id": item_id, - "path_params": request.path_params, - "query": { - "limit": request.query.get("limit", 20, type=int), - "tag": request.query.get("tag", ""), - "all_tags": request.query.getlist("tag"), - }, - "request": { - "method": request.method, - "path": request.path, - "plugin_name": request.plugin_name, - "username": request.username, - "content_type": request.content_type, - "client_host": request.client_host, - }, - "server_time": int(time.time()), - "last_settings": self.last_settings, - } - ) - - async def save_settings(self): - """Read bridge.apiPost() JSON and keep it in memory. - - Returns: - JSON response containing the saved payload. - """ - payload = await request.json(default={}) - if not isinstance(payload, dict): - return error_response("settings payload must be a JSON object") - - enabled = payload.get("enabled") - threshold = payload.get("threshold") - if not isinstance(enabled, bool): - return error_response("enabled must be a boolean") - if not isinstance(threshold, int | float): - return error_response("threshold must be a number") - - self.last_settings = { - "enabled": enabled, - "threshold": float(threshold), - "note": str(payload.get("note") or ""), - "saved_at": int(time.time()), - } - return json_response({"saved": True, "settings": self.last_settings}) - - async def import_file(self): - """Read bridge.upload() multipart data and save the uploaded file. - - Returns: - JSON response containing uploaded file metadata. - """ - form = await request.form() - files = await request.files() - upload: PluginUploadFile | None = files.get("file") - if not isinstance(upload, PluginUploadFile): - return error_response("missing file", status_code=400) - - self.upload_dir.mkdir(parents=True, exist_ok=True) - filename = Path(upload.filename or "upload.bin").name - saved_path = self.upload_dir / filename - await upload.save(saved_path) - return json_response( - { - "filename": filename, - "content_type": upload.content_type, - "content_length": upload.content_length, - "saved_path": str(saved_path), - "form_fields": dict(form.items()), - } - ) - - async def export_file(self): - """Create and return a JSON file for bridge.download(). - - Returns: - File download response. - """ - self.data_dir.mkdir(parents=True, exist_ok=True) - export_path = self.data_dir / "plugin-page-demo-export.json" - export_payload = { - "plugin": PLUGIN_NAME, - "generated_at": int(time.time()), - "format": request.query.get("format", "json"), - "settings": self.last_settings, - } - export_path.write_text( - json.dumps(export_payload, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return file_response( - export_path, - filename="plugin-page-demo-export.json", - content_type="application/json", - ) - - async def stream_events(self): - """Stream Server-Sent Events for bridge.subscribeSSE(). - - Returns: - Streaming response using the SSE media type. - """ - count = max(1, min(request.query.get("count", 5, type=int), 20)) - delay_ms = max(50, min(request.query.get("delay_ms", 500, type=int), 5000)) - - async def events(): - for index in range(count): - payload = { - "index": index + 1, - "count": count, - "time": int(time.time()), - } - yield f"id: {index + 1}\ndata: {json.dumps(payload)}\n\n" - await asyncio.sleep(delay_ms / 1000) - - return stream_response(events()) - - async def demo_error(self): - """Return a predictable error for bridge error handling. - - Returns: - Standard AstrBot error response. - """ - return error_response("demo error from plugin backend", status_code=400) diff --git a/docs/examples/astrbot_plugin_page_demo/metadata.yaml b/docs/examples/astrbot_plugin_page_demo/metadata.yaml deleted file mode 100644 index 5281776401..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/metadata.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: astrbot_plugin_page_demo -display_name: Plugin Page Demo -desc: Demo plugin for AstrBot Plugin Pages and bridge APIs. -short_desc: Reproduces Plugin Page bridge, i18n, theme, and backend APIs. -version: 0.1.0 -author: AstrBot Team -repo: https://github.com/AstrBotDevs/AstrBot diff --git a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js deleted file mode 100644 index a4e3536897..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/app.js +++ /dev/null @@ -1,183 +0,0 @@ -const bridge = window.AstrBotPluginPage; -const state = { - context: null, - sseId: null, -}; - -const $ = (selector) => document.querySelector(selector); -const output = $("#output"); -const eventList = $("#event-list"); - -function text(key, fallback) { - return bridge.t(key, fallback); -} - -function writeOutput(label, value) { - output.textContent = JSON.stringify( - { - label, - value, - context: bridge.getContext(), - locale: bridge.getLocale(), - i18nLocales: Object.keys(bridge.getI18n()), - }, - null, - 2, - ); -} - -function renderContext() { - const context = bridge.getContext() || {}; - state.context = context; - document.title = text("pages.bridge-lab.title", "Bridge Lab"); - document.documentElement.lang = bridge.getLocale(); - $("#locale-chip").textContent = `${text("pages.bridge-lab.locale", "Locale")}: ${bridge.getLocale()}`; - $("#theme-chip").textContent = `${text("pages.bridge-lab.theme", "Theme")}: ${ - context.isDark - ? text("pages.bridge-lab.dark", "Dark") - : text("pages.bridge-lab.light", "Light") - }`; - $("#plugin-name").textContent = context.pluginName || "-"; - $("#page-name").textContent = context.pageName || "-"; - $("#display-name").textContent = context.displayName || "-"; - $("#page-title").textContent = context.pageTitle || "-"; - - document.querySelectorAll("[data-i18n]").forEach((node) => { - const key = node.getAttribute("data-i18n"); - node.textContent = text(key, node.textContent); - }); -} - -async function callGet() { - const result = await bridge.apiGet("echo/demo-item", { - limit: $("#limit-input").value, - tag: $("#tag-input").value, - }); - writeOutput("apiGet", result); -} - -async function callPost() { - const result = await bridge.apiPost("settings/save", { - enabled: $("#enabled-input").checked, - threshold: Number($("#threshold-input").value), - note: $("#note-input").value, - }); - writeOutput("apiPost", result); -} - -async function uploadFile() { - const file = $("#file-input").files?.[0]; - if (!file) { - writeOutput("upload", { - error: text("pages.bridge-lab.noFile", "Choose a file first."), - }); - return; - } - const result = await bridge.upload("files/import", file); - writeOutput("upload", result); -} - -async function downloadFile() { - const result = await bridge.download( - "files/export", - { format: "json" }, - "plugin-page-demo-export.json", - ); - writeOutput("download", result); -} - -async function startSse() { - if (state.sseId) { - await bridge.unsubscribeSSE(state.sseId); - state.sseId = null; - } - eventList.replaceChildren(); - state.sseId = await bridge.subscribeSSE( - "events", - { - onOpen() { - appendEvent("open", {}); - }, - onMessage(event) { - appendEvent("message", { - raw: event.raw, - parsed: event.parsed, - lastEventId: event.lastEventId, - }); - }, - onError() { - appendEvent("error", {}); - }, - }, - { count: 5, delay_ms: 400 }, - ); - writeOutput("subscribeSSE", { subscriptionId: state.sseId }); -} - -async function stopSse() { - if (!state.sseId) { - return; - } - const closedId = state.sseId; - await bridge.unsubscribeSSE(closedId); - state.sseId = null; - appendEvent("closed", { subscriptionId: closedId }); - writeOutput("unsubscribeSSE", { - subscriptionId: closedId, - message: text("pages.bridge-lab.sseClosed", "SSE closed"), - }); -} - -async function callError() { - try { - await bridge.apiGet("error"); - } catch (error) { - writeOutput("error", { - message: error.message, - }); - } -} - -function appendEvent(type, value) { - const item = document.createElement("li"); - const code = document.createElement("code"); - code.textContent = JSON.stringify({ type, value }); - item.append(code); - eventList.append(item); -} - -function bindActions() { - $("#refresh-context").addEventListener("click", () => { - renderContext(); - writeOutput("getContext", { - message: text("pages.bridge-lab.contextChanged", "Context updated"), - context: bridge.getContext(), - }); - }); - $("#call-get").addEventListener("click", () => void callGet()); - $("#call-post").addEventListener("click", () => void callPost()); - $("#upload-file").addEventListener("click", () => void uploadFile()); - $("#download-file").addEventListener("click", () => void downloadFile()); - $("#start-sse").addEventListener("click", () => void startSse()); - $("#stop-sse").addEventListener("click", () => void stopSse()); - $("#call-error").addEventListener("click", () => void callError()); -} - -window.addEventListener("beforeunload", () => { - if (state.sseId) { - void bridge.unsubscribeSSE(state.sseId); - } -}); - -await bridge.ready(); -bindActions(); -renderContext(); -bridge.onContext(() => { - renderContext(); - writeOutput("onContext", { - message: text("pages.bridge-lab.contextChanged", "Context updated"), - }); -}); -writeOutput("ready", { - message: text("pages.bridge-lab.ready", "bridge.ready() completed"), -}); diff --git a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html deleted file mode 100644 index af3688833f..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/index.html +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - Bridge Lab - - - -
    - - -
    -
    -
    -

    Context

    - -
    -
    -
    -
    pluginName
    -
    -
    -
    -
    -
    pageName
    -
    -
    -
    -
    -
    displayName
    -
    -
    -
    -
    -
    pageTitle
    -
    -
    -
    -
    -
    - -
    -
    -

    Backend Requests

    -
    -
    - - - - - -
    -
    - - - -
    -
    - -
    -
    -

    Bridge APIs

    -
    -
    - - Choose a file before testing upload(). -
    -
    - - -
    -
    - -
    -
    -

    SSE Events

    -
    -
    - - -
    -
      -
      -
      - -
      -
      -

      Output

      -
      -
      {}
      -
      -
      - - - - diff --git a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css b/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css deleted file mode 100644 index c8902e797e..0000000000 --- a/docs/examples/astrbot_plugin_page_demo/pages/bridge-lab/style.css +++ /dev/null @@ -1,257 +0,0 @@ -:root { - color-scheme: light; - --bg: #f7f8fb; - --panel: #ffffff; - --text: #172033; - --muted: #5e6b80; - --line: #dce2ea; - --accent: #246bfe; - --accent-contrast: #ffffff; - --danger: #b42318; - --shadow: 0 10px 24px rgba(23, 32, 51, 0.08); -} - -[data-theme="dark"] { - color-scheme: dark; - --bg: #111827; - --panel: #1f2937; - --text: #f3f6fb; - --muted: #b7c0ce; - --line: #344154; - --accent: #7cc4ff; - --accent-contrast: #0e1726; - --danger: #ff9b91; - --shadow: 0 10px 24px rgba(0, 0, 0, 0.24); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 320px; - background: var(--bg); - color: var(--text); - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - sans-serif; - letter-spacing: 0; -} - -button, -input { - font: inherit; -} - -button { - min-height: 36px; - border: 1px solid var(--accent); - border-radius: 6px; - padding: 0 12px; - background: var(--accent); - color: var(--accent-contrast); - cursor: pointer; -} - -button:hover { - filter: brightness(1.05); -} - -input { - width: 100%; - min-height: 36px; - border: 1px solid var(--line); - border-radius: 6px; - padding: 0 10px; - background: var(--panel); - color: var(--text); -} - -input[type="checkbox"] { - width: 18px; - min-height: 18px; -} - -.page-shell { - width: min(1180px, 100%); - margin: 0 auto; - padding: 24px; -} - -.page-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; - margin-bottom: 18px; -} - -.page-header h1 { - margin: 0; - font-size: 30px; - line-height: 1.2; -} - -.page-header p { - margin: 8px 0 0; - color: var(--muted); -} - -.status-strip { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.status-strip span { - min-height: 30px; - border: 1px solid var(--line); - border-radius: 999px; - padding: 5px 10px; - background: var(--panel); - color: var(--muted); - white-space: nowrap; -} - -.grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; -} - -.panel, -.output-panel { - border: 1px solid var(--line); - border-radius: 8px; - padding: 16px; - background: var(--panel); - box-shadow: var(--shadow); -} - -.output-panel { - margin-top: 16px; -} - -.panel-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 14px; -} - -.panel-title h2 { - margin: 0; - font-size: 18px; - line-height: 1.25; -} - -.context-list { - display: grid; - gap: 10px; - margin: 0; -} - -.context-list div { - display: grid; - grid-template-columns: 120px minmax(0, 1fr); - gap: 12px; -} - -.context-list dt { - color: var(--muted); -} - -.context-list dd { - margin: 0; - overflow-wrap: anywhere; -} - -.control-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.control-grid label { - display: grid; - gap: 6px; - color: var(--muted); -} - -.control-grid .wide { - grid-column: 1 / -1; -} - -.button-row, -.file-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - margin-top: 14px; -} - -.file-row input { - max-width: 280px; -} - -.file-row span { - color: var(--muted); -} - -.event-list { - display: grid; - max-height: 180px; - margin: 0; - padding-left: 22px; - overflow: auto; - gap: 8px; -} - -pre, -code { - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; -} - -pre { - min-height: 220px; - max-height: 420px; - margin: 0; - overflow: auto; - border: 1px solid var(--line); - border-radius: 8px; - padding: 14px; - background: color-mix(in srgb, var(--panel), var(--bg) 52%); - color: var(--text); - white-space: pre-wrap; -} - -@media (max-width: 760px) { - .page-shell { - padding: 16px; - } - - .page-header, - .panel-title { - flex-direction: column; - align-items: stretch; - } - - .status-strip { - justify-content: flex-start; - } - - .grid, - .control-grid { - grid-template-columns: 1fr; - } - - .context-list div { - grid-template-columns: 1fr; - gap: 4px; - } -} diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md index 8a4fcbd903..e8af5f724e 100644 --- a/docs/zh/dev/star/guides/plugin-pages.md +++ b/docs/zh/dev/star/guides/plugin-pages.md @@ -26,8 +26,6 @@ astrbot_plugin_page_demo/ 用户可以在 WebUI 的插件页点击插件卡片进入插件详情页,然后打开插件声明的 Pages。 -完整可运行示例见 [`docs/examples/astrbot_plugin_page_demo`](../../../../examples/astrbot_plugin_page_demo/)。 - ## 开发流程 1. 在插件目录下创建 `pages//index.html`。 From 7803be25650c340b921439a339d7c2f55c8fa369 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 14 Jun 2026 14:51:54 +0800 Subject: [PATCH 29/29] fix: authenticate plugin page SSE bridge --- astrbot/dashboard/plugin_page_bridge.js | 1 + dashboard/src/views/PluginPagePage.vue | 178 ++++++++++++++++++++---- docs/en/dev/star/guides/plugin-pages.md | 3 +- docs/zh/dev/star/guides/plugin-pages.md | 3 +- 4 files changed, 156 insertions(+), 29 deletions(-) diff --git a/astrbot/dashboard/plugin_page_bridge.js b/astrbot/dashboard/plugin_page_bridge.js index a2383c6911..f1ecc198d3 100644 --- a/astrbot/dashboard/plugin_page_bridge.js +++ b/astrbot/dashboard/plugin_page_bridge.js @@ -186,6 +186,7 @@ handlers.onMessage({ raw: message.data, parsed: parseMaybeJson(message.data), + eventType: message.eventType || "message", lastEventId: message.lastEventId, }); } diff --git a/dashboard/src/views/PluginPagePage.vue b/dashboard/src/views/PluginPagePage.vue index d3284d0dfd..2621d7acf2 100644 --- a/dashboard/src/views/PluginPagePage.vue +++ b/dashboard/src/views/PluginPagePage.vue @@ -3,6 +3,7 @@ import axios from "axios"; import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue"; import { useRoute } from "vue-router"; import { pluginApi } from "@/api/v1"; +import { fetchWithAuth } from "@/api/http"; import { useModuleI18n } from "@/i18n/composables"; import { usePluginI18n } from "@/utils/pluginI18n"; import { useCustomizerStore } from "@/stores/customizer"; @@ -49,8 +50,8 @@ const toPostMessageData = (value, fallback = null) => { }; const cleanupSSEConnections = () => { - for (const eventSource of sseConnections.values()) { - eventSource.close(); + for (const connection of sseConnections.values()) { + connection.close(); } sseConnections.clear(); }; @@ -228,13 +229,131 @@ const getBridgeErrorMessage = (error, fallback) => { }; const closeSSEConnection = (subscriptionId) => { - const eventSource = sseConnections.get(subscriptionId); - if (eventSource) { - eventSource.close(); + const connection = sseConnections.get(subscriptionId); + if (connection) { + connection.close(); sseConnections.delete(subscriptionId); } }; +const getFetchErrorMessage = async (response, fallback) => { + let text = ""; + try { + text = await response.text(); + } catch { + return fallback; + } + if (!text) { + return fallback; + } + try { + const parsed = JSON.parse(text); + if (typeof parsed?.message === "string" && parsed.message) { + return parsed.message; + } + if (typeof parsed?.error === "string" && parsed.error) { + return parsed.error; + } + } catch { + return text; + } + return text; +}; + +const parseSSEBlock = (block, previousLastEventId) => { + let eventType = "message"; + let lastEventId = previousLastEventId; + const dataLines = []; + + for (const rawLine of block.split("\n")) { + if (!rawLine || rawLine.startsWith(":")) { + continue; + } + + const colonIndex = rawLine.indexOf(":"); + const field = colonIndex === -1 ? rawLine : rawLine.slice(0, colonIndex); + let value = colonIndex === -1 ? "" : rawLine.slice(colonIndex + 1); + if (value.startsWith(" ")) { + value = value.slice(1); + } + + if (field === "event") { + eventType = value || "message"; + } else if (field === "data") { + dataLines.push(value); + } else if (field === "id" && !value.includes("\0")) { + lastEventId = value; + } + } + + return { + eventType, + lastEventId, + data: dataLines.join("\n"), + hasData: dataLines.length > 0, + }; +}; + +const readSSEStream = async (subscriptionId, response, abortController) => { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEventId = ""; + + const dispatchBufferedEvents = (flush = false) => { + const normalized = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const blocks = normalized.split("\n\n"); + const completeBlocks = flush ? blocks : blocks.slice(0, -1); + buffer = flush ? "" : blocks[blocks.length - 1] || ""; + + for (const block of completeBlocks) { + if (!sseConnections.has(subscriptionId)) { + return; + } + const event = parseSSEBlock(block, lastEventId); + lastEventId = event.lastEventId; + if (!event.hasData) { + continue; + } + postToIframe({ + kind: "sse_message", + subscriptionId, + data: event.data, + eventType: event.eventType, + lastEventId, + }); + } + }; + + try { + while (!abortController.signal.aborted) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + dispatchBufferedEvents(); + } + + buffer += decoder.decode(); + if (buffer.trim()) { + dispatchBufferedEvents(true); + } + + if (!abortController.signal.aborted) { + postToIframe({ kind: "sse_state", subscriptionId, state: "closed" }); + } + } catch { + if (!abortController.signal.aborted) { + postToIframe({ kind: "sse_state", subscriptionId, state: "error" }); + } + } finally { + if (sseConnections.get(subscriptionId)?.abortController === abortController) { + sseConnections.delete(subscriptionId); + } + } +}; + const sendIframeContext = () => { if (!plugin.value || !page.value) { return; @@ -350,30 +469,35 @@ const handleBridgeRequest = async (message) => { Object.entries(message.params || {}).forEach(([key, value]) => { url.searchParams.set(key, String(value)); }); - const eventSource = new EventSource(url.toString(), { - withCredentials: true, - }); - sseConnections.set(subscriptionId, eventSource); - eventSource.onopen = () => { - postToIframe({ kind: "sse_state", subscriptionId, state: "open" }); - }; - eventSource.onmessage = (event) => { - postToIframe({ - kind: "sse_message", - subscriptionId, - data: event.data, - lastEventId: event.lastEventId, - }); - }; - eventSource.onerror = () => { - if (eventSource.readyState === EventSource.CLOSED) { - closeSSEConnection(subscriptionId); - postToIframe({ kind: "sse_state", subscriptionId, state: "closed" }); - return; - } - postToIframe({ kind: "sse_state", subscriptionId, state: "error" }); + const abortController = new AbortController(); + const connection = { + abortController, + close() { + abortController.abort(); + }, }; + sseConnections.set(subscriptionId, connection); + + const response = await fetchWithAuth(url.toString(), { + headers: { Accept: "text/event-stream" }, + signal: abortController.signal, + }); + if (!response.ok) { + const errorMessage = await getFetchErrorMessage( + response, + `Plugin SSE request failed with status ${response.status}.`, + ); + closeSSEConnection(subscriptionId); + throw new Error(errorMessage); + } + if (!response.body) { + closeSSEConnection(subscriptionId); + throw new Error("Plugin SSE response body is not readable."); + } + sendBridgeResponse(requestId, true, { subscriptionId }); + postToIframe({ kind: "sse_state", subscriptionId, state: "open" }); + void readSSEStream(subscriptionId, response, abortController); return; } diff --git a/docs/en/dev/star/guides/plugin-pages.md b/docs/en/dev/star/guides/plugin-pages.md index 5160ec3a23..22ce6d6efb 100644 --- a/docs/en/dev/star/guides/plugin-pages.md +++ b/docs/en/dev/star/guides/plugin-pages.md @@ -511,7 +511,7 @@ const subscriptionId = await bridge.subscribeSSE( ); ``` -`event.raw` is the raw string. If the message is a JSON string, `event.parsed` is parsed automatically; otherwise it equals the raw string. +`event.raw` is the raw string. If the message is a JSON string, `event.parsed` is parsed automatically; otherwise it equals the raw string. `event.eventType` matches the SSE `event:` field and defaults to `message`. The backend must return `text/event-stream`: @@ -649,3 +649,4 @@ Backend handlers must still validate input. Do not trust paths, filenames, forma - Query or JSON is empty: pass GET values through `apiGet(endpoint, params)` and POST JSON through `apiPost(endpoint, body)`. - Upload is empty: `upload()` always uses the field name `file`; read it with `(await request.files()).get("file")`. - SSE has no messages: make sure the backend response is `text/event-stream` and each message ends with a blank line, such as `data: ...\n\n`. +- SSE returns 401: do not call `new EventSource("/api/v1/...")` directly from the Page. Native `EventSource` cannot send the `Authorization` header; call through `bridge.subscribeSSE()` instead. diff --git a/docs/zh/dev/star/guides/plugin-pages.md b/docs/zh/dev/star/guides/plugin-pages.md index e8af5f724e..72b6690860 100644 --- a/docs/zh/dev/star/guides/plugin-pages.md +++ b/docs/zh/dev/star/guides/plugin-pages.md @@ -511,7 +511,7 @@ const subscriptionId = await bridge.subscribeSSE( ); ``` -`event.raw` 是原始字符串;如果内容是 JSON 字符串,`event.parsed` 会自动解析,否则等于原始字符串。 +`event.raw` 是原始字符串;如果内容是 JSON 字符串,`event.parsed` 会自动解析,否则等于原始字符串。`event.eventType` 对应 SSE 的 `event:` 字段,未设置时为 `message`。 后端必须返回 `text/event-stream`: @@ -649,3 +649,4 @@ Page 不能直接访问 Dashboard cookies、LocalStorage 或父页面 DOM,也 - query 或 JSON 为空:GET 参数放到 `apiGet(endpoint, params)`,POST JSON 放到 `apiPost(endpoint, body)`。 - 文件上传为空:`upload()` 字段名固定为 `file`,后端用 `(await request.files()).get("file")` 读取。 - SSE 没消息:确认后端响应是 `text/event-stream`,每条消息以空行结尾,例如 `data: ...\n\n`。 +- SSE 401:不要在 Page 中直接 `new EventSource("/api/v1/...")`,原生 `EventSource` 不能携带 `Authorization` header;请通过 `bridge.subscribeSSE()` 调用。