diff --git a/CHANGELOG.md b/CHANGELOG.md index edbd54a8663..f4aaaf4bebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md index 1534c227b0e..669470005dd 100644 --- a/docs/tools/diffs.md +++ b/docs/tools/diffs.md @@ -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: diff --git a/docs/tools/index.md b/docs/tools/index.md index 676671a07f6..0d3a7094870 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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 diff --git a/extensions/diffs/README.md b/extensions/diffs/README.md index f6c5b154c8d..a415a502f68 100644 --- a/extensions/diffs/README.md +++ b/extensions/diffs/README.md @@ -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). diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index 7cc66938a3a..a6879f8a512 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -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); diff --git a/extensions/diffs/openclaw.plugin.json b/extensions/diffs/openclaw.plugin.json index 44791385cec..00db3002142 100644 --- a/extensions/diffs/openclaw.plugin.json +++ b/extensions/diffs/openclaw.plugin.json @@ -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" } } diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index e23dec9e70f..56251aad236 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -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 }> = []; + const pages: Array<{ + close: ReturnType; + screenshot: ReturnType; + pdf: ReturnType; + }> = []; const browser = createMockBrowser(pages); launchMock.mockResolvedValue(browser); const { PlaywrightDiffScreenshotter } = await import("./browser.js"); @@ -49,11 +53,25 @@ describe("PlaywrightDiffScreenshotter", () => { html: '
', outputPath, theme: "dark", + image: { + format: "png", + qualityPreset: "standard", + scale: 2, + maxWidth: 960, + maxPixels: 8_000_000, + }, }); await screenshotter.screenshotHtml({ 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: '
', 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; + screenshot: ReturnType; + pdf: ReturnType; + }> = []; + 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: '
', + 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 | 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; + screenshot: ReturnType; + pdf: ReturnType; + }> = []; + 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: '
', + 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; + screenshot: ReturnType; + pdf: ReturnType; + }> = []; + 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: '
', + 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 }>) { +function createMockBrowser( + pages: Array<{ + close: ReturnType; + screenshot: ReturnType; + pdf: ReturnType; + }>, + 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 }>) { 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 () => {}), }; } diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.ts index 9538d51c9c7..d0afa23bb8b 100644 --- a/extensions/diffs/src/browser.ts +++ b/extensions/diffs/src/browser.ts @@ -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; + screenshotHtml(params: { + html: string; + outputPath: string; + theme: DiffTheme; + image: DiffRenderOptions["image"]; + }): Promise; }; type BrowserInstance = Awaited>; @@ -49,6 +57,7 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter { html: string; outputPath: string; theme: DiffTheme; + image: DiffRenderOptions["image"]; }): Promise { 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> | 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(() => {}); diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index d8e0b08c096..a2795546fdb 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -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", () => { diff --git a/extensions/diffs/src/config.ts b/extensions/diffs/src/config.ts index 1f2b363e2b1..153cf27bb10 100644 --- a/extensions/diffs/src/config.ts +++ b/extensions/diffs/src/config.ts @@ -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, + }; +} diff --git a/extensions/diffs/src/prompt-guidance.ts b/extensions/diffs/src/prompt-guidance.ts index 43d6656e43c..e70fa881ea8 100644 --- a/extensions/diffs/src/prompt-guidance.ts +++ b/extensions/diffs/src/prompt-guidance.ts @@ -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"); diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts index 6ab7de73d2a..f46a2c9abe9 100644 --- a/extensions/diffs/src/render.test.ts +++ b/extensions/diffs/src/render.test.ts @@ -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, }, ), diff --git a/extensions/diffs/src/render.ts b/extensions/diffs/src/render.ts index 7bf53a5939b..5360b2f46c7 100644 --- a/extensions/diffs/src/render.ts +++ b/extensions/diffs/src/render.ts @@ -197,6 +197,7 @@ function buildHtmlDocument(params: { title: string; bodyHtml: string; theme: DiffRenderOptions["presentation"]["theme"]; + imageMaxWidth: number; runtimeMode: "viewer" | "image"; }): string { return ` @@ -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, diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index 1e4a65209b7..d4e6aacd409 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -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: "demo", 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: "demo", 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: "demo", + 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: "demo", + 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 () => { diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index ce6e391f5a6..26a0784ca7a 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -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 { + async updateFilePath(id: string, filePath: string): Promise { 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 { + 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 { - 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 { + const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact"); + if (!parsed) { + return null; + } + return parsed as DiffArtifactMeta; + } + + private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise { + await this.writeJsonMeta(meta.id, "file-meta.json", meta); + } + + private async readStandaloneMeta(id: string): Promise { + 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; + 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 { + await fs.writeFile(this.metaFilePath(id, fileName), JSON.stringify(data, null, 2), "utf8"); + } + + private async readJsonMeta( + id: string, + fileName: ArtifactMetaFileName, + context: string, + ): Promise { + 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; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 1ec3e1a67cc..23b71b1e6eb 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -39,9 +39,44 @@ describe("diffs tool", () => { expect((result?.details as Record).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 }; + 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).filePath).toBeDefined(); expect((result?.details as Record).imagePath).toBeDefined(); + expect((result?.details as Record).format).toBe("png"); + expect((result?.details as Record).fileQuality).toBe("standard"); + expect((result?.details as Record).imageQuality).toBe("standard"); + expect((result?.details as Record).fileScale).toBe(2); + expect((result?.details as Record).imageScale).toBe(2); + expect((result?.details as Record).fileMaxWidth).toBe(960); + expect((result?.details as Record).imageMaxWidth).toBe(960); expect((result?.details as Record).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).format).toBe("pdf"); + expect((result?.details as Record).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).mode).toBe("file"); + expect((result?.details as Record).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).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).fileQuality).toBe("hq"); + expect((result?.details as Record).fileScale).toBe(2.4); + expect((result?.details as Record).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).fileFormat).toBe("pdf"); + expect((result?.details as Record).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).mode).toBe("file"); + expect((result?.details as Record).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).fileError).toBe("browser missing"); expect((result?.details as Record).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).mode).toBe("both"); expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); + expect((result?.details as Record).format).toBe("png"); + expect((result?.details as Record).fileQuality).toBe("print"); + expect((result?.details as Record).fileScale).toBe(2.75); + expect((result?.details as Record).fileMaxWidth).toBe(1320); const viewerPath = String((result?.details as Record).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; - }), - }; -} diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index 064f36640c5..92f9f5c778d 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -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; +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).`); -} diff --git a/extensions/diffs/src/types.ts b/extensions/diffs/src/types.ts index 231ef7d2ea5..ff389688839 100644 --- a/extensions/diffs/src/types.ts +++ b/extensions/diffs/src/types.ts @@ -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; }; diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index f87f00593a0..335cab6454d 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -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; + 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(); + }); });