feat(tool): add render_html_image to deliver HTML artifacts as native IM images

This commit is contained in:
matevip 2026-05-12 07:11:35 +08:00
parent b74176ef66
commit 071ccff9bf
3 changed files with 208 additions and 3 deletions

View File

@ -403,6 +403,11 @@ public class AgentBindingService {
"image_generate", "image_generate",
"music_generate", "music_generate",
"video_generate", "video_generate",
// HTML PNG rasteriser. Closes the loop for HTML-producing skills
// (architecture-diagram, infographics, dashboards) so IM channels
// can deliver the artifact as a native image instead of a file or
// a dead markdown link.
"render_html_image",
// Universal capabilities the global system prompts (SOUL.md / // Universal capabilities the global system prompts (SOUL.md /
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines") // AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
// explicitly tell the LLM exist. Pre-Phase-2b they were globally // explicitly tell the LLM exist. Pre-Phase-2b they were globally

View File

@ -0,0 +1,189 @@
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.
*
* <p>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/<id>` 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="<html>...")
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="<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 ~12 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);
}
}

View File

@ -41,7 +41,8 @@ Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-
1. User describes their system architecture (components, connections, technologies) 1. User describes their system architecture (components, connections, technologies)
2. Generate the HTML file following the design system below 2. Generate the HTML file following the design system below
3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`) 3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`)
4. User opens in any browser — works offline, no dependencies 4. **If the user wants to view/share the diagram in chat (web console, WeCom / 企业微信, DingTalk, Feishu, Telegram, ...): call `render_html_image(filePath="<the .html path>", filename="<name>")`** and return the markdown link it produces. IM channels can only deliver rasterised images natively, so a PNG is required for the diagram to appear inline rather than as a dead link or a file attachment.
5. Otherwise, the user opens the `.html` directly in a browser — works offline, no dependencies.
### Output Location ### Output Location
@ -50,9 +51,19 @@ Save diagrams to a user-specified path, or default to the current working direct
./[project-name]-architecture.html ./[project-name]-architecture.html
``` ```
### Preview ### Delivering through chat / IM channels
After saving, suggest the user open it: When the current channel is anything other than a local browser session, follow up `write_file` with:
```
render_html_image(filePath="./architecture-diagram.html", filename="architecture")
```
This returns a `/api/v1/files/generated/<id>` URL with `image/png` MIME. The channel layer detects the image MIME and uploads the PNG as a native image message (so it renders inline in WeCom / DingTalk / Feishu / Telegram / Web). Without this step, an `.html` artifact reaches IM channels as either a dead markdown link or, at best, a non-previewable file attachment.
### Local preview
After saving, the user can open the `.html` directly:
```bash ```bash
# macOS # macOS
open ./my-architecture.html open ./my-architecture.html