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,96 @@
import { describe, expect, test } from "bun:test";
import { buildMultimodalMessages } from "@/lib/ai-event-messages";
// ---------------------------------------------------------------------------
// buildMultimodalMessages behavioral tests
//
// Public behavior under test: given a system prompt, an optional text prompt,
// and an array of base64 image strings, returns a well-formed messages array
// for the OpenRouter chat API.
//
// We test WHAT the function produces (message structure), not HOW it does it.
// ---------------------------------------------------------------------------
const SYSTEM_PROMPT = "You are an assistant…";
const FAKE_PNG = "data:image/png;base64,abc123";
const FAKE_JPEG = "data:image/jpeg;base64,def456";
describe("buildMultimodalMessages message structure", () => {
test("first message is always the system prompt", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "hello", [FAKE_PNG]);
expect(messages[0].role).toBe("system");
expect((messages[0].content as string)).toBe(SYSTEM_PROMPT);
});
test("second message is the user message", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "hello", [FAKE_PNG]);
expect(messages[1].role).toBe("user");
});
test("user message content array starts with the text part", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "any prompt", [FAKE_PNG]);
const userContent = messages[1].content as Array<{ type: string }>;
expect(userContent[0].type).toBe("text");
});
test("user message content array includes one image_url part per image", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "prompt", [FAKE_PNG, FAKE_JPEG]);
const userContent = messages[1].content as Array<{ type: string }>;
const imageparts = userContent.filter((p) => p.type === "image_url");
expect(imageparts).toHaveLength(2);
});
test("each image_url part carries the correct base64 URL", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, undefined, [FAKE_PNG, FAKE_JPEG]);
const userContent = messages[1].content as Array<{
type: string;
imageUrl?: { url: string };
}>;
const imageParts = userContent.filter((p) => p.type === "image_url");
expect(imageParts[0].imageUrl?.url).toBe(FAKE_PNG);
expect(imageParts[1].imageUrl?.url).toBe(FAKE_JPEG);
});
test("text part uses a fallback when prompt is undefined", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, undefined, [FAKE_PNG]);
const userContent = messages[1].content as Array<{
type: string;
text?: string;
}>;
const textPart = userContent.find((p) => p.type === "text");
expect(typeof textPart?.text).toBe("string");
expect(textPart?.text?.length ?? 0).toBeGreaterThan(0);
});
test("text part carries the provided prompt text when given", () => {
const prompt = "Extract all events from these flyers";
const messages = buildMultimodalMessages(SYSTEM_PROMPT, prompt, [FAKE_PNG]);
const userContent = messages[1].content as Array<{
type: string;
text?: string;
}>;
const textPart = userContent.find((p) => p.type === "text");
expect(textPart?.text).toBe(prompt);
});
test("produces exactly 2 messages (system + user)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [FAKE_PNG]);
expect(messages).toHaveLength(2);
});
test("single image produces content array of length 2 (1 text + 1 image)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [FAKE_PNG]);
const userContent = messages[1].content as unknown[];
expect(userContent).toHaveLength(2);
});
test("three images produce content array of length 4 (1 text + 3 images)", () => {
const messages = buildMultimodalMessages(SYSTEM_PROMPT, "x", [
FAKE_PNG,
FAKE_JPEG,
FAKE_PNG,
]);
const userContent = messages[1].content as unknown[];
expect(userContent).toHaveLength(4);
});
});