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:
47
src/lib/ai-event-messages.ts
Normal file
47
src/lib/ai-event-messages.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
43
src/lib/multi-image.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user