Diffs: extend image quality configs and add PDF as a format option (#31342)

Merged via squash.

Prepared head SHA: cc12097851d7b63f1f5f2f754c23cfb1c3faff9b
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-02 04:38:50 -05:00
committed by GitHub
parent 756f9c9fef
commit 5f49a5da3c
19 changed files with 1501 additions and 256 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.

View File

@@ -1,10 +1,10 @@
---
title: "Diffs"
summary: "Read-only diff viewer and PNG renderer for agents (optional plugin tool)"
description: "Use the optional Diffs plugin to render before or after text or unified patches as a gateway-hosted diff view, a PNG image, or both."
summary: "Read-only diff viewer and file renderer for agents (optional plugin tool)"
description: "Use the optional Diffs plugin to render before and after text or unified patches as a gateway-hosted diff view, a file (PNG or PDF), or both."
read_when:
- You want agents to show code or markdown edits as diffs
- You want a canvas-ready viewer URL or a rendered diff PNG
- You want a canvas-ready viewer URL or a rendered diff file
- You need controlled, temporary diff artifacts with secure defaults
---
@@ -20,14 +20,14 @@ It accepts either:
It can return:
- a gateway viewer URL for canvas presentation
- a rendered PNG path for message delivery
- a rendered file path (PNG or PDF) for message delivery
- both outputs in one call
## Quick start
1. Enable the plugin.
2. Call `diffs` with `mode: "view"` for canvas-first flows.
3. Call `diffs` with `mode: "image"` for chat/image-first flows.
3. Call `diffs` with `mode: "file"` for chat file delivery flows.
4. Call `diffs` with `mode: "both"` when you need both artifacts.
## Enable the plugin
@@ -50,7 +50,7 @@ It can return:
2. Agent reads `details` fields.
3. Agent either:
- opens `details.viewerUrl` with `canvas present`
- sends `details.imagePath` with `message` using `path` or `filePath`
- sends `details.filePath` with `message` using `path` or `filePath`
- does both
## Input examples
@@ -85,10 +85,14 @@ All fields are optional unless noted:
- `path` (`string`): display filename for before and after mode.
- `lang` (`string`): language override hint for before and after mode.
- `title` (`string`): viewer title override.
- `mode` (`"view" | "image" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
- `mode` (`"view" | "file" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
- `expandUnchanged` (`boolean`): expand unchanged sections.
- `expandUnchanged` (`boolean`): expand unchanged sections when full context is available. Per-call option only (not a plugin default key).
- `fileFormat` (`"png" | "pdf"`): rendered file format. Defaults to plugin default `defaults.fileFormat`.
- `fileQuality` (`"standard" | "hq" | "print"`): quality preset for PNG or PDF rendering.
- `fileScale` (`number`): device scale override (`1`-`4`).
- `fileMaxWidth` (`number`): max render width in CSS pixels (`640`-`2400`).
- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
@@ -117,17 +121,29 @@ Shared fields for modes that create a viewer:
- `fileCount`
- `mode`
Image fields when PNG is rendered:
File fields when PNG or PDF is rendered:
- `imagePath`
- `path` (same value as `imagePath`, for message tool compatibility)
- `imageBytes`
- `filePath`
- `path` (same value as `filePath`, for message tool compatibility)
- `fileBytes`
- `fileFormat`
- `fileQuality`
- `fileScale`
- `fileMaxWidth`
Mode behavior summary:
- `mode: "view"`: viewer fields only.
- `mode: "image"`: image fields only, no viewer artifact.
- `mode: "both"`: viewer fields plus image fields. If screenshot fails, viewer still returns with `imageError`.
- `mode: "file"`: file fields only, no viewer artifact.
- `mode: "both"`: viewer fields plus file fields. If file rendering fails, viewer still returns with `fileError`.
## Collapsed unchanged sections
- The viewer can show rows like `N unmodified lines`.
- Expand controls on those rows are conditional and not guaranteed for every input kind.
- Expand controls appear when the rendered diff has expandable context data, which is typical for before and after input.
- For many unified patch inputs, omitted context bodies are not available in the parsed patch hunks, so the row can appear without expand controls. This is expected behavior.
- `expandUnchanged` applies only when expandable context exists.
## Plugin defaults
@@ -150,6 +166,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: 2,
fileMaxWidth: 960,
mode: "both",
},
},
@@ -170,6 +190,10 @@ Supported defaults:
- `wordWrap`
- `background`
- `theme`
- `fileFormat`
- `fileQuality`
- `fileScale`
- `fileMaxWidth`
- `mode`
Explicit tool parameters override these defaults.
@@ -250,15 +274,15 @@ Viewer hardening:
- 40 failures per 60 seconds
- 60 second lockout (`429 Too Many Requests`)
Image rendering hardening:
File rendering hardening:
- Screenshot browser request routing is deny-by-default.
- Only local viewer assets from `http://127.0.0.1/plugins/diffs/assets/*` are allowed.
- External network requests are blocked.
## Browser requirements for image mode
## Browser requirements for file mode
`mode: "image"` and `mode: "both"` need a Chromium-compatible browser.
`mode: "file"` and `mode: "both"` need a Chromium-compatible browser.
Resolution order:
@@ -271,7 +295,7 @@ Resolution order:
Common failure text:
- `Diff image rendering requires a Chromium-compatible browser...`
- `Diff PNG/PDF rendering requires a Chromium-compatible browser...`
Fix by installing Chrome, Chromium, Edge, or Brave, or setting one of the executable path options above.
@@ -298,6 +322,11 @@ Viewer accessibility issues:
- use `gateway.bind=custom` and `gateway.customBindHost`
- Enable `security.allowRemoteViewer` only when you intend external viewer access.
Unmodified-lines row has no expand button:
- This can happen for patch input when the patch does not carry expandable context.
- This is expected and does not indicate a viewer failure.
Artifact not found:
- Artifact expired due TTL.
@@ -307,10 +336,11 @@ Artifact not found:
## Operational guidance
- Prefer `mode: "view"` for local interactive reviews in canvas.
- Prefer `mode: "image"` for outbound chat channels that need an attachment.
- Prefer `mode: "file"` for outbound chat channels that need an attachment.
- Keep `allowRemoteViewer` disabled unless your deployment requires remote viewer URLs.
- Set explicit short `ttlSeconds` for sensitive diffs.
- Avoid sending secrets in diff input when not required.
- If your channel compresses images aggressively (for example Telegram or WhatsApp), prefer PDF output (`fileFormat: "pdf"`).
Diff rendering engine:

View File

@@ -174,7 +174,7 @@ Optional plugin tools:
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
- [Diffs](/tools/diffs): read-only diff viewer and PNG renderer for before/after text or unified patches.
- [Diffs](/tools/diffs): read-only diff viewer and PNG or PDF file renderer for before/after text or unified patches.
## Tool inventory

View File

