diff --git a/containers/image-converter/Dockerfile b/containers/image-converter/Dockerfile deleted file mode 100644 index e422c5ce2..000000000 --- a/containers/image-converter/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM node:22-trixie-slim - -RUN apt-get update \ - && apt-get install -y --no-install-recommends libvips-tools \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -COPY server.mjs /app/server.mjs - -EXPOSE 8080 -CMD ["node", "/app/server.mjs"] diff --git a/containers/image-converter/server.mjs b/containers/image-converter/server.mjs deleted file mode 100644 index 59ccf9f5f..000000000 --- a/containers/image-converter/server.mjs +++ /dev/null @@ -1,170 +0,0 @@ -import { createServer } from "node:http"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Readable } from "node:stream"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); -const port = 8080; -const maxInputBytes = 100 * 1024 * 1024; -const jpegQuality = 92; -const maxOutputBytes = 1024 * 1024; -const outputProfiles = [ - { dimension: 2048, quality: 85 }, - { dimension: 1280, quality: 75 }, - { dimension: 768, quality: 65 }, - { dimension: 384, quality: 55 }, -]; - -createServer(async (request, response) => { - if (request.method === "GET" && request.url === "/health") { - send(response, 200, "ok"); - return; - } - - const isStandardConversion = request.method === "POST" && request.url === "/convert/jpeg"; - const isChatConversion = request.method === "POST" && request.url === "/convert/chat-jpeg"; - - if (!isStandardConversion && !isChatConversion) { - send(response, 404, "Not found."); - return; - } - - try { - const jpeg = await convertRequestImageToJpeg(request, { bounded: isChatConversion }); - response.writeHead(200, { - "Content-Length": String(jpeg.byteLength), - "Content-Type": "image/jpeg", - }); - response.end(jpeg); - } catch (error) { - const status = error instanceof ConversionError ? error.status : 500; - send(response, status, error instanceof Error ? error.message : "Image conversion failed."); - } -}).listen(port, "0.0.0.0"); - -async function convertRequestImageToJpeg(incomingMessage, options) { - const contentLength = Number(incomingMessage.headers["content-length"] ?? 0); - - if (!Number.isFinite(contentLength) || contentLength <= 0) { - throw new ConversionError(400, "Missing image upload."); - } - - if (contentLength > maxInputBytes) { - throw new ConversionError(400, "Image upload is too large."); - } - - const request = new Request(`http://image-file-converter${incomingMessage.url}`, { - body: Readable.toWeb(incomingMessage), - duplex: "half", - headers: incomingMessage.headers, - method: incomingMessage.method, - }); - const formData = await request.formData(); - const file = formData.get("file"); - - if (!(file instanceof File)) { - throw new ConversionError(400, "Missing image upload."); - } - - const inputBytes = Buffer.from(await file.arrayBuffer()); - - if (inputBytes.byteLength === 0) { - throw new ConversionError(400, "Missing image upload."); - } - - return convertImageBytesToJpeg(inputBytes, options); -} - -async function convertImageBytesToJpeg(inputBytes, options) { - const tempDir = await mkdtemp(join(tmpdir(), "thinkex-image-")); - - try { - const inputPath = join(tempDir, "input.heic"); - - await writeFile(inputPath, inputBytes); - if (!options.bounded) { - const outputPath = join(tempDir, "output.jpg"); - await execFileAsync("vips", ["autorot", inputPath, `${outputPath}[Q=${jpegQuality}]`], { - timeout: 60_000, - }); - return requireImageOutput(await readFile(outputPath)); - } - - for (const [index, profile] of outputProfiles.entries()) { - const outputPath = join(tempDir, `output-${index}.jpg`); - await createJpegThumbnail(inputPath, outputPath, profile); - const outputBytes = await readFile(outputPath); - - requireImageOutput(outputBytes); - - if (outputBytes.byteLength <= maxOutputBytes) { - return outputBytes; - } - } - - throw new ConversionError(422, "Image conversion did not produce an output."); - } catch (error) { - if (error instanceof ConversionError) { - throw error; - } - - const message = getProcessErrorMessage(error); - throw new ConversionError(422, message || "Image conversion failed."); - } finally { - await rm(tempDir, { force: true, recursive: true }); - } -} - -function requireImageOutput(outputBytes) { - if (outputBytes.byteLength === 0) { - throw new ConversionError(422, "Image conversion returned an empty JPEG."); - } - - return outputBytes; -} - -async function createJpegThumbnail(inputPath, outputPath, profile) { - await execFileAsync( - "vips", - [ - "thumbnail", - inputPath, - `${outputPath}[Q=${profile.quality},strip]`, - String(profile.dimension), - "--height", - String(profile.dimension), - "--size", - "down", - "--auto-rotate", - ], - { timeout: 60_000 }, - ); -} - -function send(response, status, message) { - response.writeHead(status, { - "Content-Type": "text/plain; charset=utf-8", - }); - response.end(message); -} - -function getProcessErrorMessage(error) { - if (typeof error !== "object" || error === null) { - return ""; - } - - const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim() : ""; - const message = error instanceof Error ? error.message.trim() : ""; - - return stderr || message; -} - -class ConversionError extends Error { - constructor(status, message) { - super(message); - this.status = status; - } -} diff --git a/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx b/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx index a7bbfc9e0..136914181 100644 --- a/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx @@ -21,6 +21,7 @@ import { AttachmentTrigger, } from "#/components/ui/attachment"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "#/components/ui/dialog"; +import { Skeleton } from "#/components/ui/skeleton"; import { Spinner } from "#/components/ui/spinner"; import type { AttachmentData, @@ -51,7 +52,7 @@ export function AiChatAttachmentItem({ data: AttachmentData; onRemove?: () => void; }) { - if (isPreviewableImageAttachment(data)) { + if (isImageAttachment(data)) { return ; } @@ -64,57 +65,63 @@ export function AiChatAttachmentItem({ ); } -function isPreviewableImageAttachment( - data: AttachmentData, -): data is FileAttachmentData & { status: "ready"; url: string } { - return ( - data.type === "file" && - data.status === "ready" && - getMediaCategory(data) === "image" && - Boolean(data.url) - ); +function isImageAttachment(data: AttachmentData): data is FileAttachmentData { + return data.type === "file" && getMediaCategory(data) === "image"; } function AiChatImageAttachment({ data, onRemove, }: { - data: FileAttachmentData & { status: "ready"; url: string }; + data: FileAttachmentData; onRemove?: () => void; }) { const [isOpen, setIsOpen] = useState(false); const label = getAttachmentLabel(data); + const imageUrl = data.status === "ready" ? data.url : undefined; return ( <> - {label} + {imageUrl ? ( + {label} + ) : ( + <> + - setIsOpen(true)} /> + {imageUrl ? ( + setIsOpen(true)} /> + ) : null} - - - - {label} - -
- {label} -
-
-
+ {imageUrl ? ( + + + + {label} + +
+ {label} +
+
+
+ ) : null} ); } diff --git a/src/features/workspaces/conversion/errors.ts b/src/features/workspaces/conversion/errors.ts index edccbac99..e51bc60c4 100644 --- a/src/features/workspaces/conversion/errors.ts +++ b/src/features/workspaces/conversion/errors.ts @@ -1,8 +1,15 @@ +export type WorkspaceFileConversionFailure = "conversion_failed" | "output_too_large"; + export class WorkspaceFileConversionError extends Error { readonly userMessage: string; - constructor(message: string, userMessage: string) { - super(message); + constructor( + message: string, + userMessage: string, + readonly failure: WorkspaceFileConversionFailure = "conversion_failed", + options?: ErrorOptions, + ) { + super(message, options); this.name = "WorkspaceFileConversionError"; this.userMessage = userMessage; } diff --git a/src/features/workspaces/conversion/image-file-converter.ts b/src/features/workspaces/conversion/image-file-converter.ts deleted file mode 100644 index 8d3c40215..000000000 --- a/src/features/workspaces/conversion/image-file-converter.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Container, getRandom } from "@cloudflare/containers"; - -import { - convertFileStreamWithContainer, - convertFileWithContainer, -} from "#/features/workspaces/conversion/container-file-conversion"; -import { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors"; - -const imageConverterPort = 8080; -const imageConverterPath = "/convert/jpeg"; -const chatImageConverterPath = "/convert/chat-jpeg"; -const jpegContentType = "image/jpeg"; -const converterPoolSize = 1; - -export class ImageFileConverter extends Container { - defaultPort = imageConverterPort; - requiredPorts = [imageConverterPort]; - sleepAfter = "5m"; - enableInternet = false; -} - -export interface ConvertImageFileToJpegInput { - file: File; - fileName: string; -} - -export interface ConvertImageFileToJpegResult { - bytes: ArrayBuffer; - contentType: typeof jpegContentType; - sizeBytes: number; -} - -export class ImageFileConversionError extends WorkspaceFileConversionError { - constructor(message: string) { - super(message, "Unable to convert this image right now."); - this.name = "ImageFileConversionError"; - } -} - -export async function convertImageStreamToJpeg( - env: Cloudflare.Env, - input: { - body: ReadableStream; - contentType: string; - fileName: string; - sizeBytes: number; - }, -) { - return convertImageStreamToJpegAtPath(env, input, imageConverterPath); -} - -export async function convertImageStreamToChatJpeg( - env: Cloudflare.Env, - input: { - body: ReadableStream; - contentType: string; - fileName: string; - sizeBytes: number; - }, -) { - return convertImageStreamToJpegAtPath(env, input, chatImageConverterPath); -} - -async function convertImageStreamToJpegAtPath( - env: Cloudflare.Env, - input: { - body: ReadableStream; - contentType: string; - fileName: string; - sizeBytes: number; - }, - path: string, -) { - const converter = await getRandom(env.IMAGE_FILE_CONVERTER, converterPoolSize); - - return convertFileStreamWithContainer({ - ...input, - container: converter, - emptyMessage: "Image conversion returned an empty JPEG.", - error: (message) => new ImageFileConversionError(message), - formFieldName: "file", - url: `http://image-file-converter${path}`, - }); -} - -export async function convertImageFileToChatJpeg( - env: Cloudflare.Env, - input: ConvertImageFileToJpegInput, -): Promise { - return convertImageFileToJpegAtPath(env, input, chatImageConverterPath); -} - -async function convertImageFileToJpegAtPath( - env: Cloudflare.Env, - input: ConvertImageFileToJpegInput, - path: string, -): Promise { - const converter = await getRandom(env.IMAGE_FILE_CONVERTER, converterPoolSize); - const bytes = await convertFileWithContainer({ - container: converter, - emptyMessage: "Image conversion returned an empty JPEG.", - error: (message) => new ImageFileConversionError(message), - file: input.file, - fileName: input.fileName, - formFieldName: "file", - url: `http://image-file-converter${path}`, - }); - - return { - bytes, - contentType: jpegContentType, - sizeBytes: bytes.byteLength, - }; -} diff --git a/src/features/workspaces/conversion/image-normalizer.test.ts b/src/features/workspaces/conversion/image-normalizer.test.ts new file mode 100644 index 000000000..b0d2ea86a --- /dev/null +++ b/src/features/workspaces/conversion/image-normalizer.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors"; +import { + normalizeChatImageToJpeg, + normalizeImageToJpeg, +} from "#/features/workspaces/conversion/image-normalizer"; + +describe("image normalizer", () => { + const maxBytes = 1024 * 1024; + + it("streams canonical JPEG output from the Images binding", async () => { + const output = new Uint8Array([9, 8, 7]); + const images = createImagesBinding([output]); + const result = await normalizeImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ); + + expect(new Uint8Array(await new Response(result.body).arrayBuffer())).toEqual(output); + expect(result.sizeBytes).toBe(output.byteLength); + expect(images.input).toHaveBeenCalledTimes(1); + expect(images.image).toHaveBeenCalledTimes(1); + expect(images.output).toHaveBeenCalledWith({ + anim: false, + background: "#ffffff", + format: "image/jpeg", + quality: 92, + }); + }); + + it("uses one bounded profile for chat images", async () => { + const accepted = new Uint8Array([1, 2, 3, 4]); + const images = createImagesBinding([accepted]); + + const result = await normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ); + + expect(new Uint8Array(result.bytes)).toEqual(accepted); + expect(images.input).toHaveBeenCalledTimes(1); + expect(images.transform).toHaveBeenCalledWith({ + fit: "scale-down", + height: 1024, + width: 1024, + }); + expect(images.output).toHaveBeenCalledWith({ + anim: false, + background: "#ffffff", + format: "image/jpeg", + quality: 70, + }); + }); + + it("translates Images failures at the Module boundary", async () => { + const cause = new Error("binding unavailable"); + const images = createImagesBinding([cause]); + + await expect( + normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ), + ).rejects.toMatchObject({ + cause, + message: "binding unavailable", + name: "ImageNormalizationError", + userMessage: "Unable to convert this image right now.", + } satisfies Partial); + }); + + it("rejects chat images that exceed the normalized output limit", async () => { + const oversized = new Uint8Array(1024 * 1024 + 1); + const images = createImagesBinding([oversized]); + + await expect( + normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ), + ).rejects.toMatchObject({ + failure: "output_too_large", + name: "ImageNormalizationError", + } satisfies Partial); + expect(images.output).toHaveBeenCalledTimes(1); + }); + + it("bounds canonical workspace JPEG output", async () => { + const images = createImagesBinding([new Uint8Array(maxBytes + 1)]); + + await expect( + normalizeImageToJpeg(createEnv(images.binding), stream(new Uint8Array([1, 2, 3])), maxBytes), + ).rejects.toMatchObject({ + failure: "output_too_large", + name: "ImageNormalizationError", + }); + }); + + it.each(["workspace", "chat"] as const)("rejects empty %s image output", async (kind) => { + const images = createImagesBinding([new Uint8Array()]); + + const result = + kind === "workspace" + ? normalizeImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ) + : normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ); + + await expect(result).rejects.toMatchObject({ + message: "Image conversion returned an empty JPEG.", + name: "ImageNormalizationError", + }); + expect(images.image).toHaveBeenCalledTimes(1); + }); +}); + +function createImagesBinding(outputs: Array) { + let outputIndex = 0; + const image = vi.fn((value: Uint8Array) => stream(value)); + const output = vi.fn(async () => { + const value = outputs[outputIndex++]; + + if (!value) { + throw new Error("Missing fake Images output."); + } + if (value instanceof Error) { + throw value; + } + + return { + contentType: () => "image/jpeg", + image: () => image(value), + response: () => + new Response(value.slice().buffer, { + headers: { + "content-type": "image/jpeg", + }, + }), + } satisfies ImageTransformationResult; + }); + const transform = vi.fn(() => ({ output })); + const input = vi.fn(() => ({ output, transform })); + + return { + binding: { input } as unknown as ImagesBinding, + image, + input, + output, + transform, + }; +} + +function createEnv(images: ImagesBinding) { + return { IMAGES: images } as Cloudflare.Env; +} + +function stream(bytes: Uint8Array) { + const body = new Response(bytes.slice().buffer).body; + + if (!body) { + throw new Error("Test stream was not created."); + } + + return body; +} diff --git a/src/features/workspaces/conversion/image-normalizer.ts b/src/features/workspaces/conversion/image-normalizer.ts new file mode 100644 index 000000000..35666cf10 --- /dev/null +++ b/src/features/workspaces/conversion/image-normalizer.ts @@ -0,0 +1,155 @@ +import { + WorkspaceFileConversionError, + type WorkspaceFileConversionFailure, +} from "#/features/workspaces/conversion/errors"; +import type { SizedResponseBody } from "#/lib/http/sized-response-body"; + +const jpegContentType = "image/jpeg"; +const workspaceProfile = { quality: 92 } as const; +const chatProfile = { dimension: 1024, quality: 70 } as const; + +type ImageProfile = { + dimension?: number; + quality: number; +}; + +interface NormalizedChatImage { + bytes: ArrayBuffer; + contentType: typeof jpegContentType; + sizeBytes: number; +} + +class ImageNormalizationError extends WorkspaceFileConversionError { + constructor( + message: string, + failure: WorkspaceFileConversionFailure = "conversion_failed", + options?: ErrorOptions, + ) { + super(message, "Unable to convert this image right now.", failure, options); + this.name = "ImageNormalizationError"; + } +} + +export async function normalizeImageToJpeg( + env: Cloudflare.Env, + body: ReadableStream, + maxBytes: number, +): Promise { + return translateImageNormalizationErrors(async () => { + const bytes = await normalizeWithProfile(env, body, workspaceProfile, maxBytes); + + return { + body: new Blob([bytes], { type: jpegContentType }).stream(), + sizeBytes: bytes.byteLength, + }; + }); +} + +export async function normalizeChatImageToJpeg( + env: Cloudflare.Env, + body: ReadableStream, + maxBytes: number, +): Promise { + return translateImageNormalizationErrors(async () => { + const bytes = await normalizeWithProfile(env, body, chatProfile, maxBytes); + + return { + bytes, + contentType: jpegContentType, + sizeBytes: bytes.byteLength, + }; + }); +} + +async function normalizeWithProfile( + env: Cloudflare.Env, + body: ReadableStream, + profile: ImageProfile, + maxBytes: number, +): Promise { + let transformer = env.IMAGES.input(body); + + if (profile.dimension) { + transformer = transformer.transform({ + fit: "scale-down", + height: profile.dimension, + width: profile.dimension, + }); + } + + const result = await transformer.output({ + anim: false, + background: "#ffffff", + format: jpegContentType, + quality: profile.quality, + }); + + return readStreamWithinLimit(result.image(), maxBytes); +} + +async function readStreamWithinLimit( + body: ReadableStream, + maxBytes: number, +): Promise { + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (totalBytes + value.byteLength > maxBytes) { + await reader.cancel("Image output exceeds the byte limit."); + throw createImageOutputTooLargeError(maxBytes); + } + + chunks.push(value); + totalBytes += value.byteLength; + } + } finally { + reader.releaseLock(); + } + + if (totalBytes === 0) { + throw createEmptyImageError(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + return bytes.buffer; +} + +function createEmptyImageError() { + return new ImageNormalizationError("Image conversion returned an empty JPEG."); +} + +function createImageOutputTooLargeError(maxBytes: number) { + return new ImageNormalizationError( + `Cloudflare Images output exceeds the ${maxBytes}-byte limit.`, + "output_too_large", + ); +} + +async function translateImageNormalizationErrors(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof ImageNormalizationError) { + throw error; + } + + throw new ImageNormalizationError( + error instanceof Error ? error.message : "Image normalization failed.", + "conversion_failed", + { cause: error }, + ); + } +} diff --git a/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts b/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts index 918e563fc..3aedc7cf8 100644 --- a/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts +++ b/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts @@ -1,4 +1,5 @@ -import { convertImageStreamToChatJpeg } from "#/features/workspaces/conversion/image-file-converter"; +import { normalizeChatImageToJpeg } from "#/features/workspaces/conversion/image-normalizer"; +import { WORKSPACE_AI_CHAT_ATTACHMENT_POLICY } from "#/features/workspaces/ai/chat-attachment-policy"; import type { MarkdownExtractionProvider, MarkdownExtractionResult, @@ -9,17 +10,15 @@ export function createWorkersAiToMarkdownProvider(env: Env): MarkdownExtractionP return { id: "workers_ai_to_markdown", async extract(input) { - const conversion = await convertImageStreamToChatJpeg(env, { - body: input.body, - contentType: input.contentType, - fileName: input.fileName, - sizeBytes: input.sizeBytes, - }); - const bytes = await new Response(conversion.body).arrayBuffer(); + const conversion = await normalizeChatImageToJpeg( + env, + input.body, + WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize, + ); const result = await env.AI.toMarkdown( { name: input.fileName, - blob: new Blob([bytes], { + blob: new Blob([conversion.bytes], { type: "image/jpeg", }), }, diff --git a/src/features/workspaces/model/workspace-file/limits.ts b/src/features/workspaces/model/workspace-file/limits.ts index 4fbc68117..deb5b0a1c 100644 --- a/src/features/workspaces/model/workspace-file/limits.ts +++ b/src/features/workspaces/model/workspace-file/limits.ts @@ -1,6 +1,7 @@ export const workspaceFileUploadLimits = { maxFilesPerSelection: 50, maxFileBytes: 100 * 1024 * 1024, + maxImageFileBytes: 20 * 1024 * 1024, maxSelectionBytes: 100 * 1024 * 1024, maxDocumentImportBytes: 10 * 1024 * 1024, concurrency: 3, diff --git a/src/features/workspaces/upload/workspace-file-upload-storage.test.ts b/src/features/workspaces/upload/workspace-file-upload-storage.test.ts index be85e0086..60bf94fdf 100644 --- a/src/features/workspaces/upload/workspace-file-upload-storage.test.ts +++ b/src/features/workspaces/upload/workspace-file-upload-storage.test.ts @@ -6,14 +6,15 @@ import { } from "#/features/workspaces/model/workspace-file"; import { finalizeWorkspaceFileUploadStorage } from "#/features/workspaces/upload/workspace-file-upload-storage"; -const { convertImageStreamToJpeg, convertOfficeStreamToPdf, createWorkspaceFilePreview } = - vi.hoisted(() => ({ - convertImageStreamToJpeg: vi.fn(), +const { normalizeImageToJpeg, convertOfficeStreamToPdf, createWorkspaceFilePreview } = vi.hoisted( + () => ({ + normalizeImageToJpeg: vi.fn(), convertOfficeStreamToPdf: vi.fn(), createWorkspaceFilePreview: vi.fn(), - })); -vi.mock("#/features/workspaces/conversion/image-file-converter", () => ({ - convertImageStreamToJpeg, + }), +); +vi.mock("#/features/workspaces/conversion/image-normalizer", () => ({ + normalizeImageToJpeg, })); vi.mock("#/features/workspaces/conversion/office-pdf-converter", () => ({ convertOfficeStreamToPdf, @@ -45,7 +46,7 @@ beforeAll(() => { describe("workspace file upload storage", () => { beforeEach(() => { - convertImageStreamToJpeg.mockReset(); + normalizeImageToJpeg.mockReset(); convertOfficeStreamToPdf.mockReset(); createWorkspaceFilePreview.mockReset().mockResolvedValue({ body: stream(previewBytes), @@ -90,7 +91,7 @@ describe("workspace file upload storage", () => { it("streams conversion output to R2 and records source provenance", async () => { const bucket = createR2Bucket(); const converted = new Uint8Array([9, 8, 7]); - convertImageStreamToJpeg.mockResolvedValue({ + normalizeImageToJpeg.mockResolvedValue({ body: stream(converted), sizeBytes: converted.byteLength, }); diff --git a/src/features/workspaces/upload/workspace-file-upload-storage.ts b/src/features/workspaces/upload/workspace-file-upload-storage.ts index 662947888..e9dd962f4 100644 --- a/src/features/workspaces/upload/workspace-file-upload-storage.ts +++ b/src/features/workspaces/upload/workspace-file-upload-storage.ts @@ -1,4 +1,4 @@ -import { convertImageStreamToJpeg } from "#/features/workspaces/conversion/image-file-converter"; +import { normalizeImageToJpeg } from "#/features/workspaces/conversion/image-normalizer"; import { convertOfficeStreamToPdf } from "#/features/workspaces/conversion/office-pdf-converter"; import { createWorkspaceFilePreview, @@ -36,21 +36,6 @@ export interface StoredWorkspaceFileUpload { type PreparedWorkspaceFileUpload = Omit; -type WorkspaceUploadStreamConverter = ( - env: Cloudflare.Env, - input: { - body: ReadableStream; - contentType: string; - fileName: string; - sizeBytes: number; - }, -) => Promise<{ body: ReadableStream; sizeBytes: number }>; - -const defaultConverters = { - heic_to_jpeg: convertImageStreamToJpeg, - office_to_pdf: convertOfficeStreamToPdf, -} satisfies Record; - interface FinalizeWorkspaceFileUploadStorageInput { contentType: string; descriptor: WorkspaceFileTypeDescriptor; @@ -156,13 +141,7 @@ async function convertAndStoreWorkspaceFileUpload( throw new Error("Converted workspace uploads must use a temporary input object."); } - const response = await defaultConverters[conversion](input.env, { - body: input.uploadedObject.body, - contentType: input.contentType, - fileName: input.fileName, - sizeBytes: input.fileSize, - }); - const contentType = getConvertedContentType(conversion); + const { contentType, response } = await convertWorkspaceFileUpload(input, conversion); const fileName = getWorkspaceConvertedFileName(input.fileName, conversion); const descriptor = requireWorkspaceFileTypeFromHint({ fileName, contentType }); const stored = await putFixedLengthR2Object( @@ -194,8 +173,30 @@ async function convertAndStoreWorkspaceFileUpload( }; } -function getConvertedContentType(conversion: WorkspaceUploadConversion) { - return conversion === "office_to_pdf" ? "application/pdf" : "image/jpeg"; +async function convertWorkspaceFileUpload( + input: FinalizeWorkspaceFileUploadStorageInput, + conversion: WorkspaceUploadConversion, +) { + if (conversion === "office_to_pdf") { + return { + contentType: "application/pdf", + response: await convertOfficeStreamToPdf(input.env, { + body: input.uploadedObject.body, + contentType: input.contentType, + fileName: input.fileName, + sizeBytes: input.fileSize, + }), + }; + } + + return { + contentType: "image/jpeg", + response: await normalizeImageToJpeg( + input.env, + input.uploadedObject.body, + workspaceFileUploadLimits.maxImageFileBytes, + ), + }; } function createConvertedFileSizeError(): WorkspaceFileUploadError { diff --git a/src/features/workspaces/upload/workspace-upload-intake.test.ts b/src/features/workspaces/upload/workspace-upload-intake.test.ts index ae6bedb64..992f5249c 100644 --- a/src/features/workspaces/upload/workspace-upload-intake.test.ts +++ b/src/features/workspaces/upload/workspace-upload-intake.test.ts @@ -32,6 +32,30 @@ describe("workspace upload intake", () => { }); }); + it("bounds images at the Images binding input limit", () => { + expect( + validateWorkspaceUpload({ + contentType: "image/heic", + fileName: "photo.heic", + sizeBytes: workspaceFileUploadLimits.maxImageFileBytes, + }), + ).toMatchObject({ ok: true, plan: { kind: "file" } }); + expect( + validateWorkspaceUpload({ + contentType: "image/jpeg", + fileName: "photo.jpg", + sizeBytes: workspaceFileUploadLimits.maxImageFileBytes + 1, + }), + ).toMatchObject({ + error: { + code: "SELECTION_TOO_LARGE", + message: "Upload images up to 20 MB.", + status: 413, + }, + ok: false, + }); + }); + it("bounds document imports before they are materialized in Worker memory", () => { expect( validateWorkspaceUpload({ diff --git a/src/features/workspaces/upload/workspace-upload-intake.ts b/src/features/workspaces/upload/workspace-upload-intake.ts index 7523c4cb2..15296731e 100644 --- a/src/features/workspaces/upload/workspace-upload-intake.ts +++ b/src/features/workspaces/upload/workspace-upload-intake.ts @@ -134,6 +134,21 @@ export function validateWorkspaceUpload(input: { }; } + if ( + plan.kind === "file" && + plan.descriptor.assetKind === "image" && + input.sizeBytes > workspaceFileUploadLimits.maxImageFileBytes + ) { + return { + error: { + code: "SELECTION_TOO_LARGE", + message: "Upload images up to 20 MB.", + status: 413, + }, + ok: false, + }; + } + if (input.sizeBytes > workspaceFileUploadLimits.maxFileBytes) { return { error: { diff --git a/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts b/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts index 0561bab42..1699b425c 100644 --- a/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts +++ b/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts @@ -7,8 +7,8 @@ import { getChatAttachmentContentUrl, getChatAttachmentObjectKey, } from "#/features/workspaces/ai/chat-attachment-storage"; -import { convertImageFileToChatJpeg } from "#/features/workspaces/conversion/image-file-converter"; import { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors"; +import { normalizeChatImageToJpeg } from "#/features/workspaces/conversion/image-normalizer"; import { observeWorkspaceFileIntake, type WorkspaceFileIntakeObservation, @@ -72,22 +72,14 @@ async function executeChatAttachmentUpload( ); } - const normalized = await convertImageFileToChatJpeg(env, { - file, - fileName: file.name, - }); + const normalized = await normalizeChatImageToJpeg( + env, + file.stream(), + WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize, + ); observation.conversion = "image_to_jpeg"; observation.outputBytes = normalized.sizeBytes; - if (normalized.sizeBytes > WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize) { - return apiError( - requestId, - 413, - "ATTACHMENT_TOO_LARGE", - "This image is too detailed to attach after optimization.", - ); - } - const attachmentId = crypto.randomUUID(); const identity = { attachmentId, threadId, workspaceId }; objectKey = getChatAttachmentObjectKey({ ...identity, userId: authorized.userId }); @@ -112,6 +104,14 @@ async function executeChatAttachmentUpload( } catch (error) { observation.error = error; if (error instanceof WorkspaceFileConversionError) { + if (error.failure === "output_too_large") { + return apiError( + requestId, + 413, + "ATTACHMENT_TOO_LARGE", + "This image is too detailed to attach after optimization.", + ); + } return apiError(requestId, 422, "CONVERSION_FAILED", error.userMessage); } diff --git a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts index 439b1d8d3..27601cb4e 100644 --- a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts +++ b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts @@ -354,6 +354,14 @@ function workspaceUploadErrorResponse(requestId: string, error: unknown) { } if (error instanceof WorkspaceFileConversionError) { + if (error.failure === "output_too_large") { + return apiError( + requestId, + 413, + "SELECTION_TOO_LARGE", + "This image is too detailed to upload after optimization.", + ); + } return apiError(requestId, 422, "CONVERSION_FAILED", error.userMessage); } diff --git a/src/server.ts b/src/server.ts index f2ecb2d21..039b2143b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,6 @@ import { buildContentSecurityPolicy } from "#/lib/http/content-security-policy"; export { CodemodeRuntime } from "@cloudflare/codemode"; export { Sandbox } from "@cloudflare/sandbox"; export { AIThread, UserAIStore } from "#/features/workspaces/ai/user-ai-agents"; -export { ImageFileConverter } from "#/features/workspaces/conversion/image-file-converter"; export { OfficePdfConverter } from "#/features/workspaces/conversion/office-pdf-converter"; export { DocumentSession } from "#/features/workspaces/documents/document-session"; export { WorkspaceFileExtractionWorkflow } from "#/features/workspaces/extraction/workspace-file-extraction-workflow"; diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index a9e7da700..5ff33da39 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 58de4e88b517f3692dd84708e9339a59) -// Runtime types generated with workerd@1.20260701.1 2026-03-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 5348c83093b741e804d43d2ed057d3fa) +// Runtime types generated with workerd@1.20260722.1 2026-03-24 nodejs_compat interface __BaseEnv_Env { WORKSPACE_KERNEL_FILES: R2Bucket; DB: D1Database; @@ -8,6 +8,7 @@ interface __BaseEnv_Env { LOADER: WorkerLoader; BROWSER: BrowserRun; AI: Ai; + IMAGES: ImagesBinding; CF_VERSION_METADATA: WorkerVersionMetadata; FIRECRAWL_API_URL: "https://api.firecrawl.dev"; R2_BUCKET_NAME: "thinkex-workspace-kernel-files-staging" | "thinkex-workspace-kernel-files"; @@ -30,14 +31,13 @@ interface __BaseEnv_Env { DocumentSession: DurableObjectNamespace; CODE_SANDBOX: DurableObjectNamespace; OFFICE_PDF_CONVERTER: DurableObjectNamespace; - IMAGE_FILE_CONVERTER: DurableObjectNamespace; WORKSPACE_FILE_PROCESSOR: DurableObjectNamespace; WORKSPACE_FILE_EXTRACTION_WORKFLOW: Workflow[0]['payload']>; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/server"); - durableNamespaces: "UserAIStore" | "WorkspaceKernel" | "DocumentSession" | "OfficePdfConverter" | "Sandbox" | "ImageFileConverter" | "WorkspaceFileProcessor"; + durableNamespaces: "UserAIStore" | "WorkspaceKernel" | "DocumentSession" | "OfficePdfConverter" | "Sandbox" | "WorkspaceFileProcessor"; } interface StagingEnv { WORKSPACE_KERNEL_FILES: R2Bucket; @@ -46,6 +46,7 @@ declare namespace Cloudflare { LOADER: WorkerLoader; BROWSER: BrowserRun; AI: Ai; + IMAGES: ImagesBinding; CF_VERSION_METADATA: WorkerVersionMetadata; FIRECRAWL_API_URL: "https://api.firecrawl.dev"; R2_BUCKET_NAME: "thinkex-workspace-kernel-files-staging"; @@ -68,7 +69,6 @@ declare namespace Cloudflare { DocumentSession: DurableObjectNamespace; CODE_SANDBOX: DurableObjectNamespace; OFFICE_PDF_CONVERTER: DurableObjectNamespace; - IMAGE_FILE_CONVERTER: DurableObjectNamespace; WORKSPACE_FILE_PROCESSOR: DurableObjectNamespace; WORKSPACE_FILE_EXTRACTION_WORKFLOW: Workflow[0]['payload']>; } @@ -79,6 +79,7 @@ declare namespace Cloudflare { LOADER: WorkerLoader; BROWSER: BrowserRun; AI: Ai; + IMAGES: ImagesBinding; CF_VERSION_METADATA: WorkerVersionMetadata; FIRECRAWL_API_URL: "https://api.firecrawl.dev"; R2_BUCKET_NAME: "thinkex-workspace-kernel-files"; @@ -101,7 +102,6 @@ declare namespace Cloudflare { DocumentSession: DurableObjectNamespace; CODE_SANDBOX: DurableObjectNamespace; OFFICE_PDF_CONVERTER: DurableObjectNamespace; - IMAGE_FILE_CONVERTER: DurableObjectNamespace; WORKSPACE_FILE_PROCESSOR: DurableObjectNamespace; WORKSPACE_FILE_EXTRACTION_WORKFLOW: Workflow[0]['payload']>; } @@ -628,7 +628,7 @@ declare abstract class DurableObjectNamespace; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; } -type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us"; interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } @@ -12323,6 +12323,13 @@ interface ForwardableEmailMessage extends EmailMessage { * @returns A promise that resolves when the email message is replied. */ reply(message: EmailMessage): Promise; + /** + * Reply to the sender of this email message with a message built from the given + * fields. Threading headers (In-Reply-To/References) are set automatically. + * @param builder The reply message contents. + * @returns A promise that resolves when the email message is replied. + */ + reply(builder: EmailReplyMessageBuilder): Promise; } /** A file attachment for an email message */ type EmailAttachment = { @@ -12343,23 +12350,46 @@ interface EmailAddress { name: string; email: string; } +/** + * Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or + * `bcc` must be provided. + */ +type EmailDestinations = { + to?: string | EmailAddress | (string | EmailAddress)[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; +} & ({ + to: string | EmailAddress | (string | EmailAddress)[]; +} | { + cc: string | EmailAddress | (string | EmailAddress)[]; +} | { + bcc: string | EmailAddress | (string | EmailAddress)[]; +}); +/** + * Fields shared by all composed emails (no recipients). Used directly by + * `ForwardableEmailMessage.reply()`, which always replies to the original + * sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`. + */ +interface EmailReplyMessageBuilder { + from: string | EmailAddress; + subject: string; + replyTo?: string | EmailAddress; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; +} +/** + * Fields for composing an email without constructing raw MIME, for + * `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`. + */ +type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations; /** * A binding that allows a Worker to send email messages. */ interface SendEmail { send(message: EmailMessage): Promise; - send(builder: { - from: string | EmailAddress; - to: string | EmailAddress | (string | EmailAddress)[]; - subject: string; - replyTo?: string | EmailAddress; - cc?: string | EmailAddress | (string | EmailAddress)[]; - bcc?: string | EmailAddress | (string | EmailAddress)[]; - headers?: Record; - text?: string; - html?: string; - attachments?: EmailAttachment[]; - }): Promise; + send(builder: EmailMessageBuilder): Promise; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; @@ -13181,6 +13211,11 @@ declare namespace CloudflareWorkersModule { export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowDynamicDelayContext = { + ctx: WorkflowStepContext; + error: Error; + }; + export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise; export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; @@ -13188,7 +13223,7 @@ declare namespace CloudflareWorkersModule { export type WorkflowStepConfig = { retries?: { limit: number; - delay: WorkflowDelayDuration | number; + delay: WorkflowDelayDuration | number | WorkflowDelayFunction; backoff?: WorkflowBackoff; }; timeout?: WorkflowTimeoutDuration | number; @@ -13214,13 +13249,22 @@ declare namespace CloudflareWorkersModule { type: string; sensitive?: WorkflowStepSensitivity; }; - export type WorkflowStepContext = { + export type WorkflowStepContext = { step: { name: string; count: number; }; attempt: number; - config: WorkflowStepConfig; + config: { + retries?: { + limit: number; + backoff?: WorkflowBackoff; + } & (Delay extends WorkflowDelayFunction ? {} : { + delay: WorkflowDelayDuration | number; + }); + timeout?: WorkflowTimeoutDuration | number; + sensitive?: WorkflowStepSensitivity; + }; }; export type WorkflowRollbackContext = { ctx: WorkflowStepContext; @@ -13236,7 +13280,9 @@ declare namespace CloudflareWorkersModule { }; export abstract class WorkflowStep { do>(name: string, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; - do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; + do, const C extends WorkflowStepConfig>(name: string, config: C, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { @@ -14146,6 +14192,7 @@ declare namespace TailStream { readonly dispatchNamespace?: string; readonly entrypoint?: string; readonly executionModel: string; + readonly durableObjectId?: string; readonly scriptName?: string; readonly scriptTags?: string[]; readonly scriptVersion?: ScriptVersion; @@ -14700,6 +14747,13 @@ interface WorkflowError { code?: number; message: string; } +interface WorkflowInstanceTerminateOptions { + /** + * If true, run registered rollback handlers before terminating the instance. + * Only steps that registered rollback handlers are rolled back. + */ + rollback?: boolean; +} interface WorkflowInstanceRestartOptions { /** * Restart from a specific step. If omitted, the instance restarts from the beginning. @@ -14733,8 +14787,9 @@ declare abstract class WorkflowInstance { public resume(): Promise; /** * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + * @param options Options for termination, including whether registered rollback handlers should run. */ - public terminate(): Promise; + public terminate(options?: WorkflowInstanceTerminateOptions): Promise; /** * Restart the instance. Optionally restart from a specific step, preserving * cached results for all steps before it. diff --git a/wrangler.jsonc b/wrangler.jsonc index f11e1ff58..1eed3b57b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -34,6 +34,9 @@ "binding": "AI", "remote": true, }, + "images": { + "binding": "IMAGES", + }, "worker_loaders": [ { "binding": "LOADER", @@ -97,12 +100,6 @@ "instance_type": "standard-1", "max_instances": 1, }, - { - "class_name": "ImageFileConverter", - "image": "./containers/image-converter/Dockerfile", - "instance_type": "lite", - "max_instances": 1, - }, { "class_name": "WorkspaceFileProcessor", "image": "./containers/liteparse/Dockerfile", @@ -133,10 +130,6 @@ "name": "OFFICE_PDF_CONVERTER", "class_name": "OfficePdfConverter", }, - { - "name": "IMAGE_FILE_CONVERTER", - "class_name": "ImageFileConverter", - }, { "name": "WORKSPACE_FILE_PROCESSOR", "class_name": "WorkspaceFileProcessor", @@ -181,6 +174,10 @@ }, ], }, + { + "tag": "v10_remove_image_file_converter", + "deleted_classes": ["ImageFileConverter"], + }, ], "d1_databases": [ { @@ -229,6 +226,9 @@ "binding": "AI", "remote": true, }, + "images": { + "binding": "IMAGES", + }, "worker_loaders": [ { "binding": "LOADER", @@ -269,12 +269,6 @@ "instance_type": "standard-1", "max_instances": 2, }, - { - "class_name": "ImageFileConverter", - "image": "./containers/image-converter/Dockerfile", - "instance_type": "basic", - "max_instances": 1, - }, { "class_name": "WorkspaceFileProcessor", "image": "./containers/liteparse/Dockerfile", @@ -304,10 +298,6 @@ "name": "OFFICE_PDF_CONVERTER", "class_name": "OfficePdfConverter", }, - { - "name": "IMAGE_FILE_CONVERTER", - "class_name": "ImageFileConverter", - }, { "name": "WORKSPACE_FILE_PROCESSOR", "class_name": "WorkspaceFileProcessor", @@ -360,6 +350,9 @@ "binding": "AI", "remote": true, }, + "images": { + "binding": "IMAGES", + }, "worker_loaders": [ { "binding": "LOADER", @@ -400,12 +393,6 @@ "instance_type": "standard-1", "max_instances": 2, }, - { - "class_name": "ImageFileConverter", - "image": "./containers/image-converter/Dockerfile", - "instance_type": "basic", - "max_instances": 1, - }, { "class_name": "WorkspaceFileProcessor", "image": "./containers/liteparse/Dockerfile", @@ -436,10 +423,6 @@ "name": "OFFICE_PDF_CONVERTER", "class_name": "OfficePdfConverter", }, - { - "name": "IMAGE_FILE_CONVERTER", - "class_name": "ImageFileConverter", - }, { "name": "WORKSPACE_FILE_PROCESSOR", "class_name": "WorkspaceFileProcessor",