From 09a2d022899b0acbb6fa3d4d3b6eaf6b93c9ce67 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 13 Jul 2026 10:56:41 +0800 Subject: [PATCH 1/5] feat(render): add configurable long image layouts Co-authored-by: GPT-5 Codex --- config.toml.example | 9 + docs/configuration.md | 9 + docs/deployment.md | 4 + docs/usage.md | 12 +- src/Undefined/config/config_class.py | 3 + src/Undefined/config/env_registry.py | 3 + src/Undefined/config/hot_reload.py | 3 + src/Undefined/config/load_sections/network.py | 44 ++++ src/Undefined/render.py | 88 ++++++- src/Undefined/skills/toolsets/README.md | 4 +- .../skills/toolsets/render/README.md | 5 +- .../skills/toolsets/render/layout.py | 171 +++++++++++++ .../toolsets/render/render_html/config.json | 22 +- .../toolsets/render/render_html/handler.py | 15 +- .../render/render_markdown/config.json | 20 +- .../render/render_markdown/handler.py | 15 +- src/Undefined/utils/render_cache.py | 20 +- tests/test_config_api.py | 13 + tests/test_config_env_only.py | 12 + tests/test_config_env_registry.py | 15 ++ tests/test_config_hot_reload.py | 28 +++ tests/test_render.py | 230 ++++++++++++++++++ tests/test_render_cache.py | 12 + tests/test_render_layout_tools.py | 196 +++++++++++++++ 24 files changed, 935 insertions(+), 18 deletions(-) create mode 100644 src/Undefined/skills/toolsets/render/layout.py create mode 100644 tests/test_render_layout_tools.py diff --git a/config.toml.example b/config.toml.example index 3818e77c..3904769a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1101,6 +1101,15 @@ use_proxy = false # zh: 渲染浏览器最大同时开启数量。0 表示自动:Linux 默认 1,其它平台默认 2。 # en: Max concurrent render browser pages. 0 = auto: Linux defaults to 1, other platforms default to 2. browser_max_concurrency = 0 +# zh: 可选的 Chrome/Chromium 可执行文件路径。留空时优先使用 Playwright 自带浏览器,缺失时再自动查找系统浏览器。 +# en: Optional Chrome/Chromium executable path. Empty prefers Playwright's bundled browser, then discovers an installed system browser if missing. +browser_executable_path = "" +# zh: render_html/render_markdown 的 long 布局未显式传 width 时使用的最终图片宽度(像素,320-2048)。 +# en: Final image width used by the long layout when width is omitted (pixels, 320-2048). +long_image_default_width = 900 +# zh: long 布局未显式传 padding 时使用的内边距(像素,0-160)。 +# en: Content padding used by the long layout when padding is omitted (pixels, 0-160). +long_image_default_padding = 28 # zh: HTML 渲染结果缓存:基于 HTML 内容 hash 复用同一张图片,避免重复渲染。 # en: HTML render result cache: reuse rendered images by content hash to skip re-rendering. diff --git a/docs/configuration.md b/docs/configuration.md index 09f88948..49003871 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -757,12 +757,18 @@ Prompt caching 补充: | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| | `browser_max_concurrency` | `0` | 渲染浏览器最大同时开启数量 | `<=0` 时启用自动值:Linux=`1`,其它平台=`2` | +| `browser_executable_path` | `""` | 可选 Chrome/Chromium 可执行文件路径 | 留空时优先使用 Playwright 自带浏览器;其缺失时自动查找系统 Chrome/Chromium | | `use_proxy` | `false` | HTML/Markdown 渲染及网页抓取渲染链路是否使用 `[proxy]` 中的代理地址 | | +| `long_image_default_width` | `900` | `layout=long` 未传 `width` 时的最终图片宽度(像素) | 自动钳制到 `320..2048` | +| `long_image_default_padding` | `28` | `layout=long` 未传 `padding` 时的内边距(像素) | 自动钳制到 `0..160`,且保证小于宽度的一半 | 说明: - 该配置只影响 `render.py` 的 HTML/Markdown 图片渲染链路,不影响 `crawl_webpage` 等独立浏览器实现。 - 渲染浏览器当前采用单例复用,因此这里限制的是并发页面/上下文数量,而不是浏览器进程数量。 +- 显式修改 `browser_executable_path` 后需重启 Bot;仅当 Playwright 报告自带浏览器缺失时才会自动回退到系统浏览器,其他启动错误仍会原样报出。 - 配置变更会对后续新的渲染请求生效;已在执行中的渲染任务不受影响。 +- `render.render_html` 和 `render.render_markdown` 默认使用 `layout=default`,视觉效果与旧版一致。显式传 `layout=long` 时,高度按内容自动延伸,使用 CSS 像素截图保证 `width` 对应最终图片宽度,并去掉两侧外部留白。 +- `width` 可选范围为 `320..2048`,`padding` 可选范围为 `0..160`;两者只能与 `layout=long` 一起使用。HTML 长图仍支持完整 CSS、外部资源与脚本;`padding=0` 可用于全幅设计。 #### `[render.cache]` HTML 渲染结果缓存 @@ -1567,6 +1573,9 @@ Prompt caching 补充: | TOML 路径 | 环境变量 | |-----------|----------| +| `render.browser_executable_path` | `RENDER_BROWSER_EXECUTABLE_PATH` | +| `render.long_image_default_padding` | `RENDER_LONG_IMAGE_DEFAULT_PADDING` | +| `render.long_image_default_width` | `RENDER_LONG_IMAGE_DEFAULT_WIDTH` | | `render.use_proxy` | `RENDER_USE_PROXY` | #### `search` diff --git a/docs/deployment.md b/docs/deployment.md index 36889c81..46539dd8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -61,6 +61,10 @@ uv run playwright install `render.render_latex` 会优先使用 Python 依赖中的 `matplotlib` mathtext 在本地渲染常见数学公式,不需要额外安装系统 TeX。mathtext 无法处理的复杂内容会回退到 MathJax + Playwright;如果运行环境无法访问 MathJax CDN,请在配置中启用 HTTP/HTTPS 代理。 +`render.render_html` / `render.render_markdown` 的 `layout=long` 与普通渲染复用同一套 Playwright 运行时,无需新增系统依赖。HTML 中的外部图片、字体和脚本仍需要宿主机可访问对应地址;需要代理时使用 `[render].use_proxy`。 + +如果 Playwright 自带 Chromium 未安装,渲染器会尝试复用系统已安装的 Chrome/Chromium。需要指定其他路径时,设置 `[render].browser_executable_path`;与 Playwright 自带版本相比,系统浏览器的版本兼容性不受 Playwright 保证,因此生产环境仍优先执行 `uv run playwright install`。 + ### 4. 配置环境 复制示例配置文件 `config.toml.example` 为 `config.toml` 并填写你的配置信息。 diff --git a/docs/usage.md b/docs/usage.md index f103a24b..13fd06d6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -153,15 +153,23 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 | 工具 | 说明 | |---|---| -| `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为图片发送 | +| `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为普通图片或单张长图 | | `render.render_latex` | 将 LaTeX 数学公式渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright(详见[部署文档](deployment.md#3-安装渲染运行时)) | -| `render.render_html` | 将 HTML 内容渲染为图片 | +| `render.render_html` | 将完整 HTML/CSS/脚本渲染为普通图片或单张长图 | 支持 `embed`(嵌入回复)和 `send`(直接发送)两种图片交付方式。 +HTML 和 Markdown 工具都支持显式长图版式: + +- `layout=default`:保持原有页面与居中宽版布局,不接受 `width` / `padding`。 +- `layout=long`:输出一张高度随内容延伸的 PNG,去除两侧外部留白。`width` 表示最终图片像素宽度,`padding` 表示内边距。 +- 未指定宽度和内边距时,默认为 `900px` 和 `28px`,可在 `[render]` 中调整。HTML 全幅设计可显式传 `padding=0`。 + **示例:** > *"请把这段数学公式渲染成图片发给我:$E=mc^2$"* > *"请把下面这份 Markdown 表格渲染成图片。"* +> *"请把这份 Markdown 渲染成 900px 宽的单张长图,不要两侧留白。"* +> *"把这份完整 HTML 按长图渲染,宽 1080px、内边距 0。"* --- diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index a29f9005..40d59516 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -123,7 +123,10 @@ class Config: network_request_timeout: float network_request_retries: int render_browser_max_concurrency: int + render_browser_executable_path: str render_use_proxy: bool + render_long_image_default_width: int + render_long_image_default_padding: int api_xxapi_base_url: str api_xingzhige_base_url: str api_jkyai_base_url: str diff --git a/src/Undefined/config/env_registry.py b/src/Undefined/config/env_registry.py index 3f31508d..2f5083d5 100644 --- a/src/Undefined/config/env_registry.py +++ b/src/Undefined/config/env_registry.py @@ -116,6 +116,9 @@ ("naga", "use_proxy"): "NAGA_USE_PROXY", ("onebot", "token"): "ONEBOT_TOKEN", ("onebot", "ws_url"): "ONEBOT_WS_URL", + ("render", "browser_executable_path"): "RENDER_BROWSER_EXECUTABLE_PATH", + ("render", "long_image_default_padding"): "RENDER_LONG_IMAGE_DEFAULT_PADDING", + ("render", "long_image_default_width"): "RENDER_LONG_IMAGE_DEFAULT_WIDTH", ("render", "use_proxy"): "RENDER_USE_PROXY", ("search", "use_proxy"): "SEARCH_USE_PROXY", ("search", "firecrawl_search_enabled"): "FIRECRAWL_SEARCH_ENABLED", diff --git a/src/Undefined/config/hot_reload.py b/src/Undefined/config/hot_reload.py index ed505b52..4bb432bc 100644 --- a/src/Undefined/config/hot_reload.py +++ b/src/Undefined/config/hot_reload.py @@ -32,6 +32,7 @@ "webui_port", "webui_password", "webui_autostart_bot", + "render_browser_executable_path", "api", "api.enabled", "api.host", @@ -79,6 +80,8 @@ "tool_search_enabled", "tool_search_always_loaded", "tool_search_max_results", + "render_long_image_default_width", + "render_long_image_default_padding", ) _AGENT_INTRO_KEYS: set[str] = { diff --git a/src/Undefined/config/load_sections/network.py b/src/Undefined/config/load_sections/network.py index 767f285e..0c786897 100644 --- a/src/Undefined/config/load_sections/network.py +++ b/src/Undefined/config/load_sections/network.py @@ -23,6 +23,10 @@ logger = logging.getLogger(__name__) +_LONG_IMAGE_MIN_WIDTH = 320 +_LONG_IMAGE_MAX_WIDTH = 2048 +_LONG_IMAGE_MAX_PADDING = 160 + def load_network( data: dict[str, Any], *, config_path: Optional[Path] = None @@ -127,9 +131,46 @@ def load_network( 0, ), ) + render_browser_executable_path = _coerce_str( + _get_value( + data, + ("render", "browser_executable_path"), + "RENDER_BROWSER_EXECUTABLE_PATH", + ), + "", + ) render_use_proxy = _coerce_bool( _get_value(data, ("render", "use_proxy"), "RENDER_USE_PROXY"), False ) + render_long_image_default_width = min( + _LONG_IMAGE_MAX_WIDTH, + max( + _LONG_IMAGE_MIN_WIDTH, + _coerce_int( + _get_value( + data, + ("render", "long_image_default_width"), + "RENDER_LONG_IMAGE_DEFAULT_WIDTH", + ), + 900, + ), + ), + ) + render_long_image_default_padding = min( + _LONG_IMAGE_MAX_PADDING, + max( + 0, + _coerce_int( + _get_value( + data, + ("render", "long_image_default_padding"), + "RENDER_LONG_IMAGE_DEFAULT_PADDING", + ), + 28, + ), + ), + (render_long_image_default_width - 1) // 2, + ) api_xxapi_base_url = _normalize_base_url( _coerce_str( @@ -194,7 +235,10 @@ def load_network( "network_request_timeout": network_request_timeout, "network_request_retries": network_request_retries, "render_browser_max_concurrency": render_browser_max_concurrency, + "render_browser_executable_path": render_browser_executable_path, "render_use_proxy": render_use_proxy, + "render_long_image_default_width": render_long_image_default_width, + "render_long_image_default_padding": render_long_image_default_padding, "api_xxapi_base_url": api_xxapi_base_url, "api_xingzhige_base_url": api_xingzhige_base_url, "api_jkyai_base_url": api_jkyai_base_url, diff --git a/src/Undefined/render.py b/src/Undefined/render.py index b8c309c9..a480fda9 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -2,6 +2,7 @@ import asyncio import logging +import shutil import sys from collections.abc import Awaitable, Callable from pathlib import Path @@ -9,7 +10,7 @@ import markdown -from typing import Any, TypeVar +from typing import Any, Literal, TypeVar from Undefined.config import get_config from Undefined.utils.render_cache import compute_render_cache_key, get_render_cache @@ -59,6 +60,13 @@ # 默认并发限制:Linux 默认 1,其它平台默认 2 _DEFAULT_MAX_CONCURRENT = 1 if sys.platform == "linux" else 2 +_SYSTEM_CHROMIUM_COMMANDS = ( + "google-chrome-stable", + "google-chrome", + "chromium", + "chromium-browser", + "microsoft-edge-stable", +) _RenderResult = TypeVar("_RenderResult") @@ -89,6 +97,44 @@ def _resolve_render_browser_max_concurrency() -> int: return configured_limit +def _resolve_configured_browser_executable() -> str | None: + """读取显式配置的浏览器路径;配置错误时不静默回退。""" + try: + runtime_config = get_config(strict=False) + except Exception: + logger.debug("[渲染] 读取浏览器路径配置失败", exc_info=True) + return None + + configured = str( + getattr(runtime_config, "render_browser_executable_path", "") or "" + ).strip() + if not configured: + return None + + path = Path(configured).expanduser() + if not path.is_absolute(): + path = (Path.cwd() / path).resolve() + else: + path = path.resolve() + if not path.is_file(): + raise FileNotFoundError(f"配置的渲染浏览器不存在: {path}") + return str(path) + + +def _find_system_browser_executable() -> str | None: + """在 Playwright 自带 Chromium 缺失时查找已安装的系统浏览器。""" + for command in _SYSTEM_CHROMIUM_COMMANDS: + executable = shutil.which(command) + if executable: + return executable + return None + + +def _is_missing_playwright_browser(error: BaseException) -> bool: + text = str(error) + return "Executable doesn't exist" in text or "playwright install" in text + + async def _get_browser() -> Browser: """获取或创建浏览器实例(懒加载单例)""" global _playwright, _browser @@ -100,9 +146,32 @@ async def _get_browser() -> Browser: if _browser is not None: return _browser + configured_executable = _resolve_configured_browser_executable() playwright = await async_playwright().start() try: - browser = await playwright.chromium.launch(headless=True) + if configured_executable is not None: + browser = await playwright.chromium.launch( + headless=True, + executable_path=configured_executable, + ) + else: + try: + browser = await playwright.chromium.launch(headless=True) + except Exception as exc: + system_executable = _find_system_browser_executable() + if ( + not _is_missing_playwright_browser(exc) + or system_executable is None + ): + raise + logger.warning( + "[render] Playwright Chromium 未安装,回退到系统浏览器: %s", + system_executable, + ) + browser = await playwright.chromium.launch( + headless=True, + executable_path=system_executable, + ) except Exception: await playwright.stop() raise @@ -206,6 +275,8 @@ async def render_html_to_image( *, viewport_width: int = 1280, screenshot_selector: str | None = None, + screenshot_scale: Literal["css", "device"] = "device", + screenshot_style: str | None = None, timeout_ms: int = 60000, proxy: str | None = None, ) -> None: @@ -217,12 +288,19 @@ async def render_html_to_image( output_path: 输出图片路径 (例如 'result.png') viewport_width: 视口宽度(像素),默认 1280 screenshot_selector: 仅截图匹配的元素,默认截整页 + screenshot_scale: 输出像素尺度,device 按 DPR 输出,css 按 CSS 像素输出 + screenshot_style: 仅在截图期间注入的 CSS 样式 timeout_ms: 截图超时时间(毫秒),默认 60000 proxy: 可选浏览器代理地址 """ cache = await get_render_cache() cache_key = compute_render_cache_key( - html_content, viewport_width, screenshot_selector, proxy + html_content, + viewport_width, + screenshot_selector, + proxy, + screenshot_scale, + screenshot_style, ) if await cache.copy_to(cache_key, output_path): @@ -234,12 +312,16 @@ async def _capture(page: Page) -> None: if screenshot_selector: await page.locator(screenshot_selector).first.screenshot( path=output_path, + scale=screenshot_scale, + style=screenshot_style, timeout=timeout_ms, ) else: await page.screenshot( path=output_path, full_page=True, + scale=screenshot_scale, + style=screenshot_style, timeout=timeout_ms, ) diff --git a/src/Undefined/skills/toolsets/README.md b/src/Undefined/skills/toolsets/README.md index a17a830d..2be6982b 100644 --- a/src/Undefined/skills/toolsets/README.md +++ b/src/Undefined/skills/toolsets/README.md @@ -135,9 +135,9 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: ### Render(渲染) -- `render.render_html`: 将 HTML 渲染为图片 +- `render.render_html`: 将 HTML 渲染为普通图片或指定宽度的单张长图 - `render.render_latex`: 将 LaTeX 渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright -- `render.render_markdown`: 将 Markdown 渲染为图片 +- `render.render_markdown`: 将 Markdown 渲染为普通图片或指定宽度的单张长图 ### Memes(表情包) diff --git a/src/Undefined/skills/toolsets/render/README.md b/src/Undefined/skills/toolsets/render/README.md index 31652ff1..cad70f92 100644 --- a/src/Undefined/skills/toolsets/render/README.md +++ b/src/Undefined/skills/toolsets/render/README.md @@ -3,9 +3,12 @@ 渲染相关工具集合,工具名以 `render.*` 命名。 主要能力: -- HTML 渲染 +- HTML 渲染,保留完整 CSS、外部资源与脚本执行 - Markdown 渲染 - LaTeX 渲染 +- HTML/Markdown 可显式传 `layout=long`、`width`、`padding` 输出无两侧外部留白的单张长图 + +`layout=default` 保持原有布局。`layout=long` 时,`width` 是最终图片像素宽度,高度按内容自动延伸;`padding=0` 可用于 HTML 全幅设计。 目录结构: - 每个子目录对应一个工具(`config.json` + `handler.py`) diff --git a/src/Undefined/skills/toolsets/render/layout.py b/src/Undefined/skills/toolsets/render/layout.py new file mode 100644 index 00000000..76ed3cf2 --- /dev/null +++ b/src/Undefined/skills/toolsets/render/layout.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Mapping + +RenderContentKind = Literal["html", "markdown"] +RenderLayoutName = Literal["default", "long"] + +MIN_LONG_IMAGE_WIDTH = 320 +MAX_LONG_IMAGE_WIDTH = 2048 +MAX_LONG_IMAGE_PADDING = 160 +DEFAULT_LONG_IMAGE_WIDTH = 900 +DEFAULT_LONG_IMAGE_PADDING = 28 + + +@dataclass(frozen=True) +class RenderLayoutOptions: + """已校验的渲染布局选项。""" + + layout: RenderLayoutName + viewport_width: int | None = None + screenshot_scale: Literal["css", "device"] = "device" + screenshot_style: str | None = None + + def render_kwargs(self) -> dict[str, Any]: + """仅为长图返回额外参数,保持默认调用完全兼容。""" + if self.layout == "default": + return {} + return { + "viewport_width": self.viewport_width, + "screenshot_scale": self.screenshot_scale, + "screenshot_style": self.screenshot_style, + } + + +def _config_int(context: Mapping[str, Any], attribute: str, fallback: int) -> int: + runtime_config = context.get("runtime_config") + raw_value = getattr(runtime_config, attribute, fallback) + if isinstance(raw_value, bool): + return fallback + try: + return int(raw_value) + except (TypeError, ValueError): + return fallback + + +def _parse_explicit_int( + value: Any, + *, + name: str, + minimum: int, + maximum: int, +) -> tuple[int | None, str | None]: + if isinstance(value, bool) or not isinstance(value, int): + return None, f"{name} 必须是整数" + if value < minimum or value > maximum: + return None, f"{name} 必须在 {minimum}..{maximum} 之间" + return value, None + + +def _long_layout_style(content_kind: RenderContentKind, padding: int) -> str: + shared = """ +html, body { + margin: 0 !important; + width: 100% !important; + min-width: 0 !important; + max-width: 100% !important; + overflow-x: hidden !important; +} +img, video, canvas, svg { + max-width: 100% !important; +} +""" + if content_kind == "markdown": + return ( + shared + + f""" +body {{ + padding: 0 !important; +}} +.markdown-body {{ + box-sizing: border-box !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + margin: 0 !important; + padding: {padding}px !important; + overflow-wrap: anywhere !important; +}} +.markdown-body pre {{ + max-width: 100% !important; + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; +}} +""" + ) + return ( + shared + + f""" +body {{ + box-sizing: border-box !important; + padding: {padding}px !important; +}} +""" + ) + + +def resolve_render_layout( + args: Mapping[str, Any], + context: Mapping[str, Any], + *, + content_kind: RenderContentKind, +) -> tuple[RenderLayoutOptions | None, str | None]: + """解析并校验工具的 default/long 布局参数。""" + layout_raw = str(args.get("layout", "default") or "default").strip().lower() + if layout_raw not in {"default", "long"}: + return None, f"layout 无效:{layout_raw}。仅支持 default 或 long" + + has_width = args.get("width") is not None + has_padding = args.get("padding") is not None + if layout_raw == "default": + if has_width or has_padding: + return None, "width 和 padding 仅支持在 layout=long 时使用" + return RenderLayoutOptions(layout="default"), None + + if has_width: + width, error = _parse_explicit_int( + args.get("width"), + name="width", + minimum=MIN_LONG_IMAGE_WIDTH, + maximum=MAX_LONG_IMAGE_WIDTH, + ) + if error is not None or width is None: + return None, error + else: + width = _config_int( + context, + "render_long_image_default_width", + DEFAULT_LONG_IMAGE_WIDTH, + ) + width = min(MAX_LONG_IMAGE_WIDTH, max(MIN_LONG_IMAGE_WIDTH, width)) + + if has_padding: + padding, error = _parse_explicit_int( + args.get("padding"), + name="padding", + minimum=0, + maximum=MAX_LONG_IMAGE_PADDING, + ) + if error is not None or padding is None: + return None, error + else: + padding = _config_int( + context, + "render_long_image_default_padding", + DEFAULT_LONG_IMAGE_PADDING, + ) + padding = min(MAX_LONG_IMAGE_PADDING, max(0, padding)) + + if padding * 2 >= width: + return None, "padding 过大,必须满足 2 * padding < width" + + return ( + RenderLayoutOptions( + layout="long", + viewport_width=width, + screenshot_scale="css", + screenshot_style=_long_layout_style(content_kind, padding), + ), + None, + ) diff --git a/src/Undefined/skills/toolsets/render/render_html/config.json b/src/Undefined/skills/toolsets/render/render_html/config.json index 53a1b17b..d011844d 100644 --- a/src/Undefined/skills/toolsets/render/render_html/config.json +++ b/src/Undefined/skills/toolsets/render/render_html/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "render_html", - "description": "将 HTML 内容渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持完整的 HTML 文档,包括内联 CSS 和样式。", + "description": "将 HTML 内容渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持完整 HTML、CSS、外部资源和脚本;需要窄幅、高度随内容延伸且无两侧外部留白时,使用 layout=long。", "parameters": { "type": "object", "properties": { @@ -10,6 +10,24 @@ "type": "string", "description": "要渲染的 HTML 内容。必须是完整的 HTML 文档(包含 、、、 标签)。" }, + "layout": { + "type": "string", + "description": "版式:default 保持原始页面布局;long 输出单张长图,高度随内容自动延伸并去除 html/body 外边距", + "enum": ["default", "long"], + "default": "default" + }, + "width": { + "type": "integer", + "description": "长图最终像素宽度,仅 layout=long 时可用;不传则使用 [render].long_image_default_width", + "minimum": 320, + "maximum": 2048 + }, + "padding": { + "type": "integer", + "description": "长图 body 内边距(像素),仅 layout=long 时可用;传 0 可做全幅设计,不传则使用 [render].long_image_default_padding", + "minimum": 0, + "maximum": 160 + }, "delivery": { "type": "string", "description": "图片交付方式:embed 返回可插入回复的图片 UID;send 立即发送到目标", @@ -28,4 +46,4 @@ "required": ["html_content"] } } -} \ No newline at end of file +} diff --git a/src/Undefined/skills/toolsets/render/render_html/handler.py b/src/Undefined/skills/toolsets/render/render_html/handler.py index 24079aaf..1ceb02bc 100644 --- a/src/Undefined/skills/toolsets/render/render_html/handler.py +++ b/src/Undefined/skills/toolsets/render/render_html/handler.py @@ -5,6 +5,7 @@ import uuid from Undefined.attachments import scope_from_context +from Undefined.skills.toolsets.render.layout import resolve_render_layout logger = logging.getLogger(__name__) @@ -41,6 +42,14 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if delivery not in {"embed", "send"}: return f"delivery 无效:{delivery}。仅支持 embed 或 send" + layout_options, layout_error = resolve_render_layout( + args, + context, + content_kind="html", + ) + if layout_error is not None or layout_options is None: + return layout_error or "渲染布局参数无效" + if delivery == "send" and message_type and message_type not in ("group", "private"): return "消息类型必须是 group 或 private" @@ -55,7 +64,11 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if not render_html_to_image: return "错误:渲染函数 (render_html_to_image) 未在上下文中提供,请检查 AIClient 配置。" - await render_html_to_image(html_content, str(filepath)) + await render_html_to_image( + html_content, + str(filepath), + **layout_options.render_kwargs(), + ) # 注册到附件系统 attachment_registry = context.get("attachment_registry") diff --git a/src/Undefined/skills/toolsets/render/render_markdown/config.json b/src/Undefined/skills/toolsets/render/render_markdown/config.json index 9b768e7d..fff20e1c 100644 --- a/src/Undefined/skills/toolsets/render/render_markdown/config.json +++ b/src/Undefined/skills/toolsets/render/render_markdown/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "render_markdown", - "description": "将 Markdown 文本渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持标准 Markdown 格式,包括标题、列表、代码块、表格等。", + "description": "将 Markdown 文本渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持标题、列表、代码块、表格等;需要窄幅、高度随内容延伸且无两侧外部留白时,使用 layout=long。", "parameters": { "type": "object", "properties": { @@ -10,6 +10,24 @@ "type": "string", "description": "要渲染的 Markdown 内容。支持标准 Markdown 格式。" }, + "layout": { + "type": "string", + "description": "版式:default 保持原始居中宽版;long 输出单张长图,高度随内容自动延伸且正文铺满画布", + "enum": ["default", "long"], + "default": "default" + }, + "width": { + "type": "integer", + "description": "长图最终像素宽度,仅 layout=long 时可用;不传则使用 [render].long_image_default_width", + "minimum": 320, + "maximum": 2048 + }, + "padding": { + "type": "integer", + "description": "长图正文内边距(像素),仅 layout=long 时可用;不传则使用 [render].long_image_default_padding", + "minimum": 0, + "maximum": 160 + }, "delivery": { "type": "string", "description": "图片交付方式:embed 返回可插入回复的图片 UID;send 立即发送到目标", diff --git a/src/Undefined/skills/toolsets/render/render_markdown/handler.py b/src/Undefined/skills/toolsets/render/render_markdown/handler.py index f2040b13..33d063c4 100644 --- a/src/Undefined/skills/toolsets/render/render_markdown/handler.py +++ b/src/Undefined/skills/toolsets/render/render_markdown/handler.py @@ -5,6 +5,7 @@ import uuid from Undefined.attachments import scope_from_context +from Undefined.skills.toolsets.render.layout import resolve_render_layout logger = logging.getLogger(__name__) @@ -41,6 +42,14 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if delivery not in {"embed", "send"}: return f"delivery 无效:{delivery}。仅支持 embed 或 send" + layout_options, layout_error = resolve_render_layout( + args, + context, + content_kind="markdown", + ) + if layout_error is not None or layout_options is None: + return layout_error or "渲染布局参数无效" + if delivery == "send" and message_type and message_type not in ("group", "private"): return "消息类型必须是 group 或 private" @@ -59,7 +68,11 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: try: html_content = await render_markdown_to_html(content) - await render_html_to_image(html_content, str(filepath)) + await render_html_to_image( + html_content, + str(filepath), + **layout_options.render_kwargs(), + ) except Exception as e: logger.exception(f"Markdown 渲染失败: {e}") return "Markdown 渲染失败,请稍后重试" diff --git a/src/Undefined/utils/render_cache.py b/src/Undefined/utils/render_cache.py index 12d7ed47..ade2ca33 100644 --- a/src/Undefined/utils/render_cache.py +++ b/src/Undefined/utils/render_cache.py @@ -11,6 +11,7 @@ import asyncio import hashlib +import json import logging import shutil import time @@ -331,12 +332,21 @@ def compute_render_cache_key( viewport_width: int, screenshot_selector: str | None, proxy: str | None, + screenshot_scale: str = "device", + screenshot_style: str | None = None, ) -> str: - data = ( - html_content - + f"|{viewport_width}" - + f"|{str(screenshot_selector) if screenshot_selector is not None else ''}" - + f"|{str(proxy) if proxy is not None else ''}" + data = json.dumps( + { + "html_content": html_content, + "proxy": proxy, + "screenshot_scale": screenshot_scale, + "screenshot_selector": screenshot_selector, + "screenshot_style": screenshot_style, + "viewport_width": viewport_width, + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, ) return hashlib.sha256(data.encode()).hexdigest() diff --git a/tests/test_config_api.py b/tests/test_config_api.py index 284976ce..83e3f3fd 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -187,6 +187,9 @@ def test_api_tool_invoke_invalid_timeout_fallback(tmp_path: Path) -> None: def test_render_config_defaults_to_auto(tmp_path: Path) -> None: cfg = _load_config(tmp_path / "config.toml", "") assert cfg.render_browser_max_concurrency == 0 + assert cfg.render_browser_executable_path == "" + assert cfg.render_long_image_default_width == 900 + assert cfg.render_long_image_default_padding == 28 def test_render_config_accepts_custom_value(tmp_path: Path) -> None: @@ -195,9 +198,15 @@ def test_render_config_accepts_custom_value(tmp_path: Path) -> None: """ [render] browser_max_concurrency = 4 +browser_executable_path = "/opt/chrome/chrome" +long_image_default_width = 1080 +long_image_default_padding = 36 """, ) assert cfg.render_browser_max_concurrency == 4 + assert cfg.render_browser_executable_path == "/opt/chrome/chrome" + assert cfg.render_long_image_default_width == 1080 + assert cfg.render_long_image_default_padding == 36 def test_render_config_invalid_values_fallback_to_auto(tmp_path: Path) -> None: @@ -206,9 +215,13 @@ def test_render_config_invalid_values_fallback_to_auto(tmp_path: Path) -> None: """ [render] browser_max_concurrency = -3 +long_image_default_width = -1 +long_image_default_padding = 999 """, ) assert cfg.render_browser_max_concurrency == 0 + assert cfg.render_long_image_default_width == 320 + assert cfg.render_long_image_default_padding == 159 def test_prompt_system_info_defaults_to_disabled(tmp_path: Path) -> None: diff --git a/tests/test_config_env_only.py b/tests/test_config_env_only.py index 54492cdb..e815bc5c 100644 --- a/tests/test_config_env_only.py +++ b/tests/test_config_env_only.py @@ -75,3 +75,15 @@ def test_tool_search_env_fallback(monkeypatch: pytest.MonkeyPatch) -> None: "get_current_time", ] assert cfg.tool_search_max_results == 8 + + +def test_long_image_render_env_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("RENDER_BROWSER_EXECUTABLE_PATH", "/opt/chrome/chrome") + monkeypatch.setenv("RENDER_LONG_IMAGE_DEFAULT_WIDTH", "1080") + monkeypatch.setenv("RENDER_LONG_IMAGE_DEFAULT_PADDING", "36") + + cfg = Config.from_mapping({}, strict=False) + + assert cfg.render_browser_executable_path == "/opt/chrome/chrome" + assert cfg.render_long_image_default_width == 1080 + assert cfg.render_long_image_default_padding == 36 diff --git a/tests/test_config_env_registry.py b/tests/test_config_env_registry.py index fa76fcba..112f567d 100644 --- a/tests/test_config_env_registry.py +++ b/tests/test_config_env_registry.py @@ -55,6 +55,21 @@ def test_registry_has_tool_search_entries() -> None: ) +def test_registry_has_long_image_render_entries() -> None: + assert ( + ENV_REGISTRY[("render", "browser_executable_path")] + == "RENDER_BROWSER_EXECUTABLE_PATH" + ) + assert ( + ENV_REGISTRY[("render", "long_image_default_width")] + == "RENDER_LONG_IMAGE_DEFAULT_WIDTH" + ) + assert ( + ENV_REGISTRY[("render", "long_image_default_padding")] + == "RENDER_LONG_IMAGE_DEFAULT_PADDING" + ) + + def test_registry_uses_scoped_proxy_env_keys() -> None: assert "USE_PROXY" not in ENV_REGISTRY.values() assert ENV_REGISTRY[("search", "use_proxy")] == "SEARCH_USE_PROXY" diff --git a/tests/test_config_hot_reload.py b/tests/test_config_hot_reload.py index c62987de..8862befb 100644 --- a/tests/test_config_hot_reload.py +++ b/tests/test_config_hot_reload.py @@ -415,6 +415,34 @@ def test_apply_config_updates_hot_reloads_tool_search_config() -> None: assert ai_client.runtime_updates == [updated] +def test_apply_config_updates_hot_reloads_long_image_defaults() -> None: + updated = cast( + Any, + SimpleNamespace( + render_long_image_default_width=1080, + render_long_image_default_padding=36, + ), + ) + ai_client = _FakeAIClient() + context = HotReloadContext( + ai_client=cast(Any, ai_client), + queue_manager=cast(Any, _FakeQueueManager()), + config_manager=cast(Any, SimpleNamespace()), + security_service=cast(Any, _FakeSecurityService()), + ) + + apply_config_updates( + updated, + { + "render_long_image_default_width": (900, 1080), + "render_long_image_default_padding": (28, 36), + }, + context, + ) + + assert ai_client.runtime_updates == [updated] + + def test_apply_config_updates_hot_reloads_attachment_config() -> None: updated = cast( Any, diff --git a/tests/test_render.py b/tests/test_render.py index 30fc60f1..864c9d83 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import shutil from collections.abc import Iterator +from pathlib import Path from types import SimpleNamespace from typing import Any @@ -144,6 +146,125 @@ async def start(self) -> _FakePlaywright: assert render_module._browser is None +@pytest.mark.asyncio +async def test_get_browser_falls_back_to_installed_system_chrome( + monkeypatch: pytest.MonkeyPatch, +) -> None: + browser = object() + + class _FakeChromium: + def __init__(self) -> None: + self.executable_paths: list[str | None] = [] + + async def launch( + self, + *, + headless: bool, + executable_path: str | None = None, + ) -> Any: + assert headless is True + self.executable_paths.append(executable_path) + if executable_path is None: + raise RuntimeError( + "BrowserType.launch: Executable doesn't exist; playwright install" + ) + return browser + + class _FakePlaywright: + def __init__(self) -> None: + self.chromium = _FakeChromium() + self.stopped = False + + async def stop(self) -> None: + self.stopped = True + + class _FakePlaywrightFactory: + def __init__(self, playwright: _FakePlaywright) -> None: + self.playwright = playwright + + async def start(self) -> _FakePlaywright: + return self.playwright + + playwright = _FakePlaywright() + monkeypatch.setattr( + render_module, + "get_config", + lambda strict=False: SimpleNamespace(render_browser_executable_path=""), + ) + monkeypatch.setattr( + render_module, + "async_playwright", + lambda: _FakePlaywrightFactory(playwright), + ) + monkeypatch.setattr( + shutil, + "which", + lambda command: ( + "/usr/bin/google-chrome-stable" + if command == "google-chrome-stable" + else None + ), + ) + + result = await render_module._get_browser() + + assert result is browser + assert playwright.chromium.executable_paths == [ + None, + "/usr/bin/google-chrome-stable", + ] + assert playwright.stopped is False + + +@pytest.mark.asyncio +async def test_get_browser_uses_configured_executable( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + executable = tmp_path / "chrome" + executable.write_bytes(b"binary") + browser = object() + + class _FakeChromium: + async def launch( + self, + *, + headless: bool, + executable_path: str, + ) -> Any: + assert headless is True + assert executable_path == str(executable) + return browser + + class _FakePlaywright: + def __init__(self) -> None: + self.chromium = _FakeChromium() + + async def stop(self) -> None: + return None + + class _FakePlaywrightFactory: + async def start(self) -> _FakePlaywright: + return _FakePlaywright() + + monkeypatch.setattr( + render_module, + "get_config", + lambda strict=False: SimpleNamespace( + render_browser_executable_path=str(executable) + ), + ) + monkeypatch.setattr( + render_module, + "async_playwright", + lambda: _FakePlaywrightFactory(), + ) + + result = await render_module._get_browser() + + assert result is browser + + @pytest.mark.asyncio async def test_render_html_with_page_closes_context_when_new_page_fails( monkeypatch: pytest.MonkeyPatch, @@ -252,3 +373,112 @@ async def _callback(_page: Any) -> str: assert result == "ok" assert render_module._render_active_count == 0 + + +@pytest.mark.asyncio +async def test_render_html_to_image_passes_long_screenshot_options( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + screenshot_kwargs: dict[str, Any] = {} + render_page_kwargs: dict[str, Any] = {} + + class _FakeCache: + async def copy_to(self, _key: str, _dest: str) -> bool: + return False + + async def put(self, _key: str, _path: str, _size: int) -> None: + raise AssertionError("empty fake output should not be cached") + + class _FakePage: + async def wait_for_load_state(self, _state: str, *, timeout: int) -> None: + assert timeout == 1234 + + async def screenshot(self, **kwargs: Any) -> None: + screenshot_kwargs.update(kwargs) + + async def _fake_get_render_cache() -> _FakeCache: + return _FakeCache() + + async def _fake_render_html_with_page( + html_content: str, + callback: Any, + **kwargs: Any, + ) -> None: + assert html_content == "" + render_page_kwargs.update(kwargs) + await callback(_FakePage()) + + async def _no_sleep(_delay: float) -> None: + return None + + monkeypatch.setattr(render_module, "get_render_cache", _fake_get_render_cache) + monkeypatch.setattr( + render_module, "render_html_with_page", _fake_render_html_with_page + ) + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + + style = "html, body { margin: 0 !important; }" + await render_module.render_html_to_image( + "", + str(tmp_path / "long.png"), + viewport_width=900, + screenshot_scale="css", + screenshot_style=style, + timeout_ms=1234, + ) + + assert render_page_kwargs["viewport_width"] == 900 + assert screenshot_kwargs == { + "path": str(tmp_path / "long.png"), + "full_page": True, + "scale": "css", + "style": style, + "timeout": 1234, + } + + +@pytest.mark.asyncio +async def test_render_html_with_page_keeps_html_and_javascript_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context_kwargs: dict[str, Any] = {} + html_seen = "" + + class _FakePage: + def set_default_timeout(self, _timeout_ms: int) -> None: + return None + + async def set_content(self, html_content: str) -> None: + nonlocal html_seen + html_seen = html_content + + class _FakeContext: + async def new_page(self) -> _FakePage: + return _FakePage() + + async def close(self) -> None: + return None + + class _FakeBrowser: + async def new_context(self, **kwargs: Any) -> _FakeContext: + context_kwargs.update(kwargs) + return _FakeContext() + + async def _fake_get_browser() -> _FakeBrowser: + return _FakeBrowser() + + async def _fake_get_semaphore() -> asyncio.Semaphore: + return asyncio.Semaphore(1) + + async def _callback(_page: Any) -> None: + return None + + monkeypatch.setattr(render_module, "_get_browser", _fake_get_browser) + monkeypatch.setattr(render_module, "_get_semaphore", _fake_get_semaphore) + + html = '' + await render_module.render_html_with_page(html, _callback) + + assert html_seen == html + assert "java_script_enabled" not in context_kwargs diff --git a/tests/test_render_cache.py b/tests/test_render_cache.py index 7ec6b8a0..b65c9db0 100644 --- a/tests/test_render_cache.py +++ b/tests/test_render_cache.py @@ -211,7 +211,19 @@ def test_compute_render_cache_key_is_deterministic_and_distinct() -> None: a_again = compute_render_cache_key("

