feat: support multiple image uploads for AI event generation 🖼️

- Updated OpenRouter integration to accept an array of image URLs
- Updated ImagePicker to use the `multiple` attribute natively
- Added `appendImagesDeduped` for handling client-side image deduplication
- Enhanced clipboard pasting to extract multiple images at once
- Rendered multiple images in a horizontal thumbnail strip in the AIToolbar
- Added tests to cover multi-image logic and AI request mapping
This commit is contained in:
2026-04-08 20:46:43 -04:00
parent cac201a4d2
commit 513aafcebc
18 changed files with 881 additions and 238 deletions

View File

@@ -0,0 +1,47 @@
/**
* Pure helper that builds the OpenRouter chat messages array for a multimodal
* AI-event request.
*
* Extracted from the API route so it can be unit-tested without mocking HTTP.
*/
type TextPart = { type: "text"; text: string };
type ImageUrlPart = { type: "image_url"; imageUrl: { url: string } };
type ContentPart = TextPart | ImageUrlPart;
type Message =
| { role: "system"; content: string }
| { role: "user"; content: ContentPart[] };
/**
* Builds a 2-message array:
* [0] system → the system prompt string
* [1] user → [text part, ...image_url parts (one per image)]
*
* @param systemPrompt Instruction string for the model
* @param prompt Optional text from the user
* @param images Array of base64 data URLs
*/
export function buildMultimodalMessages(
systemPrompt: string,
prompt: string | undefined,
images: string[],
): Message[] {
const userContent: ContentPart[] = [
{
type: "text",
text: prompt || "Extract all calendar events from these images.",
},
...images.map(
(url): ImageUrlPart => ({
type: "image_url",
imageUrl: { url },
}),
),
];
return [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
}

View File

@@ -1,5 +1,5 @@
/**
* Extracts the first image File from a DataTransfer object.
* Extracts ALL image Files from a DataTransfer object.
*
* Resolution order (most → least reliable across browsers/OS):
* 1. clipboardData.files browser-normalised FileList; Chrome/Linux/Mac/Safari
@@ -11,35 +11,53 @@
* subtype, including OS-specific variants like "image/x-png" on Linux that
* a strict allowlist (["image/png", ...]) would silently reject.
*
* The caller (onImageSelect / handleImageSelect) still runs validateImageFile
* which enforces the app's supported format allowlist — so we stay permissive
* here and strict at the validation boundary.
* The caller (onImagesSelect / handleImagesSelect) still runs validateImageFile
* on each file, which enforces the app's supported format allowlist — so we
* stay permissive here and strict at the validation boundary.
*
* Returns an array (possibly empty). Never returns null.
*/
export function extractImageFromClipboard(
export function extractAllImagesFromClipboard(
clipboardData: DataTransfer | null | undefined,
): File | null {
if (!clipboardData) return null;
): File[] {
if (!clipboardData) return [];
// ── 1. files array (primary) ──────────────────────────────────────────────
const { files } = clipboardData;
if (files?.length) {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith("image/")) return file;
if (file.type.startsWith("image/")) images.push(file);
}
if (images.length > 0) return images;
}
// ── 2. items fallback ─────────────────────────────────────────────────────
const { items } = clipboardData;
if (items?.length) {
const images: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) return file;
if (file) images.push(file);
}
}
return images;
}
return null;
return [];
}
/**
* Backward-compatible single-file extractor.
* Returns the first image found, or null.
*
* @deprecated Prefer extractAllImagesFromClipboard for multi-image support.
*/
export function extractImageFromClipboard(
clipboardData: DataTransfer | null | undefined,
): File | null {
return extractAllImagesFromClipboard(clipboardData)[0] ?? null;
}

View File

@@ -74,8 +74,9 @@ export function detectOs(): Os {
if (typeof navigator === "undefined") return "unknown";
// Modern API — Chromium 90+
const uaData = (navigator as Navigator & { userAgentData?: { platform: string } })
.userAgentData;
const uaData = (
navigator as Navigator & { userAgentData?: { platform: string } }
).userAgentData;
if (uaData?.platform) {
return uaData.platform.toLowerCase().includes("mac") ? "mac" : "other";
}

43
src/lib/multi-image.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Multi-image helpers.
*
* These are pure functions so they can be tested without a DOM or React.
* The caller (page.tsx) owns state; these functions own the "which files
* are new" logic.
*/
/**
* Returns a stable deduplication key for a File.
* Key = `name:size` — cheap, deterministic, and catches re-selections of the
* exact same file (same name *and* same byte count).
*
* Two different files that happen to share a name but have different content
* will have different sizes and therefore different keys.
*/
export function imageFileKey(file: File): string {
return `${file.name}:${file.size}`;
}
/**
* Appends `incoming` files to `existing`, skipping any file whose key
* already appears in the combined list.
*
* Returns a new array — never mutates `existing`.
*/
export function appendImagesDeduped(
existing: File[],
incoming: File[],
): File[] {
const seen = new Set(existing.map(imageFileKey));
const result = [...existing];
for (const file of incoming) {
const key = imageFileKey(file);
if (!seen.has(key)) {
seen.add(key);
result.push(file);
}
}
return result;
}

View File

@@ -3,30 +3,35 @@ import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import { normalizeAiDateString } from "@/lib/date-normalizer";
/** Validates that a base64 data URL string decodes to binary under the max size. */
const isValidImageSize = (val: string | undefined): boolean => {
if (!val) return true;
const isValidImageSize = (val: string): boolean => {
const base64Part = val.split(",")[1] ?? "";
const binarySize = Math.ceil(base64Part.length * 0.75);
return binarySize <= MAX_IMAGE_SIZE_BYTES;
};
/** Single image data-URL validator (reused inside the array schema). */
const imageDataUrl = z
.string()
.regex(
/^data:image\/(png|jpeg|webp);base64,/,
"Must be a valid image data URL (PNG, JPEG, or WebP)",
)
.refine(isValidImageSize, {
message: "Image must be less than 10MB",
});
export const AiEventRequestSchema = z
.object({
prompt: z.string().trim().max(2000).optional(),
imageBase64: z
.string()
.regex(
/^data:image\/(png|jpeg|webp);base64,/,
"Must be a valid image data URL (PNG, JPEG, or WebP)",
)
.refine(isValidImageSize, {
message: "Image must be less than 10MB",
})
.optional(),
/** Array of base64-encoded image data URLs (PNG, JPEG, WebP). */
images: z.array(imageDataUrl).optional(),
})
.refine((data) => data.prompt || data.imageBase64, {
message: "Either a prompt or an image is required",
});
.refine(
(data) =>
(data.prompt && data.prompt.trim().length > 0) ||
(data.images && data.images.length > 0),
{ message: "Either a prompt or at least one image is required" },
);
export type AiEventRequest = z.infer<typeof AiEventRequestSchema>;