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,101 @@
import { describe, expect, test } from "bun:test";
import { AiEventRequestSchema } from "@/lib/types";
// ---------------------------------------------------------------------------
// AiEventRequestSchema behavioral validation tests
//
// The schema is the contract between the client and the API route.
// Tests verify what the schema ALLOWS and what it REJECTS.
// ---------------------------------------------------------------------------
const VALID_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const VALID_JPEG =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJQAB/9k=";
describe("AiEventRequestSchema images array", () => {
test("accepts a prompt with no images", () => {
const result = AiEventRequestSchema.safeParse({ prompt: "Team standup every Monday at 9am" });
expect(result.success).toBe(true);
});
test("accepts images array with one valid base64 image", () => {
const result = AiEventRequestSchema.safeParse({
prompt: "What events are on this flyer?",
images: [VALID_PNG],
});
expect(result.success).toBe(true);
});
test("accepts images array with multiple valid base64 images", () => {
const result = AiEventRequestSchema.safeParse({
images: [VALID_PNG, VALID_JPEG],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.images).toHaveLength(2);
}
});
test("accepts request with images only (no prompt)", () => {
const result = AiEventRequestSchema.safeParse({ images: [VALID_PNG] });
expect(result.success).toBe(true);
});
test("rejects request with neither prompt nor images", () => {
const result = AiEventRequestSchema.safeParse({});
expect(result.success).toBe(false);
});
test("rejects images array containing an invalid data URL", () => {
const result = AiEventRequestSchema.safeParse({
images: ["not-a-data-url"],
});
expect(result.success).toBe(false);
});
test("rejects images array containing a non-image data URL (e.g. PDF)", () => {
const result = AiEventRequestSchema.safeParse({
images: ["data:application/pdf;base64,abc123"],
});
expect(result.success).toBe(false);
});
test("rejects empty images array (must have at least one image OR a prompt)", () => {
// An empty images array with no prompt should fail the 'prompt or images' refinement
const result = AiEventRequestSchema.safeParse({ images: [] });
expect(result.success).toBe(false);
});
test("returns data.images as string[] when images are valid", () => {
const result = AiEventRequestSchema.safeParse({
images: [VALID_PNG, VALID_JPEG],
});
expect(result.success).toBe(true);
if (result.success) {
expect(Array.isArray(result.data.images)).toBe(true);
for (const img of result.data.images ?? []) {
expect(typeof img).toBe("string");
}
}
});
});
describe("AiEventRequestSchema prompt validation", () => {
test("accepts a plain text prompt with no images", () => {
const result = AiEventRequestSchema.safeParse({ prompt: "Birthday party Saturday" });
expect(result.success).toBe(true);
});
test("rejects a prompt that exceeds 2000 characters", () => {
const longPrompt = "a".repeat(2001);
const result = AiEventRequestSchema.safeParse({ prompt: longPrompt });
expect(result.success).toBe(false);
});
test("accepts a prompt of exactly 2000 characters", () => {
const maxPrompt = "a".repeat(2000);
const result = AiEventRequestSchema.safeParse({ prompt: maxPrompt });
expect(result.success).toBe(true);
});
});