From afa08d94793c4cd47bc0399fc7dbdd1a95d12a30 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 16 Mar 2026 23:42:42 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=A2=84=E5=8E=8B=E7=BC=A9=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=20=E9=81=BF=E5=85=8D=E5=8E=9F=E5=9B=BE=E4=BD=93=E7=A7=AF?= =?UTF-8?q?=E8=BF=87=E5=A4=A7=E9=80=A0=E6=88=90=E7=9A=84413=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 87b1726d67..e9d3c45594 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import copy import datetime import json @@ -448,7 +449,7 @@ async def _ensure_img_caption( caption = await _request_img_caption( image_caption_provider, cfg, - req.image_urls, + [await _compress_image_internal(url) for url in req.image_urls], plugin_context, ) if caption: @@ -458,6 +459,9 @@ async def _ensure_img_caption( req.image_urls = [] except Exception as exc: # noqa: BLE001 logger.error("处理图片描述失败: %s", exc) + finally: + req.extra_user_content_parts.append(TextPart(text="图片解析失败")) + req.image_urls = [] def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> None: @@ -523,7 +527,11 @@ async def _process_quote_message( if prov and isinstance(prov, Provider): llm_resp = await prov.text_chat( prompt=IMAGE_CAPTION_DEFAULT_PROMPT, - image_urls=[await image_seg.convert_to_file_path()], + image_urls=[ + await _compress_image_internal( + await image_seg.convert_to_file_path() + ) + ], ) if llm_resp.completion_text: content_parts.append( @@ -1164,3 +1172,47 @@ async def build_main_agent( provider=provider, reset_coro=reset_coro if not apply_reset else None, ) + +# 压缩用户上传的大体积图片 未来可以提取为通用工具 +async def _compress_image_internal(url_or_path: str) -> str: + try: + data = None + # 若为远程图片则直接返回原值 无需压缩 + if url_or_path.startswith("http"): + return url_or_path + elif url_or_path.startswith("data:image"): + header, encoded = url_or_path.split(",", 1) + data = base64.b64decode(encoded) + elif os.path.exists(url_or_path): + if os.path.getsize(url_or_path) < 1024 * 1024: + return url_or_path + with open(url_or_path, "rb") as f: + data = f.read() + if not data: + return url_or_path + import io + + from PIL import Image as PILImage + + img = PILImage.open(io.BytesIO(data)) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + max_size = 1280 + if max(img.size) > max_size: + img.thumbnail((max_size, max_size), PILImage.LANCZOS) + out_io = io.BytesIO() + img.save(out_io, format="JPEG", quality=75, optimize=True) + temp_dir = "/www/server/python_project/AstrBot/data/temp" + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) + import uuid + + temp_path = os.path.join(temp_dir, f"compressed_{uuid.uuid4().hex}.jpg") + with open(temp_path, "wb") as f: + f.write(out_io.getvalue()) + return temp_path + except Exception as e: + from astrbot.core import logger + + logger.warning(f"图片压缩失败: {e}") + return url_or_path From 6fae903d978e199c43ec5e803d4e68d35734b115 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 16 Mar 2026 23:50:41 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E2=80=9C=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E2=80=9D=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=BF=BD=E5=8A=A0=E7=9A=84=E4=BD=8D=E7=BD=AE=E9=94=99?= =?UTF-8?q?=E8=AF=AF=20=E4=BF=AE=E6=AD=A3"temp=5Fdir"=E7=9A=84=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index e9d3c45594..65dd48028c 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -459,8 +459,8 @@ async def _ensure_img_caption( req.image_urls = [] except Exception as exc: # noqa: BLE001 logger.error("处理图片描述失败: %s", exc) - finally: req.extra_user_content_parts.append(TextPart(text="图片解析失败")) + finally: req.image_urls = [] @@ -1202,7 +1202,7 @@ async def _compress_image_internal(url_or_path: str) -> str: img.thumbnail((max_size, max_size), PILImage.LANCZOS) out_io = io.BytesIO() img.save(out_io, format="JPEG", quality=75, optimize=True) - temp_dir = "/www/server/python_project/AstrBot/data/temp" + temp_dir = "./data/temp" if not os.path.exists(temp_dir): os.makedirs(temp_dir) import uuid From 47d82f05b53bbe5e631d00a2d0956444c46be52b Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 16 Mar 2026 23:55:10 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E4=BF=AE=E6=AD=A3temp=5Fdir=E7=9A=84?= =?UTF-8?q?=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 65dd48028c..dc188e7587 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1202,7 +1202,7 @@ async def _compress_image_internal(url_or_path: str) -> str: img.thumbnail((max_size, max_size), PILImage.LANCZOS) out_io = io.BytesIO() img.save(out_io, format="JPEG", quality=75, optimize=True) - temp_dir = "./data/temp" + temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data/temp") if not os.path.exists(temp_dir): os.makedirs(temp_dir) import uuid From 3dce797082cc7f5ff241c0af9b1b62bc07efa5fc Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 16 Mar 2026 23:56:27 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index dc188e7587..c998a23370 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -4,12 +4,15 @@ import base64 import copy import datetime +import io import json import os import zoneinfo from collections.abc import Coroutine from dataclasses import dataclass, field +from PIL import Image as PILImage + from astrbot.core import logger, sp from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool @@ -1190,9 +1193,6 @@ async def _compress_image_internal(url_or_path: str) -> str: data = f.read() if not data: return url_or_path - import io - - from PIL import Image as PILImage img = PILImage.open(io.BytesIO(data)) if img.mode in ("RGBA", "P"): From 7d1e50d68f6fb8f27cb54f0a2515c064425bfe73 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 01:51:54 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix:=E9=87=8D=E6=9E=84=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E6=96=B9=E6=B3=95=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=96=B9=E5=BC=8F=E9=81=BF=E5=85=8D=E9=98=BB?= =?UTF-8?q?=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index e9d3c45594..d9d22bbf6b 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -4,12 +4,16 @@ import base64 import copy import datetime +import io import json import os +import uuid import zoneinfo from collections.abc import Coroutine from dataclasses import dataclass, field +from PIL import Image as PILImage + from astrbot.core import logger, sp from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool @@ -459,8 +463,8 @@ async def _ensure_img_caption( req.image_urls = [] except Exception as exc: # noqa: BLE001 logger.error("处理图片描述失败: %s", exc) - finally: req.extra_user_content_parts.append(TextPart(text="图片解析失败")) + finally: req.image_urls = [] @@ -1190,9 +1194,6 @@ async def _compress_image_internal(url_or_path: str) -> str: data = f.read() if not data: return url_or_path - import io - - from PIL import Image as PILImage img = PILImage.open(io.BytesIO(data)) if img.mode in ("RGBA", "P"): @@ -1202,17 +1203,14 @@ async def _compress_image_internal(url_or_path: str) -> str: img.thumbnail((max_size, max_size), PILImage.LANCZOS) out_io = io.BytesIO() img.save(out_io, format="JPEG", quality=75, optimize=True) - temp_dir = "/www/server/python_project/AstrBot/data/temp" + temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data/temp") if not os.path.exists(temp_dir): os.makedirs(temp_dir) - import uuid temp_path = os.path.join(temp_dir, f"compressed_{uuid.uuid4().hex}.jpg") with open(temp_path, "wb") as f: f.write(out_io.getvalue()) return temp_path except Exception as e: - from astrbot.core import logger - logger.warning(f"图片压缩失败: {e}") return url_or_path From b0ba8562d5923fa4e4270cf1462595af0ea8c158 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 01:52:45 +0800 Subject: [PATCH 06/12] =?UTF-8?q?```=20feat(core):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8E=8B=E7=BC=A9=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2UUID=E4=B8=BA=E6=97=B6=E9=97=B4=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将图片压缩的同步阻塞操作移至线程池执行,提升性能 - 替换uuid依赖为time模块,使用时间戳生成文件名 - 添加异步图片压缩内部函数_do_compress_sync - 修复图片压缩时的异常处理日志级别 - 在消息附件和回复链中集成图片压缩功能 ``` --- astrbot/core/astr_main_agent.py | 46 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index d9d22bbf6b..03540139e6 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -7,7 +7,7 @@ import io import json import os -import uuid +import time import zoneinfo from collections.abc import Coroutine from dataclasses import dataclass, field @@ -948,7 +948,9 @@ async def build_main_agent( # media files attachments for comp in event.message_obj.message: if isinstance(comp, Image): - image_path = await comp.convert_to_file_path() + image_path = await _compress_image_internal( + await comp.convert_to_file_path() + ) req.image_urls.append(image_path) req.extra_user_content_parts.append( TextPart(text=f"[Image Attachment: path {image_path}]") @@ -975,7 +977,9 @@ async def build_main_agent( for reply_comp in comp.chain: if isinstance(reply_comp, Image): has_embedded_image = True - image_path = await reply_comp.convert_to_file_path() + image_path = await _compress_image_internal( + await reply_comp.convert_to_file_path() + ) req.image_urls.append(image_path) _append_quoted_image_attachment(req, image_path) elif isinstance(reply_comp, File): @@ -1177,6 +1181,22 @@ async def build_main_agent( reset_coro=reset_coro if not apply_reset else None, ) +# 异步图片压缩 +def _do_compress_sync(data: bytes, temp_dir: str) -> str: + """同步执行图片压缩逻辑,由 asyncio.to_thread 调用""" + + img = PILImage.open(io.BytesIO(data)) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + max_size = 1280 + if max(img.size) > max_size: + img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) + + timestamp = int(time.time() * 1000) + save_path = os.path.join(temp_dir, f"compressed_{timestamp}.jpg") + img.save(save_path, "JPEG", quality=85, optimize=True) + return save_path + # 压缩用户上传的大体积图片 未来可以提取为通用工具 async def _compress_image_internal(url_or_path: str) -> str: try: @@ -1192,25 +1212,15 @@ async def _compress_image_internal(url_or_path: str) -> str: return url_or_path with open(url_or_path, "rb") as f: data = f.read() + if not data: return url_or_path - img = PILImage.open(io.BytesIO(data)) - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - max_size = 1280 - if max(img.size) > max_size: - img.thumbnail((max_size, max_size), PILImage.LANCZOS) - out_io = io.BytesIO() - img.save(out_io, format="JPEG", quality=75, optimize=True) temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data/temp") - if not os.path.exists(temp_dir): - os.makedirs(temp_dir) - temp_path = os.path.join(temp_dir, f"compressed_{uuid.uuid4().hex}.jpg") - with open(temp_path, "wb") as f: - f.write(out_io.getvalue()) - return temp_path + # 使用 asyncio.to_thread 将同步阻塞的图片处理任务交给线程池 + return await asyncio.to_thread(_do_compress_sync, data, temp_dir) + except Exception as e: - logger.warning(f"图片压缩失败: {e}") + logger.error("图片压缩失败: %s", e) return url_or_path From 1347b3db8381d1aff285128586a0bd71a03235e3 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 02:33:58 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9temp=5Fdir=E6=8C=87?= =?UTF-8?q?=E5=90=91=20=E7=8E=B0=E5=9C=A8=E4=BD=BF=E7=94=A8=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E5=86=85=E7=BD=AE=E7=9A=84get=5Fastrbot=5Ftemp=5Fpath?= =?UTF-8?q?=E6=9D=A5=E8=8E=B7=E5=8F=96=E4=B8=B4=E6=97=B6=E7=9B=AE=20fix:?= =?UTF-8?q?=E5=88=86=E7=A6=BBimage=5Fpath=E7=9A=84=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=20=E6=8F=90=E9=AB=98=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=80=A7=20fix:=E4=BD=BF=E7=94=A8uuid=E6=9D=A5=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8E=8B=E7=BC=A9=E5=90=8E=E7=9A=84=E5=9B=BE=E7=89=87?= =?UTF-8?q?=20=E8=80=8C=E9=9D=9E=E6=97=B6=E9=97=B4=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 03540139e6..4eea96506e 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -7,7 +7,7 @@ import io import json import os -import time +import uuid import zoneinfo from collections.abc import Coroutine from dataclasses import dataclass, field @@ -53,6 +53,7 @@ WEBCHAT_TITLE_GENERATOR_USER_PROMPT, ) from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.file_extract import extract_file_moonshotai from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.quoted_message.settings import ( @@ -529,12 +530,12 @@ async def _process_quote_message( prov = plugin_context.get_using_provider(event.unified_msg_origin) if prov and isinstance(prov, Provider): + path = await image_seg.convert_to_file_path() + image_path = await _compress_image_internal(path) llm_resp = await prov.text_chat( prompt=IMAGE_CAPTION_DEFAULT_PROMPT, image_urls=[ - await _compress_image_internal( - await image_seg.convert_to_file_path() - ) + image_path ], ) if llm_resp.completion_text: @@ -948,9 +949,8 @@ async def build_main_agent( # media files attachments for comp in event.message_obj.message: if isinstance(comp, Image): - image_path = await _compress_image_internal( - await comp.convert_to_file_path() - ) + path = await comp.convert_to_file_path() + image_path = await _compress_image_internal(path) req.image_urls.append(image_path) req.extra_user_content_parts.append( TextPart(text=f"[Image Attachment: path {image_path}]") @@ -977,9 +977,8 @@ async def build_main_agent( for reply_comp in comp.chain: if isinstance(reply_comp, Image): has_embedded_image = True - image_path = await _compress_image_internal( - await reply_comp.convert_to_file_path() - ) + path = await reply_comp.convert_to_file_path() + image_path = await _compress_image_internal(path) req.image_urls.append(image_path) _append_quoted_image_attachment(req, image_path) elif isinstance(reply_comp, File): @@ -1181,6 +1180,7 @@ async def build_main_agent( reset_coro=reset_coro if not apply_reset else None, ) + # 异步图片压缩 def _do_compress_sync(data: bytes, temp_dir: str) -> str: """同步执行图片压缩逻辑,由 asyncio.to_thread 调用""" @@ -1192,11 +1192,12 @@ def _do_compress_sync(data: bytes, temp_dir: str) -> str: if max(img.size) > max_size: img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) - timestamp = int(time.time() * 1000) - save_path = os.path.join(temp_dir, f"compressed_{timestamp}.jpg") + new_uuid = uuid.uuid4().hex + save_path = os.path.join(temp_dir, f"compressed_{new_uuid}.jpg") img.save(save_path, "JPEG", quality=85, optimize=True) return save_path + # 压缩用户上传的大体积图片 未来可以提取为通用工具 async def _compress_image_internal(url_or_path: str) -> str: try: @@ -1216,7 +1217,8 @@ async def _compress_image_internal(url_or_path: str) -> str: if not data: return url_or_path - temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data/temp") + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) # 使用 asyncio.to_thread 将同步阻塞的图片处理任务交给线程池 return await asyncio.to_thread(_do_compress_sync, data, temp_dir) From e9becb5260a1a0a55499064ccc561271453d64f6 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 02:39:28 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 4eea96506e..bd5a06976a 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1183,7 +1183,7 @@ async def build_main_agent( # 异步图片压缩 def _do_compress_sync(data: bytes, temp_dir: str) -> str: - """同步执行图片压缩逻辑,由 asyncio.to_thread 调用""" + """同步执行图片压缩逻辑,由 _compress_image_internal 调用""" img = PILImage.open(io.BytesIO(data)) if img.mode in ("RGBA", "P"): From ed373a7f8bebe9b3ce24e84179afe39c4a37ca2c Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 17:43:25 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix:=E5=88=86=E7=A6=BB=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E5=87=BD=E6=95=B0=E5=88=B0=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=84=E7=90=86=E5=B7=A5=E5=85=B7=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 68 +++++-------------------------- astrbot/core/utils/media_utils.py | 66 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 57 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index bd5a06976a..51c28ab130 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1,19 +1,14 @@ from __future__ import annotations import asyncio -import base64 import copy import datetime -import io import json import os -import uuid import zoneinfo from collections.abc import Coroutine from dataclasses import dataclass, field -from PIL import Image as PILImage - from astrbot.core import logger, sp from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool @@ -53,9 +48,9 @@ WEBCHAT_TITLE_GENERATOR_USER_PROMPT, ) from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.file_extract import extract_file_moonshotai from astrbot.core.utils.llm_metadata import LLM_METADATAS +from astrbot.core.utils.media_utils import compress_image from astrbot.core.utils.quoted_message.settings import ( SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS, ) @@ -454,7 +449,7 @@ async def _ensure_img_caption( caption = await _request_img_caption( image_caption_provider, cfg, - [await _compress_image_internal(url) for url in req.image_urls], + [await compress_image(url, cfg) for url in req.image_urls], plugin_context, ) if caption: @@ -492,6 +487,7 @@ async def _process_quote_message( img_cap_prov_id: str, plugin_context: Context, quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS, + config: MainAgentBuildConfig | None = None, ) -> None: quote = None for comp in event.message_obj.message: @@ -531,7 +527,9 @@ async def _process_quote_message( if prov and isinstance(prov, Provider): path = await image_seg.convert_to_file_path() - image_path = await _compress_image_internal(path) + image_path = await compress_image( + path, config.provider_settings if config else None + ) llm_resp = await prov.text_chat( prompt=IMAGE_CAPTION_DEFAULT_PROMPT, image_urls=[ @@ -628,6 +626,7 @@ async def _decorate_llm_request( img_cap_prov_id, plugin_context, quoted_message_settings, + config, ) tz = config.timezone @@ -950,7 +949,7 @@ async def build_main_agent( for comp in event.message_obj.message: if isinstance(comp, Image): path = await comp.convert_to_file_path() - image_path = await _compress_image_internal(path) + image_path = await compress_image(path, config.provider_settings) req.image_urls.append(image_path) req.extra_user_content_parts.append( TextPart(text=f"[Image Attachment: path {image_path}]") @@ -978,7 +977,9 @@ async def build_main_agent( if isinstance(reply_comp, Image): has_embedded_image = True path = await reply_comp.convert_to_file_path() - image_path = await _compress_image_internal(path) + image_path = await compress_image( + path, config.provider_settings + ) req.image_urls.append(image_path) _append_quoted_image_attachment(req, image_path) elif isinstance(reply_comp, File): @@ -1179,50 +1180,3 @@ async def build_main_agent( provider=provider, reset_coro=reset_coro if not apply_reset else None, ) - - -# 异步图片压缩 -def _do_compress_sync(data: bytes, temp_dir: str) -> str: - """同步执行图片压缩逻辑,由 _compress_image_internal 调用""" - - img = PILImage.open(io.BytesIO(data)) - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - max_size = 1280 - if max(img.size) > max_size: - img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) - - new_uuid = uuid.uuid4().hex - save_path = os.path.join(temp_dir, f"compressed_{new_uuid}.jpg") - img.save(save_path, "JPEG", quality=85, optimize=True) - return save_path - - -# 压缩用户上传的大体积图片 未来可以提取为通用工具 -async def _compress_image_internal(url_or_path: str) -> str: - try: - data = None - # 若为远程图片则直接返回原值 无需压缩 - if url_or_path.startswith("http"): - return url_or_path - elif url_or_path.startswith("data:image"): - header, encoded = url_or_path.split(",", 1) - data = base64.b64decode(encoded) - elif os.path.exists(url_or_path): - if os.path.getsize(url_or_path) < 1024 * 1024: - return url_or_path - with open(url_or_path, "rb") as f: - data = f.read() - - if not data: - return url_or_path - - temp_dir = get_astrbot_temp_path() - os.makedirs(temp_dir, exist_ok=True) - - # 使用 asyncio.to_thread 将同步阻塞的图片处理任务交给线程池 - return await asyncio.to_thread(_do_compress_sync, data, temp_dir) - - except Exception as e: - logger.error("图片压缩失败: %s", e) - return url_or_path diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index 8d833514fb..f77d7c58e0 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -4,11 +4,15 @@ """ import asyncio +import base64 +import io import os import subprocess import uuid from pathlib import Path +from PIL import Image as PILImage + from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path @@ -316,3 +320,65 @@ async def extract_video_cover( return output_path except FileNotFoundError: raise Exception("ffmpeg not found") +def _compress_image_sync(data: bytes, temp_dir: str) -> str: + """同步执行图片压缩逻辑,由 asyncio.to_thread 调用""" + img = PILImage.open(io.BytesIO(data)) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + max_size = 1280 + if max(img.size) > max_size: + img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) + + new_uuid = uuid.uuid4().hex + save_path = os.path.join(temp_dir, f"compressed_{new_uuid}.jpg") + img.save(save_path, "JPEG", quality=85, optimize=True) + logger.info(f"图片压缩成功:{save_path}") + return save_path + + +async def compress_image( + url_or_path: str, provider_settings: dict | None = None +) -> str: + """压缩用户上传的大体积图片 + + Args: + url_or_path: 图片路径或URL + provider_settings: 提供商设置字典,用于获取 image_compress_enabled 配置 + + Returns: + 压缩后的图片路径,如果未启用压缩或压缩失败则返回原路径 + """ + # 从 provider_settings 获取 image_compress_enabled,默认为 True + enabled = True + if provider_settings: + enabled = provider_settings.get("image_compress_enabled", True) + if not enabled: + logger.info("未启用图像压缩 跳过压缩阶段") + return url_or_path + logger.info("已启用图像压缩") + try: + data = None + # 若为远程图片则直接返回原值 无需压缩 + if url_or_path.startswith("http"): + return url_or_path + elif url_or_path.startswith("data:image"): + _header, encoded = url_or_path.split(",", 1) + data = base64.b64decode(encoded) + elif os.path.exists(url_or_path): + if os.path.getsize(url_or_path) < 1024 * 1024: + return url_or_path + with open(url_or_path, "rb") as f: + data = f.read() + + if not data: + return url_or_path + + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + + # 使用 asyncio.to_thread 将同步阻塞的图片处理任务交给线程池 + return await asyncio.to_thread(_compress_image_sync, data, str(temp_dir)) + + except Exception as e: + logger.error("图片压缩失败: %s", e) + return url_or_path From 8472a9e37e86ed2a3dbb500781632751a05d6934 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 18:24:15 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E5=8E=9F=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E5=88=97=E8=A1=A8=E6=8E=A8=E5=AF=BC=E5=BC=8F=E4=BB=A5?= =?UTF-8?q?=E6=8F=90=E9=AB=98=E5=8F=AF=E8=AF=BB=E6=80=A7=20feat:=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=A2=84=E7=95=99=E5=8A=9F=E8=83=BD=E7=9A=84=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 11 +++++++---- astrbot/core/utils/media_utils.py | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 51c28ab130..8da81169d3 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -446,10 +446,15 @@ async def _ensure_img_caption( image_caption_provider: str, ) -> None: try: + # 同步处理以避免高额cpu开销 + compressed_urls = [] + for url in req.image_urls: + compressed_url = await compress_image(url, cfg) + compressed_urls.append(compressed_url) caption = await _request_img_caption( image_caption_provider, cfg, - [await compress_image(url, cfg) for url in req.image_urls], + compressed_urls, plugin_context, ) if caption: @@ -532,9 +537,7 @@ async def _process_quote_message( ) llm_resp = await prov.text_chat( prompt=IMAGE_CAPTION_DEFAULT_PROMPT, - image_urls=[ - image_path - ], + image_urls=[image_path], ) if llm_resp.completion_text: content_parts.append( diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index f77d7c58e0..4c467cc6ae 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -349,6 +349,7 @@ async def compress_image( 压缩后的图片路径,如果未启用压缩或压缩失败则返回原路径 """ # 从 provider_settings 获取 image_compress_enabled,默认为 True + # 未来视需求 可在前端增加独立配置项 此处配置读取的作用是预留功能 enabled = True if provider_settings: enabled = provider_settings.get("image_compress_enabled", True) From 684d207fa919e18ec694e2ccab3d1fcb40b9c652 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Tue, 17 Mar 2026 21:56:07 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=E5=9C=A8=E5=9B=BE=E7=89=87=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E5=B7=A5=E5=85=B7=E4=B8=AD=E4=BD=BF=E7=94=A8pathlib.p?= =?UTF-8?q?ath=E8=8E=B7=E5=8F=96=E8=B7=AF=E5=BE=84=E5=B9=B6=E6=8B=BC?= =?UTF-8?q?=E6=8E=A5=E5=8E=8B=E7=BC=A9=E5=90=8E=E7=9A=84=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/utils/media_utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index 4c467cc6ae..2117bc7b2a 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -320,7 +320,9 @@ async def extract_video_cover( return output_path except FileNotFoundError: raise Exception("ffmpeg not found") -def _compress_image_sync(data: bytes, temp_dir: str) -> str: + + +def _compress_image_sync(data: bytes, temp_dir: Path) -> str: """同步执行图片压缩逻辑,由 asyncio.to_thread 调用""" img = PILImage.open(io.BytesIO(data)) if img.mode in ("RGBA", "P"): @@ -330,10 +332,10 @@ def _compress_image_sync(data: bytes, temp_dir: str) -> str: img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) new_uuid = uuid.uuid4().hex - save_path = os.path.join(temp_dir, f"compressed_{new_uuid}.jpg") + save_path = temp_dir / f"compressed_{new_uuid}.jpg" img.save(save_path, "JPEG", quality=85, optimize=True) logger.info(f"图片压缩成功:{save_path}") - return save_path + return str(save_path) async def compress_image( @@ -374,11 +376,11 @@ async def compress_image( if not data: return url_or_path - temp_dir = get_astrbot_temp_path() - os.makedirs(temp_dir, exist_ok=True) + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) # 使用 asyncio.to_thread 将同步阻塞的图片处理任务交给线程池 - return await asyncio.to_thread(_compress_image_sync, data, str(temp_dir)) + return await asyncio.to_thread(_compress_image_sync, data, temp_dir) except Exception as e: logger.error("图片压缩失败: %s", e) From e17ba4114c5f74cb41e62a3fb2b8cb5b574da2e3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 22 Mar 2026 16:35:57 +0800 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E7=89=87=E9=A2=84=E5=8E=8B=E7=BC=A9=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=9B=BE=E7=89=87=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=AE=B9=E9=94=99=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 66 ++++++++-- astrbot/core/computer/tools/shell.py | 4 +- astrbot/core/config/default.py | 28 ++++ astrbot/core/utils/media_utils.py | 121 +++++++++++------- .../en-US/features/config-metadata.json | 18 ++- .../ru-RU/features/config-metadata.json | 16 +++ .../zh-CN/features/config-metadata.json | 16 +++ 7 files changed, 210 insertions(+), 59 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 8da81169d3..1b3ace7203 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -50,7 +50,11 @@ from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL from astrbot.core.utils.file_extract import extract_file_moonshotai from astrbot.core.utils.llm_metadata import LLM_METADATAS -from astrbot.core.utils.media_utils import compress_image +from astrbot.core.utils.media_utils import ( + IMAGE_COMPRESS_DEFAULT_MAX_SIZE, + IMAGE_COMPRESS_DEFAULT_QUALITY, + compress_image, +) from astrbot.core.utils.quoted_message.settings import ( SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS, ) @@ -446,10 +450,9 @@ async def _ensure_img_caption( image_caption_provider: str, ) -> None: try: - # 同步处理以避免高额cpu开销 compressed_urls = [] for url in req.image_urls: - compressed_url = await compress_image(url, cfg) + compressed_url = await _compress_image_for_provider(url, cfg) compressed_urls.append(compressed_url) caption = await _request_img_caption( image_caption_provider, @@ -464,7 +467,7 @@ async def _ensure_img_caption( req.image_urls = [] except Exception as exc: # noqa: BLE001 logger.error("处理图片描述失败: %s", exc) - req.extra_user_content_parts.append(TextPart(text="图片解析失败")) + req.extra_user_content_parts.append(TextPart(text="[Image Captioning Failed]")) finally: req.image_urls = [] @@ -486,6 +489,46 @@ def _get_quoted_message_parser_settings( return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides) +def _get_image_compress_args( + provider_settings: dict[str, object] | None, +) -> tuple[bool, int, int]: + if not isinstance(provider_settings, dict): + return True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY + + enabled = provider_settings.get("image_compress_enabled", True) + if not isinstance(enabled, bool): + enabled = True + + raw_options = provider_settings.get("image_compress_options", {}) + options = raw_options if isinstance(raw_options, dict) else {} + + max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE) + if not isinstance(max_size, int): + max_size = IMAGE_COMPRESS_DEFAULT_MAX_SIZE + max_size = max(max_size, 1) + + quality = options.get("quality", IMAGE_COMPRESS_DEFAULT_QUALITY) + if not isinstance(quality, int): + quality = IMAGE_COMPRESS_DEFAULT_QUALITY + quality = min(max(quality, 1), 100) + + return enabled, max_size, quality + + +async def _compress_image_for_provider( + url_or_path: str, + provider_settings: dict[str, object] | None, +) -> str: + try: + enabled, max_size, quality = _get_image_compress_args(provider_settings) + if not enabled: + return url_or_path + return await compress_image(url_or_path, max_size=max_size, quality=quality) + except Exception as exc: # noqa: BLE001 + logger.error("Image compression failed: %s", exc) + return url_or_path + + async def _process_quote_message( event: AstrMessageEvent, req: ProviderRequest, @@ -532,8 +575,9 @@ async def _process_quote_message( if prov and isinstance(prov, Provider): path = await image_seg.convert_to_file_path() - image_path = await compress_image( - path, config.provider_settings if config else None + image_path = await _compress_image_for_provider( + path, + config.provider_settings if config else None, ) llm_resp = await prov.text_chat( prompt=IMAGE_CAPTION_DEFAULT_PROMPT, @@ -952,7 +996,10 @@ async def build_main_agent( for comp in event.message_obj.message: if isinstance(comp, Image): path = await comp.convert_to_file_path() - image_path = await compress_image(path, config.provider_settings) + image_path = await _compress_image_for_provider( + path, + config.provider_settings, + ) req.image_urls.append(image_path) req.extra_user_content_parts.append( TextPart(text=f"[Image Attachment: path {image_path}]") @@ -980,8 +1027,9 @@ async def build_main_agent( if isinstance(reply_comp, Image): has_embedded_image = True path = await reply_comp.convert_to_file_path() - image_path = await compress_image( - path, config.provider_settings + image_path = await _compress_image_for_provider( + path, + config.provider_settings, ) req.image_urls.append(image_path) _append_quoted_image_attachment(req, image_path) diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index 251a67f361..d9fb25e7dc 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -62,7 +62,9 @@ async def call( umo=context.context.event.unified_msg_origin ) try: - timeout = int(config.get("provider_settings", {}).get("tool_call_timeout", 30)) + timeout = int( + config.get("provider_settings", {}).get("tool_call_timeout", 30) + ) except (ValueError, TypeError): timeout = 30 result = await sb.shell.exec( diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index d7d0020f1e..74355f487c 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -147,6 +147,11 @@ "shipyard_neo_profile": "python-default", "shipyard_neo_ttl": 3600, }, + "image_compress_enabled": True, + "image_compress_options": { + "max_size": 1024, + "quality": 95, + }, }, # SubAgent orchestrator mode: # - main_enable = False: disabled; main LLM mounts tools normally (persona selection). @@ -3328,6 +3333,29 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "可使用 {{prompt}} 作为用户输入的占位符。如果不输入占位符则代表添加在用户输入的前面。", }, + "provider_settings.image_compress_enabled": { + "description": "启用图片压缩", + "type": "bool", + "hint": "启用后,发送给多模态模型前会先压缩本地大图片。仅对 chat_completion 提供商生效。", + }, + "provider_settings.image_compress_options.max_size": { + "description": "最大边长", + "type": "int", + "hint": "压缩后图片的最长边,单位为像素。超过该尺寸时会按比例缩放。", + "condition": { + "provider_settings.image_compress_enabled": True, + }, + "slider": {"min": 256, "max": 4096, "step": 64}, + }, + "provider_settings.image_compress_options.quality": { + "description": "压缩质量", + "type": "int", + "hint": "JPEG 输出质量,范围为 1-100。值越高,画质越好,文件也越大。", + "condition": { + "provider_settings.image_compress_enabled": True, + }, + "slider": {"min": 1, "max": 100, "step": 1}, + }, "provider_tts_settings.dual_output": { "description": "开启 TTS 时同时输出语音和文字内容", "type": "bool", diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index 2117bc7b2a..d3f3cc75d3 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -16,6 +16,11 @@ from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +IMAGE_COMPRESS_DEFAULT_MAX_SIZE = 1280 +IMAGE_COMPRESS_DEFAULT_QUALITY = 95 +IMAGE_COMPRESS_DEFAULT_OPTIMIZE = True +IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB = 1.0 + async def get_media_duration(file_path: str) -> int | None: """使用ffprobe获取媒体文件时长 @@ -322,66 +327,86 @@ async def extract_video_cover( raise Exception("ffmpeg not found") -def _compress_image_sync(data: bytes, temp_dir: Path) -> str: - """同步执行图片压缩逻辑,由 asyncio.to_thread 调用""" - img = PILImage.open(io.BytesIO(data)) - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - max_size = 1280 - if max(img.size) > max_size: - img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) +def _compress_image_sync( + data: bytes, + temp_dir: Path, + max_size: int, + quality: int, + optimize: bool, +) -> str: + """Run image compression synchronously via ``asyncio.to_thread``.""" + with PILImage.open(io.BytesIO(data)) as opened_img: + img = opened_img + converted_img: PILImage.Image | None = None + + try: + if img.mode != "RGB": + converted_img = img.convert("RGB") + img = converted_img + + if max(img.size) > max_size: + img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) - new_uuid = uuid.uuid4().hex - save_path = temp_dir / f"compressed_{new_uuid}.jpg" - img.save(save_path, "JPEG", quality=85, optimize=True) - logger.info(f"图片压缩成功:{save_path}") - return str(save_path) + new_uuid = uuid.uuid4().hex + save_path = temp_dir / f"compressed_{new_uuid}.jpg" + img.save(save_path, "JPEG", quality=quality, optimize=optimize) + logger.debug(f"Image compressed successfully: {save_path}") + return str(save_path) + finally: + if converted_img is not None: + converted_img.close() async def compress_image( - url_or_path: str, provider_settings: dict | None = None + url_or_path: str, + max_size: int = IMAGE_COMPRESS_DEFAULT_MAX_SIZE, + quality: int = IMAGE_COMPRESS_DEFAULT_QUALITY, ) -> str: - """压缩用户上传的大体积图片 + """Compress large user-uploaded images. Args: - url_or_path: 图片路径或URL - provider_settings: 提供商设置字典,用于获取 image_compress_enabled 配置 + url_or_path: Image path or URL. + max_size: Longest edge of the compressed image in pixels. + quality: JPEG output quality in the range 1-100. Returns: - 压缩后的图片路径,如果未启用压缩或压缩失败则返回原路径 + The compressed image path. Returns the original path if compression + fails or the source does not need compression. """ - # 从 provider_settings 获取 image_compress_enabled,默认为 True - # 未来视需求 可在前端增加独立配置项 此处配置读取的作用是预留功能 - enabled = True - if provider_settings: - enabled = provider_settings.get("image_compress_enabled", True) - if not enabled: - logger.info("未启用图像压缩 跳过压缩阶段") + max_size = max(int(max_size), 1) + quality = min(max(int(quality), 1), 100) + optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE + min_file_size_bytes = int(IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024) + data = None + # Skip compression for remote images and return the original value. + if url_or_path.startswith("http"): return url_or_path - logger.info("已启用图像压缩") - try: - data = None - # 若为远程图片则直接返回原值 无需压缩 - if url_or_path.startswith("http"): + elif url_or_path.startswith("data:image"): + _header, encoded = url_or_path.split(",", 1) + data = base64.b64decode(encoded) + if len(data) < min_file_size_bytes: return url_or_path - elif url_or_path.startswith("data:image"): - _header, encoded = url_or_path.split(",", 1) - data = base64.b64decode(encoded) - elif os.path.exists(url_or_path): - if os.path.getsize(url_or_path) < 1024 * 1024: - return url_or_path - with open(url_or_path, "rb") as f: - data = f.read() - - if not data: + else: + local_path = Path(url_or_path) + if not local_path.exists(): return url_or_path + if local_path.stat().st_size < min_file_size_bytes: + return url_or_path + with local_path.open("rb") as f: + data = f.read() - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) - - # 使用 asyncio.to_thread 将同步阻塞的图片处理任务交给线程池 - return await asyncio.to_thread(_compress_image_sync, data, temp_dir) - - except Exception as e: - logger.error("图片压缩失败: %s", e) + if not data: return url_or_path + + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + + # Offload the blocking image processing task to a thread. + return await asyncio.to_thread( + _compress_image_sync, + data, + temp_dir, + max_size, + quality, + optimize, + ) 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 5688b4e45a..32f6b2733e 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -335,6 +335,22 @@ "description": "User Prompt", "hint": "You can use {{prompt}} as a placeholder for user input. If no placeholder is provided, it will be added before the user input." }, + "image_compress_enabled": { + "description": "Enable image compression", + "hint": "When enabled, large local images are compressed before being sent to multimodal models. Applies only to chat_completion providers." + }, + "image_compress_options": { + "description": "Image compression settings", + "hint": "Control image resize limits, JPEG quality, and the minimum size threshold for compression.", + "max_size": { + "description": "Maximum edge length", + "hint": "Longest edge of the compressed image in pixels. Images larger than this are resized proportionally." + }, + "quality": { + "description": "JPEG quality", + "hint": "JPEG output quality from 1 to 100. Higher values preserve more detail but produce larger files." + } + }, "reachability_check": { "description": "Provider Reachability Check", "hint": "When running the /provider command, test provider connectivity in parallel. This actively pings models and may consume extra tokens." @@ -1523,4 +1539,4 @@ "helpMiddle": "or", "helpSuffix": "." } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 56d12c9838..1a6961e8db 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -335,6 +335,22 @@ "description": "Промпт пользователя", "hint": "Вы можете использовать {{prompt}} как заполнитель для ввода. Если заполнитель не указан, он будет добавлен перед текстом пользователя." }, + "image_compress_enabled": { + "description": "Включить сжатие изображений", + "hint": "Когда включено, большие локальные изображения сжимаются перед отправкой в мультимодальные модели. Применяется только к провайдерам chat_completion." + }, + "image_compress_options": { + "description": "Настройки сжатия изображений", + "hint": "Управляет ограничением размера, качеством JPEG и минимальным порогом размера для сжатия.", + "max_size": { + "description": "Максимальная длина стороны", + "hint": "Максимальная длина стороны сжатого изображения в пикселях. Более крупные изображения пропорционально уменьшаются." + }, + "quality": { + "description": "Качество JPEG", + "hint": "Качество JPEG от 1 до 100. Более высокие значения сохраняют больше деталей, но увеличивают размер файла." + } + }, "reachability_check": { "description": "Проверка доступности провайдеров", "hint": "При выполнении команды /provider проверяет связь со всеми моделями. Это может расходовать токены." 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 be52995189..83a9cbb4bb 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -337,6 +337,22 @@ "description": "用户提示词", "hint": "可使用 {{prompt}} 作为用户输入的占位符。如果不输入占位符则代表添加在用户输入的前面。" }, + "image_compress_enabled": { + "description": "启用图片压缩", + "hint": "启用后,发送给多模态模型前会先压缩本地大图片。仅对 chat_completion 提供商生效。" + }, + "image_compress_options": { + "description": "图片压缩配置", + "hint": "用于控制图片压缩的尺寸、质量和触发阈值。", + "max_size": { + "description": "最大边长", + "hint": "压缩后图片的最长边,单位为像素。超过该尺寸时会按比例缩放。" + }, + "quality": { + "description": "JPEG 质量", + "hint": "JPEG 输出质量,范围为 1-100。值越高,画质越好,文件也越大。" + } + }, "reachability_check": { "description": "提供商可达性检测", "hint": "/provider 命令列出模型时并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。"