From 8ea47c87e5f8e21580860d851bdd0c780753e631 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 8 Jun 2026 10:23:17 +0800 Subject: [PATCH 1/9] 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 | 582 +- 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 | 492 ++ 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 | 732 ++ .../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 | 1552 +++++ 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 +- .../extension/componentPanel/index.vue | 7 +- 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 +- 199 files changed, 36797 insertions(+), 15784 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 17a7dbcbb3..677e8e33a5 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -3,11 +3,11 @@ import time from typing import cast -import quart from botpy import BotAPI, BotHttp, BotWebSocket, Client, ConnectionSession, Token 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[:]: @@ -31,7 +31,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, @@ -92,15 +92,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 31436ebf2e..ee25994c50 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 convert_audio_to_wav 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 8b646e43f3..5d05e75c14 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 convert_audio_to_wav 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 157b4d75bf..4041913e33 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 -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), @@ -58,514 +24,80 @@ def __init__( "/tools/mcp/sync-provider": ("POST", self.sync_provider), } self.register_routes() - self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools - - 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 - - async def get_mcp_servers(self): - 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"] = [] + @staticmethod + def _ok(data: dict | list | None = None, message: str | None = None) -> dict: + return Response().ok(data, message).__dict__ - servers.append(server_info) + @staticmethod + def _error(message: str) -> dict: + return Response().error(message).__dict__ - 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__ + @staticmethod + async def _json_body() -> dict: + data = await request.get_json() + return data if isinstance(data, dict) else {} - async def add_mcp_server(self): + async def _run(self, operation, *, message: str | None = None) -> dict: 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__ + 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 - config["mcpServers"][name] = server_config + async def get_mcp_servers(self): + return await self._run(self.service.get_mcp_servers) - 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__ + async def add_mcp_server(self): + 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, - } - 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) 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 7bc91be8ded0638f7fd566ab028cca5b2b7e2859 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 15:58:39 +0800 Subject: [PATCH 4/9] 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 1c22506409d2e7a3aa29f5e787c9e866008cd98d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 17:10:59 +0800 Subject: [PATCH 5/9] 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 | 3327 ---------------- .../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 | 126 +- docs/scripts/update_openapi_json.py | 60 + 9 files changed, 6973 insertions(+), 3756 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 55b51d478e..0000000000 --- a/dashboard/src/api/generated/openapi-v1.ts +++ /dev/null @@ -1,3327 +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 ToolPermissionPatch { - permission: "admin" | "member"; -} - -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 SetToolPermissionPath { - tool_id: string; -} - -export interface SetToolPermissionArgs { - path: SetToolPermissionPath; - body: ToolPermissionPatch; -} - -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); - }, - setToolPermission(args: SetToolPermissionArgs, config?: AxiosRequestConfig) { - return request("PATCH", "/tools/{tool_id}/permission", 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 e1d185c428..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( @@ -1078,7 +1117,7 @@ export const pluginApi = { return typed( openApiV1.uploadPluginConfigFilesById({ query: { plugin_id: pluginId, config_key: configKey }, - body: formData, + body: generatedFormData(formData), }), ); }, @@ -1121,7 +1160,9 @@ export const pluginApi = { }, installUpload(formData: FormData) { return typed( - openApiV1.installPluginFromUpload({ body: formData }), + openApiV1.installPluginFromUpload({ + body: generatedFormData(formData), + }), ); }, installGithub(body: OpenConfig) { @@ -1222,7 +1263,7 @@ export const knowledgeApi = { return typed( openApiV1.uploadKnowledgeDocument({ path: { kb_id: kbId }, - body: formData, + body: generatedFormData(formData), }), ); }, @@ -1287,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( @@ -1302,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( @@ -1448,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) { @@ -1493,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 66d2320e30d642cdbcb24faa3ec2eb9a004a0ed5 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 17:25:10 +0800 Subject: [PATCH 6/9] 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 62a3b6590a6f162481890bc5cbabf67a7bc056be Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 17:34:46 +0800 Subject: [PATCH 7/9] 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 43966445abb154dfbb16e097a94efb2eb9e62fb8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 18:15:13 +0800 Subject: [PATCH 8/9] 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 dc4796059a..f71de53f2b 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, @@ -325,7 +325,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), @@ -333,7 +333,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), @@ -344,7 +344,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), @@ -359,7 +359,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), @@ -367,7 +367,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), @@ -376,7 +376,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), @@ -386,7 +386,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), @@ -396,7 +396,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), @@ -416,7 +416,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 76b440714a..5600595291 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -1748,6 +1748,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 ee83b2e9a74cd7b7613d157dc73d5b2187d1fcec Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 9 Jun 2026 18:26:08 +0800 Subject: [PATCH 9/9] 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