fix(image): support data URLs
This commit is contained in:
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
__testing,
|
||||||
createImageTool,
|
createImageTool,
|
||||||
resolveImageModelConfigForTool,
|
resolveImageModelConfigForTool,
|
||||||
} from "./image-tool.js";
|
} from "./image-tool.js";
|
||||||
@@ -132,3 +133,20 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
).rejects.toThrow(/escapes sandbox root/i);
|
).rejects.toThrow(/escapes sandbox root/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("image tool data URL support", () => {
|
||||||
|
it("decodes base64 image data URLs", () => {
|
||||||
|
const pngB64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||||
|
const out = __testing.decodeDataUrl(`data:image/png;base64,${pngB64}`);
|
||||||
|
expect(out.kind).toBe("image");
|
||||||
|
expect(out.mimeType).toBe("image/png");
|
||||||
|
expect(out.buffer.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-image data URLs", () => {
|
||||||
|
expect(() =>
|
||||||
|
__testing.decodeDataUrl("data:text/plain;base64,SGVsbG8="),
|
||||||
|
).toThrow(/Unsupported data URL type/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,6 +31,30 @@ const DEFAULT_PROMPT = "Describe the image.";
|
|||||||
|
|
||||||
type ImageModelConfig = { primary?: string; fallbacks?: string[] };
|
type ImageModelConfig = { primary?: string; fallbacks?: string[] };
|
||||||
|
|
||||||
|
function decodeDataUrl(dataUrl: string): {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
kind: "image";
|
||||||
|
} {
|
||||||
|
const trimmed = dataUrl.trim();
|
||||||
|
const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(trimmed);
|
||||||
|
if (!match) throw new Error("Invalid data URL (expected base64 data: URL).");
|
||||||
|
const mimeType = (match[1] ?? "").trim().toLowerCase();
|
||||||
|
if (!mimeType.startsWith("image/")) {
|
||||||
|
throw new Error(`Unsupported data URL type: ${mimeType || "unknown"}`);
|
||||||
|
}
|
||||||
|
const b64 = (match[2] ?? "").trim();
|
||||||
|
const buffer = Buffer.from(b64, "base64");
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
throw new Error("Invalid data URL: empty payload.");
|
||||||
|
}
|
||||||
|
return { buffer, mimeType, kind: "image" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __testing = {
|
||||||
|
decodeDataUrl,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function coerceImageModelConfig(cfg?: ClawdbotConfig): ImageModelConfig {
|
function coerceImageModelConfig(cfg?: ClawdbotConfig): ImageModelConfig {
|
||||||
const imageModel = cfg?.agents?.defaults?.imageModel as
|
const imageModel = cfg?.agents?.defaults?.imageModel as
|
||||||
| { primary?: string; fallbacks?: string[] }
|
| { primary?: string; fallbacks?: string[] }
|
||||||
@@ -349,18 +373,31 @@ export function createImageTool(options?: {
|
|||||||
throw new Error("Sandboxed image tool does not allow remote URLs.");
|
throw new Error("Sandboxed image tool does not allow remote URLs.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedImage = sandboxRoot
|
const isDataUrl = /^data:/i.test(imageRaw);
|
||||||
? (
|
const resolvedImage = (() => {
|
||||||
await assertSandboxPath({
|
if (sandboxRoot) return imageRaw;
|
||||||
filePath: imageRaw,
|
if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw);
|
||||||
cwd: sandboxRoot,
|
return imageRaw;
|
||||||
root: sandboxRoot,
|
})();
|
||||||
})
|
const resolvedPath = isDataUrl
|
||||||
).resolved
|
? null
|
||||||
: imageRaw.startsWith("~")
|
: sandboxRoot
|
||||||
? resolveUserPath(imageRaw)
|
? (
|
||||||
: imageRaw;
|
await assertSandboxPath({
|
||||||
const media = await loadWebMedia(resolvedImage, maxBytes);
|
filePath: resolvedImage.startsWith("file://")
|
||||||
|
? resolvedImage.slice("file://".length)
|
||||||
|
: resolvedImage,
|
||||||
|
cwd: sandboxRoot,
|
||||||
|
root: sandboxRoot,
|
||||||
|
})
|
||||||
|
).resolved
|
||||||
|
: resolvedImage.startsWith("file://")
|
||||||
|
? resolvedImage.slice("file://".length)
|
||||||
|
: resolvedImage;
|
||||||
|
|
||||||
|
const media = isDataUrl
|
||||||
|
? decodeDataUrl(resolvedImage)
|
||||||
|
: await loadWebMedia(resolvedPath ?? resolvedImage, maxBytes);
|
||||||
if (media.kind !== "image") {
|
if (media.kind !== "image") {
|
||||||
throw new Error(`Unsupported media type: ${media.kind}`);
|
throw new Error(`Unsupported media type: ${media.kind}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user