Files
tweakcn/hooks/use-image-upload.ts
Sahaj Jain d21c8e3d40 fix: keep AI chat image payloads under the serverless body limit
Uploaded images were inlined as full-resolution base64 in the message
metadata, and the entire chat history is posted on every turn. Two 4MB
uploads exceeded the 4.5MB request body cap, so the platform rejected the
request before the route ran (FUNCTION_PAYLOAD_TOO_LARGE).

- Downscale and re-encode raster uploads client-side (1536px longest edge,
  WebP with a JPEG fallback, ~300KB target). UI screenshots land around
  12KB with imperceptible color drift, so token extraction is unaffected.
- Trim images from the oldest messages when the serialized request body
  would still exceed the budget, rather than failing the request outright.
- Raise MAX_IMAGE_FILES from 2 to 5 now that the payload allows it, and
  wrap the multi-image message preview row so the thumbnails stay legible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:50:38 +05:30

162 lines
4.7 KiB
TypeScript

import { useToast } from "@/components/ui/use-toast";
import { MAX_SVG_FILE_SIZE } from "@/lib/constants";
import { PromptImage } from "@/types/ai";
import {
ALLOWED_IMAGE_TYPES,
compressImageToDataUrl,
optimizeSvgContent,
validateSvgContent,
} from "@/utils/ai/image-upload";
import { useRef } from "react";
export type PromptImageWithLoading = PromptImage & { loading: boolean };
export type ImageUploadAction =
| { type: "ADD"; payload: { url: string; file: File }[] }
| { type: "REMOVE"; payload: { index: number } }
| { type: "REMOVE_BY_URL"; payload: { url: string } }
| { type: "CLEAR" }
| { type: "UPDATE_URL"; payload: { tempUrl: string; finalUrl: string } }
| { type: "INITIALIZE"; payload: { url: string }[] };
interface UseImageUploadOptions {
maxFiles: number;
maxFileSize: number;
images: PromptImageWithLoading[];
dispatch: (action: ImageUploadAction) => void;
}
export function useImageUpload({ maxFiles, maxFileSize, images, dispatch }: UseImageUploadOptions) {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImagesUpload = (files: File[]) => {
if (!files || files.length === 0) return;
let fileArray = Array.from(files);
const totalImages = images.length;
if (totalImages + fileArray.length > maxFiles) {
toast({
title: "Image upload limit reached",
description: `You can only upload up to ${maxFiles} images.`,
});
fileArray = fileArray.slice(0, maxFiles - totalImages);
if (fileArray.length <= 0) return;
}
const validFiles = fileArray.filter((file) => {
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
toast({
title: "Unsupported file type",
description: `"${file.name}" is not supported. Please use JPG, PNG, WebP, or SVG files.`,
});
return false;
}
if (file.size > maxFileSize) {
toast({
title: "File too large",
description: `Image "${file.name}" exceeds the ${maxFileSize / 1024 / 1024}MB size limit.`,
});
return false;
}
return true;
});
if (validFiles.length === 0) return;
const filesWithTempUrls = validFiles.map((file) => ({
url: URL.createObjectURL(file),
file,
}));
dispatch({ type: "ADD", payload: filesWithTempUrls });
filesWithTempUrls.forEach(({ url: tempUrl, file }) => {
const handleSuccess = (finalUrl: string) => {
dispatch({ type: "UPDATE_URL", payload: { tempUrl, finalUrl } });
URL.revokeObjectURL(tempUrl);
};
const handleError = () => {
toast({
title: "File read error",
description: `Failed to read "${file.name}". Please try again.`,
});
dispatch({
type: "REMOVE_BY_URL",
payload: { url: tempUrl },
});
URL.revokeObjectURL(tempUrl);
};
if (file.type !== "image/svg+xml") {
// Raster images are downscaled and re-encoded so the chat payload stays within the
// serverless request body limit
compressImageToDataUrl(file).then(handleSuccess).catch(handleError);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (!result || typeof result !== "string") {
handleError();
return;
}
try {
const isValidSvg = validateSvgContent(result);
if (!isValidSvg) {
toast({
title: "Potentially unsafe SVG",
description: `"${file.name}" may contain unsafe content but will be processed anyway.`,
});
}
const optimizedSvg = optimizeSvgContent(result);
const encodedSvg = encodeURIComponent(optimizedSvg);
if (encodedSvg.length > MAX_SVG_FILE_SIZE) {
handleError();
return;
}
handleSuccess(`data:image/svg+xml,${encodedSvg}`);
} catch {
handleError();
}
};
reader.onerror = handleError;
reader.onabort = handleError;
reader.readAsText(file);
});
};
const handleImageRemove = (index: number) => {
dispatch({ type: "REMOVE", payload: { index } });
if (fileInputRef.current) fileInputRef.current.value = "";
};
const clearUploadedImages = () => {
dispatch({ type: "CLEAR" });
if (fileInputRef.current) fileInputRef.current.value = "";
};
const isSomeImageUploading = images.some((img) => img.loading);
const canUploadMore = images.length < maxFiles && !isSomeImageUploading;
return {
fileInputRef,
handleImagesUpload,
handleImageRemove,
clearUploadedImages,
canUploadMore,
isSomeImageUploading,
};
}