From 6fd923323e27006a96aced4eff5bf8a97775d4f9 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:09:12 -0400 Subject: [PATCH 1/6] feat(images): normalize uploads with Cloudflare Images --- containers/image-converter/Dockerfile | 11 -- containers/image-converter/server.mjs | 170 ------------------ .../conversion/image-file-converter.ts | 114 ------------ .../conversion/image-normalizer.test.ts | 141 +++++++++++++++ .../workspaces/conversion/image-normalizer.ts | 151 ++++++++++++++++ .../providers/workers-ai-to-markdown.ts | 17 +- src/features/workspaces/extraction/types.ts | 1 + .../workspace-file-extraction-workflow.ts | 25 +++ .../workspace-file-upload-storage.test.ts | 17 +- .../upload/workspace-file-upload-storage.ts | 47 +++-- ...paceId.ai-threads.$threadId.attachments.ts | 20 +-- src/server.ts | 1 - worker-configuration.d.ts | 103 ++++++++--- wrangler.jsonc | 43 ++--- 14 files changed, 455 insertions(+), 406 deletions(-) delete mode 100644 containers/image-converter/Dockerfile delete mode 100644 containers/image-converter/server.mjs delete mode 100644 src/features/workspaces/conversion/image-file-converter.ts create mode 100644 src/features/workspaces/conversion/image-normalizer.test.ts create mode 100644 src/features/workspaces/conversion/image-normalizer.ts 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/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..13771a09e --- /dev/null +++ b/src/features/workspaces/conversion/image-normalizer.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ImageNormalizationError, + 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])), + ); + + expect(new Uint8Array(await new Response(result.body).arrayBuffer())).toEqual(output); + expect(result.sizeBytes).toBe(output.byteLength); + expect(images.input).toHaveBeenCalledTimes(1); + expect(images.output).toHaveBeenCalledWith({ + anim: false, + background: "#ffffff", + format: "image/jpeg", + quality: 92, + }); + }); + + it("uses progressively smaller chat profiles until output fits one MiB", async () => { + const oversized = new Uint8Array(1024 * 1024 + 1); + const accepted = new Uint8Array([1, 2, 3, 4]); + const images = createImagesBinding([oversized, accepted]); + const openBody = createOpenBody(); + + const result = await normalizeChatImageToJpeg(createEnv(images.binding), openBody, maxBytes); + + expect(new Uint8Array(result.bytes)).toEqual(accepted); + expect(openBody).toHaveBeenCalledTimes(2); + expect(images.transform).toHaveBeenNthCalledWith(1, { + fit: "scale-down", + height: 2048, + width: 2048, + }); + expect(images.transform).toHaveBeenNthCalledWith(2, { + fit: "scale-down", + height: 1024, + width: 1024, + }); + expect(images.output).toHaveBeenNthCalledWith(1, { + anim: false, + background: "#ffffff", + format: "image/jpeg", + quality: 85, + }); + expect(images.output).toHaveBeenNthCalledWith(2, { + anim: false, + background: "#ffffff", + format: "image/jpeg", + quality: 70, + }); + }); + + it("translates Images failures at the Module boundary", async () => { + const images = createImagesBinding([new Error("binding unavailable")]); + + await expect( + normalizeChatImageToJpeg(createEnv(images.binding), createOpenBody(), maxBytes), + ).rejects.toMatchObject({ + message: "binding unavailable", + name: "ImageNormalizationError", + userMessage: "Unable to convert this image right now.", + } satisfies Partial); + }); + + it("rejects chat images that exceed the limit after both profiles", async () => { + const oversized = new Uint8Array(1024 * 1024 + 1); + const images = createImagesBinding([oversized, oversized]); + const openBody = createOpenBody(); + + await expect( + normalizeChatImageToJpeg(createEnv(images.binding), openBody, maxBytes), + ).rejects.toBeInstanceOf(ImageNormalizationError); + expect(openBody).toHaveBeenCalledTimes(2); + expect(images.output).toHaveBeenCalledTimes(2); + }); +}); + +function createOpenBody() { + return vi.fn(async () => stream(new Uint8Array([1, 2, 3]))); +} + +function createImagesBinding(outputs: Array) { + let outputIndex = 0; + 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: () => stream(value), + response: () => + new Response(value.slice().buffer, { + headers: { + "content-length": String(value.byteLength), + "content-type": "image/jpeg", + }, + }), + } satisfies ImageTransformationResult; + }); + const transform = vi.fn(() => ({ output })); + const input = vi.fn(() => ({ output, transform })); + + return { + binding: { input } as unknown as ImagesBinding, + 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..8ab03a125 --- /dev/null +++ b/src/features/workspaces/conversion/image-normalizer.ts @@ -0,0 +1,151 @@ +import { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors"; +import type { SizedResponseBody } from "#/lib/http/sized-response-body"; +import { requireSizedResponseBody } from "#/lib/http/sized-response-body"; + +const jpegContentType = "image/jpeg"; +const chatProfiles = [ + { dimension: 2048, quality: 85 }, + { dimension: 1024, quality: 70 }, +] as const; + +export type OpenImageBody = () => Promise>; + +export interface NormalizedChatImage { + bytes: ArrayBuffer; + contentType: typeof jpegContentType; + sizeBytes: number; +} + +export class ImageNormalizationError extends WorkspaceFileConversionError { + constructor(message: string) { + super(message, "Unable to convert this image right now."); + this.name = "ImageNormalizationError"; + } +} + +export async function normalizeImageToJpeg( + env: Cloudflare.Env, + body: ReadableStream, +): Promise { + return translateImageNormalizationErrors(async () => { + const result = await env.IMAGES.input(body).output({ + anim: false, + background: "#ffffff", + format: jpegContentType, + quality: 92, + }); + + return requireSizedResponseBody(result.response(), createEmptyImageError); + }); +} + +export async function normalizeChatImageToJpeg( + env: Cloudflare.Env, + openBody: OpenImageBody, + maxBytes: number, +): Promise { + return translateImageNormalizationErrors(async () => { + for (const profile of chatProfiles) { + const result = await env.IMAGES.input(await openBody()) + .transform({ + fit: "scale-down", + height: profile.dimension, + width: profile.dimension, + }) + .output({ + anim: false, + background: "#ffffff", + format: jpegContentType, + quality: profile.quality, + }); + const bytes = await readBodyWithinLimit(result.response(), maxBytes); + + if (bytes) { + return { + bytes, + contentType: jpegContentType, + sizeBytes: bytes.byteLength, + }; + } + } + + throw new ImageNormalizationError( + "Cloudflare Images could not produce a JPEG within the chat attachment limit.", + ); + }); +} + +async function readBodyWithinLimit( + response: Response, + maxBytes: number, +): Promise { + if (!response.body) { + throw createEmptyImageError(); + } + + const contentLength = Number(response.headers.get("content-length")); + if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) { + await response.body.cancel("Image output exceeds the byte limit."); + return null; + } + + return readStreamWithinLimit(response.body, 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."); + return null; + } + + 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."); +} + +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.", + ); + } +} 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..caf0cb1ea 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.openBody, + 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/extraction/types.ts b/src/features/workspaces/extraction/types.ts index 5dbc9b86c..200fa2adc 100644 --- a/src/features/workspaces/extraction/types.ts +++ b/src/features/workspaces/extraction/types.ts @@ -39,6 +39,7 @@ export interface MarkdownExtractionInput { body: ReadableStream; fileName: string; contentType: string; + openBody: () => Promise>; sizeBytes: number; sourceHash: string; mode: MarkdownExtractionProviderMode; diff --git a/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts b/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts index 446226008..aa2844ad6 100644 --- a/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts +++ b/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts @@ -73,6 +73,13 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< body: object.body, fileName: source.fileName, contentType: source.contentType, + openBody: () => + reopenWorkspaceFileSource( + this.env.WORKSPACE_KERNEL_FILES, + source.objectKey, + source.sizeBytes, + object.etag, + ), sizeBytes: source.sizeBytes, sourceHash: object.etag, mode: route.mode, @@ -238,6 +245,24 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< } } +async function reopenWorkspaceFileSource( + bucket: R2Bucket, + objectKey: string, + expectedSizeBytes: number, + expectedEtag: string, +) { + const object = await bucket.get(objectKey); + + if (!object) { + throw new Error("Workspace file source object was not found during extraction."); + } + if (object.size !== expectedSizeBytes || object.etag !== expectedEtag) { + throw new Error("Workspace file source changed during extraction."); + } + + return object.body; +} + interface StagedPageExtractionResult { manifestObjectKey: string; markdownLength: number; 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..9bd21374c 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,26 @@ 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), + }; } function createConvertedFileSizeError(): WorkspaceFileUploadError { 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..be5b0562d 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, + async () => 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 }); 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", From 2d3cfe8ab8903e5f01fb3c33cb04882abb699a26 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:09:56 -0400 Subject: [PATCH 2/6] fix(uploads): enforce image transformation size limit --- .../workspaces/model/workspace-file/limits.ts | 1 + .../upload/workspace-upload-intake.test.ts | 24 +++++++++++++++++++ .../upload/workspace-upload-intake.ts | 15 ++++++++++++ 3 files changed, 40 insertions(+) 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-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: { From 983b1a97533e79152b37fbad98d1770895a0f357 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:46:55 -0400 Subject: [PATCH 3/6] fix(images): consume binding output as a bounded stream Use the Images binding byte-stream interface instead of response headers. Classify output limits for route-specific 413 responses and preserve causes. --- src/features/workspaces/conversion/errors.ts | 11 +- .../conversion/image-normalizer.test.ts | 50 ++++++- .../workspaces/conversion/image-normalizer.ts | 125 ++++++++++-------- .../upload/workspace-file-upload-storage.ts | 6 +- ...paceId.ai-threads.$threadId.attachments.ts | 8 ++ .../v1/workspaces.$workspaceId.file-upload.ts | 8 ++ 6 files changed, 145 insertions(+), 63 deletions(-) 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-normalizer.test.ts b/src/features/workspaces/conversion/image-normalizer.test.ts index 13771a09e..43dd3de15 100644 --- a/src/features/workspaces/conversion/image-normalizer.test.ts +++ b/src/features/workspaces/conversion/image-normalizer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors"; import { - ImageNormalizationError, normalizeChatImageToJpeg, normalizeImageToJpeg, } from "#/features/workspaces/conversion/image-normalizer"; @@ -15,11 +15,13 @@ describe("image normalizer", () => { 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", @@ -63,15 +65,17 @@ describe("image normalizer", () => { }); it("translates Images failures at the Module boundary", async () => { - const images = createImagesBinding([new Error("binding unavailable")]); + const cause = new Error("binding unavailable"); + const images = createImagesBinding([cause]); await expect( normalizeChatImageToJpeg(createEnv(images.binding), createOpenBody(), maxBytes), ).rejects.toMatchObject({ + cause, message: "binding unavailable", name: "ImageNormalizationError", userMessage: "Unable to convert this image right now.", - } satisfies Partial); + } satisfies Partial); }); it("rejects chat images that exceed the limit after both profiles", async () => { @@ -81,10 +85,43 @@ describe("image normalizer", () => { await expect( normalizeChatImageToJpeg(createEnv(images.binding), openBody, maxBytes), - ).rejects.toBeInstanceOf(ImageNormalizationError); + ).rejects.toMatchObject({ + failure: "output_too_large", + name: "ImageNormalizationError", + } satisfies Partial); expect(openBody).toHaveBeenCalledTimes(2); expect(images.output).toHaveBeenCalledTimes(2); }); + + 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), createOpenBody(), maxBytes); + + await expect(result).rejects.toMatchObject({ + message: "Image conversion returned an empty JPEG.", + name: "ImageNormalizationError", + }); + expect(images.image).toHaveBeenCalledTimes(1); + }); }); function createOpenBody() { @@ -93,6 +130,7 @@ function createOpenBody() { function createImagesBinding(outputs: Array) { let outputIndex = 0; + const image = vi.fn((value: Uint8Array) => stream(value)); const output = vi.fn(async () => { const value = outputs[outputIndex++]; @@ -105,11 +143,10 @@ function createImagesBinding(outputs: Array) { return { contentType: () => "image/jpeg", - image: () => stream(value), + image: () => image(value), response: () => new Response(value.slice().buffer, { headers: { - "content-length": String(value.byteLength), "content-type": "image/jpeg", }, }), @@ -120,6 +157,7 @@ function createImagesBinding(outputs: Array) { return { binding: { input } as unknown as ImagesBinding, + image, input, output, transform, diff --git a/src/features/workspaces/conversion/image-normalizer.ts b/src/features/workspaces/conversion/image-normalizer.ts index 8ab03a125..d93e72520 100644 --- a/src/features/workspaces/conversion/image-normalizer.ts +++ b/src/features/workspaces/conversion/image-normalizer.ts @@ -1,24 +1,36 @@ -import { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors"; +import { + WorkspaceFileConversionError, + type WorkspaceFileConversionFailure, +} from "#/features/workspaces/conversion/errors"; import type { SizedResponseBody } from "#/lib/http/sized-response-body"; -import { requireSizedResponseBody } from "#/lib/http/sized-response-body"; const jpegContentType = "image/jpeg"; +const workspaceProfile = { quality: 92 } as const; const chatProfiles = [ { dimension: 2048, quality: 85 }, { dimension: 1024, quality: 70 }, ] as const; -export type OpenImageBody = () => Promise>; +type ImageProfile = { + dimension?: number; + quality: number; +}; -export interface NormalizedChatImage { +type OpenImageBody = () => Promise>; + +interface NormalizedChatImage { bytes: ArrayBuffer; contentType: typeof jpegContentType; sizeBytes: number; } -export class ImageNormalizationError extends WorkspaceFileConversionError { - constructor(message: string) { - super(message, "Unable to convert this image right now."); +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"; } } @@ -26,17 +38,18 @@ export class ImageNormalizationError extends WorkspaceFileConversionError { export async function normalizeImageToJpeg( env: Cloudflare.Env, body: ReadableStream, + maxBytes: number, ): Promise { - return translateImageNormalizationErrors(async () => { - const result = await env.IMAGES.input(body).output({ - anim: false, - background: "#ffffff", - format: jpegContentType, - quality: 92, - }); + const bytes = await normalizeWithProfile(env, async () => body, workspaceProfile, maxBytes); - return requireSizedResponseBody(result.response(), createEmptyImageError); - }); + if (!bytes) { + throw createImageOutputTooLargeError(maxBytes); + } + + return { + body: new Blob([bytes], { type: jpegContentType }).stream(), + sizeBytes: bytes.byteLength, + }; } export async function normalizeChatImageToJpeg( @@ -44,52 +57,47 @@ export async function normalizeChatImageToJpeg( openBody: OpenImageBody, maxBytes: number, ): Promise { - return translateImageNormalizationErrors(async () => { - for (const profile of chatProfiles) { - const result = await env.IMAGES.input(await openBody()) - .transform({ - fit: "scale-down", - height: profile.dimension, - width: profile.dimension, - }) - .output({ - anim: false, - background: "#ffffff", - format: jpegContentType, - quality: profile.quality, - }); - const bytes = await readBodyWithinLimit(result.response(), maxBytes); - - if (bytes) { - return { - bytes, - contentType: jpegContentType, - sizeBytes: bytes.byteLength, - }; - } + for (const profile of chatProfiles) { + const bytes = await normalizeWithProfile(env, openBody, profile, maxBytes); + + if (bytes) { + return { + bytes, + contentType: jpegContentType, + sizeBytes: bytes.byteLength, + }; } + } - throw new ImageNormalizationError( - "Cloudflare Images could not produce a JPEG within the chat attachment limit.", - ); - }); + throw createImageOutputTooLargeError(maxBytes); } -async function readBodyWithinLimit( - response: Response, +async function normalizeWithProfile( + env: Cloudflare.Env, + openBody: OpenImageBody, + profile: ImageProfile, maxBytes: number, ): Promise { - if (!response.body) { - throw createEmptyImageError(); - } + return translateImageNormalizationErrors(async () => { + let transformer = env.IMAGES.input(await openBody()); + + if (profile.dimension) { + transformer = transformer.transform({ + fit: "scale-down", + height: profile.dimension, + width: profile.dimension, + }); + } - const contentLength = Number(response.headers.get("content-length")); - if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) { - await response.body.cancel("Image output exceeds the byte limit."); - return null; - } + const result = await transformer.output({ + anim: false, + background: "#ffffff", + format: jpegContentType, + quality: profile.quality, + }); - return readStreamWithinLimit(response.body, maxBytes); + return readStreamWithinLimit(result.image(), maxBytes); + }); } async function readStreamWithinLimit( @@ -136,6 +144,13 @@ 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(); @@ -146,6 +161,8 @@ async function translateImageNormalizationErrors(run: () => Promise): Prom throw new ImageNormalizationError( error instanceof Error ? error.message : "Image normalization failed.", + "conversion_failed", + { cause: error }, ); } } diff --git a/src/features/workspaces/upload/workspace-file-upload-storage.ts b/src/features/workspaces/upload/workspace-file-upload-storage.ts index 9bd21374c..e9dd962f4 100644 --- a/src/features/workspaces/upload/workspace-file-upload-storage.ts +++ b/src/features/workspaces/upload/workspace-file-upload-storage.ts @@ -191,7 +191,11 @@ async function convertWorkspaceFileUpload( return { contentType: "image/jpeg", - response: await normalizeImageToJpeg(input.env, input.uploadedObject.body), + response: await normalizeImageToJpeg( + input.env, + input.uploadedObject.body, + workspaceFileUploadLimits.maxImageFileBytes, + ), }; } 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 be5b0562d..b636b8e43 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 @@ -104,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); } From 41885a826326b2033d619277da75ede9eac6e4c2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:58:39 -0400 Subject: [PATCH 4/6] refactor(images): remove retry normalization path Use one bounded 1024px JPEG profile for chat and image extraction. Consume the existing stream directly and remove the duplicate R2 read contract. --- .../conversion/image-normalizer.test.ts | 59 ++++++++-------- .../workspaces/conversion/image-normalizer.ts | 67 +++++++++---------- .../providers/workers-ai-to-markdown.ts | 2 +- src/features/workspaces/extraction/types.ts | 1 - .../workspace-file-extraction-workflow.ts | 25 ------- ...paceId.ai-threads.$threadId.attachments.ts | 2 +- 6 files changed, 61 insertions(+), 95 deletions(-) diff --git a/src/features/workspaces/conversion/image-normalizer.test.ts b/src/features/workspaces/conversion/image-normalizer.test.ts index 43dd3de15..b0d2ea86a 100644 --- a/src/features/workspaces/conversion/image-normalizer.test.ts +++ b/src/features/workspaces/conversion/image-normalizer.test.ts @@ -30,33 +30,24 @@ describe("image normalizer", () => { }); }); - it("uses progressively smaller chat profiles until output fits one MiB", async () => { - const oversized = new Uint8Array(1024 * 1024 + 1); + it("uses one bounded profile for chat images", async () => { const accepted = new Uint8Array([1, 2, 3, 4]); - const images = createImagesBinding([oversized, accepted]); - const openBody = createOpenBody(); + const images = createImagesBinding([accepted]); - const result = await normalizeChatImageToJpeg(createEnv(images.binding), openBody, maxBytes); + const result = await normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ); expect(new Uint8Array(result.bytes)).toEqual(accepted); - expect(openBody).toHaveBeenCalledTimes(2); - expect(images.transform).toHaveBeenNthCalledWith(1, { - fit: "scale-down", - height: 2048, - width: 2048, - }); - expect(images.transform).toHaveBeenNthCalledWith(2, { + expect(images.input).toHaveBeenCalledTimes(1); + expect(images.transform).toHaveBeenCalledWith({ fit: "scale-down", height: 1024, width: 1024, }); - expect(images.output).toHaveBeenNthCalledWith(1, { - anim: false, - background: "#ffffff", - format: "image/jpeg", - quality: 85, - }); - expect(images.output).toHaveBeenNthCalledWith(2, { + expect(images.output).toHaveBeenCalledWith({ anim: false, background: "#ffffff", format: "image/jpeg", @@ -69,7 +60,11 @@ describe("image normalizer", () => { const images = createImagesBinding([cause]); await expect( - normalizeChatImageToJpeg(createEnv(images.binding), createOpenBody(), maxBytes), + normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ), ).rejects.toMatchObject({ cause, message: "binding unavailable", @@ -78,19 +73,21 @@ describe("image normalizer", () => { } satisfies Partial); }); - it("rejects chat images that exceed the limit after both profiles", async () => { + it("rejects chat images that exceed the normalized output limit", async () => { const oversized = new Uint8Array(1024 * 1024 + 1); - const images = createImagesBinding([oversized, oversized]); - const openBody = createOpenBody(); + const images = createImagesBinding([oversized]); await expect( - normalizeChatImageToJpeg(createEnv(images.binding), openBody, maxBytes), + normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ), ).rejects.toMatchObject({ failure: "output_too_large", name: "ImageNormalizationError", } satisfies Partial); - expect(openBody).toHaveBeenCalledTimes(2); - expect(images.output).toHaveBeenCalledTimes(2); + expect(images.output).toHaveBeenCalledTimes(1); }); it("bounds canonical workspace JPEG output", async () => { @@ -114,7 +111,11 @@ describe("image normalizer", () => { stream(new Uint8Array([1, 2, 3])), maxBytes, ) - : normalizeChatImageToJpeg(createEnv(images.binding), createOpenBody(), maxBytes); + : normalizeChatImageToJpeg( + createEnv(images.binding), + stream(new Uint8Array([1, 2, 3])), + maxBytes, + ); await expect(result).rejects.toMatchObject({ message: "Image conversion returned an empty JPEG.", @@ -124,10 +125,6 @@ describe("image normalizer", () => { }); }); -function createOpenBody() { - return vi.fn(async () => stream(new Uint8Array([1, 2, 3]))); -} - function createImagesBinding(outputs: Array) { let outputIndex = 0; const image = vi.fn((value: Uint8Array) => stream(value)); diff --git a/src/features/workspaces/conversion/image-normalizer.ts b/src/features/workspaces/conversion/image-normalizer.ts index d93e72520..7061cbee6 100644 --- a/src/features/workspaces/conversion/image-normalizer.ts +++ b/src/features/workspaces/conversion/image-normalizer.ts @@ -6,18 +6,13 @@ import type { SizedResponseBody } from "#/lib/http/sized-response-body"; const jpegContentType = "image/jpeg"; const workspaceProfile = { quality: 92 } as const; -const chatProfiles = [ - { dimension: 2048, quality: 85 }, - { dimension: 1024, quality: 70 }, -] as const; +const chatProfile = { dimension: 1024, quality: 70 } as const; type ImageProfile = { dimension?: number; quality: number; }; -type OpenImageBody = () => Promise>; - interface NormalizedChatImage { bytes: ArrayBuffer; contentType: typeof jpegContentType; @@ -40,25 +35,27 @@ export async function normalizeImageToJpeg( body: ReadableStream, maxBytes: number, ): Promise { - const bytes = await normalizeWithProfile(env, async () => body, workspaceProfile, maxBytes); + return translateImageNormalizationErrors(async () => { + const bytes = await normalizeWithProfile(env, body, workspaceProfile, maxBytes); - if (!bytes) { - throw createImageOutputTooLargeError(maxBytes); - } + if (!bytes) { + throw createImageOutputTooLargeError(maxBytes); + } - return { - body: new Blob([bytes], { type: jpegContentType }).stream(), - sizeBytes: bytes.byteLength, - }; + return { + body: new Blob([bytes], { type: jpegContentType }).stream(), + sizeBytes: bytes.byteLength, + }; + }); } export async function normalizeChatImageToJpeg( env: Cloudflare.Env, - openBody: OpenImageBody, + body: ReadableStream, maxBytes: number, ): Promise { - for (const profile of chatProfiles) { - const bytes = await normalizeWithProfile(env, openBody, profile, maxBytes); + return translateImageNormalizationErrors(async () => { + const bytes = await normalizeWithProfile(env, body, chatProfile, maxBytes); if (bytes) { return { @@ -67,37 +64,35 @@ export async function normalizeChatImageToJpeg( sizeBytes: bytes.byteLength, }; } - } - throw createImageOutputTooLargeError(maxBytes); + throw createImageOutputTooLargeError(maxBytes); + }); } async function normalizeWithProfile( env: Cloudflare.Env, - openBody: OpenImageBody, + body: ReadableStream, profile: ImageProfile, maxBytes: number, ): Promise { - return translateImageNormalizationErrors(async () => { - let transformer = env.IMAGES.input(await openBody()); - - if (profile.dimension) { - transformer = transformer.transform({ - fit: "scale-down", - height: profile.dimension, - width: profile.dimension, - }); - } + let transformer = env.IMAGES.input(body); - const result = await transformer.output({ - anim: false, - background: "#ffffff", - format: jpegContentType, - quality: profile.quality, + if (profile.dimension) { + transformer = transformer.transform({ + fit: "scale-down", + height: profile.dimension, + width: profile.dimension, }); + } - return readStreamWithinLimit(result.image(), maxBytes); + const result = await transformer.output({ + anim: false, + background: "#ffffff", + format: jpegContentType, + quality: profile.quality, }); + + return readStreamWithinLimit(result.image(), maxBytes); } async function readStreamWithinLimit( 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 caf0cb1ea..3aedc7cf8 100644 --- a/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts +++ b/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts @@ -12,7 +12,7 @@ export function createWorkersAiToMarkdownProvider(env: Env): MarkdownExtractionP async extract(input) { const conversion = await normalizeChatImageToJpeg( env, - input.openBody, + input.body, WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize, ); const result = await env.AI.toMarkdown( diff --git a/src/features/workspaces/extraction/types.ts b/src/features/workspaces/extraction/types.ts index 200fa2adc..5dbc9b86c 100644 --- a/src/features/workspaces/extraction/types.ts +++ b/src/features/workspaces/extraction/types.ts @@ -39,7 +39,6 @@ export interface MarkdownExtractionInput { body: ReadableStream; fileName: string; contentType: string; - openBody: () => Promise>; sizeBytes: number; sourceHash: string; mode: MarkdownExtractionProviderMode; diff --git a/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts b/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts index aa2844ad6..446226008 100644 --- a/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts +++ b/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts @@ -73,13 +73,6 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< body: object.body, fileName: source.fileName, contentType: source.contentType, - openBody: () => - reopenWorkspaceFileSource( - this.env.WORKSPACE_KERNEL_FILES, - source.objectKey, - source.sizeBytes, - object.etag, - ), sizeBytes: source.sizeBytes, sourceHash: object.etag, mode: route.mode, @@ -245,24 +238,6 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< } } -async function reopenWorkspaceFileSource( - bucket: R2Bucket, - objectKey: string, - expectedSizeBytes: number, - expectedEtag: string, -) { - const object = await bucket.get(objectKey); - - if (!object) { - throw new Error("Workspace file source object was not found during extraction."); - } - if (object.size !== expectedSizeBytes || object.etag !== expectedEtag) { - throw new Error("Workspace file source changed during extraction."); - } - - return object.body; -} - interface StagedPageExtractionResult { manifestObjectKey: string; markdownLength: number; 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 b636b8e43..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 @@ -74,7 +74,7 @@ async function executeChatAttachmentUpload( const normalized = await normalizeChatImageToJpeg( env, - async () => file.stream(), + file.stream(), WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize, ); observation.conversion = "image_to_jpeg"; From 545af58b1453393082b7d7ab6985779429ee7188 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:08:22 -0400 Subject: [PATCH 5/6] refactor(images): throw on output overflow Raise the typed size error where bounded stream reading detects overflow. Remove the impossible nullable result from both normalization paths. --- .../workspaces/conversion/image-normalizer.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/features/workspaces/conversion/image-normalizer.ts b/src/features/workspaces/conversion/image-normalizer.ts index 7061cbee6..35666cf10 100644 --- a/src/features/workspaces/conversion/image-normalizer.ts +++ b/src/features/workspaces/conversion/image-normalizer.ts @@ -38,10 +38,6 @@ export async function normalizeImageToJpeg( return translateImageNormalizationErrors(async () => { const bytes = await normalizeWithProfile(env, body, workspaceProfile, maxBytes); - if (!bytes) { - throw createImageOutputTooLargeError(maxBytes); - } - return { body: new Blob([bytes], { type: jpegContentType }).stream(), sizeBytes: bytes.byteLength, @@ -57,15 +53,11 @@ export async function normalizeChatImageToJpeg( return translateImageNormalizationErrors(async () => { const bytes = await normalizeWithProfile(env, body, chatProfile, maxBytes); - if (bytes) { - return { - bytes, - contentType: jpegContentType, - sizeBytes: bytes.byteLength, - }; - } - - throw createImageOutputTooLargeError(maxBytes); + return { + bytes, + contentType: jpegContentType, + sizeBytes: bytes.byteLength, + }; }); } @@ -74,7 +66,7 @@ async function normalizeWithProfile( body: ReadableStream, profile: ImageProfile, maxBytes: number, -): Promise { +): Promise { let transformer = env.IMAGES.input(body); if (profile.dimension) { @@ -98,7 +90,7 @@ async function normalizeWithProfile( async function readStreamWithinLimit( body: ReadableStream, maxBytes: number, -): Promise { +): Promise { const reader = body.getReader(); const chunks: Uint8Array[] = []; let totalBytes = 0; @@ -111,7 +103,7 @@ async function readStreamWithinLimit( } if (totalBytes + value.byteLength > maxBytes) { await reader.cancel("Image output exceeds the byte limit."); - return null; + throw createImageOutputTooLargeError(maxBytes); } chunks.push(value); From 6ddc8512683add193ae5e224b29e8ec88c22e8b2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:16:30 -0400 Subject: [PATCH 6/6] fix(attachments): match image loading geometry Render pending images in the same square tile used by the ready preview. Keep non-image attachment loading states in their compact horizontal layout. --- .../ai-chat/AiChatAttachmentItem.tsx | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) 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} ); }