mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-13 19:10:39 +00:00
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { Static, Type } from "@sinclair/typebox";
|
|
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/diffs";
|
|
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 { 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";
|
|
|
|
const MAX_BEFORE_AFTER_BYTES = 512 * 1024;
|
|
const MAX_PATCH_BYTES = 2 * 1024 * 1024;
|
|
const MAX_TITLE_BYTES = 1_024;
|
|
const MAX_PATH_BYTES = 2_048;
|
|
const MAX_LANG_BYTES = 128;
|
|
|
|
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.",
|
|
maxLength: MAX_PATCH_BYTES,
|
|
}),
|
|
),
|
|
path: Type.Optional(
|
|
Type.String({
|
|
description: "Display path for before/after input.",
|
|
maxLength: MAX_PATH_BYTES,
|
|
}),
|
|
),
|
|
lang: Type.Optional(
|
|
Type.String({
|
|
description: "Optional language override for before/after input.",
|
|
maxLength: MAX_LANG_BYTES,
|
|
}),
|
|
),
|
|
title: Type.Optional(
|
|
Type.String({
|
|
description: "Optional title for the rendered diff.",
|
|
maxLength: MAX_TITLE_BYTES,
|
|
}),
|
|
),
|
|
mode: Type.Optional(
|
|
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." }),
|
|
),
|
|
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>;
|
|
type DiffsToolRawParams = DiffsToolParams & {
|
|
// Keep backward compatibility for direct calls that still pass `format`.
|
|
format?: DiffOutputFormat;
|
|
};
|
|
|
|
export function createDiffsTool(params: {
|
|
api: OpenClawPluginApi;
|
|
store: DiffArtifactStore;
|
|
defaults: DiffToolDefaults;
|
|
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 or PDF.",
|
|
parameters: DiffsToolSchema,
|
|
execute: async (_toolCallId, rawParams) => {
|
|
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: {
|
|
...params.defaults,
|
|
layout,
|
|
theme,
|
|
},
|
|
image,
|
|
expandUnchanged,
|
|
});
|
|
|
|
const screenshotter =
|
|
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
|
|
|
|
if (isArtifactOnlyMode(mode)) {
|
|
const artifactFile = await renderDiffArtifactFile({
|
|
screenshotter,
|
|
store: params.store,
|
|
html: rendered.imageHtml,
|
|
theme,
|
|
image,
|
|
ttlMs,
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: buildFileArtifactMessage({
|
|
format: image.format,
|
|
filePath: artifactFile.path,
|
|
}),
|
|
},
|
|
],
|
|
details: buildArtifactDetails({
|
|
baseDetails: {
|
|
title: rendered.title,
|
|
inputKind: rendered.inputKind,
|
|
fileCount: rendered.fileCount,
|
|
mode,
|
|
},
|
|
artifactFile,
|
|
image,
|
|
}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const artifactFile = await renderDiffArtifactFile({
|
|
screenshotter,
|
|
store: params.store,
|
|
artifactId: artifact.id,
|
|
html: rendered.imageHtml,
|
|
theme,
|
|
image,
|
|
});
|
|
await params.store.updateFilePath(artifact.id, artifactFile.path);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: buildFileArtifactMessage({
|
|
format: image.format,
|
|
filePath: artifactFile.path,
|
|
viewerUrl,
|
|
}),
|
|
},
|
|
],
|
|
details: buildArtifactDetails({
|
|
baseDetails,
|
|
artifactFile,
|
|
image,
|
|
}),
|
|
};
|
|
} catch (error) {
|
|
if (mode === "both") {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
`Diff viewer ready.\n${viewerUrl}\n` +
|
|
`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),
|
|
},
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function buildArtifactDetails(params: {
|
|
baseDetails: Record<string, unknown>;
|
|
artifactFile: { path: string; bytes: number };
|
|
image: DiffRenderOptions["image"];
|
|
}) {
|
|
return {
|
|
...params.baseDetails,
|
|
filePath: params.artifactFile.path,
|
|
imagePath: params.artifactFile.path,
|
|
path: params.artifactFile.path,
|
|
fileBytes: params.artifactFile.bytes,
|
|
imageBytes: params.artifactFile.bytes,
|
|
format: params.image.format,
|
|
fileFormat: params.image.format,
|
|
fileQuality: params.image.qualityPreset,
|
|
imageQuality: params.image.qualityPreset,
|
|
fileScale: params.image.scale,
|
|
imageScale: params.image.scale,
|
|
fileMaxWidth: params.image.maxWidth,
|
|
imageMaxWidth: params.image.maxWidth,
|
|
};
|
|
}
|
|
|
|
function buildFileArtifactMessage(params: {
|
|
format: DiffOutputFormat;
|
|
filePath: string;
|
|
viewerUrl?: string;
|
|
}): string {
|
|
const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : [];
|
|
lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`);
|
|
lines.push("Use the `message` tool with `path` or `filePath` to send this file.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
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;
|
|
const after = params.after;
|
|
|
|
if (patch) {
|
|
assertMaxBytes(patch, "patch", MAX_PATCH_BYTES);
|
|
if (before !== undefined || after !== undefined) {
|
|
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
|
|
}
|
|
const title = params.title?.trim();
|
|
if (title) {
|
|
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
|
|
}
|
|
return {
|
|
kind: "patch",
|
|
patch,
|
|
title,
|
|
};
|
|
}
|
|
|
|
if (before === undefined || after === undefined) {
|
|
throw new PluginToolInputError("Provide patch or both before and after text.");
|
|
}
|
|
assertMaxBytes(before, "before", MAX_BEFORE_AFTER_BYTES);
|
|
assertMaxBytes(after, "after", MAX_BEFORE_AFTER_BYTES);
|
|
const path = params.path?.trim() || undefined;
|
|
const lang = params.lang?.trim() || undefined;
|
|
const title = params.title?.trim() || undefined;
|
|
if (path) {
|
|
assertMaxBytes(path, "path", MAX_PATH_BYTES);
|
|
}
|
|
if (lang) {
|
|
assertMaxBytes(lang, "lang", MAX_LANG_BYTES);
|
|
}
|
|
if (title) {
|
|
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
|
|
}
|
|
|
|
return {
|
|
kind: "before_after",
|
|
before,
|
|
after,
|
|
path,
|
|
lang,
|
|
title,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return normalizeViewerBaseUrl(normalized);
|
|
} catch {
|
|
throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`);
|
|
}
|
|
}
|
|
|
|
function normalizeMode(mode: DiffMode | undefined, fallback: DiffMode): DiffMode {
|
|
return mode && DIFF_MODES.includes(mode) ? mode : fallback;
|
|
}
|
|
|
|
function normalizeTheme(theme: DiffTheme | undefined, fallback: DiffTheme): DiffTheme {
|
|
return theme && DIFF_THEMES.includes(theme) ? theme : fallback;
|
|
}
|
|
|
|
function normalizeLayout(layout: DiffLayout | undefined, fallback: DiffLayout): DiffLayout {
|
|
return layout && DIFF_LAYOUTS.includes(layout) ? layout : fallback;
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|