@@ -5,25 +5,26 @@ Read-only diff viewer plugin for **OpenClaw** agents.
It gives agents one tool, `diffs`, that can:
- render a gateway-hosted diff viewer for canvas use
- render the same diff to a PNG image
- accept either arbitrary `before`/`after` text or a unified patch
- render the same diff to a file (PNG or PDF)
- accept either arbitrary `before` and `after` text or a unified patch
## What Agents Get
The tool can return:
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
- `details.imagePath`: a local PNG artifact when image rendering is requested
- `details.filePath`: a local rendered artifact path when file rendering is requested
- `details.fileFormat`: the rendered file format (`png` or `pdf`)
This means an agent can:
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
- call `diffs` with `mode=image`, then send the PNG through the normal `message` tool using `path` or `filePath`
- call `diffs` with `mode=file`, then send the file through the normal `message` tool using `path` or `filePath`
- call `diffs` with `mode=both` when it wants both outputs
## Tool Inputs
Before/after:
Before and after:
```json
{
@@ -45,18 +46,22 @@ Patch:
Useful options:
- `mode`: `view`, `image`, or `both`
- `mode`: `view`, `file`, or `both`
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` (default: `dark`)
- `expandUnchanged`: expand unchanged sections
- `path`: display name for before/after input
- `fileFormat`: `png` or `pdf` (default: `png`)
- `fileQuality`: `standard`, `hq`, or `print`
- `fileScale`: device scale override (`1`-`4`)
- `fileMaxWidth`: max width override in CSS pixels (`640`-`2400`)
- `expandUnchanged`: expand unchanged sections (per-call option only, not a plugin default key)
- `path`: display name for before and after input
- `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
Input safety limits:
- `before` / `after`: max 512 KiB each
- `before` and `after`: max 512 KiB each
- `patch`: max 2 MiB
- patch rendering cap: max 128 files / 120,000 lines
@@ -81,6 +86,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: 2,
fileMaxWidth: 960,
mode: "both",
},
},
@@ -101,7 +110,7 @@ Security options:
Open in canvas:
```text
Use the `diffs` tool in `view` mode for this before/after content, then open the returned viewer URL in the canvas.
Use the `diffs` tool in `view` mode for this before and after content, then open the returned viewer URL in the canvas.
Path: docs/example.md
@@ -116,10 +125,10 @@ After:
This is version two.
```
Render a PNG:
Render a file (PNG or PDF):
```text
Use the `diffs` tool in `image` mode for this before/after input. After it returns `details.imagePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff image.
Use the `diffs` tool in `file` mode for this before and after input. After it returns `details.filePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff file.
Path: README.md
@@ -133,7 +142,7 @@ OpenClaw supports plugins and hosted diff views.
Do both:
```text
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered PNG by passing `details.imagePath` to the `message` tool.
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered file by passing `details.filePath` to the `message` tool.
Path: src/demo.ts
@@ -165,5 +174,7 @@ diff --git a/src/example.ts b/src/example.ts
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
- Default viewer URLs use loopback (`127.0.0.1`) unless you set `baseUrl` (or use `gateway.bind=custom` + `gateway.customBindHost`).
- Remote viewer misses are throttled to reduce token-guess abuse.
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
- PNG or PDF rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
- If your delivery channel compresses images heavily (for example Telegram or WhatsApp), prefer `fileFormat: "pdf"` to preserve readability.
- `N unmodified lines` rows may not always include expand controls for patch input, because many patch hunks do not carry full expandable context data.
- Diff rendering is powered by [Diffs](https://diffs.com).

View File

@@ -14,7 +14,7 @@ import { createDiffsTool } from "./src/tool.js";
const plugin = {
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG renderer for agents.",
description: "Read-only diff viewer and PNG/PDF renderer for agents.",
configSchema: diffsPluginConfigSchema,
register(api: OpenClawPluginApi) {
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);

View File

@@ -1,7 +1,7 @@
{
"id": "diffs",
"name": "Diffs",
"description": "Read-only diff viewer and image renderer for agents.",
"description": "Read-only diff viewer and file renderer for agents.",
"uiHints": {
"defaults.fontFamily": {
"label": "Default Font",
@@ -39,9 +39,25 @@
"label": "Default Theme",
"help": "Initial viewer theme."
},
"defaults.fileFormat": {
"label": "Default File Format",
"help": "Rendered file format for file mode (PNG or PDF)."
},
"defaults.fileQuality": {
"label": "Default File Quality",
"help": "Quality preset for PNG/PDF rendering."
},
"defaults.fileScale": {
"label": "Default File Scale",
"help": "Device scale factor used while rendering file artifacts."
},
"defaults.fileMaxWidth": {
"label": "Default File Max Width",
"help": "Maximum file render width in CSS pixels."
},
"defaults.mode": {
"label": "Default Output Mode",
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both."
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both."
},
"security.allowRemoteViewer": {
"label": "Allow Remote Viewer",
@@ -99,9 +115,53 @@
"enum": ["light", "dark"],
"default": "dark"
},
"fileFormat": {
"type": "string",
"enum": ["png", "pdf"],
"default": "png"
},
"format": {
"type": "string",
"enum": ["png", "pdf"]
},
"fileQuality": {
"type": "string",
"enum": ["standard", "hq", "print"],
"default": "standard"
},
"fileScale": {
"type": "number",
"minimum": 1,
"maximum": 4,
"default": 2
},
"fileMaxWidth": {
"type": "number",
"minimum": 640,
"maximum": 2400,
"default": 960
},
"imageFormat": {
"type": "string",
"enum": ["png", "pdf"]
},
"imageQuality": {
"type": "string",
"enum": ["standard", "hq", "print"]
},
"imageScale": {
"type": "number",
"minimum": 1,
"maximum": 4
},
"imageMaxWidth": {
"type": "number",
"minimum": 640,
"maximum": 2400
},
"mode": {
"type": "string",
"enum": ["view", "image", "both"],
"enum": ["view", "image", "file", "both"],
"default": "both"
}
}

View File

@@ -35,7 +35,11 @@ describe("PlaywrightDiffScreenshotter", () => {
});
it("reuses the same browser across renders and closes it after the idle window", async () => {
const pages: Array<{ close: ReturnType<typeof vi.fn> }> = [];
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
@@ -49,11 +53,25 @@ describe("PlaywrightDiffScreenshotter", () => {
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
@@ -75,10 +93,128 @@ describe("PlaywrightDiffScreenshotter", () => {
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "light",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(2);
});
it("renders PDF output when format is pdf", async () => {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
const pdfPath = path.join(rootDir, "preview.pdf");
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
expect(pages[0]?.pdf).toHaveBeenCalledTimes(1);
const pdfCall = pages[0]?.pdf.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
expect(pdfCall).toBeDefined();
expect(pdfCall).not.toHaveProperty("pageRanges");
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
});
it("fails fast when PDF render exceeds size limits", async () => {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages, {
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
});
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
const pdfPath = path.join(rootDir, "oversized.pdf");
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
}),
).rejects.toThrow("Diff frame did not render within image size limits.");
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
expect(pages[0]?.pdf).toHaveBeenCalledTimes(0);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
});
it("fails fast when maxPixels is still exceeded at scale 1", async () => {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
await expect(
screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 1,
maxWidth: 960,
maxPixels: 10,
},
}),
).rejects.toThrow("Diff frame did not render within image size limits.");
expect(pages).toHaveLength(1);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
});
});
function createConfig(): OpenClawConfig {
@@ -89,10 +225,17 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function createMockBrowser(pages: Array<{ close: ReturnType<typeof vi.fn> }>) {
function createMockBrowser(
pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}>,
options?: { boundingBox?: { x: number; y: number; width: number; height: number } },
) {
const browser = {
newPage: vi.fn(async () => {
const page = createMockPage();
const page = createMockPage(options);
pages.push(page);
return page;
}),
@@ -102,20 +245,30 @@ function createMockBrowser(pages: Array<{ close: ReturnType<typeof vi.fn> }>) {
return browser;
}
function createMockPage() {
function createMockPage(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
const box = options?.boundingBox ?? { x: 40, y: 40, width: 640, height: 240 };
const screenshot = vi.fn(async ({ path: screenshotPath }: { path: string }) => {
await fs.writeFile(screenshotPath, Buffer.from("png"));
});
const pdf = vi.fn(async ({ path: pdfPath }: { path: string }) => {
await fs.writeFile(pdfPath, "%PDF-1.7 mock");
});
return {
route: vi.fn(async () => {}),
setContent: vi.fn(async () => {}),
waitForFunction: vi.fn(async () => {}),
evaluate: vi.fn(async () => {}),
evaluate: vi.fn(async () => 1),
emulateMedia: vi.fn(async () => {}),
locator: vi.fn(() => ({
waitFor: vi.fn(async () => {}),
boundingBox: vi.fn(async () => ({ x: 40, y: 40, width: 640, height: 240 })),
boundingBox: vi.fn(async () => box),
})),
setViewportSize: vi.fn(async () => {}),
screenshot: vi.fn(async ({ path: screenshotPath }: { path: string }) => {
await fs.writeFile(screenshotPath, Buffer.from("png"));
}),
screenshot,
pdf,
close: vi.fn(async () => {}),
};
}

View File

@@ -3,14 +3,22 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { chromium } from "playwright-core";
import type { DiffTheme } from "./types.js";
import type { DiffRenderOptions, DiffTheme } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
const DEFAULT_BROWSER_IDLE_MS = 30_000;
const SHARED_BROWSER_KEY = "__default__";
const IMAGE_SIZE_LIMIT_ERROR = "Diff frame did not render within image size limits.";
const PDF_REFERENCE_PAGE_HEIGHT_PX = 1_056;
const MAX_PDF_PAGES = 50;
export type DiffScreenshotter = {
screenshotHtml(params: { html: string; outputPath: string; theme: DiffTheme }): Promise<string>;
screenshotHtml(params: {
html: string;
outputPath: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<string>;
};
type BrowserInstance = Awaited<ReturnType<typeof chromium.launch>>;
@@ -49,6 +57,7 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
html: string;
outputPath: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<string> {
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
const lease = await acquireSharedBrowser({
@@ -56,121 +65,198 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
idleMs: this.browserIdleMs,
});
let page: Awaited<ReturnType<BrowserInstance["newPage"]>> | undefined;
let currentScale = params.image.scale;
const maxRetries = 2;
try {
page = await lease.browser.newPage({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 2,
colorScheme: params.theme,
});
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
await route.continue();
return;
}
let parsed: URL;
try {
parsed = new URL(requestUrl);
} catch {
await route.abort();
return;
}
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
await route.abort();
return;
}
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
await route.abort();
return;
}
const asset = await getServedViewerAsset(parsed.pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
page = await lease.browser.newPage({
viewport: {
width: Math.max(Math.ceil(params.image.maxWidth + 240), 1200),
height: 900,
},
deviceScaleFactor: currentScale,
colorScheme: params.theme,
});
});
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
await page.waitForFunction(
() => {
if (document.documentElement.dataset.openclawDiffsReady === "true") {
return true;
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
await route.continue();
return;
}
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
return (
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
);
let parsed: URL;
try {
parsed = new URL(requestUrl);
} catch {
await route.abort();
return;
}
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
await route.abort();
return;
}
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
await route.abort();
return;
}
const pathname = parsed.pathname;
const asset = await getServedViewerAsset(pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
});
},
{
timeout: 10_000,
},
);
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.evaluate(() => {
const frame = document.querySelector(".oc-frame");
if (frame instanceof HTMLElement) {
frame.dataset.renderMode = "image";
});
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
await page.waitForFunction(
() => {
if (document.documentElement.dataset.openclawDiffsReady === "true") {
return true;
}
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
return (
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
);
});
},
{
timeout: 10_000,
},
);
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.evaluate(() => {
const frame = document.querySelector(".oc-frame");
if (frame instanceof HTMLElement) {
frame.dataset.renderMode = "image";
}
});
const frame = page.locator(".oc-frame");
await frame.waitFor();
const initialBox = await frame.boundingBox();
if (!initialBox) {
throw new Error("Diff frame did not render.");
}
});
const frame = page.locator(".oc-frame");
await frame.waitFor();
const initialBox = await frame.boundingBox();
if (!initialBox) {
throw new Error("Diff frame did not render.");
const isPdf = params.image.format === "pdf";
const padding = isPdf ? 0 : 20;
const clipWidth = Math.ceil(initialBox.width + padding * 2);
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
await page.setViewportSize({
width: Math.max(clipWidth + padding, 900),
height: Math.max(clipHeight + padding, 700),
});
const box = await frame.boundingBox();
if (!box) {
throw new Error("Diff frame was lost after resizing.");
}
if (isPdf) {
await page.emulateMedia({ media: "screen" });
await page.evaluate(() => {
const html = document.documentElement;
const body = document.body;
const frame = document.querySelector(".oc-frame");
html.style.background = "transparent";
body.style.margin = "0";
body.style.padding = "0";
body.style.background = "transparent";
body.style.setProperty("-webkit-print-color-adjust", "exact");
if (frame instanceof HTMLElement) {
frame.style.margin = "0";
}
});
const pdfBox = await frame.boundingBox();
if (!pdfBox) {
throw new Error("Diff frame was lost before PDF render.");
}
const pdfWidth = Math.max(Math.ceil(pdfBox.width), 1);
const pdfHeight = Math.max(Math.ceil(pdfBox.height), 1);
const estimatedPixels = pdfWidth * pdfHeight;
const estimatedPages = Math.ceil(pdfHeight / PDF_REFERENCE_PAGE_HEIGHT_PX);
if (estimatedPixels > params.image.maxPixels || estimatedPages > MAX_PDF_PAGES) {
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
}
await page.pdf({
path: params.outputPath,
width: `${pdfWidth}px`,
height: `${pdfHeight}px`,
printBackground: true,
margin: {
top: "0",
right: "0",
bottom: "0",
left: "0",
},
});
return params.outputPath;
}
const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
// Raw clip in CSS px
const rawX = Math.max(box.x - padding, 0);
const rawY = Math.max(box.y - padding, 0);
const rawRight = rawX + clipWidth;
const rawBottom = rawY + clipHeight;
// Snap to device-pixel grid to avoid soft text from sub-pixel crop
const x = Math.floor(rawX * dpr) / dpr;
const y = Math.floor(rawY * dpr) / dpr;
const right = Math.ceil(rawRight * dpr) / dpr;
const bottom = Math.ceil(rawBottom * dpr) / dpr;
const cssWidth = Math.max(right - x, 1);
const cssHeight = Math.max(bottom - y, 1);
const estimatedPixels = cssWidth * cssHeight * dpr * dpr;
if (estimatedPixels > params.image.maxPixels) {
if (currentScale > 1) {
const maxScaleForPixels = Math.sqrt(params.image.maxPixels / (cssWidth * cssHeight));
const reducedScale = Math.max(
1,
Math.round(Math.min(currentScale, maxScaleForPixels) * 100) / 100,
);
if (reducedScale < currentScale - 0.01 && attempt < maxRetries) {
await page.close().catch(() => {});
page = undefined;
currentScale = reducedScale;
continue;
}
}
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
}
await page.screenshot({
path: params.outputPath,
type: "png",
scale: "device",
clip: {
x,
y,
width: cssWidth,
height: cssHeight,
},
});
return params.outputPath;
}
const padding = 20;
const clipWidth = Math.ceil(initialBox.width + padding * 2);
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
await page.setViewportSize({
width: Math.max(clipWidth + padding, 900),
height: Math.max(clipHeight + padding, 700),
});
const box = await frame.boundingBox();
if (!box) {
throw new Error("Diff frame was lost after resizing.");
}
const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
// Raw clip in CSS px
const rawX = Math.max(box.x - padding, 0);
const rawY = Math.max(box.y - padding, 0);
const rawRight = rawX + clipWidth;
const rawBottom = rawY + clipHeight;
// Snap to device-pixel grid to avoid soft text from sub-pixel crop
const x = Math.floor(rawX * dpr) / dpr;
const y = Math.floor(rawY * dpr) / dpr;
const right = Math.ceil(rawRight * dpr) / dpr;
const bottom = Math.ceil(rawBottom * dpr) / dpr;
await page.screenshot({
path: params.outputPath,
type: "png",
scale: "device",
clip: {
x,
y,
width: right - x,
height: bottom - y,
},
});
return params.outputPath;
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
} catch (error) {
if (error instanceof Error && error.message === IMAGE_SIZE_LIMIT_ERROR) {
throw error;
}
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Diff image rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
`Diff PNG/PDF rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
);
} finally {
await page?.close().catch(() => {});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_DIFFS_PLUGIN_SECURITY,
DEFAULT_DIFFS_TOOL_DEFAULTS,
resolveDiffImageRenderOptions,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
} from "./config.js";
@@ -24,7 +25,11 @@ describe("resolveDiffsPluginDefaults", () => {
wordWrap: false,
background: false,
theme: "light",
mode: "view",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
},
}),
).toEqual({
@@ -37,7 +42,11 @@ describe("resolveDiffsPluginDefaults", () => {
wordWrap: false,
background: false,
theme: "light",
mode: "view",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
});
});
@@ -74,6 +83,88 @@ describe("resolveDiffsPluginDefaults", () => {
lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
});
});
it("derives file defaults from quality preset and clamps explicit overrides", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
fileQuality: "print",
},
}),
).toMatchObject({
fileQuality: "print",
fileScale: 3,
fileMaxWidth: 1400,
});
expect(
resolveDiffsPluginDefaults({
defaults: {
fileQuality: "hq",
fileScale: 99,
fileMaxWidth: 99999,
},
}),
).toMatchObject({
fileQuality: "hq",
fileScale: 4,
fileMaxWidth: 2400,
});
});
it("falls back to png for invalid file format defaults", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
fileFormat: "invalid" as "png",
},
}),
).toMatchObject({
fileFormat: "png",
});
});
it("resolves file render format from defaults and explicit overrides", () => {
const defaults = resolveDiffsPluginDefaults({
defaults: {
fileFormat: "pdf",
},
});
expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
});
it("accepts format as a config alias for fileFormat", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
format: "pdf",
},
}),
).toMatchObject({
fileFormat: "pdf",
});
});
it("accepts image* config aliases for backward compatibility", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
imageFormat: "pdf",
imageQuality: "hq",
imageScale: 2.2,
imageMaxWidth: 1024,
},
}),
).toMatchObject({
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1024,
});
});
});
describe("resolveDiffsPluginSecurity", () => {

View File

@@ -1,12 +1,17 @@
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_INDICATORS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffFileDefaults,
type DiffImageQualityPreset,
type DiffIndicators,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffPresentationDefaults,
type DiffTheme,
type DiffToolDefaults,
@@ -23,6 +28,16 @@ type DiffsPluginConfig = {
wordWrap?: boolean;
background?: boolean;
theme?: DiffTheme;
fileFormat?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
format?: DiffOutputFormat;
// Backward-compatible aliases retained for existing configs.
imageFormat?: DiffOutputFormat;
imageQuality?: DiffImageQualityPreset;
imageScale?: number;
imageMaxWidth?: number;
mode?: DiffMode;
};
security?: {
@@ -30,6 +45,27 @@ type DiffsPluginConfig = {
};
};
const DEFAULT_IMAGE_QUALITY_PROFILES = {
standard: {
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
hq: {
scale: 2.5,
maxWidth: 1200,
maxPixels: 14_000_000,
},
print: {
scale: 3,
maxWidth: 1400,
maxPixels: 24_000_000,
},
} as const satisfies Record<
DiffImageQualityPreset,
{ scale: number; maxWidth: number; maxPixels: number }
>;
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
fontFamily: "Fira Code",
fontSize: 15,
@@ -40,6 +76,10 @@ export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: DEFAULT_IMAGE_QUALITY_PROFILES.standard.scale,
fileMaxWidth: DEFAULT_IMAGE_QUALITY_PROFILES.standard.maxWidth,
mode: "both",
};
@@ -93,6 +133,50 @@ const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
enum: [...DIFF_THEMES],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.theme,
},
fileFormat: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat,
},
format: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
},
fileQuality: {
type: "string",
enum: [...DIFF_IMAGE_QUALITY_PRESETS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality,
},
fileScale: {
type: "number",
minimum: 1,
maximum: 4,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileScale,
},
fileMaxWidth: {
type: "number",
minimum: 640,
maximum: 2400,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileMaxWidth,
},
imageFormat: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
},
imageQuality: {
type: "string",
enum: [...DIFF_IMAGE_QUALITY_PRESETS],
},
imageScale: {
type: "number",
minimum: 1,
maximum: 4,
},
imageMaxWidth: {
type: "number",
minimum: 640,
maximum: 2400,
},
mode: {
type: "string",
enum: [...DIFF_MODES],
@@ -142,6 +226,9 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
}
const fileQuality = normalizeFileQuality(defaults.fileQuality ?? defaults.imageQuality);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[fileQuality];
return {
fontFamily: normalizeFontFamily(defaults.fontFamily),
fontSize: normalizeFontSize(defaults.fontSize),
@@ -152,6 +239,13 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
wordWrap: defaults.wordWrap !== false,
background: defaults.background !== false,
theme: normalizeTheme(defaults.theme),
fileFormat: normalizeFileFormat(defaults.fileFormat ?? defaults.imageFormat ?? defaults.format),
fileQuality,
fileScale: normalizeFileScale(defaults.fileScale ?? defaults.imageScale, profile.scale),
fileMaxWidth: normalizeFileMaxWidth(
defaults.fileMaxWidth ?? defaults.imageMaxWidth,
profile.maxWidth,
),
mode: normalizeMode(defaults.mode),
};
}
@@ -230,6 +324,80 @@ function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
}
function normalizeFileFormat(fileFormat?: DiffOutputFormat): DiffOutputFormat {
return fileFormat && DIFF_OUTPUT_FORMATS.includes(fileFormat)
? fileFormat
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat;
}
function normalizeFileQuality(fileQuality?: DiffImageQualityPreset): DiffImageQualityPreset {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality)
? fileQuality
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality;
}
function normalizeFileScale(fileScale: number | undefined, fallback: number): number {
if (fileScale === undefined || !Number.isFinite(fileScale)) {
return fallback;
}
const rounded = Math.round(fileScale * 100) / 100;
return Math.min(Math.max(rounded, 1), 4);
}
function normalizeFileMaxWidth(fileMaxWidth: number | undefined, fallback: number): number {
if (fileMaxWidth === undefined || !Number.isFinite(fileMaxWidth)) {
return fallback;
}
const rounded = Math.round(fileMaxWidth);
return Math.min(Math.max(rounded, 640), 2400);
}
function normalizeMode(mode?: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
}
export function resolveDiffImageRenderOptions(params: {
defaults: DiffFileDefaults;
fileFormat?: DiffOutputFormat;
format?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
imageFormat?: DiffOutputFormat;
imageQuality?: DiffImageQualityPreset;
imageScale?: number;
imageMaxWidth?: number;
}): {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
scale: number;
maxWidth: number;
maxPixels: number;
} {
const format = normalizeFileFormat(
params.fileFormat ?? params.imageFormat ?? params.format ?? params.defaults.fileFormat,
);
const qualityOverrideProvided =
params.fileQuality !== undefined || params.imageQuality !== undefined;
const qualityPreset = normalizeFileQuality(
params.fileQuality ?? params.imageQuality ?? params.defaults.fileQuality,
);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[qualityPreset];
const scale = normalizeFileScale(
params.fileScale ?? params.imageScale,
qualityOverrideProvided ? profile.scale : params.defaults.fileScale,
);
const maxWidth = normalizeFileMaxWidth(
params.fileMaxWidth ?? params.imageMaxWidth,
qualityOverrideProvided ? profile.maxWidth : params.defaults.fileMaxWidth,
);
return {
format,
qualityPreset,
scale,
maxWidth,
maxPixels: profile.maxPixels,
};
}

