diffs plugin

This commit is contained in:
Gustavo Madeira Santana
2026-02-28 18:38:00 -05:00
parent fca0467082
commit 612ed5b3e1
23 changed files with 4067 additions and 4 deletions

124
extensions/diffs/README.md Normal file
View File

@@ -0,0 +1,124 @@
# @openclaw/diffs
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
## 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
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=both` when it wants both outputs
## Tool Inputs
Before/after:
```json
{
"before": "# Hello\n\nOne",
"after": "# Hello\n\nTwo",
"path": "docs/example.md",
"mode": "view"
}
```
Patch:
```json
{
"patch": "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-const x = 1;\n+const x = 2;\n",
"mode": "both"
}
```
Useful options:
- `mode`: `view`, `image`, or `both`
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` (default: `dark`)
- `expandUnchanged`: expand unchanged sections
- `path`: display name for before/after input
- `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link
## Example Agent Prompts
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.
Path: docs/example.md
Before:
# Hello
This is version one.
After:
# Hello
This is version two.
```
Render a PNG:
```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.
Path: README.md
Before:
OpenClaw supports plugins.
After:
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.
Path: src/demo.ts
Before:
const status = "old";
After:
const status = "new";
```
Patch input:
```text
Use the `diffs` tool with this unified patch in `view` mode. After it returns the viewer URL, present it in the canvas.
diff --git a/src/example.ts b/src/example.ts
--- a/src/example.ts
+++ b/src/example.ts
@@ -1,3 +1,3 @@
export function add(a: number, b: number) {
- return a + b;
+ return a + b + 1;
}
```
## Notes
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
- Artifacts are ephemeral and stored in the local temp directory.
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
it("registers the tool, http handler, and prompt guidance hook", () => {
const registerTool = vi.fn();
const registerHttpHandler = vi.fn();
const on = vi.fn();
plugin.register?.({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {},
runtime: {} as never,
logger: {
info() {},
warn() {},
error() {},
},
registerTool,
registerHook() {},
registerHttpHandler,
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
resolvePath(input: string) {
return input;
},
on,
});
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
});
});

28
extensions/diffs/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
import { createDiffsHttpHandler } from "./src/http.js";
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js";
import { createDiffsTool } from "./src/tool.js";
const plugin = {
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG renderer for agents.",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger,
});
api.registerTool(createDiffsTool({ api, store }));
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger }));
api.on("before_prompt_build", async () => ({
prependContext: DIFFS_AGENT_GUIDANCE,
}));
},
};
export default plugin;

View File

