From 754144ad99a2d685f68ec3fe66de24368b380061 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:51:34 +0800 Subject: [PATCH 001/109] feat: add fallback chat model chain in tool loop runner (#5109) * feat: implement fallback provider support for chat models and update configuration * feat: enhance provider selection display with count and chips for selected providers * feat: update fallback chat providers to use provider settings and add warning for non-list fallback models --- .../agent/runners/tool_loop_agent_runner.py | 94 +++++++++- astrbot/core/astr_main_agent.py | 38 ++++ astrbot/core/config/default.py | 16 +- .../components/shared/ConfigItemRenderer.vue | 8 + .../components/shared/ProviderSelector.vue | 166 +++++++++++++++++- .../src/i18n/locales/en-US/core/shared.json | 3 +- .../en-US/features/config-metadata.json | 4 + .../src/i18n/locales/zh-CN/core/shared.json | 3 +- .../zh-CN/features/config-metadata.json | 8 +- tests/test_tool_loop_agent_runner.py | 73 ++++++++ 10 files changed, 394 insertions(+), 19 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index d6aed6dfa..8309e6674 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -91,6 +91,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): custom_token_counter: TokenCounter | None = None, custom_compressor: ContextCompressor | None = None, tool_schema_mode: str | None = "full", + fallback_providers: list[Provider] | None = None, **kwargs: T.Any, ) -> None: self.req = request @@ -120,6 +121,17 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self.context_manager = ContextManager(self.context_config) self.provider = provider + self.fallback_providers: list[Provider] = [] + seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))} + for fallback_provider in fallback_providers or []: + fallback_id = str(fallback_provider.provider_config.get("id", "")) + if fallback_provider is provider: + continue + if fallback_id and fallback_id in seen_provider_ids: + continue + self.fallback_providers.append(fallback_provider) + if fallback_id: + seen_provider_ids.add(fallback_id) self.final_llm_resp = None self._state = AgentState.IDLE self.tool_executor = tool_executor @@ -166,16 +178,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self.stats = AgentStats() self.stats.start_time = time.time() - async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: + async def _iter_llm_responses( + self, *, include_model: bool = True + ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" payload = { "contexts": self.run_context.messages, # list[Message] "func_tool": self.req.func_tool, - "model": self.req.model, # NOTE: in fact, this arg is None in most cases "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] } - + if include_model: + # For primary provider we keep explicit model selection if provided. + payload["model"] = self.req.model if self.streaming: stream = self.provider.text_chat_stream(**payload) async for resp in stream: # type: ignore @@ -183,6 +198,77 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): else: yield await self.provider.text_chat(**payload) + async def _iter_llm_responses_with_fallback( + self, + ) -> T.AsyncGenerator[LLMResponse, None]: + """Wrap _iter_llm_responses with provider fallback handling.""" + candidates = [self.provider, *self.fallback_providers] + total_candidates = len(candidates) + last_exception: Exception | None = None + last_err_response: LLMResponse | None = None + + for idx, candidate in enumerate(candidates): + candidate_id = candidate.provider_config.get("id", "") + is_last_candidate = idx == total_candidates - 1 + if idx > 0: + logger.warning( + "Switched from %s to fallback chat provider: %s", + self.provider.provider_config.get("id", ""), + candidate_id, + ) + self.provider = candidate + has_stream_output = False + try: + async for resp in self._iter_llm_responses(include_model=idx == 0): + if resp.is_chunk: + has_stream_output = True + yield resp + continue + + if ( + resp.role == "err" + and not has_stream_output + and (not is_last_candidate) + ): + last_err_response = resp + logger.warning( + "Chat Model %s returns error response, trying fallback to next provider.", + candidate_id, + ) + break + + yield resp + return + + if has_stream_output: + return + except Exception as exc: # noqa: BLE001 + last_exception = exc + logger.warning( + "Chat Model %s request error: %s", + candidate_id, + exc, + exc_info=True, + ) + continue + + if last_err_response: + yield last_err_response + return + if last_exception: + yield LLMResponse( + role="err", + completion_text=( + "All chat models failed: " + f"{type(last_exception).__name__}: {last_exception}" + ), + ) + return + yield LLMResponse( + role="err", + completion_text="All available chat models are unavailable.", + ) + def _simple_print_message_role(self, tag: str = ""): roles = [] for message in self.run_context.messages: @@ -215,7 +301,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) self._simple_print_message_role("[AftCompact]") - async for llm_response in self._iter_llm_responses(): + async for llm_response in self._iter_llm_responses_with_fallback(): if llm_response.is_chunk: # update ttft if self.stats.time_to_first_token == 0: diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 12c4fde1d..7883dca8f 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -870,6 +870,41 @@ def _get_compress_provider( return provider +def _get_fallback_chat_providers( + provider: Provider, plugin_context: Context, provider_settings: dict +) -> list[Provider]: + fallback_ids = provider_settings.get("fallback_chat_models", []) + if not isinstance(fallback_ids, list): + logger.warning( + "fallback_chat_models setting is not a list, skip fallback providers." + ) + return [] + + provider_id = str(provider.provider_config.get("id", "")) + seen_provider_ids: set[str] = {provider_id} if provider_id else set() + fallbacks: list[Provider] = [] + + for fallback_id in fallback_ids: + if not isinstance(fallback_id, str) or not fallback_id: + continue + if fallback_id in seen_provider_ids: + continue + fallback_provider = plugin_context.get_provider_by_id(fallback_id) + if fallback_provider is None: + logger.warning("Fallback chat provider `%s` not found, skip.", fallback_id) + continue + if not isinstance(fallback_provider, Provider): + logger.warning( + "Fallback chat provider `%s` is invalid type: %s, skip.", + fallback_id, + type(fallback_provider), + ) + continue + fallbacks.append(fallback_provider) + seen_provider_ids.add(fallback_id) + return fallbacks + + async def build_main_agent( *, event: AstrMessageEvent, @@ -1093,6 +1128,9 @@ async def build_main_agent( truncate_turns=config.dequeue_context_length, enforce_max_turns=config.max_context_length, tool_schema_mode=config.tool_schema_mode, + fallback_providers=_get_fallback_chat_providers( + provider, plugin_context, config.provider_settings + ), ) if apply_reset: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 235915c59..43d9991bd 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -68,6 +68,7 @@ DEFAULT_CONFIG = { "provider_settings": { "enable": True, "default_provider_id": "", + "fallback_chat_models": [], "default_image_caption_provider_id": "", "image_caption_prompt": "Please describe the image using Chinese.", "provider_pool": ["*"], # "*" 表示使用所有可用的提供者 @@ -2207,6 +2208,10 @@ CONFIG_METADATA_2 = { "default_provider_id": { "type": "string", }, + "fallback_chat_models": { + "type": "list", + "items": {"type": "string"}, + }, "wake_prefix": { "type": "string", }, @@ -2504,15 +2509,22 @@ CONFIG_METADATA_3 = { }, "ai": { "description": "模型", - "hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。", + "hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。", "type": "object", "items": { "provider_settings.default_provider_id": { - "description": "默认聊天模型", + "description": "默认对话模型", "type": "string", "_special": "select_provider", "hint": "留空时使用第一个模型", }, + "provider_settings.fallback_chat_models": { + "description": "回退对话模型列表", + "type": "list", + "items": {"type": "string"}, + "_special": "select_providers", + "hint": "主聊天模型请求失败时,按顺序切换到这些模型。", + }, "provider_settings.default_image_caption_provider_id": { "description": "默认图片转述模型", "type": "string", diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 5f2341ee7..3c3262064 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -10,6 +10,14 @@ + - {{ getSourceDisplayName(source) }} + {{ getSourceDisplayName(source) }} {{ source.api_base || 'N/A' }} @@ -112,4 +177,4 @@ export default { margin-top: 16px; } } - \ No newline at end of file + diff --git a/dashboard/src/components/shared/AstrBotConfigV4.vue b/dashboard/src/components/shared/AstrBotConfigV4.vue index 15339980a..b4ab52f8c 100644 --- a/dashboard/src/components/shared/AstrBotConfigV4.vue +++ b/dashboard/src/components/shared/AstrBotConfigV4.vue @@ -19,6 +19,10 @@ const props = defineProps({ metadataKey: { type: String, required: true + }, + searchKeyword: { + type: String, + default: '' } }) @@ -124,16 +128,27 @@ function saveEditedContent() { } function shouldShowItem(itemMeta, itemKey) { - if (!itemMeta?.condition) { - return true - } - for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) { - const actualValue = getValueBySelector(props.iterable, conditionKey) - if (actualValue !== expectedValue) { - return false + if (itemMeta?.condition) { + for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) { + const actualValue = getValueBySelector(props.iterable, conditionKey) + if (actualValue !== expectedValue) { + return false + } } } - return true + + const keyword = String(props.searchKeyword || '').trim().toLowerCase() + if (!keyword) { + return true + } + + const searchableText = [ + itemKey, + translateIfKey(itemMeta?.description || ''), + translateIfKey(itemMeta?.hint || '') + ].join(' ').toLowerCase() + + return searchableText.includes(keyword) } // 检查最外层的 object 是否应该显示 @@ -148,7 +163,10 @@ function shouldShowSection() { return false } } - return true + + const sectionItems = props.metadata?.[props.metadataKey]?.items || {} + const hasVisibleItems = Object.entries(sectionItems).some(([itemKey, itemMeta]) => shouldShowItem(itemMeta, itemKey)) + return hasVisibleItems } function hasVisibleItemsAfter(items, currentIndex) { @@ -436,9 +454,13 @@ function getSpecialSubtype(value) { } .property-info, - .type-indicator, + .type-indicator { + padding: 4px 8px; + } + .config-input { - padding: 4px; + padding-left: 24px; + padding-right: 24px; } } diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 4a766ad3d..5f3af4135 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -69,6 +69,10 @@ "normalConfig": "Basic", "systemConfig": "System" }, + "search": { + "placeholder": "Search config items (key/description/hint)", + "noResult": "No matching config items found" + }, "configManagement": { "title": "Configuration Management", "description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 6ab2292f5..39564a717 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -69,6 +69,10 @@ "normalConfig": "普通", "systemConfig": "系统" }, + "search": { + "placeholder": "搜索配置项(字段名/描述/提示)", + "noResult": "未找到匹配的配置项" + }, "configManagement": { "title": "配置文件管理", "description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。", diff --git a/dashboard/src/views/ConfigPage.vue b/dashboard/src/views/ConfigPage.vue index 88ba3d5f1..f667b7ab1 100644 --- a/dashboard/src/views/ConfigPage.vue +++ b/dashboard/src/views/ConfigPage.vue @@ -4,13 +4,24 @@
-
-
- + +
@@ -34,6 +45,7 @@ @@ -290,6 +302,7 @@ export default { // 配置类型切换 configType: 'normal', // 'normal' 或 'system' + configSearchKeyword: '', // 系统配置开关 isSystemConfig: false, @@ -702,6 +715,21 @@ export default { .config-panel { width: 100%; } + + .config-toolbar { + padding-right: 0 !important; + } + + .config-toolbar-controls { + width: 100%; + flex-wrap: wrap; + } + + .config-select, + .config-search-input { + width: 100%; + min-width: 0 !important; + } } /* 测试聊天抽屉样式 */ From 80c22f4f7241452bb3d7624af0e62f13ff35a066 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 17 Feb 2026 21:01:24 +0800 Subject: [PATCH 028/109] feat: add FAQ link to vertical sidebar and update navigation for localization --- .../src/i18n/locales/en-US/core/navigation.json | 1 + .../src/i18n/locales/zh-CN/core/navigation.json | 1 + .../full/vertical-sidebar/VerticalSidebar.vue | 13 ++++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 9526729db..5cd5af13a 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -28,6 +28,7 @@ "settings": "Settings", "changelog": "Changelog", "documentation": "Documentation", + "faq": "FAQ", "github": "GitHub", "drag": "Drag", "groups": { diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index 4713265de..02370bcce 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -28,6 +28,7 @@ "settings": "设置", "changelog": "更新日志", "documentation": "官方文档", + "faq": "FAQ", "github": "GitHub", "drag": "拖拽", "groups": { diff --git a/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue b/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue index bb879d9c5..8dcb2b3e3 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue +++ b/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue @@ -7,7 +7,7 @@ import NavItem from './NavItem.vue'; import { applySidebarCustomization } from '@/utils/sidebarCustomization'; import ChangelogDialog from '@/components/shared/ChangelogDialog.vue'; -const { t } = useI18n(); +const { t, locale } = useI18n(); const customizer = useCustomizerStore(); const sidebarMenu = shallowRef(sidebarItems); @@ -109,6 +109,13 @@ function openIframeLink(url) { } } +function openFaqLink() { + const faqUrl = locale.value === 'en-US' + ? 'https://docs.astrbot.app/en/faq.html' + : 'https://docs.astrbot.app/faq.html'; + openIframeLink(faqUrl); +} + let offsetX = 0; let offsetY = 0; let isDragging = false; @@ -264,6 +271,10 @@ function openChangelogDialog() { @click="toggleIframe"> {{ t('core.navigation.documentation') }} + + {{ t('core.navigation.faq') }} + {{ t('core.navigation.github') }} From 92d71fffe9cf5900692fa7b8a24cfde39da69729 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 17 Feb 2026 21:15:12 +0800 Subject: [PATCH 029/109] feat: add announcement section to WelcomePage and localize announcement title --- .../i18n/locales/en-US/features/welcome.json | 3 + .../i18n/locales/zh-CN/features/welcome.json | 3 + dashboard/src/views/WelcomePage.vue | 69 ++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/dashboard/src/i18n/locales/en-US/features/welcome.json b/dashboard/src/i18n/locales/en-US/features/welcome.json index 676e0f07f..670d0a66d 100644 --- a/dashboard/src/i18n/locales/en-US/features/welcome.json +++ b/dashboard/src/i18n/locales/en-US/features/welcome.json @@ -6,6 +6,9 @@ "newYear": "Happy New Year!" }, "subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.", + "announcement": { + "title": "Announcement" + }, "onboard": { "title": "Quick Onboarding", "subtitle": "Complete initialization directly on the welcome page.", diff --git a/dashboard/src/i18n/locales/zh-CN/features/welcome.json b/dashboard/src/i18n/locales/zh-CN/features/welcome.json index 188445379..1eb23d7ca 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/welcome.json +++ b/dashboard/src/i18n/locales/zh-CN/features/welcome.json @@ -6,6 +6,9 @@ "newYear": "新年快乐!" }, "subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。", + "announcement": { + "title": "公告" + }, "onboard": { "title": "快速引导", "subtitle": "欢迎页可直接完成初始化。", diff --git a/dashboard/src/views/WelcomePage.vue b/dashboard/src/views/WelcomePage.vue index 8d52131b1..5cabdc66a 100644 --- a/dashboard/src/views/WelcomePage.vue +++ b/dashboard/src/views/WelcomePage.vue @@ -116,6 +116,21 @@ + + + + +
+ {{ tm('announcement.title') }} +
+ +
+
+
('pending'); const providerStepState = ref('pending'); +const welcomeAnnouncementRaw = ref(null); + +function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) { + if (typeof raw === 'string') { + return raw.trim(); + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return ''; + } + + const localeMap = raw as Record; + const normalized = currentLocale.replace('-', '_'); + const preferredKeys = + normalized.startsWith('zh') + ? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en'] + : [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh']; + + for (const key of preferredKeys) { + const value = localeMap[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + + return ''; +} + +const welcomeAnnouncement = computed(() => + resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value) +); +const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0); const springFestivalDates: Record = { 2025: '01-29', @@ -285,7 +336,19 @@ async function syncDefaultConfigProviderIfNeeded() { showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId })); } +async function loadWelcomeAnnouncement() { + try { + const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement'); + welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null; + } catch (e) { + welcomeAnnouncementRaw.value = null; + console.error(e); + } +} + onMounted(async () => { + await loadWelcomeAnnouncement(); + try { await loadPlatformConfigBase(); if ((platformConfigData.value.platform || []).length > 0) { @@ -363,4 +426,8 @@ watch(showProviderDialog, async (visible, wasVisible) => { .welcome-card { border-radius: 16px; } + +.welcome-announcement-markdown { + line-height: 1.7; +} From 9b0e24ec4995b1d1f2b9487db86752dea57f3368 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 17 Feb 2026 21:19:53 +0800 Subject: [PATCH 030/109] chore: bump version to 4.17.4 --- astrbot/cli/__init__.py | 2 +- astrbot/core/computer/tools/python.py | 2 +- astrbot/core/computer/tools/shell.py | 2 +- astrbot/core/config/default.py | 2 +- changelogs/v4.17.4.md | 32 +++++++++++++++++++++++++++ desktop/package.json | 2 +- pyproject.toml | 2 +- 7 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 changelogs/v4.17.4.md diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 10b26dff5..bbdf42b26 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.17.3" +__version__ = "4.17.4" diff --git a/astrbot/core/computer/tools/python.py b/astrbot/core/computer/tools/python.py index 9c4768320..dc3ab4b7f 100644 --- a/astrbot/core/computer/tools/python.py +++ b/astrbot/core/computer/tools/python.py @@ -90,7 +90,7 @@ class LocalPythonTool(FunctionTool): if context.context.event.role != "admin": return ( "error: Permission denied. Local Python execution is only allowed for admin users. " - "Tell user to set admins in AstrBot WebUI by adding their user ID to the admins list if they need this feature." + "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature." f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." ) sb = get_local_booter() diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index e63124d2d..8c2331bd4 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -49,7 +49,7 @@ class ExecuteShellTool(FunctionTool): if context.context.event.role != "admin": return ( "error: Permission denied. Local shell execution is only allowed for admin users. " - "Tell user to set admins in AstrBot WebUI by adding their user ID to the admins list if they need this feature." + "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature." f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." ) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8a21f58ac..8602250cf 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.17.3" +VERSION = "4.17.4" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") WEBHOOK_SUPPORTED_PLATFORMS = [ diff --git a/changelogs/v4.17.4.md b/changelogs/v4.17.4.md new file mode 100644 index 000000000..667b03060 --- /dev/null +++ b/changelogs/v4.17.4.md @@ -0,0 +1,32 @@ +## What's Changed + +### 新增 +- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。 +- 支持在 WebUI 搜索配置 + +### 修复 +- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。 + +### 优化 +- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。 +- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。 +- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。 +- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。 +- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。 + +## What's Changed (EN) + +### New Features +- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)). +- Added an announcement section to the Welcome page, with localized announcement title support. +- Added an FAQ link to the vertical sidebar and updated navigation for localization. + +### Fixes +- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)). +- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity. + +### Improvements +- Enhanced the provider sources panel with a refined menu style and better mobile support. +- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)). +- Added an unsaved-changes notice on the configuration page to reduce accidental config loss. +- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)). diff --git a/desktop/package.json b/desktop/package.json index 3e04f21b1..6487c2878 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "astrbot-desktop", - "version": "4.17.3", + "version": "4.17.4", "description": "AstrBot desktop wrapper", "private": true, "main": "main.js", diff --git a/pyproject.toml b/pyproject.toml index d8d751b8e..7420582c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.17.3" +version = "4.17.4" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" requires-python = ">=3.12" From 3476afce41c94fed440b1f139d06fec22c607f56 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:35:52 +0800 Subject: [PATCH 031/109] feat: supports send markdown message in qqofficial (#5173) * feat: supports send markdown message in qqofficial closes: #1093 #918 #4180 #4264 * ruff format --- .../qqofficial/qqofficial_message_event.py | 86 +++++++++++++++---- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 1af4de49b..b49acd4f8 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -7,13 +7,14 @@ from typing import cast import aiofiles import botpy +import botpy.errors import botpy.message import botpy.types import botpy.types.message from botpy import Client from botpy.http import Route from botpy.types import message -from botpy.types.message import Media +from botpy.types.message import MarkdownPayload, Media from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -25,6 +26,8 @@ from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk class QQOfficialMessageEvent(AstrMessageEvent): + MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" + def __init__( self, message_str: str, @@ -114,7 +117,9 @@ class QQOfficialMessageEvent(AstrMessageEvent): return None payload: dict = { - "content": plain_text, + # "content": plain_text, + "markdown": MarkdownPayload(content=plain_text) if plain_text else None, + "msg_type": 2, "msg_id": self.message_obj.message_id, } @@ -145,9 +150,13 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) payload["media"] = media payload["msg_type"] = 7 - ret = await self.bot.api.post_group_message( - group_openid=source.group_openid, - **payload, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_group_message( + group_openid=source.group_openid, # type: ignore + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) case botpy.message.C2CMessage(): @@ -168,30 +177,49 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["media"] = media payload["msg_type"] = 7 if stream: - ret = await self.post_c2c_message( - openid=source.author.user_openid, - **payload, - stream=stream, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.author.user_openid, + **retry_payload, + stream=stream, + ), + payload=payload, + plain_text=plain_text, ) else: - ret = await self.post_c2c_message( - openid=source.author.user_openid, - **payload, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.author.user_openid, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) logger.debug(f"Message sent to C2C: {ret}") case botpy.message.Message(): if image_path: payload["file_image"] = image_path - ret = await self.bot.api.post_message( - channel_id=source.channel_id, - **payload, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_message( + channel_id=source.channel_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) case botpy.message.DirectMessage(): if image_path: payload["file_image"] = image_path - ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload) + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_dms( + guild_id=source.guild_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, + ) case _: pass @@ -202,6 +230,32 @@ class QQOfficialMessageEvent(AstrMessageEvent): return ret + async def _send_with_markdown_fallback( + self, + send_func, + payload: dict, + plain_text: str, + ): + try: + return await send_func(payload) + except botpy.errors.ServerError as err: + if ( + self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) + or not payload.get("markdown") + or not plain_text + ): + raise + + logger.warning( + "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" + ) + fallback_payload = payload.copy() + fallback_payload["markdown"] = None + fallback_payload["content"] = plain_text + if fallback_payload.get("msg_type") == 2: + fallback_payload["msg_type"] = 0 + return await send_func(fallback_payload) + async def upload_group_and_c2c_image( self, image_base64: str, From 3ca8dd204f02799d546966dea1bbc53310b9116e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=82=E5=A3=B9?= <137363396+KBVsent@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:29:35 +0900 Subject: [PATCH 032/109] fix: prevent duplicate error message when all LLM providers fail (#5183) --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 8309e6674..9f80dae1c 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -357,6 +357,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ), ), ) + return if not llm_resp.tools_call_name: # 如果没有工具调用,转换到完成状态 From 8cb26d886fdf1b6e901dfb84c75f878f76b94598 Mon Sep 17 00:00:00 2001 From: SnowNightt <127504703+SnowNightt@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:33:18 +0800 Subject: [PATCH 033/109] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E7=9B=B4=E6=8E=A5=E5=85=B3=E9=97=AD=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=9A=84=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8D=E6=AD=A3=E7=A1=AE=20(#5174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/views/ConfigPage.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/src/views/ConfigPage.vue b/dashboard/src/views/ConfigPage.vue index f667b7ab1..fee392554 100644 --- a/dashboard/src/views/ConfigPage.vue +++ b/dashboard/src/views/ConfigPage.vue @@ -503,6 +503,7 @@ export default { // 重置选择到之前的值 this.$nextTick(() => { this.selectedConfigID = this.selectedConfigInfo.id || 'default'; + this.getConfig(this.selectedConfigID); }); } else { this.getConfig(value); From b8d24994755ae92eaaf8679876518ba0d9628811 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:29:04 +0800 Subject: [PATCH 034/109] feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190) * feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage * feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events --- .../components/extension/MarketPluginCard.vue | 277 ++++++++++++ .../locales/en-US/features/extension.json | 7 +- .../locales/zh-CN/features/extension.json | 9 +- dashboard/src/views/ExtensionPage.vue | 395 +++++------------- 4 files changed, 385 insertions(+), 303 deletions(-) create mode 100644 dashboard/src/components/extension/MarketPluginCard.vue diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue new file mode 100644 index 000000000..03425553d --- /dev/null +++ b/dashboard/src/components/extension/MarketPluginCard.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 52d03827b..c184b7cf2 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -38,7 +38,8 @@ "selectFile": "Select File", "refresh": "Refresh", "updateAll": "Update All", - "deleteSource": "Delete Source" + "deleteSource": "Delete Source", + "reshuffle": "Shuffle Again" }, "status": { "enabled": "Enabled", @@ -103,7 +104,9 @@ "sourceUpdated": "Source updated successfully", "defaultOfficialSource": "Default Official Source", "sourceExists": "This source already exists", - "installPlugin": "Install Plugin" + "installPlugin": "Install Plugin", + "randomPlugins": "🎲 Random Plugins", + "sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use." }, "sort": { "default": "Default", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 5f838789e..88ca6fc8b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -3,7 +3,7 @@ "subtitle": "管理和配置系统插件", "tabs": { "installedPlugins": "AstrBot 插件", - "market": "AstrBot 插件市场", + "market": "AstrBot 插件市场", "installedMcpServers": "MCP", "skills": "Skills", "handlersOperation": "管理行为" @@ -38,7 +38,8 @@ "selectFile": "选择文件", "refresh": "刷新", "updateAll": "更新全部插件", - "deleteSource": "删除源" + "deleteSource": "删除源", + "reshuffle": "随机一发" }, "status": { "enabled": "启用", @@ -103,7 +104,9 @@ "sourceUpdated": "插件源更新成功", "defaultOfficialSource": "默认官方源", "sourceExists": "该插件源已存在", - "installPlugin": "安装插件" + "installPlugin": "安装插件", + "randomPlugins": "🎲 随机插件", + "sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。" }, "sort": { "default": "默认排序", diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index fb6055d6a..e24053fd0 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -7,6 +7,7 @@ import ProxySelector from "@/components/shared/ProxySelector.vue"; import UninstallConfirmDialog from "@/components/shared/UninstallConfirmDialog.vue"; import McpServersSection from "@/components/extension/McpServersSection.vue"; import SkillsSection from "@/components/extension/SkillsSection.vue"; +import MarketPluginCard from "@/components/extension/MarketPluginCard.vue"; import ComponentPanel from "@/components/extension/componentPanel/index.vue"; import axios from "axios"; import { pinyin } from "pinyin-pro"; @@ -175,6 +176,7 @@ const debouncedMarketSearch = ref(""); const refreshingMarket = ref(false); const sortBy = ref("default"); // default, stars, author, updated const sortOrder = ref("desc"); // desc (降序) or asc (升序) +const randomPluginNames = ref([]); // 插件市场拼音搜索 const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim(); @@ -310,8 +312,42 @@ const sortedPlugins = computed(() => { return plugins; }); +const RANDOM_PLUGINS_COUNT = 6; + +const randomPlugins = computed(() => { + const allPlugins = pluginMarketData.value; + if (allPlugins.length === 0) return []; + + const pluginsByName = new Map(allPlugins.map((plugin) => [plugin.name, plugin])); + const selected = randomPluginNames.value + .map((name) => pluginsByName.get(name)) + .filter(Boolean); + + if (selected.length > 0) { + return selected; + } + + return allPlugins.slice(0, Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length)); +}); + +const shufflePlugins = (plugins) => { + const shuffled = [...plugins]; + for (let i = shuffled.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +}; + +const refreshRandomPlugins = () => { + const shuffled = shufflePlugins(pluginMarketData.value); + randomPluginNames.value = shuffled + .slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length)) + .map((plugin) => plugin.name); +}; + // 分页计算属性 -const displayItemsPerPage = 9; // 固定每页显示6个卡片(2行) +const displayItemsPerPage = 9; // 固定每页显示9个卡片(3行) const totalPages = computed(() => { return Math.ceil(sortedPlugins.value.length / displayItemsPerPage); @@ -1037,6 +1073,7 @@ const refreshPluginMarket = async () => { trimExtensionName(); checkAlreadyInstalled(); checkUpdate(); + refreshRandomPlugins(); currentPage.value = 1; // 重置到第一页 toast(tm("messages.refreshSuccess"), "success"); @@ -1085,6 +1122,7 @@ onMounted(async () => { trimExtensionName(); checkAlreadyInstalled(); checkUpdate(); + refreshRandomPlugins(); } catch (err) { toast(tm("messages.getMarketDataFailed") + " " + err, "error"); } @@ -1788,17 +1826,21 @@ watch(activeTab, (newTab) => { -
- -
+
+ mdi-alert-outline + {{ tm("market.sourceSafetyWarning") }} +
+
@@ -1883,6 +1925,42 @@ watch(activeTab, (newTab) => {
+
+

+ {{ tm("market.randomPlugins") }} +

+ + {{ tm("buttons.reshuffle") }} + +
+ + + + + + +
- { }} -
- + - - - - 🥳 推荐 - - - -
- -
- -
- -
- - {{ - plugin.display_name?.length - ? plugin.display_name - : showPluginFullName - ? plugin.name - : plugin.trimmedName - }} - -
- - -
- - - {{ plugin.author }} - - - {{ plugin.author }} - -
- - {{ plugin.version }} -
-
- - -
- {{ plugin.desc }} -
- - -
-
- - {{ plugin.stars }} -
-
- - {{ - new Date(plugin.updated_at).toLocaleString() - }} -
-
-
-
- - - - - {{ tag === "danger" ? tm("tags.danger") : tag }} - - - - - - - {{ tag === "danger" ? tm("tags.danger") : tag }} - - - - - - - - {{ tm("buttons.viewRepo") }} - - - {{ tm("buttons.install") }} - - - ✓ {{ tm("status.installed") }} - - -
+
-
{ background-color: #f5f5f5; } -.plugin-description { - color: rgba(var(--v-theme-on-surface), 0.6); - line-height: 1.3; - margin-bottom: 6px; - flex: 1; - overflow-y: hidden; -} - -.plugin-card:hover .plugin-description { - overflow-y: auto; -} - -.plugin-description::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.plugin-description::-webkit-scrollbar-track { - background: transparent; -} - -.plugin-description::-webkit-scrollbar-thumb { - background-color: rgba(var(--v-theme-primary-rgb), 0.4); - border-radius: 4px; - border: 2px solid transparent; - background-clip: content-box; -} - -.plugin-description::-webkit-scrollbar-thumb:hover { - background-color: rgba(var(--v-theme-primary-rgb), 0.6); -} - .fab-button { transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); From 3f4d7255a009ceb8123c45528a75372abc4c3969 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 18 Feb 2026 18:11:13 +0800 Subject: [PATCH 035/109] feat: supports aihubmix --- astrbot/core/config/default.py | 12 ++++++++++++ astrbot/core/provider/manager.py | 6 ++++++ .../provider/sources/oai_aihubmix_source.py | 17 +++++++++++++++++ dashboard/src/utils/providerUtils.js | 1 + 4 files changed, 36 insertions(+) create mode 100644 astrbot/core/provider/sources/oai_aihubmix_source.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8602250cf..21758149a 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1029,6 +1029,18 @@ CONFIG_METADATA_2 = { "proxy": "", "custom_headers": {}, }, + "AIHubMix": { + "id": "aihubmix", + "provider": "aihubmix", + "type": "aihubmix_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://aihubmix.com/v1", + "proxy": "", + "custom_headers": {}, + }, "NVIDIA": { "id": "nvidia", "provider": "nvidia", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index ff0bb303d..38700d5e8 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -295,6 +295,12 @@ class ProviderManager: from .sources.zhipu_source import ProviderZhipu as ProviderZhipu case "groq_chat_completion": from .sources.groq_source import ProviderGroq as ProviderGroq + case "xai_chat_completion": + from .sources.xai_source import ProviderXAI as ProviderXAI + case "aihubmix_chat_completion": + from .sources.oai_aihubmix_source import ( + ProviderAIHubMix as ProviderAIHubMix, + ) case "anthropic_chat_completion": from .sources.anthropic_source import ( ProviderAnthropic as ProviderAnthropic, diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py new file mode 100644 index 000000000..1572f6ecd --- /dev/null +++ b/astrbot/core/provider/sources/oai_aihubmix_source.py @@ -0,0 +1,17 @@ +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + + +@register_provider_adapter( + "aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter" +) +class ProviderAIHubMix(ProviderOpenAIOfficial): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + # Reference to: https://aihubmix.com/appstore + # Use this code can enjoy 10% off prices for AIHubMix API calls. + self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore diff --git a/dashboard/src/utils/providerUtils.js b/dashboard/src/utils/providerUtils.js index 93a3ad547..594f68e36 100644 --- a/dashboard/src/utils/providerUtils.js +++ b/dashboard/src/utils/providerUtils.js @@ -33,6 +33,7 @@ export function getProviderIcon(type) { 'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg', 'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg', 'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg', + 'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg', "tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png", "compshare": "https://compshare.cn/favicon.ico" }; From ae0a9cb591efffddb832be69adb8920bc9ce87db Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:20:08 +0800 Subject: [PATCH 036/109] docs: update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 494a43833..fb6e13f1a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ paru -S astrbot-git - DeepSeek - Ollama (本地部署) - LM Studio (本地部署) +- [AIHubMix](https://aihubmix.com/?aff=4bfH) - [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [小马算力](https://www.tokenpony.cn/3YPyf) From 0c0f8bf484f903860e11cdc3f53eb61b22abd1d0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 18 Feb 2026 18:21:57 +0800 Subject: [PATCH 037/109] chore: ruff format --- astrbot/core/provider/sources/oai_aihubmix_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py index 1572f6ecd..ca8ad5959 100644 --- a/astrbot/core/provider/sources/oai_aihubmix_source.py +++ b/astrbot/core/provider/sources/oai_aihubmix_source.py @@ -14,4 +14,4 @@ class ProviderAIHubMix(ProviderOpenAIOfficial): super().__init__(provider_config, provider_settings) # Reference to: https://aihubmix.com/appstore # Use this code can enjoy 10% off prices for AIHubMix API calls. - self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore + self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore From 567390e27c9343510abf79060345b78cff9ba91d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 18 Feb 2026 21:35:27 +0800 Subject: [PATCH 038/109] feat: add LINE support to multiple language README files --- README.md | 5 +-- README_en.md | 2 +- README_fr.md | 2 +- README_ja.md | 2 +- README_ru.md | 3 +- README_zh-TW.md | 3 +- heihe.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 heihe.md diff --git a/README.md b/README.md index fb6e13f1a..bc2c9e26d 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ paru -S astrbot-git **官方维护** -- QQ (官方平台 & OneBot) +- QQ +- OneBot v11 协议实现 - Telegram - 企微应用 & 企微智能机器人 - 微信客服 & 微信公众号 @@ -162,10 +163,10 @@ paru -S astrbot-git - 钉钉 - Slack - Discord +- LINE - Satori - Misskey - Whatsapp (将支持) -- LINE (将支持) **社区维护** diff --git a/README_en.md b/README_en.md index c0f2536d3..d6950c33b 100644 --- a/README_en.md +++ b/README_en.md @@ -172,8 +172,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA - Discord - Satori - Misskey +- LINE - WhatsApp (Coming Soon) -- LINE (Coming Soon) **Community Maintained** diff --git a/README_fr.md b/README_fr.md index ab1d2a1d1..35b47daf5 100644 --- a/README_fr.md +++ b/README_fr.md @@ -168,8 +168,8 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - WhatsApp (Bientôt disponible) -- LINE (Bientôt disponible) **Maintenues par la communauté** diff --git a/README_ja.md b/README_ja.md index 21023b536..50e9fb7c6 100644 --- a/README_ja.md +++ b/README_ja.md @@ -168,8 +168,8 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - WhatsApp (近日対応予定) -- LINE (近日対応予定) **コミュニティメンテナンス** diff --git a/README_ru.md b/README_ru.md index 2ed768103..204b6c881 100644 --- a/README_ru.md +++ b/README_ru.md @@ -158,8 +158,9 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - WhatsApp (Скоро) -- LINE (Скоро) + **Поддерживаемые сообществом** diff --git a/README_zh-TW.md b/README_zh-TW.md index 7232d8cc7..1d4d7b43c 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -158,8 +158,9 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - Whatsapp(即將支援) -- LINE(即將支援) + **社群維護** diff --git a/heihe.md b/heihe.md new file mode 100644 index 000000000..8489c1e93 --- /dev/null +++ b/heihe.md @@ -0,0 +1,93 @@ +# 黑盒语音机器人帮助文档 +codex resume 019c57d5-3b44-7a50-a514-1b1b3f0a4448 +## Docs +- [教程](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5031038m0.md): +- [开发者服务协议](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5083727m0.md): +- [使用交流](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/4778396m0.md): +- [更新日志](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029501m0.md): +- [开发计划](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029504m0.md): +- [基础框架须知](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7279187m0.md): +- 资源 [请求速率限制](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5192003m0.md): +- 资源 [Websocket](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5029558m0.md): +- 资源 [Bot命令](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5030757m0.md): +- HTTP接口 > 消息接口 [发送消息接口的参数](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5112305m0.md): +- HTTP接口 > 消息接口 [发送消息接口的返回值](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5156437m0.md): +- HTTP接口 > 消息接口 [发送图片形式的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5088949m0.md): +- HTTP接口 > 消息接口 [发送Markdown文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5324071m0.md): +- HTTP接口 > 消息接口 [更新指定频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274453m0.md): +- HTTP接口 > 消息接口 [删除指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274471m0.md): +- HTTP接口 > 消息接口 [对某条频道消息增加/取消回应(小表情)](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274495m0.md): +- HTTP接口 > 消息接口 [发送卡片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5430115m0.md): +- HTTP接口 > 消息接口 [给用户发送私聊消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5722305m0.md): +- HTTP接口 > 媒体文件上传 [上传媒体文件的参数解析](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5156807m0.md): +- HTTP接口 > 房间角色接口 [权限相关说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/4781009m0.md): +- HTTP接口 > 房间角色接口 [接口说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5274618m0.md): +- HTTP接口 > 房间表情 [房间表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5252750m0.md): +- HTTP接口 > 房间接口 [房间相关接口文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5650569m0.md): +- HTTP接口 > 在线媒体流 [在线媒体流说明文档](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7020148m0.md): +- HTTP接口 > OAuth [OAuth使用说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/7145802m0.md): +- 服务端推送事件 [事件说明](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5243813m0.md): +- 服务端推送事件 [通用推送字段](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5254214m0.md): +- 服务端推送事件 > 机器人命令 [用户使用Bot命令](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5116164m0.md): +- 服务端推送事件 > 频道消息事件 [频道消息事件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5243816m0.md): +- 服务端推送事件 > 房间消息事件 [房间消息事件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/5254078m0.md): +- 自定义卡片消息 [自定义卡片编辑器](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997428m0.md): +- 自定义卡片消息 > 物料组件 [卡片](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997517m0.md): +- 自定义卡片消息 > 物料组件 [文本](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997518m0.md): +- 自定义卡片消息 > 物料组件 [标题](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997729m0.md): +- 自定义卡片消息 > 物料组件 [图片](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997730m0.md): +- 自定义卡片消息 > 物料组件 [按钮组](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997731m0.md): +- 自定义卡片消息 > 物料组件 [分割线](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997733m0.md): +- 自定义卡片消息 > 物料组件 [倒计时](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/6997735m0.md): + +## API Docs +- WEBSOCKET 连接请求 [连接到黑盒语音服务](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/3545540w0.md): +- HTTP接口 > 消息接口 [发送频道图片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196181766e0.md): +- HTTP接口 > 消息接口 [发送频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195916005e0.md): +- HTTP接口 > 消息接口 [发送卡片消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/231244234e0.md): +- HTTP接口 > 消息接口 [发送频道消息@全体成员/@在线成员](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196225350e0.md): +- HTTP接口 > 消息接口 [更新指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221115476e0.md): +- HTTP接口 > 消息接口 [删除指定的频道消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221117785e0.md): +- HTTP接口 > 消息接口 [对某条频道消息增加/取消回应(小表情)](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220985915e0.md): +- HTTP接口 > 消息接口 [给用户发送私聊消息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/247164510e0.md): +- HTTP接口 > 媒体文件上传 [上传媒体文件](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/196172729e0.md): +- HTTP接口 > 房间角色接口 [获取房间角色列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220721816e0.md): +- HTTP接口 > 房间角色接口 [创建角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220860098e0.md): +- HTTP接口 > 房间角色接口 [更新角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220893910e0.md): +- HTTP接口 > 房间角色接口 [删除角色](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/220864876e0.md): +- HTTP接口 > 房间角色接口 [对指定用户授予指定权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195925401e0.md): +- HTTP接口 > 房间角色接口 [对指定用户剥夺指定权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/195927164e0.md): +- HTTP接口 > 房间表情 [获取房间上传的表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221092473e0.md): +- HTTP接口 > 房间表情 [房间删除表情包](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221112168e0.md): +- HTTP接口 > 房间表情 [房间更新表情包名称](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/221346019e0.md): +- HTTP接口 > 房间接口 [修改房间内昵称](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373089e0.md): +- HTTP接口 > 房间接口 [分页获取加入的房间列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373523e0.md): +- HTTP接口 > 房间接口 [获取房间信息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373528e0.md): +- HTTP接口 > 房间接口 [退出房间](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373638e0.md): +- HTTP接口 > 房间接口 [房间踢人](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/226373709e0.md): +- HTTP接口 > 房间接口 [语音频道之间移动用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/318260744e0.md): +- HTTP接口 > 房间接口 [踢出语音频道中的用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/318266039e0.md): +- HTTP接口 > 房间接口 [禁言/解禁用户](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325086722e0.md): +- HTTP接口 > 房间接口 [频道内麦克风静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325092125e0.md): 对未静音对象调用时对其静音;对静音对象调用时解除静音 +- HTTP接口 > 房间接口 [房间内麦克风静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325104333e0.md): +- HTTP接口 > 房间接口 [房间内扬声器静音/解禁](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325105640e0.md): +- HTTP接口 > 房间接口 [获取用户所在频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325187362e0.md): bot需要在查询的房间中 +- HTTP接口 > 房间接口 [获取语音频道内在线成员列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325207647e0.md): +- HTTP接口 > 房间接口 [创建频道邀请链接](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325223584e0.md): 需要 创建邀请 权限 +- HTTP接口 > 房间接口 [频道设置修改](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325259753e0.md): 需要 编辑频道 权限 +- HTTP接口 > 房间接口 [频道名编辑](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325264530e0.md): 需要 编辑频道 权限 +- HTTP接口 > 房间接口 [设置频道密码](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325512688e0.md): +- HTTP接口 > 房间接口 [修改权限组或成员权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/325672775e0.md): # 服务器权限管理文档 +- HTTP接口 > 房间接口 [获取房间用户列表](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/326508787e0.md): +- HTTP接口 > 房间接口 [获取用户频道权限](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/339765173e0.md): +- HTTP接口 > 房间接口 [创建频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/340658298e0.md): 需要 管理频道(1<<2) 权限 +- HTTP接口 > 房间接口 [删除频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/340660409e0.md): 需要 管理频道(1<<2) 权限 +- HTTP接口 > 在线媒体流 [推流至语音频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/320947489e0.md): +- HTTP接口 > 在线媒体流 [停止推流至语音频道](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/320947513e0.md): +- HTTP接口 > OAuth [获取授权码](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329051392e0.md): 获取授权码链接示例 +- HTTP接口 > OAuth [获取AccessToken](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329070402e0.md): +- HTTP接口 > OAuth [刷新AccessToken](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329079907e0.md): +- HTTP接口 > OAuth [获取用户信息](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/329599863e0.md): +- HTTP接口 > OAuth [获取用户房间内语音时长](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/332236185e0.md): 时间跨度不能超过30天 +- HTTP接口 > OAuth [获取用户房间内语音游戏时长](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/332238065e0.md): 时间跨度不能超过30天 +- HTTP接口 > OAuth [获取用户信息-自动触发授权](https://s.apifox.cn/43256fe4-9a8c-4f22-949a-74a3f8b431f5/331602654e0.md): 在发起api请求时可以携带以下query作为参数 如果没有token且用户在线则会为用户唤起授权弹窗 \ No newline at end of file From c6289d8f754c7d1f5c0ff6669778567ee25f4ad2 Mon Sep 17 00:00:00 2001 From: sanyekana Date: Wed, 18 Feb 2026 21:38:27 +0800 Subject: [PATCH 039/109] feat(core): add plugin error hook for custom error routing (#5192) * feat(core): add plugin error hook for custom error routing * fix(core): align plugin error suppression with event stop state --- astrbot/api/event/filter/__init__.py | 2 ++ .../process_stage/method/star_request.py | 18 ++++++++++++++---- astrbot/core/star/register/__init__.py | 2 ++ astrbot/core/star/register/star_handler.py | 18 ++++++++++++++++++ astrbot/core/star/star_handler.py | 9 +++++++++ astrbot/dashboard/routes/plugin.py | 1 + 6 files changed, 46 insertions(+), 4 deletions(-) diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index 287c60b73..7354ec766 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -24,6 +24,7 @@ from astrbot.core.star.register import ( register_on_llm_tool_respond as on_llm_tool_respond, ) from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded +from astrbot.core.star.register import register_on_plugin_error as on_plugin_error from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool from astrbot.core.star.register import ( register_on_waiting_llm_request as on_waiting_llm_request, @@ -52,6 +53,7 @@ __all__ = [ "on_decorating_result", "on_llm_request", "on_llm_response", + "on_plugin_error", "on_platform_loaded", "on_waiting_llm_request", "permission_type", diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 8a79b96c9..9422d6317 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -8,9 +8,9 @@ from astrbot.core import logger from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star import star_map -from astrbot.core.star.star_handler import StarHandlerMetadata +from astrbot.core.star.star_handler import EventType, StarHandlerMetadata -from ...context import PipelineContext, call_handler +from ...context import PipelineContext, call_event_hook, call_handler from ..stage import Stage @@ -48,10 +48,20 @@ class StarRequestSubStage(Stage): yield ret event.clear_result() # 清除上一个 handler 的结果 except Exception as e: - logger.error(traceback.format_exc()) + traceback_text = traceback.format_exc() + logger.error(traceback_text) logger.error(f"Star {handler.handler_full_name} handle error: {e}") - if event.is_at_or_wake_command: + await call_event_hook( + event, + EventType.OnPluginErrorEvent, + md.name, + handler.handler_name, + e, + traceback_text, + ) + + if not event.is_stopped() and event.is_at_or_wake_command: ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}" event.set_result(MessageEventResult().message(ret)) yield diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 4856ffe50..f1daf2968 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -13,6 +13,7 @@ from .star_handler import ( register_on_llm_response, register_on_llm_tool_respond, register_on_platform_loaded, + register_on_plugin_error, register_on_using_llm_tool, register_on_waiting_llm_request, register_permission_type, @@ -32,6 +33,7 @@ __all__ = [ "register_on_decorating_result", "register_on_llm_request", "register_on_llm_response", + "register_on_plugin_error", "register_on_platform_loaded", "register_on_waiting_llm_request", "register_permission_type", diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index dfca5a25c..c4ed0d4a7 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -339,6 +339,24 @@ def register_on_platform_loaded(**kwargs): return decorator +def register_on_plugin_error(**kwargs): + """当插件处理消息异常时触发。 + + Hook 参数: + event, plugin_name, handler_name, error, traceback_text + + 说明: + 在 hook 中调用 `event.stop_event()` 可屏蔽默认报错回显, + 并由插件自行决定是否转发到其他会话。 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs) + return awaitable + + return decorator + + def register_on_waiting_llm_request(**kwargs): """当等待调用 LLM 时的通知事件(在获取锁之前) diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index ced4d7739..63b0c447d 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -97,6 +97,14 @@ class StarHandlerRegistry(Generic[T]): plugins_name: list[str] | None = None, ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnPluginErrorEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload def get_handlers_by_event_type( self, @@ -192,6 +200,7 @@ class EventType(enum.Enum): OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具 OnLLMToolRespondEvent = enum.auto() # 调用函数工具后 OnAfterMessageSentEvent = enum.auto() # 发送消息后 + OnPluginErrorEvent = enum.auto() # 插件处理消息异常时 H = TypeVar("H", bound=Callable[..., Any]) diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index ca271cdf6..bfa4dca39 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -73,6 +73,7 @@ class PluginRoute(Route): EventType.OnDecoratingResultEvent: "回复消息前", EventType.OnCallingFuncToolEvent: "函数工具", EventType.OnAfterMessageSentEvent: "发送消息后", + EventType.OnPluginErrorEvent: "插件报错时", } self._logo_cache = {} From a2b61e2ab8af24366382c59973de567d12d45988 Mon Sep 17 00:00:00 2001 From: Chiu Chun-Hsien <95356121+911218sky@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:45:19 +0800 Subject: [PATCH 040/109] refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204) - Add _send_voice_with_fallback helper to deduplicate voice forbidden handling - Catch telegram.error.BadRequest instead of bare Exception with string matching - Add text field to Record component to preserve TTS source text - Store original text in Record during TTS conversion for use as document caption - Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings --- astrbot/core/message/components.py | 2 + .../core/pipeline/result_decorate/stage.py | 1 + .../platform/sources/telegram/tg_event.py | 78 +++++++++++++++++-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 758381ba2..a9bb09122 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -119,6 +119,8 @@ class Record(BaseMessageComponent): cache: bool | None = True proxy: bool | None = True timeout: int | None = 0 + # Original text content (e.g. TTS source text), used as caption in fallback scenarios + text: str | None = None # 额外 path: str | None diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 823aa0eaa..15d68fb22 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -315,6 +315,7 @@ class ResultDecorateStage(Stage): Record( file=url or audio_path, url=url or audio_path, + text=comp.text, ), ) if dual_output: diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index ffa58e1a8..d7e3f1678 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -6,6 +6,7 @@ from typing import Any, cast import telegramify_markdown from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram.constants import ChatAction +from telegram.error import BadRequest from telegram.ext import ExtBot from astrbot import logger @@ -119,6 +120,65 @@ class TelegramPlatformEvent(AstrMessageEvent): client, user_name, ChatAction.TYPING, message_thread_id ) + @classmethod + async def _send_voice_with_fallback( + cls, + client: ExtBot, + path: str, + payload: dict[str, Any], + *, + caption: str | None = None, + user_name: str = "", + message_thread_id: str | None = None, + use_media_action: bool = False, + ) -> None: + """Send a voice message, falling back to a document if the user's + privacy settings forbid voice messages (``BadRequest`` with + ``Voice_messages_forbidden``). + + When *use_media_action* is ``True`` the helper wraps the send calls + with ``_send_media_with_action`` (used by the streaming path). + """ + try: + if use_media_action: + await cls._send_media_with_action( + client, + ChatAction.UPLOAD_VOICE, + client.send_voice, + user_name=user_name, + message_thread_id=message_thread_id, + voice=path, + **cast(Any, payload), + ) + else: + await client.send_voice(voice=path, **cast(Any, payload)) + except BadRequest as e: + # python-telegram-bot raises BadRequest for Voice_messages_forbidden; + # distinguish the voice-privacy case via the API error message. + if "Voice_messages_forbidden" not in e.message: + raise + logger.warning( + "User privacy settings prevent receiving voice messages, falling back to sending an audio file. " + "To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'." + ) + if use_media_action: + await cls._send_media_with_action( + client, + ChatAction.UPLOAD_DOCUMENT, + client.send_document, + user_name=user_name, + message_thread_id=message_thread_id, + document=path, + caption=caption, + **cast(Any, payload), + ) + else: + await client.send_document( + document=path, + caption=caption, + **cast(Any, payload), + ) + async def _ensure_typing( self, user_name: str, @@ -211,7 +271,13 @@ class TelegramPlatformEvent(AstrMessageEvent): ) elif isinstance(i, Record): path = await i.convert_to_file_path() - await client.send_voice(voice=path, **cast(Any, payload)) + await cls._send_voice_with_fallback( + client, + path, + payload, + caption=i.text or None, + use_media_action=False, + ) async def send(self, message: MessageChain) -> None: if self.get_message_type() == MessageType.GROUP_MESSAGE: @@ -330,14 +396,14 @@ class TelegramPlatformEvent(AstrMessageEvent): continue elif isinstance(i, Record): path = await i.convert_to_file_path() - await self._send_media_with_action( + await self._send_voice_with_fallback( self.client, - ChatAction.UPLOAD_VOICE, - self.client.send_voice, + path, + payload, + caption=i.text or delta or None, user_name=user_name, message_thread_id=message_thread_id, - voice=path, - **cast(Any, payload), + use_media_action=True, ) continue else: From 20d6ff46209b9832aa8c3bedfbe23eb331d30a29 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 18 Feb 2026 22:04:43 +0800 Subject: [PATCH 041/109] chore: bump version to 4.17.5 --- astrbot/cli/__init__.py | 2 +- astrbot/core/config/default.py | 2 +- changelogs/v4.17.5.md | 37 ++++++++++++++++++++++++++++++++++ desktop/package.json | 2 +- pyproject.toml | 2 +- 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 changelogs/v4.17.5.md diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index bbdf42b26..a839e11eb 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.17.4" +__version__ = "4.17.5" diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 21758149a..536399664 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.17.4" +VERSION = "4.17.5" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") WEBHOOK_SUPPORTED_PLATFORMS = [ diff --git a/changelogs/v4.17.5.md b/changelogs/v4.17.5.md new file mode 100644 index 000000000..c01ba4ea1 --- /dev/null +++ b/changelogs/v4.17.5.md @@ -0,0 +1,37 @@ +## What's Changed + +### 新增 +- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。 +- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。 +- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。 + +### 修复 +- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。 +- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。 + +### 优化 +- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。 + +### 其他 +- 更新 README 相关文档内容。 +- 执行 `ruff format` 代码格式整理。 + +## What's Changed (EN) + +### New Features +- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)). +- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)). +- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)). +- Added support for the `aihubmix` provider. +- Added LINE support notes to multilingual README files. + +### Fixes +- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)). +- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)). + +### Improvements +- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)). + +### Others +- Updated README documentation. +- Applied `ruff format` code formatting. diff --git a/desktop/package.json b/desktop/package.json index 6487c2878..2fb2349d3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "astrbot-desktop", - "version": "4.17.4", + "version": "4.17.5", "description": "AstrBot desktop wrapper", "private": true, "main": "main.js", diff --git a/pyproject.toml b/pyproject.toml index 7420582c9..7df18d06f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.17.4" +version = "4.17.5" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" requires-python = ">=3.12" From 3b2ce9f50019a5db45793c5601a072b7017c0051 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:48:48 +0800 Subject: [PATCH 042/109] feat: add admin permission checks for Python and Shell execution (#5214) --- astrbot/core/computer/tools/python.py | 25 ++++++++++++++----- astrbot/core/computer/tools/shell.py | 23 ++++++++++++----- astrbot/core/config/default.py | 6 +++++ .../en-US/features/config-metadata.json | 4 +++ .../zh-CN/features/config-metadata.json | 4 +++ 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/astrbot/core/computer/tools/python.py b/astrbot/core/computer/tools/python.py index dc3ab4b7f..26eaba8e3 100644 --- a/astrbot/core/computer/tools/python.py +++ b/astrbot/core/computer/tools/python.py @@ -26,6 +26,21 @@ param_schema = { } +def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None: + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + require_admin = provider_settings.get("computer_use_require_admin", True) + if require_admin and context.context.event.role != "admin": + return ( + "error: Permission denied. Python execution is only allowed for admin users. " + "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature." + f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." + ) + return None + + async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult: data = result.get("data", {}) output = data.get("output", {}) @@ -66,6 +81,8 @@ class PythonTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False ) -> ToolExecResult: + if permission_error := _check_admin_permission(context): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, @@ -87,12 +104,8 @@ class LocalPythonTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False ) -> ToolExecResult: - if context.context.event.role != "admin": - return ( - "error: Permission denied. Local Python execution is only allowed for admin users. " - "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature." - f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." - ) + if permission_error := _check_admin_permission(context): + return permission_error sb = get_local_booter() try: result = await sb.python.exec(code, silent=silent) diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index 8c2331bd4..b07f98a21 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -9,6 +9,21 @@ from astrbot.core.astr_agent_context import AstrAgentContext from ..computer_client import get_booter, get_local_booter +def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None: + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + require_admin = provider_settings.get("computer_use_require_admin", True) + if require_admin and context.context.event.role != "admin": + return ( + "error: Permission denied. Shell execution is only allowed for admin users. " + "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature." + f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." + ) + return None + + @dataclass class ExecuteShellTool(FunctionTool): name: str = "astrbot_execute_shell" @@ -46,12 +61,8 @@ class ExecuteShellTool(FunctionTool): background: bool = False, env: dict = {}, ) -> ToolExecResult: - if context.context.event.role != "admin": - return ( - "error: Permission denied. Local shell execution is only allowed for admin users. " - "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature." - f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." - ) + if permission_error := _check_admin_permission(context): + return permission_error if self.is_local: sb = get_local_booter() diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 536399664..b50bcd8de 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -128,6 +128,7 @@ DEFAULT_CONFIG = { "add_cron_tools": True, }, "computer_use_runtime": "local", + "computer_use_require_admin": True, "sandbox": { "booter": "shipyard", "shipyard_endpoint": "", @@ -2737,6 +2738,11 @@ CONFIG_METADATA_3 = { "labels": ["无", "本地", "沙箱"], "hint": "选择 Computer Use 运行环境。", }, + "provider_settings.computer_use_require_admin": { + "description": "需要 AstrBot 管理员权限", + "type": "bool", + "hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。", + }, "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index c61be33ef..639be6578 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -149,6 +149,10 @@ "description": "Computer Use Runtime", "hint": "sandbox means running in a sandbox environment, local means running in a local environment, none means disabling Computer Use. If skills are uploaded, choosing none will cause them to not be usable by the Agent." }, + "computer_use_require_admin": { + "description": "Require AstrBot Admin Permission", + "hint": "When enabled, AstrBot admin permission is required to use computer capabilities. Admins can be added in Platform Config. Use the /sid command to view admin IDs." + }, "sandbox": { "booter": { "description": "Sandbox Environment Driver" diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index a51723d59..c0838b5ee 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -152,6 +152,10 @@ "description": "运行环境", "hint": "sandbox 代表在沙箱环境中运行, local 代表在本地环境中运行, none 代表不启用。如果上传了 skills,选择 none 会导致其无法被 Agent 正常使用。" }, + "computer_use_require_admin": { + "description": "需要 AstrBot 管理员权限", + "hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。" + }, "sandbox": { "booter": { "description": "沙箱环境驱动器" From bf3fa3e91898b860fa548a1a4fc92c87d6aa05ee Mon Sep 17 00:00:00 2001 From: Dream Tokenizer <60459821+Trance-0@users.noreply.github.com> Date: Thu, 19 Feb 2026 03:42:38 -0600 Subject: [PATCH 043/109] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=85=AC=E4=BC=97=E5=8F=B7=E8=A2=AB=E5=8A=A8=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=A4=84=E7=90=86=E6=9C=BA=E5=88=B6=EF=BC=8C=E5=BC=95?= =?UTF-8?q?=E5=85=A5=E7=BC=93=E5=86=B2=E4=B8=8E=E5=88=86=E7=89=87=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=20(#5224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复wechat official 被动回复功能 * ruff format --------- Co-authored-by: Soulter <905617992@qq.com> --- .../weixin_offacc_adapter.py | 215 ++++++++++++++++-- .../weixin_offacc_event.py | 38 ++-- 2 files changed, 215 insertions(+), 38 deletions(-) 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 28985f757..5020624a8 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 @@ -1,13 +1,14 @@ import asyncio import os import sys +import time import uuid from collections.abc import Awaitable, Callable from typing import Any, cast import quart from requests import Response -from wechatpy import WeChatClient, parse_message +from wechatpy import WeChatClient, create_reply, parse_message from wechatpy.crypto import WeChatCrypto from wechatpy.exceptions import InvalidSignatureException from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage @@ -38,7 +39,12 @@ else: class WeixinOfficialAccountServer: - def __init__(self, event_queue: asyncio.Queue, config: dict) -> None: + def __init__( + self, + event_queue: asyncio.Queue, + config: dict, + user_buffer: dict[Any, dict[str, Any]], + ) -> None: self.server = quart.Quart(__name__) self.port = int(cast(int | str, config.get("port"))) self.callback_server_host = config.get("callback_server_host", "0.0.0.0") @@ -62,6 +68,10 @@ class WeixinOfficialAccountServer: self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None self.shutdown_event = asyncio.Event() + self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复 + self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state + self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调 + async def verify(self): """内部服务器的 GET 验证入口""" return await self.handle_verify(quart.request) @@ -98,6 +108,22 @@ class WeixinOfficialAccountServer: """内部服务器的 POST 回调入口""" return await self.handle_callback(quart.request) + def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str: + if xml and "" not in xml and nonce and timestamp: + return self.crypto.encrypt_message(xml, nonce, timestamp) + return xml or "success" + + def _preview(self, msg: BaseMessage, limit: int = 24) -> str: + """生成消息预览文本,供占位符使用""" + if isinstance(msg, TextMessage): + t = cast(str, msg.content).strip() + return (t[:limit] + "...") if len(t) > limit else (t or "空消息") + if isinstance(msg, ImageMessage): + return "图片" + if isinstance(msg, VoiceMessage): + return "语音" + return getattr(msg, "type", "未知消息") + async def handle_callback(self, request) -> str: """处理回调请求,可被统一 webhook 入口复用 @@ -123,14 +149,152 @@ class WeixinOfficialAccountServer: raise logger.info(f"解析成功: {msg}") - if self.callback: + if not self.callback: + return "success" + + # by pass passive reply logic and return active reply directly. + if self.active_send_mode: result_xml = await self.callback(msg) if not result_xml: return "success" if isinstance(result_xml, str): return result_xml - return "success" + # passive reply + from_user = str(getattr(msg, "source", "")) + msg_id = str(cast(str | int, getattr(msg, "id", ""))) + state = self.user_buffer.get(from_user) + + def _reply_text(text: str) -> str: + reply_obj = create_reply(text, msg) + reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj) + return self._maybe_encrypt(reply_xml, nonce, timestamp) + + # if in cached state, return cached result or placeholder + if state: + logger.debug(f"用户消息缓冲状态: user={from_user} state={state}") + cached = state.get("cached_xml") + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info(f"wx buffer hit on trigger: user={from_user}") + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + return _reply_text(cached_xml) + else: + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + + task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task")) + placeholder = ( + f"【正在思考'{state.get('preview', '...')}'中,已思考" + f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】" + ) + + # same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder + if task and state.get("msg_id") == msg_id: + done, _ = await asyncio.wait( + {task}, + timeout=self._wx_msg_time_out, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + try: + cached = state.get("cached_xml") + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info( + f"wx buffer hit on retry window: user={from_user}" + ) + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + logger.debug( + f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} " + ) + return _reply_text(cached_xml) + else: + logger.debug( + f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} " + ) + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + logger.info( + f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} " + ) + return _reply_text(placeholder) + except Exception: + logger.critical( + "wx task failed in passive window", exc_info=True + ) + self.user_buffer.pop(from_user, None) + return _reply_text("处理消息失败,请稍后再试。") + + logger.info( + f"wx passive window timeout: user={from_user} msg_id={msg_id}" + ) + return _reply_text(placeholder) + + logger.debug(f"wx trigger while thinking: user={from_user}") + return _reply_text(placeholder) + + # create new trigger when state is empty, and store state in buffer + logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}") + preview = self._preview(msg) + placeholder = ( + f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】" + ) + logger.info( + f"wx start task: user={from_user} msg_id={msg_id} preview={preview}" + ) + + self.user_buffer[from_user] = state = { + "msg_id": msg_id, + "preview": preview, + "task": None, # set later after task created + "cached_xml": [], # for passive reply + "started_at": time.monotonic(), + } + self.user_buffer[from_user]["task"] = task = asyncio.create_task( + self.callback(msg) + ) + + # immediate return if done + done, _ = await asyncio.wait( + {task}, + timeout=self._wx_msg_time_out, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + try: + cached = state.get("cached_xml", None) + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info(f"wx buffer hit immediately: user={from_user}") + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + return _reply_text(cached_xml) + else: + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + logger.info( + f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} " + ) + return _reply_text(placeholder) + except Exception: + logger.critical("wx task failed in first window", exc_info=True) + self.user_buffer.pop(from_user, None) + return _reply_text("处理消息失败,请稍后再试。") + + logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}") + return _reply_text(placeholder) async def start_polling(self) -> None: logger.info( @@ -176,7 +340,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform): if not self.api_base_url.endswith("/"): self.api_base_url += "/" - self.server = WeixinOfficialAccountServer(self._event_queue, self.config) + self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state + self.server = WeixinOfficialAccountServer( + self._event_queue, self.config, self.user_buffer + ) self.client = WeChatClient( self.config["appid"].strip(), @@ -193,28 +360,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform): try: if self.active_send_mode: await self.convert_message(msg, None) + return None + + msg_id = str(cast(str | int, msg.id)) + future = self.wexin_event_workers.get(msg_id) + if future: + logger.debug(f"duplicate message id checked: {msg.id}") else: - if str(msg.id) in self.wexin_event_workers: - future = self.wexin_event_workers[str(cast(str | int, msg.id))] - logger.debug(f"duplicate message id checked: {msg.id}") - else: - future = asyncio.get_event_loop().create_future() - self.wexin_event_workers[str(cast(str | int, msg.id))] = future - await self.convert_message(msg, future) + future = asyncio.get_event_loop().create_future() + self.wexin_event_workers[msg_id] = future + await self.convert_message(msg, future) # I love shield so much! result = await asyncio.wait_for( asyncio.shield(future), - 60, - ) # wait for 60s - logger.debug(f"Got future result: {result}") - self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None) - return result # xml. see weixin_offacc_event.py + 180, + ) # wait for 180s + logger.debug(f"Got future result: {result}") + return result except asyncio.TimeoutError: - pass + logger.info(f"callback 处理消息超时: message_id={msg.id}") + return create_reply("处理消息超时,请稍后再试。", msg) except Exception as e: logger.error(f"转换消息时出现异常: {e}") + finally: + self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None) self.server.callback = callback + self.server.active_send_mode = self.active_send_mode @override async def send_by_session( @@ -336,12 +508,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform): await self.handle_msg(abm) async def handle_msg(self, message: AstrBotMessage) -> None: + buffer = self.user_buffer.get(message.sender.user_id, None) + if buffer is None: + logger.critical( + f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}" + ) + return message_event = WeixinOfficialAccountPlatformEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), session_id=message.session_id, client=self.client, + message_out=buffer, ) self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py index 816893be2..ae536593c 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py @@ -1,9 +1,9 @@ import asyncio import os -from typing import cast +from typing import Any, cast from wechatpy import WeChatClient -from wechatpy.replies import ImageReply, TextReply, VoiceReply +from wechatpy.replies import ImageReply, VoiceReply from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): platform_meta: PlatformMetadata, session_id: str, client: WeChatClient, + message_out: dict[Any, Any], ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client + self.message_out = message_out @staticmethod async def send_with_client( @@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): ) -> None: pass - async def split_plain(self, plain: str) -> list[str]: - """将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符 + async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]: + """将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符 Args: plain (str): 要分割的长文本 @@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): list[str]: 分割后的文本列表 """ - if len(plain) <= 2048: + if len(plain) <= max_length: return [plain] result = [] start = 0 while start < len(plain): - # 剩下的字符串长度<2048时结束 - if start + 2048 >= len(plain): + # 剩下的字符串长度= len(plain): result.append(plain[start:]) break # 向前搜索分割标点符号 - end = min(start + 2048, len(plain)) + end = min(start + max_length, len(plain)) cut_position = end for i in range(end, start, -1): if i < len(plain) and plain[i - 1] in [ @@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): if isinstance(comp, Plain): # Split long text messages if needed plain_chunks = await self.split_plain(comp.text) - for chunk in plain_chunks: - if active_send_mode: + if active_send_mode: + for chunk in plain_chunks: self.client.message.send_text(message_obj.sender.user_id, chunk) - else: - reply = TextReply( - content=chunk, - message=cast(dict, self.message_obj.raw_message)["message"], - ) - xml = reply.render() - future = cast(dict, self.message_obj.raw_message)["future"] - assert isinstance(future, asyncio.Future) - future.set_result(xml) - await asyncio.sleep(0.5) # Avoid sending too fast + else: + # disable passive sending, just store the chunks in + logger.debug( + f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent." + ) + self.message_out["cached_xml"] = plain_chunks elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() From 651a0645c5c0d803ddd3448cda8c1ab534828e83 Mon Sep 17 00:00:00 2001 From: NanoRocky <76585834+NanoRocky@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:47:08 +0800 Subject: [PATCH 044/109] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=85?= =?UTF-8?q?=E5=8F=91=E9=80=81=20JSON=20=E6=B6=88=E6=81=AF=E6=AE=B5?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E7=A9=BA=E6=B6=88=E6=81=AF=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E6=8A=A5=E9=94=99=20(#5208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Register_Stage · 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。 · 顺带补全其它消息类型判断。 Co-authored-by: Pizero * Fix formatting and comments in stage.py * Format stage.py --------- Co-authored-by: Pizero --- astrbot/core/pipeline/respond/stage.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index b4a7ee7fa..72e853ffc 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -33,6 +33,21 @@ class RespondStage(Stage): Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点 Comp.File: lambda comp: bool(comp.file_ or comp.url), Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情 + Comp.Json: lambda comp: bool(comp.data), # Json 卡片 + Comp.Share: lambda comp: bool(comp.url) or bool(comp.title), + Comp.Music: lambda comp: ( + (comp.id and comp._type and comp._type != "custom") + or (comp._type == "custom" and comp.url and comp.audio and comp.title) + ), # 音乐分享 + Comp.Forward: lambda comp: bool(comp.id), # 合并转发 + Comp.Location: lambda comp: bool( + comp.lat is not None and comp.lon is not None + ), # 位置 + Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群 + Comp.Shake: lambda _: True, # 窗口抖动(戳一戳) + Comp.Dice: lambda _: True, # 掷骰子魔法表情 + Comp.RPS: lambda _: True, # 猜拳魔法表情 + Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), } async def initialize(self, ctx: PipelineContext) -> None: From a4a37c268de043d552ec3db01567fb532f4f39c8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 19 Feb 2026 18:11:00 +0800 Subject: [PATCH 045/109] docs: update related repo links --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index bc2c9e26d..7fbf982fd 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,16 @@ pre-commit install - [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架 +开源项目友情链接: + +- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架 +- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架 +- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot +- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot +- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot +- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件 +- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP + ## ⭐ Star History > [!TIP] From 3597726aadc43fa5f20ddfbdee7446b10024fcab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=AA=E8=AA=9E?= <167516635+YukiRa1n@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:26:47 +0800 Subject: [PATCH 046/109] fix(core): terminate active events on reset/new/del to prevent stale responses (#5225) * fix(core): terminate active events on reset/new/del to prevent stale responses Closes #5222 * style: fix import sorting in scheduler.py --- .../builtin_commands/commands/conversation.py | 20 +++++--- astrbot/core/pipeline/scheduler.py | 15 ++++-- astrbot/core/utils/active_event_registry.py | 50 +++++++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 astrbot/core/utils/active_event_registry.py diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index eb8cfdefa..f6d5db914 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -4,6 +4,7 @@ from astrbot.api import sp, star from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.core.platform.astr_message_event import MessageSession from astrbot.core.platform.message_type import MessageType +from astrbot.core.utils.active_event_registry import active_event_registry from .utils.rst_scene import RstScene @@ -62,6 +63,7 @@ class ConversationCommands: agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(umo, exclude=message) await sp.remove_async( scope="umo", scope_id=umo, @@ -86,6 +88,8 @@ class ConversationCommands: ) return + active_event_registry.stop_all(umo, exclude=message) + await self.context.conversation_manager.update_conversation( umo, cid, @@ -221,6 +225,7 @@ class ConversationCommands: cfg = self.context.get_config(umo=message.unified_msg_origin) agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) await sp.remove_async( scope="umo", scope_id=message.unified_msg_origin, @@ -229,6 +234,7 @@ class ConversationCommands: message.set_result(MessageEventResult().message("已创建新对话。")) return + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) cpersona = await self._get_current_persona_id(message.unified_msg_origin) cid = await self.context.conversation_manager.new_conversation( message.unified_msg_origin, @@ -321,7 +327,8 @@ class ConversationCommands: async def del_conv(self, message: AstrMessageEvent) -> None: """删除当前对话""" - cfg = self.context.get_config(umo=message.unified_msg_origin) + umo = message.unified_msg_origin + cfg = self.context.get_config(umo=umo) is_unique_session = cfg["platform_settings"]["unique_session"] if message.get_group_id() and not is_unique_session and message.role != "admin": # 群聊,没开独立会话,发送人不是管理员 @@ -334,18 +341,17 @@ class ConversationCommands: agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(umo, exclude=message) await sp.remove_async( scope="umo", - scope_id=message.unified_msg_origin, + scope_id=umo, key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type], ) message.set_result(MessageEventResult().message("重置对话成功。")) return session_curr_cid = ( - await self.context.conversation_manager.get_curr_conversation_id( - message.unified_msg_origin, - ) + await self.context.conversation_manager.get_curr_conversation_id(umo) ) if not session_curr_cid: @@ -356,8 +362,10 @@ class ConversationCommands: ) return + active_event_registry.stop_all(umo, exclude=message) + await self.context.conversation_manager.delete_conversation( - message.unified_msg_origin, + umo, session_curr_cid, ) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 71c98778f..c4a65077a 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -6,6 +6,7 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import ( WecomAIBotMessageEvent, ) +from astrbot.core.utils.active_event_registry import active_event_registry from . import STAGES_ORDER from .context import PipelineContext @@ -79,10 +80,14 @@ class PipelineScheduler: event (AstrMessageEvent): 事件对象 """ - await self._process_stages(event) + active_event_registry.register(event) + try: + await self._process_stages(event) - # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 - if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): - await event.send(None) + # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 + if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): + await event.send(None) - logger.debug("pipeline 执行完毕。") + logger.debug("pipeline 执行完毕。") + finally: + active_event_registry.unregister(event) diff --git a/astrbot/core/utils/active_event_registry.py b/astrbot/core/utils/active_event_registry.py new file mode 100644 index 000000000..254859933 --- /dev/null +++ b/astrbot/core/utils/active_event_registry.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from astrbot.core.platform import AstrMessageEvent + + +class ActiveEventRegistry: + """维护 unified_msg_origin 到活跃事件的映射。 + + 用于在 reset 等场景下终止该会话正在处理的事件。 + """ + + def __init__(self) -> None: + self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set) + + def register(self, event: AstrMessageEvent) -> None: + self._events[event.unified_msg_origin].add(event) + + def unregister(self, event: AstrMessageEvent) -> None: + umo = event.unified_msg_origin + self._events[umo].discard(event) + if not self._events[umo]: + del self._events[umo] + + def stop_all( + self, + umo: str, + exclude: AstrMessageEvent | None = None, + ) -> int: + """终止指定 UMO 的所有活跃事件。 + + Args: + umo: 统一消息来源标识符。 + exclude: 需要排除的事件(通常是发起 reset 的事件本身)。 + + Returns: + 被终止的事件数量。 + """ + count = 0 + for event in list(self._events.get(umo, [])): + if event is not exclude: + event.stop_event() + count += 1 + return count + + +active_event_registry = ActiveEventRegistry() From 9c691b2266cbf0e7416d5d65fd6397aecbaa829f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <62183434+zouyonghe@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:04:18 +0900 Subject: [PATCH 047/109] chore: remove Electron desktop pipeline and switch to tauri repo (#5226) * ci: remove Electron desktop build from release pipeline * chore: remove electron desktop and switch to tauri release trigger * ci: remove desktop workflow dispatch trigger * refactor: migrate data paths to astrbot_path helpers * fix: point desktop update prompt to AstrBot-desktop releases --- .github/workflows/release.yml | 165 -- .gitignore | 7 - README.md | 4 +- README_en.md | 4 +- astrbot/core/knowledge_base/kb_db_sqlite.py | 7 +- astrbot/core/knowledge_base/kb_mgr.py | 5 +- .../sources/sensevoice_selfhosted_source.py | 6 +- astrbot/core/utils/astrbot_path.py | 4 +- astrbot/core/utils/pip_installer.py | 8 +- astrbot/core/utils/runtime_env.py | 4 +- astrbot/dashboard/routes/plugin.py | 10 +- astrbot/dashboard/utils.py | 9 +- .../full/vertical-header/VerticalHeader.vue | 10 +- desktop/README.md | 131 - desktop/assets/icon-no-shadow.svg | 1 - desktop/assets/icon.png | Bin 59198 -> 0 bytes desktop/assets/tray.png | Bin 48530 -> 0 bytes desktop/lib/backend-manager.js | 821 ------ desktop/lib/buffered-rotating-logger.js | 162 -- desktop/lib/common.js | 115 - desktop/lib/dashboard-loader.js | 30 - desktop/lib/electron-logger.js | 53 - desktop/lib/locale-service.js | 174 -- desktop/lib/rotating-log-writer.js | 178 -- desktop/lib/startup-screen.js | 116 - desktop/main.js | 420 --- desktop/package.json | 97 - desktop/pnpm-lock.yaml | 2277 ----------------- desktop/preload.js | 22 - desktop/scripts/build-backend.mjs | 86 - desktop/scripts/prepare-webui.mjs | 20 - desktop/scripts/sync-version.mjs | 66 - main.py | 2 + 33 files changed, 45 insertions(+), 4969 deletions(-) delete mode 100644 desktop/README.md delete mode 100644 desktop/assets/icon-no-shadow.svg delete mode 100644 desktop/assets/icon.png delete mode 100644 desktop/assets/tray.png delete mode 100644 desktop/lib/backend-manager.js delete mode 100644 desktop/lib/buffered-rotating-logger.js delete mode 100644 desktop/lib/common.js delete mode 100644 desktop/lib/dashboard-loader.js delete mode 100644 desktop/lib/electron-logger.js delete mode 100644 desktop/lib/locale-service.js delete mode 100644 desktop/lib/rotating-log-writer.js delete mode 100644 desktop/lib/startup-screen.js delete mode 100644 desktop/main.js delete mode 100644 desktop/package.json delete mode 100644 desktop/pnpm-lock.yaml delete mode 100644 desktop/preload.js delete mode 100644 desktop/scripts/build-backend.mjs delete mode 100644 desktop/scripts/prepare-webui.mjs delete mode 100644 desktop/scripts/sync-version.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59c229b04..8d5791ba3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,170 +102,11 @@ jobs: cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip" rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress - build-desktop: - name: Build ${{ matrix.name }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - runner: ubuntu-24.04 - os: linux - arch: amd64 - - name: linux-arm64 - runner: ubuntu-24.04-arm - os: linux - arch: arm64 - - name: windows-x64 - runner: windows-2022 - os: win - arch: amd64 - - name: windows-arm64 - runner: windows-11-arm - os: win - arch: arm64 - - name: macos-x64 - runner: macos-15-intel - os: mac - arch: amd64 - - name: macos-arm64 - runner: macos-15 - os: mac - arch: arm64 - env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} - - - name: Resolve tag - id: tag - shell: bash - run: | - if [ "${{ github.event_name }}" = "push" ]; then - tag="${GITHUB_REF_NAME}" - elif [ -n "${{ inputs.tag }}" ]; then - tag="${{ inputs.tag }}" - else - tag="$(git describe --tags --abbrev=0)" - fi - if [ -z "$tag" ]; then - echo "Failed to resolve tag." >&2 - exit 1 - fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" - - - name: Setup uv - uses: astral-sh/setup-uv@v7 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '24.13.0' - cache: "pnpm" - cache-dependency-path: | - dashboard/pnpm-lock.yaml - desktop/pnpm-lock.yaml - - - name: Prepare OpenSSL for Windows ARM64 - if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }} - shell: pwsh - run: | - git clone https://github.com/microsoft/vcpkg.git C:\vcpkg - & C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics - & C:\vcpkg\vcpkg.exe install openssl:arm64-windows - - "VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Install dependencies - shell: bash - run: | - uv sync - pnpm --dir dashboard install --frozen-lockfile - pnpm --dir desktop install --frozen-lockfile - - - name: Build desktop package - shell: bash - run: | - pnpm --dir dashboard run build - pnpm --dir desktop run build:webui - pnpm --dir desktop run build:backend - pnpm --dir desktop run sync:version - pnpm --dir desktop exec electron-builder --publish never - - - name: Normalize artifact names - shell: bash - env: - NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }} - run: | - shopt -s nullglob - out_dir="desktop/dist/release" - mkdir -p "$out_dir" - files=( - desktop/dist/*.AppImage - desktop/dist/*.dmg - desktop/dist/*.zip - desktop/dist/*.exe - ) - if [ ${#files[@]} -eq 0 ]; then - echo "No desktop artifacts found to rename." >&2 - exit 1 - fi - for src in "${files[@]}"; do - file="$(basename "$src")" - case "$file" in - *.AppImage) - dest="$out_dir/${NAME_PREFIX}.AppImage" - ;; - *.dmg) - dest="$out_dir/${NAME_PREFIX}.dmg" - ;; - *.exe) - dest="$out_dir/${NAME_PREFIX}.exe" - ;; - *.zip) - dest="$out_dir/${NAME_PREFIX}.zip" - ;; - *) - continue - ;; - esac - cp "$src" "$dest" - done - ls -la "$out_dir" - - - name: Upload desktop artifacts - uses: actions/upload-artifact@v6 - with: - name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }} - if-no-files-found: error - path: desktop/dist/release/* - publish-release: name: Publish GitHub Release runs-on: ubuntu-24.04 needs: - build-dashboard - - build-desktop steps: - name: Checkout repository uses: actions/checkout@v6 @@ -296,12 +137,6 @@ jobs: name: Dashboard-${{ steps.tag.outputs.tag }} path: release-assets - - name: Download desktop artifacts - uses: actions/download-artifact@v7 - with: - pattern: AstrBot-${{ steps.tag.outputs.tag }}-* - path: release-assets - merge-multiple: true - name: Resolve release notes id: notes diff --git a/.gitignore b/.gitignore index e060b85a6..e3ffbd473 100644 --- a/.gitignore +++ b/.gitignore @@ -33,13 +33,6 @@ tests/astrbot_plugin_openai dashboard/node_modules/ dashboard/dist/ .pnpm-store/ -desktop/node_modules/ -desktop/dist/ -desktop/out/ -desktop/resources/backend/astrbot-backend* -desktop/resources/backend/*.exe -desktop/resources/webui/* -desktop/resources/.pyinstaller/ package-lock.json yarn.lock diff --git a/README.md b/README.md index 7fbf982fd..4a0bb5338 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,9 @@ yay -S astrbot-git paru -S astrbot-git ``` -#### 桌面端 Electron 打包 +#### 桌面端(Tauri) -桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。 +桌面端已迁移为独立仓库(Tauri):[https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 ## 支持的消息平台 diff --git a/README_en.md b/README_en.md index d6950c33b..b20e806c0 100644 --- a/README_en.md +++ b/README_en.md @@ -154,9 +154,9 @@ yay -S astrbot-git paru -S astrbot-git ``` -#### Desktop Electron Build +#### Desktop (Tauri) -For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md). +Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). ## Supported Messaging Platforms diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py index ba25ed7e5..39fc72ac8 100644 --- a/astrbot/core/knowledge_base/kb_db_sqlite.py +++ b/astrbot/core/knowledge_base/kb_db_sqlite.py @@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import ( KBMedia, KnowledgeBase, ) +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path class KBSQLiteDatabase: - def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None: + def __init__(self, db_path: str | None = None) -> None: """初始化知识库数据库 Args: - db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db + db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db """ + if db_path is None: + db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db") self.db_path = db_path self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" self.inited = False diff --git a/astrbot/core/knowledge_base/kb_mgr.py b/astrbot/core/knowledge_base/kb_mgr.py index ae5a1b9e7..f26409e56 100644 --- a/astrbot/core/knowledge_base/kb_mgr.py +++ b/astrbot/core/knowledge_base/kb_mgr.py @@ -3,6 +3,7 @@ from pathlib import Path from astrbot.core import logger from astrbot.core.provider.manager import ProviderManager +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path # from .chunking.fixed_size import FixedSizeChunker from .chunking.recursive import RecursiveCharacterChunker @@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult from .retrieval.rank_fusion import RankFusion from .retrieval.sparse_retriever import SparseRetriever -FILES_PATH = "data/knowledge_base" +FILES_PATH = get_astrbot_knowledge_base_path() DB_PATH = Path(FILES_PATH) / "kb.db" """Knowledge Base storage root directory""" CHUNKER = RecursiveCharacterChunker() @@ -27,7 +28,7 @@ class KnowledgeBaseManager: self, provider_manager: ProviderManager, ) -> None: - Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + DB_PATH.parent.mkdir(parents=True, exist_ok=True) self.provider_manager = provider_manager self._session_deleted_callback_registered = False diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index 965b83a5a..af6c0f631 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -7,12 +7,14 @@ import asyncio import os import re from datetime import datetime +from pathlib import Path from typing import cast from funasr_onnx import SenseVoiceSmall from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav @@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): async def get_timestamped_path(self) -> str: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return os.path.join("data", "temp", f"{timestamp}") + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + return str(temp_dir / timestamp) async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index 063c8ddfc..987ce110a 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录 import os -from astrbot.core.utils.runtime_env import is_packaged_electron_runtime +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime def get_astrbot_path() -> str: @@ -29,7 +29,7 @@ def get_astrbot_root() -> str: """获取Astrbot根目录路径""" if path := os.environ.get("ASTRBOT_ROOT"): return os.path.realpath(path) - if is_packaged_electron_runtime(): + if is_packaged_desktop_runtime(): return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot")) return os.path.realpath(os.getcwd()) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 1c8da23c1..562a0ed30 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -12,7 +12,7 @@ import threading from collections import deque from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path -from astrbot.core.utils.runtime_env import is_packaged_electron_runtime +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime logger = logging.getLogger("astrbot") @@ -35,7 +35,7 @@ def _get_pip_main(): "pip module is unavailable " f"(sys.executable={sys.executable}, " f"frozen={getattr(sys, 'frozen', False)}, " - f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})" + f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})" ) from exc return pip_main @@ -556,7 +556,7 @@ class PipInstaller: args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url]) target_site_packages = None - if is_packaged_electron_runtime(): + if is_packaged_desktop_runtime(): target_site_packages = get_astrbot_site_packages_path() os.makedirs(target_site_packages, exist_ok=True) _prepend_sys_path(target_site_packages) @@ -582,7 +582,7 @@ class PipInstaller: def prefer_installed_dependencies(self, requirements_path: str) -> None: """优先使用已安装在插件 site-packages 中的依赖,不执行安装。""" - if not is_packaged_electron_runtime(): + if not is_packaged_desktop_runtime(): return target_site_packages = get_astrbot_site_packages_path() diff --git a/astrbot/core/utils/runtime_env.py b/astrbot/core/utils/runtime_env.py index 2eb1bc7e4..483f5bc0c 100644 --- a/astrbot/core/utils/runtime_env.py +++ b/astrbot/core/utils/runtime_env.py @@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool: return bool(getattr(sys, "frozen", False)) -def is_packaged_electron_runtime() -> bool: - return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1" +def is_packaged_desktop_runtime() -> bool: + return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1" diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index bfa4dca39..25fed7d27 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -20,7 +20,10 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType, star_handlers_registry from astrbot.core.star.star_manager import PluginManager -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.astrbot_path import ( + get_astrbot_data_path, + get_astrbot_temp_path, +) from .route import Response, Route, RouteContext @@ -196,10 +199,11 @@ class PluginRoute(Route): 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 = f"data/plugins_custom_{url_hash}.json" + cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json") # 更安全的后缀处理方式 if custom_url.endswith(".json"): @@ -209,7 +213,7 @@ class PluginRoute(Route): urls = [custom_url] else: - cache_file = "data/plugins.json" + 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", diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py index b81faad06..3a0ee5bdc 100644 --- a/astrbot/dashboard/utils.py +++ b/astrbot/dashboard/utils.py @@ -1,5 +1,4 @@ import base64 -import os import traceback from io import BytesIO @@ -51,14 +50,14 @@ async def generate_tsne_visualization( return None kb = kb_helper.kb - index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss" + index_path = kb_helper.kb_dir / "index.faiss" # 读取 FAISS 索引 - if not os.path.exists(index_path): - logger.warning(f"FAISS 索引不存在: {index_path}") + if not index_path.exists(): + logger.warning(f"FAISS 索引不存在: {index_path!s}") return None - index = faiss.read_index(index_path) + index = faiss.read_index(str(index_path)) if index.ntotal == 0: logger.warning("索引为空") diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 48cefd3cb..1bcd7f167 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -51,7 +51,8 @@ const isElectronApp = ref( const redirectConfirmDialog = ref(false); const pendingRedirectUrl = ref(''); const resolvingReleaseTarget = ref(false); -const fallbackReleaseUrl = 'https://github.com/AstrBotDevs/AstrBot/releases/latest'; +const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases'; +const fallbackReleaseUrl = desktopReleaseBaseUrl; const getSelectedGitHubProxy = () => { if (typeof window === "undefined" || !window.localStorage) return ""; @@ -128,12 +129,15 @@ function confirmExternalRedirect() { const getReleaseUrlForElectron = () => { const firstRelease = (releases.value as any[])?.[0]; - if (firstRelease?.html_url) return firstRelease.html_url as string; + if (firstRelease?.tag_name) { + const tag = firstRelease.tag_name as string; + return `${desktopReleaseBaseUrl}/tag/${tag}`; + } if (hasNewVersion.value) return fallbackReleaseUrl; const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest'; return tag === 'latest' ? fallbackReleaseUrl - : `https://github.com/AstrBotDevs/AstrBot/releases/tag/${tag}`; + : `${desktopReleaseBaseUrl}/tag/${tag}`; }; function handleUpdateClick() { diff --git a/desktop/README.md b/desktop/README.md deleted file mode 100644 index 48dcb341a..000000000 --- a/desktop/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# AstrBot Desktop (Electron) - -This document describes how to build the Electron desktop app from source. - -## What This Package Contains - -- Electron desktop shell (`desktop/main.js`) -- Bundled WebUI static files (`desktop/resources/webui`) -- App assets (`desktop/assets`) - -Current behavior: - -- Backend executable is bundled in the installer/package. -- App startup checks backend availability and auto-starts bundled backend when needed. -- Runtime data is stored under `~/.astrbot` by default, not as a full AstrBot source project. - -## Prerequisites - -- Python environment ready in repository root (`uv` available) -- Node.js available -- `pnpm` available - -Desktop dependency management uses `pnpm` with a lockfile: - -- `desktop/pnpm-lock.yaml` -- `pnpm --dir desktop install --frozen-lockfile` - -## Build From Scratch - -Run commands from repository root: - -```bash -uv sync -pnpm --dir dashboard install -pnpm --dir dashboard build -pnpm --dir desktop install --frozen-lockfile -pnpm --dir desktop run dist:full -``` - -Output files are generated under: - -- `desktop/dist/` - -## Local Run (Development) - -Start backend first: - -```bash -uv run main.py -``` - -Start Electron shell: - -```bash -pnpm --dir desktop run dev -``` - -## Notes - -- `dist:full` runs WebUI build + backend build + Electron packaging. -- In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`). -- Backend build uses `uv run --with pyinstaller ...`, so no manual `PyInstaller` install is required. - -## Runtime Directory Layout - -By default (`ASTRBOT_ROOT` not set), packaged desktop app uses this layout: - -```text -~/.astrbot/ - data/ - config/ # Main configuration - plugins/ # Installed plugins - plugin_data/ # Plugin persistent data - site-packages/ # Plugin dependency installation target in packaged mode - temp/ # Runtime temp files - skills/ # Skill-related runtime data - knowledge_base/ # Knowledge base files - backups/ # Backup data -``` - -The app does not store a full AstrBot source tree in home directory. - -## Troubleshooting - -Startup behavior: - -- Packaged app shows a local startup page first, then switches to dashboard after backend is reachable. -- If startup page never switches, check logs and timeout settings below. - -Runtime logs: - -- Electron shell log: `~/.astrbot/logs/electron.log` -- Backend stdout/stderr log: `~/.astrbot/logs/backend.log` -- Both files rotate by size by default: `20MB` per file, keep `3` backups. -- Electron log rotation envs: - - `ASTRBOT_ELECTRON_LOG_MAX_MB` - - `ASTRBOT_ELECTRON_LOG_BACKUP_COUNT` -- Backend log rotation envs: - - `ASTRBOT_BACKEND_LOG_MAX_MB` - - `ASTRBOT_BACKEND_LOG_BACKUP_COUNT` -- Rotation debug logging: - - `ASTRBOT_LOG_ROTATION_DEBUG=1` (or `NODE_ENV=development`) to print filesystem errors from rotation operations. -- On backend startup failure, the app dialog also shows the backend reason and backend log path. - -Timeout and loading controls: - -- `ASTRBOT_BACKEND_TIMEOUT_MS` controls how long Electron waits for backend reachability. -- In packaged mode, default is `0` (auto mode with a 5-minute safety cap). -- In development mode, default is `20000`. -- If backend startup times out, app shows startup failure dialog and exits. -- `ASTRBOT_DASHBOARD_TIMEOUT_MS` controls dashboard page load wait time after backend is ready (default `20000`). -- If you see `Unable to load the AstrBot dashboard.`, increase `ASTRBOT_DASHBOARD_TIMEOUT_MS`. - -Startup page locale: - -- Startup page language follows cached dashboard locale in `~/.astrbot/data/desktop_state.json`. -- Supported startup locales are `zh-CN` and `en-US`. -- Remove that file to reset locale fallback behavior. - -Backend auto-start: - -- `ASTRBOT_BACKEND_AUTO_START=0` disables Electron-managed backend startup. -- When disabled, backend must already be running at `ASTRBOT_BACKEND_URL` before launching app. - -If Electron download times out on restricted networks, configure mirrors before install: - -```bash -export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" -export ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/" -pnpm --dir desktop install --frozen-lockfile -``` diff --git a/desktop/assets/icon-no-shadow.svg b/desktop/assets/icon-no-shadow.svg deleted file mode 100644 index 4268e03e2..000000000 --- a/desktop/assets/icon-no-shadow.svg +++ /dev/null @@ -1 +0,0 @@ -
\ No newline at end of file diff --git a/desktop/assets/icon.png b/desktop/assets/icon.png deleted file mode 100644 index 512d1eaedf84835e5fff19eb8879da1ad6122ffd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59198 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 diff --git a/desktop/assets/tray.png b/desktop/assets/tray.png deleted file mode 100644 index 4fcc92ba63813e7801cd49b4130426e82ed3effe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48530 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 e6@ap!yaYv)&hcOkpBmosW0*X diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js deleted file mode 100644 index eb8958a4c..000000000 --- a/desktop/lib/backend-manager.js +++ /dev/null @@ -1,821 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { spawn, spawnSync } = require('child_process'); -const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); -const { - delay, - ensureDir, - formatLogTimestamp, - normalizeUrl, - parseLogBackupCount, - parseLogMaxBytes, - waitForProcessExit, -} = require('./common'); - -const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000; -const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000; -const BACKEND_LOG_FLUSH_INTERVAL_MS = 120; -const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024; - -function parseBackendTimeoutMs(app) { - const defaultTimeoutMs = app.isPackaged ? 0 : 20000; - const parsed = Number.parseInt( - process.env.ASTRBOT_BACKEND_TIMEOUT_MS || `${defaultTimeoutMs}`, - 10, - ); - if (Number.isFinite(parsed) && parsed >= 0) { - return parsed; - } - return defaultTimeoutMs; -} - -class BackendManager { - constructor({ app, baseDir, log, shouldSkipStart }) { - this.app = app; - this.baseDir = baseDir; - this.log = typeof log === 'function' ? log : () => {}; - this.shouldSkipStart = - typeof shouldSkipStart === 'function' ? shouldSkipStart : () => false; - - this.backendUrl = normalizeUrl( - process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/', - ); - this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0'; - this.backendTimeoutMs = parseBackendTimeoutMs(app); - this.backendLogMaxBytes = parseLogMaxBytes( - process.env.ASTRBOT_BACKEND_LOG_MAX_MB, - ); - this.backendLogBackupCount = parseLogBackupCount( - process.env.ASTRBOT_BACKEND_LOG_BACKUP_COUNT, - ); - - this.backendProcess = null; - this.backendConfig = null; - this.backendLogger = new BufferedRotatingLogger({ - logPath: null, - maxBytes: this.backendLogMaxBytes, - backupCount: this.backendLogBackupCount, - flushIntervalMs: BACKEND_LOG_FLUSH_INTERVAL_MS, - maxBufferBytes: BACKEND_LOG_MAX_BUFFER_BYTES, - }); - this.backendLastExitReason = null; - this.backendStartupFailureReason = null; - this.backendSpawning = false; - this.backendRestarting = false; - } - - getBackendUrl() { - return this.backendUrl; - } - - getBackendTimeoutMs() { - return this.backendTimeoutMs; - } - - getRootDir() { - return ( - process.env.ASTRBOT_ROOT || - this.backendConfig?.rootDir || - this.resolveBackendRoot() - ); - } - - getBackendLogPath() { - const rootDir = this.getRootDir(); - if (!rootDir) { - return null; - } - return path.join(rootDir, 'logs', 'backend.log'); - } - - getStartupFailureReason() { - return this.backendStartupFailureReason; - } - - isSpawning() { - return this.backendSpawning; - } - - isRestarting() { - return this.backendRestarting; - } - - resolveBackendRoot() { - if (!this.app.isPackaged) { - return null; - } - return path.join(os.homedir(), '.astrbot'); - } - - resolveBackendCwd() { - if (!this.app.isPackaged) { - return path.resolve(this.baseDir, '..'); - } - return this.resolveBackendRoot(); - } - - resolveWebuiDir() { - if (process.env.ASTRBOT_WEBUI_DIR) { - return process.env.ASTRBOT_WEBUI_DIR; - } - if (!this.app.isPackaged) { - return null; - } - const candidate = path.join(process.resourcesPath, 'webui'); - const indexPath = path.join(candidate, 'index.html'); - return fs.existsSync(indexPath) ? candidate : null; - } - - getPackagedBackendPath() { - if (!this.app.isPackaged) { - return null; - } - const filename = - process.platform === 'win32' ? 'astrbot-backend.exe' : 'astrbot-backend'; - const candidate = path.join(process.resourcesPath, 'backend', filename); - return fs.existsSync(candidate) ? candidate : null; - } - - buildDefaultBackendLaunch(webuiDir) { - if (this.app.isPackaged) { - const packagedBackend = this.getPackagedBackendPath(); - if (!packagedBackend) { - return null; - } - const args = []; - if (webuiDir) { - args.push('--webui-dir', webuiDir); - } - return { - cmd: packagedBackend, - args, - shell: false, - }; - } - - const args = ['run', 'main.py']; - if (webuiDir) { - args.push('--webui-dir', webuiDir); - } - return { - cmd: 'uv', - args, - shell: process.platform === 'win32', - }; - } - - resolveBackendConfig() { - const webuiDir = this.resolveWebuiDir(); - const customCmd = process.env.ASTRBOT_BACKEND_CMD; - const launch = customCmd - ? { - cmd: customCmd, - args: [], - shell: true, - } - : this.buildDefaultBackendLaunch(webuiDir); - const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); - const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); - ensureDir(cwd); - if (rootDir) { - ensureDir(rootDir); - } - this.backendConfig = { - cmd: launch ? launch.cmd : null, - args: launch ? launch.args : [], - shell: launch ? launch.shell : true, - cwd, - webuiDir, - rootDir, - }; - return this.backendConfig; - } - - getBackendConfig() { - if (!this.backendConfig) { - return this.resolveBackendConfig(); - } - return this.backendConfig; - } - - getBackendPort() { - try { - const parsed = new URL(this.backendUrl); - if (parsed.port) { - const port = Number.parseInt(parsed.port, 10); - return Number.isFinite(port) ? port : null; - } - return parsed.protocol === 'https:' ? 443 : 80; - } catch { - return null; - } - } - - canManageBackend() { - return Boolean(this.getBackendConfig().cmd); - } - - async flushLogs() { - await this.backendLogger.flush(); - } - - async pingBackend(timeoutMs = 800) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - await fetch(this.backendUrl, { - signal: controller.signal, - redirect: 'manual', - }); - return true; - } catch { - return false; - } finally { - clearTimeout(timeout); - } - } - - getEffectiveWaitMs(maxWaitMs = 0) { - if (maxWaitMs > 0) { - return maxWaitMs; - } - if (this.app.isPackaged) { - return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS; - } - return 0; - } - - async requestBackendJson(pathname, options = {}) { - const timeoutMs = options.timeoutMs || 2000; - const method = options.method || 'GET'; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const requestUrl = new URL(pathname, this.backendUrl); - requestUrl.searchParams.set('_ts', `${Date.now()}`); - - const authToken = - typeof options.authToken === 'string' && options.authToken - ? options.authToken - : null; - - try { - const response = await fetch(requestUrl.toString(), { - method, - signal: controller.signal, - redirect: 'manual', - headers: { - Accept: 'application/json', - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - ...(options.headers || {}), - }, - }); - if (!response.ok) { - return { ok: false, data: null }; - } - const data = await response.json(); - return { ok: true, data }; - } catch { - return { ok: false, data: null }; - } finally { - clearTimeout(timeout); - } - } - - async getBackendStartTime() { - const result = await this.requestBackendJson('/api/stat/start-time', { - timeoutMs: 1800, - method: 'GET', - }); - if (!result.ok || !result.data) { - return null; - } - const rawStartTime = result.data?.data?.start_time; - const numericStartTime = Number(rawStartTime); - return Number.isFinite(numericStartTime) ? numericStartTime : null; - } - - async requestGracefulRestart(authToken = null) { - const result = await this.requestBackendJson('/api/stat/restart-core', { - timeoutMs: 2500, - method: 'POST', - authToken, - headers: { - 'Content-Type': 'application/json', - }, - }); - return result.ok; - } - - async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) { - const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs); - const gracefulWaitMs = - effectiveMaxWaitMs > 0 - ? effectiveMaxWaitMs - : GRACEFUL_RESTART_WAIT_FALLBACK_MS; - const start = Date.now(); - let sawBackendDown = false; - - while (true) { - const reachable = await this.pingBackend(700); - if (!reachable) { - sawBackendDown = true; - } else { - const currentStartTime = await this.getBackendStartTime(); - if ( - previousStartTime !== null && - currentStartTime !== null && - currentStartTime !== previousStartTime - ) { - return { ok: true, reason: null }; - } - if (sawBackendDown && previousStartTime === null) { - return { ok: true, reason: null }; - } - } - - if (Date.now() - start >= gracefulWaitMs) { - return { - ok: false, - reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`, - }; - } - - await delay(350); - } - } - - async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) { - const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs); - const start = Date.now(); - while (true) { - if (await this.pingBackend()) { - return { ok: true, reason: null }; - } - if (failOnProcessExit && !this.backendProcess) { - return { - ok: false, - reason: - this.backendLastExitReason || - 'Backend process exited before becoming reachable.', - }; - } - if (effectiveMaxWaitMs > 0 && Date.now() - start >= effectiveMaxWaitMs) { - return { - ok: false, - reason: `Timed out after ${effectiveMaxWaitMs}ms waiting for backend startup.`, - }; - } - await delay(600); - } - } - - async startBackend() { - if (this.shouldSkipStart()) { - this.log('Skip backend start because app is quitting.'); - return; - } - if (this.backendProcess) { - return; - } - const backendConfig = this.getBackendConfig(); - if (!backendConfig.cmd) { - return; - } - - this.backendLastExitReason = null; - const env = { - ...process.env, - PYTHONUNBUFFERED: '1', - }; - if (this.app.isPackaged) { - env.ASTRBOT_ELECTRON_CLIENT = '1'; - const hasExplicitDashboardHost = Boolean( - process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST, - ); - const hasExplicitDashboardPort = Boolean( - process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT, - ); - if (!hasExplicitDashboardHost) { - env.DASHBOARD_HOST = '127.0.0.1'; - } - if (!hasExplicitDashboardPort) { - env.DASHBOARD_PORT = '6185'; - } - } - if (backendConfig.webuiDir) { - env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir; - } - let backendLogPath = null; - if (backendConfig.rootDir) { - env.ASTRBOT_ROOT = backendConfig.rootDir; - const logsDir = path.join(backendConfig.rootDir, 'logs'); - ensureDir(logsDir); - backendLogPath = path.join(logsDir, 'backend.log'); - } - await this.backendLogger.setLogPath(backendLogPath); - const usePipedLogging = Boolean(backendLogPath); - - this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], { - cwd: backendConfig.cwd, - env, - shell: backendConfig.shell, - stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore', - windowsHide: true, - }); - - if (usePipedLogging) { - if (this.backendProcess.stdout) { - this.backendProcess.stdout.on('data', (chunk) => { - this.backendLogger.log(chunk); - }); - } - if (this.backendProcess.stderr) { - this.backendProcess.stderr.on('data', (chunk) => { - this.backendLogger.log(chunk); - }); - } - } - - if (usePipedLogging) { - const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])] - .map((item) => JSON.stringify(item)) - .join(' '); - this.backendLogger.log( - `[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`, - ); - } - - this.backendProcess.on('error', (error) => { - this.backendLastExitReason = - error instanceof Error ? error.message : String(error); - this.backendLogger.log( - `[${formatLogTimestamp()}] [Electron] Backend spawn error: ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - void this.backendLogger.flush(); - this.backendProcess = null; - }); - - this.backendProcess.on('exit', (code, signal) => { - this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`; - void this.backendLogger.flush(); - this.backendProcess = null; - }); - } - - async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) { - if (!this.canManageBackend()) { - return { - ok: false, - reason: 'Backend command is not configured.', - }; - } - this.backendSpawning = true; - try { - await this.startBackend(); - return await this.waitForBackend(maxWaitMs, true); - } finally { - this.backendSpawning = false; - } - } - - async stopManagedBackend() { - if (!this.backendProcess) { - return; - } - const processToStop = this.backendProcess; - const pid = processToStop.pid; - this.backendProcess = null; - this.log(`Stop backend requested pid=${pid ?? 'unknown'}`); - - if (process.platform === 'win32' && pid) { - try { - // Synchronous taskkill is acceptable here because stop/restart is - // already a control-path operation and not latency-sensitive. - const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - }); - if (result.status !== 0) { - this.log( - `taskkill failed pid=${pid} status=${result.status} signal=${result.signal ?? 'null'}`, - ); - } else { - this.log(`taskkill completed pid=${pid}`); - } - } catch (error) { - this.log( - `taskkill threw for pid=${pid}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - await waitForProcessExit(processToStop, 5000); - } else { - if (!processToStop.killed) { - try { - processToStop.kill('SIGTERM'); - } catch (error) { - this.log( - `SIGTERM failed for pid=${pid ?? 'unknown'}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - const exitResult = await waitForProcessExit(processToStop, 5000); - if (exitResult === 'timeout' && !processToStop.killed) { - try { - processToStop.kill('SIGKILL'); - } catch {} - await waitForProcessExit(processToStop, 1500); - } - } - await this.backendLogger.flush(); - } - - findListeningPidsOnWindows(port) { - // Synchronous netstat parsing is acceptable here because this helper is - // used only during shutdown/restart cleanup paths. - const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - }); - - if (result.status !== 0 || !result.stdout) { - return []; - } - - const pids = new Set(); - const lines = result.stdout.split(/\r?\n/); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) { - continue; - } - - const parts = trimmed.split(/\s+/); - if (parts.length < 5) { - continue; - } - - const localAddress = parts[1] || ''; - const state = (parts[3] || '').toUpperCase(); - const pid = parts[parts.length - 1]; - if (!/^\d+$/.test(pid)) { - continue; - } - - if (state !== 'LISTENING') { - continue; - } - - const cleanedLocalAddress = localAddress.replace(/\]$/, ''); - const segments = cleanedLocalAddress.split(':'); - const portStr = segments[segments.length - 1]; - const portNum = Number(portStr); - if (Number.isInteger(portNum) && portNum === Number(port)) { - pids.add(pid); - } - } - - return Array.from(pids); - } - - getWindowsProcessInfo(pid) { - const result = spawnSync( - 'tasklist', - ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], - { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - }, - ); - if (result.status !== 0 || !result.stdout) { - return null; - } - - const firstLine = result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0); - if (!firstLine || firstLine.startsWith('INFO:')) { - return null; - } - - const fields = firstLine - .replace(/^"/, '') - .replace(/"$/, '') - .split('","'); - const imageName = fields[0] || ''; - const parsedPid = Number.parseInt(fields[1] || '', 10); - if (!imageName || !Number.isInteger(parsedPid) || parsedPid !== Number(pid)) { - return null; - } - return { imageName, pid: parsedPid }; - } - - async stopUnmanagedBackendByPort() { - if (!this.app.isPackaged || process.platform !== 'win32') { - return false; - } - - const port = this.getBackendPort(); - if (!port) { - return false; - } - - const pids = this.findListeningPidsOnWindows(port); - if (!pids.length) { - return false; - } - - this.log( - `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, - ); - - const expectedImageName = ( - path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe' - ).toLowerCase(); - - for (const pid of pids) { - const processInfo = this.getWindowsProcessInfo(pid); - if (!processInfo) { - this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); - continue; - } - - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); - continue; - } - - try { - // Synchronous taskkill is acceptable here because unmanaged cleanup - // is performed only during shutdown/restart control flows. - spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - }); - } catch {} - } - - await delay(500); - return !(await this.pingBackend(1200)); - } - - async stopAnyBackend() { - if (this.backendProcess) { - await this.stopManagedBackend(); - const running = await this.pingBackend(); - if (!running) { - return { ok: true, reason: null }; - } - } else { - const running = await this.pingBackend(); - if (!running) { - return { ok: true, reason: null }; - } - } - - const cleaned = await this.stopUnmanagedBackendByPort(); - if (cleaned) { - return { ok: true, reason: null }; - } - - return { - ok: false, - reason: 'Backend is running but not managed by Electron.', - }; - } - - async ensureBackend() { - this.backendStartupFailureReason = null; - - const running = await this.pingBackend(); - if (running) { - return true; - } - if (!this.backendAutoStart || !this.canManageBackend()) { - this.backendStartupFailureReason = - 'Backend auto-start is disabled or backend command is not configured.'; - return false; - } - const waitResult = await this.startBackendAndWait(this.backendTimeoutMs); - if (!waitResult.ok) { - this.backendStartupFailureReason = waitResult.reason; - return false; - } - return true; - } - - async getState() { - return { - running: await this.pingBackend(), - spawning: this.backendSpawning, - restarting: this.backendRestarting, - canManage: this.canManageBackend(), - }; - } - - async restartBackend(authToken = null) { - if (!this.canManageBackend()) { - return { - ok: false, - reason: 'Backend command is not configured.', - }; - } - if (this.backendSpawning || this.backendRestarting) { - return { - ok: false, - reason: 'Backend action already in progress.', - }; - } - - this.backendRestarting = true; - try { - const backendRunning = await this.pingBackend(900); - if (backendRunning) { - const previousStartTime = await this.getBackendStartTime(); - const gracefulRequested = await this.requestGracefulRestart(authToken); - if (gracefulRequested) { - const gracefulResult = await this.waitForGracefulRestart( - previousStartTime, - this.backendTimeoutMs, - ); - if (gracefulResult.ok) { - return { - ok: true, - reason: null, - }; - } - this.log( - `Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`, - ); - } else { - this.log( - 'Graceful restart request failed; falling back to managed restart.', - ); - } - } - - await this.stopManagedBackend(); - const startResult = await this.startBackendAndWait(this.backendTimeoutMs); - if (!startResult.ok) { - return { - ok: false, - reason: startResult.reason || 'Failed to restart backend.', - }; - } - return { - ok: true, - reason: null, - }; - } catch (error) { - return { - ok: false, - reason: error instanceof Error ? error.message : String(error), - }; - } finally { - this.backendRestarting = false; - } - } - - async stopBackendForIpc() { - if (!this.canManageBackend()) { - return { - ok: false, - reason: 'Backend command is not configured.', - }; - } - if (this.backendSpawning || this.backendRestarting) { - return { - ok: false, - reason: 'Backend action already in progress.', - }; - } - - try { - return await this.stopAnyBackend(); - } catch (error) { - return { - ok: false, - reason: error instanceof Error ? error.message : String(error), - }; - } - } -} - -module.exports = { - BackendManager, -}; diff --git a/desktop/lib/buffered-rotating-logger.js b/desktop/lib/buffered-rotating-logger.js deleted file mode 100644 index 7a443a97d..000000000 --- a/desktop/lib/buffered-rotating-logger.js +++ /dev/null @@ -1,162 +0,0 @@ -'use strict'; - -const { RotatingLogWriter } = require('./rotating-log-writer'); -const { parseEnvInt } = require('./common'); - -const DEFAULT_FLUSH_INTERVAL_MS = 120; -const DEFAULT_MAX_BUFFER_BYTES = 128 * 1024; -const MIN_FLUSH_INTERVAL_MS = 10; -const MIN_MAX_BUFFER_BYTES = 4 * 1024; -const MAX_MAX_BUFFER_BYTES = 16 * 1024 * 1024; - -function clampIntOption(raw, { defaultValue, min, max }) { - const value = parseEnvInt(raw, defaultValue); - return Math.min(Math.max(value, min), max); -} - -class BufferedRotatingLogger { - constructor({ - logPath = null, - maxBytes, - backupCount, - flushIntervalMs, - maxBufferBytes, - label = 'buffered-log', - }) { - this.logPath = logPath || null; - this.flushIntervalMs = clampIntOption(flushIntervalMs, { - defaultValue: DEFAULT_FLUSH_INTERVAL_MS, - min: MIN_FLUSH_INTERVAL_MS, - max: 60 * 1000, - }); - this.maxBufferBytes = clampIntOption(maxBufferBytes, { - defaultValue: DEFAULT_MAX_BUFFER_BYTES, - min: MIN_MAX_BUFFER_BYTES, - max: MAX_MAX_BUFFER_BYTES, - }); - this.buffer = []; - this.bufferBytes = 0; - this.flushTimer = null; - this.pathSwitch = Promise.resolve(); - this.writer = new RotatingLogWriter({ - logPath: this.logPath, - maxBytes, - backupCount, - label, - }); - } - - setLogPath(logPath) { - const nextLogPath = logPath || null; - this.pathSwitch = this.pathSwitch.then(async () => { - if (nextLogPath === this.logPath) { - await this.flush(); - return; - } - - const previousLogPath = this.logPath; - if (previousLogPath) { - await this.flush(); - } - - this.logPath = null; - await this.writer.setLogPath(nextLogPath); - this.logPath = nextLogPath; - await this.flush(); - }); - return this.pathSwitch; - } - - log(payload) { - if (payload === undefined || payload === null) { - return; - } - const chunk = Buffer.isBuffer(payload) - ? payload - : Buffer.from(String(payload), 'utf8'); - if (!chunk.length) { - return; - } - - if (!this.logPath) { - const boundedChunk = this.clipChunkToBufferLimit(chunk); - this.dropOldestUntilWithinLimit(boundedChunk.length); - this.buffer.push(boundedChunk); - this.bufferBytes += boundedChunk.length; - return; - } - - this.buffer.push(chunk); - this.bufferBytes += chunk.length; - - if (this.bufferBytes >= this.maxBufferBytes) { - void this.flush(); - return; - } - this.scheduleFlush(); - } - - flush() { - this.clearFlushTimer(); - if (!this.buffer.length) { - return this.writer.flush(); - } - if (!this.logPath) { - // Path is switching or temporarily unavailable; keep buffered data. - this.dropOldestUntilWithinLimit(0); - return this.writer.flush(); - } - - const chunks = this.buffer; - this.buffer = []; - this.bufferBytes = 0; - const payload = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks); - this.writer.append(payload); - return this.writer.flush(); - } - - dropOldestUntilWithinLimit(incomingBytes = 0) { - while ( - this.buffer.length && - this.bufferBytes + Math.max(0, incomingBytes) > this.maxBufferBytes - ) { - const removed = this.buffer.shift(); - if (removed) { - this.bufferBytes -= removed.length; - } - } - if (this.bufferBytes < 0) { - this.bufferBytes = 0; - } - } - - clipChunkToBufferLimit(chunk) { - if (chunk.length <= this.maxBufferBytes) { - return chunk; - } - return chunk.subarray(chunk.length - this.maxBufferBytes); - } - - scheduleFlush() { - if (this.flushTimer !== null) { - return; - } - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - void this.flush(); - }, this.flushIntervalMs); - this.flushTimer.unref?.(); - } - - clearFlushTimer() { - if (this.flushTimer === null) { - return; - } - clearTimeout(this.flushTimer); - this.flushTimer = null; - } -} - -module.exports = { - BufferedRotatingLogger, -}; diff --git a/desktop/lib/common.js b/desktop/lib/common.js deleted file mode 100644 index 9f39358dc..000000000 --- a/desktop/lib/common.js +++ /dev/null @@ -1,115 +0,0 @@ -'use strict'; - -const fs = require('fs'); - -const LOG_ROTATION_DEFAULT_MAX_MB = 20; -const LOG_ROTATION_DEFAULT_BACKUP_COUNT = 3; - -function normalizeUrl(value) { - try { - const url = new URL(value); - if (!url.pathname.endsWith('/')) { - url.pathname += '/'; - } - return url.toString(); - } catch { - return 'http://127.0.0.1:6185/'; - } -} - -function ensureDir(value) { - if (!value) { - return; - } - if (fs.existsSync(value)) { - return; - } - fs.mkdirSync(value, { recursive: true }); -} - -function parseEnvInt(raw, defaultValue) { - const parsed = Number.parseInt(`${raw ?? ''}`, 10); - return Number.isFinite(parsed) ? parsed : defaultValue; -} - -function isLogRotationDebugEnabled() { - return ( - process.env.ASTRBOT_LOG_ROTATION_DEBUG === '1' || - process.env.NODE_ENV === 'development' - ); -} - -function parseLogMaxBytes(envValue) { - const mb = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_MAX_MB); - const maxMb = mb > 0 ? mb : LOG_ROTATION_DEFAULT_MAX_MB; - return maxMb * 1024 * 1024; -} - -function parseLogBackupCount(envValue) { - const count = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_BACKUP_COUNT); - return count >= 0 ? count : LOG_ROTATION_DEFAULT_BACKUP_COUNT; -} - -function isIgnorableFsError(error) { - return Boolean(error && typeof error === 'object' && error.code === 'ENOENT'); -} - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function waitForProcessExit(child, timeoutMs = 5000) { - if (!child) { - return Promise.resolve('missing'); - } - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve('exited'); - } - return new Promise((resolve) => { - let settled = false; - const finish = (reason) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(reason); - }; - const timeout = setTimeout(() => finish('timeout'), timeoutMs); - child.once('exit', () => finish('exit')); - child.once('error', () => finish('error')); - }); -} - -function formatLogTimestamp(date = new Date()) { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, '0'); - const day = `${date.getDate()}`.padStart(2, '0'); - const hour = `${date.getHours()}`.padStart(2, '0'); - const minute = `${date.getMinutes()}`.padStart(2, '0'); - const second = `${date.getSeconds()}`.padStart(2, '0'); - const millisecond = `${date.getMilliseconds()}`.padStart(3, '0'); - - const offsetMinutes = -date.getTimezoneOffset(); - const offsetSign = offsetMinutes >= 0 ? '+' : '-'; - const absOffsetMinutes = Math.abs(offsetMinutes); - const offsetHour = `${Math.floor(absOffsetMinutes / 60)}`.padStart(2, '0'); - const offsetMinute = `${absOffsetMinutes % 60}`.padStart(2, '0'); - - return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond} ${offsetSign}${offsetHour}${offsetMinute}`; -} - -module.exports = { - LOG_ROTATION_DEFAULT_BACKUP_COUNT, - LOG_ROTATION_DEFAULT_MAX_MB, - delay, - ensureDir, - formatLogTimestamp, - isIgnorableFsError, - isLogRotationDebugEnabled, - normalizeUrl, - parseEnvInt, - parseLogBackupCount, - parseLogMaxBytes, - waitForProcessExit, -}; diff --git a/desktop/lib/dashboard-loader.js b/desktop/lib/dashboard-loader.js deleted file mode 100644 index 6e858843f..000000000 --- a/desktop/lib/dashboard-loader.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const { delay } = require('./common'); - -async function loadDashboard(mainWindow, backendUrl, maxWaitMs = 20000) { - if (!mainWindow) { - return false; - } - const loadUrl = new URL(backendUrl); - loadUrl.searchParams.set('_electron_ts', `${Date.now()}`); - const start = Date.now(); - let lastError = null; - while (maxWaitMs <= 0 || Date.now() - start < maxWaitMs) { - try { - await mainWindow.loadURL(loadUrl.toString()); - return true; - } catch (error) { - lastError = error; - await delay(600); - } - } - if (lastError) { - throw lastError; - } - throw new Error(`Timed out loading ${backendUrl}`); -} - -module.exports = { - loadDashboard, -}; diff --git a/desktop/lib/electron-logger.js b/desktop/lib/electron-logger.js deleted file mode 100644 index 6a52d1c76..000000000 --- a/desktop/lib/electron-logger.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const path = require('path'); -const { RotatingLogWriter } = require('./rotating-log-writer'); -const { - formatLogTimestamp, - parseLogBackupCount, - parseLogMaxBytes, -} = require('./common'); - -function createElectronLogger({ app, getRootDir }) { - const electronLogMaxBytes = parseLogMaxBytes( - process.env.ASTRBOT_ELECTRON_LOG_MAX_MB, - ); - const electronLogBackupCount = parseLogBackupCount( - process.env.ASTRBOT_ELECTRON_LOG_BACKUP_COUNT, - ); - const writer = new RotatingLogWriter({ - logPath: null, - maxBytes: electronLogMaxBytes, - backupCount: electronLogBackupCount, - label: 'electron-log', - }); - - function getElectronLogPath() { - const rootDir = - process.env.ASTRBOT_ROOT || - (typeof getRootDir === 'function' ? getRootDir() : null) || - app.getPath('userData'); - return path.join(rootDir, 'logs', 'electron.log'); - } - - function logElectron(message) { - const logPath = getElectronLogPath(); - const line = `[${formatLogTimestamp()}] ${message}\n`; - void writer.setLogPath(logPath); - void writer.append(line); - } - - async function flushElectron() { - await writer.flush(); - } - - return { - getElectronLogPath, - logElectron, - flushElectron, - }; -} - -module.exports = { - createElectronLogger, -}; diff --git a/desktop/lib/locale-service.js b/desktop/lib/locale-service.js deleted file mode 100644 index d68039e7d..000000000 --- a/desktop/lib/locale-service.js +++ /dev/null @@ -1,174 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { delay, ensureDir } = require('./common'); - -const LOCALE_STORAGE_KEY = 'astrbot-locale'; -const SUPPORTED_STARTUP_LOCALES = new Set(['zh-CN', 'en-US']); - -function normalizeLocale(value) { - if (!value) { - return null; - } - const raw = String(value).trim(); - if (!raw) { - return null; - } - if (SUPPORTED_STARTUP_LOCALES.has(raw)) { - return raw; - } - const lower = raw.toLowerCase(); - if (lower.startsWith('zh')) { - return 'zh-CN'; - } - if (lower.startsWith('en')) { - return 'en-US'; - } - return null; -} - -function getStartupTexts(locale) { - if (locale === 'zh-CN') { - return { - title: 'AstrBot 正在启动', - message: '界面很快就会加载完成。', - }; - } - return { - title: 'AstrBot is starting', - message: 'The dashboard will be ready in a moment.', - }; -} - -function getShellTexts(locale) { - if (locale === 'zh-CN') { - return { - trayHide: '隐藏 AstrBot', - trayShow: '显示 AstrBot', - trayReload: '重新加载', - trayRestartBackend: '重启后端', - trayQuit: '退出', - startupFailTitle: 'AstrBot 启动失败', - startupFailMessage: 'AstrBot 后端不可达。', - startupFailReasonPrefix: '原因', - startupFailAction: - '请先启动 http://127.0.0.1:6185 的后端服务,然后重新打开 AstrBot。', - startupFailLogPrefix: '后端日志', - dashboardFailTitle: 'AstrBot 加载失败', - dashboardFailMessage: '无法加载 AstrBot 控制台页面。', - }; - } - return { - trayHide: 'Hide AstrBot', - trayShow: 'Show AstrBot', - trayReload: 'Reload', - trayRestartBackend: 'Restart Backend', - trayQuit: 'Quit', - startupFailTitle: 'AstrBot startup failed', - startupFailMessage: 'AstrBot backend is not reachable.', - startupFailReasonPrefix: 'Reason', - startupFailAction: - 'Please start the backend at http://127.0.0.1:6185 and relaunch AstrBot.', - startupFailLogPrefix: 'Backend log', - dashboardFailTitle: 'Failed to load AstrBot', - dashboardFailMessage: 'Unable to load the AstrBot dashboard.', - }; -} - -function createLocaleService({ app, getRootDir }) { - function resolveStateRoot() { - const callbackRoot = (() => { - try { - return getRootDir ? getRootDir() : null; - } catch { - return null; - } - })(); - return process.env.ASTRBOT_ROOT || callbackRoot || app.getPath('userData'); - } - - function getDesktopStatePath() { - return path.join(resolveStateRoot(), 'data', 'desktop_state.json'); - } - - function readCachedLocale() { - const statePath = getDesktopStatePath(); - try { - const raw = fs.readFileSync(statePath, 'utf8'); - const parsed = JSON.parse(raw); - return normalizeLocale(parsed?.locale); - } catch { - return null; - } - } - - function writeCachedLocale(locale) { - const normalized = normalizeLocale(locale); - if (!normalized) { - return; - } - const statePath = getDesktopStatePath(); - ensureDir(path.dirname(statePath)); - try { - fs.writeFileSync( - statePath, - `${JSON.stringify({ locale: normalized }, null, 2)}\n`, - 'utf8', - ); - } catch {} - } - - function resolveStartupLocale() { - const cached = readCachedLocale(); - if (cached) { - return cached; - } - return normalizeLocale(app.getLocale()) || 'zh-CN'; - } - - async function persistLocaleFromDashboard( - mainWindow, - backendUrl, - timeoutMs = 1200, - ) { - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - const currentUrl = mainWindow.webContents.getURL(); - if (!currentUrl || !currentUrl.startsWith(backendUrl)) { - return; - } - try { - const localeRaw = await Promise.race([ - mainWindow.webContents.executeJavaScript( - `(() => { - try { - return window.localStorage.getItem('${LOCALE_STORAGE_KEY}') || ''; - } catch { - return ''; - } - })();`, - true, - ), - delay(timeoutMs).then(() => null), - ]); - const locale = normalizeLocale(localeRaw); - if (locale) { - writeCachedLocale(locale); - } - } catch {} - } - - return { - getShellTexts, - getStartupTexts, - persistLocaleFromDashboard, - resolveStartupLocale, - }; -} - -module.exports = { - createLocaleService, - normalizeLocale, -}; diff --git a/desktop/lib/rotating-log-writer.js b/desktop/lib/rotating-log-writer.js deleted file mode 100644 index c6c8f8fb1..000000000 --- a/desktop/lib/rotating-log-writer.js +++ /dev/null @@ -1,178 +0,0 @@ -'use strict'; - -const fs = require('fs/promises'); -const path = require('path'); -const { isIgnorableFsError, isLogRotationDebugEnabled } = require('./common'); - -class RotatingLogWriter { - constructor({ logPath = null, maxBytes = 0, backupCount = 0, label = 'log' }) { - this.logPath = logPath || null; - this.maxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 0; - this.backupCount = Number.isFinite(backupCount) && backupCount >= 0 ? backupCount : 0; - this.label = label; - this.cachedSize = null; - this.dirReadyForPath = null; - this.queue = Promise.resolve(); - } - - setLogPath(logPath) { - const nextPath = logPath || null; - if (nextPath === this.logPath) { - return this.queue; - } - return this.enqueue(async () => { - this.logPath = nextPath; - this.cachedSize = null; - this.dirReadyForPath = null; - }); - } - - append(payload) { - if (payload === undefined || payload === null) { - return this.queue; - } - const content = Buffer.isBuffer(payload) - ? payload - : Buffer.from(String(payload), 'utf8'); - if (!content.length) { - return this.queue; - } - return this.enqueue(async () => { - if (!this.logPath) { - return; - } - await this.ensureDirReady(); - await this.ensureSizeLoaded(); - await this.rotateIfNeeded(content.length); - await fs.appendFile(this.logPath, content); - if (!Number.isFinite(this.cachedSize)) { - this.cachedSize = await this.readSize(); - } else { - this.cachedSize += content.length; - } - }); - } - - flush() { - return this.queue; - } - - enqueue(task) { - const run = async () => { - try { - await task(); - } catch (error) { - this.reportError('write', this.logPath || 'unknown', error); - } - }; - this.queue = this.queue.then(run, run); - return this.queue; - } - - async ensureSizeLoaded() { - if (Number.isFinite(this.cachedSize)) { - return; - } - this.cachedSize = await this.readSize(); - } - - async ensureDirReady() { - if (!this.logPath) { - return; - } - if (this.dirReadyForPath === this.logPath) { - return; - } - const dirPath = path.dirname(this.logPath); - try { - await fs.mkdir(dirPath, { recursive: true }); - this.dirReadyForPath = this.logPath; - } catch (error) { - this.reportError('mkdir', dirPath, error); - } - } - - async readSize() { - if (!this.logPath) { - return 0; - } - try { - const stat = await fs.stat(this.logPath); - return stat.size; - } catch (error) { - if (isIgnorableFsError(error)) { - return 0; - } - this.reportError('stat', this.logPath, error); - return 0; - } - } - - async rotateIfNeeded(incomingBytes) { - if (!this.logPath || this.maxBytes <= 0) { - return; - } - - const currentSize = Number.isFinite(this.cachedSize) ? this.cachedSize : 0; - if (currentSize + Math.max(0, incomingBytes) <= this.maxBytes) { - return; - } - - if (this.backupCount <= 0) { - try { - await fs.truncate(this.logPath, 0); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('truncate', this.logPath, error); - } - } - this.cachedSize = await this.readSize(); - return; - } - - const oldestPath = `${this.logPath}.${this.backupCount}`; - try { - await fs.unlink(oldestPath); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('unlink', oldestPath, error); - } - } - - for (let index = this.backupCount - 1; index >= 1; index -= 1) { - const sourcePath = `${this.logPath}.${index}`; - const targetPath = `${this.logPath}.${index + 1}`; - try { - await fs.rename(sourcePath, targetPath); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('rename', `${sourcePath} -> ${targetPath}`, error); - } - } - } - - try { - await fs.rename(this.logPath, `${this.logPath}.1`); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('rename', `${this.logPath} -> ${this.logPath}.1`, error); - } - } - - this.cachedSize = await this.readSize(); - } - - reportError(action, targetPath, error) { - if (!isLogRotationDebugEnabled()) { - return; - } - const details = error instanceof Error ? error.message : String(error); - console.error( - `[astrbot][${this.label}] ${action} failed for ${targetPath}: ${details}`, - ); - } -} - -module.exports = { - RotatingLogWriter, -}; diff --git a/desktop/lib/startup-screen.js b/desktop/lib/startup-screen.js deleted file mode 100644 index 93a342b81..000000000 --- a/desktop/lib/startup-screen.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -const fs = require('fs'); - -async function loadStartupScreen(mainWindow, { getAssetPath, startupTexts }) { - if (!mainWindow) { - return false; - } - let iconUrl = ''; - try { - const iconBuffer = fs.readFileSync(getAssetPath('icon-no-shadow.svg')); - iconUrl = `data:image/svg+xml;base64,${iconBuffer.toString('base64')}`; - } catch {} - - const html = ` - - - - - AstrBot - - - -

- -`; - const startupUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; - await mainWindow.loadURL(startupUrl); - return true; -} - -module.exports = { - loadStartupScreen, -}; diff --git a/desktop/main.js b/desktop/main.js deleted file mode 100644 index 5adff38b3..000000000 --- a/desktop/main.js +++ /dev/null @@ -1,420 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { - app, - BrowserWindow, - Menu, - Tray, - nativeImage, - shell, - dialog, - ipcMain, -} = require('electron'); - -const { BackendManager } = require('./lib/backend-manager'); -const { loadDashboard } = require('./lib/dashboard-loader'); -const { createElectronLogger } = require('./lib/electron-logger'); -const { createLocaleService } = require('./lib/locale-service'); -const { loadStartupScreen } = require('./lib/startup-screen'); - -const isMac = process.platform === 'darwin'; -const dashboardTimeoutMsParsed = Number.parseInt( - process.env.ASTRBOT_DASHBOARD_TIMEOUT_MS || '20000', - 10, -); -const dashboardTimeoutMs = Number.isFinite(dashboardTimeoutMsParsed) - ? dashboardTimeoutMsParsed - : 20000; - -let mainWindow = null; -let tray = null; -let isQuitting = false; -let quitInProgress = false; -let backendManager = null; - -app.commandLine.appendSwitch('disable-http-cache'); - -const { logElectron, flushElectron } = createElectronLogger({ - app, - getRootDir: () => (backendManager ? backendManager.getRootDir() : null), -}); - -backendManager = new BackendManager({ - app, - baseDir: __dirname, - log: logElectron, - shouldSkipStart: () => isQuitting || quitInProgress, -}); - -const localeService = createLocaleService({ - app, - getRootDir: () => backendManager.getRootDir(), -}); - -function getAssetPath(filename) { - if (app.isPackaged) { - const packaged = path.join(process.resourcesPath, 'assets', filename); - if (fs.existsSync(packaged)) { - return packaged; - } - } - return path.join(__dirname, 'assets', filename); -} - -function loadImageSafe(imagePath) { - try { - const image = nativeImage.createFromPath(imagePath); - if (!image.isEmpty()) { - return image; - } - } catch {} - return nativeImage.createEmpty(); -} - -function showWindow() { - if (!mainWindow) { - return; - } - mainWindow.show(); - mainWindow.focus(); - updateTrayMenu(); -} - -function toggleWindow() { - if (!mainWindow) { - return; - } - if (mainWindow.isVisible()) { - mainWindow.hide(); - } else { - mainWindow.show(); - mainWindow.focus(); - } - updateTrayMenu(); -} - -function updateTrayMenu() { - if (!tray || !mainWindow) { - return; - } - const shellTexts = localeService.getShellTexts( - localeService.resolveStartupLocale(), - ); - const isVisible = mainWindow.isVisible(); - const contextMenu = Menu.buildFromTemplate([ - { - label: isVisible ? shellTexts.trayHide : shellTexts.trayShow, - click: () => toggleWindow(), - }, - { - label: shellTexts.trayReload, - click: () => { - if (mainWindow) { - mainWindow.reload(); - } - }, - }, - { - label: shellTexts.trayRestartBackend, - click: async () => { - if (!backendManager) { - return; - } - if (mainWindow && !mainWindow.isDestroyed()) { - showWindow(); - const currentUrl = mainWindow.webContents.getURL(); - if (currentUrl.startsWith(backendManager.getBackendUrl())) { - mainWindow.webContents.send('astrbot-desktop:tray-restart-backend'); - return; - } - } - - const result = await backendManager.restartBackend(); - if (!result.ok) { - logElectron( - `Tray restart backend fallback failed: ${result.reason || 'unknown reason'}`, - ); - } - }, - }, - { type: 'separator' }, - { - label: shellTexts.trayQuit, - click: () => app.quit(), - }, - ]); - tray.setContextMenu(contextMenu); -} - -function createTray() { - const traySize = isMac ? 18 : 16; - const trayPath = getAssetPath('tray.png'); - let trayImage = loadImageSafe(trayPath); - if (trayImage.isEmpty()) { - trayImage = loadImageSafe(getAssetPath('icon.png')); - } - if (!trayImage.isEmpty()) { - trayImage = trayImage.resize({ width: traySize, height: traySize }); - if (isMac) { - trayImage.setTemplateImage(true); - } - tray = new Tray(trayImage); - } else { - tray = new Tray(nativeImage.createEmpty()); - } - tray.setToolTip('AstrBot'); - tray.on('click', () => toggleWindow()); - updateTrayMenu(); -} - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 800, - minWidth: 980, - minHeight: 680, - show: false, - backgroundColor: '#f9fafc', - autoHideMenuBar: !isMac, - icon: getAssetPath('icon.png'), - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - preload: path.join(__dirname, 'preload.js'), - ...(isMac - ? { - defaultFontFamily: { - standard: 'PingFang SC', - sansSerif: 'PingFang SC', - serif: 'Songti SC', - monospace: 'SF Mono', - }, - } - : {}), - }, - }); - - mainWindow.on('close', (event) => { - if (isQuitting) { - return; - } - event.preventDefault(); - mainWindow.hide(); - }); - - mainWindow.on('minimize', (event) => { - event.preventDefault(); - mainWindow.hide(); - }); - - mainWindow.on('show', () => updateTrayMenu()); - mainWindow.on('hide', () => updateTrayMenu()); - - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); - return { action: 'deny' }; - }); - - mainWindow.webContents.on( - 'did-fail-load', - (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (!isMainFrame) { - return; - } - logElectron( - `did-fail-load main-frame code=${errorCode} desc=${errorDescription} url=${validatedURL}`, - ); - }, - ); - - mainWindow.webContents.on('did-finish-load', () => { - const currentUrl = mainWindow.webContents.getURL(); - logElectron(`did-finish-load url=${currentUrl}`); - if (currentUrl.startsWith(backendManager.getBackendUrl())) { - void localeService.persistLocaleFromDashboard( - mainWindow, - backendManager.getBackendUrl(), - ); - } - }); - - mainWindow.webContents.on('render-process-gone', (_event, details) => { - logElectron( - `render-process-gone reason=${details.reason} exitCode=${details.exitCode}`, - ); - }); - - mainWindow.webContents.on( - 'console-message', - (_event, level, message, line, sourceId) => { - if (level >= 2) { - logElectron( - `renderer-console level=${level} source=${sourceId}:${line} message=${message}`, - ); - } - }, - ); - - return mainWindow; -} - -function registerIpcHandlers() { - ipcMain.handle('astrbot-desktop:is-electron-runtime', async () => true); - - ipcMain.handle('astrbot-desktop:get-backend-state', async () => { - return backendManager.getState(); - }); - - ipcMain.handle('astrbot-desktop:restart-backend', async (_event, authToken) => { - return backendManager.restartBackend(authToken); - }); - - ipcMain.handle('astrbot-desktop:stop-backend', async () => { - return backendManager.stopBackendForIpc(); - }); -} - -async function startDesktopFlow() { - createWindow(); - createTray(); - - try { - const startupTexts = localeService.getStartupTexts( - localeService.resolveStartupLocale(), - ); - await loadStartupScreen(mainWindow, { - getAssetPath, - startupTexts, - }); - } catch (error) { - logElectron( - `failed to load startup screen: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - showWindow(); - - const ready = await backendManager.ensureBackend(); - if (isQuitting) { - return; - } - - if (!ready) { - const shellTexts = localeService.getShellTexts( - localeService.resolveStartupLocale(), - ); - const backendLogPath = backendManager.getBackendLogPath(); - const detailLines = []; - const startupFailureReason = backendManager.getStartupFailureReason(); - if (startupFailureReason) { - detailLines.push( - `${shellTexts.startupFailReasonPrefix}: ${startupFailureReason}`, - ); - } - detailLines.push(shellTexts.startupFailAction); - if (backendLogPath) { - detailLines.push(`${shellTexts.startupFailLogPrefix}: ${backendLogPath}`); - } - - await dialog.showMessageBox({ - type: 'error', - title: shellTexts.startupFailTitle, - message: shellTexts.startupFailMessage, - detail: detailLines.join('\n'), - }); - isQuitting = true; - app.quit(); - return; - } - - try { - await loadDashboard( - mainWindow, - backendManager.getBackendUrl(), - dashboardTimeoutMs, - ); - showWindow(); - } catch (error) { - const shellTexts = localeService.getShellTexts( - localeService.resolveStartupLocale(), - ); - await dialog.showMessageBox({ - type: 'error', - title: shellTexts.dashboardFailTitle, - message: shellTexts.dashboardFailMessage, - detail: error instanceof Error ? error.message : String(error), - }); - isQuitting = true; - app.quit(); - } -} - -registerIpcHandlers(); - -app.setAppUserModelId('com.astrbot.desktop'); - -const gotLock = app.requestSingleInstanceLock(); -if (!gotLock) { - app.quit(); -} else { - app.on('second-instance', () => { - showWindow(); - }); -} - -app.on('before-quit', (event) => { - if (quitInProgress) { - event.preventDefault(); - return; - } - event.preventDefault(); - quitInProgress = true; - isQuitting = true; - logElectron('before-quit received, stopping backend.'); - - localeService - .persistLocaleFromDashboard(mainWindow, backendManager.getBackendUrl()) - .catch(() => {}) - .then(() => - backendManager.stopAnyBackend().then((result) => { - if (!result.ok) { - logElectron(`stopBackend failed: ${result.reason || 'unknown reason'}`); - } - }), - ) - .finally(async () => { - logElectron('Backend stop finished, exiting app.'); - await Promise.allSettled([ - flushElectron(), - backendManager ? backendManager.flushLogs() : Promise.resolve(), - ]); - app.exit(0); - }); -}); - -app.whenReady().then(async () => { - if (isMac && app.dock) { - const dockIcon = getAssetPath('icon.png'); - if (fs.existsSync(dockIcon)) { - app.dock.setIcon(dockIcon); - } - } - await startDesktopFlow(); -}); - -app.on('activate', () => { - if (mainWindow) { - showWindow(); - } -}); - -app.on('window-all-closed', () => { - if (!isMac) { - app.quit(); - } -}); diff --git a/desktop/package.json b/desktop/package.json deleted file mode 100644 index 2fb2349d3..000000000 --- a/desktop/package.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "name": "astrbot-desktop", - "version": "4.17.5", - "description": "AstrBot desktop wrapper", - "private": true, - "main": "main.js", - "author": "AstrBot", - "packageManager": "pnpm@10.28.2", - "pnpm": { - "onlyBuiltDependencies": [ - "electron" - ] - }, - "scripts": { - "dev": "electron .", - "start": "electron .", - "sync:version": "node scripts/sync-version.mjs", - "build:webui": "node scripts/prepare-webui.mjs", - "build:backend": "node scripts/build-backend.mjs", - "dist:full": "pnpm run build:webui && pnpm run build:backend && pnpm run dist", - "pack": "pnpm run sync:version && electron-builder --dir", - "dist": "pnpm run sync:version && electron-builder" - }, - "devDependencies": { - "electron": "^40.3.0", - "electron-builder": "^24.13.0" - }, - "build": { - "appId": "com.astrbot.desktop", - "productName": "AstrBot", - "icon": "assets/icon.png", - "extraResources": [ - { - "from": "resources/backend", - "to": "backend", - "filter": [ - "**/*", - "!**/*.map" - ] - }, - { - "from": "resources/webui", - "to": "webui", - "filter": [ - "**/*", - "!**/*.map" - ] - }, - { - "from": "assets", - "to": "assets", - "filter": [ - "**/*", - "!**/*.map" - ] - } - ], - "files": [ - "**/*", - "!**/*.map", - "!**/*.d.ts", - "!**/{test,__tests__,tests,powered-test,example,examples}/**" - ], - "compression": "maximum", - "electronLanguages": [ - "en-US", - "zh-CN" - ], - "asar": true, - "directories": { - "buildResources": "assets" - }, - "linux": { - "target": [ - "AppImage" - ], - "category": "Utility" - }, - "mac": { - "target": [ - "dmg", - "zip" - ], - "category": "public.app-category.productivity" - }, - "win": { - "target": [ - "nsis", - "zip" - ] - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - } - } -} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml deleted file mode 100644 index 98411a90e..000000000 --- a/desktop/pnpm-lock.yaml +++ /dev/null @@ -1,2277 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - electron: - specifier: ^40.3.0 - version: 40.3.0 - electron-builder: - specifier: ^24.13.0 - version: 24.13.3(electron-builder-squirrel-windows@24.13.3) - -packages: - - 7zip-bin@5.2.0: - resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} - - '@develar/schema-utils@2.6.5': - resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} - engines: {node: '>= 8.9.0'} - - '@electron/asar@3.4.1': - resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} - engines: {node: '>=10.12.0'} - hasBin: true - - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - - '@electron/notarize@2.2.1': - resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==} - engines: {node: '>= 10.0.0'} - - '@electron/osx-sign@1.0.5': - resolution: {integrity: sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==} - engines: {node: '>=12.0.0'} - hasBin: true - - '@electron/universal@1.5.1': - resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==} - engines: {node: '>=8.6'} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@malept/cross-spawn-promise@1.1.1': - resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} - engines: {node: '>= 10'} - - '@malept/flatpak-bundler@0.4.0': - resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} - engines: {node: '>= 10.0.0'} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - - '@szmarczak/http-timer@4.0.6': - resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} - engines: {node: '>=10'} - - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} - engines: {node: '>= 10'} - - '@types/cacheable-request@6.0.3': - resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - - '@types/fs-extra@9.0.13': - resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} - - '@types/http-cache-semantics@4.2.0': - resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} - - '@types/keyv@3.1.4': - resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - - '@types/node@25.2.2': - resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} - - '@types/plist@3.0.5': - resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} - - '@types/responselike@1.0.3': - resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - - '@types/verror@1.10.11': - resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} - engines: {node: '>=10.0.0'} - - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - app-builder-bin@4.0.0: - resolution: {integrity: sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==} - - app-builder-lib@24.13.3: - resolution: {integrity: sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==} - engines: {node: '>=14.0.0'} - peerDependencies: - dmg-builder: 24.13.3 - electron-builder-squirrel-windows: 24.13.3 - - archiver-utils@2.1.0: - resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} - engines: {node: '>= 6'} - - archiver-utils@3.0.4: - resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} - engines: {node: '>= 10'} - - archiver@5.3.2: - resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} - engines: {node: '>= 10'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - - async-exit-hook@2.0.1: - resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} - engines: {node: '>=0.12.0'} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - at-least-node@1.0.0: - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} - engines: {node: '>= 4.0.0'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - bluebird-lst@1.0.9: - resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==} - - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - - boolean@3.2.0: - resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - - buffer-equal@1.0.1: - resolution: {integrity: sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==} - engines: {node: '>=0.4'} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - builder-util-runtime@9.2.4: - resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==} - engines: {node: '>=12.0.0'} - - builder-util@24.13.1: - resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==} - - cacheable-lookup@5.0.4: - resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} - engines: {node: '>=10.6.0'} - - cacheable-request@7.0.4: - resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - - chromium-pickle-js@0.2.0: - resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cli-truncate@2.1.0: - resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} - engines: {node: '>=8'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone-response@1.0.3: - resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@5.1.0: - resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} - engines: {node: '>= 6'} - - compare-version@0.1.2: - resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} - engines: {node: '>=0.10.0'} - - compress-commons@4.1.2: - resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} - engines: {node: '>= 10'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - config-file-ts@0.2.6: - resolution: {integrity: sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==} - - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - - crc32-stream@4.0.3: - resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} - engines: {node: '>= 10'} - - crc@3.8.0: - resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - defer-to-connect@2.0.1: - resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} - engines: {node: '>=10'} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - detect-node@2.1.0: - resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - - dir-compare@3.3.0: - resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==} - - dmg-builder@24.13.3: - resolution: {integrity: sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==} - - dmg-license@1.0.11: - resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} - engines: {node: '>=8'} - os: [darwin] - hasBin: true - - dotenv-expand@5.1.0: - resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} - - dotenv@9.0.2: - resolution: {integrity: sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==} - engines: {node: '>=10'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - electron-builder-squirrel-windows@24.13.3: - resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==} - - electron-builder@24.13.3: - resolution: {integrity: sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==} - engines: {node: '>=14.0.0'} - hasBin: true - - electron-publish@24.13.1: - resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==} - - electron@40.3.0: - resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} - engines: {node: '>= 12.20.55'} - hasBin: true - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es6-error@4.1.1: - resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - - extsprintf@1.4.1: - resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} - engines: {'0': node >=0.6.0} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - - fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - - fs-extra@9.1.0: - resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} - engines: {node: '>=10'} - - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob 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 - hasBin: true - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob 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 - - global-agent@3.0.0: - resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} - engines: {node: '>=10.0'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - got@11.8.6: - resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} - engines: {node: '>=10.19.0'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hosted-git-info@4.1.0: - resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} - engines: {node: '>=10'} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - - http2-wrapper@1.0.3: - resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} - engines: {node: '>=10.19.0'} - - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - - iconv-corefoundation@1.1.7: - resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} - engines: {node: ^8.11.2 || >=10} - os: [darwin] - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-ci@3.0.1: - resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} - hasBin: true - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - isbinaryfile@4.0.10: - resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} - engines: {node: '>= 8.0.0'} - - isbinaryfile@5.0.7: - resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} - engines: {node: '>= 18.0.0'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - lazy-val@1.0.5: - resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} - - lazystream@1.0.1: - resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} - engines: {node: '>= 0.6.3'} - - lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - - lodash.difference@4.5.0: - resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} - - lodash.flatten@4.4.0: - resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.union@4.6.0: - resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - lowercase-keys@2.0.0: - resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} - engines: {node: '>=8'} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - - matcher@3.0.0: - resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} - engines: {node: '>=10'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - - mimic-response@1.0.1: - resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} - engines: {node: '>=4'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@9.0.5: - 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'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - - 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 - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - node-addon-api@1.7.2: - resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - normalize-url@6.1.0: - resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} - engines: {node: '>=10'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - p-cancelable@2.1.1: - resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} - engines: {node: '>=8'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - plist@3.1.0: - resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} - engines: {node: '>=10.4.0'} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - - promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - - read-config-file@6.3.2: - resolution: {integrity: sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==} - engines: {node: '>=12.0.0'} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdir-glob@1.1.3: - resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-alpn@1.2.1: - resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - - responselike@2.0.1: - resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - - roarr@2.15.4: - resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} - engines: {node: '>=8.0'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - sanitize-filename@1.6.3: - resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} - - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} - engines: {node: '>=11.0.0'} - - semver-compare@1.0.0: - resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - serialize-error@7.0.1: - resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} - engines: {node: '>=10'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} - - slice-ansi@3.0.0: - resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} - engines: {node: '>=8'} - - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - - stat-mode@1.0.0: - resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} - engines: {node: '>= 6'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - sumchecker@3.0.1: - resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} - engines: {node: '>= 8.0'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - 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 - - temp-file@3.4.0: - resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} - - tmp-promise@3.0.3: - resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} - engines: {node: '>=14.14'} - - truncate-utf8-bytes@1.0.2: - resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - - type-fest@0.13.1: - resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} - engines: {node: '>=10'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - utf8-byte-length@1.0.5: - resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - verror@1.10.1: - resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} - engines: {node: '>=0.6.0'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - xmlbuilder@15.1.1: - resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} - engines: {node: '>=8.0'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - - zip-stream@4.1.1: - resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} - engines: {node: '>= 10'} - -snapshots: - - 7zip-bin@5.2.0: {} - - '@develar/schema-utils@2.6.5': - dependencies: - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - - '@electron/asar@3.4.1': - dependencies: - commander: 5.1.0 - glob: 7.2.3 - minimatch: 3.1.2 - - '@electron/get@2.0.3': - dependencies: - debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 - progress: 2.0.3 - semver: 6.3.1 - sumchecker: 3.0.1 - optionalDependencies: - global-agent: 3.0.0 - transitivePeerDependencies: - - supports-color - - '@electron/notarize@2.2.1': - dependencies: - debug: 4.4.3 - fs-extra: 9.1.0 - promise-retry: 2.0.1 - transitivePeerDependencies: - - supports-color - - '@electron/osx-sign@1.0.5': - dependencies: - compare-version: 0.1.2 - debug: 4.4.3 - fs-extra: 10.1.0 - isbinaryfile: 4.0.10 - minimist: 1.2.8 - plist: 3.1.0 - transitivePeerDependencies: - - supports-color - - '@electron/universal@1.5.1': - dependencies: - '@electron/asar': 3.4.1 - '@malept/cross-spawn-promise': 1.1.1 - debug: 4.4.3 - dir-compare: 3.3.0 - fs-extra: 9.1.0 - minimatch: 3.1.2 - plist: 3.1.0 - transitivePeerDependencies: - - supports-color - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@malept/cross-spawn-promise@1.1.1': - dependencies: - cross-spawn: 7.0.6 - - '@malept/flatpak-bundler@0.4.0': - dependencies: - debug: 4.4.3 - fs-extra: 9.1.0 - lodash: 4.17.23 - tmp-promise: 3.0.3 - transitivePeerDependencies: - - supports-color - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@sindresorhus/is@4.6.0': {} - - '@szmarczak/http-timer@4.0.6': - dependencies: - defer-to-connect: 2.0.1 - - '@tootallnate/once@2.0.0': {} - - '@types/cacheable-request@6.0.3': - dependencies: - '@types/http-cache-semantics': 4.2.0 - '@types/keyv': 3.1.4 - '@types/node': 25.2.2 - '@types/responselike': 1.0.3 - - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - - '@types/fs-extra@9.0.13': - dependencies: - '@types/node': 25.2.2 - - '@types/http-cache-semantics@4.2.0': {} - - '@types/keyv@3.1.4': - dependencies: - '@types/node': 25.2.2 - - '@types/ms@2.1.0': {} - - '@types/node@24.10.13': - dependencies: - undici-types: 7.16.0 - - '@types/node@25.2.2': - dependencies: - undici-types: 7.16.0 - - '@types/plist@3.0.5': - dependencies: - '@types/node': 25.2.2 - xmlbuilder: 15.1.1 - optional: true - - '@types/responselike@1.0.3': - dependencies: - '@types/node': 25.2.2 - - '@types/verror@1.10.11': - optional: true - - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 25.2.2 - optional: true - - '@xmldom/xmldom@0.8.11': {} - - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - app-builder-bin@4.0.0: {} - - app-builder-lib@24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3): - dependencies: - '@develar/schema-utils': 2.6.5 - '@electron/notarize': 2.2.1 - '@electron/osx-sign': 1.0.5 - '@electron/universal': 1.5.1 - '@malept/flatpak-bundler': 0.4.0 - '@types/fs-extra': 9.0.13 - async-exit-hook: 2.0.1 - bluebird-lst: 1.0.9 - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - chromium-pickle-js: 0.2.0 - debug: 4.4.3 - dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) - ejs: 3.1.10 - electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3) - electron-publish: 24.13.1 - form-data: 4.0.5 - fs-extra: 10.1.0 - hosted-git-info: 4.1.0 - is-ci: 3.0.1 - isbinaryfile: 5.0.7 - js-yaml: 4.1.1 - lazy-val: 1.0.5 - minimatch: 5.1.6 - read-config-file: 6.3.2 - sanitize-filename: 1.6.3 - semver: 7.7.4 - tar: 6.2.1 - temp-file: 3.4.0 - transitivePeerDependencies: - - supports-color - - archiver-utils@2.1.0: - dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - lazystream: 1.0.1 - lodash.defaults: 4.2.0 - lodash.difference: 4.5.0 - lodash.flatten: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.union: 4.6.0 - normalize-path: 3.0.0 - readable-stream: 2.3.8 - - archiver-utils@3.0.4: - dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - lazystream: 1.0.1 - lodash.defaults: 4.2.0 - lodash.difference: 4.5.0 - lodash.flatten: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.union: 4.6.0 - normalize-path: 3.0.0 - readable-stream: 3.6.2 - - archiver@5.3.2: - dependencies: - archiver-utils: 2.1.0 - async: 3.2.6 - buffer-crc32: 0.2.13 - readable-stream: 3.6.2 - readdir-glob: 1.1.3 - tar-stream: 2.2.0 - zip-stream: 4.1.1 - - argparse@2.0.1: {} - - assert-plus@1.0.0: - optional: true - - astral-regex@2.0.0: - optional: true - - async-exit-hook@2.0.1: {} - - async@3.2.6: {} - - asynckit@0.4.0: {} - - at-least-node@1.0.0: {} - - balanced-match@1.0.2: {} - - base64-js@1.5.1: {} - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - bluebird-lst@1.0.9: - dependencies: - bluebird: 3.7.2 - - bluebird@3.7.2: {} - - boolean@3.2.0: - optional: true - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - buffer-crc32@0.2.13: {} - - buffer-equal@1.0.1: {} - - buffer-from@1.1.2: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - builder-util-runtime@9.2.4: - dependencies: - debug: 4.4.3 - sax: 1.4.4 - transitivePeerDependencies: - - supports-color - - builder-util@24.13.1: - dependencies: - 7zip-bin: 5.2.0 - '@types/debug': 4.1.12 - app-builder-bin: 4.0.0 - bluebird-lst: 1.0.9 - builder-util-runtime: 9.2.4 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - fs-extra: 10.1.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - is-ci: 3.0.1 - js-yaml: 4.1.1 - source-map-support: 0.5.21 - stat-mode: 1.0.0 - temp-file: 3.4.0 - transitivePeerDependencies: - - supports-color - - cacheable-lookup@5.0.4: {} - - cacheable-request@7.0.4: - dependencies: - clone-response: 1.0.3 - get-stream: 5.2.0 - http-cache-semantics: 4.2.0 - keyv: 4.5.4 - lowercase-keys: 2.0.0 - normalize-url: 6.1.0 - responselike: 2.0.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chownr@2.0.0: {} - - chromium-pickle-js@0.2.0: {} - - ci-info@3.9.0: {} - - cli-truncate@2.1.0: - dependencies: - slice-ansi: 3.0.0 - string-width: 4.2.3 - optional: true - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone-response@1.0.3: - dependencies: - mimic-response: 1.0.1 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@5.1.0: {} - - compare-version@0.1.2: {} - - compress-commons@4.1.2: - dependencies: - buffer-crc32: 0.2.13 - crc32-stream: 4.0.3 - normalize-path: 3.0.0 - readable-stream: 3.6.2 - - concat-map@0.0.1: {} - - config-file-ts@0.2.6: - dependencies: - glob: 10.5.0 - typescript: 5.9.3 - - core-util-is@1.0.2: - optional: true - - core-util-is@1.0.3: {} - - crc-32@1.2.2: {} - - crc32-stream@4.0.3: - dependencies: - crc-32: 1.2.2 - readable-stream: 3.6.2 - - crc@3.8.0: - dependencies: - buffer: 5.7.1 - optional: true - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - defer-to-connect@2.0.1: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - optional: true - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - optional: true - - delayed-stream@1.0.0: {} - - detect-node@2.1.0: - optional: true - - dir-compare@3.3.0: - dependencies: - buffer-equal: 1.0.1 - minimatch: 3.1.2 - - dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): - dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - fs-extra: 10.1.0 - iconv-lite: 0.6.3 - js-yaml: 4.1.1 - optionalDependencies: - dmg-license: 1.0.11 - transitivePeerDependencies: - - electron-builder-squirrel-windows - - supports-color - - dmg-license@1.0.11: - dependencies: - '@types/plist': 3.0.5 - '@types/verror': 1.10.11 - ajv: 6.12.6 - crc: 3.8.0 - iconv-corefoundation: 1.1.7 - plist: 3.1.0 - smart-buffer: 4.2.0 - verror: 1.10.1 - optional: true - - dotenv-expand@5.1.0: {} - - dotenv@9.0.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - ejs@3.1.10: - dependencies: - jake: 10.9.4 - - electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3): - dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) - archiver: 5.3.2 - builder-util: 24.13.1 - fs-extra: 10.1.0 - transitivePeerDependencies: - - dmg-builder - - supports-color - - electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): - dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - chalk: 4.1.2 - dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) - fs-extra: 10.1.0 - is-ci: 3.0.1 - lazy-val: 1.0.5 - read-config-file: 6.3.2 - simple-update-notifier: 2.0.0 - yargs: 17.7.2 - transitivePeerDependencies: - - electron-builder-squirrel-windows - - supports-color - - electron-publish@24.13.1: - dependencies: - '@types/fs-extra': 9.0.13 - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - chalk: 4.1.2 - fs-extra: 10.1.0 - lazy-val: 1.0.5 - mime: 2.6.0 - transitivePeerDependencies: - - supports-color - - electron@40.3.0: - dependencies: - '@electron/get': 2.0.3 - '@types/node': 24.10.13 - extract-zip: 2.0.1 - transitivePeerDependencies: - - supports-color - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - env-paths@2.2.1: {} - - err-code@2.0.3: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es6-error@4.1.1: - optional: true - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: - optional: true - - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - - extsprintf@1.4.1: - optional: true - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - fs-constants@1.0.0: {} - - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-extra@8.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - - fs-extra@9.1.0: - dependencies: - at-least-node: 1.0.0 - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - - fs.realpath@1.0.0: {} - - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@5.2.0: - dependencies: - pump: 3.0.3 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - global-agent@3.0.0: - dependencies: - boolean: 3.2.0 - es6-error: 4.1.1 - matcher: 3.0.0 - roarr: 2.15.4 - semver: 7.7.4 - serialize-error: 7.0.1 - optional: true - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - optional: true - - gopd@1.2.0: {} - - got@11.8.6: - dependencies: - '@sindresorhus/is': 4.6.0 - '@szmarczak/http-timer': 4.0.6 - '@types/cacheable-request': 6.0.3 - '@types/responselike': 1.0.3 - cacheable-lookup: 5.0.4 - cacheable-request: 7.0.4 - decompress-response: 6.0.0 - http2-wrapper: 1.0.3 - lowercase-keys: 2.0.0 - p-cancelable: 2.1.1 - responselike: 2.0.1 - - graceful-fs@4.2.11: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - optional: true - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hosted-git-info@4.1.0: - dependencies: - lru-cache: 6.0.0 - - http-cache-semantics@4.2.0: {} - - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.0 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - http2-wrapper@1.0.3: - dependencies: - quick-lru: 5.1.1 - resolve-alpn: 1.2.1 - - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - iconv-corefoundation@1.1.7: - dependencies: - cli-truncate: 2.1.0 - node-addon-api: 1.7.2 - optional: true - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-ci@3.0.1: - dependencies: - ci-info: 3.9.0 - - is-fullwidth-code-point@3.0.0: {} - - isarray@1.0.0: {} - - isbinaryfile@4.0.10: {} - - isbinaryfile@5.0.7: {} - - isexe@2.0.0: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jake@10.9.4: - dependencies: - async: 3.2.6 - filelist: 1.0.4 - picocolors: 1.1.1 - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stringify-safe@5.0.1: - optional: true - - json5@2.2.3: {} - - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - lazy-val@1.0.5: {} - - lazystream@1.0.1: - dependencies: - readable-stream: 2.3.8 - - lodash.defaults@4.2.0: {} - - lodash.difference@4.5.0: {} - - lodash.flatten@4.4.0: {} - - lodash.isplainobject@4.0.6: {} - - lodash.union@4.6.0: {} - - lodash@4.17.23: {} - - lowercase-keys@2.0.0: {} - - lru-cache@10.4.3: {} - - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - - matcher@3.0.0: - dependencies: - escape-string-regexp: 4.0.0 - optional: true - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@2.6.0: {} - - mimic-response@1.0.1: {} - - mimic-response@3.1.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} - - minipass@7.1.2: {} - - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - - mkdirp@1.0.4: {} - - ms@2.1.3: {} - - node-addon-api@1.7.2: - optional: true - - normalize-path@3.0.0: {} - - normalize-url@6.1.0: {} - - object-keys@1.1.1: - optional: true - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - p-cancelable@2.1.1: {} - - package-json-from-dist@1.0.1: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - - pend@1.2.0: {} - - picocolors@1.1.1: {} - - plist@3.1.0: - dependencies: - '@xmldom/xmldom': 0.8.11 - base64-js: 1.5.1 - xmlbuilder: 15.1.1 - - process-nextick-args@2.0.1: {} - - progress@2.0.3: {} - - promise-retry@2.0.1: - dependencies: - err-code: 2.0.3 - retry: 0.12.0 - - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - punycode@2.3.1: {} - - quick-lru@5.1.1: {} - - read-config-file@6.3.2: - dependencies: - config-file-ts: 0.2.6 - dotenv: 9.0.2 - dotenv-expand: 5.1.0 - js-yaml: 4.1.1 - json5: 2.2.3 - lazy-val: 1.0.5 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdir-glob@1.1.3: - dependencies: - minimatch: 5.1.6 - - require-directory@2.1.1: {} - - resolve-alpn@1.2.1: {} - - responselike@2.0.1: - dependencies: - lowercase-keys: 2.0.0 - - retry@0.12.0: {} - - roarr@2.15.4: - dependencies: - boolean: 3.2.0 - detect-node: 2.1.0 - globalthis: 1.0.4 - json-stringify-safe: 5.0.1 - semver-compare: 1.0.0 - sprintf-js: 1.1.3 - optional: true - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - sanitize-filename@1.6.3: - dependencies: - truncate-utf8-bytes: 1.0.2 - - sax@1.4.4: {} - - semver-compare@1.0.0: - optional: true - - semver@6.3.1: {} - - semver@7.7.4: {} - - serialize-error@7.0.1: - dependencies: - type-fest: 0.13.1 - optional: true - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@4.1.0: {} - - simple-update-notifier@2.0.0: - dependencies: - semver: 7.7.4 - - slice-ansi@3.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - optional: true - - smart-buffer@4.2.0: - optional: true - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - sprintf-js@1.1.3: - optional: true - - stat-mode@1.0.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - sumchecker@3.0.1: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - 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 - - temp-file@3.4.0: - dependencies: - async-exit-hook: 2.0.1 - fs-extra: 10.1.0 - - tmp-promise@3.0.3: - dependencies: - tmp: 0.2.5 - - tmp@0.2.5: {} - - truncate-utf8-bytes@1.0.2: - dependencies: - utf8-byte-length: 1.0.5 - - type-fest@0.13.1: - optional: true - - typescript@5.9.3: {} - - undici-types@7.16.0: {} - - universalify@0.1.2: {} - - universalify@2.0.1: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - utf8-byte-length@1.0.5: {} - - util-deprecate@1.0.2: {} - - verror@1.10.1: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.4.1 - optional: true - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - - wrappy@1.0.2: {} - - xmlbuilder@15.1.1: {} - - y18n@5.0.8: {} - - yallist@4.0.0: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - - zip-stream@4.1.1: - dependencies: - archiver-utils: 3.0.4 - compress-commons: 4.1.2 - readable-stream: 3.6.2 diff --git a/desktop/preload.js b/desktop/preload.js deleted file mode 100644 index 7d699a21f..000000000 --- a/desktop/preload.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -const { contextBridge, ipcRenderer } = require('electron'); - -contextBridge.exposeInMainWorld('astrbotDesktop', { - isElectron: true, - isElectronRuntime: () => ipcRenderer.invoke('astrbot-desktop:is-electron-runtime'), - getBackendState: () => ipcRenderer.invoke('astrbot-desktop:get-backend-state'), - restartBackend: (authToken) => - ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken), - stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'), - onTrayRestartBackend: (callback) => { - const listener = () => { - if (typeof callback === 'function') { - callback(); - } - }; - ipcRenderer.on('astrbot-desktop:tray-restart-backend', listener); - return () => - ipcRenderer.removeListener('astrbot-desktop:tray-restart-backend', listener); - }, -}); diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs deleted file mode 100644 index 921cf19cb..000000000 --- a/desktop/scripts/build-backend.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..', '..'); -const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend'); -const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller'); -const dataSeparator = process.platform === 'win32' ? ';' : ':'; -const kbStopwordsSrc = path.join( - rootDir, - 'astrbot', - 'core', - 'knowledge_base', - 'retrieval', - 'hit_stopwords.txt', -); -const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval'; -const builtinStarsSrc = path.join(rootDir, 'astrbot', 'builtin_stars'); -const builtinStarsDest = 'astrbot/builtin_stars'; - -const args = [ - 'run', - '--with', - 'pyinstaller', - 'python', - '-m', - 'PyInstaller', - '--noconfirm', - '--clean', - '--onefile', - '--name', - 'astrbot-backend', - '--collect-all', - 'aiosqlite', - '--collect-all', - 'pip', - '--collect-all', - 'bs4', - '--collect-all', - 'readability', - '--collect-all', - 'lxml', - '--collect-all', - 'lxml_html_clean', - '--collect-all', - 'rfc3987_syntax', - '--collect-submodules', - 'astrbot.api', - '--collect-submodules', - 'astrbot.builtin_stars', - '--collect-data', - 'certifi', - '--add-data', - `${builtinStarsSrc}${dataSeparator}${builtinStarsDest}`, - '--add-data', - `${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`, - '--distpath', - outputDir, - '--workpath', - workDir, - '--specpath', - workDir, - path.join(rootDir, 'main.py'), -]; - -const result = spawnSync('uv', args, { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', -}); - -if (result.error) { - console.error(`Failed to run 'uv': ${result.error.message}`); - process.exit(typeof result.status === 'number' ? result.status : 1); -} - -if (result.status !== 0) { - console.error( - `'uv' exited with status ${result.status} while running PyInstaller. ` + - 'Verify that uv and pyinstaller are installed and that arguments are valid.', - ); - process.exit(result.status ?? 1); -} - -process.exit(0); diff --git a/desktop/scripts/prepare-webui.mjs b/desktop/scripts/prepare-webui.mjs deleted file mode 100644 index 404ae7ef9..000000000 --- a/desktop/scripts/prepare-webui.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { cp, mkdir, rm } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..', '..'); -const distDir = path.join(rootDir, 'dashboard', 'dist'); -const targetDir = path.join(rootDir, 'desktop', 'resources', 'webui'); - -if (!existsSync(distDir)) { - console.error('dashboard/dist is missing. Run `pnpm --dir dashboard build` first.'); - process.exit(1); -} - -await rm(targetDir, { recursive: true, force: true }); -await mkdir(targetDir, { recursive: true }); -await cp(distDir, targetDir, { recursive: true }); - -console.log(`Copied WebUI to ${targetDir}`); diff --git a/desktop/scripts/sync-version.mjs b/desktop/scripts/sync-version.mjs deleted file mode 100644 index 08651d75a..000000000 --- a/desktop/scripts/sync-version.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..', '..'); -const desktopPackagePath = path.join(rootDir, 'desktop', 'package.json'); -const pyprojectPath = path.join(rootDir, 'pyproject.toml'); - -function getGitTag() { - const result = spawnSync('git', ['describe', '--tags', '--abbrev=0'], { - cwd: rootDir, - encoding: 'utf8', - }); - if (result.status === 0) { - const tag = result.stdout.trim(); - return tag.length ? tag : null; - } - return null; -} - -function normalizeTag(tag) { - return tag.replace(/^v/i, ''); -} - -async function getPyprojectVersion() { - try { - const data = await readFile(pyprojectPath, 'utf8'); - const match = data.match(/^\s*version\s*=\s*"([^"]+)"/m); - return match ? match[1] : null; - } catch { - return null; - } -} - -const pkgRaw = await readFile(desktopPackagePath, 'utf8'); -const pkg = JSON.parse(pkgRaw); -const tag = getGitTag(); -const versionFromTag = tag ? normalizeTag(tag) : null; -const versionFromPyproject = await getPyprojectVersion(); -const version = versionFromPyproject || versionFromTag || pkg.version; - -if ( - versionFromPyproject && - versionFromTag && - versionFromPyproject !== versionFromTag -) { - console.log( - `Using pyproject version ${versionFromPyproject} (ignoring git tag ${versionFromTag}).`, - ); -} - -if (!version) { - console.warn('No version found to sync.'); - process.exit(0); -} - -if (pkg.version === version) { - console.log(`Desktop version already ${version}`); - process.exit(0); -} - -pkg.version = version; -await writeFile(desktopPackagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); -console.log(`Updated desktop version to ${version}`); diff --git a/main.py b/main.py index be188140c..36c46fca3 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ from astrbot.core.initial_loader import InitialLoader # noqa: E402 from astrbot.core.utils.astrbot_path import ( # noqa: E402 get_astrbot_config_path, get_astrbot_data_path, + get_astrbot_knowledge_base_path, get_astrbot_plugin_path, get_astrbot_root, get_astrbot_site_packages_path, @@ -55,6 +56,7 @@ def check_env() -> None: os.makedirs(get_astrbot_config_path(), exist_ok=True) os.makedirs(get_astrbot_plugin_path(), exist_ok=True) os.makedirs(get_astrbot_temp_path(), exist_ok=True) + os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True) os.makedirs(site_packages_path, exist_ok=True) # 针对问题 #181 的临时解决方案 From 0a517980b777de842e4493375a6534b78ed62f4b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 20 Feb 2026 12:07:42 +0800 Subject: [PATCH 048/109] fix: update feature request template for clarity and consistency in English and Chinese --- .github/ISSUE_TEMPLATE/feature-request.yml | 26 ++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 484959318..c97eb1a4c 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,42 +1,40 @@ -name: '🎉 功能建议' +name: '🎉 Feature Request / 功能建议' title: "[Feature]" -description: 提交建议帮助我们改进。 +description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。 labels: [ "enhancement" ] body: - type: markdown attributes: value: | - 感谢您抽出时间提出新功能建议,请准确解释您的想法。 + Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。 - type: textarea attributes: - label: 描述 - description: 简短描述您的功能建议。 + label: Description / 描述 + description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。 - type: textarea attributes: - label: 使用场景 - description: 你想要发生什么? - placeholder: > - 一个清晰且具体的描述这个功能的使用场景。 + label: Use Case / 使用场景 + description: Please describe the use case for this feature. / 请描述这个功能的使用场景。 - type: checkboxes attributes: - label: 你愿意提交PR吗? + label: Willing to Submit PR? / 是否愿意提交PR? description: > - 这不是必须的,但我们欢迎您的贡献。 + This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激! options: - - label: 是的, 我愿意提交PR! + - label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。 - type: checkboxes attributes: label: Code of Conduct options: - label: > - 我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。 + I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). / required: true - type: markdown attributes: - value: "感谢您填写我们的表单!" \ No newline at end of file + value: "Thank you for filling out our form!" \ No newline at end of file From e469178a6b633db4ffd5a39c0556b2ecf486f2da Mon Sep 17 00:00:00 2001 From: SnowNightt <127504703+SnowNightt@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:55:21 +0800 Subject: [PATCH 049/109] Feat/config leave confirm (#5249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 配置文件增加未保存提示弹窗 * fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗 --- .../config/UnsavedChangesConfirmDialog.vue | 98 ++++++++++ .../i18n/locales/en-US/features/config.json | 13 ++ .../i18n/locales/zh-CN/features/config.json | 13 ++ dashboard/src/views/ConfigPage.vue | 170 ++++++++++++++++-- 4 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 dashboard/src/components/config/UnsavedChangesConfirmDialog.vue diff --git a/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue b/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue new file mode 100644 index 000000000..f81f1167f --- /dev/null +++ b/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 5f3af4135..4b726ae3c 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -112,5 +112,18 @@ "addToConfig": "Added to config", "fileCount": "Files: {count}", "done": "Done" + }, + "unsavedChangesWarning": { + "dialogTitle": "Unsaved changes", + "leavePage": "You have unsaved changes. Do you want to save before leaving?", + "switchConfig": "Switching config will discard unsaved changes. Do you want to save first?", + "options": { + "save": "Save", + "saveAndSwitch": "Save and switch", + "discardAndSwitch": "Discard changes and switch", + "closeCard": "Close the pop-up window", + "confirm": "confirm", + "cancel": "cancel" + } } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 39564a717..e7cd90408 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -112,5 +112,18 @@ "addToConfig": "已加入配置", "fileCount": "文件:{count}", "done": "完成" + }, + "unsavedChangesWarning": { + "dialogTitle": "未保存的更改", + "leavePage": "当前配置有未保存的更改,切换前是否保存?", + "switchConfig": "切换配置文件会丢失当前未保存的更改,是否先保存?", + "options": { + "save": "保存", + "saveAndSwitch": "保存并切换", + "discardAndSwitch": "放弃更改并切换", + "closeCard": "关闭弹窗", + "confirm": "确定", + "cancel": "取消" + } } } diff --git a/dashboard/src/views/ConfigPage.vue b/dashboard/src/views/ConfigPage.vue index fee392554..7c50fbb58 100644 --- a/dashboard/src/views/ConfigPage.vue +++ b/dashboard/src/views/ConfigPage.vue @@ -7,7 +7,7 @@
- @@ -191,6 +191,10 @@
+ + + + @@ -206,6 +210,7 @@ import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'; +import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue'; export default { name: 'ConfigPage', @@ -213,7 +218,8 @@ export default { AstrBotCoreConfigWrapper, VueMonacoEditor, WaitingForRestart, - StandaloneChat + StandaloneChat, + UnsavedChangesConfirmDialog }, props: { initialConfigId: { @@ -233,6 +239,40 @@ export default { }; }, +// 检查未保存的更改 + async beforeRouteLeave(to, from, next) { + if (this.hasUnsavedChanges) { + const confirmed = await this.$refs.unsavedChangesDialog?.open({ + title: this.tm('unsavedChangesWarning.dialogTitle'), + message: this.tm('unsavedChangesWarning.leavePage'), + confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`, + cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`, + closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"` + }); + // 关闭弹窗不跳转 + if (confirmed === 'close') { + next(false); + } else if (confirmed) { + const result = await this.updateConfig(); + if (this.isSystemConfig) { + next(false); + } else { + if (result?.success) { + await new Promise(resolve => setTimeout(resolve, 800)); + next(); + } else { + next(false); + } + } + } else { + this.hasUnsavedChanges = false; + next(); + } + } else { + next(); + } + }, + computed: { messages() { return { @@ -243,6 +283,11 @@ export default { configApplyError: this.tm('messages.configApplyError') }; }, + // 检查配置是否变化 + configHasChanges() { + if (!this.originalConfigData || !this.config_data) return false; + return JSON.stringify(this.originalConfigData) !== JSON.stringify(this.config_data); + }, configInfoNameList() { return this.configInfoList.map(info => info.name); }, @@ -269,8 +314,16 @@ export default { config_data_str(val) { this.config_data_has_changed = true; }, - '$route.fullPath'(newVal) { - this.syncConfigTypeFromHash(newVal); + config_data: { + deep: true, + handler() { + if (this.fetched) { + this.hasUnsavedChanges = this.configHasChanges; + } + } + }, + async '$route.fullPath'(newVal) { + await this.syncConfigTypeFromHash(newVal); }, initialConfigId(newVal) { if (!newVal) { @@ -309,6 +362,7 @@ export default { // 多配置文件管理 selectedConfigID: null, // 用于存储当前选中的配置项信息 + currentConfigId: null, // 跟踪当前正在编辑的配置id configInfoList: [], configFormData: { name: '', @@ -318,6 +372,11 @@ export default { // 测试聊天 testChatDrawer: false, testConfigId: null, + + // 未保存的更改状态 + hasUnsavedChanges: false, + // 存储原始配置 + originalConfigData: null, } }, mounted() { @@ -334,6 +393,13 @@ export default { // 监听语言切换事件,重新加载配置以获取插件的 i18n 数据 window.addEventListener('astrbot-locale-changed', this.handleLocaleChange); + + // 保存初始配置 + this.$watch('config_data', (newVal) => { + if (!this.originalConfigData && newVal) { + this.originalConfigData = JSON.parse(JSON.stringify(newVal)); + } + }, { immediate: false, deep: true }); }, beforeUnmount() { @@ -362,14 +428,14 @@ export default { const cleanHash = rawHash.slice(lastHashIndex + 1); return cleanHash === 'system' || cleanHash === 'normal' ? cleanHash : null; }, - syncConfigTypeFromHash(hash) { + async syncConfigTypeFromHash(hash) { const configType = this.extractConfigTypeFromHash(hash); if (!configType || configType === this.configType) { return false; } this.configType = configType; - this.onConfigTypeToggle(); + await this.onConfigTypeToggle(); return true; }, getConfigInfoList(abconf_id) { @@ -382,6 +448,7 @@ export default { for (let i = 0; i < this.configInfoList.length; i++) { if (this.configInfoList[i].id === abconf_id) { this.selectedConfigID = this.configInfoList[i].id; + this.currentConfigId = this.configInfoList[i].id; this.getConfig(abconf_id); matched = true; break; @@ -391,6 +458,7 @@ export default { if (!matched && this.configInfoList.length) { // 当找不到目标配置时,默认展示列表中的第一个配置 this.selectedConfigID = this.configInfoList[0].id; + this.currentConfigId = this.configInfoList[0].id; this.getConfig(this.selectedConfigID); } } @@ -418,6 +486,14 @@ export default { this.fetched = true this.metadata = res.data.data.metadata; this.configContentKey += 1; + // 获取配置后更新 + this.$nextTick(() => { + this.originalConfigData = JSON.parse(JSON.stringify(this.config_data)); + this.hasUnsavedChanges = false; + if (!this.isSystemConfig) { + this.currentConfigId = abconf_id || this.selectedConfigID; + } + }); }).catch((err) => { this.save_message = this.messages.loadError; this.save_message_snack = true; @@ -437,27 +513,37 @@ export default { postData.conf_id = this.selectedConfigID; } - axios.post('/api/config/astrbot/update', postData).then((res) => { + return axios.post('/api/config/astrbot/update', postData).then((res) => { if (res.data.status === "ok") { this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data); this.save_message = res.data.message || this.messages.saveSuccess; this.save_message_snack = true; this.save_message_success = "success"; + this.onConfigSaved(); if (this.isSystemConfig) { restartAstrBotRuntime(this.$refs.wfr).catch(() => {}) } + return { success: true }; } else { this.save_message = res.data.message || this.messages.saveError; this.save_message_snack = true; this.save_message_success = "error"; + return { success: false }; } }).catch((err) => { this.save_message = this.messages.saveError; this.save_message_snack = true; this.save_message_success = "error"; + return { success: false }; }); }, + // 重置未保存状态 + onConfigSaved() { + this.hasUnsavedChanges = false; + this.originalConfigData = JSON.parse(JSON.stringify(this.config_data)); + }, + configToString() { this.config_data_str = JSON.stringify(this.config_data, null, 2); this.config_data_has_changed = false; @@ -497,7 +583,7 @@ export default { this.save_message_success = "error"; }); }, - onConfigSelect(value) { + async onConfigSelect(value) { if (value === '_%manage%_') { this.configManageDialog = true; // 重置选择到之前的值 @@ -506,7 +592,44 @@ export default { this.getConfig(this.selectedConfigID); }); } else { - this.getConfig(value); + // 检查是否有未保存的更改 + if (this.hasUnsavedChanges) { + // 获取之前正在编辑的配置id + const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default'); + const message = this.tm('unsavedChangesWarning.switchConfig'); + const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({ + title: this.tm('unsavedChangesWarning.dialogTitle'), + message: message, + confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`, + cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`, + closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"` + }); + // 关闭弹窗不切换 + if (saveAndSwitch === 'close') { + return; + } + if (saveAndSwitch) { + // 设置临时变量保存切换后的id + const currentSelectedId = this.selectedConfigID; + // 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的 + this.selectedConfigID = prevConfigId; + const result = await this.updateConfig(); + this.selectedConfigID = currentSelectedId; + if (result?.success) { + this.selectedConfigID = value; + this.getConfig(value); + } + return; + } else { + // 取消保存并切换配置 + this.selectedConfigID = value; + this.getConfig(value); + } + } else { + // 无未保存更改直接切换 + this.selectedConfigID = value; + this.getConfig(value); + } } }, startCreateConfig() { @@ -600,7 +723,34 @@ export default { this.save_message_success = "error"; }); }, - onConfigTypeToggle() { + async onConfigTypeToggle() { + // 检查是否有未保存的更改 + if (this.hasUnsavedChanges) { + const message = this.tm('unsavedChangesWarning.leavePage'); + const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({ + title: this.tm('unsavedChangesWarning.dialogTitle'), + message: message, + confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`, + cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`, + closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"` + }); + // 关闭弹窗 + if (saveAndSwitch === 'close') { + // 恢复路由 + const originalHash = this.isSystemConfig ? '#system' : '#normal'; + this.$router.replace('/config' + originalHash); + this.configType = this.isSystemConfig ? 'system' : 'normal'; + return; + } + if (saveAndSwitch) { + await this.updateConfig(); + // 系统配置保存后不跳转 + if (this.isSystemConfig) { + this.$router.replace('/config#system'); + return; + } + } + } this.isSystemConfig = this.configType === 'system'; this.fetched = false; // 重置加载状态 From 9a7a594cb5d57a1b984d5ab965a2b5581fec2ab2 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:35:45 +0800 Subject: [PATCH 050/109] feat: add support for plugin astrbot-version and platform requirement checks (#5235) * feat: add support for plugin astrbot-version and platform requirement checks * fix: remove unsupported platform and version constraints from metadata.yaml * fix: remove restriction on 'v' in astrbot_version specification format * ruff format --- .../core/star/filter/platform_adapter_type.py | 3 + astrbot/core/star/star.py | 6 + astrbot/core/star/star_manager.py | 108 ++++++- astrbot/dashboard/routes/plugin.py | 65 ++++- .../components/extension/MarketPluginCard.vue | 50 +++- .../src/components/shared/ExtensionCard.vue | 51 ++++ .../locales/en-US/features/extension.json | 14 +- .../locales/zh-CN/features/extension.json | 14 +- dashboard/src/stores/common.js | 8 + dashboard/src/utils/platformUtils.js | 26 ++ dashboard/src/views/ExtensionPage.vue | 266 +++++++++++++++++- pyproject.toml | 1 + requirements.txt | 1 + 13 files changed, 585 insertions(+), 28 deletions(-) diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py index 1630650a9..3ac8019ef 100644 --- a/astrbot/core/star/filter/platform_adapter_type.py +++ b/astrbot/core/star/filter/platform_adapter_type.py @@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag): QQOFFICIAL = enum.auto() TELEGRAM = enum.auto() WECOM = enum.auto() + WECOM_AI_BOT = enum.auto() LARK = enum.auto() DINGTALK = enum.auto() DISCORD = enum.auto() @@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag): | QQOFFICIAL | TELEGRAM | WECOM + | WECOM_AI_BOT | LARK | DINGTALK | DISCORD @@ -44,6 +46,7 @@ ADAPTER_NAME_2_TYPE = { "qq_official": PlatformAdapterType.QQOFFICIAL, "telegram": PlatformAdapterType.TELEGRAM, "wecom": PlatformAdapterType.WECOM, + "wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT, "lark": PlatformAdapterType.LARK, "dingtalk": PlatformAdapterType.DINGTALK, "discord": PlatformAdapterType.DISCORD, diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index c5b7b1243..8cebbd772 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -61,6 +61,12 @@ class StarMetadata: logo_path: str | None = None """插件 Logo 的路径""" + support_platforms: list[str] = field(default_factory=list) + """插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)""" + + astrbot_version: str | None = None + """插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)""" + def __str__(self) -> str: return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 51f50aedf..93512bde2 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -11,10 +11,13 @@ import traceback from types import ModuleType import yaml +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version from astrbot.core import logger, pip_installer, sp from astrbot.core.agent.handoff import FunctionTool, HandoffTool from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.config.default import VERSION from astrbot.core.platform.register import unregister_platform_adapters_by_module from astrbot.core.provider.register import llm_tools from astrbot.core.utils.astrbot_path import ( @@ -40,6 +43,10 @@ except ImportError: logger.warning("未安装 watchfiles,无法实现插件的热重载。") +class PluginVersionIncompatibleError(Exception): + """Raised when plugin astrbot_version is incompatible with current AstrBot.""" + + class PluginManager: def __init__(self, context: Context, config: AstrBotConfig) -> None: self.updator = PluginUpdator() @@ -268,10 +275,58 @@ class PluginManager: version=metadata["version"], repo=metadata["repo"] if "repo" in metadata else None, display_name=metadata.get("display_name", None), + support_platforms=( + [ + platform_id + for platform_id in metadata["support_platforms"] + if isinstance(platform_id, str) + ] + if isinstance(metadata.get("support_platforms"), list) + else [] + ), + astrbot_version=( + metadata["astrbot_version"] + if isinstance(metadata.get("astrbot_version"), str) + else None + ), ) return metadata + @staticmethod + def _validate_astrbot_version_specifier( + version_spec: str | None, + ) -> tuple[bool, str | None]: + if not version_spec: + return True, None + + normalized_spec = version_spec.strip() + if not normalized_spec: + return True, None + + try: + specifier = SpecifierSet(normalized_spec) + except InvalidSpecifier: + return ( + False, + "astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。", + ) + + try: + current_version = Version(VERSION) + except InvalidVersion: + return ( + False, + f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。", + ) + + if current_version not in specifier: + return ( + False, + f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}", + ) + return True, None + @staticmethod def _get_plugin_related_modules( plugin_root_dir: str, @@ -408,7 +463,12 @@ class PluginManager: return result - async def load(self, specified_module_path=None, specified_dir_name=None): + async def load( + self, + specified_module_path=None, + specified_dir_name=None, + ignore_version_check: bool = False, + ): """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -507,10 +567,25 @@ class PluginManager: metadata.version = metadata_yaml.version metadata.repo = metadata_yaml.repo metadata.display_name = metadata_yaml.display_name + metadata.support_platforms = metadata_yaml.support_platforms + metadata.astrbot_version = metadata_yaml.astrbot_version except Exception as e: logger.warning( f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。", ) + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + logger.info(metadata) metadata.config = plugin_config p_name = (metadata.name or "unknown").lower().replace("/", "_") @@ -621,6 +696,19 @@ class PluginManager: ) if not metadata: raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。") + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + metadata.star_cls = obj metadata.config = plugin_config metadata.module = module @@ -754,7 +842,9 @@ class PluginManager: f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}", ) - async def install_plugin(self, repo_url: str, proxy=""): + async def install_plugin( + self, repo_url: str, proxy: str = "", ignore_version_check: bool = False + ): """从仓库 URL 安装插件 从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中 @@ -788,7 +878,10 @@ class PluginManager: # reload the plugin dir_name = os.path.basename(plugin_path) - success, error_message = await self.load(specified_dir_name=dir_name) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) if not success: raise Exception( error_message @@ -1092,7 +1185,9 @@ class PluginManager: await self.reload(plugin_name) - async def install_plugin_from_file(self, zip_file_path: str): + async def install_plugin_from_file( + self, zip_file_path: str, ignore_version_check: bool = False + ): dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) @@ -1148,7 +1243,10 @@ class PluginManager: except BaseException as e: logger.warning(f"删除插件压缩包失败: {e!s}") # await self.reload() - success, error_message = await self.load(specified_dir_name=dir_name) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) if not success: raise Exception( error_message diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 25fed7d27..e1cfe12cd 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -19,7 +19,10 @@ 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_handler import EventType, star_handlers_registry -from astrbot.core.star.star_manager import PluginManager +from astrbot.core.star.star_manager import ( + PluginManager, + PluginVersionIncompatibleError, +) from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, get_astrbot_temp_path, @@ -49,6 +52,7 @@ class PluginRoute(Route): super().__init__(context) self.routes = { "/plugin/get": ("GET", self.get_plugins), + "/plugin/check-compat": ("POST", self.check_plugin_compatibility), "/plugin/install": ("POST", self.install_plugin), "/plugin/install-upload": ("POST", self.install_plugin_upload), "/plugin/update": ("POST", self.update_plugin), @@ -81,6 +85,27 @@ class PluginRoute(Route): self._logo_cache = {} + async def check_plugin_compatibility(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, + } + ) + .__dict__ + ) + except Exception as e: + return Response().error(str(e)).__dict__ + async def reload_failed_plugins(self): if DEMO_MODE: return ( @@ -121,7 +146,7 @@ class PluginRoute(Route): try: success, message = await self.plugin_manager.reload(plugin_name) if not success: - return Response().error(message).__dict__ + return Response().error(message or "插件重载失败").__dict__ return Response().ok(None, "重载成功。").__dict__ except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") @@ -349,6 +374,8 @@ class PluginRoute(Route): ), "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, } # 检查是否为全空的幽灵插件 if not any( @@ -443,6 +470,7 @@ class PluginRoute(Route): post_data = await request.get_json() repo_url = post_data["url"] + ignore_version_check = bool(post_data.get("ignore_version_check", False)) proxy: str = post_data.get("proxy", None) if proxy: @@ -450,10 +478,23 @@ class PluginRoute(Route): try: logger.info(f"正在安装插件 {repo_url}") - plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy) + plugin_info = await self.plugin_manager.install_plugin( + repo_url, + proxy, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() 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__ @@ -469,16 +510,32 @@ class PluginRoute(Route): try: file = await request.files file = file["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) + plugin_info = await self.plugin_manager.install_plugin_from_file( + file_path, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() 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__ diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue index 03425553d..a32bf3b85 100644 --- a/dashboard/src/components/extension/MarketPluginCard.vue +++ b/dashboard/src/components/extension/MarketPluginCard.vue @@ -1,5 +1,6 @@ + + diff --git a/dashboard/src/components/shared/PersonaSelector.vue b/dashboard/src/components/shared/PersonaSelector.vue index a77f27640..07a5358d3 100644 --- a/dashboard/src/components/shared/PersonaSelector.vue +++ b/dashboard/src/components/shared/PersonaSelector.vue @@ -188,10 +188,16 @@ function openEditPersona(persona: Persona) { // 人格保存成功(创建或编辑) async function handlePersonaSaved(message: string) { console.log('人格保存成功:', message) + const savedPersonaId = editingPersona.value?.persona_id || '' showPersonaDialog.value = false editingPersona.value = null // 刷新当前文件夹的人格列表 await loadPersonasInFolder(currentFolderId.value) + window.dispatchEvent( + new CustomEvent('astrbot:persona-saved', { + detail: { persona_id: savedPersonaId } + }) + ) } // 错误处理 diff --git a/dashboard/src/i18n/locales/en-US/core/shared.json b/dashboard/src/i18n/locales/en-US/core/shared.json index cc4b81094..d3c3e59fe 100644 --- a/dashboard/src/i18n/locales/en-US/core/shared.json +++ b/dashboard/src/i18n/locales/en-US/core/shared.json @@ -62,6 +62,23 @@ "rootFolder": "All Personas", "emptyFolder": "This folder is empty" }, + "personaQuickPreview": { + "title": "Quick Persona Preview", + "loading": "Loading...", + "noPersonaSelected": "No persona selected", + "personaNotFound": "Persona details not found", + "systemPromptLabel": "System Prompt", + "toolsLabel": "Tools", + "skillsLabel": "Skills", + "originLabel": "Origin", + "originNameLabel": "Origin Name", + "allTools": "All tools available", + "allToolsWithCount": "All tools available ({count})", + "noTools": "No tools configured", + "allSkills": "All Skills available", + "allSkillsWithCount": "All Skills available ({count})", + "noSkills": "No Skills configured" + }, "t2iTemplateEditor": { "buttonText": "Customize T2I Template", "dialogTitle": "Customize Text-to-Image HTML Template", diff --git a/dashboard/src/i18n/locales/en-US/features/subagent.json b/dashboard/src/i18n/locales/en-US/features/subagent.json index b72ef1b40..355d923c9 100644 --- a/dashboard/src/i18n/locales/en-US/features/subagent.json +++ b/dashboard/src/i18n/locales/en-US/features/subagent.json @@ -19,7 +19,8 @@ "enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set." }, "section": { - "title": "SubAgents" + "title": "SubAgents", + "globalSettings": "Global Settings" }, "cards": { "statusEnabled": "Enabled", diff --git a/dashboard/src/i18n/locales/zh-CN/core/shared.json b/dashboard/src/i18n/locales/zh-CN/core/shared.json index 9831b4eb1..74b7fa20b 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/shared.json +++ b/dashboard/src/i18n/locales/zh-CN/core/shared.json @@ -62,6 +62,23 @@ "rootFolder": "全部人格", "emptyFolder": "此文件夹为空" }, + "personaQuickPreview": { + "title": "快速预览", + "loading": "加载中...", + "noPersonaSelected": "未选择人格", + "personaNotFound": "未找到该人格的详情", + "systemPromptLabel": "系统提示词", + "toolsLabel": "工具", + "skillsLabel": "技能(Skills)", + "originLabel": "来源", + "originNameLabel": "来源名称", + "allTools": "全部工具可用", + "allToolsWithCount": "全部工具可用({count})", + "noTools": "未配置工具", + "allSkills": "全部 Skills 可用", + "allSkillsWithCount": "全部 Skills 可用({count})", + "noSkills": "未配置 Skills" + }, "t2iTemplateEditor": { "buttonText": "自定义 T2I 模板", "dialogTitle": "自定义文转图 HTML 模板", diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index ac1f9f146..b2484844e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -24,7 +24,7 @@ "presetDialogsHelp": "添加一些预设的对话来帮助机器人更好地理解角色设定。", "userMessage": "用户消息", "assistantMessage": "AI 回答", - "tools": "工具选择", + "tools": "工具 / MCP 工具选择", "toolsHelp": "为这个人格选择可用的外部工具。外部工具给了 AI 接触外部环境的能力,如搜索、计算、获取信息等。", "toolsSelection": "工具选择操作", "selectAllTools": "选择所有工具", diff --git a/dashboard/src/i18n/locales/zh-CN/features/subagent.json b/dashboard/src/i18n/locales/zh-CN/features/subagent.json index 2e210720b..014422780 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/subagent.json +++ b/dashboard/src/i18n/locales/zh-CN/features/subagent.json @@ -19,7 +19,8 @@ "enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。" }, "section": { - "title": "SubAgents" + "title": "SubAgents 配置", + "globalSettings": "全局设置" }, "cards": { "statusEnabled": "启用", @@ -28,7 +29,8 @@ "transferPrefix": "transfer_to_{name}", "switchLabel": "启用", "previewTitle": "预览:主 LLM 将看到的 handoff 工具", - "personaChip": "Persona: {id}" + "personaChip": "Persona: {id}", + "noDescription": "暂无描述" }, "form": { "nameLabel": "Agent 名称(用于 transfer_to_{name})", diff --git a/dashboard/src/views/ConversationPage.vue b/dashboard/src/views/ConversationPage.vue index ff53301b1..3d25855f3 100644 --- a/dashboard/src/views/ConversationPage.vue +++ b/dashboard/src/views/ConversationPage.vue @@ -1121,7 +1121,7 @@ export default { .text-truncate { display: inline-block; - max-width: 100px; + /* max-width: 100px; */ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue index 3870f2d0c..18e057e83 100644 --- a/dashboard/src/views/SubAgentPage.vue +++ b/dashboard/src/views/SubAgentPage.vue @@ -1,155 +1,249 @@ @@ -158,9 +252,12 @@ import { computed, onMounted, ref } from 'vue' import axios from 'axios' import ProviderSelector from '@/components/shared/ProviderSelector.vue' +import PersonaSelector from '@/components/shared/PersonaSelector.vue' +import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue' import { useModuleI18n } from '@/i18n/composables' type SubAgentItem = { + __key: string name: string persona_id: string @@ -196,9 +293,6 @@ const cfg = ref({ agents: [] }) -const personaOptions = ref<{ title: string; value: string }[]>([]) -const personaLoading = ref(false) - const mainStateDescription = computed(() => cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled') ) @@ -244,24 +338,6 @@ async function loadConfig() { } } -async function loadPersonas() { - personaLoading.value = true - try { - const res = await axios.get('/api/persona/list') - if (res.data.status === 'ok') { - const list = Array.isArray(res.data.data) ? res.data.data : [] - personaOptions.value = list.map((p: any) => ({ - title: p.persona_id, - value: p.persona_id - })) - } - } catch (e: any) { - toast(e?.response?.data?.message || tm('messages.loadPersonaFailed'), 'error') - } finally { - personaLoading.value = false - } -} - function addAgent() { cfg.value.agents.push({ __key: `${Date.now()}_${Math.random().toString(16).slice(2)}`, @@ -333,7 +409,7 @@ async function save() { } async function reload() { - await Promise.all([loadConfig(), loadPersonas()]) + await Promise.all([loadConfig()]) } onMounted(() => { @@ -343,101 +419,21 @@ onMounted(() => { - - From 46152d3faf256cd2640b57d266adaa9df12b87a8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 20 Feb 2026 15:54:02 +0800 Subject: [PATCH 055/109] fix: enhance PersonaForm layout and improve tool selection display --- .../src/components/shared/PersonaForm.vue | 86 +++++++++++-------- .../src/views/persona/PersonaManager.vue | 20 ++++- 2 files changed, 70 insertions(+), 36 deletions(-) diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index 781420f7d..de1c508dd 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -1,36 +1,30 @@