Files
local-cal/src/app/api/ai-event/route.ts
Dmytro Stanchiev 79f98ebfd3 refactor(api): simplify AI event route with extracted utilities and env-based model
Replace inline JSON extraction with the shared json-utils module,
extract chat response content parsing into a dedicated helper, make
the AI model configurable via AI_MODEL env var, and improve error
messages for production safety.
2026-04-07 13:11:15 -04:00

163 lines
4.3 KiB
TypeScript

import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { extractJsonFromText } from "@/lib/json-utils";
import { openRouterClient } from "@/lib/openrouter-client";
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
const MODEL = process.env.AI_MODEL ?? "openai/gpt-5.4-mini";
const buildSystemPrompt = () => `
You are an assistant that converts natural language and images into an ARRAY of calendar events.
TypeScript type:
{
id?: string,
title: string,
description?: string,
location?: string,
url?: string,
start: string, // ISO datetime
end?: string,
allDay?: boolean,
recurrenceRule?: string // valid iCal RRULE string like FREQ=WEEKLY;BYDAY=MO;INTERVAL=1
}[]
Rules:
- If the user describes multiple events in one prompt, return multiple objects (one per event).
- Always return a valid JSON array of objects, even if there's only one event.
- Today is ${new Date().toLocaleString()}.
- If no time is given, assume allDay event.
- If no end time is given (and event is not allDay), default to 1 hour after start.
- If multiple events are described, return multiple.
- If recurrence is implied (e.g. "every Monday", "daily for 10 days", "monthly on the 15th"), generate a recurrenceRule.
- When analyzing an image, extract ALL visible event details: titles, dates, times, locations, descriptions.
- Output ONLY valid JSON (no prose).
`;
const callTextOnly = async (systemPrompt: string, prompt: string) => {
const result = openRouterClient.callModel({
model: MODEL,
instructions: systemPrompt,
input: prompt,
});
const rawResponse = await result.getText();
return { rawResponse };
};
/** Extract the text content from an OpenRouter chat.send response. */
const extractContentFromChatResponse = (response: unknown): string => {
if (
typeof response === "object" &&
response !== null &&
"choices" in response
) {
const choices = (
response as {
choices: Array<{ message: { content: string | unknown } }>;
}
).choices;
const content = choices?.[0]?.message?.content;
if (typeof content === "string") return content;
if (content) return JSON.stringify(content);
}
throw new Error("Unexpected response format from AI chat API");
};
const callMultimodal = async (
systemPrompt: string,
prompt: string | undefined,
imageBase64: string,
) => {
const messages = [
{
role: "system" as const,
content: systemPrompt,
},
{
role: "user" as const,
content: [
{
type: "text" as const,
text: prompt || "Extract all calendar events from this image.",
},
{
type: "image_url" as const,
imageUrl: { url: imageBase64 },
},
],
},
];
const response = await openRouterClient.chat.send({
chatRequest: {
model: MODEL,
messages,
},
});
const rawResponse = extractContentFromChatResponse(response);
return { rawResponse };
};
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 },
);
}
const body = await request.json();
const parsedInput = AiEventRequestSchema.safeParse(body);
if (!parsedInput.success) {
return NextResponse.json(
{
error: "Invalid input",
details: parsedInput.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const { prompt, imageBase64 } = parsedInput.data;
const systemPrompt = buildSystemPrompt();
try {
const result = imageBase64
? await callMultimodal(systemPrompt, prompt, imageBase64)
: await callTextOnly(systemPrompt, prompt ?? "");
const rawJson = extractJsonFromText(result.rawResponse);
const validated = AiEventResponseSchema.safeParse(rawJson);
if (!validated.success) {
console.error("AI response validation failed:", {
issues: validated.error.flatten().fieldErrors,
});
return NextResponse.json(
{
error: "AI returned invalid event data",
details: validated.error.flatten().fieldErrors,
},
{ status: 422 },
);
}
return NextResponse.json(validated.data);
} catch (error) {
console.error("AI Event Creation Error:", error);
return NextResponse.json(
{ error: "Failed to process AI response. Please try again." },
{ status: 500 },
);
}
}