@@ -0,0 +1,10 @@
{
"id": "diffs",
"name": "Diffs",
"description": "Read-only diff viewer and image renderer for agents.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@openclaw/diffs",
"version": "2026.2.27",
"private": true,
"description": "OpenClaw diff viewer plugin",
"type": "module",
"scripts": {
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
},
"dependencies": {
"@pierre/diffs": "1.0.11",
"@sinclair/typebox": "0.34.48",
"playwright-core": "1.58.2"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,261 @@
import { constants as fsConstants } from "node:fs";
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 { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
export type DiffScreenshotter = {
screenshotHtml(params: { html: string; outputPath: string; theme: DiffTheme }): Promise<string>;
};
export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
private readonly config: OpenClawConfig;
constructor(params: { config: OpenClawConfig }) {
this.config = params.config;
}
async screenshotHtml(params: {
html: string;
outputPath: string;
theme: DiffTheme;
}): Promise<string> {
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
const executablePath = await resolveBrowserExecutablePath(this.config);
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
try {
browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
args: ["--disable-dev-shm-usage", "--disable-gpu"],
});
const page = await browser.newPage({
viewport: { width: 1200, height: 900 },
colorScheme: params.theme,
});
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => {
const pathname = new URL(route.request().url()).pathname;
const asset = await getServedViewerAsset(pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
});
});
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 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.");
}
await page.screenshot({
path: params.outputPath,
type: "png",
clip: {
x: Math.max(box.x - padding, 0),
y: Math.max(box.y - padding, 0),
width: clipWidth,
height: clipHeight,
},
});
return params.outputPath;
} catch (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}`,
);
} finally {
await browser?.close().catch(() => {});
}
}
}
function injectBaseHref(html: string): string {
if (html.includes("<base ")) {
return html;
}
return html.replace("<head>", '<head><base href="http://127.0.0.1/" />');
}
async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<string | undefined> {
const configPath = config.browser?.executablePath?.trim();
if (configPath) {
await assertExecutable(configPath, "browser.executablePath");
return configPath;
}
const envCandidates = [
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH,
process.env.BROWSER_EXECUTABLE_PATH,
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
for (const candidate of envCandidates) {
if (await isExecutable(candidate)) {
return candidate;
}
}
for (const candidate of await collectExecutableCandidates()) {
if (await isExecutable(candidate)) {
return candidate;
}
}
return undefined;
}
async function collectExecutableCandidates(): Promise<string[]> {
const candidates = new Set<string>();
for (const command of pathCommandsForPlatform()) {
const resolved = await findExecutableInPath(command);
if (resolved) {
candidates.add(resolved);
}
}
for (const candidate of commonExecutablePathsForPlatform()) {
candidates.add(candidate);
}
return [...candidates];
}
function pathCommandsForPlatform(): string[] {
if (process.platform === "win32") {
return ["chrome.exe", "msedge.exe", "brave.exe"];
}
if (process.platform === "darwin") {
return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
}
return [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"msedge",
"brave-browser",
"brave",
];
}
function commonExecutablePathsForPlatform(): string[] {
if (process.platform === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
];
}
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA ?? "";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
return [
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
];
}
return [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/msedge",
"/usr/bin/brave-browser",
"/snap/bin/chromium",
];
}
async function findExecutableInPath(command: string): Promise<string | undefined> {
const pathValue = process.env.PATH;
if (!pathValue) {
return undefined;
}
for (const directory of pathValue.split(path.delimiter)) {
if (!directory) {
continue;
}
const candidate = path.join(directory, command);
if (await isExecutable(candidate)) {
return candidate;
}
}
return undefined;
}
async function assertExecutable(candidate: string, label: string): Promise<void> {
if (!(await isExecutable(candidate))) {
throw new Error(`${label} not found or not executable: ${candidate}`);
}
}
async function isExecutable(candidate: string): Promise<boolean> {
try {
await fs.access(candidate, fsConstants.X_OK);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,115 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
describe("createDiffsHttpHandler", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("serves a stored diff document", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: artifact.viewerPath,
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
} as IncomingMessage,
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
});

View File

@@ -0,0 +1,136 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PluginLogger } from "openclaw/plugin-sdk";
import type { DiffArtifactStore } from "./store.js";
import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
const VIEW_PREFIX = "/plugins/diffs/view/";
const VIEWER_CONTENT_SECURITY_POLICY = [
"default-src 'none'",
"script-src 'self'",
"style-src 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
"object-src 'none'",
].join("; ");
export function createDiffsHttpHandler(params: {
store: DiffArtifactStore;
logger?: PluginLogger;
}) {
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const parsed = parseRequestUrl(req.url);
if (!parsed) {
return false;
}
if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
return await serveAsset(req, res, parsed.pathname, params.logger);
}
if (!parsed.pathname.startsWith(VIEW_PREFIX)) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
const id = pathParts[3];
const token = pathParts[4];
if (
!id ||
!token ||
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
) {
respondText(res, 404, "Diff not found");
return true;
}
const artifact = await params.store.getArtifact(id, token);
if (!artifact) {
respondText(res, 404, "Diff not found or expired");
return true;
}
try {
const html = await params.store.readHtml(id);
res.statusCode = 200;
setSharedHeaders(res, "text/html; charset=utf-8");
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
if (req.method === "HEAD") {
res.end();
} else {
res.end(html);
}
return true;
} catch (error) {
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
respondText(res, 500, "Failed to load diff");
return true;
}
};
}
function parseRequestUrl(rawUrl?: string): URL | null {
if (!rawUrl) {
return null;
}
try {
return new URL(rawUrl, "http://127.0.0.1");
} catch {
return null;
}
}
async function serveAsset(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
logger?: PluginLogger,
): Promise<boolean> {
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
try {
const asset = await getServedViewerAsset(pathname);
if (!asset) {
respondText(res, 404, "Asset not found");
return true;
}
res.statusCode = 200;
setSharedHeaders(res, asset.contentType);
if (req.method === "HEAD") {
res.end();
} else {
res.end(asset.body);
}
return true;
} catch (error) {
logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`);
respondText(res, 500, "Failed to load asset");
return true;
}
}
function respondText(res: ServerResponse, statusCode: number, body: string): void {
res.statusCode = statusCode;
setSharedHeaders(res, "text/plain; charset=utf-8");
res.end(body);
}
function setSharedHeaders(res: ServerResponse, contentType: string): void {
res.setHeader("cache-control", "no-store, max-age=0");
res.setHeader("content-type", contentType);
res.setHeader("x-content-type-options", "nosniff");
res.setHeader("referrer-policy", "no-referrer");
}

View File

@@ -0,0 +1,9 @@
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.",
"Good defaults: `theme=dark` for canvas rendering, `layout=unified` for most diffs, and include `path` for before/after text when you know the file name.",
].join("\n");

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
layout: "unified",
expandUnchanged: false,
theme: "light",
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
layout: "split",
expandUnchanged: true,
theme: "dark",
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
});
});

View File

