diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ac1137..9f0b1f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 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,显式配置路径无效或其他启动错误仍会直接报告,避免掩盖真实故障。 +- 收紧 HTML 渲染网络边界。BrowserContext 强制离线、禁用 Service Worker,并在上下文级终止全部网络请求,不再依赖可被 DNS 重绑定绕过的主机名预检;内联 CSS、脚本及 `data:` / `blob:` 资源仍可使用。LaTeX 同步收敛为本地 mathtext 渲染,复杂且不受支持的 TeX 不再等待外部 MathJax CDN。 +- 加固渲染缓存与配置集成。缓存键纳入截图缩放和样式参数,避免不同布局错误复用缓存;同步环境变量、热更新边界、配置模板、部署与使用文档,并补充长图参数、缓存隔离、浏览器回退和实际渲染回归测试。 + +--- + ## 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/config.toml.example b/config.toml.example index 3818e77c..0dfdb392 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1095,12 +1095,21 @@ 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. 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/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 09f88948..4383645b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -757,12 +757,18 @@ Prompt caching 补充: | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| | `browser_max_concurrency` | `0` | 渲染浏览器最大同时开启数量 | `<=0` 时启用自动值:Linux=`1`,其它平台=`2` | -| `use_proxy` | `false` | HTML/Markdown 渲染及网页抓取渲染链路是否使用 `[proxy]` 中的代理地址 | | +| `browser_executable_path` | `""` | 可选 Chrome/Chromium 可执行文件路径 | 留空时优先使用 Playwright 自带浏览器;其缺失时自动查找系统 Chrome/Chromium | +| `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、脚本和 `data:` / `blob:` 资源;BrowserContext 强制离线并终止全部网络请求,外部图片、字体、样式和脚本不会加载。`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..b11286ee 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -53,13 +53,17 @@ 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 运行时,无需新增系统依赖。渲染 BrowserContext 强制离线并终止全部网络请求;请将所需样式、脚本和图片内联,图片可使用 `data:` / `blob:` 资源。 + +如果 Playwright 自带 Chromium 未安装,渲染器会尝试复用系统已安装的 Chrome/Chromium。需要指定其他路径时,设置 `[render].browser_executable_path`;与 Playwright 自带版本相比,系统浏览器的版本兼容性不受 Playwright 保证,因此生产环境仍优先执行 `uv run playwright install`。 ### 4. 配置环境 @@ -144,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 f103a24b..e72e1f20 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -153,15 +153,23 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 | 工具 | 说明 | |---|---| -| `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为图片发送 | -| `render.render_latex` | 将 LaTeX 数学公式渲染为图片;常见公式本地渲染,复杂内容回退 MathJax + Playwright(详见[部署文档](deployment.md#3-安装渲染运行时)) | -| `render.render_html` | 将 HTML 内容渲染为图片 | +| `render.render_markdown` | 将 Markdown 文本(含表格、代码块、标题等)渲染为普通图片或单张长图 | +| `render.render_latex` | 通过本地 `matplotlib.mathtext` 将常见 LaTeX 数学公式渲染为图片或 PDF;不访问外部网络,复杂 TeX 环境可能不受支持 | +| `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/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/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..39a7af3b 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: int = 320 +_LONG_IMAGE_MAX_WIDTH: int = 2048 +_LONG_IMAGE_MAX_PADDING: int = 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..fb2eb51f 100644 --- a/src/Undefined/render.py +++ b/src/Undefined/render.py @@ -5,13 +5,14 @@ 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 import markdown +from playwright.async_api import Browser, Page, Playwright, Route, async_playwright -from typing import Any, 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__) @@ -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,40 @@ def _resolve_render_browser_max_concurrency() -> int: return configured_limit +async 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 = await resolve_path(configured) + if not await is_file(path): + raise FileNotFoundError(f"配置的渲染浏览器不存在: {path}") + return str(path) + + +async def _find_system_browser_executable() -> str | None: + """在 Playwright 自带 Chromium 缺失时查找已安装的系统浏览器。""" + for command in _SYSTEM_CHROMIUM_COMMANDS: + executable = await find_executable(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 +142,32 @@ async def _get_browser() -> Browser: if _browser is not None: return _browser + configured_executable = await _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 = await _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 +271,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 +284,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: 可选浏览器代理地址 + 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 +308,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, ) @@ -264,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() @@ -276,11 +358,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("**/*", _abort_render_network_request) page = await context.new_page() page.set_default_timeout(timeout_ms) await page.set_content(html_content) @@ -291,3 +374,8 @@ async def render_html_with_page( await context.close() finally: _render_active_count = max(0, _render_active_count - 1) + + +async def _abort_render_network_request(route: Route) -> None: + """终止渲染上下文中的所有网络请求,避免 DNS 重绑定绕过。""" + await route.abort() 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..89dee946 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、脚本和 `data:` / `blob:` 资源;浏览器上下文默认完全离线,不加载外部资源 - 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..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 和样式。", + "description": "将 HTML 内容渲染为图片。默认返回可嵌入回复的图片 UID(embed),也可直接发送到指定目标(send)。支持完整 HTML、内联 CSS、脚本和 data:/blob: 资源;浏览器上下文不加载外部资源。需要窄幅、高度随内容延伸且无两侧外部留白时,使用 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_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""" - - - - - - - - -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_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 == '