From fc843fac03e7c26dc621c7167025aa65d86a7c87 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Thu, 23 Apr 2026 08:43:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20T2I=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E4=B8=8E=E8=87=AA=E5=AE=9A=E4=B9=89=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 支持配置两轮 T2I 渲染策略,允许用户自定义图片格式(PNG/JPEG)、质量、分辨率及超时时间。 2. 实现 T2I 返回 HTML 错误页的自动识别与摘要提取(如 502 Bad Gateway),提升故障排查效率。 - 参考 #174 关于 T2I 动态超时与配置的建议 - 参考 #171 关于处理 T2I 返回 HTML 错误信息的建议 - 解决了 [Feature Request] 支持配置 PNG/JPEG 渲染优先级,避免大报告场景下因本地大图保护而回退为文件发送 #164 --- README.md | 17 ++- _conf_schema.json | 79 +++++++++++++ src/infrastructure/config/config_manager.py | 23 ++++ src/infrastructure/reporting/generators.py | 118 ++++++++++++-------- 4 files changed, 186 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 222df1a..9e4375a 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,19 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** > [!TIP] -> 如果生成图片失败,日志显示 `[Core] [WARN] [v4.xx.0] [utils.logger:xx]: [xxxxx_xxx群_xxxx] [群分析插件] 渲染策略 {'full_page': True, 'type': 'xxx', 'quality': xxx, 'scale': 'device', 'device_scale_factor_level': 'xxx'} 返回了无效或空数据`、`Endpoint https://t2i.soulter.top/text2img failed`、`Endpoint https://t2i.rcfortress.site/text2img failed` 这种情况并回退到文本总结,可考虑自部署 T2I 服务。 -> +> **图片生成失败/渲染超时的解决办法** +> +> 如果生成图片失败,日志显示 `渲染策略 ... 返回了无效或空数据`、`Endpoint ... failed` 等并回退到文本总结,通常是因为日报内容过大导致 T2I 渲染超时(默认 30s 左右)。 +> +> ### 1. 调整插件渲染参数 +> 插件现支持 **两轮渲染策略**,可在配置面板的 **图片渲染策略 (`t2i_rendering`)** 分组中按需调整: +> +> - **增加超时时间**:若日报极其复杂(包含大量内联 CSS/JS/图表),请将 `渲染超时 (ms)` 调大。建议范围:30,000ms - 180,000ms (3 分钟)。 +> - **优化回退策略**:建议第一轮使用 `png` + `ultra` 追求极致清晰;第二轮作为回退,建议使用 `jpeg` + `high/normal` 分辨率并配合更长的超时时间,以确保即使在资源受限的情况下也能产出报告。 +> +> ### 2. 使用备用 T2I 服务或自部署 >
-> 若不便自部署,可尝试使用以下 T2I 服务(点击展开): +> 若配置调整后渲染仍频繁失败,可尝试更换 T2I 服务(点击展开): > > - **Hugging Face 服务**: `https://huggingface.co/spaces/clown145/astrbot-t2i-service` > - **API 接口地址**: `https://clown145-astrbot-t2i-service.hf.space` @@ -119,7 +128,7 @@ _✨ 一个基于 AstrBot 的智能群聊分析插件,支持 **QQ (OneBot)** > - **说明**: 在国内直接访问原始域名下载图片可能较慢,可选择使用此代理域名。在一段时间内大概率稳定。 >
> -> **自部署 T2I 文档**:docs.astrbot.app/others/self-host-t2i.html +> **自部署 T2I 文档**:[docs.astrbot.app/others/self-host-t2i.html](https://docs.astrbot.app/others/self-host-t2i.html) > [!IMPORTANT] > diff --git a/_conf_schema.json b/_conf_schema.json index db3c2d4..e3876f9 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -139,6 +139,85 @@ } } }, + "t2i_rendering": { + "description": "图片渲染策略", + "type": "object", + "hint": "配置 HTML 转图片的渲染参数。支持两轮尝试:第一轮优先保证质量,第二轮作为回退方案,建议使用更低的分辨率和质量以提高成功率。", + "items": { + "t2i_r1_type": { + "type": "string", + "description": "第一轮:图片格式", + "options": ["jpeg", "png"], + "default": "png", + "hint": "建议:PNG 格式无损但体积较大;JPEG 格式体积较小。" + }, + "t2i_r1_quality": { + "type": "int", + "description": "第一轮:图片质量 (若为 JPEG)", + "default": 100, + "slider": { + "min": 1, + "max": 100, + "step": 1 + }, + "hint": "仅对 JPEG 格式生效。建议 80-100。" + }, + "t2i_r1_device_scale": { + "type": "string", + "description": "第一轮:分辨率等级", + "options": ["normal", "high", "ultra"], + "default": "ultra", + "hint": "影响图片清晰度。normal=1.0x, high=1.3x, ultra=1.8x。高质量日报建议 ultra。" + }, + "t2i_r1_timeout": { + "type": "int", + "description": "第一轮:渲染超时 (ms)", + "default": 50000, + "slider": { + "min": 30000, + "max": 180000, + "step": 5000 + }, + "hint": "单次渲染的最长等待时间。复杂页面建议调大。" + }, + "t2i_r2_type": { + "type": "string", + "description": "第二轮 (回退):图片格式", + "options": ["jpeg", "png"], + "default": "jpeg", + "hint": "回退策略建议使用 jpeg 以减少渲染压力。" + }, + "t2i_r2_quality": { + "type": "int", + "description": "第二轮 (回退):图片质量 (若为 JPEG)", + "default": 80, + "slider": { + "min": 1, + "max": 100, + "step": 1 + }, + "hint": "仅对 JPEG 格式生效。可以设置较低值以确保生成成功。" + }, + "t2i_r2_device_scale": { + "type": "string", + "description": "第二轮 (回退):分辨率等级", + "options": ["normal", "high", "ultra"], + "default": "high", + "hint": "回退建议使用 high/normal 以降低复杂页面的内存消耗和超时概率。" + }, + "t2i_r2_timeout": { + "type": "int", + "description": "第二轮 (回退):渲染超时 (ms)", + "default": 100000, + "slider": { + "min": 30000, + "max": 180000, + "step": 5000 + }, + "hint": "回退尝试通常针对复杂页面,建议设置更长的超时时间(如 100000ms+)。" + } + } + }, "auto_analysis": { "description": "定时分析设置", "type": "object", diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 68e429b..801c0eb 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -202,6 +202,29 @@ class ConfigManager: """获取是否启用 Base64 图片传输""" return self._get_group("basic").get("enable_base64_image", False) + def get_t2i_rendering_strategies(self) -> list[dict]: + """获取用户配置的两轮 T2I 渲染策略""" + group = self._get_group("t2i_rendering") + + return [ + # 第一轮:质量优先 + { + "full_page": True, + "type": group.get("t2i_r1_type", "png"), + "quality": group.get("t2i_r1_quality", 100), + "device_scale_factor_level": group.get("t2i_r1_device_scale", "ultra"), + "timeout": group.get("t2i_r1_timeout", 30000), + }, + # 第二轮:稳定性/回退优先 + { + "full_page": True, + "type": group.get("t2i_r2_type", "jpeg"), + "quality": group.get("t2i_r2_quality", 80), + "device_scale_factor_level": group.get("t2i_r2_device_scale", "normal"), + "timeout": group.get("t2i_r2_timeout", 60000), + }, + ] + def get_llm_provider_id(self) -> str: """获取主 LLM Provider ID""" return self._get_group("llm").get("llm_provider_id", "") diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index 668d65a..99ec734 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -366,54 +366,23 @@ class ReportGenerator(IReportGenerator): logger.info(f"图片报告HTML渲染完成,长度: {len(html_content)} 字符") + # 从配置中获取两轮渲染策略 + render_strategies = self.config_manager.get_t2i_rendering_strategies() + # 使用信号量控制并发进入渲染引擎 async with self._render_semaphore: logger.debug(f"[T2I] 已进入渲染队列 (群: {group_id})") - # 定义渲染策略 - render_strategies = [ - # 1. 第一策略: PNG, Ultra quality, Device scale - { - "full_page": True, - "type": "png", - "scale": "device", - "device_scale_factor_level": "ultra", - }, - # 2. 第二策略: JPEG, ultra, quality 100%, Device scale - { - "full_page": True, - "type": "jpeg", - "quality": 100, - "scale": "device", - "device_scale_factor_level": "ultra", - }, - # 3. 第三策略: JPEG, high, quality 80%, Device scale - { - "full_page": True, - "type": "jpeg", - "quality": 95, - "scale": "device", - "device_scale_factor_level": "high", # 尝试高分辨率 - }, - # 4. 第四策略: JPEG, normal quality, Device scale (后备) - { - "full_page": True, - "type": "jpeg", - "quality": 80, - "scale": "device", - # normal quality - }, - ] - last_exception = None - for image_options in render_strategies: + for attempt, image_options in enumerate(render_strategies, 1): try: # Cleanse options if image_options.get("type") == "png": - image_options["quality"] = None + image_options.pop("quality", None) + + logger.info(f"正在尝试第 {attempt} 轮渲染策略: {image_options}") - logger.info(f"正在尝试渲染策略: {image_options}") # 改为获取 bytes 数据,避免 OneBot 无法访问内部 URL image_data = await html_render_func( html_content, # 渲染后的HTML内容 @@ -445,32 +414,62 @@ class ReportGenerator(IReportGenerator): ) or actual_data_head.startswith(b"\x89PNG"): is_valid = True else: - logger.warning( - f"渲染结果似乎不是有效的图片数据 (头部: {actual_data_head.hex()})" - ) + # 尝试解析 HTML 错误(如 502 Bad Gateway) + html_error = None + if isinstance(image_data, bytes): + html_error = self._extract_html_error_summary( + image_data + ) + elif isinstance(image_data, str) and os.path.exists( + image_data + ): + try: + with open(image_data, "rb") as f: + # 读取前 4KB 即可识别 HTML 错误 + html_error = ( + self._extract_html_error_summary( + f.read(4096) + ) + ) + except Exception: + pass + + if html_error: + logger.warning( + f"[T2I] 渲染引擎返回了错误页面而非图片: {html_error}" + ) + else: + logger.warning( + f"渲染结果似乎不是有效的图片数据 (头部: {actual_data_head.hex()})" + ) if is_valid: if isinstance(image_data, bytes): b64 = base64.b64encode(image_data).decode("utf-8") image_url = f"base64://{b64}" logger.info( - f"图片生成成功 ({image_options}): [Base64 Data {len(image_data)} bytes]" + f"图片生成成功 (轮次 {attempt}): [Base64 Data {len(image_data)} bytes]" ) return image_url, html_content elif isinstance(image_data, str): - logger.info(f"图片生成成功 (String): {image_data}") + logger.info( + f"图片生成成功 (轮次 {attempt}): {image_data}" + ) return image_data, html_content - logger.warning(f"渲染策略 {image_options} 返回了无效或空数据") + logger.warning( + f"渲染轮次 {attempt} ({image_options['type']}) 返回了无效或空数据" + ) except Exception as e: - logger.warning(f"渲染策略 {image_options} 失败: {e}") + logger.warning(f"渲染轮次 {attempt} 失败: {e}") last_exception = e - logger.warning("尝试下一个策略") + if attempt < len(render_strategies): + logger.info("准备尝试下一轮回退策略") continue # 如果所有策略都失败 - logger.error(f"所有渲染策略都失败。最后一个错误: {last_exception}") + logger.error(f"所有渲染尝试都失败。最后一个错误: {last_exception}") return None, html_content except Exception as e: @@ -1071,3 +1070,28 @@ class ReportGenerator(IReportGenerator): logger.debug("头像缓存已关闭") except Exception as e: logger.warning(f"关闭头像缓存失败: {e}") + + def _extract_html_error_summary(self, data: bytes) -> str | None: + """从返回的字节流中尝试提取 HTML 错误信息(如 )""" + try: + content = data.decode("utf-8", errors="ignore") + content_lower = content.lower() + if "<html" in content_lower or "<!doctype html" in content_lower: + # 尝试提取标题 + title_match = re.search( + r"<title>(.*?)", content, re.IGNORECASE | re.DOTALL + ) + if title_match: + return f"HTML 错误页: {title_match.group(1).strip()}" + + # 尝试提取 h1 + h1_match = re.search( + r"

(.*?)

", content, re.IGNORECASE | re.DOTALL + ) + if h1_match: + return f"HTML 错误页: {h1_match.group(1).strip()}" + + return f"HTML 响应 (前100字): {content[:100].strip()}..." + except Exception: + pass + return None