diff --git a/apps/dashboard/src/components/assistant-ui/compress-image.ts b/apps/dashboard/src/components/assistant-ui/compress-image.ts new file mode 100644 index 0000000000..71a714b5c5 --- /dev/null +++ b/apps/dashboard/src/components/assistant-ui/compress-image.ts @@ -0,0 +1,96 @@ +import { MAX_IMAGE_BYTES_PER_FILE } from "@stackframe/stack-shared/dist/ai/image-limits"; + +/** + * Maximum pixel dimension (width or height) for compressed output. + * 2048px is plenty for AI chat while keeping file sizes manageable. + */ +const MAX_DIMENSION = 2048; + +/** + * Target compressed size: well under the hard 3 MB server limit so the + * base-64 encoded payload (≈ 33 % larger) still fits comfortably. + */ +const COMPRESS_TARGET_BYTES = Math.floor(MAX_IMAGE_BYTES_PER_FILE / 2); + +function canvasToBlob( + canvas: HTMLCanvasElement, + type: string, + quality: number, +): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob == null) { + reject(new Error("Canvas toBlob returned null")); + return; + } + resolve(blob); + }, + type, + quality, + ); + }); +} + +/** + * Compresses an image `File` on the client so it stays well under the + * server-side size limit. Returns the original file unchanged when it is + * already small enough. + * + * Strategy: + * 1. Down-scale to at most `MAX_DIMENSION` px on the longest side. + * 2. Encode as JPEG with decreasing quality until the result fits. + * 3. If quality alone isn't enough, halve the dimensions and retry. + */ +export async function compressImageFile(file: File): Promise { + if (file.size <= MAX_IMAGE_BYTES_PER_FILE) { + return file; + } + + const bitmap = await createImageBitmap(file); + const dimensionScale = Math.min( + 1, + MAX_DIMENSION / Math.max(bitmap.width, bitmap.height), + ); + const baseWidth = Math.round(bitmap.width * dimensionScale); + const baseHeight = Math.round(bitmap.height * dimensionScale); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx == null) { + bitmap.close(); + throw new Error("Failed to get canvas 2d context for image compression"); + } + + // Try progressively smaller sizes until the output fits. + for ( + let sizeScale = 1; + sizeScale >= 0.25; + sizeScale = Math.round((sizeScale * 0.5) * 100) / 100 + ) { + const w = Math.max(1, Math.round(baseWidth * sizeScale)); + const h = Math.max(1, Math.round(baseHeight * sizeScale)); + canvas.width = w; + canvas.height = h; + ctx.drawImage(bitmap, 0, 0, w, h); + + for (let quality = 0.85; quality >= 0.15; quality -= 0.1) { + const blob = await canvasToBlob(canvas, "image/jpeg", quality); + if (blob.size <= COMPRESS_TARGET_BYTES) { + bitmap.close(); + return new File([blob], file.name, { + type: "image/jpeg", + lastModified: file.lastModified, + }); + } + } + } + + // Fallback: lowest quality at the smallest attempted dimension. + const blob = await canvasToBlob(canvas, "image/jpeg", 0.1); + bitmap.close(); + return new File([blob], file.name, { + type: "image/jpeg", + lastModified: file.lastModified, + }); +} diff --git a/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts index 7062edca96..99aa3c57ae 100644 --- a/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts +++ b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts @@ -1,4 +1,4 @@ -import { validateComposerImageByteLength } from "@/components/assistant-ui/image-attachment-validation"; +import { compressImageFile } from "@/components/assistant-ui/compress-image"; import { type AttachmentAdapter, type CompleteAttachment, @@ -6,21 +6,18 @@ import { } from "@assistant-ui/react"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; -/** Chat composer attachments: UUID ids, shared max file size (see `image-limits`). */ +/** Chat composer attachments: UUID ids, auto-compressed to fit shared max file size (see `image-limits`). */ export class ImageAttachmentAdapter implements AttachmentAdapter { public readonly accept = "image/*"; public async add(state: { file: File }): Promise { - const sizeValidation = validateComposerImageByteLength(state.file.size); - if (!sizeValidation.ok) { - throw new Error(`"${state.file.name}": ${sizeValidation.reason}`); - } + const compressed = await compressImageFile(state.file); return { id: generateUuid(), type: "image", name: state.file.name, - contentType: state.file.type, - file: state.file, + contentType: compressed.type, + file: compressed, status: { type: "requires-action", reason: "composer-send" }, }; } diff --git a/apps/dashboard/src/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index 2a9a7261e4..d2eda88da7 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -3,7 +3,6 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button import { Button, useToast } from "@/components/ui"; import { cn } from "@/lib/utils"; import { - validateComposerImageByteLength, validateComposerImageCount, } from "./image-attachment-validation"; import { @@ -24,7 +23,6 @@ import { import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle, XIcon } from "@phosphor-icons/react"; import { MAX_IMAGES_PER_MESSAGE, - MAX_IMAGE_MB_PER_FILE, } from "@stackframe/stack-shared/dist/ai/image-limits"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react"; @@ -375,16 +373,6 @@ const ComposerAttachmentsAddButton: FC = () => { const remaining = Math.max(0, MAX_IMAGES_PER_MESSAGE - liveCount); const picked = Array.from(files); const selected = picked.slice(0, remaining); - const valid: File[] = []; - const oversized: File[] = []; - for (const file of selected) { - const sizeValidation = validateComposerImageByteLength(file.size); - if (sizeValidation.ok) { - valid.push(file); - } else { - oversized.push(file); - } - } const countValidation = validateComposerImageCount(liveCount + picked.length); if (!countValidation.ok) { @@ -394,20 +382,9 @@ const ComposerAttachmentsAddButton: FC = () => { }); } - if (oversized.length > 0) { - const firstOversizedValidation = validateComposerImageByteLength(oversized[0]!.size); - toast({ - variant: "destructive", - description: - oversized.length === 1 - ? `"${oversized[0]!.name}": ${firstOversizedValidation.ok ? `Image exceeds ${MAX_IMAGE_MB_PER_FILE}MB limit.` : firstOversizedValidation.reason}` - : `${oversized.length} images exceeded the ${MAX_IMAGE_MB_PER_FILE}MB limit and were skipped.`, - }); - } - runAsynchronously( (async () => { - for (const file of valid) { + for (const file of selected) { if (composerRuntime.getState().attachments.length >= MAX_IMAGES_PER_MESSAGE) { break; } @@ -437,7 +414,7 @@ const ComposerAttachmentsAddButton: FC = () => { const tooltipText = atLimit ? `Limit reached (${MAX_IMAGES_PER_MESSAGE}/${MAX_IMAGES_PER_MESSAGE})` - : `Attach image (${count}/${MAX_IMAGES_PER_MESSAGE}, max ${MAX_IMAGE_MB_PER_FILE}MB)`; + : `Attach image (${count}/${MAX_IMAGES_PER_MESSAGE})`; return (