From 80e1231e9adb9f94a605f73dc1d8962bbd62f5ec Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:26:27 +0800 Subject: [PATCH 01/49] feat: adding support for media and quoted message attachments for feishu (#5018) --- astrbot/core/astr_main_agent.py | 31 ++ .../platform/sources/lark/lark_adapter.py | 436 +++++++++++++++--- 2 files changed, 393 insertions(+), 74 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 4e70f3d59..6a14f48e8 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -867,6 +867,8 @@ async def build_main_agent( return None req.prompt = event.message_str[len(config.provider_wake_prefix) :] + + # media files attachments for comp in event.message_obj.message: if isinstance(comp, Image): image_path = await comp.convert_to_file_path() @@ -882,6 +884,35 @@ async def build_main_agent( text=f"[File Attachment: name {file_name}, path {file_path}]" ) ) + # quoted message attachments + reply_comps = [ + comp + for comp in event.message_obj.message + if isinstance(comp, Reply) and comp.chain + ] + for comp in reply_comps: + if not comp.chain: + continue + for reply_comp in comp.chain: + if isinstance(reply_comp, Image): + image_path = await reply_comp.convert_to_file_path() + req.image_urls.append(image_path) + req.extra_user_content_parts.append( + TextPart( + text=f"[Image Attachment in quoted message: path {image_path}]" + ) + ) + elif isinstance(reply_comp, File): + file_path = await reply_comp.get_file() + file_name = reply_comp.name or os.path.basename(file_path) + req.extra_user_content_parts.append( + TextPart( + text=( + f"[File Attachment in quoted message: " + f"name {file_name}, path {file_path}]" + ) + ) + ) conversation = await _get_session_conv(event, plugin_context) req.conversation = conversation diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index e76572768..be1c81c26 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -3,10 +3,13 @@ import base64 import json import re import time +from pathlib import Path from typing import Any, cast +from uuid import uuid4 import lark_oapi as lark from lark_oapi.api.im.v1 import ( + GetMessageRequest, GetMessageResourceRequest, ) from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor @@ -22,6 +25,7 @@ from astrbot.api.platform import ( PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter @@ -91,6 +95,347 @@ class LarkPlatformAdapter(Platform): self.event_id_timestamps: dict[str, float] = {} + async def _download_message_resource( + self, + *, + message_id: str, + file_key: str, + resource_type: str, + ) -> bytes | None: + if self.lark_api.im is None: + logger.error("[Lark] API Client im 模块未初始化") + return None + + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + response = await self.lark_api.im.v1.message_resource.aget(request) + if not response.success(): + logger.error( + f"[Lark] 下载消息资源失败 type={resource_type}, key={file_key}, " + f"code={response.code}, msg={response.msg}", + ) + return None + + if response.file is None: + logger.error(f"[Lark] 消息资源响应中不包含文件流: {file_key}") + return None + + return response.file.read() + + @staticmethod + def _build_message_str_from_components( + components: list[Comp.BaseMessageComponent], + ) -> str: + parts: list[str] = [] + for comp in components: + if isinstance(comp, Comp.Plain): + text = comp.text.strip() + if text: + parts.append(text) + elif isinstance(comp, Comp.At): + name = str(comp.name or comp.qq or "").strip() + if name: + parts.append(f"@{name}") + elif isinstance(comp, Comp.Image): + parts.append("[image]") + elif isinstance(comp, Comp.File): + parts.append(str(comp.name or "[file]")) + elif isinstance(comp, Comp.Record): + parts.append("[audio]") + elif isinstance(comp, Comp.Video): + parts.append("[video]") + + return " ".join(parts).strip() + + @staticmethod + def _parse_post_content(content: dict[str, Any]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in content.get("content", []): + if isinstance(item, list): + for comp in item: + if isinstance(comp, dict): + result.append(comp) + elif isinstance(item, dict): + result.append(item) + return result + + @staticmethod + def _build_at_map(mentions: list[Any] | None) -> dict[str, Comp.At]: + at_map: dict[str, Comp.At] = {} + if not mentions: + return at_map + + for mention in mentions: + key = getattr(mention, "key", None) + if not key: + continue + + mention_id = getattr(mention, "id", None) + open_id = "" + if mention_id is not None: + if hasattr(mention_id, "open_id"): + open_id = getattr(mention_id, "open_id", "") or "" + else: + open_id = str(mention_id) + + mention_name = str(getattr(mention, "name", "") or "") + at_map[key] = Comp.At(qq=open_id, name=mention_name) + + return at_map + + async def _parse_message_components( + self, + *, + message_id: str | None, + message_type: str, + content: dict[str, Any], + at_map: dict[str, Comp.At], + ) -> list[Comp.BaseMessageComponent]: + components: list[Comp.BaseMessageComponent] = [] + + if message_type == "text": + message_str_raw = str(content.get("text", "")) + at_pattern = r"(@_user_\d+)" + parts = re.split(at_pattern, message_str_raw) + for part in parts: + segment = part.strip() + if not segment: + continue + if segment in at_map: + components.append(at_map[segment]) + else: + components.append(Comp.Plain(segment)) + return components + + if message_type in ("post", "image"): + if message_type == "image": + comp_list = [ + { + "tag": "img", + "image_key": content.get("image_key"), + }, + ] + else: + comp_list = self._parse_post_content(content) + + for comp in comp_list: + tag = comp.get("tag") + if tag == "at": + user_key = str(comp.get("user_id", "")) + if user_key in at_map: + components.append(at_map[user_key]) + elif tag == "text": + text = str(comp.get("text", "")).strip() + if text: + components.append(Comp.Plain(text)) + elif tag == "a": + text = str(comp.get("text", "")).strip() + href = str(comp.get("href", "")).strip() + if text and href: + components.append(Comp.Plain(f"{text}({href})")) + elif text: + components.append(Comp.Plain(text)) + elif tag == "img": + image_key = str(comp.get("image_key", "")).strip() + if not image_key: + continue + if not message_id: + logger.error("[Lark] 图片消息缺少 message_id") + continue + image_bytes = await self._download_message_resource( + message_id=message_id, + file_key=image_key, + resource_type="image", + ) + if image_bytes is None: + continue + image_base64 = base64.b64encode(image_bytes).decode() + components.append(Comp.Image.fromBase64(image_base64)) + elif tag == "media": + file_key = str(comp.get("file_key", "")).strip() + file_name = ( + str(comp.get("file_name", "")).strip() or "lark_media.mp4" + ) + if not file_key: + continue + if not message_id: + logger.error("[Lark] 富文本视频消息缺少 message_id") + continue + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="post_media", + file_name=file_name, + default_suffix=".mp4", + ) + if file_path: + components.append(Comp.Video(file=file_path, path=file_path)) + + return components + + if message_type == "file": + file_key = str(content.get("file_key", "")).strip() + file_name = str(content.get("file_name", "")).strip() or "lark_file" + if not message_id: + logger.error("[Lark] 文件消息缺少 message_id") + return components + if not file_key: + logger.error("[Lark] 文件消息缺少 file_key") + return components + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="file", + file_name=file_name, + ) + if file_path: + components.append(Comp.File(name=file_name, file=file_path)) + return components + + if message_type == "audio": + file_key = str(content.get("file_key", "")).strip() + if not message_id: + logger.error("[Lark] 音频消息缺少 message_id") + return components + if not file_key: + logger.error("[Lark] 音频消息缺少 file_key") + return components + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="audio", + default_suffix=".opus", + ) + if file_path: + components.append(Comp.Record(file=file_path, url=file_path)) + return components + + if message_type == "media": + file_key = str(content.get("file_key", "")).strip() + file_name = str(content.get("file_name", "")).strip() or "lark_media.mp4" + if not message_id: + logger.error("[Lark] 视频消息缺少 message_id") + return components + if not file_key: + logger.error("[Lark] 视频消息缺少 file_key") + return components + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="media", + file_name=file_name, + default_suffix=".mp4", + ) + if file_path: + components.append(Comp.Video(file=file_path, path=file_path)) + return components + + return components + + async def _build_reply_from_parent_id( + self, + parent_message_id: str, + ) -> Comp.Reply | None: + if self.lark_api.im is None: + logger.error("[Lark] API Client im 模块未初始化") + return None + + request = GetMessageRequest.builder().message_id(parent_message_id).build() + response = await self.lark_api.im.v1.message.aget(request) + if not response.success(): + logger.error( + f"[Lark] 获取引用消息失败 id={parent_message_id}, " + f"code={response.code}, msg={response.msg}", + ) + return None + + if response.data is None or not response.data.items: + logger.error( + f"[Lark] 引用消息响应为空 id={parent_message_id}", + ) + return None + + parent_message = response.data.items[0] + quoted_message_id = parent_message.message_id or parent_message_id + quoted_sender_id = ( + parent_message.sender.id + if parent_message.sender and parent_message.sender.id + else "unknown" + ) + quoted_time_raw = parent_message.create_time or 0 + quoted_time = ( + quoted_time_raw // 1000 + if isinstance(quoted_time_raw, int) and quoted_time_raw > 10**11 + else quoted_time_raw + ) + quoted_content = ( + parent_message.body.content if parent_message.body else "" + ) or "" + quoted_type = parent_message.msg_type or "" + quoted_content_json: dict[str, Any] = {} + if quoted_content: + try: + parsed = json.loads(quoted_content) + if isinstance(parsed, dict): + quoted_content_json = parsed + except json.JSONDecodeError: + logger.warning( + f"[Lark] 解析引用消息内容失败 id={quoted_message_id}", + ) + + quoted_at_map = self._build_at_map(parent_message.mentions) + quoted_chain = await self._parse_message_components( + message_id=quoted_message_id, + message_type=quoted_type, + content=quoted_content_json, + at_map=quoted_at_map, + ) + quoted_text = self._build_message_str_from_components(quoted_chain) + sender_nickname = ( + quoted_sender_id[:8] if quoted_sender_id != "unknown" else "unknown" + ) + + return Comp.Reply( + id=quoted_message_id, + chain=quoted_chain, + sender_id=quoted_sender_id, + sender_nickname=sender_nickname, + time=quoted_time, + message_str=quoted_text, + text=quoted_text, + ) + + async def _download_file_resource_to_temp( + self, + *, + message_id: str, + file_key: str, + message_type: str, + file_name: str = "", + default_suffix: str = ".bin", + ) -> str | None: + file_bytes = await self._download_message_resource( + message_id=message_id, + file_key=file_key, + resource_type="file", + ) + if file_bytes is None: + return None + + suffix = Path(file_name).suffix if file_name else default_suffix + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + temp_path = ( + temp_dir / f"lark_{message_type}_{file_name}_{uuid4().hex[:4]}{suffix}" + ) + temp_path.write_bytes(file_bytes) + return str(temp_path.resolve()) + def _clean_expired_events(self) -> None: """清理超过 30 分钟的事件记录""" current_time = time.time() @@ -176,6 +521,11 @@ class LarkPlatformAdapter(Platform): abm.message_str = "" at_list = {} + if message.parent_id: + reply_seg = await self._build_reply_from_parent_id(message.parent_id) + if reply_seg: + abm.message.append(reply_seg) + if message.mentions: for m in message.mentions: if m.id is None: @@ -198,80 +548,19 @@ class LarkPlatformAdapter(Platform): logger.error(f"[Lark] 解析消息内容失败: {message.content}") return - if message.message_type == "text": - message_str_raw = content_json_b.get("text", "") # 带有 @ 的消息 - at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则 - # at_users = re.findall(at_pattern, message_str_raw) - # 拆分文本,去掉AT符号部分 - parts = re.split(at_pattern, message_str_raw) - for i in range(len(parts)): - s = parts[i].strip() - if not s: - continue - if s in at_list: - abm.message.append(at_list[s]) - else: - abm.message.append(Comp.Plain(parts[i].strip())) - elif message.message_type == "post": - _ls = [] + if not isinstance(content_json_b, dict): + logger.error(f"[Lark] 消息内容不是 JSON Object: {message.content}") + return - content_ls = content_json_b.get("content", []) - for comp in content_ls: - if isinstance(comp, list): - _ls.extend(comp) - elif isinstance(comp, dict): - _ls.append(comp) - content_json_b = _ls - elif message.message_type == "image": - content_json_b = [ - { - "tag": "img", - "image_key": content_json_b.get("image_key"), - "style": [], - }, - ] - - if message.message_type in ("post", "image"): - for comp in content_json_b: - if comp.get("tag") == "at": - user_id = comp.get("user_id") - if user_id in at_list: - abm.message.append(at_list[user_id]) - elif comp.get("tag") == "text" and comp.get("text", "").strip(): - abm.message.append(Comp.Plain(comp["text"].strip())) - elif comp.get("tag") == "img": - image_key = comp.get("image_key") - if not image_key: - continue - - request = ( - GetMessageResourceRequest.builder() - .message_id(cast(str, message.message_id)) - .file_key(image_key) - .type("image") - .build() - ) - - if self.lark_api.im is None: - logger.error("[Lark] API Client im 模块未初始化") - continue - - response = await self.lark_api.im.v1.message_resource.aget(request) - if not response.success(): - logger.error(f"无法下载飞书图片: {image_key}") - continue - - if response.file is None: - logger.error(f"飞书图片响应中不包含文件流: {image_key}") - continue - - image_bytes = response.file.read() - image_base64 = base64.b64encode(image_bytes).decode() - abm.message.append(Comp.Image.fromBase64(image_base64)) - - for comp in abm.message: - if isinstance(comp, Comp.Plain): - abm.message_str += comp.text + logger.debug(f"[Lark] 解析消息内容: {content_json_b}") + parsed_components = await self._parse_message_components( + message_id=message.message_id, + message_type=message.message_type or "unknown", + content=content_json_b, + at_map=at_list, + ) + abm.message.extend(parsed_components) + abm.message_str = self._build_message_str_from_components(parsed_components) if message.message_id is None: logger.error("[Lark] 消息缺少 message_id") @@ -296,7 +585,6 @@ class LarkPlatformAdapter(Platform): else: abm.session_id = abm.sender.user_id - logger.debug(abm) await self.handle_msg(abm) async def handle_msg(self, abm: AstrBotMessage) -> None: From 331ada02fdab69872824dd279f4d207b7516f5a4 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 11 Feb 2026 13:36:17 +0800 Subject: [PATCH 02/49] docs: add AUR installation method (#4879) * docs: sync system package manager installation instructions to all languages * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * fix/typo * refactor: update system package manager installation instructions for Arch Linux across multiple language README files * feat: add installation command for AstrBot in multiple language README files --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: Soulter <905617992@qq.com> --- README.md | 13 ++++++++++++- README_en.md | 23 ++++++++++++++++++++++- README_fr.md | 23 ++++++++++++++++++++++- README_ja.md | 23 ++++++++++++++++++++++- README_ru.md | 13 ++++++++++++- README_zh-TW.md | 14 ++++++++++++-- 6 files changed, 102 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8e6ae6973..320a215f1 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 #### uv 部署 ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` #### 宝塔面板部署 @@ -132,6 +133,16 @@ uv run main.py 或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 +#### 系统包管理器安装 + +##### Arch Linux + +```bash +yay -S astrbot-git +# 或者使用 paru +paru -S astrbot-git +``` + #### 桌面端 Electron 打包 桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。 diff --git a/README_en.md b/README_en.md index 2a9bc41db..698c9c3d4 100644 --- a/README_en.md +++ b/README_en.md @@ -63,7 +63,18 @@ Please refer to the official documentation: [Deploy AstrBot with Docker](https:/ #### uv Deployment ```bash -uvx astrbot +uv tool install astrbot +astrbot +``` + +#### System Package Manager Installation + +##### Arch Linux + +```bash +yay -S astrbot-git +# or use paru +paru -S astrbot-git ``` #### BT-Panel Deployment @@ -117,6 +128,16 @@ uv run main.py Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html). +#### Установка через системный пакетный менеджер + +##### Arch Linux + +```bash +yay -S astrbot-git +# или используйте paru +paru -S astrbot-git +``` + #### Desktop Electron Build For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md). diff --git a/README_fr.md b/README_fr.md index a47e15eea..c4e6f4065 100644 --- a/README_fr.md +++ b/README_fr.md @@ -61,7 +61,18 @@ Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker] #### Déploiement uv ```bash -uvx astrbot +uv tool install astrbot +astrbot +``` + +#### Installation via le gestionnaire de paquets du système + +##### Arch Linux + +```bash +yay -S astrbot-git +# ou utiliser paru +paru -S astrbot-git ``` #### Déploiement BT-Panel @@ -115,6 +126,16 @@ uv run main.py Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html). +#### Установка через системный пакетный менеджер + +##### Arch Linux + +```bash +yay -S astrbot-git +# или используйте paru +paru -S astrbot-git +``` + ## Plateformes de messagerie prises en charge **Maintenues officiellement** diff --git a/README_ja.md b/README_ja.md index bab9d629e..9e663db9d 100644 --- a/README_ja.md +++ b/README_ja.md @@ -61,7 +61,18 @@ Docker / Docker Compose を使用した AstrBot のデプロイを推奨しま #### uv デプロイ ```bash -uvx astrbot +uv tool install astrbot +astrbot +``` + +#### システムパッケージマネージャーでのインストール + +##### Arch Linux + +```bash +yay -S astrbot-git +# または paru を使用 +paru -S astrbot-git ``` #### 宝塔パネルデプロイ @@ -115,6 +126,16 @@ uv run main.py または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。 +#### Установка через системный пакетный менеджер + +##### Arch Linux + +```bash +yay -S astrbot-git +# или используйте paru +paru -S astrbot-git +``` + ## サポートされているメッセージプラットフォーム **公式メンテナンス** diff --git a/README_ru.md b/README_ru.md index 0f52c1c6a..b9053ddce 100644 --- a/README_ru.md +++ b/README_ru.md @@ -61,7 +61,8 @@ AstrBot — это универсальная платформа Agent-чатб #### Развёртывание uv ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` #### Развёртывание BT-Panel @@ -115,6 +116,16 @@ uv run main.py Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html). +#### Установка через системный пакетный менеджер + +##### Arch Linux + +```bash +yay -S astrbot-git +# или используйте paru +paru -S astrbot-git +``` + ## Поддерживаемые платформы обмена сообщениями **Официально поддерживаемые** diff --git a/README_zh-TW.md b/README_zh-TW.md index c6df22ea2..79a91a09f 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -61,7 +61,8 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主 #### uv 部署 ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` #### 寶塔面板部署 @@ -115,6 +116,16 @@ uv run main.py 或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。 +#### 系統套件管理員安裝 + +##### Arch Linux + +```bash +yay -S astrbot-git +# 或者使用 paru +paru -S astrbot-git +``` + ## 支援的訊息平台 **官方維護** @@ -244,4 +255,3 @@ pre-commit install _私は、高性能ですから!_ - From dc995af34b84de0133309ff67c5d8207346eca53 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: Wed, 11 Feb 2026 20:17:57 +0900 Subject: [PATCH 03/49] =?UTF-8?q?fix(desktop):=20=E4=B8=BA=20Electron=20?= =?UTF-8?q?=E4=B8=8E=E5=90=8E=E7=AB=AF=E6=97=A5=E5=BF=97=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=8C=89=E5=A4=A7=E5=B0=8F=E8=BD=AE=E8=BD=AC=20(#5029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): rotate electron and backend logs * refactor(desktop): centralize log rotation defaults and debug fs errors * fix(desktop): harden rotation fs ops and buffer backend log writes * refactor(desktop): extract buffered logger and reduce sync stat calls * refactor(desktop): simplify rotation flow and harden logger config * fix(desktop): make app logging async and flush-safe * fix: harden app log path switching and debug-gated rotation errors * fix: cap buffered log chunk size during path switch --- desktop/README.md | 9 ++ desktop/lib/backend-manager.js | 100 +++++++------ desktop/lib/buffered-rotating-logger.js | 162 +++++++++++++++++++++ desktop/lib/common.js | 37 +++++ desktop/lib/electron-logger.js | 29 +++- desktop/lib/rotating-log-writer.js | 178 ++++++++++++++++++++++++ desktop/main.js | 8 +- 7 files changed, 473 insertions(+), 50 deletions(-) create mode 100644 desktop/lib/buffered-rotating-logger.js create mode 100644 desktop/lib/rotating-log-writer.js diff --git a/desktop/README.md b/desktop/README.md index b5698e3ee..48dcb341a 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -91,6 +91,15 @@ 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: diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 477995027..0ca56931e 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -4,10 +4,20 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); -const { delay, ensureDir, normalizeUrl, waitForProcessExit } = require('./common'); +const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); +const { + delay, + ensureDir, + 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; @@ -34,10 +44,22 @@ class BackendManager { ); 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.backendLogFd = 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; @@ -195,14 +217,8 @@ class BackendManager { return Boolean(this.getBackendConfig().cmd); } - closeBackendLogFd() { - if (this.backendLogFd === null) { - return; - } - try { - fs.closeSync(this.backendLogFd); - } catch {} - this.backendLogFd = null; + async flushLogs() { + await this.backendLogger.flush(); } async pingBackend(timeoutMs = 800) { @@ -355,7 +371,7 @@ class BackendManager { } } - startBackend() { + async startBackend() { if (this.shouldSkipStart()) { this.log('Skip backend start because app is quitting.'); return; @@ -379,61 +395,61 @@ class BackendManager { 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); - const logPath = path.join(logsDir, 'backend.log'); - try { - this.backendLogFd = fs.openSync(logPath, 'a'); - } catch { - this.backendLogFd = null; - } + 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: - this.backendLogFd === null - ? 'ignore' - : ['ignore', this.backendLogFd, this.backendLogFd], + stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore', windowsHide: true, }); - if (this.backendLogFd !== null) { + 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(' '); - try { - fs.writeSync( - this.backendLogFd, - `[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`, - ); - } catch {} + this.backendLogger.log( + `[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`, + ); } this.backendProcess.on('error', (error) => { this.backendLastExitReason = error instanceof Error ? error.message : String(error); - if (this.backendLogFd !== null) { - try { - fs.writeSync( - this.backendLogFd, - `[${new Date().toISOString()}] [Electron] Backend spawn error: ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - } catch {} - } - this.closeBackendLogFd(); + this.backendLogger.log( + `[${new Date().toISOString()}] [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'}).`; - this.closeBackendLogFd(); + void this.backendLogger.flush(); this.backendProcess = null; }); } @@ -447,7 +463,7 @@ class BackendManager { } this.backendSpawning = true; try { - this.startBackend(); + await this.startBackend(); return await this.waitForBackend(maxWaitMs, true); } finally { this.backendSpawning = false; @@ -506,7 +522,7 @@ class BackendManager { await waitForProcessExit(processToStop, 1500); } } - this.closeBackendLogFd(); + await this.backendLogger.flush(); } findListeningPidsOnWindows(port) { diff --git a/desktop/lib/buffered-rotating-logger.js b/desktop/lib/buffered-rotating-logger.js new file mode 100644 index 000000000..7a443a97d --- /dev/null +++ b/desktop/lib/buffered-rotating-logger.js @@ -0,0 +1,162 @@ +'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 index 561eca39e..e592aff7f 100644 --- a/desktop/lib/common.js +++ b/desktop/lib/common.js @@ -2,6 +2,9 @@ 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); @@ -24,6 +27,33 @@ function ensureDir(value) { 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)); } @@ -52,8 +82,15 @@ function waitForProcessExit(child, timeoutMs = 5000) { } module.exports = { + LOG_ROTATION_DEFAULT_BACKUP_COUNT, + LOG_ROTATION_DEFAULT_MAX_MB, delay, ensureDir, + isIgnorableFsError, + isLogRotationDebugEnabled, normalizeUrl, + parseEnvInt, + parseLogBackupCount, + parseLogMaxBytes, waitForProcessExit, }; diff --git a/desktop/lib/electron-logger.js b/desktop/lib/electron-logger.js index b8dc73bc6..b9328ef44 100644 --- a/desktop/lib/electron-logger.js +++ b/desktop/lib/electron-logger.js @@ -1,10 +1,23 @@ 'use strict'; -const fs = require('fs'); const path = require('path'); -const { ensureDir } = require('./common'); +const { RotatingLogWriter } = require('./rotating-log-writer'); +const { 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 || @@ -15,19 +28,23 @@ function createElectronLogger({ app, getRootDir }) { function logElectron(message) { const logPath = getElectronLogPath(); - ensureDir(path.dirname(logPath)); const line = `[${new Date().toISOString()}] ${message}\n`; - try { - fs.appendFileSync(logPath, line, 'utf8'); - } catch {} + 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/rotating-log-writer.js b/desktop/lib/rotating-log-writer.js new file mode 100644 index 000000000..c6c8f8fb1 --- /dev/null +++ b/desktop/lib/rotating-log-writer.js @@ -0,0 +1,178 @@ +'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/main.js b/desktop/main.js index 6118c4360..5adff38b3 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -36,7 +36,7 @@ let backendManager = null; app.commandLine.appendSwitch('disable-http-cache'); -const { logElectron } = createElectronLogger({ +const { logElectron, flushElectron } = createElectronLogger({ app, getRootDir: () => (backendManager ? backendManager.getRootDir() : null), }); @@ -387,8 +387,12 @@ app.on('before-quit', (event) => { } }), ) - .finally(() => { + .finally(async () => { logElectron('Backend stop finished, exiting app.'); + await Promise.allSettled([ + flushElectron(), + backendManager ? backendManager.flushLogs() : Promise.resolve(), + ]); app.exit(0); }); }); From cd7755fe071d119117bf2a44f8a4ca0960922831 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 12 Feb 2026 00:00:53 +0800 Subject: [PATCH 04/49] feat: add first notice feature with multilingual support and UI integration --- FIRST_NOTICE.en-US.md | 14 ++++ FIRST_NOTICE.md | 14 ++++ astrbot/dashboard/routes/stat.py | 38 +++++++++ .../src/components/shared/ReadmeDialog.vue | 81 ++++++++++++++----- .../src/i18n/locales/en-US/core/common.json | 8 ++ .../src/i18n/locales/zh-CN/core/common.json | 8 ++ dashboard/src/layouts/full/FullLayout.vue | 76 ++++++++++++----- 7 files changed, 201 insertions(+), 38 deletions(-) create mode 100644 FIRST_NOTICE.en-US.md create mode 100644 FIRST_NOTICE.md diff --git a/FIRST_NOTICE.en-US.md b/FIRST_NOTICE.en-US.md new file mode 100644 index 000000000..ba717b5ef --- /dev/null +++ b/FIRST_NOTICE.en-US.md @@ -0,0 +1,14 @@ +## Welcome to AstrBot + +🌟 Thank you for using AstrBot! + +AstrBot is an Agentic AI assistant for personal and group chats, with support for multiple IM platforms and a wide range of built-in features. We hope it brings you an efficient and enjoyable experience. ❤️ + +Important notice: + +AstrBot is a **free and open-source software project** protected by the AGPLv3 license. You can find the full source code and related resources on our [**official website**](https://astrbot.app) and [**GitHub**](https://github.com/astrbotdevs/astrbot). +As of now, AstrBot has **no commercial services of any kind**, and the official team **will never charge users any fees** under any name. + +If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email. + +📮 Official email: [community@astrbot.app](mailto:community@astrbot.app) diff --git a/FIRST_NOTICE.md b/FIRST_NOTICE.md new file mode 100644 index 000000000..bc739ed73 --- /dev/null +++ b/FIRST_NOTICE.md @@ -0,0 +1,14 @@ +## 欢迎使用 AstrBot + +🌟 感谢您使用 AstrBot! + +AstrBot 是一款可接入多种 IM 平台的 Agentic AI 个人 / 群聊助手,内置多项强大功能,希望能为您带来高效、愉快的使用体验。❤️ + +我们想特别说明: + +AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可以在[**官方网站**](https://astrbot.app)、[**GitHub**](https://github.com/astrbotdevs/astrbot) 上找到 AstrBot 的全部源代码及相关资源。 +截至目前,AstrBot 项目**未开展任何形式的商业化服务**,官方**不会以任何名义向用户收取费用**。 + +如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。 + +📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app) diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 054eec995..666eb4c83 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -4,6 +4,7 @@ import threading import time import traceback from functools import cmp_to_key +from pathlib import Path import aiohttp import psutil @@ -37,6 +38,7 @@ class StatRoute(Route): "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection), "/stat/changelog": ("GET", self.get_changelog), "/stat/changelog/list": ("GET", self.list_changelog_versions), + "/stat/first-notice": ("GET", self.get_first_notice), } self.db_helper = db_helper self.register_routes() @@ -279,3 +281,39 @@ class StatRoute(Route): except Exception as e: logger.error(traceback.format_exc()) return Response().error(f"Error: {e!s}").__dict__ + + async def get_first_notice(self): + """读取项目根目录 FIRST_NOTICE.md 内容。""" + try: + locale = (request.args.get("locale") or "").strip() + if not re.match(r"^[A-Za-z0-9_-]*$", locale): + locale = "" + + base_path = Path(get_astrbot_path()) + candidates: list[Path] = [] + + if locale: + candidates.append(base_path / f"FIRST_NOTICE.{locale}.md") + if locale.lower().startswith("zh"): + candidates.append(base_path / "FIRST_NOTICE.zh-CN.md") + elif locale.lower().startswith("en"): + candidates.append(base_path / "FIRST_NOTICE.en-US.md") + + candidates.extend( + [ + base_path / "FIRST_NOTICE.en-US.md", + base_path / "FIRST_NOTICE.md", + ], + ) + + for notice_path in candidates: + if not notice_path.is_file(): + continue + content = notice_path.read_text(encoding="utf-8") + if content.strip(): + return Response().ok({"content": content}).__dict__ + + return Response().ok({"content": None}).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"Error: {e!s}").__dict__ diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue index 04ab7afd1..ddc27cd90 100644 --- a/dashboard/src/components/shared/ReadmeDialog.vue +++ b/dashboard/src/components/shared/ReadmeDialog.vue @@ -35,7 +35,7 @@ const props = defineProps({ mode: { type: String, default: "readme", - validator: (value) => ["readme", "changelog"].includes(value), + validator: (value) => ["readme", "changelog", "first-notice"].includes(value), }, }); @@ -166,19 +166,50 @@ const renderedHtml = computed(() => { }); const modeConfig = computed(() => { - const isChangelog = props.mode === "changelog"; - const keyBase = `core.common.${isChangelog ? "changelog" : "readme"}`; + if (props.mode === "changelog") { + return { + title: t("core.common.changelog.title"), + loading: t("core.common.changelog.loading"), + emptyTitle: t("core.common.changelog.empty.title"), + emptySubtitle: t("core.common.changelog.empty.subtitle"), + apiPath: "/api/plugin/changelog", + showGithubButton: false, + showRefreshButton: true, + refreshLabel: t("core.common.readme.buttons.refresh"), + }; + } + + if (props.mode === "first-notice") { + return { + title: t("core.common.firstNotice.title"), + loading: t("core.common.firstNotice.loading"), + emptyTitle: t("core.common.firstNotice.empty.title"), + emptySubtitle: t("core.common.firstNotice.empty.subtitle"), + apiPath: "/api/stat/first-notice", + showGithubButton: false, + showRefreshButton: false, + refreshLabel: "", + }; + } + return { - title: t(`${keyBase}.title`), - loading: t(`${keyBase}.loading`), - emptyTitle: t(`${keyBase}.empty.title`), - emptySubtitle: t(`${keyBase}.empty.subtitle`), - apiPath: `/api/plugin/${isChangelog ? "changelog" : "readme"}`, + title: t("core.common.readme.title"), + loading: t("core.common.readme.loading"), + emptyTitle: t("core.common.readme.empty.title"), + emptySubtitle: t("core.common.readme.empty.subtitle"), + apiPath: "/api/plugin/readme", + showGithubButton: true, + showRefreshButton: true, + refreshLabel: t("core.common.readme.buttons.refresh"), }; }); +const requiresPluginName = computed( + () => props.mode === "readme" || props.mode === "changelog", +); + async function fetchContent() { - if (!props.pluginName) return; + if (requiresPluginName.value && !props.pluginName) return; const requestId = ++lastRequestId.value; loading.value = true; content.value = null; @@ -186,9 +217,13 @@ async function fetchContent() { isEmpty.value = false; try { - const res = await axios.get( - `${modeConfig.value.apiPath}?name=${props.pluginName}`, - ); + let params; + if (requiresPluginName.value) { + params = { name: props.pluginName }; + } else if (props.mode === "first-notice") { + params = { locale: locale.value }; + } + const res = await axios.get(modeConfig.value.apiPath, { params }); if (requestId !== lastRequestId.value) return; if (res.data.status === "ok") { @@ -207,7 +242,9 @@ async function fetchContent() { watch( [() => props.show, () => props.pluginName, () => props.mode], ([show, name]) => { - if (show && name) fetchContent(); + if (!show) return; + if (requiresPluginName.value && !name) return; + fetchContent(); }, { immediate: true }, ); @@ -273,22 +310,26 @@ function openExternalLink(url) { if (!url) return; window.open(url, "_blank", "noopener,noreferrer"); } + +const showActionArea = computed(() => { + const hasGithub = modeConfig.value.showGithubButton && !!props.repoUrl; + return hasGithub || modeConfig.value.showRefreshButton; +}); From a8dda20a30f6d99cad50da6d392d071e28cff745 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, 12 Feb 2026 02:04:04 +0900 Subject: [PATCH 05/49] =?UTF-8?q?fix:=20=E6=8F=90=E5=8D=87=E6=89=93?= =?UTF-8?q?=E5=8C=85=E7=89=88=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7=E5=B9=B6=E4=BC=98=E5=8C=96=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BE=9D=E8=B5=96=E5=A4=84=E7=90=86=20(#5031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): rotate electron and backend logs * refactor(desktop): centralize log rotation defaults and debug fs errors * fix(desktop): harden rotation fs ops and buffer backend log writes * refactor(desktop): extract buffered logger and reduce sync stat calls * refactor(desktop): simplify rotation flow and harden logger config * fix(desktop): make app logging async and flush-safe * fix: harden app log path switching and debug-gated rotation errors * fix: cap buffered log chunk size during path switch * fix: avoid redundant plugin reinstall and upgrade electron * fix: stop webchat tasks cleanly and bind packaged backend to localhost * fix: unify platform shutdown and await webchat listener cleanup * fix: improve startup logs for dashboard and onebot listeners * fix: revert extra startup service logs * fix: harden plugin import recovery and webchat listener cleanup * fix: pin dashboard ci node version to 24.13.0 * fix: avoid duplicate webchat listener cleanup on terminate * refactor: clarify platform task lifecycle management * fix: continue platform shutdown when terminate fails --- .github/workflows/dashboard_ci.yml | 4 +- .github/workflows/release.yml | 4 +- astrbot/core/platform/manager.py | 87 ++++++++++++++----- .../sources/webchat/webchat_adapter.py | 20 +++-- .../sources/webchat/webchat_queue_mgr.py | 13 +++ astrbot/core/star/star_manager.py | 53 ++++++++--- astrbot/core/utils/pip_installer.py | 20 +++++ astrbot/dashboard/server.py | 16 ++-- desktop/lib/backend-manager.js | 17 +++- desktop/lib/common.js | 19 ++++ desktop/lib/electron-logger.js | 9 +- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 33 +++---- 13 files changed, 227 insertions(+), 70 deletions(-) diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index f403da773..5be935ebc 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 'latest' + node-version: '24.13.0' - name: npm install, build run: | @@ -52,4 +52,4 @@ jobs: repo: astrbot-release-harbour body: "Automated release from commit ${{ github.sha }}" token: ${{ secrets.ASTRBOT_HARBOUR_TOKEN }} - artifacts: "dashboard/dist.zip" \ No newline at end of file + artifacts: "dashboard/dist.zip" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5c0f09a5..59c229b04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 20 + node-version: '24.13.0' cache: "pnpm" cache-dependency-path: dashboard/pnpm-lock.yaml @@ -175,7 +175,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 20 + node-version: '24.13.0' cache: "pnpm" cache-dependency-path: | dashboard/pnpm-lock.yaml diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 9cec8a942..dfe729f51 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -1,6 +1,7 @@ import asyncio import traceback from asyncio import Queue +from dataclasses import dataclass from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig @@ -12,12 +13,19 @@ from .register import platform_cls_map from .sources.webchat.webchat_adapter import WebChatAdapter +@dataclass +class PlatformTasks: + run: asyncio.Task + wrapper: asyncio.Task + + class PlatformManager: def __init__(self, config: AstrBotConfig, event_queue: Queue) -> None: self.platform_insts: list[Platform] = [] """加载的 Platform 的实例""" self._inst_map: dict[str, dict] = {} + self._platform_tasks: dict[str, PlatformTasks] = {} self.astrbot_config = config self.platforms_config = config["platform"] @@ -38,6 +46,44 @@ class PlatformManager: sanitized = platform_id.replace(":", "_").replace("!", "_") return sanitized, sanitized != platform_id + def _start_platform_task(self, task_name: str, inst: Platform) -> None: + run_task = asyncio.create_task(inst.run(), name=task_name) + wrapper_task = asyncio.create_task( + self._task_wrapper(run_task, platform=inst), + name=f"{task_name}_wrapper", + ) + self._platform_tasks[inst.client_self_id] = PlatformTasks( + run=run_task, + wrapper=wrapper_task, + ) + + async def _stop_platform_task(self, client_id: str) -> None: + tasks = self._platform_tasks.pop(client_id, None) + if not tasks: + return + for task in (tasks.run, tasks.wrapper): + if not task.done(): + task.cancel() + await asyncio.gather(tasks.run, tasks.wrapper, return_exceptions=True) + + async def _terminate_inst_and_tasks(self, inst: Platform) -> None: + client_id = inst.client_self_id + try: + if getattr(inst, "terminate", None): + try: + await inst.terminate() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + "终止平台适配器失败: client_id=%s, error=%s", + client_id, + e, + ) + logger.error(traceback.format_exc()) + finally: + await self._stop_platform_task(client_id) + async def initialize(self) -> None: """初始化所有平台适配器""" for platform in self.platforms_config: @@ -51,12 +97,7 @@ class PlatformManager: # 网页聊天 webchat_inst = WebChatAdapter({}, self.settings, self.event_queue) self.platform_insts.append(webchat_inst) - asyncio.create_task( - self._task_wrapper( - asyncio.create_task(webchat_inst.run(), name="webchat"), - platform=webchat_inst, - ), - ) + self._start_platform_task("webchat", webchat_inst) async def load_platform(self, platform_config: dict) -> None: """实例化一个平台""" @@ -154,15 +195,9 @@ class PlatformManager: "client_id": inst.client_self_id, } self.platform_insts.append(inst) - - asyncio.create_task( - self._task_wrapper( - asyncio.create_task( - inst.run(), - name=f"platform_{platform_config['type']}_{platform_config['id']}", - ), - platform=inst, - ), + self._start_platform_task( + f"platform_{platform_config['type']}_{platform_config['id']}", + inst, ) handlers = star_handlers_registry.get_handlers_by_event_type( EventType.OnPlatformLoadedEvent, @@ -230,13 +265,25 @@ class PlatformManager: except Exception: logger.warning(f"可能未完全移除 {platform_id} 平台适配器") - if getattr(inst, "terminate", None): - await inst.terminate() + await self._terminate_inst_and_tasks(inst) async def terminate(self) -> None: - for inst in self.platform_insts: - if getattr(inst, "terminate", None): - await inst.terminate() + terminated_client_ids: set[str] = set() + for platform_id in list(self._inst_map.keys()): + info = self._inst_map.get(platform_id) + if info: + terminated_client_ids.add(info["client_id"]) + await self.terminate_platform(platform_id) + + for inst in list(self.platform_insts): + client_id = inst.client_self_id + if client_id in terminated_client_ids: + continue + await self._terminate_inst_and_tasks(inst) + + self.platform_insts.clear() + self._inst_map.clear() + self._platform_tasks.clear() def get_insts(self): return self.platform_insts diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 5eb62e6b3..047417aaa 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -26,14 +26,23 @@ from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr class QueueListener: - def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None: + def __init__( + self, + webchat_queue_mgr: WebChatQueueMgr, + callback: Callable, + stop_event: asyncio.Event, + ) -> None: self.webchat_queue_mgr = webchat_queue_mgr self.callback = callback + self.stop_event = stop_event async def run(self) -> None: """Register callback and keep adapter task alive.""" self.webchat_queue_mgr.set_listener(self.callback) - await asyncio.Event().wait() + try: + await self.stop_event.wait() + finally: + await self.webchat_queue_mgr.clear_listener() @register_platform_adapter("webchat", "webchat") @@ -56,6 +65,8 @@ class WebChatAdapter(Platform): id="webchat", support_proactive_message=False, ) + self._shutdown_event = asyncio.Event() + self._webchat_queue_mgr = webchat_queue_mgr async def send_by_session( self, @@ -184,7 +195,7 @@ class WebChatAdapter(Platform): abm = await self.convert_message(data) await self.handle_msg(abm) - bot = QueueListener(webchat_queue_mgr, callback) + bot = QueueListener(self._webchat_queue_mgr, callback, self._shutdown_event) return bot.run() def meta(self) -> PlatformMetadata: @@ -209,5 +220,4 @@ class WebChatAdapter(Platform): self.commit_event(message_event) async def terminate(self) -> None: - # Do nothing - pass + self._shutdown_event.set() diff --git a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py index 688d83e2c..fd35e837c 100644 --- a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +++ b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py @@ -87,6 +87,19 @@ class WebChatQueueMgr: for conversation_id in list(self.queues.keys()): self._start_listener_if_needed(conversation_id) + async def clear_listener(self) -> None: + self._listener_callback = None + for close_event in list(self._queue_close_events.values()): + close_event.set() + self._queue_close_events.clear() + + listener_tasks = list(self._listener_tasks.values()) + for task in listener_tasks: + task.cancel() + if listener_tasks: + await asyncio.gather(*listener_tasks, return_exceptions=True) + self._listener_tasks.clear() + def _start_listener_if_needed(self, conversation_id: str): if self._listener_callback is None: return diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 587808956..b2d70e79e 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -193,6 +193,37 @@ class PluginManager: logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}") return True + async def _import_plugin_with_dependency_recovery( + self, + path: str, + module_str: str, + root_dir_name: str, + requirements_path: str, + ) -> ModuleType: + try: + return __import__(path, fromlist=[module_str]) + except (ModuleNotFoundError, ImportError) as import_exc: + if os.path.exists(requirements_path): + try: + logger.info( + f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}" + ) + pip_installer.prefer_installed_dependencies( + requirements_path=requirements_path + ) + module = __import__(path, fromlist=[module_str]) + logger.info( + f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。" + ) + return module + except Exception as recover_exc: + logger.info( + f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}" + ) + + await self._check_plugin_dept_update(target_plugin=root_dir_name) + return __import__(path, fromlist=[module_str]) + @staticmethod def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None: """先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。 @@ -386,6 +417,12 @@ class PluginManager: "reserved", False, ) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。 + plugin_dir_path = ( + os.path.join(self.plugin_store_path, root_dir_name) + if not reserved + else os.path.join(self.reserved_plugin_path, root_dir_name) + ) + requirements_path = os.path.join(plugin_dir_path, "requirements.txt") path = "data.plugins." if not reserved else "astrbot.builtin_stars." path += root_dir_name + "." + module_str @@ -400,11 +437,12 @@ class PluginManager: # 尝试导入模块 try: - module = __import__(path, fromlist=[module_str]) - except (ModuleNotFoundError, ImportError): - # 尝试安装依赖 - await self._check_plugin_dept_update(target_plugin=root_dir_name) - module = __import__(path, fromlist=[module_str]) + module = await self._import_plugin_with_dependency_recovery( + path=path, + module_str=module_str, + root_dir_name=root_dir_name, + requirements_path=requirements_path, + ) except Exception as e: logger.error(traceback.format_exc()) logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}") @@ -412,11 +450,6 @@ class PluginManager: # 检查 _conf_schema.json plugin_config = None - plugin_dir_path = ( - os.path.join(self.plugin_store_path, root_dir_name) - if not reserved - else os.path.join(self.reserved_plugin_path, root_dir_name) - ) plugin_schema_path = os.path.join( plugin_dir_path, self.conf_schema_fname, diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 8d43c11b2..1c8da23c1 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -580,6 +580,26 @@ class PipInstaller: ) importlib.invalidate_caches() + def prefer_installed_dependencies(self, requirements_path: str) -> None: + """优先使用已安装在插件 site-packages 中的依赖,不执行安装。""" + if not is_packaged_electron_runtime(): + return + + target_site_packages = get_astrbot_site_packages_path() + if not os.path.isdir(target_site_packages): + return + + requested_requirements = _extract_requirement_names(requirements_path) + if not requested_requirements: + return + + _prepend_sys_path(target_site_packages) + _ensure_plugin_dependencies_preferred( + target_site_packages, + requested_requirements, + ) + importlib.invalidate_caches() + async def _run_pip_in_process(self, args: list[str]) -> int: pip_main = _get_pip_main() _patch_distlib_finder_for_frozen_runtime() diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 604866a87..9d8dffa37 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -201,11 +201,16 @@ class AstrBotDashboard: def run(self): ip_addr = [] - if p := os.environ.get("DASHBOARD_PORT"): - port = p - else: - port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) - host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0") + port = ( + os.environ.get("DASHBOARD_PORT") + or os.environ.get("ASTRBOT_DASHBOARD_PORT") + or self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) + ) + host = ( + os.environ.get("DASHBOARD_HOST") + or os.environ.get("ASTRBOT_DASHBOARD_HOST") + or self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0") + ) enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True) if not enable: @@ -213,7 +218,6 @@ class AstrBotDashboard: return None logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}") - if host == "0.0.0.0": logger.info( "提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)", diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 0ca56931e..eb8958a4c 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -8,6 +8,7 @@ const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); const { delay, ensureDir, + formatLogTimestamp, normalizeUrl, parseLogBackupCount, parseLogMaxBytes, @@ -391,6 +392,18 @@ class BackendManager { }; 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; @@ -431,7 +444,7 @@ class BackendManager { .map((item) => JSON.stringify(item)) .join(' '); this.backendLogger.log( - `[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`, + `[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`, ); } @@ -439,7 +452,7 @@ class BackendManager { this.backendLastExitReason = error instanceof Error ? error.message : String(error); this.backendLogger.log( - `[${new Date().toISOString()}] [Electron] Backend spawn error: ${ + `[${formatLogTimestamp()}] [Electron] Backend spawn error: ${ error instanceof Error ? error.message : String(error) }\n`, ); diff --git a/desktop/lib/common.js b/desktop/lib/common.js index e592aff7f..9f39358dc 100644 --- a/desktop/lib/common.js +++ b/desktop/lib/common.js @@ -81,11 +81,30 @@ function waitForProcessExit(child, timeoutMs = 5000) { }); } +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, diff --git a/desktop/lib/electron-logger.js b/desktop/lib/electron-logger.js index b9328ef44..6a52d1c76 100644 --- a/desktop/lib/electron-logger.js +++ b/desktop/lib/electron-logger.js @@ -2,7 +2,11 @@ const path = require('path'); const { RotatingLogWriter } = require('./rotating-log-writer'); -const { parseLogBackupCount, parseLogMaxBytes } = require('./common'); +const { + formatLogTimestamp, + parseLogBackupCount, + parseLogMaxBytes, +} = require('./common'); function createElectronLogger({ app, getRootDir }) { const electronLogMaxBytes = parseLogMaxBytes( @@ -28,7 +32,7 @@ function createElectronLogger({ app, getRootDir }) { function logElectron(message) { const logPath = getElectronLogPath(); - const line = `[${new Date().toISOString()}] ${message}\n`; + const line = `[${formatLogTimestamp()}] ${message}\n`; void writer.setLogPath(logPath); void writer.append(line); } @@ -47,4 +51,3 @@ function createElectronLogger({ app, getRootDir }) { module.exports = { createElectronLogger, }; - diff --git a/desktop/package.json b/desktop/package.json index c70689f1e..59ad3cd52 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -22,7 +22,7 @@ "dist": "pnpm run sync:version && electron-builder" }, "devDependencies": { - "electron": "^30.0.0", + "electron": "^40.3.0", "electron-builder": "^24.13.0" }, "build": { diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index f91a21a86..98411a90e 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: electron: - specifier: ^30.0.0 - version: 30.5.1 + 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) @@ -92,8 +92,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.33': - resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@24.10.13': + resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} '@types/node@25.2.2': resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} @@ -397,8 +397,8 @@ packages: electron-publish@24.13.1: resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==} - electron@30.5.1: - resolution: {integrity: sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==} + electron@40.3.0: + resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -992,9 +992,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -1158,7 +1155,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 20.19.33 + '@types/node': 25.2.2 '@types/responselike': 1.0.3 '@types/debug@4.1.12': @@ -1173,13 +1170,13 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 20.19.33 + '@types/node': 25.2.2 '@types/ms@2.1.0': {} - '@types/node@20.19.33': + '@types/node@24.10.13': dependencies: - undici-types: 6.21.0 + undici-types: 7.16.0 '@types/node@25.2.2': dependencies: @@ -1193,14 +1190,14 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 20.19.33 + '@types/node': 25.2.2 '@types/verror@1.10.11': optional: true '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.19.33 + '@types/node': 25.2.2 optional: true '@xmldom/xmldom@0.8.11': {} @@ -1597,10 +1594,10 @@ snapshots: transitivePeerDependencies: - supports-color - electron@30.5.1: + electron@40.3.0: dependencies: '@electron/get': 2.0.3 - '@types/node': 20.19.33 + '@types/node': 24.10.13 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -2211,8 +2208,6 @@ snapshots: typescript@5.9.3: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} universalify@0.1.2: {} From 9d93bda3fe9befae41219176a4c78f733ce1a3d3 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:04:48 +0800 Subject: [PATCH 06/49] feat: temporary file handling and introduce TempDirCleaner (#5026) * feat: temporary file handling and introduce TempDirCleaner - Updated various modules to use `get_astrbot_temp_path()` instead of `get_astrbot_data_path()` for temporary file storage. - Renamed temporary files for better identification and organization. - Introduced `TempDirCleaner` to manage the size of the temporary directory, ensuring it does not exceed a specified limit by deleting the oldest files. - Added configuration option for maximum temporary directory size in the dashboard. - Implemented tests for `TempDirCleaner` to verify cleanup functionality and size management. * ruff --- .../agent/runners/dify/dify_agent_runner.py | 6 +- astrbot/core/astr_main_agent_resources.py | 15 +- astrbot/core/computer/tools/fs.py | 15 +- astrbot/core/config/default.py | 7 + astrbot/core/core_lifecycle.py | 19 +++ astrbot/core/message/components.py | 24 +-- .../sources/dingtalk/dingtalk_adapter.py | 6 +- .../core/platform/sources/lark/lark_event.py | 9 +- .../sources/misskey/misskey_adapter.py | 4 +- .../qqofficial/qqofficial_message_event.py | 6 +- .../platform/sources/wecom/wecom_adapter.py | 9 +- .../weixin_offacc_adapter.py | 10 +- .../core/provider/sources/azure_tts_source.py | 3 +- .../core/provider/sources/dashscope_tts.py | 4 +- .../core/provider/sources/edge_tts_source.py | 4 +- .../sources/fishaudio_tts_api_source.py | 4 +- .../provider/sources/gemini_tts_source.py | 4 +- astrbot/core/provider/sources/genie_tts.py | 6 +- .../provider/sources/gsv_selfhosted_source.py | 4 +- .../core/provider/sources/gsvi_tts_source.py | 4 +- .../sources/minimax_tts_api_source.py | 4 +- .../provider/sources/openai_tts_api_source.py | 4 +- .../core/provider/sources/volcengine_tts.py | 10 +- .../provider/sources/whisper_api_source.py | 17 +- .../sources/whisper_selfhosted_source.py | 17 +- .../sources/xinference_stt_provider.py | 14 +- astrbot/core/skills/skill_manager.py | 1 - astrbot/core/utils/io.py | 17 +- astrbot/core/utils/media_utils.py | 21 +-- astrbot/core/utils/temp_dir_cleaner.py | 150 ++++++++++++++++++ astrbot/core/utils/tencent_record_helper.py | 6 +- astrbot/dashboard/routes/knowledge_base.py | 6 +- astrbot/dashboard/routes/live_chat.py | 4 +- astrbot/dashboard/routes/plugin.py | 6 +- .../en-US/features/config-metadata.json | 4 + .../zh-CN/features/config-metadata.json | 4 + tests/test_temp_dir_cleaner.py | 52 ++++++ 37 files changed, 388 insertions(+), 112 deletions(-) create mode 100644 astrbot/core/utils/temp_dir_cleaner.py create mode 100644 tests/test_temp_dir_cleaner.py diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index d9a8b7cd6..93f8d3570 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -10,7 +10,7 @@ from astrbot.core.provider.entities import ( LLMResponse, ProviderRequest, ) -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from ...hooks import BaseAgentRunHooks @@ -291,8 +291,8 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): return Comp.Image(file=item["url"], url=item["url"]) case "audio": # 仅支持 wav - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, f"{item['filename']}.wav") + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"dify_{item['filename']}.wav") await download_file(item["url"], path) return Comp.Image(file=item["url"], url=item["url"]) case "video": diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 1d5c085ce..634647e7a 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -1,6 +1,7 @@ import base64 import json import os +import uuid from pydantic import Field from pydantic.dataclasses import dataclass @@ -240,7 +241,9 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): if "_&exists_" in json.dumps(result): # Download the file from sandbox name = os.path.basename(path) - local_path = os.path.join(get_astrbot_temp_path(), name) + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) await sb.download_file(path, local_path) logger.info(f"Downloaded file from sandbox: {path} -> {local_path}") return local_path, True @@ -352,11 +355,11 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): MessageChain(chain=components), ) - if file_from_sandbox: - try: - os.remove(local_path) - except Exception as e: - logger.error(f"Error removing temp file {local_path}: {e}") + # if file_from_sandbox: + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") return f"Message sent to session {target_session}" diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 9cf590a61..126da4258 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -1,4 +1,5 @@ import os +import uuid from dataclasses import dataclass, field from astrbot.api import FunctionTool, logger @@ -167,7 +168,9 @@ class FileDownloadTool(FunctionTool): try: name = os.path.basename(remote_path) - local_path = os.path.join(get_astrbot_temp_path(), name) + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) # Download file from sandbox await sb.download_file(remote_path, local_path) @@ -183,12 +186,12 @@ class FileDownloadTool(FunctionTool): logger.error(f"Error sending file message: {e}") # remove - try: - os.remove(local_path) - except Exception as e: - logger.error(f"Error removing temp file {local_path}: {e}") + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") - return f"File downloaded successfully to {local_path} and sent to user. The file has been removed from local storage." + return f"File downloaded successfully to {local_path} and sent to user." return f"File downloaded successfully to {local_path}" except Exception as e: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 546768812..411384c1d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -203,6 +203,7 @@ DEFAULT_CONFIG = { "log_file_enable": False, "log_file_path": "logs/astrbot.log", "log_file_max_mb": 20, + "temp_dir_max_size": 1024, "trace_enable": False, "trace_log_enable": False, "trace_log_path": "logs/astrbot.trace.log", @@ -2394,6 +2395,7 @@ CONFIG_METADATA_2 = { "log_file_enable": {"type": "bool"}, "log_file_path": {"type": "string", "condition": {"log_file_enable": True}}, "log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}}, + "temp_dir_max_size": {"type": "int"}, "trace_log_enable": {"type": "bool"}, "trace_log_path": { "type": "string", @@ -3372,6 +3374,11 @@ CONFIG_METADATA_3_SYSTEM = { "type": "int", "hint": "超过大小后自动轮转,默认 20MB。", }, + "temp_dir_max_size": { + "description": "临时目录大小上限 (MB)", + "type": "int", + "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。", + }, "trace_log_enable": { "description": "启用 Trace 文件日志", "type": "bool", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 6b36cca0d..758cf1ccd 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -37,6 +37,7 @@ from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.llm_metadata import update_llm_metadata from astrbot.core.utils.migra_helper import migra +from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner from . import astrbot_config, html_renderer from .event_bus import EventBus @@ -57,6 +58,7 @@ class AstrBotCoreLifecycle: self.subagent_orchestrator: SubAgentOrchestrator | None = None self.cron_manager: CronJobManager | None = None + self.temp_dir_cleaner: TempDirCleaner | None = None # 设置代理 proxy_config = self.astrbot_config.get("http_proxy", "") @@ -125,6 +127,12 @@ class AstrBotCoreLifecycle: ucr=self.umop_config_router, sp=sp, ) + self.temp_dir_cleaner = TempDirCleaner( + max_size_getter=lambda: self.astrbot_config_mgr.default_conf.get( + TempDirCleaner.CONFIG_KEY, + TempDirCleaner.DEFAULT_MAX_SIZE, + ), + ) # apply migration try: @@ -238,6 +246,12 @@ class AstrBotCoreLifecycle: self.cron_manager.start(self.star_context), name="cron_manager", ) + temp_dir_cleaner_task = None + if self.temp_dir_cleaner: + temp_dir_cleaner_task = asyncio.create_task( + self.temp_dir_cleaner.run(), + name="temp_dir_cleaner", + ) # 把插件中注册的所有协程函数注册到事件总线中并执行 extra_tasks = [] @@ -247,6 +261,8 @@ class AstrBotCoreLifecycle: tasks_ = [event_bus_task, *(extra_tasks if extra_tasks else [])] if cron_task: tasks_.append(cron_task) + if temp_dir_cleaner_task: + tasks_.append(temp_dir_cleaner_task) for task in tasks_: self.curr_tasks.append( asyncio.create_task(self._task_wrapper(task), name=task.get_name()), @@ -298,6 +314,9 @@ class AstrBotCoreLifecycle: async def stop(self) -> None: """停止 AstrBot 核心生命周期管理类, 取消所有当前任务并终止各个管理器.""" + if self.temp_dir_cleaner: + await self.temp_dir_cleaner.stop() + # 请求停止所有正在运行的异步任务 for task in self.curr_tasks: task.cancel() diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index a192025dc..2c86bf3d9 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -31,7 +31,7 @@ from enum import Enum from pydantic.v1 import BaseModel from astrbot.core import astrbot_config, file_token_service, logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64 @@ -156,8 +156,9 @@ class Record(BaseMessageComponent): if self.file.startswith("base64://"): bs64_data = self.file.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") + file_path = os.path.join( + get_astrbot_temp_path(), f"recordseg_{uuid.uuid4()}.jpg" + ) with open(file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(file_path) @@ -245,8 +246,9 @@ class Video(BaseMessageComponent): if url and url.startswith("file:///"): return url[8:] if url and url.startswith("http"): - download_dir = os.path.join(get_astrbot_data_path(), "temp") - video_file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}") + video_file_path = os.path.join( + get_astrbot_temp_path(), f"videoseg_{uuid.uuid4().hex}" + ) await download_file(url, video_file_path) if os.path.exists(video_file_path): return os.path.abspath(video_file_path) @@ -445,8 +447,9 @@ class Image(BaseMessageComponent): if url.startswith("base64://"): bs64_data = url.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") + image_file_path = os.path.join( + get_astrbot_temp_path(), f"imgseg_{uuid.uuid4()}.jpg" + ) with open(image_file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(image_file_path) @@ -725,13 +728,12 @@ class File(BaseMessageComponent): """下载文件""" if not self.url: raise ValueError("Download failed: No URL provided in File component.") - download_dir = os.path.join(get_astrbot_data_path(), "temp") - os.makedirs(download_dir, exist_ok=True) + download_dir = get_astrbot_temp_path() if self.name: name, ext = os.path.splitext(self.name) - filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}" + filename = f"fileseg_{name}_{uuid.uuid4().hex[:8]}{ext}" else: - filename = f"{uuid.uuid4().hex}" + filename = f"fileseg_{uuid.uuid4().hex}" file_path = os.path.join(download_dir, filename) await download_file(self.url, file_path) self.file_ = os.path.abspath(file_path) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index fd0be3f1c..2d9b45cc1 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -21,7 +21,7 @@ from astrbot.api.platform import ( ) from astrbot.core import sp from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import ( convert_audio_format, @@ -253,9 +253,9 @@ class DingtalkPlatformAdapter(Platform): "downloadCode": download_code, "robotCode": robot_code, } - temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) - f_path = temp_dir / f"dingtalk_file_{uuid.uuid4()}.{ext}" + f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}" async with ( aiohttp.ClientSession() as session, session.post( diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 83a455888..92e3a32b9 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -21,7 +21,7 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import At, File, Plain, Record, Video from astrbot.api.message_components import Image as AstrBotImage -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.media_utils import ( convert_audio_to_opus, @@ -202,8 +202,11 @@ class LarkMessageEvent(AstrMessageEvent): base64_str = comp.file.removeprefix("base64://") image_data = base64.b64decode(base64_str) # save as temp file - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - file_path = os.path.join(temp_dir, f"{uuid.uuid4()}_test.jpg") + temp_dir = get_astrbot_temp_path() + file_path = os.path.join( + temp_dir, + f"lark_image_{uuid.uuid4().hex[:8]}.jpg", + ) with open(file_path, "wb") as f: f.write(BytesIO(image_data).getvalue()) else: diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 34415b855..fd61c3e50 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -21,7 +21,7 @@ try: except Exception: magic = None -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .misskey_event import MisskeyPlatformEvent from .misskey_utils import ( @@ -498,7 +498,7 @@ class MisskeyPlatformAdapter(Platform): finally: # 清理临时文件 if local_path and isinstance(local_path, str): - data_temp = os.path.join(get_astrbot_data_path(), "temp") + data_temp = get_astrbot_temp_path() if local_path.startswith(data_temp) and os.path.exists( local_path, ): diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 88c8fc225..1af4de49b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -19,7 +19,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Image, Plain, Record from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url, file_to_base64 from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk @@ -350,10 +350,10 @@ class QQOfficialMessageEvent(AstrMessageEvent): elif isinstance(i, Record): if i.file: record_wav_path = await i.convert_to_file_path() # wav 路径 - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() record_tecent_silk_path = os.path.join( temp_dir, - f"{uuid.uuid4()}.silk", + f"qqofficial_{uuid.uuid4()}.silk", ) try: duration = await wav_to_tencent_silk( diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 0a2e71b61..6647db89f 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -25,7 +25,7 @@ from astrbot.api.platform import ( ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import convert_audio_to_wav from astrbot.core.utils.webhook_utils import log_webhook_info @@ -344,7 +344,7 @@ class WecomPlatformAdapter(Platform): self.client.media.download, msg.media_id, ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) @@ -400,7 +400,8 @@ class WecomPlatformAdapter(Platform): self.client.media.download, media_id, ) - path = f"data/temp/wechat_kf_{media_id}.jpg" + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg") with open(path, "wb") as f: f.write(resp.content) abm.message = [Image(file=path, url=path)] @@ -412,7 +413,7 @@ class WecomPlatformAdapter(Platform): media_id, ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr") with open(path, "wb") as f: f.write(resp.content) 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 8f12ec82b..28985f757 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,4 +1,5 @@ import asyncio +import os import sys import uuid from collections.abc import Awaitable, Callable @@ -24,6 +25,7 @@ from astrbot.api.platform import ( ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import convert_audio_to_wav from astrbot.core.utils.webhook_utils import log_webhook_info @@ -290,12 +292,16 @@ class WeixinOfficialAccountPlatformAdapter(Platform): self.client.media.download, msg.media_id, ) - path = f"data/temp/wecom_{msg.media_id}.amr" + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) try: - path_wav = f"data/temp/wecom_{msg.media_id}.wav" + path_wav = os.path.join( + temp_dir, + f"weixin_offacc_{msg.media_id}.wav", + ) path_wav = await convert_audio_to_wav(path, path_wav) except Exception as e: logger.error( diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 29dc3f7a4..0e8f00ce5 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -12,12 +12,13 @@ from httpx import AsyncClient, Timeout from astrbot import logger from astrbot.core.config.default import VERSION +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider from ..register import register_provider_adapter -TEMP_DIR = Path("data/temp/azure_tts") +TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts" TEMP_DIR.mkdir(parents=True, exist_ok=True) diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 50bc421fd..9b6816859 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -15,7 +15,7 @@ except ( ): # pragma: no cover - older dashscope versions without Qwen TTS support MultiModalConversation = None -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -45,7 +45,7 @@ class ProviderDashscopeTTSAPI(TTSProvider): if not model: raise RuntimeError("Dashscope TTS model is not configured.") - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) if self._is_qwen_tts_model(model): diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 71a5a82d6..503bd275b 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -6,7 +6,7 @@ import uuid import edge_tts from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -46,7 +46,7 @@ class ProviderEdgeTTS(TTSProvider): self.set_model("edge_tts") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3") wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav") diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index dde2736a8..35945b7b6 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -8,7 +8,7 @@ from httpx import AsyncClient from pydantic import BaseModel, conint from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -142,7 +142,7 @@ class ProviderFishAudioTTSAPI(TTSProvider): ) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav") self.headers["content-type"] = "application/msgpack" request = await self._generate_request(text) diff --git a/astrbot/core/provider/sources/gemini_tts_source.py b/astrbot/core/provider/sources/gemini_tts_source.py index 37022f761..d6954ef82 100644 --- a/astrbot/core/provider/sources/gemini_tts_source.py +++ b/astrbot/core/provider/sources/gemini_tts_source.py @@ -6,7 +6,7 @@ from google import genai from google.genai import types from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -49,7 +49,7 @@ class ProviderGeminiTTSAPI(TTSProvider): self.voice_name: str = provider_config.get("gemini_tts_voice_name", "Leda") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"gemini_tts_{uuid.uuid4()}.wav") prompt = f"{self.prefix}: {text}" if self.prefix else text response = await self.client.models.generate_content( diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index bca92deb7..8f9b6d91d 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -6,7 +6,7 @@ from astrbot.core import logger from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.provider import TTSProvider from astrbot.core.provider.register import register_provider_adapter -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path try: import genie_tts as genie # type: ignore @@ -54,7 +54,7 @@ class GenieTTSProvider(TTSProvider): return True async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) @@ -94,7 +94,7 @@ class GenieTTSProvider(TTSProvider): break try: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index 029f6af10..fc8bccea8 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -5,7 +5,7 @@ import uuid import aiohttp from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -121,7 +121,7 @@ class ProviderGSVTTS(TTSProvider): params = self.build_synthesis_params(text) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav") diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index d8b171718..425e801f4 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -4,7 +4,7 @@ import uuid import aiohttp -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -29,7 +29,7 @@ class ProviderGSVITTS(TTSProvider): self.emotion = provider_config.get("emotion") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav") params = {"text": text} diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index dcd29060e..69860111c 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -6,7 +6,7 @@ from collections.abc import AsyncIterator import aiohttp from astrbot.api import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -145,7 +145,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider): return b"".join(chunks) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3") diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 489a37b2d..217b18925 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -5,7 +5,7 @@ import httpx from openai import NOT_GIVEN, AsyncOpenAI from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -46,7 +46,7 @@ class ProviderOpenAITTSAPI(TTSProvider): self.set_model(provider_config.get("model", "")) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"openai_tts_api_{uuid.uuid4()}.wav") async with self.client.audio.speech.with_streaming_response.create( model=self.model_name, diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py index f5d758f5c..349815907 100644 --- a/astrbot/core/provider/sources/volcengine_tts.py +++ b/astrbot/core/provider/sources/volcengine_tts.py @@ -8,6 +8,7 @@ import uuid import aiohttp from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -92,9 +93,12 @@ class ProviderVolcengineTTS(TTSProvider): if "data" in resp_data: audio_data = base64.b64decode(resp_data["data"]) - os.makedirs("data/temp", exist_ok=True) - - file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3" + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + file_path = os.path.join( + temp_dir, + f"volcengine_tts_{uuid.uuid4()}.mp3", + ) loop = asyncio.get_running_loop() await loop.run_in_executor( diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 875881b7b..386da063d 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -4,7 +4,7 @@ import uuid from openai import NOT_GIVEN, AsyncOpenAI from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +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 ( convert_to_pcm_wav, @@ -65,9 +65,11 @@ class ProviderOpenAIWhisperAPI(STTProvider): if "multimedia.nt.qq.com.cn" in audio_url: is_tencent = True - name = str(uuid.uuid4()) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, name) + temp_dir = get_astrbot_temp_path() + path = os.path.join( + temp_dir, + f"whisper_api_{uuid.uuid4().hex[:8]}.input", + ) await download_file(audio_url, path) audio_url = path @@ -79,8 +81,11 @@ class ProviderOpenAIWhisperAPI(STTProvider): # 判断是否需要转换 if file_format in ["silk", "amr"]: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + temp_dir = get_astrbot_temp_path() + output_path = os.path.join( + temp_dir, + f"whisper_api_{uuid.uuid4().hex[:8]}.wav", + ) if file_format == "silk": logger.info( diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index d5d2dc340..678deb948 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -6,7 +6,7 @@ from typing import cast import whisper from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +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 @@ -58,9 +58,11 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): if "multimedia.nt.qq.com.cn" in audio_url: is_tencent = True - name = str(uuid.uuid4()) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, name) + temp_dir = get_astrbot_temp_path() + path = os.path.join( + temp_dir, + f"whisper_selfhost_{uuid.uuid4().hex[:8]}.input", + ) await download_file(audio_url, path) audio_url = path @@ -71,8 +73,11 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): is_silk = await self._is_silk_file(audio_url) if is_silk: logger.info("Converting silk file to wav ...") - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + temp_dir = get_astrbot_temp_path() + output_path = os.path.join( + temp_dir, + f"whisper_selfhost_{uuid.uuid4().hex[:8]}.wav", + ) await tencent_silk_to_wav(audio_url, output_path) audio_url = output_path diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index a3e5be352..0a22e456e 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -7,7 +7,7 @@ from xinference_client.client.restful.async_restful_client import ( ) from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.tencent_record_helper import ( convert_to_pcm_wav, tencent_silk_to_wav, @@ -130,11 +130,17 @@ class ProviderXinferenceSTT(STTProvider): logger.info( f"Audio requires conversion ({conversion_type}), using temporary files..." ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - input_path = os.path.join(temp_dir, str(uuid.uuid4())) - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + input_path = os.path.join( + temp_dir, + f"xinference_stt_{uuid.uuid4().hex[:8]}.input", + ) + output_path = os.path.join( + temp_dir, + f"xinference_stt_{uuid.uuid4().hex[:8]}.wav", + ) temp_files.extend([input_path, output_path]) with open(input_path, "wb") as f: diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 1e6f01a6d..85190ecdf 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -93,7 +93,6 @@ class SkillManager: self.skills_root = skills_root or get_astrbot_skills_path() self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME) os.makedirs(self.skills_root, exist_ok=True) - os.makedirs(get_astrbot_temp_path(), exist_ok=True) def _load_config(self) -> dict: if not os.path.exists(self.config_path): diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 24b919bdd..0ce3624e8 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -14,7 +14,7 @@ import certifi import psutil from PIL import Image -from .astrbot_path import get_astrbot_data_path +from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path logger = logging.getLogger("astrbot") @@ -50,21 +50,10 @@ def port_checker(port: int, host: str = "localhost") -> bool: def save_temp_img(img: Image.Image | bytes) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - # 获得文件创建时间,清除超过 12 小时的 - try: - for f in os.listdir(temp_dir): - path = os.path.join(temp_dir, f) - if os.path.isfile(path): - ctime = os.path.getctime(path) - if time.time() - ctime > 3600 * 12: - os.remove(path) - except Exception as e: - print(f"清除临时文件失败: {e}") - + temp_dir = get_astrbot_temp_path() # 获得时间戳 timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" - p = os.path.join(temp_dir, f"{timestamp}.jpg") + p = os.path.join(temp_dir, f"io_temp_img_{timestamp}.jpg") if isinstance(img, Image.Image): img.save(p) diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index e2abb0744..8d833514f 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -10,7 +10,7 @@ import uuid from pathlib import Path from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path async def get_media_duration(file_path: str) -> int | None: @@ -77,9 +77,9 @@ async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) # 生成输出文件路径 if output_path is None: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.opus") + output_path = os.path.join(temp_dir, f"media_audio_{uuid.uuid4().hex}.opus") try: # 使用ffmpeg转换为opus格式 @@ -156,9 +156,12 @@ async def convert_video_format( # 生成输出文件路径 if output_path is None: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.{output_format}") + output_path = os.path.join( + temp_dir, + f"media_video_{uuid.uuid4().hex}.{output_format}", + ) try: # 使用ffmpeg转换视频格式 @@ -227,9 +230,9 @@ async def convert_audio_format( return audio_path if output_path is None: - temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) - output_path = str(temp_dir / f"{uuid.uuid4()}.{output_format}") + output_path = str(temp_dir / f"media_audio_{uuid.uuid4().hex}.{output_format}") args = ["ffmpeg", "-y", "-i", audio_path] if output_format == "amr": @@ -283,9 +286,9 @@ async def extract_video_cover( ) -> str: """从视频中提取封面图(JPG)。""" if output_path is None: - temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) - output_path = str(temp_dir / f"{uuid.uuid4()}.jpg") + output_path = str(temp_dir / f"media_cover_{uuid.uuid4().hex}.jpg") try: process = await asyncio.create_subprocess_exec( diff --git a/astrbot/core/utils/temp_dir_cleaner.py b/astrbot/core/utils/temp_dir_cleaner.py new file mode 100644 index 000000000..c0c060098 --- /dev/null +++ b/astrbot/core/utils/temp_dir_cleaner.py @@ -0,0 +1,150 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +def parse_size_to_bytes(value: str | int | float | None) -> int: + """Parse size in MB to bytes.""" + if value is None: + return 0 + + try: + size_mb = float(str(value).strip()) + except (TypeError, ValueError): + return 0 + + if size_mb <= 0: + return 0 + + return int(size_mb * 1024**2) + + +@dataclass +class TempFileInfo: + path: Path + size: int + mtime: float + + +class TempDirCleaner: + CONFIG_KEY = "temp_dir_max_size" + DEFAULT_MAX_SIZE = 1024 + CHECK_INTERVAL_SECONDS = 10 * 60 + CLEANUP_RATIO = 0.30 + + def __init__( + self, + max_size_getter: Callable[[], str | int | float | None], + temp_dir: Path | None = None, + ) -> None: + self._max_size_getter = max_size_getter + self._temp_dir = temp_dir or Path(get_astrbot_temp_path()) + self._stop_event = asyncio.Event() + + def _limit_bytes(self) -> int: + configured = self._max_size_getter() + parsed = parse_size_to_bytes(configured) + if parsed <= 0: + fallback = parse_size_to_bytes(self.DEFAULT_MAX_SIZE) + logger.warning( + f"Invalid {self.CONFIG_KEY}={configured!r}, fallback to {self.DEFAULT_MAX_SIZE}MB.", + ) + return fallback + return parsed + + def _scan_temp_files(self) -> tuple[int, list[TempFileInfo]]: + if not self._temp_dir.exists(): + return 0, [] + + total_size = 0 + files: list[TempFileInfo] = [] + for path in self._temp_dir.rglob("*"): + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError as e: + logger.debug(f"Skip temp file {path} due to stat error: {e}") + continue + total_size += stat.st_size + files.append( + TempFileInfo(path=path, size=stat.st_size, mtime=stat.st_mtime) + ) + + return total_size, files + + def _cleanup_empty_dirs(self) -> None: + if not self._temp_dir.exists(): + return + for path in sorted( + self._temp_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True + ): + if not path.is_dir(): + continue + try: + path.rmdir() + except OSError: + continue + + def cleanup_once(self) -> None: + limit = self._limit_bytes() + if limit <= 0: + return + + total_size, files = self._scan_temp_files() + if total_size <= limit: + return + + target_release = max(int(total_size * self.CLEANUP_RATIO), 1) + released = 0 + removed_files = 0 + + for file_info in sorted(files, key=lambda item: item.mtime): + try: + file_info.path.unlink() + except OSError as e: + logger.warning(f"Failed to delete temp file {file_info.path}: {e}") + continue + + released += file_info.size + removed_files += 1 + if released >= target_release: + break + + self._cleanup_empty_dirs() + + logger.warning( + f"Temp dir exceeded limit ({total_size} > {limit}). " + f"Removed {removed_files} files, released {released} bytes " + f"(target {target_release} bytes).", + ) + + async def run(self) -> None: + logger.info( + f"TempDirCleaner started. interval={self.CHECK_INTERVAL_SECONDS}s " + f"cleanup_ratio={self.CLEANUP_RATIO}", + ) + while not self._stop_event.is_set(): + try: + # File-system traversal and deletion are blocking operations. + # Run cleanup in a worker thread to avoid blocking the event loop. + await asyncio.to_thread(self.cleanup_once) + except Exception as e: + logger.error(f"TempDirCleaner run failed: {e}", exc_info=True) + + try: + await asyncio.wait_for( + self._stop_event.wait(), + timeout=self.CHECK_INTERVAL_SECONDS, + ) + except asyncio.TimeoutError: + continue + + logger.info("TempDirCleaner stopped.") + + async def stop(self) -> None: + self._stop_event.set() diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py index b58643bd3..f342484bd 100644 --- a/astrbot/core/utils/tencent_record_helper.py +++ b/astrbot/core/utils/tencent_record_helper.py @@ -7,7 +7,7 @@ import wave from io import BytesIO from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str: @@ -117,12 +117,13 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: except ImportError as e: raise Exception("未安装 pilk: pip install pilk") from e - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) # 是否需要转换为 WAV ext = os.path.splitext(audio_path)[1].lower() temp_wav = tempfile.NamedTemporaryFile( + prefix="tencent_record_", suffix=".wav", delete=False, dir=temp_dir, @@ -140,6 +141,7 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: rate = wav_file.getframerate() silk_path = tempfile.NamedTemporaryFile( + prefix="tencent_record_", suffix=".silk", delete=False, dir=temp_dir, diff --git a/astrbot/dashboard/routes/knowledge_base.py b/astrbot/dashboard/routes/knowledge_base.py index efdc3bc6a..f0ac5d43d 100644 --- a/astrbot/dashboard/routes/knowledge_base.py +++ b/astrbot/dashboard/routes/knowledge_base.py @@ -12,6 +12,7 @@ from quart import request from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..utils import generate_tsne_visualization from .route import Response, Route, RouteContext @@ -703,7 +704,10 @@ class KnowledgeBaseRoute(Route): file_name = file.filename # 保存到临时文件 - temp_file_path = f"data/temp/{uuid.uuid4()}_{file_name}" + temp_file_path = os.path.join( + get_astrbot_temp_path(), + f"kb_upload_{uuid.uuid4()}_{file_name}", + ) await file.save(temp_file_path) try: diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index 85af3cef8..8c922ab69 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -12,7 +12,7 @@ from quart import websocket from astrbot import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .route import Route, RouteContext @@ -60,7 +60,7 @@ class LiveChatSession: # 组装 WAV 文件 try: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav") diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index f9f8961b4..bfdad5560 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -20,6 +20,7 @@ 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 .route import Response, Route, RouteContext @@ -431,7 +432,10 @@ class PluginRoute(Route): file = await request.files file = file["file"] logger.info(f"正在安装用户上传的插件 {file.filename}") - file_path = f"data/temp/{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) # self.core_lifecycle.restart() 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 2166d5391..e1019cc8a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -819,6 +819,10 @@ "description": "Log File Max Size (MB)", "hint": "Rotate when exceeding this size; default 20MB." }, + "temp_dir_max_size": { + "description": "Temp Directory Size Limit (MB)", + "hint": "Limits total size of data/temp in MB. The system checks every 10 minutes, and when exceeded, deletes oldest files first to release about 30% of current size." + }, "trace_log_enable": { "description": "Enable Trace File Logging", "hint": "Write trace events to a separate file (does not change console output)." 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 2d1c11cda..67681aa1d 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -822,6 +822,10 @@ "description": "日志文件大小上限 (MB)", "hint": "超过大小后自动轮转,默认 20MB。" }, + "temp_dir_max_size": { + "description": "临时目录大小上限 (MB)", + "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。" + }, "trace_log_enable": { "description": "启用 Trace 文件日志", "hint": "将 Trace 事件写入独立文件(不影响控制台输出)。" diff --git a/tests/test_temp_dir_cleaner.py b/tests/test_temp_dir_cleaner.py new file mode 100644 index 000000000..01f3e65d0 --- /dev/null +++ b/tests/test_temp_dir_cleaner.py @@ -0,0 +1,52 @@ +import os +import time +from pathlib import Path + +from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner, parse_size_to_bytes + + +def test_parse_size_to_bytes(): + assert parse_size_to_bytes("1024") == 1024 * 1024**2 + assert parse_size_to_bytes(2048) == 2048 * 1024**2 + assert parse_size_to_bytes("0.5") == int(0.5 * 1024**2) + assert parse_size_to_bytes(0) == 0 + assert parse_size_to_bytes("invalid") == 0 + + +def _write_file(path: Path, size: int, mtime: float) -> None: + path.write_bytes(b"x" * size) + os.utime(path, (mtime, mtime)) + + +def test_cleanup_once_releases_30_percent_and_prefers_old_files(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + + base_time = time.time() - 1000 + file_old = temp_dir / "old.bin" + file_mid = temp_dir / "mid.bin" + file_new = temp_dir / "new.bin" + _write_file(file_old, 400, base_time) + _write_file(file_mid, 300, base_time + 10) + _write_file(file_new, 300, base_time + 20) + + cleaner = TempDirCleaner(max_size_getter=lambda: "0.0008", temp_dir=temp_dir) + cleaner.cleanup_once() + + remaining_size = sum(f.stat().st_size for f in temp_dir.rglob("*") if f.is_file()) + assert remaining_size <= 600 + assert not file_old.exists() + assert file_mid.exists() + assert file_new.exists() + + +def test_cleanup_once_noop_when_below_limit(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + file_path = temp_dir / "a.bin" + _write_file(file_path, 100, time.time()) + + cleaner = TempDirCleaner(max_size_getter=lambda: "1", temp_dir=temp_dir) + cleaner.cleanup_once() + + assert file_path.exists() From 338d8a66100f2386491e13f7a429caa4381795fd Mon Sep 17 00:00:00 2001 From: Limitless <127183162+Limitless2023@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:07:13 +0800 Subject: [PATCH 07/49] fix: close unawaited reset coroutine on early return (#5033) When an OnLLMRequestEvent hook stops event propagation, the reset_coro created by build_main_agent was never awaited, causing a RuntimeWarning. Close the coroutine explicitly before returning. Fixes #5032 Co-authored-by: Limitless2023 --- .../pipeline/process_stage/method/agent_sub_stages/internal.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index d26f67add..0abee033d 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -190,6 +190,8 @@ class InternalAgentSubStage(Stage): ) if await call_event_hook(event, EventType.OnLLMRequestEvent, req): + if reset_coro: + reset_coro.close() return # apply reset From 336e2a2c400d87728a8fad20351ef5f7535e3781 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 12 Feb 2026 01:14:49 +0800 Subject: [PATCH 08/49] fix: update error logging message for connection failures --- astrbot/core/utils/network_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index feb234a30..7c0f13049 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -78,7 +78,7 @@ def log_connection_failure( ) else: logger.error( - f"[{provider_label}] 网络连接失败 ({error_type}),未配置代理。错误: {error}" + f"[{provider_label}] 网络连接失败 ({error_type})。错误: {error}" ) From bcdd8c463c5f7dbe5841023f3c2e71840a3c318b Mon Sep 17 00:00:00 2001 From: letr <123731298+letr007@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:17:15 +0800 Subject: [PATCH 09/49] docs: clean and sync README (#5014) * fix: close missing div in README * fix: sync README_zh-TW with README * fix: sync README * fix: correct typo correct url in README_en README_fr README_ru * docs: sync README_en with README * Update README_en.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> --- README.md | 4 ++-- README_en.md | 6 +++--- README_fr.md | 9 +++++++-- README_ja.md | 8 +++++++- README_ru.md | 10 ++++++++-- README_zh-TW.md | 8 +++++++- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 320a215f1..bd38d42ee 100644 --- a/README.md +++ b/README.md @@ -275,8 +275,6 @@ pre-commit install - -
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_ @@ -284,3 +282,5 @@ _陪伴与能力从来不应该是对立面。我们希望创造的是一个既 _私は、高性能ですから!_ + +
diff --git a/README_en.md b/README_en.md index 698c9c3d4..2c6cba5d0 100644 --- a/README_en.md +++ b/README_en.md @@ -180,7 +180,7 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA - [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [TokenPony](https://www.tokenpony.cn/3YPyf) -- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot) +- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) - [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) - ModelScope - OneAPI @@ -270,10 +270,10 @@ Additionally, the birth of this project would not have been possible without the - -
+_Companionship and capability should never be at odds. What we aim to create is a robot that can understand emotions, provide genuine companionship, and reliably accomplish tasks._ + _私は、高性能ですから!_ diff --git a/README_fr.md b/README_fr.md index c4e6f4065..fab9dda6f 100644 --- a/README_fr.md +++ b/README_fr.md @@ -174,7 +174,7 @@ paru -S astrbot-git - [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [TokenPony](https://www.tokenpony.cn/3YPyf) -- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot) +- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) - [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) - ModelScope - OneAPI @@ -262,7 +262,12 @@ De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des p
- +
+ +_La compagnie et la capacité ne devraient jamais être des opposés. Nous souhaitons créer un robot capable à la fois de comprendre les émotions, d'offrir de la présence, et d'accomplir des tâches de manière fiable._ _私は、高性能ですから!_ + + +
diff --git a/README_ja.md b/README_ja.md index 9e663db9d..d923fa2e3 100644 --- a/README_ja.md +++ b/README_ja.md @@ -263,6 +263,12 @@ AstrBot への貢献をしていただいたすべてのコントリビュータ - +
+ +_共感力と能力は決して対立するものではありません。私たちが目指すのは、感情を理解し、心の支えとなるだけでなく、確実に仕事をこなせるロボットの創造です。_ _私は、高性能ですから!_ + + + +
diff --git a/README_ru.md b/README_ru.md index b9053ddce..95e4656f0 100644 --- a/README_ru.md +++ b/README_ru.md @@ -164,7 +164,7 @@ paru -S astrbot-git - [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [TokenPony](https://www.tokenpony.cn/3YPyf) -- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot) +- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) - [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) - ModelScope - OneAPI @@ -246,13 +246,19 @@ pre-commit install > [!TIP] > Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3 +
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
- +
+ +_Сопровождение и способности никогда не должны быть противоположностями. Мы стремимся создать робота, который сможет как понимать эмоции, оказывать душевную поддержку, так и надёжно выполнять работу._ _私は、高性能ですから!_ + + +
diff --git a/README_zh-TW.md b/README_zh-TW.md index 79a91a09f..c5bc39478 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -252,6 +252,12 @@ pre-commit install - +
+ +_陪伴與能力從來不應該是對立面。我們希望創造的是一個既能理解情緒、給予陪伴,也能可靠完成工作的機器人。_ _私は、高性能ですから!_ + + + +
From b0de33c8015e4cd68f8e88dfd70f759cbc414026 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 12 Feb 2026 13:09:18 +0800 Subject: [PATCH 10/49] fix: provider extra param dialog key display error --- .../src/components/shared/ObjectEditor.vue | 63 +++++++++++-------- .../components/shared/TemplateListEditor.vue | 28 +++++---- .../src/i18n/locales/en-US/core/common.json | 16 +++++ .../src/i18n/locales/zh-CN/core/common.json | 16 +++++ 4 files changed, 87 insertions(+), 36 deletions(-) diff --git a/dashboard/src/components/shared/ObjectEditor.vue b/dashboard/src/components/shared/ObjectEditor.vue index ee6dc84bf..3247d1d22 100644 --- a/dashboard/src/components/shared/ObjectEditor.vue +++ b/dashboard/src/components/shared/ObjectEditor.vue @@ -2,7 +2,7 @@
- 暂无项目 + {{ t('core.common.objectEditor.noItems') }}
@@ -14,7 +14,7 @@
- {{ buttonText }} + {{ resolveButtonText }}
@@ -22,7 +22,7 @@ - {{ dialogTitle }} + {{ resolveDialogTitle }} @@ -36,7 +36,7 @@ density="compact" variant="outlined" hide-details - placeholder="键名" + :placeholder="t('core.common.objectEditor.placeholders.keyName')" @blur="updateKey(index, pair.key)" > @@ -47,7 +47,7 @@ density="compact" variant="outlined" hide-details - placeholder="字符串值" + :placeholder="t('core.common.objectEditor.placeholders.stringValue')" >
@@ -85,7 +85,7 @@ density="compact" variant="outlined" hide-details="auto" - placeholder="JSON" + :placeholder="t('core.common.objectEditor.placeholders.jsonValue')" @blur="updateJSON(index, pair.value)" :error-messages="pair.jsonError" > @@ -108,13 +108,13 @@
-
预设
+
{{ t('core.common.objectEditor.presets') }}
- {{ template.name || template.description || templateKey }} - {{ template.hint }} + {{ getTemplateTitle(template, templateKey) }} + {{ translateIfKey(template.hint) }}
@@ -125,7 +125,7 @@ density="compact" variant="outlined" hide-details - placeholder="字符串值" + :placeholder="t('core.common.objectEditor.placeholders.stringValue')" >
@@ -178,7 +178,7 @@
mdi-code-json -

暂无参数

+

{{ t('core.common.objectEditor.noParams') }}

@@ -187,7 +187,7 @@
mdi-plus - 添加 + {{ t('core.common.add') }}
- 取消 - 确认 + {{ t('core.common.cancel') }} + {{ t('core.common.confirm') }} @@ -220,9 +220,10 @@ \ No newline at end of file + diff --git a/dashboard/src/components/shared/TemplateListEditor.vue b/dashboard/src/components/shared/TemplateListEditor.vue index 796dae2dd..9cc49d9a9 100644 --- a/dashboard/src/components/shared/TemplateListEditor.vue +++ b/dashboard/src/components/shared/TemplateListEditor.vue @@ -19,8 +19,8 @@ :key="option.value" @click="addEntry(option.value)" > - {{ option.label }} - {{ option.hint }} + {{ translateIfKey(option.label) }} + {{ translateIfKey(option.hint) }} @@ -58,7 +58,7 @@
{{ templateLabel(entry.__template_key) }} - {{ getTemplate(entry)?.hint || getTemplate(entry)?.description }} + {{ translateIfKey(getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
@@ -82,10 +82,10 @@ >
- {{ itemMeta?.description || itemKey }} + {{ translateIfKey(itemMeta?.description) || itemKey }} - {{ itemMeta.hint }} + {{ translateIfKey(itemMeta.hint) }}
@@ -94,10 +94,10 @@ - {{ childMeta?.description || childKey }} + {{ translateIfKey(childMeta?.description) || childKey }} - {{ childMeta?.hint }} + {{ translateIfKey(childMeta?.hint) }} @@ -122,11 +122,11 @@ - {{ itemMeta?.description }} ({{ itemKey }}) + {{ translateIfKey(itemMeta?.description) }} ({{ itemKey }}) {{ itemKey }} - {{ itemMeta?.hint }} + {{ translateIfKey(itemMeta?.hint) }} @@ -153,7 +153,7 @@