@@ -0,0 +1,351 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import type {
DiffInput,
DiffRenderOptions,
DiffViewerOptions,
DiffViewerPayload,
RenderedDiffDocument,
} from "./types.js";
import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt";
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeJsonScript(value: unknown): string {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
function buildDiffTitle(input: DiffInput): string {
if (input.title?.trim()) {
return input.title.trim();
}
if (input.kind === "before_after") {
return input.path?.trim() || "Text diff";
}
return "Patch diff";
}
function resolveBeforeAfterFileName(input: Extract<DiffInput, { kind: "before_after" }>): string {
if (input.path?.trim()) {
return input.path.trim();
}
if (input.lang?.trim()) {
return `diff.${input.lang.trim().replace(/^\.+/, "")}`;
}
return DEFAULT_FILE_NAME;
}
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
return {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: options.layout,
expandUnchanged: options.expandUnchanged,
themeType: options.theme,
overflow: "wrap" as const,
unsafeCSS: `
:host {
--diffs-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-header-font-family: "Fira Code", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-font-size: 15px;
--diffs-line-height: 24px;
}
[data-diffs-header] {
min-height: 64px;
padding-inline: 18px 14px;
}
[data-header-content] {
gap: 10px;
}
[data-metadata] {
gap: 10px;
}
.oc-diff-toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
margin-inline-start: 6px;
flex: 0 0 auto;
}
.oc-diff-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
line-height: 0;
overflow: visible;
transition: opacity 120ms ease;
flex: 0 0 auto;
}
.oc-diff-toolbar-button:hover {
opacity: 1;
}
.oc-diff-toolbar-button[data-active="true"] {
opacity: 0.92;
}
.oc-diff-toolbar-button svg {
display: block;
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
overflow: visible;
flex: 0 0 auto;
color: inherit;
fill: currentColor;
stroke: currentColor;
pointer-events: none;
}
`,
};
}
function normalizeSupportedLanguage(value?: string): SupportedLanguages | undefined {
const normalized = value?.trim();
return normalized ? (normalized as SupportedLanguages) : undefined;
}
function buildPayloadLanguages(payload: {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
}): SupportedLanguages[] {
const langs = new Set<SupportedLanguages>();
if (payload.fileDiff?.lang) {
langs.add(payload.fileDiff.lang);
}
if (payload.oldFile?.lang) {
langs.add(payload.oldFile.lang);
}
if (payload.newFile?.lang) {
langs.add(payload.newFile.lang);
}
if (langs.size === 0) {
langs.add("text");
}
return [...langs];
}
function renderDiffCard(payload: DiffViewerPayload): string {
return `<section class="oc-diff-card">
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
</diffs-container>
<script type="application/json" data-openclaw-diff-payload>${escapeJsonScript(payload)}</script>
</section>`;
}
function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["theme"];
}): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>${escapeHtml(params.title)}</title>
<style>
* {
box-sizing: border-box;
}
html {
background: #05070b;
}
body {
margin: 0;
padding: 22px;
font-family:
"Fira Code",
"SF Mono",
Monaco,
Consolas,
monospace;
background: #05070b;
color: #f8fafc;
}
body[data-theme="light"] {
background: #f3f5f8;
color: #0f172a;
}
.oc-frame {
max-width: 1560px;
margin: 0 auto;
}
.oc-frame[data-render-mode="image"] {
max-width: 1120px;
}
[data-openclaw-diff-root] {
display: grid;
gap: 18px;
}
.oc-diff-card {
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.14);
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.22);
}
body[data-theme="light"] .oc-diff-card {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}
.oc-diff-host {
display: block;
}
.oc-frame[data-render-mode="image"] .oc-diff-card {
min-height: 240px;
}
@media (max-width: 720px) {
body {
padding: 12px;
}
[data-openclaw-diff-root] {
gap: 12px;
}
}
</style>
</head>
<body data-theme="${params.theme}">
<main class="oc-frame" data-render-mode="viewer">
<div data-openclaw-diff-root>
${params.bodyHtml}
</div>
</main>
<script type="module" src="${VIEWER_LOADER_PATH}"></script>
</body>
</html>`;
}
async function renderBeforeAfterDiff(
input: Extract<DiffInput, { kind: "before_after" }>,
options: DiffRenderOptions,
): Promise<{ bodyHtml: string; fileCount: number }> {
const fileName = resolveBeforeAfterFileName(input);
const lang = normalizeSupportedLanguage(input.lang);
const oldFile: FileContents = {
name: fileName,
contents: input.before,
...(lang ? { lang } : {}),
};
const newFile: FileContents = {
name: fileName,
contents: input.after,
...(lang ? { lang } : {}),
};
const payloadOptions = buildDiffOptions(options);
const result = await preloadMultiFileDiff({
oldFile,
newFile,
options: payloadOptions,
});
return {
bodyHtml: renderDiffCard({
prerenderedHTML: result.prerenderedHTML,
oldFile: result.oldFile,
newFile: result.newFile,
options: payloadOptions,
langs: buildPayloadLanguages({ oldFile: result.oldFile, newFile: result.newFile }),
}),
fileCount: 1,
};
}
async function renderPatchDiff(
input: Extract<DiffInput, { kind: "patch" }>,
options: DiffRenderOptions,
): Promise<{ bodyHtml: string; fileCount: number }> {
const files = parsePatchFiles(input.patch).flatMap((entry) => entry.files ?? []);
if (files.length === 0) {
throw new Error("Patch input did not contain any file diffs.");
}
const payloadOptions = buildDiffOptions(options);
const sections = await Promise.all(
files.map(async (fileDiff) => {
const result = await preloadFileDiff({
fileDiff,
options: payloadOptions,
});
return renderDiffCard({
prerenderedHTML: result.prerenderedHTML,
fileDiff: result.fileDiff,
options: payloadOptions,
langs: buildPayloadLanguages({ fileDiff: result.fileDiff }),
});
}),
);
return {
bodyHtml: sections.join("\n"),
fileCount: files.length,
};
}
export async function renderDiffDocument(
input: DiffInput,
options: DiffRenderOptions,
): Promise<RenderedDiffDocument> {
const title = buildDiffTitle(input);
const rendered =
input.kind === "before_after"
? await renderBeforeAfterDiff(input, options)
: await renderPatchDiff(input, options);
return {
html: buildHtmlDocument({
title,
bodyHtml: rendered.bodyHtml,
theme: options.theme,
}),
title,
fileCount: rendered.fileCount,
inputKind: input.kind,
};
}

View File

@@ -0,0 +1,64 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DiffArtifactStore } from "./store.js";
describe("DiffArtifactStore", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-store-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
vi.useRealTimers();
await fs.rm(rootDir, { recursive: true, force: true });
});
it("creates and retrieves an artifact", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded?.id).toBe(artifact.id);
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
});
it("expires artifacts after the ttl", async () => {
vi.useFakeTimers();
const now = new Date("2026-02-27T16:00:00Z");
vi.setSystemTime(now);
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "patch",
fileCount: 2,
ttlMs: 1_000,
});
vi.setSystemTime(new Date(now.getTime() + 2_000));
const loaded = await store.getArtifact(artifact.id, artifact.token);
expect(loaded).toBeNull();
});
it("updates the stored image path", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.imagePath).toBe(imagePath);
});
});

View File

@@ -0,0 +1,183 @@
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";
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000;
const VIEWER_PREFIX = "/plugins/diffs/view";
type CreateArtifactParams = {
html: string;
title: string;
inputKind: DiffArtifactMeta["inputKind"];
fileCount: number;
ttlMs?: number;
};
export class DiffArtifactStore {
private readonly rootDir: string;
private readonly logger?: PluginLogger;
constructor(params: { rootDir: string; logger?: PluginLogger }) {
this.rootDir = params.rootDir;
this.logger = params.logger;
}
async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> {
await this.ensureRoot();
await this.cleanupExpired();
const id = crypto.randomBytes(10).toString("hex");
const token = crypto.randomBytes(24).toString("hex");
const artifactDir = this.artifactDir(id);
const htmlPath = path.join(artifactDir, "viewer.html");
const ttlMs = normalizeTtlMs(params.ttlMs);
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + ttlMs);
const meta: DiffArtifactMeta = {
id,
token,
title: params.title,
inputKind: params.inputKind,
fileCount: params.fileCount,
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
htmlPath,
};
await fs.mkdir(artifactDir, { recursive: true });
await fs.writeFile(htmlPath, params.html, "utf8");
await this.writeMeta(meta);
return meta;
}
async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> {
const meta = await this.readMeta(id);
if (!meta) {
return null;
}
if (meta.token !== token) {
return null;
}
if (isExpired(meta)) {
await this.deleteArtifact(id);
return null;
}
return meta;
}
async readHtml(id: string): Promise<string> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
return await fs.readFile(meta.htmlPath, "utf8");
}
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
const next: DiffArtifactMeta = {
...meta,
imagePath,
};
await this.writeMeta(next);
return next;
}
allocateImagePath(id: string): string {
return path.join(this.artifactDir(id), "preview.png");
}
async cleanupExpired(): Promise<void> {
await this.ensureRoot();
const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const id = entry.name;
const meta = await this.readMeta(id);
if (meta) {
if (isExpired(meta)) {
await this.deleteArtifact(id);
}
return;
}
const artifactPath = this.artifactDir(id);
const stat = await fs.stat(artifactPath).catch(() => null);
if (!stat) {
return;
}
if (now - stat.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
await this.deleteArtifact(id);
}
}),
);
}
private async ensureRoot(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
}
private artifactDir(id: string): string {
return path.join(this.rootDir, 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");
}
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
try {
const raw = await fs.readFile(this.metaPath(id), "utf8");
return JSON.parse(raw) as DiffArtifactMeta;
} catch (error) {
if (isFileNotFound(error)) {
return null;
}
this.logger?.warn(`Failed to read diff artifact metadata for ${id}: ${String(error)}`);
return null;
}
}
private async deleteArtifact(id: string): Promise<void> {
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
}
}
function normalizeTtlMs(value?: number): number {
if (!Number.isFinite(value) || value === undefined) {
return DEFAULT_TTL_MS;
}
const rounded = Math.floor(value);
if (rounded <= 0) {
return DEFAULT_TTL_MS;
}
return Math.min(rounded, MAX_TTL_MS);
}
function isExpired(meta: DiffArtifactMeta): boolean {
const expiresAt = Date.parse(meta.expiresAt);
if (!Number.isFinite(expiresAt)) {
return true;
}
return Date.now() >= expiresAt;
}
function isFileNotFound(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}

View File

@@ -0,0 +1,147 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DiffArtifactStore } from "./store.js";
import { createDiffsTool } from "./tool.js";
describe("diffs tool", () => {
let rootDir: string;
let store: DiffArtifactStore;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-"));
store = new DiffArtifactStore({ rootDir });
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("returns a viewer URL in view mode", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
});
const result = await tool.execute?.("tool-1", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
});
const text = readTextContent(result, 0);
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
});
it("returns an image artifact in image mode", 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,
screenshotter,
});
const result = await tool.execute?.("tool-2", {
before: "one\n",
after: "two\n",
mode: "image",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff image generated at:");
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
});
it("falls back to view output when both mode cannot render an image", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
screenshotter: {
screenshotHtml: vi.fn(async () => {
throw new Error("browser missing");
}),
},
});
const result = await tool.execute?.("tool-3", {
before: "one\n",
after: "two\n",
mode: "both",
});
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("Image rendering failed");
expect((result?.details as Record<string, unknown>).imageError).toBe("browser missing");
});
it("rejects invalid base URLs as tool input errors", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
});
await expect(
tool.execute?.("tool-4", {
before: "one\n",
after: "two\n",
mode: "view",
baseUrl: "javascript:alert(1)",
}),
).rejects.toThrow("Invalid baseUrl");
});
});
function createApi(): OpenClawPluginApi {
return {
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
runtime: {} as OpenClawPluginApi["runtime"],
logger: {
info() {},
warn() {},
error() {},
},
registerTool() {},
registerHook() {},
registerHttpHandler() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerCommand() {},
resolvePath(input: string) {
return input;
},
on() {},
};
}
function readTextContent(result: unknown, index: number): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
?.content;
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}

View File

@@ -0,0 +1,245 @@
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 { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import {
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_THEMES,
type DiffInput,
type DiffLayout,
type DiffMode,
type DiffTheme,
} from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
function stringEnum<T extends readonly string[]>(values: T, description: string) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
description,
});
}
const DiffsToolSchema = Type.Object(
{
before: Type.Optional(Type.String({ description: "Original text content." })),
after: Type.Optional(Type.String({ description: "Updated text content." })),
patch: Type.Optional(Type.String({ description: "Unified diff or patch text." })),
path: Type.Optional(Type.String({ description: "Display path for before/after input." })),
lang: Type.Optional(
Type.String({ description: "Optional language override for before/after input." }),
),
title: Type.Optional(Type.String({ description: "Optional title for the rendered diff." })),
mode: Type.Optional(
stringEnum(DIFF_MODES, "Output mode: view, 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.")),
expandUnchanged: Type.Optional(
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
),
ttlSeconds: Type.Optional(
Type.Number({
description: "Artifact lifetime in seconds. Default: 1800. Maximum: 21600.",
minimum: 1,
maximum: 21_600,
}),
),
baseUrl: Type.Optional(
Type.String({
description:
"Optional gateway base URL override used when building the viewer URL, for example https://gateway.example.com.",
}),
),
},
{ additionalProperties: false },
);
type DiffsToolParams = Static<typeof DiffsToolSchema>;
export function createDiffsTool(params: {
api: OpenClawPluginApi;
store: DiffArtifactStore;
screenshotter?: DiffScreenshotter;
}): AnyAgentTool {
return {
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.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolParams;
const input = normalizeDiffInput(toolParams);
const mode = normalizeMode(toolParams.mode);
const theme = normalizeTheme(toolParams.theme);
const layout = normalizeLayout(toolParams.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
const rendered = await renderDiffDocument(input, {
layout,
expandUnchanged,
theme,
});
const artifact = await params.store.createArtifact({
html: rendered.html,
title: rendered.title,
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
ttlMs,
});
const viewerUrl = buildViewerUrl({
config: params.api.config,
viewerPath: artifact.viewerPath,
baseUrl: normalizeBaseUrl(toolParams.baseUrl),
});
const baseDetails = {
artifactId: artifact.id,
viewerUrl,
viewerPath: artifact.viewerPath,
title: artifact.title,
expiresAt: artifact.expiresAt,
inputKind: artifact.inputKind,
fileCount: artifact.fileCount,
mode,
};
if (mode === "view") {
return {
content: [
{
type: "text",
text: `Diff viewer ready.\n${viewerUrl}`,
},
],
details: baseDetails,
};
}
const screenshotter =
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
try {
const imagePath = params.store.allocateImagePath(artifact.id);
await screenshotter.screenshotHtml({
html: rendered.html,
outputPath: imagePath,
theme,
});
await params.store.updateImagePath(artifact.id, imagePath);
const imageStats = await fs.stat(imagePath);
return {
content: [
{
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.",
},
],
details: {
...baseDetails,
imagePath,
path: imagePath,
imageBytes: imageStats.size,
},
};
} catch (error) {
if (mode === "both") {
return {
content: [
{
type: "text",
text:
`Diff viewer ready.\n${viewerUrl}\n` +
`Image rendering failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: {
...baseDetails,
imageError: error instanceof Error ? error.message : String(error),
},
};
}
throw error;
}
},
};
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;
const after = params.after;
if (patch) {
if (before !== undefined || after !== undefined) {
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
}
return {
kind: "patch",
patch,
title: params.title?.trim() || undefined,
};
}
if (before === undefined || after === undefined) {
throw new PluginToolInputError("Provide patch or both before and after text.");
}
return {
kind: "before_after",
before,
after,
path: params.path?.trim() || undefined,
lang: params.lang?.trim() || undefined,
title: params.title?.trim() || undefined,
};
}
function normalizeBaseUrl(baseUrl?: string): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
return undefined;
}
try {
return normalizeViewerBaseUrl(normalized);
} catch {
throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`);
}
}
function normalizeMode(mode?: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : "both";
}
function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : "dark";
}
function normalizeLayout(layout?: DiffLayout): DiffLayout {
return layout && DIFF_LAYOUTS.includes(layout) ? layout : "unified";
}
function normalizeTtlMs(ttlSeconds?: number): number | undefined {
if (!Number.isFinite(ttlSeconds) || ttlSeconds === undefined) {
return undefined;
}
return Math.floor(ttlSeconds * 1000);
}
class PluginToolInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolInputError";
}
}

View File

@@ -0,0 +1,76 @@
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_THEMES = ["light", "dark"] 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 BeforeAfterDiffInput = {
kind: "before_after";
before: string;
after: string;
path?: string;
lang?: string;
title?: string;
};
export type PatchDiffInput = {
kind: "patch";
patch: string;
title?: string;
};
export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
export type DiffRenderOptions = {
layout: DiffLayout;
expandUnchanged: boolean;
theme: DiffTheme;
};
export type DiffViewerOptions = {
theme: {
light: "pierre-light";
dark: "pierre-dark";
};
diffStyle: DiffLayout;
expandUnchanged: boolean;
themeType: DiffTheme;
overflow: "scroll" | "wrap";
unsafeCSS: string;
};
export type DiffViewerPayload = {
prerenderedHTML: string;
options: DiffViewerOptions;
langs: SupportedLanguages[];
oldFile?: FileContents;
newFile?: FileContents;
fileDiff?: FileDiffMetadata;
};
export type RenderedDiffDocument = {
html: string;
title: string;
fileCount: number;
inputKind: DiffInput["kind"];
};
export type DiffArtifactMeta = {
id: string;
token: string;
createdAt: string;
expiresAt: string;
title: string;
inputKind: DiffInput["kind"];
fileCount: number;
viewerPath: string;
htmlPath: string;
imagePath?: string;
};
export const DIFF_ARTIFACT_ID_PATTERN = /^[0-9a-f]{20}$/;
export const DIFF_ARTIFACT_TOKEN_PATTERN = /^[0-9a-f]{48}$/;

120
extensions/diffs/src/url.ts Normal file
View File

@@ -0,0 +1,120 @@
import os from "node:os";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
const DEFAULT_GATEWAY_PORT = 18789;
export function buildViewerUrl(params: {
config: OpenClawConfig;
viewerPath: string;
baseUrl?: string;
}): string {
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
const normalizedPath = params.viewerPath.startsWith("/")
? params.viewerPath
: `/${params.viewerPath}`;
return `${normalizedBase}${normalizedPath}`;
}
export function normalizeViewerBaseUrl(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid baseUrl: ${raw}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`baseUrl must use http or https: ${raw}`);
}
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
return withoutTrailingSlash;
}
function resolveGatewayBaseUrl(config: OpenClawConfig): string {
const scheme = config.gateway?.tls?.enabled ? "https" : "http";
const port =
typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
const bind = config.gateway?.bind ?? "loopback";
if (bind === "custom" && config.gateway?.customBindHost?.trim()) {
return `${scheme}://${config.gateway.customBindHost.trim()}:${port}`;
}
if (bind === "lan") {
return `${scheme}://${pickPrimaryLanIPv4() ?? "127.0.0.1"}:${port}`;
}
if (bind === "tailnet") {
return `${scheme}://${pickPrimaryTailnetIPv4() ?? "127.0.0.1"}:${port}`;
}
return `${scheme}://127.0.0.1:${port}`;
}
function pickPrimaryLanIPv4(): string | undefined {
const nets = os.networkInterfaces();
const preferredNames = ["en0", "eth0"];
for (const name of preferredNames) {
const candidate = pickPrivateAddress(nets[name]);
if (candidate) {
return candidate;
}
}
for (const entries of Object.values(nets)) {
const candidate = pickPrivateAddress(entries);
if (candidate) {
return candidate;
}
}
return undefined;
}
function pickPrimaryTailnetIPv4(): string | undefined {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
const candidate = entries?.find((entry) => isTailnetIPv4(entry.address) && !entry.internal);
if (candidate?.address) {
return candidate.address;
}
}
return undefined;
}
function pickPrivateAddress(entries: os.NetworkInterfaceInfo[] | undefined): string | undefined {
return entries?.find(
(entry) => entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address),
)?.address;
}
function isPrivateIPv4(address: string): boolean {
const octets = parseIpv4(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function isTailnetIPv4(address: string): boolean {
const octets = parseIpv4(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) {
return null;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return null;
}
return octets;
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
describe("viewer assets", () => {
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
const VIEWER_RUNTIME_FILE_URL = new URL("../assets/viewer-runtime.js", import.meta.url);
export type ServedViewerAsset = {
body: string | Buffer;
contentType: string;
};
type RuntimeAssetCache = {
mtimeMs: number;
runtimeBody: Buffer;
loaderBody: string;
};
let runtimeAssetCache: RuntimeAssetCache | null = null;
export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
return null;
}
const assets = await loadViewerAssets();
if (pathname === VIEWER_LOADER_PATH) {
return {
body: assets.loaderBody,
contentType: "text/javascript; charset=utf-8",
};
}
if (pathname === VIEWER_RUNTIME_PATH) {
return {
body: assets.runtimeBody,
contentType: "text/javascript; charset=utf-8",
};
}
return null;
}
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
const runtimePath = fileURLToPath(VIEWER_RUNTIME_FILE_URL);
const runtimeStat = await fs.stat(runtimePath);
if (runtimeAssetCache && runtimeAssetCache.mtimeMs === runtimeStat.mtimeMs) {
return runtimeAssetCache;
}
const runtimeBody = await fs.readFile(runtimePath);
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
runtimeAssetCache = {
mtimeMs: runtimeStat.mtimeMs,
runtimeBody,
loaderBody: `import "${VIEWER_RUNTIME_PATH}?v=${hash}";\n`,
};
return runtimeAssetCache;
}

View File

@@ -0,0 +1,295 @@
import { FileDiff, preloadHighlighter } from "@pierre/diffs";
import type {
FileContents,
FileDiffMetadata,
FileDiffOptions,
SupportedLanguages,
} from "@pierre/diffs";
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
type ViewerState = {
theme: DiffTheme;
layout: DiffLayout;
backgroundEnabled: boolean;
wrapEnabled: boolean;
};
type DiffController = {
payload: DiffViewerPayload;
diff: FileDiff;
};
const controllers: DiffController[] = [];
const viewerState: ViewerState = {
theme: "dark",
layout: "unified",
backgroundEnabled: true,
wrapEnabled: true,
};
function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
const raw = element.textContent?.trim();
if (!raw) {
throw new Error("Diff payload was empty.");
}
return JSON.parse(raw) as DiffViewerPayload;
}
function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> {
return [...document.querySelectorAll<HTMLElement>(".oc-diff-card")].flatMap((card) => {
const host = card.querySelector<HTMLElement>("[data-openclaw-diff-host]");
const payloadNode = card.querySelector<HTMLScriptElement>("[data-openclaw-diff-payload]");
if (!host || !payloadNode) {
return [];
}
return [{ host, payload: parsePayload(payloadNode) }];
});
}
function ensureShadowRoot(host: HTMLElement): void {
if (host.shadowRoot) {
return;
}
const template = host.querySelector<HTMLTemplateElement>(
":scope > template[shadowrootmode='open']",
);
if (!template) {
return;
}
const shadowRoot = host.attachShadow({ mode: "open" });
shadowRoot.append(template.content.cloneNode(true));
template.remove();
}
function getHydrateProps(payload: DiffViewerPayload): {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
} {
if (payload.fileDiff) {
return { fileDiff: payload.fileDiff };
}
return {
oldFile: payload.oldFile,
newFile: payload.newFile,
};
}
function createToolbarButton(params: {
title: string;
active: boolean;
iconMarkup: string;
onClick: () => void;
}): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "oc-diff-toolbar-button";
button.dataset.active = String(params.active);
button.title = params.title;
button.setAttribute("aria-label", params.title);
button.innerHTML = params.iconMarkup;
button.addEventListener("click", (event) => {
event.preventDefault();
params.onClick();
});
return button;
}
function splitIcon(): string {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0"></path>
<path fill="currentColor" opacity="0.35" d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1"></path>
</svg>`;
}
function unifiedIcon(): string {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" fill-rule="evenodd" d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8.5h16zm-8-4a.5.5 0 0 0-.5.5v1h-1a.5.5 0 0 0 0 1h1v1a.5.5 0 0 0 1 0v-1h1a.5.5 0 0 0 0-1h-1v-1A.5.5 0 0 0 8 10" clip-rule="evenodd"></path>
<path fill="currentColor" fill-rule="evenodd" opacity="0.4" d="M14 0a2 2 0 0 1 2 2v5.5H0V2a2 2 0 0 1 2-2zM6.5 3.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
</svg>`;
}
function wrapIcon(active: boolean): string {
if (active) {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.88" d="M2 4.25h8.25a2.75 2.75 0 1 1 0 5.5H7.5a.75.75 0 0 0 0 1.5h3.1l-1.07 1.06a.75.75 0 1 0 1.06 1.06l2.35-2.34a.75.75 0 0 0 0-1.06l-2.35-2.34a.75.75 0 1 0-1.06 1.06l1.07 1.06H10.25a1.25 1.25 0 1 0 0-2.5H2z"></path>
<rect x="2" y="11.75" width="4.75" height="1.5" rx=".75" fill="currentColor" opacity="0.55"></rect>
</svg>`;
}
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<rect x="2" y="4" width="12" height="1.5" rx=".75" fill="currentColor"></rect>
<rect x="2" y="7.25" width="12" height="1.5" rx=".75" fill="currentColor" opacity="0.82"></rect>
<rect x="2" y="10.5" width="12" height="1.5" rx=".75" fill="currentColor" opacity="0.64"></rect>
</svg>`;
}
function backgroundIcon(active: boolean): string {
if (active) {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.4" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
<path fill="currentColor" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
<path fill="currentColor" opacity="0.4" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
</svg>`;
}
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" opacity="0.22" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
<path fill="currentColor" opacity="0.22" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
<path fill="currentColor" opacity="0.22" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
<path d="M2.5 13.5 13.5 2.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"></path>
</svg>`;
}
function themeIcon(theme: DiffTheme): string {
if (theme === "dark") {
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M10.794 3.647a.217.217 0 0 1 .412 0l.387 1.162c.173.518.58.923 1.097 1.096l1.162.388a.217.217 0 0 1 0 .412l-1.162.386a1.73 1.73 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.74 1.74 0 0 0 9.31 7.092l-1.162-.386a.217.217 0 0 1 0-.412l1.162-.388a1.73 1.73 0 0 0 1.097-1.096zM13.863.598a.144.144 0 0 1 .221-.071.14.14 0 0 1 .053.07l.258.775c.115.345.386.616.732.731l.774.258a.145.145 0 0 1 0 .274l-.774.259a1.16 1.16 0 0 0-.732.732l-.258.773a.145.145 0 0 1-.274 0l-.258-.773a1.16 1.16 0 0 0-.732-.732l-.774-.259a.145.145 0 0 1 0-.273l.774-.259c.346-.115.617-.386.732-.732z"></path>
<path fill="currentColor" d="M6.25 1.742a.67.67 0 0 1 .07.75 6.3 6.3 0 0 0-.768 3.028c0 2.746 1.746 5.084 4.193 5.979H1.774A7.2 7.2 0 0 1 1 8.245c0-3.013 1.85-5.598 4.484-6.694a.66.66 0 0 1 .766.19M.75 12.499a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
</svg>`;
}
return `<svg viewBox="0 0 16 16" aria-hidden="true">
<path fill="currentColor" d="M6.856.764a.75.75 0 0 1 .094 1.035A5.75 5.75 0 0 0 13.81 10.95a.75.75 0 1 1 1.13.99A7.251 7.251 0 1 1 6.762.858a.75.75 0 0 1 .094-.094"></path>
</svg>`;
}
function createToolbar(): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "oc-diff-toolbar";
toolbar.append(
createToolbarButton({
title: viewerState.layout === "unified" ? "Switch to split diff" : "Switch to unified diff",
active: viewerState.layout === "split",
iconMarkup: viewerState.layout === "split" ? splitIcon() : unifiedIcon(),
onClick: () => {
viewerState.layout = viewerState.layout === "unified" ? "split" : "unified";
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.wrapEnabled ? "Disable word wrap" : "Enable word wrap",
active: viewerState.wrapEnabled,
iconMarkup: wrapIcon(viewerState.wrapEnabled),
onClick: () => {
viewerState.wrapEnabled = !viewerState.wrapEnabled;
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.backgroundEnabled
? "Hide background highlights"
: "Show background highlights",
active: viewerState.backgroundEnabled,
iconMarkup: backgroundIcon(viewerState.backgroundEnabled),
onClick: () => {
viewerState.backgroundEnabled = !viewerState.backgroundEnabled;
syncAllControllers();
},
}),
);
toolbar.append(
createToolbarButton({
title: viewerState.theme === "dark" ? "Switch to light theme" : "Switch to dark theme",
active: viewerState.theme === "dark",
iconMarkup: themeIcon(viewerState.theme),
onClick: () => {
viewerState.theme = viewerState.theme === "dark" ? "light" : "dark";
syncAllControllers();
},
}),
);
return toolbar;
}
function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefined> {
return {
theme: payload.options.theme,
themeType: viewerState.theme,
diffStyle: viewerState.layout,
expandUnchanged: payload.options.expandUnchanged,
overflow: viewerState.wrapEnabled ? "wrap" : "scroll",
disableBackground: !viewerState.backgroundEnabled,
unsafeCSS: payload.options.unsafeCSS,
renderHeaderMetadata: () => createToolbar(),
};
}
function syncDocumentTheme(): void {
document.body.dataset.theme = viewerState.theme;
}
function applyState(controller: DiffController): void {
controller.diff.setOptions(createRenderOptions(controller.payload));
controller.diff.rerender();
}
function syncAllControllers(): void {
syncDocumentTheme();
for (const controller of controllers) {
applyState(controller);
}
}
async function hydrateViewer(): Promise<void> {
const cards = getCards();
const langs = new Set<SupportedLanguages>();
const firstPayload = cards[0]?.payload;
if (firstPayload) {
viewerState.theme = firstPayload.options.themeType;
viewerState.layout = firstPayload.options.diffStyle;
viewerState.wrapEnabled = firstPayload.options.overflow === "wrap";
}
for (const { payload } of cards) {
for (const lang of payload.langs) {
langs.add(lang);
}
}
await preloadHighlighter({
themes: ["pierre-light", "pierre-dark"],
langs: langs.size > 0 ? [...langs] : ["text"],
});
syncDocumentTheme();
for (const { host, payload } of cards) {
ensureShadowRoot(host);
const diff = new FileDiff(createRenderOptions(payload));
diff.hydrate({
fileContainer: host,
prerenderedHTML: payload.prerenderedHTML,
...getHydrateProps(payload),
});
const controller = { payload, diff };
controllers.push(controller);
applyState(controller);
}
}
async function main(): Promise<void> {
try {
await hydrateViewer();
document.documentElement.dataset.openclawDiffsReady = "true";
} catch (error) {
document.documentElement.dataset.openclawDiffsError = "true";
console.error("Failed to hydrate diff viewer", error);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
void main();
});
} else {
void main();
}