View File

@@ -2,9 +2,10 @@ export const DIFFS_AGENT_GUIDANCE = [
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
"The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.",
"Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.",
"Use `mode=image` when you need a rendered PNG. The tool result includes `details.imagePath` for the generated file.",
"When you need to deliver the PNG to a user or channel, do not rely on the raw tool-result image renderer. Instead, call the `message` tool and pass `details.imagePath` through `path` or `filePath`.",
"Use `mode=both` when you want both the gateway viewer URL and the PNG artifact.",
"Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.",
"For large or high-fidelity files, use `fileQuality` (`standard`|`hq`|`print`) and optionally override `fileScale`/`fileMaxWidth`.",
"When you need to deliver the rendered file to a user or channel, do not rely on the raw tool-result renderer. Instead, call the `message` tool and pass `details.filePath` through `path` or `filePath`.",
"Use `mode=both` when you want both the gateway viewer URL and the rendered artifact.",
"If the user has configured diffs plugin defaults, prefer omitting `mode`, `theme`, `layout`, and related presentation options unless you need to override them for this specific diff.",
"Include `path` for before/after text when you know the file name.",
].join("\n");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
@@ -13,6 +13,7 @@ describe("renderDiffDocument", () => {
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
@@ -26,6 +27,7 @@ describe("renderDiffDocument", () => {
expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"');
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
@@ -61,6 +63,11 @@ describe("renderDiffDocument", () => {
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
@@ -68,6 +75,7 @@ describe("renderDiffDocument", () => {
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
@@ -90,6 +98,7 @@ describe("renderDiffDocument", () => {
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),

View File

@@ -197,6 +197,7 @@ function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["presentation"]["theme"];
imageMaxWidth: number;
runtimeMode: "viewer" | "image";
}): string {
return `<!doctype html>
@@ -211,12 +212,18 @@ function buildHtmlDocument(params: {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
html {
background: #05070b;
}
body {
margin: 0;
min-height: 100vh;
padding: 22px;
font-family:
"Fira Code",
@@ -239,7 +246,7 @@ function buildHtmlDocument(params: {
}
.oc-frame[data-render-mode="image"] {
max-width: 960px;
max-width: ${Math.max(640, Math.round(params.imageMaxWidth))}px;
}
[data-openclaw-diff-root] {
@@ -407,12 +414,14 @@ export async function renderDiffDocument(
title,
bodyHtml: rendered.viewerBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "viewer",
}),
imageHtml: buildHtmlDocument({
title,
bodyHtml: rendered.imageBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "image",
}),
title,

View File

@@ -49,7 +49,7 @@ describe("DiffArtifactStore", () => {
expect(loaded).toBeNull();
});
it("updates the stored image path", async () => {
it("updates the stored file path", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
@@ -57,12 +57,13 @@ describe("DiffArtifactStore", () => {
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.imagePath).toBe(imagePath);
const filePath = store.allocateFilePath(artifact.id);
const updated = await store.updateFilePath(artifact.id, filePath);
expect(updated.filePath).toBe(filePath);
expect(updated.imagePath).toBe(filePath);
});
it("rejects image paths that escape the store root", async () => {
it("rejects file paths that escape the store root", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
@@ -70,7 +71,7 @@ describe("DiffArtifactStore", () => {
fileCount: 1,
});
await expect(store.updateImagePath(artifact.id, "../outside.png")).rejects.toThrow(
await expect(store.updateFilePath(artifact.id, "../outside.png")).rejects.toThrow(
"escapes store root",
);
});
@@ -91,10 +92,62 @@ describe("DiffArtifactStore", () => {
await expect(store.readHtml(artifact.id)).rejects.toThrow("escapes store root");
});
it("allocates standalone image paths outside artifact metadata", async () => {
const imagePath = store.allocateStandaloneImagePath();
expect(imagePath).toMatch(/preview\.png$/);
expect(imagePath).toContain(rootDir);
it("creates standalone file artifacts with managed metadata", async () => {
const standalone = await store.createStandaloneFileArtifact();
expect(standalone.filePath).toMatch(/preview\.png$/);
expect(standalone.filePath).toContain(rootDir);
expect(Date.parse(standalone.expiresAt)).toBeGreaterThan(Date.now());
});
it("expires standalone file artifacts using ttl metadata", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
const standalone = await store.createStandaloneFileArtifact({
format: "png",
ttlMs: 1_000,
});
await fs.writeFile(standalone.filePath, Buffer.from("png"));
vi.setSystemTime(new Date(now.getTime() + 2_000));
await store.cleanupExpired();
await expect(fs.stat(path.dirname(standalone.filePath))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("supports image path aliases for backward compatibility", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id, "pdf");
expect(imagePath).toMatch(/preview\.pdf$/);
const standalone = await store.createStandaloneFileArtifact();
expect(standalone.filePath).toMatch(/preview\.png$/);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.filePath).toBe(imagePath);
expect(updated.imagePath).toBe(imagePath);
});
it("allocates PDF file paths when format is pdf", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const artifactPdf = store.allocateFilePath(artifact.id, "pdf");
const standalonePdf = await store.createStandaloneFileArtifact({ format: "pdf" });
expect(artifactPdf).toMatch(/preview\.pdf$/);
expect(standalonePdf.filePath).toMatch(/preview\.pdf$/);
});
it("throttles cleanup sweeps across repeated artifact creation", async () => {

View File

@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginLogger } from "openclaw/plugin-sdk";
import type { DiffArtifactMeta } from "./types.js";
import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js";
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
@@ -18,6 +18,21 @@ type CreateArtifactParams = {
ttlMs?: number;
};
type CreateStandaloneFileArtifactParams = {
format?: DiffOutputFormat;
ttlMs?: number;
};
type StandaloneFileMeta = {
kind: "standalone_file";
id: string;
createdAt: string;
expiresAt: string;
filePath: string;
};
type ArtifactMetaFileName = "meta.json" | "file-meta.json";
export class DiffArtifactStore {
private readonly rootDir: string;
private readonly logger?: PluginLogger;
@@ -87,27 +102,61 @@ export class DiffArtifactStore {
return await fs.readFile(htmlPath, "utf8");
}
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
async updateFilePath(id: string, filePath: string): Promise<DiffArtifactMeta> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
const normalizedImagePath = this.normalizeStoredPath(imagePath, "imagePath");
const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath");
const next: DiffArtifactMeta = {
...meta,
imagePath: normalizedImagePath,
filePath: normalizedFilePath,
imagePath: normalizedFilePath,
};
await this.writeMeta(next);
return next;
}
allocateImagePath(id: string): string {
return path.join(this.artifactDir(id), "preview.png");
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
return this.updateFilePath(id, imagePath);
}
allocateStandaloneImagePath(): string {
allocateFilePath(id: string, format: DiffOutputFormat = "png"): string {
return path.join(this.artifactDir(id), `preview.${format}`);
}
async createStandaloneFileArtifact(
params: CreateStandaloneFileArtifactParams = {},
): Promise<{ id: string; filePath: string; expiresAt: string }> {
await this.ensureRoot();
const id = crypto.randomBytes(10).toString("hex");
return path.join(this.artifactDir(id), "preview.png");
const artifactDir = this.artifactDir(id);
const format = params.format ?? "png";
const filePath = path.join(artifactDir, `preview.${format}`);
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + ttlMs).toISOString();
const meta: StandaloneFileMeta = {
kind: "standalone_file",
id,
createdAt: createdAt.toISOString(),
expiresAt,
filePath: this.normalizeStoredPath(filePath, "filePath"),
};
await fs.mkdir(artifactDir, { recursive: true });
await this.writeStandaloneMeta(meta);
this.scheduleCleanup();
return {
id,
filePath: meta.filePath,
expiresAt: meta.expiresAt,
};
}
allocateImagePath(id: string, format: DiffOutputFormat = "png"): string {
return this.allocateFilePath(id, format);
}
scheduleCleanup(): void {
@@ -132,6 +181,14 @@ export class DiffArtifactStore {
return;
}
const standaloneMeta = await this.readStandaloneMeta(id);
if (standaloneMeta) {
if (isExpired(standaloneMeta)) {
await this.deleteArtifact(id);
}
return;
}
const artifactPath = this.artifactDir(id);
const stat = await fs.stat(artifactPath).catch(() => null);
if (!stat) {
@@ -173,23 +230,76 @@ export class DiffArtifactStore {
return this.resolveWithinRoot(id);
}
private metaPath(id: string): string {
return path.join(this.artifactDir(id), "meta.json");
}
private async writeMeta(meta: DiffArtifactMeta): Promise<void> {
await fs.writeFile(this.metaPath(meta.id), JSON.stringify(meta, null, 2), "utf8");
await this.writeJsonMeta(meta.id, "meta.json", meta);
}
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact");
if (!parsed) {
return null;
}
return parsed as DiffArtifactMeta;
}
private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise<void> {
await this.writeJsonMeta(meta.id, "file-meta.json", meta);
}
private async readStandaloneMeta(id: string): Promise<StandaloneFileMeta | null> {
const parsed = await this.readJsonMeta(id, "file-meta.json", "standalone diff");
if (!parsed) {
return null;
}
try {
const raw = await fs.readFile(this.metaPath(id), "utf8");
return JSON.parse(raw) as DiffArtifactMeta;
const value = parsed as Partial<StandaloneFileMeta>;
if (
value.kind !== "standalone_file" ||
typeof value.id !== "string" ||
typeof value.createdAt !== "string" ||
typeof value.expiresAt !== "string" ||
typeof value.filePath !== "string"
) {
return null;
}
return {
kind: value.kind,
id: value.id,
createdAt: value.createdAt,
expiresAt: value.expiresAt,
filePath: this.normalizeStoredPath(value.filePath, "filePath"),
};
} catch (error) {
this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`);
return null;
}
}
private metaFilePath(id: string, fileName: ArtifactMetaFileName): string {
return path.join(this.artifactDir(id), fileName);
}
private async writeJsonMeta(
id: string,
fileName: ArtifactMetaFileName,
data: unknown,
): Promise<void> {
await fs.writeFile(this.metaFilePath(id, fileName), JSON.stringify(data, null, 2), "utf8");
}
private async readJsonMeta(
id: string,
fileName: ArtifactMetaFileName,
context: string,
): Promise<unknown | null> {
try {
const raw = await fs.readFile(this.metaFilePath(id, fileName), "utf8");
return JSON.parse(raw) as unknown;
} catch (error) {
if (isFileNotFound(error)) {
return null;
}
this.logger?.warn(`Failed to read diff artifact metadata for ${id}: ${String(error)}`);
this.logger?.warn(`Failed to read ${context} metadata for ${id}: ${String(error)}`);
return null;
}
}
@@ -235,7 +345,7 @@ function normalizeTtlMs(value?: number): number {
return Math.min(rounded, MAX_TTL_MS);
}
function isExpired(meta: DiffArtifactMeta): boolean {
function isExpired(meta: { expiresAt: string }): boolean {
const expiresAt = Date.parse(meta.expiresAt);
if (!Number.isFinite(expiresAt)) {
return true;

View File

@@ -39,9 +39,44 @@ describe("diffs tool", () => {
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
});
it("does not expose reserved format in the tool schema", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const parameters = tool.parameters as { properties?: Record<string, unknown> };
expect(parameters.properties).toBeDefined();
expect(parameters.properties).not.toHaveProperty("format");
});
it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = createScreenshotter();
const screenshotter = {
screenshotHtml: vi.fn(
async ({
html,
outputPath,
image,
}: {
html: string;
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
expect(image).toMatchObject({
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
});
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
@@ -57,14 +92,236 @@ describe("diffs tool", () => {
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff image generated at:");
expect(readTextContent(result, 0)).toContain("Diff PNG generated at:");
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).filePath).toBeDefined();
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
expect((result?.details as Record<string, unknown>).format).toBe("png");
expect((result?.details as Record<string, unknown>).fileQuality).toBe("standard");
expect((result?.details as Record<string, unknown>).imageQuality).toBe("standard");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2);
expect((result?.details as Record<string, unknown>).imageScale).toBe(2);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(960);
expect((result?.details as Record<string, unknown>).imageMaxWidth).toBe(960);
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
it("renders PDF output when fileFormat is pdf", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image.format).toBe("pdf");
expect(outputPath).toMatch(/preview\.pdf$/);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2b", {
before: "one\n",
after: "two\n",
mode: "image",
fileFormat: "pdf",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PDF generated at:");
expect((result?.details as Record<string, unknown>).format).toBe("pdf");
expect((result?.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("accepts mode=file as an alias for file artifact rendering", async () => {
const screenshotter = {
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
expect(outputPath).toMatch(/preview\.png$/);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2c", {
before: "one\n",
after: "two\n",
mode: "file",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).mode).toBe("file");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
});
it("honors ttlSeconds for artifact-only file output", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
try {
const screenshotter = {
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2c-ttl", {
before: "one\n",
after: "two\n",
mode: "file",
ttlSeconds: 1,
});
const filePath = (result?.details as Record<string, unknown>).filePath as string;
await expect(fs.stat(filePath)).resolves.toBeDefined();
vi.setSystemTime(new Date(now.getTime() + 2_000));
await store.cleanupExpired();
await expect(fs.stat(filePath)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
vi.useRealTimers();
}
});
it("accepts image* tool options for backward compatibility", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image).toMatchObject({
qualityPreset: "hq",
scale: 2.4,
maxWidth: 1100,
});
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2legacy", {
before: "one\n",
after: "two\n",
mode: "file",
imageQuality: "hq",
imageScale: 2.4,
imageMaxWidth: 1100,
});
expect((result?.details as Record<string, unknown>).fileQuality).toBe("hq");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2.4);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(1100);
});
it("accepts deprecated format alias for fileFormat", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image.format).toBe("pdf");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2format", {
before: "one\n",
after: "two\n",
mode: "file",
format: "pdf",
});
expect((result?.details as Record<string, unknown>).fileFormat).toBe("pdf");
expect((result?.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("honors defaults.mode=file when mode is omitted", async () => {
const screenshotter = {
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "file",
},
screenshotter,
});
const result = await tool.execute?.("tool-2d", {
before: "one\n",
after: "two\n",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).mode).toBe("file");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
});
it("falls back to view output when both mode cannot render an image", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -84,7 +341,8 @@ describe("diffs tool", () => {
});
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("Image rendering failed");
expect(readTextContent(result, 0)).toContain("File rendering failed");
expect((result?.details as Record<string, unknown>).fileError).toBe("browser missing");
expect((result?.details as Record<string, unknown>).imageError).toBe("browser missing");
});
@@ -105,23 +363,6 @@ describe("diffs tool", () => {
).rejects.toThrow("Invalid baseUrl");
});
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-large-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
it("rejects oversized patch payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -130,13 +371,30 @@ describe("diffs tool", () => {
});
await expect(
tool.execute?.("tool-large-patch", {
tool.execute?.("tool-oversize-patch", {
patch: "x".repeat(2_100_000),
mode: "view",
}),
).rejects.toThrow("patch exceeds maximum size");
});
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-oversize-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
it("uses configured defaults when tool params omit them", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -171,7 +429,30 @@ describe("diffs tool", () => {
});
it("prefers explicit tool params over configured defaults", async () => {
const screenshotter = createScreenshotter();
const screenshotter = {
screenshotHtml: vi.fn(
async ({
html,
outputPath,
image,
}: {
html: string;
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
expect(image).toMatchObject({
format: "png",
qualityPreset: "print",
scale: 2.75,
maxWidth: 1320,
});
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
@@ -180,6 +461,9 @@ describe("diffs tool", () => {
mode: "view",
theme: "light",
layout: "split",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1180,
},
screenshotter,
});
@@ -190,10 +474,17 @@ describe("diffs tool", () => {
mode: "both",
theme: "dark",
layout: "unified",
fileQuality: "print",
fileScale: 2.75,
fileMaxWidth: 1320,
});
expect((result?.details as Record<string, unknown>).mode).toBe("both");
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).format).toBe("png");
expect((result?.details as Record<string, unknown>).fileQuality).toBe("print");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2.75);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
const html = await store.readHtml(id);
@@ -242,14 +533,3 @@ function readTextContent(result: unknown, index: number): string {
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}
function createScreenshotter() {
return {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
}

View File

@@ -2,16 +2,21 @@ import fs from "node:fs/promises";
import { Static, Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import type { DiffToolDefaults } from "./types.js";
import type { DiffRenderOptions, DiffToolDefaults } from "./types.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffInput,
type DiffImageQualityPreset,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffTheme,
} from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
@@ -59,10 +64,46 @@ const DiffsToolSchema = Type.Object(
}),
),
mode: Type.Optional(
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
stringEnum(DIFF_MODES, "Output mode: view, file, image, or both. Default: both."),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
fileQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "File quality preset: standard, hq, or print."),
),
fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Rendered file format: png or pdf.")),
fileScale: Type.Optional(
Type.Number({
description: "Optional rendered-file device scale factor override (1-4).",
minimum: 1,
maximum: 4,
}),
),
fileMaxWidth: Type.Optional(
Type.Number({
description: "Optional rendered-file max width in CSS pixels (640-2400).",
minimum: 640,
maximum: 2400,
}),
),
imageQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "Deprecated alias for fileQuality."),
),
imageFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.")),
imageScale: Type.Optional(
Type.Number({
description: "Deprecated alias for fileScale.",
minimum: 1,
maximum: 4,
}),
),
imageMaxWidth: Type.Optional(
Type.Number({
description: "Deprecated alias for fileMaxWidth.",
minimum: 640,
maximum: 2400,
}),
),
expandUnchanged: Type.Optional(
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
),
@@ -84,6 +125,10 @@ const DiffsToolSchema = Type.Object(
);
type DiffsToolParams = Static<typeof DiffsToolSchema>;
type DiffsToolRawParams = DiffsToolParams & {
// Keep backward compatibility for direct calls that still pass `format`.
format?: DiffOutputFormat;
};
export function createDiffsTool(params: {
api: OpenClawPluginApi;
@@ -95,16 +140,25 @@ export function createDiffsTool(params: {
name: "diffs",
label: "Diffs",
description:
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG.",
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG or PDF.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolParams;
const toolParams = rawParams as DiffsToolRawParams;
const input = normalizeDiffInput(toolParams);
const mode = normalizeMode(toolParams.mode, params.defaults.mode);
const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
const image = resolveDiffImageRenderOptions({
defaults: params.defaults,
fileFormat: normalizeOutputFormat(
toolParams.fileFormat ?? toolParams.imageFormat ?? toolParams.format,
),
fileQuality: normalizeFileQuality(toolParams.fileQuality ?? toolParams.imageQuality),
fileScale: toolParams.fileScale ?? toolParams.imageScale,
fileMaxWidth: toolParams.fileMaxWidth ?? toolParams.imageMaxWidth,
});
const rendered = await renderDiffDocument(input, {
presentation: {
@@ -112,29 +166,30 @@ export function createDiffsTool(params: {
layout,
theme,
},
image,
expandUnchanged,
});
const screenshotter =
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
if (mode === "image") {
const imagePath = params.store.allocateStandaloneImagePath();
await screenshotter.screenshotHtml({
if (isArtifactOnlyMode(mode)) {
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
html: rendered.imageHtml,
outputPath: imagePath,
theme,
image,
ttlMs,
});
const imageStats = await fs.stat(imagePath);
params.store.scheduleCleanup();
return {
content: [
{
type: "text",
text:
`Diff image generated at: ${imagePath}\n` +
"Use the `message` tool with `path` or `filePath` to send the PNG.",
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
"Use the `message` tool with `path` or `filePath` to send this file.",
},
],
details: {
@@ -142,9 +197,19 @@ export function createDiffsTool(params: {
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
mode,
imagePath,
path: imagePath,
imageBytes: imageStats.size,
filePath: artifactFile.path,
imagePath: artifactFile.path,
path: artifactFile.path,
fileBytes: artifactFile.bytes,
imageBytes: artifactFile.bytes,
format: image.format,
fileFormat: image.format,
fileQuality: image.qualityPreset,
imageQuality: image.qualityPreset,
fileScale: image.scale,
imageScale: image.scale,
fileMaxWidth: image.maxWidth,
imageMaxWidth: image.maxWidth,
},
};
}
@@ -187,14 +252,15 @@ export function createDiffsTool(params: {
}
try {
const imagePath = params.store.allocateImagePath(artifact.id);
await screenshotter.screenshotHtml({
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
artifactId: artifact.id,
html: rendered.imageHtml,
outputPath: imagePath,
theme,
image,
});
await params.store.updateImagePath(artifact.id, imagePath);
const imageStats = await fs.stat(imagePath);
await params.store.updateFilePath(artifact.id, artifactFile.path);
return {
content: [
@@ -202,15 +268,25 @@ export function createDiffsTool(params: {
type: "text",
text:
`Diff viewer: ${viewerUrl}\n` +
`Diff image generated at: ${imagePath}\n` +
"Use the `message` tool with `path` or `filePath` to send the PNG.",
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
"Use the `message` tool with `path` or `filePath` to send this file.",
},
],
details: {
...baseDetails,
imagePath,
path: imagePath,
imageBytes: imageStats.size,
filePath: artifactFile.path,
imagePath: artifactFile.path,
path: artifactFile.path,
fileBytes: artifactFile.bytes,
imageBytes: artifactFile.bytes,
format: image.format,
fileFormat: image.format,
fileQuality: image.qualityPreset,
imageQuality: image.qualityPreset,
fileScale: image.scale,
imageScale: image.scale,
fileMaxWidth: image.maxWidth,
imageMaxWidth: image.maxWidth,
},
};
} catch (error) {
@@ -221,11 +297,12 @@ export function createDiffsTool(params: {
type: "text",
text:
`Diff viewer ready.\n${viewerUrl}\n` +
`Image rendering failed: ${error instanceof Error ? error.message : String(error)}`,
`File rendering failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: {
...baseDetails,
fileError: error instanceof Error ? error.message : String(error),
imageError: error instanceof Error ? error.message : String(error),
},
};
@@ -236,6 +313,52 @@ export function createDiffsTool(params: {
};
}
function normalizeFileQuality(
fileQuality: DiffImageQualityPreset | undefined,
): DiffImageQualityPreset | undefined {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality) ? fileQuality : undefined;
}
function normalizeOutputFormat(format: DiffOutputFormat | undefined): DiffOutputFormat | undefined {
return format && DIFF_OUTPUT_FORMATS.includes(format) ? format : undefined;
}
function isArtifactOnlyMode(mode: DiffMode): mode is "image" | "file" {
return mode === "image" || mode === "file";
}
async function renderDiffArtifactFile(params: {
screenshotter: DiffScreenshotter;
store: DiffArtifactStore;
artifactId?: string;
html: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
ttlMs?: number;
}): Promise<{ path: string; bytes: number }> {
const outputPath = params.artifactId
? params.store.allocateFilePath(params.artifactId, params.image.format)
: (
await params.store.createStandaloneFileArtifact({
format: params.image.format,
ttlMs: params.ttlMs,
})
).filePath;
await params.screenshotter.screenshotHtml({
html: params.html,
outputPath,
theme: params.theme,
image: params.image,
});
const stats = await fs.stat(outputPath);
return {
path: outputPath,
bytes: stats.size,
};
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;
@@ -285,6 +408,13 @@ function normalizeDiffInput(params: DiffsToolParams): DiffInput {
};
}
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}
function normalizeBaseUrl(baseUrl?: string): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
@@ -322,10 +452,3 @@ class PluginToolInputError extends Error {
this.name = "ToolInputError";
}
}
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}

View File

@@ -1,14 +1,18 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
export const DIFF_LAYOUTS = ["unified", "split"] as const;
export const DIFF_MODES = ["view", "image", "both"] as const;
export const DIFF_MODES = ["view", "image", "file", "both"] as const;
export const DIFF_THEMES = ["light", "dark"] as const;
export const DIFF_INDICATORS = ["bars", "classic", "none"] as const;
export const DIFF_IMAGE_QUALITY_PRESETS = ["standard", "hq", "print"] as const;
export const DIFF_OUTPUT_FORMATS = ["png", "pdf"] as const;
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
export type DiffMode = (typeof DIFF_MODES)[number];
export type DiffTheme = (typeof DIFF_THEMES)[number];
export type DiffIndicators = (typeof DIFF_INDICATORS)[number];
export type DiffImageQualityPreset = (typeof DIFF_IMAGE_QUALITY_PRESETS)[number];
export type DiffOutputFormat = (typeof DIFF_OUTPUT_FORMATS)[number];
export type DiffPresentationDefaults = {
fontFamily: string;
@@ -22,10 +26,18 @@ export type DiffPresentationDefaults = {
theme: DiffTheme;
};
export type DiffToolDefaults = DiffPresentationDefaults & {
mode: DiffMode;
export type DiffFileDefaults = {
fileFormat: DiffOutputFormat;
fileQuality: DiffImageQualityPreset;
fileScale: number;
fileMaxWidth: number;
};
export type DiffToolDefaults = DiffPresentationDefaults &
DiffFileDefaults & {
mode: DiffMode;
};
export type BeforeAfterDiffInput = {
kind: "before_after";
before: string;
@@ -45,6 +57,13 @@ export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
export type DiffRenderOptions = {
presentation: DiffPresentationDefaults;
image: {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
scale: number;
maxWidth: number;
maxPixels: number;
};
expandUnchanged: boolean;
};
@@ -90,6 +109,7 @@ export type DiffArtifactMeta = {
fileCount: number;
viewerPath: string;
htmlPath: string;
filePath?: string;
imagePath?: string;
};

View File

@@ -123,6 +123,25 @@ vi.mock("../agents/openclaw-tools.js", () => {
return { ok: true };
},
},
{
name: "diffs_compat_test",
parameters: {
type: "object",
properties: {
mode: { type: "string" },
fileFormat: { type: "string" },
},
additionalProperties: false,
},
execute: async (_toolCallId: string, args: unknown) => {
const input = (args ?? {}) as Record<string, unknown>;
return {
ok: true,
observedFormat: input.format,
observedFileFormat: input.fileFormat,
};
},
},
];
return {
@@ -546,4 +565,25 @@ describe("POST /tools/invoke", () => {
expect(crashBody.error?.type).toBe("tool_error");
expect(crashBody.error?.message).toBe("tool execution failed");
});
it("passes deprecated format alias through invoke payloads even when schema omits it", async () => {
cfg = {
...cfg,
agents: {
list: [{ id: "main", default: true, tools: { allow: ["diffs_compat_test"] } }],
},
};
const res = await invokeToolAuthed({
tool: "diffs_compat_test",
args: { mode: "file", format: "pdf" },
sessionKey: "main",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.result?.observedFormat).toBe("pdf");
expect(body.result?.observedFileFormat).toBeUndefined();
});
});