x

", 1280, None, None) b = compute_render_cache_key("

y

", 1280, None, None) c = compute_render_cache_key("

x

", 1024, None, None) + css_scale = compute_render_cache_key( + "

x

", 1280, None, None, screenshot_scale="css" + ) + styled = compute_render_cache_key( + "

x

", + 1280, + None, + None, + screenshot_style="body { margin: 0; }", + ) assert a == a_again assert a != b assert a != c + assert a != css_scale + assert a != styled diff --git a/tests/test_render_layout_tools.py b/tests/test_render_layout_tools.py new file mode 100644 index 00000000..82213567 --- /dev/null +++ b/tests/test_render_layout_tools.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from Undefined.skills.toolsets.render.layout import resolve_render_layout +from Undefined.skills.toolsets.render.render_html.handler import execute as render_html +from Undefined.skills.toolsets.render.render_markdown.handler import ( + execute as render_markdown, +) + + +class _FakeAttachmentRegistry: + async def register_local_file( + self, + scope_key: str, + local_path: str | Path, + **kwargs: Any, + ) -> Any: + assert scope_key == "private:42" + assert Path(local_path).is_file() + assert kwargs["kind"] == "image" + return SimpleNamespace(uid="pic_long_image") + + +def _render_context(**overrides: Any) -> dict[str, Any]: + context: dict[str, Any] = { + "attachment_registry": _FakeAttachmentRegistry(), + "request_type": "private", + "runtime_config": SimpleNamespace( + render_long_image_default_width=900, + render_long_image_default_padding=28, + ), + "user_id": 42, + } + context.update(overrides) + return context + + +def test_resolve_default_layout_rejects_long_only_parameters() -> None: + options, error = resolve_render_layout( + {"layout": "default", "width": 900}, + {}, + content_kind="html", + ) + + assert options is None + assert error == "width 和 padding 仅支持在 layout=long 时使用" + + +def test_resolve_long_layout_validates_padding_against_width() -> None: + options, error = resolve_render_layout( + {"layout": "long", "width": 320, "padding": 160}, + {}, + content_kind="markdown", + ) + + assert options is None + assert error == "padding 过大,必须满足 2 * padding < width" + + +@pytest.mark.asyncio +async def test_render_html_long_layout_uses_explicit_final_width( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from Undefined.utils import paths + + calls: list[tuple[str, dict[str, Any]]] = [] + + async def _fake_render( + html_content: str, + output_path: str, + **kwargs: Any, + ) -> None: + calls.append((html_content, kwargs)) + Path(output_path).write_bytes(b"png") + + monkeypatch.setattr(paths, "RENDER_CACHE_DIR", tmp_path) + html = """""" + result = await render_html( + { + "html_content": html, + "layout": "long", + "width": 1080, + "padding": 0, + }, + _render_context(render_html_to_image=_fake_render), + ) + + assert result == '' + assert calls[0][0] == html + assert calls[0][1]["viewport_width"] == 1080 + assert calls[0][1]["screenshot_scale"] == "css" + style = str(calls[0][1]["screenshot_style"]) + assert "margin: 0 !important" in style + assert "padding: 0px !important" in style + assert "script" not in style + + +@pytest.mark.asyncio +async def test_render_markdown_long_layout_uses_configured_defaults( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from Undefined.utils import paths + + calls: list[dict[str, Any]] = [] + markdown_seen = "" + + async def _fake_markdown(content: str) -> str: + nonlocal markdown_seen + markdown_seen = content + return '

Title

' + + async def _fake_render( + _html_content: str, + output_path: str, + **kwargs: Any, + ) -> None: + calls.append(kwargs) + Path(output_path).write_bytes(b"png") + + monkeypatch.setattr(paths, "RENDER_CACHE_DIR", tmp_path) + context = _render_context( + render_html_to_image=_fake_render, + render_markdown_to_html=_fake_markdown, + runtime_config=SimpleNamespace( + render_long_image_default_width=960, + render_long_image_default_padding=32, + ), + ) + result = await render_markdown( + {"content": "# Title", "layout": "long"}, + context, + ) + + assert result == '' + assert markdown_seen == "# Title" + assert calls[0]["viewport_width"] == 960 + assert calls[0]["screenshot_scale"] == "css" + style = str(calls[0]["screenshot_style"]) + assert "max-width: none !important" in style + assert "padding: 32px !important" in style + + +@pytest.mark.asyncio +async def test_render_markdown_default_layout_preserves_original_call( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from Undefined.utils import paths + + calls: list[dict[str, Any]] = [] + + async def _fake_markdown(_content: str) -> str: + return "default" + + async def _fake_render( + _html_content: str, + output_path: str, + **kwargs: Any, + ) -> None: + calls.append(kwargs) + Path(output_path).write_bytes(b"png") + + monkeypatch.setattr(paths, "RENDER_CACHE_DIR", tmp_path) + result = await render_markdown( + {"content": "default"}, + _render_context( + render_html_to_image=_fake_render, + render_markdown_to_html=_fake_markdown, + ), + ) + + assert result == '' + assert calls == [{}] + + +@pytest.mark.parametrize("tool_name", ["render_html", "render_markdown"]) +def test_render_tool_schema_exposes_long_layout(tool_name: str) -> None: + config_path = ( + Path("src/Undefined/skills/toolsets/render") / tool_name / "config.json" + ) + schema = json.loads(config_path.read_text(encoding="utf-8")) + properties = schema["function"]["parameters"]["properties"] + + assert properties["layout"]["enum"] == ["default", "long"] + assert properties["width"]["minimum"] == 320 + assert properties["width"]["maximum"] == 2048 + assert properties["padding"]["minimum"] == 0 + assert properties["padding"]["maximum"] == 160 From c711e6d1d3f7b371b22419d591ebd508b85999f6 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 13 Jul 2026 12:35:18 +0800 Subject: [PATCH 2/5] chore(version): bump version to 3.8.1 Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 11 +++++++++++ apps/undefined-chat/package-lock.json | 4 ++-- apps/undefined-chat/package.json | 2 +- apps/undefined-chat/src-tauri/Cargo.lock | 2 +- apps/undefined-chat/src-tauri/Cargo.toml | 2 +- apps/undefined-chat/src-tauri/tauri.conf.json | 2 +- apps/undefined-console/package-lock.json | 4 ++-- apps/undefined-console/package.json | 2 +- apps/undefined-console/src-tauri/Cargo.lock | 2 +- apps/undefined-console/src-tauri/Cargo.toml | 2 +- apps/undefined-console/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 2 +- src/Undefined/__init__.py | 2 +- uv.lock | 2 +- 14 files changed, 26 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ac1137..ea27386d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## v3.8.1 可配置长图渲染与浏览器运行时回退 + +本版本为 AI 渲染工具新增适合长内容发送的长图布局,可精确控制成图宽度与内边距;同时完善 Playwright 浏览器选择,在缺少内置浏览器时可使用已配置或系统安装的 Chrome / Chromium 完成渲染。 + +- 扩展 `render.render_html` 与 `render.render_markdown`。新增 `layout=long`、`width` 和 `padding` 参数;长图默认宽度为 900 像素、内边距为 28 像素,并可通过 `[network]` 配置统一调整。默认布局保持原有行为,避免影响现有调用。 +- 优化长图版式与输出尺寸。长图模式移除页面外部留白和 Markdown 内容最大宽度限制,使正文填满指定画布;截图使用 CSS 像素缩放,最终图片宽度与请求的 `width` 一致,便于聊天平台直接预览和发送。 +- 完善渲染浏览器选择。新增 `render_browser_executable_path` 配置;Playwright 内置浏览器缺失时自动探测系统 Chrome / Chromium,显式配置路径无效或其他启动错误仍会直接报告,避免掩盖真实故障。 +- 加固渲染缓存与配置集成。缓存键纳入截图缩放和样式参数,避免不同布局错误复用缓存;同步环境变量、热更新边界、配置模板、部署与使用文档,并补充长图参数、缓存隔离、浏览器回退和实际渲染回归测试。 + +--- + ## v3.8.0 多协议 LLM SDK、推理回放与 WebUI 可用性 本版本重构生成模型请求层,统一 OpenAI Chat Completions、OpenAI Responses 与 Anthropic Messages 的 SDK 调用和配置语义,补全多轮工具调用中的原生推理载体回放,并修复 WebUI 配置编辑器与多行日志查询的可用性问题。 diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index 5b419a85..fba838fb 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.8.0", + "version": "3.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.8.0", + "version": "3.8.1", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json index a805e77f..6f9cc7c3 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,7 +1,7 @@ { "name": "undefined-chat", "private": true, - "version": "3.8.0", + "version": "3.8.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index 4e95240f..a3b74eb2 100644 --- a/apps/undefined-chat/src-tauri/Cargo.lock +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "undefined_chat" -version = "3.8.0" +version = "3.8.1" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index f14e6d3c..4e20edd0 100644 --- a/apps/undefined-chat/src-tauri/Cargo.toml +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_chat" -version = "3.8.0" +version = "3.8.1" description = "Undefined native chat client" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json index 18dd5a7e..f8551c11 100644 --- a/apps/undefined-chat/src-tauri/tauri.conf.json +++ b/apps/undefined-chat/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Chat", - "version": "3.8.0", + "version": "3.8.1", "identifier": "com.undefined.chat", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 3c811748..18180c9b 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.8.0", + "version": "3.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.8.0", + "version": "3.8.1", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 4c28cea7..4d9f2cf2 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.8.0", + "version": "3.8.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index 85cb4d2b..2f7fde3f 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.8.0" +version = "3.8.1" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 3ab3e356..90bd22f5 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.8.0" +version = "3.8.1" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index cefb570a..0f4cf714 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.8.0", + "version": "3.8.1", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/pyproject.toml b/pyproject.toml index 67f97b3a..1884c3ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.8.0" +version = "3.8.1" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 316072e5..588882f5 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__: str = "3.8.0" +__version__: str = "3.8.1" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/uv.lock b/uv.lock index cacbbec3..d81f5f4f 100644 --- a/uv.lock +++ b/uv.lock @@ -4645,7 +4645,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.8.0" +version = "3.8.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 562409f0b93009f91efa09ed453638a596a80812 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 13 Jul 2026 18:19:13 +0800 Subject: [PATCH 3/5] fix(render): harden browser discovery and network access Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 1 + docs/configuration.md | 2 +- docs/usage.md | 2 +- src/Undefined/config/load_sections/network.py | 6 +- src/Undefined/render.py | 74 +++++++++++++++---- .../skills/toolsets/render/README.md | 2 +- src/Undefined/utils/io.py | 14 ++++ tests/test_render.py | 55 +++++++++++++- tests/test_render_layout_tools.py | 16 ++-- 9 files changed, 141 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea27386d..0802046c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 扩展 `render.render_html` 与 `render.render_markdown`。新增 `layout=long`、`width` 和 `padding` 参数;长图默认宽度为 900 像素、内边距为 28 像素,并可通过 `[network]` 配置统一调整。默认布局保持原有行为,避免影响现有调用。 - 优化长图版式与输出尺寸。长图模式移除页面外部留白和 Markdown 内容最大宽度限制,使正文填满指定画布;截图使用 CSS 像素缩放,最终图片宽度与请求的 `width` 一致,便于聊天平台直接预览和发送。 - 完善渲染浏览器选择。新增 `render_browser_executable_path` 配置;Playwright 内置浏览器缺失时自动探测系统 Chrome / Chromium,显式配置路径无效或其他启动错误仍会直接报告,避免掩盖真实故障。 +- 收紧 HTML 渲染网络边界。渲染页面加载资源前会阻断本机、私网、保留地址和本地文件访问,并禁用 Service Worker 绕过;公共 CDN、外部样式与脚本仍可正常使用。 - 加固渲染缓存与配置集成。缓存键纳入截图缩放和样式参数,避免不同布局错误复用缓存;同步环境变量、热更新边界、配置模板、部署与使用文档,并补充长图参数、缓存隔离、浏览器回退和实际渲染回归测试。 --- diff --git a/docs/configuration.md b/docs/configuration.md index 49003871..09b66313 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -768,7 +768,7 @@ Prompt caching 补充: - 显式修改 `browser_executable_path` 后需重启 Bot;仅当 Playwright 报告自带浏览器缺失时才会自动回退到系统浏览器,其他启动错误仍会原样报出。 - 配置变更会对后续新的渲染请求生效;已在执行中的渲染任务不受影响。 - `render.render_html` 和 `render.render_markdown` 默认使用 `layout=default`,视觉效果与旧版一致。显式传 `layout=long` 时,高度按内容自动延伸,使用 CSS 像素截图保证 `width` 对应最终图片宽度,并去掉两侧外部留白。 -- `width` 可选范围为 `320..2048`,`padding` 可选范围为 `0..160`;两者只能与 `layout=long` 一起使用。HTML 长图仍支持完整 CSS、外部资源与脚本;`padding=0` 可用于全幅设计。 +- `width` 可选范围为 `320..2048`,`padding` 可选范围为 `0..160`;两者只能与 `layout=long` 一起使用。HTML 长图仍支持完整 CSS、公共网络资源与脚本,但渲染页面默认阻断本机、私网和本地文件访问;`padding=0` 可用于全幅设计。 #### `[render.cache]` HTML 渲染结果缓存 diff --git a/docs/usage.md b/docs/usage.md index 13fd06d6..d46b585a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -155,7 +155,7 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 |---|---| | `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为普通图片或单张长图 | | `render.render_latex` | 将 LaTeX 数学公式渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright(详见[部署文档](deployment.md#3-安装渲染运行时)) | -| `render.render_html` | 将完整 HTML/CSS/脚本渲染为普通图片或单张长图 | +| `render.render_html` | 将完整 HTML/CSS/脚本渲染为普通图片或单张长图;默认阻断本机、私网和本地文件访问 | 支持 `embed`(嵌入回复)和 `send`(直接发送)两种图片交付方式。 diff --git a/src/Undefined/config/load_sections/network.py b/src/Undefined/config/load_sections/network.py index 0c786897..39a7af3b 100644 --- a/src/Undefined/config/load_sections/network.py +++ b/src/Undefined/config/load_sections/network.py @@ -23,9 +23,9 @@ logger = logging.getLogger(__name__) -_LONG_IMAGE_MIN_WIDTH = 320 -_LONG_IMAGE_MAX_WIDTH = 2048 -_LONG_IMAGE_MAX_PADDING = 160 +_LONG_IMAGE_MIN_WIDTH: int = 320 +_LONG_IMAGE_MAX_WIDTH: int = 2048 +_LONG_IMAGE_MAX_PADDING: int = 160 def load_network( diff --git a/src/Undefined/render.py b/src/Undefined/render.py index a480fda9..473574b3 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -1,18 +1,20 @@ """HTML 渲染模块:将 HTML/Markdown 渲染为图片""" import asyncio +import ipaddress import logging -import shutil import sys from collections.abc import Awaitable, Callable from pathlib import Path -from playwright.async_api import async_playwright, Browser, Page, Playwright +from typing import Any, Literal, TypeVar +from urllib.parse import urlsplit import markdown +from playwright.async_api import Browser, Page, Playwright, Route, async_playwright -from typing import Any, Literal, TypeVar from Undefined.config import get_config +from Undefined.utils.io import find_executable, is_file, resolve_path from Undefined.utils.render_cache import compute_render_cache_key, get_render_cache logger = logging.getLogger(__name__) @@ -67,6 +69,16 @@ "chromium-browser", "microsoft-edge-stable", ) +_NETWORK_SCHEMES: frozenset[str] = frozenset({"http", "https", "ws", "wss"}) +_LOCAL_RESOURCE_SCHEMES: frozenset[str] = frozenset({"about", "blob", "data"}) +_PRIVATE_HOST_SUFFIXES: tuple[str, ...] = ( + ".home.arpa", + ".internal", + ".lan", + ".local", + ".localdomain", + ".localhost", +) _RenderResult = TypeVar("_RenderResult") @@ -97,7 +109,7 @@ def _resolve_render_browser_max_concurrency() -> int: return configured_limit -def _resolve_configured_browser_executable() -> str | None: +async def _resolve_configured_browser_executable() -> str | None: """读取显式配置的浏览器路径;配置错误时不静默回退。""" try: runtime_config = get_config(strict=False) @@ -111,20 +123,16 @@ def _resolve_configured_browser_executable() -> str | None: if not configured: return None - path = Path(configured).expanduser() - if not path.is_absolute(): - path = (Path.cwd() / path).resolve() - else: - path = path.resolve() - if not path.is_file(): + path = await resolve_path(configured) + if not await is_file(path): raise FileNotFoundError(f"配置的渲染浏览器不存在: {path}") return str(path) -def _find_system_browser_executable() -> str | None: +async def _find_system_browser_executable() -> str | None: """在 Playwright 自带 Chromium 缺失时查找已安装的系统浏览器。""" for command in _SYSTEM_CHROMIUM_COMMANDS: - executable = shutil.which(command) + executable = await find_executable(command) if executable: return executable return None @@ -146,7 +154,7 @@ async def _get_browser() -> Browser: if _browser is not None: return _browser - configured_executable = _resolve_configured_browser_executable() + configured_executable = await _resolve_configured_browser_executable() playwright = await async_playwright().start() try: if configured_executable is not None: @@ -158,7 +166,7 @@ async def _get_browser() -> Browser: try: browser = await playwright.chromium.launch(headless=True) except Exception as exc: - system_executable = _find_system_browser_executable() + system_executable = await _find_system_browser_executable() if ( not _is_missing_playwright_browser(exc) or system_executable is None @@ -358,11 +366,13 @@ async def render_html_with_page( try: context_kwargs: dict[str, Any] = { "device_scale_factor": 2, + "service_workers": "block", "viewport": {"width": viewport_width, "height": 800}, } if proxy: context_kwargs["proxy"] = {"server": proxy} context = await browser.new_context(**context_kwargs) + await context.route("**/*", _guard_render_request) page = await context.new_page() page.set_default_timeout(timeout_ms) await page.set_content(html_content) @@ -373,3 +383,39 @@ async def render_html_with_page( await context.close() finally: _render_active_count = max(0, _render_active_count - 1) + + +def _is_restricted_render_url(url: str) -> bool: + """判断页面资源是否会访问本机、私网或非 Web 资源。""" + try: + parsed = urlsplit(url) + scheme = parsed.scheme.lower() + hostname = parsed.hostname + except ValueError: + return True + + if scheme in _LOCAL_RESOURCE_SCHEMES: + return False + if scheme not in _NETWORK_SCHEMES or hostname is None: + return True + + normalized_host = hostname.rstrip(".").lower() + if not normalized_host: + return True + + try: + address = ipaddress.ip_address(normalized_host) + except ValueError: + return "." not in normalized_host or normalized_host.endswith( + _PRIVATE_HOST_SUFFIXES + ) + else: + return not address.is_global + + +async def _guard_render_request(route: Route) -> None: + """阻断 HTML 渲染页面对本机和私网的网络访问。""" + if _is_restricted_render_url(route.request.url): + await route.abort() + return + await route.continue_() diff --git a/src/Undefined/skills/toolsets/render/README.md b/src/Undefined/skills/toolsets/render/README.md index cad70f92..a40ce6e5 100644 --- a/src/Undefined/skills/toolsets/render/README.md +++ b/src/Undefined/skills/toolsets/render/README.md @@ -3,7 +3,7 @@ 渲染相关工具集合,工具名以 `render.*` 命名。 主要能力: -- HTML 渲染,保留完整 CSS、外部资源与脚本执行 +- HTML 渲染,保留完整 CSS、公共网络资源与脚本执行,并默认阻断本机、私网和本地文件访问 - Markdown 渲染 - LaTeX 渲染 - HTML/Markdown 可显式传 `layout=long`、`width`、`padding` 输出无两侧外部留白的单张长图 diff --git a/src/Undefined/utils/io.py b/src/Undefined/utils/io.py index 8a8b53ce..a66ab3ba 100644 --- a/src/Undefined/utils/io.py +++ b/src/Undefined/utils/io.py @@ -181,6 +181,20 @@ async def is_file(file_path: Path | str) -> bool: return await asyncio.to_thread(Path(file_path).is_file) +async def resolve_path(file_path: Path | str) -> Path: + """在线程中展开用户目录并解析绝对路径。""" + + def sync_resolve() -> Path: + return Path(file_path).expanduser().resolve() + + return await asyncio.to_thread(sync_resolve) + + +async def find_executable(command: str) -> str | None: + """在线程中查找 PATH 中的可执行文件。""" + return await asyncio.to_thread(shutil.which, command) + + async def get_file_size(file_path: Path | str) -> int: """异步读取文件大小(字节)。""" return await asyncio.to_thread(lambda: Path(file_path).stat().st_size) diff --git a/tests/test_render.py b/tests/test_render.py index 864c9d83..2da23895 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -10,6 +10,7 @@ import pytest import Undefined.render as render_module +from Undefined.utils.io import write_bytes def _reset_render_state() -> None: @@ -222,7 +223,7 @@ async def test_get_browser_uses_configured_executable( tmp_path: Path, ) -> None: executable = tmp_path / "chrome" - executable.write_bytes(b"binary") + await write_bytes(executable, b"binary") browser = object() class _FakeChromium: @@ -273,6 +274,9 @@ class _FailingContext: def __init__(self) -> None: self.closed = False + async def route(self, pattern: str, _handler: Any) -> None: + assert pattern == "**/*" + async def new_page(self) -> Any: raise RuntimeError("new page failed") @@ -345,6 +349,9 @@ async def set_content(self, _html_content: str) -> None: pass class _FakeContext: + async def route(self, pattern: str, _handler: Any) -> None: + assert pattern == "**/*" + async def new_page(self) -> _FakePage: return _FakePage() @@ -439,11 +446,29 @@ async def _no_sleep(_delay: float) -> None: @pytest.mark.asyncio -async def test_render_html_with_page_keeps_html_and_javascript_enabled( +async def test_render_html_with_page_blocks_private_network_by_default( monkeypatch: pytest.MonkeyPatch, ) -> None: context_kwargs: dict[str, Any] = {} html_seen = "" + route_pattern = "" + route_handler: Any = None + + class _FakeRequest: + def __init__(self, url: str) -> None: + self.url = url + + class _FakeRoute: + def __init__(self, url: str) -> None: + self.request = _FakeRequest(url) + self.aborted = False + self.continued = False + + async def abort(self) -> None: + self.aborted = True + + async def continue_(self) -> None: + self.continued = True class _FakePage: def set_default_timeout(self, _timeout_ms: int) -> None: @@ -454,6 +479,11 @@ async def set_content(self, html_content: str) -> None: html_seen = html_content class _FakeContext: + async def route(self, pattern: str, handler: Any) -> None: + nonlocal route_handler, route_pattern + route_pattern = pattern + route_handler = handler + async def new_page(self) -> _FakePage: return _FakePage() @@ -472,7 +502,23 @@ async def _fake_get_semaphore() -> asyncio.Semaphore: return asyncio.Semaphore(1) async def _callback(_page: Any) -> None: - return None + assert route_handler is not None + private_routes = [ + _FakeRoute("http://127.0.0.1:8080/private"), + _FakeRoute("http://192.168.1.10/private"), + _FakeRoute("http://metadata.google.internal/computeMetadata/v1/"), + _FakeRoute("file:///etc/passwd"), + ] + public_route = _FakeRoute("https://example.com/app.js") + + for route in private_routes: + await route_handler(route) + assert route.aborted is True + assert route.continued is False + + await route_handler(public_route) + assert public_route.aborted is False + assert public_route.continued is True monkeypatch.setattr(render_module, "_get_browser", _fake_get_browser) monkeypatch.setattr(render_module, "_get_semaphore", _fake_get_semaphore) @@ -481,4 +527,5 @@ async def _callback(_page: Any) -> None: await render_module.render_html_with_page(html, _callback) assert html_seen == html - assert "java_script_enabled" not in context_kwargs + assert route_pattern == "**/*" + assert context_kwargs["service_workers"] == "block" diff --git a/tests/test_render_layout_tools.py b/tests/test_render_layout_tools.py index 82213567..90ba4a4c 100644 --- a/tests/test_render_layout_tools.py +++ b/tests/test_render_layout_tools.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from pathlib import Path from types import SimpleNamespace from typing import Any @@ -12,6 +11,7 @@ from Undefined.skills.toolsets.render.render_markdown.handler import ( execute as render_markdown, ) +from Undefined.utils.io import is_file, read_json, write_bytes class _FakeAttachmentRegistry: @@ -22,7 +22,7 @@ async def register_local_file( **kwargs: Any, ) -> Any: assert scope_key == "private:42" - assert Path(local_path).is_file() + assert await is_file(local_path) assert kwargs["kind"] == "image" return SimpleNamespace(uid="pic_long_image") @@ -78,7 +78,7 @@ async def _fake_render( **kwargs: Any, ) -> None: calls.append((html_content, kwargs)) - Path(output_path).write_bytes(b"png") + await write_bytes(output_path, b"png") monkeypatch.setattr(paths, "RENDER_CACHE_DIR", tmp_path) html = """""" @@ -123,7 +123,7 @@ async def _fake_render( **kwargs: Any, ) -> None: calls.append(kwargs) - Path(output_path).write_bytes(b"png") + await write_bytes(output_path, b"png") monkeypatch.setattr(paths, "RENDER_CACHE_DIR", tmp_path) context = _render_context( @@ -166,7 +166,7 @@ async def _fake_render( **kwargs: Any, ) -> None: calls.append(kwargs) - Path(output_path).write_bytes(b"png") + await write_bytes(output_path, b"png") monkeypatch.setattr(paths, "RENDER_CACHE_DIR", tmp_path) result = await render_markdown( @@ -182,11 +182,13 @@ async def _fake_render( @pytest.mark.parametrize("tool_name", ["render_html", "render_markdown"]) -def test_render_tool_schema_exposes_long_layout(tool_name: str) -> None: +@pytest.mark.asyncio +async def test_render_tool_schema_exposes_long_layout(tool_name: str) -> None: config_path = ( Path("src/Undefined/skills/toolsets/render") / tool_name / "config.json" ) - schema = json.loads(config_path.read_text(encoding="utf-8")) + schema = await read_json(config_path) + assert isinstance(schema, dict) properties = schema["function"]["parameters"]["properties"] assert properties["layout"]["enum"] == ["default", "long"] From 31ef9737771ceab09f2205d92e7360d2575c8751 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 13 Jul 2026 19:28:05 +0800 Subject: [PATCH 4/5] fix(render): disable browser network access Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 2 +- config.toml.example | 4 +- docs/build.md | 8 +- docs/configuration.md | 6 +- docs/deployment.md | 8 +- docs/usage.md | 4 +- src/Undefined/render.py | 56 +------- .../skills/toolsets/render/README.md | 2 +- .../toolsets/render/render_html/config.json | 2 +- .../toolsets/render/render_latex/config.json | 4 +- .../toolsets/render/render_latex/handler.py | 125 ++---------------- tests/test_render.py | 28 +--- tests/test_render_latex_tool.py | 77 ++++------- 13 files changed, 67 insertions(+), 259 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0802046c..9f0b1f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - 扩展 `render.render_html` 与 `render.render_markdown`。新增 `layout=long`、`width` 和 `padding` 参数;长图默认宽度为 900 像素、内边距为 28 像素,并可通过 `[network]` 配置统一调整。默认布局保持原有行为,避免影响现有调用。 - 优化长图版式与输出尺寸。长图模式移除页面外部留白和 Markdown 内容最大宽度限制,使正文填满指定画布;截图使用 CSS 像素缩放,最终图片宽度与请求的 `width` 一致,便于聊天平台直接预览和发送。 - 完善渲染浏览器选择。新增 `render_browser_executable_path` 配置;Playwright 内置浏览器缺失时自动探测系统 Chrome / Chromium,显式配置路径无效或其他启动错误仍会直接报告,避免掩盖真实故障。 -- 收紧 HTML 渲染网络边界。渲染页面加载资源前会阻断本机、私网、保留地址和本地文件访问,并禁用 Service Worker 绕过;公共 CDN、外部样式与脚本仍可正常使用。 +- 收紧 HTML 渲染网络边界。BrowserContext 强制离线、禁用 Service Worker,并在上下文级终止全部网络请求,不再依赖可被 DNS 重绑定绕过的主机名预检;内联 CSS、脚本及 `data:` / `blob:` 资源仍可使用。LaTeX 同步收敛为本地 mathtext 渲染,复杂且不受支持的 TeX 不再等待外部 MathJax CDN。 - 加固渲染缓存与配置集成。缓存键纳入截图缩放和样式参数,避免不同布局错误复用缓存;同步环境变量、热更新边界、配置模板、部署与使用文档,并补充长图参数、缓存隔离、浏览器回退和实际渲染回归测试。 --- diff --git a/config.toml.example b/config.toml.example index 3904769a..0dfdb392 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1095,8 +1095,8 @@ request_retries = 0 # zh: HTML/Markdown 图片渲染配置。 # en: HTML/Markdown image rendering settings. [render] -# zh: 是否让 crawl4ai、HTML/Markdown/LaTeX 渲染中的远程资源使用 [proxy] 代理地址。默认关闭。 -# en: Whether crawl4ai and remote resources in HTML/Markdown/LaTeX rendering use proxy addresses from [proxy]. Disabled by default. +# zh: 是否让 crawl4ai 等网页抓取链路使用 [proxy] 代理地址。HTML/Markdown 浏览器渲染始终离线。默认关闭。 +# en: Whether crawl4ai and other web crawling paths use proxy addresses from [proxy]. HTML/Markdown browser rendering is always offline. Disabled by default. use_proxy = false # zh: 渲染浏览器最大同时开启数量。0 表示自动:Linux 默认 1,其它平台默认 2。 # en: Max concurrent render browser pages. 0 = auto: Linux defaults to 1, other platforms default to 2. diff --git a/docs/build.md b/docs/build.md index 2f7a0148..1f77fd8e 100644 --- a/docs/build.md +++ b/docs/build.md @@ -31,15 +31,17 @@ uv sync --group dev -p 3.12 uv run playwright install ``` -### LaTeX 渲染环境 +### 渲染环境 -`render.render_latex` 会优先使用 Python 依赖中的 `matplotlib` mathtext 在本地渲染常见数学公式,不需要额外安装系统 TeX。mathtext 无法处理的复杂内容会回退到 MathJax + Playwright,因此请确保已经执行: +`render.render_latex` 使用 Python 依赖中的 `matplotlib.mathtext` 本地渲染常见数学公式,不需要系统 TeX、Playwright 或外部网络。复杂 TeX 环境和自定义宏可能不受支持。 + +HTML 和 Markdown 图片渲染需要 Playwright: ```bash uv run playwright install ``` -如果运行环境无法访问 MathJax CDN,请在配置中启用 HTTP/HTTPS 代理,或尽量使用 mathtext 支持的常见数学公式语法。 +渲染 BrowserContext 强制离线;外部图片、字体、样式和脚本不会加载,应改为内联资源。 ### Node.js / Rust / Tauri diff --git a/docs/configuration.md b/docs/configuration.md index 09b66313..4383645b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -758,17 +758,17 @@ Prompt caching 补充: |---|---:|---|---| | `browser_max_concurrency` | `0` | 渲染浏览器最大同时开启数量 | `<=0` 时启用自动值:Linux=`1`,其它平台=`2` | | `browser_executable_path` | `""` | 可选 Chrome/Chromium 可执行文件路径 | 留空时优先使用 Playwright 自带浏览器;其缺失时自动查找系统 Chrome/Chromium | -| `use_proxy` | `false` | HTML/Markdown 渲染及网页抓取渲染链路是否使用 `[proxy]` 中的代理地址 | | +| `use_proxy` | `false` | 网页抓取链路是否使用 `[proxy]` 中的代理地址 | HTML/Markdown 浏览器渲染始终离线,不使用代理 | | `long_image_default_width` | `900` | `layout=long` 未传 `width` 时的最终图片宽度(像素) | 自动钳制到 `320..2048` | | `long_image_default_padding` | `28` | `layout=long` 未传 `padding` 时的内边距(像素) | 自动钳制到 `0..160`,且保证小于宽度的一半 | 说明: -- 该配置只影响 `render.py` 的 HTML/Markdown 图片渲染链路,不影响 `crawl_webpage` 等独立浏览器实现。 +- 浏览器路径、并发和长图配置只影响 `render.py` 的 HTML/Markdown 图片渲染链路;`use_proxy` 仍供 `crawl_webpage` 等独立网页抓取实现使用。 - 渲染浏览器当前采用单例复用,因此这里限制的是并发页面/上下文数量,而不是浏览器进程数量。 - 显式修改 `browser_executable_path` 后需重启 Bot;仅当 Playwright 报告自带浏览器缺失时才会自动回退到系统浏览器,其他启动错误仍会原样报出。 - 配置变更会对后续新的渲染请求生效;已在执行中的渲染任务不受影响。 - `render.render_html` 和 `render.render_markdown` 默认使用 `layout=default`,视觉效果与旧版一致。显式传 `layout=long` 时,高度按内容自动延伸,使用 CSS 像素截图保证 `width` 对应最终图片宽度,并去掉两侧外部留白。 -- `width` 可选范围为 `320..2048`,`padding` 可选范围为 `0..160`;两者只能与 `layout=long` 一起使用。HTML 长图仍支持完整 CSS、公共网络资源与脚本,但渲染页面默认阻断本机、私网和本地文件访问;`padding=0` 可用于全幅设计。 +- `width` 可选范围为 `320..2048`,`padding` 可选范围为 `0..160`;两者只能与 `layout=long` 一起使用。HTML 长图支持内联 CSS、脚本和 `data:` / `blob:` 资源;BrowserContext 强制离线并终止全部网络请求,外部图片、字体、样式和脚本不会加载。`padding=0` 可用于全幅设计。 #### `[render.cache]` HTML 渲染结果缓存 diff --git a/docs/deployment.md b/docs/deployment.md index 46539dd8..b11286ee 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -53,15 +53,15 @@ uv run playwright install ### 3. 安装渲染运行时 -网页截图、Markdown 渲染和复杂 LaTeX 公式回退渲染依赖 Playwright 浏览器内核。源码部署时请执行: +网页截图和 Markdown 渲染依赖 Playwright 浏览器内核。源码部署时请执行: ```bash uv run playwright install ``` -`render.render_latex` 会优先使用 Python 依赖中的 `matplotlib` mathtext 在本地渲染常见数学公式,不需要额外安装系统 TeX。mathtext 无法处理的复杂内容会回退到 MathJax + Playwright;如果运行环境无法访问 MathJax CDN,请在配置中启用 HTTP/HTTPS 代理。 +`render.render_latex` 使用 Python 依赖中的 `matplotlib.mathtext` 在本地渲染常见数学公式,不需要额外安装系统 TeX,也不访问外部网络。复杂 TeX 环境和自定义宏可能不受支持,此时工具会立即返回明确错误。 -`render.render_html` / `render.render_markdown` 的 `layout=long` 与普通渲染复用同一套 Playwright 运行时,无需新增系统依赖。HTML 中的外部图片、字体和脚本仍需要宿主机可访问对应地址;需要代理时使用 `[render].use_proxy`。 +`render.render_html` / `render.render_markdown` 的 `layout=long` 与普通渲染复用同一套 Playwright 运行时,无需新增系统依赖。渲染 BrowserContext 强制离线并终止全部网络请求;请将所需样式、脚本和图片内联,图片可使用 `data:` / `blob:` 资源。 如果 Playwright 自带 Chromium 未安装,渲染器会尝试复用系统已安装的 Chrome/Chromium。需要指定其他路径时,设置 `[render].browser_executable_path`;与 Playwright 自带版本相比,系统浏览器的版本兼容性不受 Playwright 保证,因此生产环境仍优先执行 `uv run playwright install`。 @@ -148,7 +148,7 @@ uv tool install Undefined-bot uv tool run --from Undefined-bot playwright install ``` -> **渲染依赖提醒**:同源码部署要求一致,你需要在宿主机上预先安装 Playwright 浏览器内核。请参考上文 [3. 安装渲染运行时](#3-安装渲染运行时)。未配置前,网页截图、Markdown 渲染和复杂 LaTeX 公式回退渲染可能会失败。 +> **渲染依赖提醒**:同源码部署要求一致,你需要在宿主机上预先安装 Playwright 浏览器内核。请参考上文 [3. 安装渲染运行时](#3-安装渲染运行时)。未配置前,HTML 与 Markdown 图片渲染可能会失败;LaTeX 常见公式使用本地 mathtext,不依赖浏览器。 安装完成后,在任意目录准备 `config.toml` 并启动(库嵌入场景也可用 `Config.from_mapping()` 代替配置文件,见 [python-api.md](python-api.md)): diff --git a/docs/usage.md b/docs/usage.md index d46b585a..e72e1f20 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -154,8 +154,8 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 | 工具 | 说明 | |---|---| | `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为普通图片或单张长图 | -| `render.render_latex` | 将 LaTeX 数学公式渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright(详见[部署文档](deployment.md#3-安装渲染运行时)) | -| `render.render_html` | 将完整 HTML/CSS/脚本渲染为普通图片或单张长图;默认阻断本机、私网和本地文件访问 | +| `render.render_latex` | 通过本地 `matplotlib.mathtext` 将常见 LaTeX 数学公式渲染为图片或 PDF;不访问外部网络,复杂 TeX 环境可能不受支持 | +| `render.render_html` | 将完整 HTML、内联 CSS/脚本渲染为普通图片或单张长图;浏览器上下文完全离线,不加载外部资源 | 支持 `embed`(嵌入回复)和 `send`(直接发送)两种图片交付方式。 diff --git a/src/Undefined/render.py b/src/Undefined/render.py index 473574b3..015cca49 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -1,13 +1,11 @@ """HTML 渲染模块:将 HTML/Markdown 渲染为图片""" import asyncio -import ipaddress import logging import sys from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any, Literal, TypeVar -from urllib.parse import urlsplit import markdown from playwright.async_api import Browser, Page, Playwright, Route, async_playwright @@ -69,16 +67,6 @@ "chromium-browser", "microsoft-edge-stable", ) -_NETWORK_SCHEMES: frozenset[str] = frozenset({"http", "https", "ws", "wss"}) -_LOCAL_RESOURCE_SCHEMES: frozenset[str] = frozenset({"about", "blob", "data"}) -_PRIVATE_HOST_SUFFIXES: tuple[str, ...] = ( - ".home.arpa", - ".internal", - ".lan", - ".local", - ".localdomain", - ".localhost", -) _RenderResult = TypeVar("_RenderResult") @@ -299,7 +287,7 @@ async def render_html_to_image( screenshot_scale: 输出像素尺度,device 按 DPR 输出,css 按 CSS 像素输出 screenshot_style: 仅在截图期间注入的 CSS 样式 timeout_ms: 截图超时时间(毫秒),默认 60000 - proxy: 可选浏览器代理地址 + proxy: 保留用于调用兼容和缓存隔离;离线上下文不会发出网络请求 """ cache = await get_render_cache() cache_key = compute_render_cache_key( @@ -366,13 +354,12 @@ async def render_html_with_page( try: context_kwargs: dict[str, Any] = { "device_scale_factor": 2, + "offline": True, "service_workers": "block", "viewport": {"width": viewport_width, "height": 800}, } - if proxy: - context_kwargs["proxy"] = {"server": proxy} context = await browser.new_context(**context_kwargs) - await context.route("**/*", _guard_render_request) + await context.route("**/*", _abort_render_network_request) page = await context.new_page() page.set_default_timeout(timeout_ms) await page.set_content(html_content) @@ -385,37 +372,6 @@ async def render_html_with_page( _render_active_count = max(0, _render_active_count - 1) -def _is_restricted_render_url(url: str) -> bool: - """判断页面资源是否会访问本机、私网或非 Web 资源。""" - try: - parsed = urlsplit(url) - scheme = parsed.scheme.lower() - hostname = parsed.hostname - except ValueError: - return True - - if scheme in _LOCAL_RESOURCE_SCHEMES: - return False - if scheme not in _NETWORK_SCHEMES or hostname is None: - return True - - normalized_host = hostname.rstrip(".").lower() - if not normalized_host: - return True - - try: - address = ipaddress.ip_address(normalized_host) - except ValueError: - return "." not in normalized_host or normalized_host.endswith( - _PRIVATE_HOST_SUFFIXES - ) - else: - return not address.is_global - - -async def _guard_render_request(route: Route) -> None: - """阻断 HTML 渲染页面对本机和私网的网络访问。""" - if _is_restricted_render_url(route.request.url): - await route.abort() - return - await route.continue_() +async def _abort_render_network_request(route: Route) -> None: + """终止渲染上下文中的所有网络请求,避免 DNS 重绑定绕过。""" + await route.abort() diff --git a/src/Undefined/skills/toolsets/render/README.md b/src/Undefined/skills/toolsets/render/README.md index a40ce6e5..89dee946 100644 --- a/src/Undefined/skills/toolsets/render/README.md +++ b/src/Undefined/skills/toolsets/render/README.md @@ -3,7 +3,7 @@ 渲染相关工具集合,工具名以 `render.*` 命名。 主要能力: -- HTML 渲染,保留完整 CSS、公共网络资源与脚本执行,并默认阻断本机、私网和本地文件访问 +- HTML 渲染,支持内联 CSS、脚本和 `data:` / `blob:` 资源;浏览器上下文默认完全离线,不加载外部资源 - Markdown 渲染 - LaTeX 渲染 - HTML/Markdown 可显式传 `layout=long`、`width`、`padding` 输出无两侧外部留白的单张长图 diff --git a/src/Undefined/skills/toolsets/render/render_html/config.json b/src/Undefined/skills/toolsets/render/render_html/config.json index d011844d..cf4cbff6 100644 --- a/src/Undefined/skills/toolsets/render/render_html/config.json +++ b/src/Undefined/skills/toolsets/render/render_html/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "render_html", - "description": "将 HTML 内容渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持完整 HTML、CSS、外部资源和脚本;需要窄幅、高度随内容延伸且无两侧外部留白时,使用 layout=long。", + "description": "将 HTML 内容渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持完整 HTML、内联 CSS、脚本和 data:/blob: 资源;浏览器上下文不加载外部资源。需要窄幅、高度随内容延伸且无两侧外部留白时,使用 layout=long。", "parameters": { "type": "object", "properties": { diff --git a/src/Undefined/skills/toolsets/render/render_latex/config.json b/src/Undefined/skills/toolsets/render/render_latex/config.json index 16b1f995..6568b6e8 100644 --- a/src/Undefined/skills/toolsets/render/render_latex/config.json +++ b/src/Undefined/skills/toolsets/render/render_latex/config.json @@ -2,13 +2,13 @@ "type": "function", "function": { "name": "render_latex", - "description": "将 LaTeX 数学公式渲染为图片或 PDF 文档,使用 MathJax(不依赖系统 TeX 安装)。支持 LaTeX 数学子集(amsmath、equation、align、matrix 等),但不支持自定义 TeX 包。返回可嵌入回复的附件 UID。", + "description": "将常见 LaTeX 数学公式通过本地 matplotlib mathtext 渲染为图片或 PDF 文档,不依赖系统 TeX 或外部网络。复杂 TeX 环境和自定义宏可能不受支持。返回可嵌入回复的附件 UID。", "parameters": { "type": "object", "properties": { "content": { "type": "string", - "description": "要渲染的 LaTeX 数学内容。支持 $...$(行内)、$$...$$(块级)、\\[...\\]、\\(...\\) 及标准数学环境(\\begin{align}、\\begin{equation}、\\begin{matrix} 等)。如果不包含分隔符,会自动用 \\[ ... \\] 包装。\\begin{document}...\\end{document} 外层包装会自动去掉。" + "description": "要渲染的 LaTeX 数学内容。支持 mathtext 常见数学语法及 $...$、$$...$$、\\[...\\]、\\(...\\) 分隔符。如果不包含分隔符,会自动用 \\[ ... \\] 包装;\\begin{document}...\\end{document} 外层包装会自动去掉。复杂 TeX 环境和自定义宏可能不受支持。" }, "output_format": { "type": "string", diff --git a/src/Undefined/skills/toolsets/render/render_latex/handler.py b/src/Undefined/skills/toolsets/render/render_latex/handler.py index 5d47065e..04e6fec7 100644 --- a/src/Undefined/skills/toolsets/render/render_latex/handler.py +++ b/src/Undefined/skills/toolsets/render/render_latex/handler.py @@ -6,8 +6,6 @@ from typing import Any, Dict from Undefined.attachments import scope_from_context -from Undefined.config import get_config -from Undefined.skills.http_config import get_configured_proxy logger = logging.getLogger(__name__) @@ -16,7 +14,7 @@ re.DOTALL, ) -# MathJax 数学分隔符模式 +# 数学分隔符模式 _MATH_DELIMITER_PATTERN = re.compile( r"(\$\$|\\\[|\\\(|\\begin\{)", re.MULTILINE, @@ -55,43 +53,6 @@ def _prepare_content(raw_content: str) -> str: return content -def _build_html(latex_content: str) -> str: - """构建包含 MathJax 的 HTML 页面。""" - # HTML 转义(防止内容中的 < > & 破坏结构) - import html - - escaped_content = html.escape(latex_content) - - return f""" - - - - - - - - -
-{escaped_content} -
- -""" - - def _strip_math_wrappers(content: str) -> str: """去掉 mathtext 可直接处理的外层数学分隔符。""" text = content.strip() @@ -148,79 +109,15 @@ async def _render_mathtext_to_bytes( return await asyncio.to_thread(_render_mathtext_sync, content, output_format) -async def _render_latex_to_bytes( - content: str, output_format: str, proxy: str | None = None -) -> tuple[bytes, str]: - """ - 优先使用本地 mathtext 渲染,复杂内容再回退到 MathJax + Playwright。 - - 返回: (渲染后的字节流, MIME 类型) - """ +async def _render_latex_to_bytes(content: str, output_format: str) -> tuple[bytes, str]: + """使用本地 mathtext 渲染,返回字节流及 MIME 类型。""" try: return await _render_mathtext_to_bytes(content, output_format) except Exception as exc: - logger.debug("本地 mathtext 渲染失败,回退到 MathJax: %s", exc) - - try: - from playwright.async_api import ( - Page, - TimeoutError as PwTimeoutError, - ) - from Undefined.render import render_html_with_page - except ImportError: - raise ImportError( - "请运行 `uv run playwright install` 安装浏览器运行时" - ) from None - - html_content = _build_html(content) - if proxy: - logger.info("LaTeX 渲染使用代理: %s", proxy) - - async def _render_page(page: Page) -> tuple[bytes, str]: - # 等待 MathJax 完成排版(pageReady 回调设置 window._mjReady) - try: - await page.wait_for_function( - "() => window._mjReady === true", - timeout=30000, - ) - except PwTimeoutError: - logger.warning("MathJax 排版超时,内容可能过于复杂或网络不可达") - raise RuntimeError( - "LaTeX 内容可能过于复杂或网络不可达(MathJax 加载超时)" - ) from None - - container = await page.query_selector("#math-container") - if container is None: - raise RuntimeError("无法定位数学容器元素") - - if output_format == "pdf": - bbox = await container.bounding_box() - if bbox is None: - raise RuntimeError("无法获取数学容器的边界框") - - pdf_bytes = await page.pdf( - width=f"{bbox['width'] + 40}px", - height=f"{bbox['height'] + 40}px", - print_background=True, - ) - return pdf_bytes, "application/pdf" - - screenshot_bytes = await container.screenshot(type="png") - return screenshot_bytes, "image/png" - - return await render_html_with_page(html_content, _render_page, proxy=proxy) - - -async def _resolve_proxy(context: Dict[str, Any]) -> str | None: - """从 context 的 runtime_config 中解析代理地址。""" - runtime_config = context.get("runtime_config") or get_config(strict=False) - if runtime_config is None: - return None - return get_configured_proxy( - "https://cdn.jsdelivr.net", - use_proxy=bool(getattr(runtime_config, "render_use_proxy", False)), - config=runtime_config, - ) + logger.debug("本地 mathtext 渲染失败: %s", exc) + raise RuntimeError( + "LaTeX 内容超出本地 mathtext 支持范围;渲染沙箱已禁用外部网络" + ) from exc async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: @@ -239,12 +136,9 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 准备内容 prepared_content = _prepare_content(raw_content) - # 解析代理 - proxy = await _resolve_proxy(context) - # 渲染 rendered_bytes, mime_type = await _render_latex_to_bytes( - prepared_content, output_format, proxy=proxy + prepared_content, output_format ) # 注册到附件系统 @@ -274,9 +168,6 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: logger.exception("注册渲染结果到附件系统失败: %s", exc) return f"渲染成功,但注册到附件系统失败: {exc}" - except ImportError as e: - logger.error("Playwright 导入失败: %s", e) - return "请运行 `uv run playwright install` 安装浏览器运行时" except RuntimeError as e: logger.error("LaTeX 渲染运行时错误: %s", e) return str(e) diff --git a/tests/test_render.py b/tests/test_render.py index 2da23895..345aeffb 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -446,7 +446,7 @@ async def _no_sleep(_delay: float) -> None: @pytest.mark.asyncio -async def test_render_html_with_page_blocks_private_network_by_default( +async def test_render_html_with_page_disables_external_network_by_default( monkeypatch: pytest.MonkeyPatch, ) -> None: context_kwargs: dict[str, Any] = {} @@ -454,22 +454,14 @@ async def test_render_html_with_page_blocks_private_network_by_default( route_pattern = "" route_handler: Any = None - class _FakeRequest: - def __init__(self, url: str) -> None: - self.url = url - class _FakeRoute: def __init__(self, url: str) -> None: - self.request = _FakeRequest(url) + self.url = url self.aborted = False - self.continued = False async def abort(self) -> None: self.aborted = True - async def continue_(self) -> None: - self.continued = True - class _FakePage: def set_default_timeout(self, _timeout_ms: int) -> None: return None @@ -503,22 +495,15 @@ async def _fake_get_semaphore() -> asyncio.Semaphore: async def _callback(_page: Any) -> None: assert route_handler is not None - private_routes = [ + network_routes = [ _FakeRoute("http://127.0.0.1:8080/private"), - _FakeRoute("http://192.168.1.10/private"), - _FakeRoute("http://metadata.google.internal/computeMetadata/v1/"), - _FakeRoute("file:///etc/passwd"), + _FakeRoute("https://dns-rebinding.example/resource"), + _FakeRoute("https://example.com/app.js"), ] - public_route = _FakeRoute("https://example.com/app.js") - for route in private_routes: + for route in network_routes: await route_handler(route) assert route.aborted is True - assert route.continued is False - - await route_handler(public_route) - assert public_route.aborted is False - assert public_route.continued is True monkeypatch.setattr(render_module, "_get_browser", _fake_get_browser) monkeypatch.setattr(render_module, "_get_semaphore", _fake_get_semaphore) @@ -528,4 +513,5 @@ async def _callback(_page: Any) -> None: assert html_seen == html assert route_pattern == "**/*" + assert context_kwargs["offline"] is True assert context_kwargs["service_workers"] == "block" diff --git a/tests/test_render_latex_tool.py b/tests/test_render_latex_tool.py index f3b4da92..e107ab12 100644 --- a/tests/test_render_latex_tool.py +++ b/tests/test_render_latex_tool.py @@ -1,12 +1,11 @@ -"""测试 LaTeX 渲染工具(MathJax + Playwright 实现)""" +"""测试本地 LaTeX 渲染工具。""" from __future__ import annotations import asyncio import pytest -from typing import Any, cast +from typing import Any -# 这个测试需要 Playwright 浏览器运行时,所以标记为可选 pytest_plugins = ("pytest_asyncio",) @@ -60,8 +59,6 @@ async def test_render_simple_equation() -> None: args = {"content": "E = mc^2", "output_format": "png"} result = await execute(args, context) - if "渲染失败" in result and "Executable doesn't exist" in result: - pytest.skip("Playwright 浏览器未安装,跳过测试") assert result == '' assert len(mock_registry.registered_items) == 1 assert mock_registry.registered_items[0]["kind"] == "image" @@ -84,8 +81,6 @@ async def test_render_with_delimiters() -> None: args = {"content": r"\[ \int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2} \]"} result = await execute(args, context) - if "渲染失败" in result and "Executable doesn't exist" in result: - pytest.skip("Playwright 浏览器未安装,跳过测试") assert result == '' assert len(mock_registry.registered_items) == 1 @@ -105,8 +100,6 @@ async def test_render_pdf_output() -> None: args = {"content": r"\frac{a}{b} + \sqrt{c}", "output_format": "pdf"} result = await execute(args, context) - if "渲染失败" in result and "Executable doesn't exist" in result: - pytest.skip("Playwright 浏览器未安装,跳过测试") assert result == '' assert len(mock_registry.registered_items) == 1 assert mock_registry.registered_items[0]["kind"] == "file" @@ -159,51 +152,42 @@ async def _fake_to_thread(func: Any, *args: Any, **kwargs: Any) -> Any: @pytest.mark.asyncio -async def test_mathjax_fallback_uses_shared_render_page( +async def test_complex_latex_does_not_fall_back_to_external_network( monkeypatch: pytest.MonkeyPatch, ) -> None: - import Undefined.render as render import Undefined.skills.toolsets.render.render_latex.handler as handler async def _fail_mathtext(_content: str, _output_format: str) -> tuple[bytes, str]: raise RuntimeError("force fallback") - class _FakeContainer: - async def screenshot(self, *, type: str) -> bytes: - assert type == "png" - return b"png-bytes" - - class _FakePage: - async def wait_for_function(self, expression: str, *, timeout: int) -> None: - assert expression == "() => window._mjReady === true" - assert timeout == 30000 - - async def query_selector(self, selector: str) -> _FakeContainer | None: - assert selector == "#math-container" - return _FakeContainer() + monkeypatch.setattr(handler, "_render_mathtext_to_bytes", _fail_mathtext) - calls: list[dict[str, Any]] = [] + with pytest.raises(RuntimeError, match="已禁用外部网络"): + await handler._render_latex_to_bytes( + r"\begin{aligned}x&=1\\y&=2\end{aligned}", + "png", + ) - async def _fake_render_html_with_page( - html_content: str, - callback: Any, - **kwargs: Any, - ) -> tuple[bytes, str]: - calls.append({"html": html_content, "kwargs": kwargs}) - return cast(tuple[bytes, str], await callback(_FakePage())) - monkeypatch.setattr(handler, "_render_mathtext_to_bytes", _fail_mathtext) - monkeypatch.setattr(render, "render_html_with_page", _fake_render_html_with_page) +@pytest.mark.asyncio +async def test_execute_reports_unsupported_complex_latex() -> None: + from Undefined.skills.toolsets.render.render_latex.handler import execute - result = await handler._render_latex_to_bytes( - r"\begin{aligned}x&=1\\y&=2\end{aligned}", - "png", - proxy="http://127.0.0.1:7890", + registry = MockAttachmentRegistry() + result = await execute( + { + "content": r"\begin{aligned}x&=1\\y&=2\end{aligned}", + "output_format": "png", + }, + { + "attachment_registry": registry, + "request_type": "private", + "user_id": 42, + }, ) - assert result == (b"png-bytes", "image/png") - assert calls[0]["kwargs"]["proxy"] == "http://127.0.0.1:7890" - assert "math-container" in calls[0]["html"] + assert "已禁用外部网络" in result + assert registry.registered_items == [] def test_strip_document_wrappers() -> None: @@ -260,14 +244,3 @@ def test_prepare_content() -> None: assert r"\nu" in result_latex assert r"\nabla" in result_latex assert r"\neq" in result_latex - - -def test_build_html_contains_mathjax_ready_flag() -> None: - """HTML 模板包含 MathJax pageReady 回调设置 _mjReady 标记""" - from Undefined.skills.toolsets.render.render_latex.handler import _build_html - - html = _build_html(r"\[ x = 1 \]") - assert "window._mjReady = true" in html - assert "pageReady" in html - assert "tex-svg.js" in html - assert "math-container" in html From 06788328614e645626f7d609e5b197e263f95ebd Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Mon, 13 Jul 2026 23:03:44 +0800 Subject: [PATCH 5/5] docs(render): explain retained proxy parameter Co-authored-by: GPT-5 Codex --- src/Undefined/render.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Undefined/render.py b/src/Undefined/render.py index 015cca49..fb2eb51f 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -342,7 +342,11 @@ async def render_html_with_page( timeout_ms: int = 60000, proxy: str | None = None, ) -> _RenderResult: - """在共享浏览器实例中打开 HTML 页面并交给调用方渲染。""" + """在共享浏览器实例中打开 HTML 页面并交给调用方渲染。 + + ``proxy`` 在移出 ``context_kwargs`` 后仍刻意保留,以兼容现有调用;上层 + ``render_html_to_image`` 继续用它隔离缓存键,离线浏览器上下文不会使用它。 + """ browser = await _get_browser() semaphore = await _get_semaphore()