package vip.mate.tool.builtin; import com.microsoft.playwright.Browser; import com.microsoft.playwright.BrowserContext; import com.microsoft.playwright.BrowserType; import com.microsoft.playwright.Page; import com.microsoft.playwright.Playwright; import com.microsoft.playwright.options.ScreenshotType; import com.microsoft.playwright.options.WaitUntilState; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.tool.browser.BrowserLauncher; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; import vip.mate.tool.document.GeneratedFileLink; import vip.mate.tool.guard.WorkspacePathGuard; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; /** * Render arbitrary HTML to a PNG and return a one-time download URL. * *

Bridges the gap between HTML-producing skills (architecture diagrams, * infographics, dashboards) and IM channels whose native message types only * accept rasterised images. The PNG is stashed in {@link GeneratedFileCache} * with an {@code image/png} MIME so the per-channel sniff layer * ({@code WeComChannelAdapter}, {@code DingTalkChannelAdapter}, …) uploads it * as a native image attachment rather than a fallback file. */ @Slf4j @Component public class HtmlImageRenderTool { private static final String PNG_MIME = "image/png"; private static final int DEFAULT_VIEWPORT_WIDTH = 1440; private static final int DEFAULT_VIEWPORT_HEIGHT = 900; private static final int MAX_VIEWPORT_DIMENSION = 4096; private static final int SET_CONTENT_TIMEOUT_MS = 15_000; private final GeneratedFileCache cache; private volatile Playwright sharedPlaywright; private final Object playwrightLock = new Object(); public HtmlImageRenderTool(GeneratedFileCache cache) { this.cache = cache; } @Tool(description = """ Render HTML to a PNG image and return a one-time download URL. Use this whenever the user wants an HTML artifact (architecture diagram, infographic, dashboard, mockup, ...) delivered as an *image* — especially when the chat is happening on an IM channel (WeCom / 企业微信, DingTalk, Feishu, Telegram, Discord) where users cannot click through a raw HTML link. The returned URL is `/api/v1/files/generated/` with MIME `image/png`. Channel adapters detect this MIME and upload the bytes as a native image message, so the recipient sees an inline picture rather than a file attachment. Typical workflow when paired with an HTML-producing skill: 1. write_file(filePath="diagram.html", content="...") 2. render_html_image(filePath="diagram.html", filename="diagram") 3. return the markdown link to the user Or directly, without going through disk: 1. render_html_image(html="...", filename="diagram") Exactly one of `filePath` or `html` must be supplied. The link is valid for 10 minutes. """) public String render_html_image( @ToolParam(description = "Path to an HTML file on disk (workspace-relative or absolute). Mutually exclusive with `html`.", required = false) String filePath, @ToolParam(description = "Inline HTML source. Mutually exclusive with `filePath`.", required = false) String html, @ToolParam(description = "Output filename without extension, e.g. 'architecture'") String filename, @ToolParam(description = "Viewport width in px (default 1440, max 4096)", required = false) Integer width, @ToolParam(description = "Viewport height in px (default 900, max 4096). Ignored when fullPage=true except as initial layout hint.", required = false) Integer height, @ToolParam(description = "Capture full scrollable page (default true). Set false to only capture the viewport.", required = false) Boolean fullPage) { String source; try { source = resolveHtml(filePath, html); } catch (IllegalArgumentException e) { return "Error: " + e.getMessage(); } catch (Exception e) { log.error("[HtmlImageRender] failed to load HTML: {}", e.getMessage(), e); return "Error: failed to load HTML — " + e.getMessage(); } int vw = clampViewport(width, DEFAULT_VIEWPORT_WIDTH); int vh = clampViewport(height, DEFAULT_VIEWPORT_HEIGHT); boolean full = fullPage == null || fullPage; String displayName = FilenameSanitizer.sanitize(filename, "image", ".png") + ".png"; byte[] pngBytes; try { pngBytes = renderToPng(source, vw, vh, full); } catch (Exception e) { log.error("[HtmlImageRender] render failed for {}: {}", displayName, e.getMessage(), e); String hint = e.getMessage() != null && e.getMessage().contains("Executable doesn't exist") ? " Hint: run `mvn exec:java -e -Dexec.mainClass=\"com.microsoft.playwright.CLI\" -Dexec.args=\"install chromium\"` to install the bundled browser." : ""; return "Render failed: " + e.getMessage() + hint; } log.info("[HtmlImageRender] rendered {} ({} bytes, viewport={}x{}, fullPage={})", displayName, pngBytes.length, vw, vh, full); return GeneratedFileLink.resultZh(pngBytes, displayName, PNG_MIME, cache, "图片"); } private String resolveHtml(String filePath, String inlineHtml) throws Exception { boolean hasPath = filePath != null && !filePath.isBlank(); boolean hasInline = inlineHtml != null && !inlineHtml.isBlank(); if (hasPath == hasInline) { throw new IllegalArgumentException( "Provide exactly one of `filePath` or `html` (not both, not neither)."); } if (hasPath) { Path path = WorkspacePathGuard.validatePath(filePath); if (!Files.exists(path)) { throw new IllegalArgumentException("HTML file not found: " + filePath); } if (Files.isDirectory(path)) { throw new IllegalArgumentException("Path is a directory, not a file: " + filePath); } return Files.readString(path, StandardCharsets.UTF_8); } return inlineHtml; } private byte[] renderToPng(String source, int viewportWidth, int viewportHeight, boolean fullPage) { Playwright pw = getOrCreatePlaywright(); BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions() .setHeadless(true) .setArgs(BrowserLauncher.chromiumLaunchArgs()); Browser browser = pw.chromium().launch(opts); try { BrowserContext ctx = browser.newContext(new Browser.NewContextOptions() .setViewportSize(viewportWidth, viewportHeight) .setDeviceScaleFactor(2.0)); try { Page page = ctx.newPage(); page.setContent(source, new Page.SetContentOptions() .setWaitUntil(WaitUntilState.NETWORKIDLE) .setTimeout(SET_CONTENT_TIMEOUT_MS)); return page.screenshot(new Page.ScreenshotOptions() .setFullPage(fullPage) .setType(ScreenshotType.PNG)); } finally { try { ctx.close(); } catch (Exception ignored) {} } } finally { try { browser.close(); } catch (Exception ignored) {} } } /** * Lazily create one Playwright instance per JVM. Playwright.create() * spawns a Node.js child process and costs ~1–2 s; keeping the instance * around means subsequent screenshots only pay the browser-launch cost. */ private Playwright getOrCreatePlaywright() { Playwright local = sharedPlaywright; if (local != null) return local; synchronized (playwrightLock) { if (sharedPlaywright == null) { sharedPlaywright = Playwright.create(); } return sharedPlaywright; } } private static int clampViewport(Integer requested, int fallback) { if (requested == null || requested <= 0) return fallback; return Math.min(requested, MAX_VIEWPORT_DIMENSION); } }