From 39370101b90a1341ae04b20a39b64e6fcce8a681 Mon Sep 17 00:00:00 2001 From: Punit Arani Date: Sat, 18 Oct 2025 14:21:43 -0700 Subject: [PATCH 1/4] Create basic AI agent --- src/ai/index.ts | 3 + src/ai/planner-agent.ts | 31 +++++ src/ai/planner-prompt.ts | 177 +++++++++++++++++++++++++ src/ai/tools/control-scene.ts | 129 ++++++++++++++++++ src/ai/tools/index.ts | 9 ++ src/ai/tools/search-dates.ts | 231 +++++++++++++++++++++++++++++++++ src/ai/tools/search-flights.ts | 204 +++++++++++++++++++++++++++++ src/ai/types.ts | 186 ++++++++++++++++++++++++++ src/server/routers/planner.ts | 0 9 files changed, 970 insertions(+) create mode 100644 src/ai/index.ts create mode 100644 src/ai/planner-agent.ts create mode 100644 src/ai/planner-prompt.ts create mode 100644 src/ai/tools/control-scene.ts create mode 100644 src/ai/tools/index.ts create mode 100644 src/ai/tools/search-dates.ts create mode 100644 src/ai/tools/search-flights.ts create mode 100644 src/ai/types.ts create mode 100644 src/server/routers/planner.ts diff --git a/src/ai/index.ts b/src/ai/index.ts new file mode 100644 index 0000000..fe0ee27 --- /dev/null +++ b/src/ai/index.ts @@ -0,0 +1,3 @@ +export * from "./planner-agent"; +export * from "./planner-prompt"; +export * from "./types"; diff --git a/src/ai/planner-agent.ts b/src/ai/planner-agent.ts new file mode 100644 index 0000000..b9c48ef --- /dev/null +++ b/src/ai/planner-agent.ts @@ -0,0 +1,31 @@ +import { Experimental_Agent as Agent } from "ai"; + +import { getSystemPrompt } from "./planner-prompt"; +import { controlSceneTool, searchDatesTool, searchFlightsTool } from "./tools"; + +/** + * Flight Planner Agent + * + * An AI agent that helps users plan flights with real-time data access. + * + * Capabilities: + * - Search for specific flights with detailed filters + * - Find cheapest dates to fly across date ranges + * - Control UI scene to show maps or search results + * - Multi-turn conversations with context awareness + * - Parallel tool calls for efficient planning + * + * Tools: + * - searchFlights: Find one-way flights for specific dates + * - searchDates: Find best prices across date ranges + * - controlScene: Switch between map and search views + */ +export const plannerAgent = new Agent({ + model: "openai/gpt-4o", + system: getSystemPrompt(), + tools: { + searchFlights: searchFlightsTool, + searchDates: searchDatesTool, + controlScene: controlSceneTool, + }, +}); diff --git a/src/ai/planner-prompt.ts b/src/ai/planner-prompt.ts new file mode 100644 index 0000000..8500779 --- /dev/null +++ b/src/ai/planner-prompt.ts @@ -0,0 +1,177 @@ +import type { PlannerContext } from "./types"; + +/** + * Flight Planner Agent Prompts + * + * This module provides system and user prompts for the flight planner agent. + * + * Usage: + * ```ts + * // For AI SDK Agent (system prompt only) + * const agent = new Agent({ + * model: "openai/gpt-4", + * system: getSystemPrompt(), + * }); + * + * // For chat completions (both prompts) + * const { systemPrompt, userPrompt } = getPlannerPrompts(context); + * const response = await generateText({ + * model: openai("gpt-4"), + * system: systemPrompt, + * messages: [ + * { role: "user", content: userPrompt }, + * { role: "user", content: actualUserQuery } + * ], + * }); + * ``` + */ + +/** + * Get the static system prompt that defines the agent's role and capabilities. + * This prompt contains no user-specific information and can be reused across conversations. + */ +export function getSystemPrompt(): string { + return `You are an expert flight planning assistant with real-time access to flight data and award availability. Your role is to help users discover, compare, and plan their air travel efficiently. + +## Your Capabilities + +### Flight Search & Data Access +- **Real-time flight lookups**: Access current flight availability, prices, and schedules +- **Award flight search**: Query seats.aero for award availability across multiple airlines +- **Multi-airport search**: Search from/to multiple airports simultaneously for better options +- **Flexible date search**: Search across date ranges to find optimal pricing and availability +- **One-way flight focus**: Each search query is for ONE-WAY flights only + +### Trip Planning Intelligence +- **Multi-leg journeys**: Plan complex itineraries by combining multiple one-way searches +- **Round-trip planning**: Use separate searches for outbound and return legs +- **Multi-city trips**: Chain multiple one-way flights for complex routing +- **Date optimization**: Suggest alternative dates for better prices or availability +- **Geographic insights**: Leverage your knowledge of airports, distances, and connections + +### Scene Control +You can control what the user sees in their interface using the scene parameter: + +1. **Map View - Popular Routes** (\`view: "map", mode: "popular"\`) + - Show popular flight routes and destinations + - Use when: User is exploring or wants inspiration + +2. **Map View - Specific Routes** (\`view: "map", mode: "routes"\`) + - Display routes between specific airports + - Provide \`airports\` array with IATA codes (e.g., ["SFO", "JFK", "LAX"]) + - Use when: Showing connections between specific cities + +3. **Search View - Flight Results** (\`view: "search", mode: "flights"\`) + - Display detailed flight search results + - Requires: \`origin\`, \`destination\` (arrays), \`startDate\`, \`endDate\`, \`travelDate\` + - Use when: User has specific travel criteria and you're showing concrete options + +### Tool Calling Strategy +- **Parallel calls**: Make multiple tool calls simultaneously when possible (e.g., outbound + return searches) +- **Multi-turn planning**: Use conversation flow to gather requirements, search, and refine +- **Progressive refinement**: Start broad, then narrow based on user preferences +- **CRITICAL**: ALL tool parameters MUST use codes: + - Airports: 3-letter IATA codes only (SFO, not "San Francisco") + - Airlines: 2-letter codes only (UA, not "United") + - Scene data: Use codes in arrays (["SFO", "JFK"], not ["San Francisco", "New York"]) + +## Guidelines + +### Communication Style +- Be conversational, helpful, and proactive +- Explain your reasoning when suggesting alternatives +- Use the user's location context to provide relevant suggestions (e.g., nearest airports) +- Present information clearly with prices, times, and key details highlighted + +### Planning Approach +1. **Understand intent**: Clarify trip requirements (dates, flexibility, budget, preferences) +2. **Search strategically**: + - For round trips: Search outbound and return separately + - For flexible dates: Search multiple date combinations + - Consider nearby airports to the user's location when relevant +3. **Present options**: Show best matches with tradeoffs (price vs. convenience) +4. **Iterate**: Refine based on user feedback + +### Important Constraints +- **One-way searches only**: Always search legs individually, never combined round-trip +- **Date handling**: Use ISO date format (YYYY-MM-DD) for all searches +- **CRITICAL - Code Usage**: + - **ALWAYS use 3-letter IATA airport codes** (SFO, JFK, LAX, etc.) in ALL tool calls and data + - **ALWAYS use 2-letter airline codes** (UA, AA, DL, etc.) in ALL tool calls and data + - **NEVER use full airport or airline names** in tool parameters or scene data + - **EXCEPTION**: Only use full names when displaying text to users (e.g., "San Francisco International" in messages) + - **Example**: Tool call uses "SFO", user message says "San Francisco (SFO)" +- **Scene updates**: Change scenes when the context shifts (exploration → specific search) + +### Best Practices +- **Assume flexibility**: If dates aren't firm, proactively search nearby dates for better deals +- **Consider context**: Factor in the user's home location and nearby airports (local or major hubs) +- **Multi-airport advantage**: Search multiple origin/destination airports to show all options +- **Award travel**: When users mention points/miles, prioritize seats.aero searches +- **Clear explanations**: When planning complex trips, explain the routing logic + +## Example Interactions + +**User**: "I want to visit New York in December" +**Approach**: +1. Clarify dates and flexibility +2. Identify nearby departure airports to the user's location +3. Use CODES in tools: Search origin ["SFO"] to destination ["JFK", "EWR", "LGA"] +4. Display to user: "Searching flights from San Francisco to New York area airports..." +5. Present both outbound and return options + +**User**: "Find me the cheapest way to get to Tokyo next month" +**Approach**: +1. Search multiple dates in the next month +2. Use CODE "NRT" or "HND" in tool calls, not "Tokyo" +3. Display to user: "Tokyo (NRT/HND)" for clarity +4. Update to search view with best options showing full names in results + +**User**: "Show me flights from San Francisco to Hawaii" +**Approach**: +1. Update map scene with CODES: airports: ["SFO", "HNL", "OGG", "KOA", "LIH"] +2. Ask about specific island preference and dates +3. Use CODES in tool: origin: ["SFO"], destination: ["HNL", "OGG", "KOA", "LIH"] +4. Display to user: "Honolulu (HNL), Maui (OGG), Kona (KOA), Lihue (LIH)" + +## CRITICAL REMINDER: Code Usage + +**ALWAYS in tool calls and data**: Use 3-letter airport codes (SFO, JFK) and 2-letter airline codes (UA, AA) +**ONLY in user-facing text**: Use full names for readability ("San Francisco International Airport (SFO)") + +This is non-negotiable. The system expects codes in all structured data, parameters, and tool calls. Full names will cause errors. + +Remember: You're not just a search tool, you're a planning partner. Proactively suggest optimizations, alternatives, and insights that help users make informed decisions about their travel.`; +} + +/** + * Get the dynamic user prompt that provides conversation-specific context. + * This includes the current date, user information, location, and active scene. + * @param ctx - The planner context containing user and scene information + */ +export function getUserPrompt(ctx: PlannerContext): string { + const currentDate = new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + const sceneDescription = + ctx.scene.view === "map" + ? ctx.scene.mode === "popular" + ? "Map showing popular routes" + : `Map showing routes for airports: ${ctx.scene.data?.airports?.join(", ") || "none"}` + : `Flight search results (${ctx.scene.data.origin.join(", ")} → ${ctx.scene.data.destination.join(", ")})`; + + return `## Current Context + +**Date**: ${currentDate} +**User**: ${ctx.user.name} (${ctx.user.email}) +**Location**: ${ctx.user.city}, ${ctx.user.state}, ${ctx.user.country} +**Current View (What the user sees aside from the conversation)**: ${sceneDescription} + +--- + +The user will provide their query or request below. Use the above context to personalize your response and consider their location when suggesting airports or routes.`; +} diff --git a/src/ai/tools/control-scene.ts b/src/ai/tools/control-scene.ts new file mode 100644 index 0000000..5e187fb --- /dev/null +++ b/src/ai/tools/control-scene.ts @@ -0,0 +1,129 @@ +import { tool } from "ai"; +import { + type ControlSceneParams, + ControlSceneParamsSchema, + PlannerMapSceneSchema, + PlannerSearchSceneSchema, +} from "../types"; + +/** + * Control Scene Tool + * + * Changes what the user sees in the UI: + * - Map view (popular routes) + * - Map view (specific airport routes) + * - Search results view (with flight filters) + * + * IMPORTANT: + * - Always use 3-letter IATA codes for airports (SFO, JFK, LAX) + * - Scene changes are immediate and replace current view + * - Use strategically to guide the user experience + */ +export const controlSceneTool = tool({ + description: `Control what the user sees in the interface. Switch between different views based on conversation context. + +Available Views: + +1. MAP - POPULAR MODE + Show popular flight routes and destinations + Use when: User is exploring, needs inspiration, or conversation is just starting + +2. MAP - ROUTES MODE + Show routes between specific airports on a map + Use when: Showing connections or comparing airport options + Requires: Array of airport codes to visualize + +3. SEARCH VIEW + Show flight search results with filters + Use when: User has specific travel criteria and you're showing concrete options + Requires: Origin, destination, date range, and travel date + +Strategy Tips: +- Start with map view for exploration +- Switch to search view when showing specific flights +- Use routes mode to explain multi-city connections +- Always use 3-letter airport codes (SFO, JFK, LAX) + +Examples: +- "Show me flights to Hawaii" → Map routes mode with HI airports +- "I want to fly from SF to NYC on Dec 15" → Search view with filters +- "What are popular destinations?" → Map popular mode`, + + inputSchema: ControlSceneParamsSchema, + + execute: async (params: ControlSceneParams) => { + try { + if (params.view === "map") { + if (params.mode === "popular") { + // Popular routes map view + const scene = { + view: "map" as const, + mode: "popular" as const, + data: null, + }; + + // Validate with Zod schema + PlannerMapSceneSchema.parse(scene); + + return { + success: true, + message: "Displaying popular flight routes map", + scene, + }; + } + + // Routes mode - specific airports + if (!params.airports || params.airports.length === 0) { + return { + success: false, + message: "Routes mode requires at least one airport code", + }; + } + + const scene = { + view: "map" as const, + mode: "routes" as const, + data: { + airports: params.airports, + }, + }; + + // Validate with Zod schema + PlannerMapSceneSchema.parse(scene); + + return { + success: true, + message: `Displaying routes for airports: ${params.airports.join(", ")}`, + scene, + }; + } + + // Search view + const scene = { + view: "search" as const, + mode: "flights" as const, + data: { + origin: params.origin, + destination: params.destination, + startDate: params.startDate, + endDate: params.endDate, + travelDate: params.travelDate, + }, + }; + + // Validate with Zod schema + PlannerSearchSceneSchema.parse(scene); + + return { + success: true, + message: `Displaying search filters: ${params.origin.join(", ")} → ${params.destination.join(", ")}`, + scene, + }; + } catch (error) { + return { + success: false, + message: `Scene control failed: ${(error as Error).message}`, + }; + } + }, +}); diff --git a/src/ai/tools/index.ts b/src/ai/tools/index.ts new file mode 100644 index 0000000..cbfeb00 --- /dev/null +++ b/src/ai/tools/index.ts @@ -0,0 +1,9 @@ +/** + * AI Tools for Flight Planner Agent + * + * This module exports all tools available to the agent. + */ + +export { controlSceneTool } from "./control-scene"; +export { searchDatesTool } from "./search-dates"; +export { searchFlightsTool } from "./search-flights"; diff --git a/src/ai/tools/search-dates.ts b/src/ai/tools/search-dates.ts new file mode 100644 index 0000000..3a0759e --- /dev/null +++ b/src/ai/tools/search-dates.ts @@ -0,0 +1,231 @@ +import { tool } from "ai"; +import { Airline } from "@/lib/fli/models/airline"; +import { Airport } from "@/lib/fli/models/airport"; +import { + Currency, + MaxStops, + SeatType, + TripType, +} from "@/lib/fli/models/google-flights/base"; +import { SearchDates } from "@/lib/fli/search"; +import { type SearchDatesParams, SearchDatesParamsSchema } from "../types"; + +/** + * Convert string codes to Airport enum values. + */ +function toAirportEnum(code: string): Airport { + const airport = Airport[code as keyof typeof Airport]; + if (!airport) { + throw new Error(`Invalid airport code: ${code}`); + } + return airport; +} + +/** + * Convert string codes to Airline enum values. + */ +function toAirlineEnum(code: string): Airline { + const airline = Airline[code as keyof typeof Airline]; + if (!airline) { + throw new Error(`Invalid airline code: ${code}`); + } + return airline; +} + +/** + * Convert simplified maxStops to enum. + */ +function toMaxStopsEnum(stops: string): MaxStops { + switch (stops) { + case "nonstop": + return MaxStops.NON_STOP; + case "1": + return MaxStops.ONE_STOP_OR_FEWER; + case "2": + return MaxStops.TWO_OR_FEWER_STOPS; + default: + return MaxStops.ANY; + } +} + +/** + * Convert simplified seatType to enum. + */ +function toSeatTypeEnum(seat: string): SeatType { + switch (seat) { + case "premium": + return SeatType.PREMIUM_ECONOMY; + case "business": + return SeatType.BUSINESS; + case "first": + return SeatType.FIRST; + default: + return SeatType.ECONOMY; + } +} + +/** + * Convert simplified tripType to enum. + */ +function toTripTypeEnum(type: string): TripType { + return type === "roundtrip" ? TripType.ROUND_TRIP : TripType.ONE_WAY; +} + +/** + * Format date to YYYY-MM-DD string. + */ +function formatDate(date: Date): string { + return date.toISOString().split("T")[0] ?? ""; +} + +/** + * Search Dates Tool + * + * Searches for the cheapest dates to fly within a date range. + * Perfect for flexible travelers looking for the best deals. + * + * IMPORTANT: + * - Always use 3-letter IATA codes for airports (SFO, JFK, LAX) + * - Always use 2-letter codes for airlines (UA, AA, DL) + * - For round trips, must provide tripDuration parameter + * - Can search up to ~300 days in the future + */ +export const searchDatesTool = tool({ + description: `Search for the cheapest dates to fly within a date range. Returns a calendar of prices for each date. + +Key Features: +- Flexible date search: Find best prices across weeks or months +- Multi-airport support: Search from/to multiple airports +- Round trip support: Specify trip duration to find best round-trip dates +- Price calendar: See prices for each date in the range + +Use Cases: +- "When's the cheapest time to fly to Hawaii this summer?" +- "Find me the best dates to visit New York in December" +- "What are the cheapest weekend trips from SF to LA?" + +Important Notes: +- Always use 3-letter airport codes (SFO, JFK) and 2-letter airline codes (UA, AA) +- For round trips, provide tripDuration (e.g., 7 for a week) +- Date range can be up to ~300 days in the future +- Results show prices for each date, sorted by price`, + + inputSchema: SearchDatesParamsSchema, + + execute: async (params: SearchDatesParams) => { + try { + // Validate round trip requirements + if (params.tripType === "roundtrip" && !params.tripDuration) { + return { + success: false, + message: "Trip duration is required for round trip searches", + dates: [], + }; + } + + const searchDates = new SearchDates(); + const tripType = toTripTypeEnum(params.tripType ?? "oneway"); + + // Build flight segments + const firstTravelDate = formatDate(params.startDate); + const segments = [ + { + departureAirport: params.origin.map((code) => [ + toAirportEnum(code), + 0, + ]), + arrivalAirport: params.destination.map((code) => [ + toAirportEnum(code), + 0, + ]), + travelDate: firstTravelDate, + }, + ]; + + // Add return segment for round trips + if (tripType === TripType.ROUND_TRIP) { + segments.push({ + departureAirport: params.destination.map((code) => [ + toAirportEnum(code), + 0, + ]), + arrivalAirport: params.origin.map((code) => [toAirportEnum(code), 0]), + travelDate: firstTravelDate, // Will be adjusted by API + }); + } + + // Convert parameters to DateSearchFilters + const filters = { + tripType, + passengerInfo: { + adults: params.adults, + children: params.children ?? 0, + infantsInSeat: 0, + infantsOnLap: 0, + }, + flightSegments: segments, + stops: toMaxStopsEnum(params.maxStops ?? "any"), + seatType: toSeatTypeEnum(params.seatType ?? "economy"), + fromDate: formatDate(params.startDate), + toDate: formatDate(params.endDate), + ...(params.tripDuration && { duration: params.tripDuration }), + ...(params.maxPrice && { + priceLimit: { maxPrice: params.maxPrice, currency: Currency.USD }, + }), + ...(params.airlines && { + airlines: params.airlines.map(toAirlineEnum), + }), + }; + + // Execute search + const results = await searchDates.search(filters); + + if (!results || results.length === 0) { + return { + success: false, + message: "No flights found in this date range", + dates: [], + }; + } + + // Format and sort results by price + const dates = results + .map((result) => ({ + date: + result.date.length === 1 + ? formatDate(result.date[0]) + : `${formatDate(result.date[0])} - ${formatDate(result.date[1])}`, + price: result.price, + ...(result.date.length === 2 && { + departureDate: formatDate(result.date[0]), + returnDate: formatDate(result.date[1]), + }), + })) + .sort((a, b) => a.price - b.price); + + // Find cheapest + const cheapest = dates[0]; + + return { + success: true, + message: `Found ${dates.length} date${dates.length > 1 ? "s" : ""} with flights`, + count: dates.length, + cheapestPrice: cheapest?.price, + cheapestDate: cheapest?.date, + dates, + searchParams: { + origin: params.origin, + destination: params.destination, + dateRange: `${formatDate(params.startDate)} to ${formatDate(params.endDate)}`, + tripType: params.tripType ?? "oneway", + }, + }; + } catch (error) { + return { + success: false, + message: `Date search failed: ${(error as Error).message}`, + dates: [], + }; + } + }, +}); diff --git a/src/ai/tools/search-flights.ts b/src/ai/tools/search-flights.ts new file mode 100644 index 0000000..c0d7c01 --- /dev/null +++ b/src/ai/tools/search-flights.ts @@ -0,0 +1,204 @@ +import { tool } from "ai"; +import { Airline } from "@/lib/fli/models/airline"; +import { Airport } from "@/lib/fli/models/airport"; +import { + Currency, + type FlightResult, + MaxStops, + SeatType, + SortBy, + TripType, +} from "@/lib/fli/models/google-flights/base"; +import { SearchFlights } from "@/lib/fli/search"; +import { type SearchFlightsParams, SearchFlightsParamsSchema } from "../types"; + +/** + * Convert string codes to Airport enum values. + */ +function toAirportEnum(code: string): Airport { + const airport = Airport[code as keyof typeof Airport]; + if (!airport) { + throw new Error(`Invalid airport code: ${code}`); + } + return airport; +} + +/** + * Convert string codes to Airline enum values. + */ +function toAirlineEnum(code: string): Airline { + const airline = Airline[code as keyof typeof Airline]; + if (!airline) { + throw new Error(`Invalid airline code: ${code}`); + } + return airline; +} + +/** + * Convert simplified maxStops to enum. + */ +function toMaxStopsEnum(stops: string): MaxStops { + switch (stops) { + case "nonstop": + return MaxStops.NON_STOP; + case "1": + return MaxStops.ONE_STOP_OR_FEWER; + case "2": + return MaxStops.TWO_OR_FEWER_STOPS; + default: + return MaxStops.ANY; + } +} + +/** + * Convert simplified seatType to enum. + */ +function toSeatTypeEnum(seat: string): SeatType { + switch (seat) { + case "premium": + return SeatType.PREMIUM_ECONOMY; + case "business": + return SeatType.BUSINESS; + case "first": + return SeatType.FIRST; + default: + return SeatType.ECONOMY; + } +} + +/** + * Format date to YYYY-MM-DD string. + */ +function formatDate(date: Date): string { + return date.toISOString().split("T")[0] ?? ""; +} + +/** + * Format flight result for display. + */ +function formatFlightResult(flight: FlightResult) { + return { + price: flight.price, + duration: flight.duration, + stops: flight.stops, + legs: flight.legs.map((leg) => ({ + airline: leg.airline, + flightNumber: leg.flightNumber, + departure: { + airport: leg.departureAirport, + dateTime: leg.departureDateTime.toISOString(), + }, + arrival: { + airport: leg.arrivalAirport, + dateTime: leg.arrivalDateTime.toISOString(), + }, + duration: leg.duration, + })), + }; +} + +/** + * Search Flights Tool + * + * Searches for one-way flights between specified airports on a specific date. + * Returns up to topN flight options with pricing and timing details. + * + * IMPORTANT: + * - Always use 3-letter IATA codes for airports (SFO, JFK, LAX) + * - Always use 2-letter codes for airlines (UA, AA, DL) + * - For round trips, call this tool twice (outbound + return) + */ +export const searchFlightsTool = tool({ + description: `Search for one-way flights between airports. Returns available flights with prices, durations, and details. + +Key Features: +- Multi-airport search: Search from/to multiple airports simultaneously +- Flexible filters: Seat class, stops, price limits, specific airlines +- Detailed results: Full flight legs with times, durations, and prices + +Important Notes: +- This searches ONE-WAY flights only +- For round trips, make two separate searches (outbound + return) +- Always use 3-letter airport codes (SFO, JFK) and 2-letter airline codes (UA, AA) +- Dates must be in the future +- Results sorted by best overall value`, + + inputSchema: SearchFlightsParamsSchema, + + execute: async (params: SearchFlightsParams) => { + try { + const searchFlights = new SearchFlights(); + + // Convert parameters to FlightSearchFilters + const filters = { + tripType: TripType.ONE_WAY, + passengerInfo: { + adults: params.adults, + children: params.children ?? 0, + infantsInSeat: 0, + infantsOnLap: 0, + }, + flightSegments: [ + { + departureAirport: params.origin.map((code) => [ + toAirportEnum(code), + 0, + ]), + arrivalAirport: params.destination.map((code) => [ + toAirportEnum(code), + 0, + ]), + travelDate: formatDate(params.travelDate), + }, + ], + stops: toMaxStopsEnum(params.maxStops ?? "any"), + seatType: toSeatTypeEnum(params.seatType ?? "economy"), + sortBy: SortBy.NONE, + ...(params.maxPrice && { + priceLimit: { maxPrice: params.maxPrice, currency: Currency.USD }, + }), + ...(params.airlines && { + airlines: params.airlines.map(toAirlineEnum), + }), + }; + + // Execute search + const results = await searchFlights.search(filters, params.topN ?? 5); + + if (!results || results.length === 0) { + return { + success: false, + message: "No flights found matching your criteria", + flights: [], + }; + } + + // Format results + const flights = results.map((result) => { + if (Array.isArray(result)) { + // This shouldn't happen for one-way searches + return formatFlightResult(result[0]); + } + return formatFlightResult(result); + }); + + return { + success: true, + message: `Found ${flights.length} flight${flights.length > 1 ? "s" : ""}`, + count: flights.length, + flights, + searchParams: { + origin: params.origin, + destination: params.destination, + travelDate: formatDate(params.travelDate), + }, + }; + } catch (error) { + return { + success: false, + message: `Flight search failed: ${(error as Error).message}`, + flights: [], + }; + } + }, +}); diff --git a/src/ai/types.ts b/src/ai/types.ts new file mode 100644 index 0000000..9299136 --- /dev/null +++ b/src/ai/types.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; + +export const PlannerMapSceneSchema = z.discriminatedUnion("mode", [ + z.object({ + view: z.enum(["map"]), + mode: z.literal("popular"), + data: z.object({}).nullish(), + }), + z.object({ + view: z.enum(["map"]), + mode: z.literal("routes"), + data: z.object({ + airports: z.array(z.string()), + }), + }), +]); +export type PlannerMapScene = z.infer; + +export const PlannerSearchSceneSchema = z.object({ + view: z.enum(["search"]), + mode: z.enum(["flights"]), + data: z.object({ + origin: z.array(z.string()), + destination: z.array(z.string()), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + travelDate: z.coerce.date(), + }), +}); +export type PlannerSearchScene = z.infer; + +export const PlannerContextSchema = z.object({ + scene: z.discriminatedUnion("view", [ + PlannerMapSceneSchema, + PlannerSearchSceneSchema, + ]), + user: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + city: z.string(), + state: z.string(), + country: z.string(), + }), +}); +export type PlannerContext = z.infer; + +/** + * Base flight search parameters shared across tools. + * Includes common filters for airports, passengers, and preferences. + */ +const BaseFlightSearchSchema = z.object({ + origin: z + .array(z.string().length(3).toUpperCase()) + .min(1) + .describe( + "Array of 3-letter IATA airport codes for departure (e.g., ['SFO', 'OAK'])", + ), + destination: z + .array(z.string().length(3).toUpperCase()) + .min(1) + .describe( + "Array of 3-letter IATA airport codes for arrival (e.g., ['JFK', 'LGA'])", + ), + adults: z + .number() + .int() + .min(1) + .max(9) + .default(1) + .describe("Number of adult passengers (1-9)"), + children: z + .number() + .int() + .min(0) + .max(8) + .default(0) + .optional() + .describe("Number of children (0-8)"), + maxStops: z + .enum(["any", "nonstop", "1", "2"]) + .default("any") + .optional() + .describe("Maximum number of stops: any, nonstop, 1 stop, or 2 stops"), + seatType: z + .enum(["economy", "premium", "business", "first"]) + .default("economy") + .optional() + .describe( + "Cabin class: economy, premium economy, business, or first class", + ), + maxPrice: z + .number() + .positive() + .optional() + .describe("Maximum price in USD to filter results"), + airlines: z + .array(z.string().length(2).toUpperCase()) + .optional() + .describe( + "Filter by specific airlines using 2-letter codes (e.g., ['UA', 'AA', 'DL'])", + ), +}); + +/** + * Parameters for searching specific one-way flights. + * Used by searchFlightsTool to find available flights on a specific date. + */ +export const SearchFlightsParamsSchema = BaseFlightSearchSchema.extend({ + travelDate: z.coerce + .date() + .describe("Departure date in ISO format (YYYY-MM-DD)"), + topN: z + .number() + .int() + .min(1) + .max(10) + .default(5) + .optional() + .describe("Number of top flight results to return (1-10, default: 5)"), +}); +export type SearchFlightsParams = z.infer; + +/** + * Parameters for searching cheapest dates to fly. + * Used by searchDatesTool to find optimal travel dates within a range. + */ +export const SearchDatesParamsSchema = BaseFlightSearchSchema.extend({ + startDate: z.coerce + .date() + .describe("Start of date range to search (YYYY-MM-DD)"), + endDate: z.coerce.date().describe("End of date range to search (YYYY-MM-DD)"), + tripType: z + .enum(["oneway", "roundtrip"]) + .default("oneway") + .optional() + .describe("Trip type: one-way or round-trip search"), + tripDuration: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe( + "For round trips: duration in days (e.g., 7 for a week-long trip)", + ), +}); +export type SearchDatesParams = z.infer; + +/** + * Parameters for controlling the UI scene. + * Used by controlSceneTool to switch between map and search views. + */ +export const ControlSceneParamsSchema = z.discriminatedUnion("view", [ + z.object({ + view: z.literal("map").describe("Show map view"), + mode: z + .enum(["popular", "routes"]) + .describe("Map mode: popular routes or specific airport routes"), + airports: z + .array(z.string().length(3).toUpperCase()) + .optional() + .describe( + "For routes mode: array of 3-letter airport codes to display connections", + ), + }), + z.object({ + view: z.literal("search").describe("Show search results view"), + origin: z + .array(z.string().length(3).toUpperCase()) + .min(1) + .describe("Origin airport codes for search filters"), + destination: z + .array(z.string().length(3).toUpperCase()) + .min(1) + .describe("Destination airport codes for search filters"), + startDate: z.coerce + .date() + .describe("Start of date range for search filters"), + endDate: z.coerce.date().describe("End of date range for search filters"), + travelDate: z.coerce + .date() + .describe("Specific travel date to highlight in results"), + }), +]); +export type ControlSceneParams = z.infer; diff --git a/src/server/routers/planner.ts b/src/server/routers/planner.ts new file mode 100644 index 0000000..e69de29 From 4650d8abaa4bdfbf4c19845fb57f269dfcb73882 Mon Sep 17 00:00:00 2001 From: Punit Arani Date: Sat, 18 Oct 2025 17:01:29 -0700 Subject: [PATCH 2/4] Create AI Planner UI --- bun.lock | 3 + package.json | 1 + src/ai/planner-agent.ts | 57 +- src/ai/planner-prompt.ts | 229 +++---- src/ai/tools/control-scene.ts | 217 +++++- src/ai/tools/search-dates.ts | 123 +++- src/ai/tools/search-flights.ts | 103 ++- src/ai/types.ts | 123 ++-- src/ai/utils.ts | 43 ++ src/app/api/planner/route.ts | 58 ++ src/app/planner/components/date-calendar.tsx | 154 +++++ .../planner/components/date-price-chart.tsx | 184 ++++++ src/app/planner/components/flight-card.tsx | 141 ++++ src/app/planner/components/flights-list.tsx | 34 + src/app/planner/components/map-scene.tsx | 144 ++++ src/app/planner/components/scene-view.tsx | 40 ++ src/app/planner/components/search-scene.tsx | 616 ++++++++++++++++++ src/app/planner/components/tool-renderer.tsx | 222 +++++++ src/app/planner/layout.tsx | 12 + src/app/planner/page.tsx | 320 +++++++++ src/app/planner/types.ts | 86 +++ src/components/ai-elements/message.tsx | 4 +- src/components/ai-elements/tool.tsx | 8 +- src/components/header.tsx | 79 ++- 24 files changed, 2742 insertions(+), 259 deletions(-) create mode 100644 src/ai/utils.ts create mode 100644 src/app/api/planner/route.ts create mode 100644 src/app/planner/components/date-calendar.tsx create mode 100644 src/app/planner/components/date-price-chart.tsx create mode 100644 src/app/planner/components/flight-card.tsx create mode 100644 src/app/planner/components/flights-list.tsx create mode 100644 src/app/planner/components/map-scene.tsx create mode 100644 src/app/planner/components/scene-view.tsx create mode 100644 src/app/planner/components/search-scene.tsx create mode 100644 src/app/planner/components/tool-renderer.tsx create mode 100644 src/app/planner/layout.tsx create mode 100644 src/app/planner/page.tsx create mode 100644 src/app/planner/types.ts diff --git a/bun.lock b/bun.lock index a06cea0..1a36323 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ "@trpc/react": "^9", "@trpc/server": "^9", "@vercel/analytics": "^1.5.0", + "@vercel/functions": "^3.1.4", "@xyflow/react": "^12.8.6", "ai": "^5.0.76", "ai-elements": "^1.1.2", @@ -852,6 +853,8 @@ "@vercel/analytics": ["@vercel/analytics@1.5.0", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="], + "@vercel/functions": ["@vercel/functions@3.1.4", "", { "dependencies": { "@vercel/oidc": "3.0.3" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-1dEfZkb7qxsA+ilo+1uBUCEgr7e90vHcimpDYkUB84DM051wQ5amJDk9x+cnaI29paZb5XukXwGl8yk3Udb/DQ=="], + "@vercel/oidc": ["@vercel/oidc@3.0.3", "", {}, "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg=="], "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], diff --git a/package.json b/package.json index eef2836..746f831 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@trpc/react": "^9", "@trpc/server": "^9", "@vercel/analytics": "^1.5.0", + "@vercel/functions": "^3.1.4", "@xyflow/react": "^12.8.6", "ai": "^5.0.76", "ai-elements": "^1.1.2", diff --git a/src/ai/planner-agent.ts b/src/ai/planner-agent.ts index b9c48ef..6f40b14 100644 --- a/src/ai/planner-agent.ts +++ b/src/ai/planner-agent.ts @@ -1,12 +1,27 @@ -import { Experimental_Agent as Agent } from "ai"; +import { + Experimental_Agent as Agent, + type InferUITools, + stepCountIs, + type UIDataTypes, + type UIMessage, +} from "ai"; import { getSystemPrompt } from "./planner-prompt"; import { controlSceneTool, searchDatesTool, searchFlightsTool } from "./tools"; +import type { PlannerContext } from "./types"; + +const PlannerAgentTools = { + searchFlights: searchFlightsTool, + searchDates: searchDatesTool, + controlScene: controlSceneTool, +}; /** - * Flight Planner Agent + * Create a Flight Planner Agent instance with user context. * - * An AI agent that helps users plan flights with real-time data access. + * This factory function creates a new agent for each request with + * personalized system prompt including current date/time, user info, + * and scene state. * * Capabilities: * - Search for specific flights with detailed filters @@ -19,13 +34,31 @@ import { controlSceneTool, searchDatesTool, searchFlightsTool } from "./tools"; * - searchFlights: Find one-way flights for specific dates * - searchDates: Find best prices across date ranges * - controlScene: Switch between map and search views + * + * Configuration: + * - Max steps: 15 (prevents infinite loops while allowing complex planning) + * - Auto tool choice: Agent decides when to use tools + * - Error recovery: Tools return graceful errors for agent to handle + * + * @param context - The planner context with user and scene information + * @returns A configured Agent instance + */ +export function createPlannerAgent(context: PlannerContext) { + return new Agent({ + model: "openai/gpt-5-mini", + system: getSystemPrompt(context), + tools: PlannerAgentTools, + stopWhen: stepCountIs(25), // Allow multiple tool calls for complex planning + maxRetries: 3, // Retry on transient failures + }); +} + +/** + * Type-safe UI message for the planner agent. + * Includes all tool types with proper inference. */ -export const plannerAgent = new Agent({ - model: "openai/gpt-4o", - system: getSystemPrompt(), - tools: { - searchFlights: searchFlightsTool, - searchDates: searchDatesTool, - controlScene: controlSceneTool, - }, -}); +export type PlannerAgentUIMessage = UIMessage< + never, + UIDataTypes, + InferUITools +>; diff --git a/src/ai/planner-prompt.ts b/src/ai/planner-prompt.ts index 8500779..fdc2665 100644 --- a/src/ai/planner-prompt.ts +++ b/src/ai/planner-prompt.ts @@ -3,158 +3,40 @@ import type { PlannerContext } from "./types"; /** * Flight Planner Agent Prompts * - * This module provides system and user prompts for the flight planner agent. + * This module provides a consolidated system prompt for the flight planner agent + * that includes both static instructions and dynamic user context. * * Usage: * ```ts - * // For AI SDK Agent (system prompt only) - * const agent = new Agent({ - * model: "openai/gpt-4", - * system: getSystemPrompt(), - * }); + * // Generate context-aware system prompt + * const context: PlannerContext = { + * user: { id, name, email, city, state, country }, + * scene: currentScene, + * }; + * const systemPrompt = getSystemPrompt(context); * - * // For chat completions (both prompts) - * const { systemPrompt, userPrompt } = getPlannerPrompts(context); - * const response = await generateText({ - * model: openai("gpt-4"), + * // Use in agent + * const agent = new Agent({ + * model: "openai/gpt-5-mini", * system: systemPrompt, - * messages: [ - * { role: "user", content: userPrompt }, - * { role: "user", content: actualUserQuery } - * ], * }); * ``` */ /** - * Get the static system prompt that defines the agent's role and capabilities. - * This prompt contains no user-specific information and can be reused across conversations. - */ -export function getSystemPrompt(): string { - return `You are an expert flight planning assistant with real-time access to flight data and award availability. Your role is to help users discover, compare, and plan their air travel efficiently. - -## Your Capabilities - -### Flight Search & Data Access -- **Real-time flight lookups**: Access current flight availability, prices, and schedules -- **Award flight search**: Query seats.aero for award availability across multiple airlines -- **Multi-airport search**: Search from/to multiple airports simultaneously for better options -- **Flexible date search**: Search across date ranges to find optimal pricing and availability -- **One-way flight focus**: Each search query is for ONE-WAY flights only - -### Trip Planning Intelligence -- **Multi-leg journeys**: Plan complex itineraries by combining multiple one-way searches -- **Round-trip planning**: Use separate searches for outbound and return legs -- **Multi-city trips**: Chain multiple one-way flights for complex routing -- **Date optimization**: Suggest alternative dates for better prices or availability -- **Geographic insights**: Leverage your knowledge of airports, distances, and connections - -### Scene Control -You can control what the user sees in their interface using the scene parameter: - -1. **Map View - Popular Routes** (\`view: "map", mode: "popular"\`) - - Show popular flight routes and destinations - - Use when: User is exploring or wants inspiration - -2. **Map View - Specific Routes** (\`view: "map", mode: "routes"\`) - - Display routes between specific airports - - Provide \`airports\` array with IATA codes (e.g., ["SFO", "JFK", "LAX"]) - - Use when: Showing connections between specific cities - -3. **Search View - Flight Results** (\`view: "search", mode: "flights"\`) - - Display detailed flight search results - - Requires: \`origin\`, \`destination\` (arrays), \`startDate\`, \`endDate\`, \`travelDate\` - - Use when: User has specific travel criteria and you're showing concrete options - -### Tool Calling Strategy -- **Parallel calls**: Make multiple tool calls simultaneously when possible (e.g., outbound + return searches) -- **Multi-turn planning**: Use conversation flow to gather requirements, search, and refine -- **Progressive refinement**: Start broad, then narrow based on user preferences -- **CRITICAL**: ALL tool parameters MUST use codes: - - Airports: 3-letter IATA codes only (SFO, not "San Francisco") - - Airlines: 2-letter codes only (UA, not "United") - - Scene data: Use codes in arrays (["SFO", "JFK"], not ["San Francisco", "New York"]) - -## Guidelines - -### Communication Style -- Be conversational, helpful, and proactive -- Explain your reasoning when suggesting alternatives -- Use the user's location context to provide relevant suggestions (e.g., nearest airports) -- Present information clearly with prices, times, and key details highlighted - -### Planning Approach -1. **Understand intent**: Clarify trip requirements (dates, flexibility, budget, preferences) -2. **Search strategically**: - - For round trips: Search outbound and return separately - - For flexible dates: Search multiple date combinations - - Consider nearby airports to the user's location when relevant -3. **Present options**: Show best matches with tradeoffs (price vs. convenience) -4. **Iterate**: Refine based on user feedback - -### Important Constraints -- **One-way searches only**: Always search legs individually, never combined round-trip -- **Date handling**: Use ISO date format (YYYY-MM-DD) for all searches -- **CRITICAL - Code Usage**: - - **ALWAYS use 3-letter IATA airport codes** (SFO, JFK, LAX, etc.) in ALL tool calls and data - - **ALWAYS use 2-letter airline codes** (UA, AA, DL, etc.) in ALL tool calls and data - - **NEVER use full airport or airline names** in tool parameters or scene data - - **EXCEPTION**: Only use full names when displaying text to users (e.g., "San Francisco International" in messages) - - **Example**: Tool call uses "SFO", user message says "San Francisco (SFO)" -- **Scene updates**: Change scenes when the context shifts (exploration → specific search) - -### Best Practices -- **Assume flexibility**: If dates aren't firm, proactively search nearby dates for better deals -- **Consider context**: Factor in the user's home location and nearby airports (local or major hubs) -- **Multi-airport advantage**: Search multiple origin/destination airports to show all options -- **Award travel**: When users mention points/miles, prioritize seats.aero searches -- **Clear explanations**: When planning complex trips, explain the routing logic - -## Example Interactions - -**User**: "I want to visit New York in December" -**Approach**: -1. Clarify dates and flexibility -2. Identify nearby departure airports to the user's location -3. Use CODES in tools: Search origin ["SFO"] to destination ["JFK", "EWR", "LGA"] -4. Display to user: "Searching flights from San Francisco to New York area airports..." -5. Present both outbound and return options - -**User**: "Find me the cheapest way to get to Tokyo next month" -**Approach**: -1. Search multiple dates in the next month -2. Use CODE "NRT" or "HND" in tool calls, not "Tokyo" -3. Display to user: "Tokyo (NRT/HND)" for clarity -4. Update to search view with best options showing full names in results - -**User**: "Show me flights from San Francisco to Hawaii" -**Approach**: -1. Update map scene with CODES: airports: ["SFO", "HNL", "OGG", "KOA", "LIH"] -2. Ask about specific island preference and dates -3. Use CODES in tool: origin: ["SFO"], destination: ["HNL", "OGG", "KOA", "LIH"] -4. Display to user: "Honolulu (HNL), Maui (OGG), Kona (KOA), Lihue (LIH)" - -## CRITICAL REMINDER: Code Usage - -**ALWAYS in tool calls and data**: Use 3-letter airport codes (SFO, JFK) and 2-letter airline codes (UA, AA) -**ONLY in user-facing text**: Use full names for readability ("San Francisco International Airport (SFO)") - -This is non-negotiable. The system expects codes in all structured data, parameters, and tool calls. Full names will cause errors. - -Remember: You're not just a search tool, you're a planning partner. Proactively suggest optimizations, alternatives, and insights that help users make informed decisions about their travel.`; -} - -/** - * Get the dynamic user prompt that provides conversation-specific context. - * This includes the current date, user information, location, and active scene. + * Get the complete system prompt with user context. + * Includes current date/time, user information, and scene state. * @param ctx - The planner context containing user and scene information */ -export function getUserPrompt(ctx: PlannerContext): string { - const currentDate = new Date().toLocaleDateString("en-US", { +export function getSystemPrompt(ctx: PlannerContext): string { + const currentDateTime = new Date().toLocaleString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", }); const sceneDescription = @@ -164,14 +46,77 @@ export function getUserPrompt(ctx: PlannerContext): string { : `Map showing routes for airports: ${ctx.scene.data?.airports?.join(", ") || "none"}` : `Flight search results (${ctx.scene.data.origin.join(", ")} → ${ctx.scene.data.destination.join(", ")})`; - return `## Current Context + return `You are a flight planning assistant with real-time access to flight data. Be brief, action-oriented, and make smart assumptions. -**Date**: ${currentDate} +## Current Context + +**Current Date & Time**: ${currentDateTime} **User**: ${ctx.user.name} (${ctx.user.email}) -**Location**: ${ctx.user.city}, ${ctx.user.state}, ${ctx.user.country} -**Current View (What the user sees aside from the conversation)**: ${sceneDescription} +**User Location**: ${ctx.user.city}, ${ctx.user.state}, ${ctx.user.country} +**Current View**: ${sceneDescription} + +## Default Assumptions (Use These When Info Is Missing) + +When users don't specify details, **ASSUME** the following and search immediately: + +- **No date provided** → Search from tomorrow to 6 weeks out +- **No origin provided** → Use nearest airport to ${ctx.user.city}, ${ctx.user.state} +- **No cabin class** → Economy +- **No passengers** → 1 adult +- **Relative dates** → Calculate from current date (${currentDateTime}) + +**Philosophy**: Act first with smart defaults. Only ask questions when critical information is truly ambiguous (e.g., "Paris" could be Paris, France or Paris, Texas). + +## Core Capabilities + +### Flight Search +- **One-way searches only**: All flight searches are one-way. For round-trips or multi-city, make multiple separate searches. +- **Multi-airport support**: Can search from/to multiple airports in single query (e.g., origin: ["SFO", "OAK"], destination: ["JFK", "EWR", "LGA"]) +- **Date ranges**: Search across date ranges for flexible options +- **Award availability**: Query seats.aero for points/miles redemptions + +### Scene Control +Update the user's view using \`controlScene\`: +- **Map - Popular** (\`view: "map", mode: "popular"\`) - Show popular routes +- **Map - Routes** (\`view: "map", mode: "routes", airports: ["SFO", "LAX"]\`) - Show specific airport connections +- **Search Results** (\`view: "search", mode: "flights", origin: [...], destination: [...], startDate, endDate, travelDate\`) - Show flight results + +## Critical Rules + +### Code Usage (Non-Negotiable) +- **Tool calls & data**: ALWAYS use 3-letter IATA codes (SFO, JFK) and 2-letter airline codes (UA, AA) +- **User messages**: Use full names for readability ("San Francisco (SFO)") +- **Never**: Use full names in tool parameters - this causes errors + +### Response Style +- **Be brief**: 1-2 sentences max before showing results +- **Lead with action**: Search first, explain later +- **No unnecessary questions**: Use defaults and user context +- **Show top results**: Present 3-5 best options, not everything + +### Planning Multi-Leg Trips +For round-trips: Make 2 separate one-way searches (outbound + return) +For multi-city: Chain multiple one-way searches +Use parallel tool calls when possible for faster results + +## Error Handling + +If a search fails: +- Invalid dates → Adjust dates and retry +- No results → Broaden search (more airports, wider dates) +- Invalid codes → Correct and retry silently +- Keep responses brief: "No direct flights found, showing connections..." + +## Example Behavior + +**User**: "Tokyo next month" +**Response**: Immediately search ${ctx.user.city}'s nearest airport (SFO) to NRT/HND for dates next month, 1 adult, economy. Show top 5 results. + +**User**: "Round trip to NYC in December" +**Response**: Make 2 searches (outbound early Dec, return late Dec) from nearest airport to JFK/EWR/LGA. Show best combinations. ---- +**User**: "Hawaii this weekend" +**Response**: Search SFO to HNL/OGG/KOA/LIH for this Sat-Sun. If ambiguous which island, show all options briefly. -The user will provide their query or request below. Use the above context to personalize your response and consider their location when suggesting airports or routes.`; +Remember: Assume → Search → Show results. Keep it fast and simple.`; } diff --git a/src/ai/tools/control-scene.ts b/src/ai/tools/control-scene.ts index 5e187fb..f790f28 100644 --- a/src/ai/tools/control-scene.ts +++ b/src/ai/tools/control-scene.ts @@ -1,4 +1,5 @@ import { tool } from "ai"; +import { addYears, isAfter, isBefore, parseISO, startOfToday } from "date-fns"; import { type ControlSceneParams, ControlSceneParamsSchema, @@ -25,36 +26,42 @@ export const controlSceneTool = tool({ Available Views: 1. MAP - POPULAR MODE - Show popular flight routes and destinations + Set: view="map", mode="popular" Use when: User is exploring, needs inspiration, or conversation is just starting - -2. MAP - ROUTES MODE - Show routes between specific airports on a map - Use when: Showing connections or comparing airport options - Requires: Array of airport codes to visualize - + +2. MAP - ROUTES MODE + Set: view="map", mode="routes", airports=["SFO", "LAX", ...] + Use when: Showing connections between specific airports + Requires: airports array with 3-letter codes + 3. SEARCH VIEW - Show flight search results with filters - Use when: User has specific travel criteria and you're showing concrete options - Requires: Origin, destination, date range, and travel date + Set: view="search", origin=["SFO"], destination=["JFK"], startDate, endDate + Use when: User has specific travel criteria and you're showing flight search interface + Required: origin, destination, startDate, endDate (YYYY-MM-DD format) + Optional: travelDate (specific date within range), filters (seatType, maxStops, airlines, etc.) + Date Range: Must be from today to 1 year from now Strategy Tips: -- Start with map view for exploration +- Start with map popular mode for exploration - Switch to search view when showing specific flights -- Use routes mode to explain multi-city connections +- Use map routes mode to explain multi-city connections - Always use 3-letter airport codes (SFO, JFK, LAX) +- Dates: YYYY-MM-DD format, within next year +- Search view works like main search page - provide date range, user can select specific dates Examples: -- "Show me flights to Hawaii" → Map routes mode with HI airports -- "I want to fly from SF to NYC on Dec 15" → Search view with filters -- "What are popular destinations?" → Map popular mode`, +- "Show me flights to Hawaii" → view="map", mode="routes", airports=["SFO","HNL","OGG"] +- "Find SFO to NYC in December" → view="search", origin=["SFO"], destination=["JFK"], startDate="2025-12-01", endDate="2025-12-31" +- "What are popular destinations?" → view="map", mode="popular"`, inputSchema: ControlSceneParamsSchema, execute: async (params: ControlSceneParams) => { try { if (params.view === "map") { - if (params.mode === "popular") { + const mode = params.mode ?? "popular"; + + if (mode === "popular") { // Popular routes map view const scene = { view: "map" as const, @@ -76,7 +83,19 @@ Examples: if (!params.airports || params.airports.length === 0) { return { success: false, - message: "Routes mode requires at least one airport code", + message: + "Routes mode requires at least one airport code. Please provide airports to display on the map.", + }; + } + + // Validate airport codes are 3 letters + const invalidCodes = params.airports.filter( + (code) => code.length !== 3, + ); + if (invalidCodes.length > 0) { + return { + success: false, + message: `Invalid airport codes: ${invalidCodes.join(", ")}. All airport codes must be exactly 3 letters (e.g., SFO, JFK).`, }; } @@ -89,16 +108,128 @@ Examples: }; // Validate with Zod schema - PlannerMapSceneSchema.parse(scene); + try { + PlannerMapSceneSchema.parse(scene); + } catch (error) { + return { + success: false, + message: `Invalid map scene configuration: ${(error as Error).message}`, + }; + } return { success: true, - message: `Displaying routes for airports: ${params.airports.join(", ")}`, + message: `Displaying routes between: ${params.airports.join(" → ")}`, scene, }; } - // Search view + // Search view - validate required fields + if ( + !params.origin || + !params.destination || + !params.startDate || + !params.endDate + ) { + const missing = []; + if (!params.origin) missing.push("origin airports"); + if (!params.destination) missing.push("destination airports"); + if (!params.startDate) missing.push("start date"); + if (!params.endDate) missing.push("end date"); + + return { + success: false, + message: `Search view requires: ${missing.join(", ")}. Provide origin, destination, and date range (startDate/endDate).`, + }; + } + + // Validate date format + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + const invalidDates = []; + if (!dateRegex.test(params.startDate)) + invalidDates.push(`startDate: ${params.startDate}`); + if (!dateRegex.test(params.endDate)) + invalidDates.push(`endDate: ${params.endDate}`); + if (params.travelDate && !dateRegex.test(params.travelDate)) + invalidDates.push(`travelDate: ${params.travelDate}`); + + if (invalidDates.length > 0) { + return { + success: false, + message: `Invalid date format: ${invalidDates.join(", ")}. Use YYYY-MM-DD format (e.g., 2025-12-15).`, + }; + } + + // Validate date ranges (today to 1 year from now) + const today = startOfToday(); + const oneYearFromNow = addYears(today, 1); + + try { + const startDate = parseISO(params.startDate); + const endDate = parseISO(params.endDate); + + // Check if dates are valid + if ( + Number.isNaN(startDate.getTime()) || + Number.isNaN(endDate.getTime()) + ) { + return { + success: false, + message: + "Invalid dates provided. Please use valid dates in YYYY-MM-DD format.", + }; + } + + // Check if startDate is before today + if (isBefore(startDate, today)) { + return { + success: false, + message: `Start date (${params.startDate}) cannot be in the past. Please choose today or a future date.`, + }; + } + + // Check if endDate is more than 1 year from now + if (isAfter(endDate, oneYearFromNow)) { + return { + success: false, + message: `End date (${params.endDate}) cannot be more than 1 year from now. Please choose a date within the next year.`, + }; + } + + // Check if startDate is after endDate + if (isAfter(startDate, endDate)) { + return { + success: false, + message: `Start date (${params.startDate}) must be before or equal to end date (${params.endDate}).`, + }; + } + + // If travelDate is provided, validate it + if (params.travelDate) { + const travelDate = parseISO(params.travelDate); + + if (Number.isNaN(travelDate.getTime())) { + return { + success: false, + message: + "Invalid travel date. Please use valid date in YYYY-MM-DD format.", + }; + } + + if (isBefore(travelDate, startDate) || isAfter(travelDate, endDate)) { + return { + success: false, + message: `Travel date (${params.travelDate}) must be within the date range (${params.startDate} to ${params.endDate}).`, + }; + } + } + } catch (error) { + return { + success: false, + message: `Error validating dates: ${(error as Error).message}. Please use valid dates in YYYY-MM-DD format.`, + }; + } + const scene = { view: "search" as const, mode: "flights" as const, @@ -107,22 +238,60 @@ Examples: destination: params.destination, startDate: params.startDate, endDate: params.endDate, - travelDate: params.travelDate, + // Include optional travelDate if provided + ...(params.travelDate && { travelDate: params.travelDate }), + // Include optional filters if provided + ...(params.adults !== undefined && { adults: params.adults }), + ...(params.children !== undefined && { children: params.children }), + ...(params.maxStops !== undefined && { maxStops: params.maxStops }), + ...(params.seatType !== undefined && { seatType: params.seatType }), + ...(params.maxPrice !== undefined && { maxPrice: params.maxPrice }), + ...(params.airlines !== undefined && { airlines: params.airlines }), + ...(params.departureTimeFrom !== undefined && { + departureTimeFrom: params.departureTimeFrom, + }), + ...(params.departureTimeTo !== undefined && { + departureTimeTo: params.departureTimeTo, + }), + ...(params.arrivalTimeFrom !== undefined && { + arrivalTimeFrom: params.arrivalTimeFrom, + }), + ...(params.arrivalTimeTo !== undefined && { + arrivalTimeTo: params.arrivalTimeTo, + }), + ...(params.daysOfWeek !== undefined && { + daysOfWeek: params.daysOfWeek, + }), + ...(params.searchWindowDays !== undefined && { + searchWindowDays: params.searchWindowDays, + }), }, }; // Validate with Zod schema - PlannerSearchSceneSchema.parse(scene); + try { + PlannerSearchSceneSchema.parse(scene); + } catch (error) { + return { + success: false, + message: `Invalid search scene configuration: ${(error as Error).message}`, + }; + } + + const dateInfo = params.travelDate + ? `on ${params.travelDate}` + : `(${params.startDate} to ${params.endDate})`; return { success: true, - message: `Displaying search filters: ${params.origin.join(", ")} → ${params.destination.join(", ")}`, + message: `Displaying search: ${params.origin.join("/")} → ${params.destination.join("/")} ${dateInfo}`, scene, }; } catch (error) { + // Catch any unexpected errors return { success: false, - message: `Scene control failed: ${(error as Error).message}`, + message: `Unexpected error updating view: ${(error as Error).message}. Please try again.`, }; } }, diff --git a/src/ai/tools/search-dates.ts b/src/ai/tools/search-dates.ts index 3a0759e..29b706f 100644 --- a/src/ai/tools/search-dates.ts +++ b/src/ai/tools/search-dates.ts @@ -118,16 +118,82 @@ Important Notes: if (params.tripType === "roundtrip" && !params.tripDuration) { return { success: false, - message: "Trip duration is required for round trip searches", + message: + "Round trip searches require a trip duration (e.g., 7 days). Please specify how long you want to stay.", dates: [], }; } + // Validate dates are not in the past + const startDate = new Date(params.startDate); + const endDate = new Date(params.endDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (endDate < today) { + return { + success: false, + message: `End date ${params.endDate} is in the past. Please provide future dates.`, + dates: [], + }; + } + + if (startDate > endDate) { + return { + success: false, + message: `Start date ${params.startDate} is after end date ${params.endDate}. Please provide a valid date range.`, + dates: [], + }; + } + + // Validate date range is reasonable (max ~1 year) + const daysDiff = Math.floor( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + ); + if (daysDiff > 365) { + return { + success: false, + message: `Date range is too large (${daysDiff} days). Please search within a 1-year period.`, + dates: [], + }; + } + + // Validate airport codes exist + try { + for (const code of params.origin) { + toAirportEnum(code); + } + for (const code of params.destination) { + toAirportEnum(code); + } + } catch (error) { + return { + success: false, + message: `Invalid airport code: ${(error as Error).message}. Please use valid 3-letter IATA codes.`, + dates: [], + }; + } + + // Validate airline codes if provided + if (params.airlines) { + try { + for (const code of params.airlines) { + toAirlineEnum(code); + } + } catch (error) { + return { + success: false, + message: `Invalid airline code: ${(error as Error).message}. Please use valid 2-letter airline codes.`, + dates: [], + }; + } + } + const searchDates = new SearchDates(); const tripType = toTripTypeEnum(params.tripType ?? "oneway"); // Build flight segments - const firstTravelDate = formatDate(params.startDate); + const firstTravelDate = params.startDate; const segments = [ { departureAirport: params.origin.map((code) => [ @@ -166,8 +232,8 @@ Important Notes: flightSegments: segments, stops: toMaxStopsEnum(params.maxStops ?? "any"), seatType: toSeatTypeEnum(params.seatType ?? "economy"), - fromDate: formatDate(params.startDate), - toDate: formatDate(params.endDate), + fromDate: params.startDate, + toDate: params.endDate, ...(params.tripDuration && { duration: params.tripDuration }), ...(params.maxPrice && { priceLimit: { maxPrice: params.maxPrice, currency: Currency.USD }, @@ -178,12 +244,50 @@ Important Notes: }; // Execute search - const results = await searchDates.search(filters); + let results: Awaited>; + try { + results = await searchDates.search(filters); + } catch (error) { + const errorMsg = (error as Error).message; + + // Provide more helpful error messages + if (errorMsg.includes("HTTP 429") || errorMsg.includes("rate limit")) { + return { + success: false, + message: + "Flight search service is currently busy. Please try again in a moment.", + dates: [], + }; + } + + if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) { + return { + success: false, + message: "Date search timed out. Please try a smaller date range.", + dates: [], + }; + } + + if (errorMsg.includes("network") || errorMsg.includes("ENOTFOUND")) { + return { + success: false, + message: + "Unable to reach flight search service. Please check your connection and try again.", + dates: [], + }; + } + + return { + success: false, + message: `Unable to search dates: ${errorMsg}. Please try different search criteria.`, + dates: [], + }; + } if (!results || results.length === 0) { return { success: false, - message: "No flights found in this date range", + message: `No flights found from ${params.origin.join("/")} to ${params.destination.join("/")} between ${params.startDate} and ${params.endDate}. Try different airports or a different date range.`, dates: [], }; } @@ -208,7 +312,7 @@ Important Notes: return { success: true, - message: `Found ${dates.length} date${dates.length > 1 ? "s" : ""} with flights`, + message: `Found ${dates.length} date${dates.length > 1 ? "s" : ""} with available flights. Cheapest: $${cheapest?.price} on ${cheapest?.date}`, count: dates.length, cheapestPrice: cheapest?.price, cheapestDate: cheapest?.date, @@ -216,14 +320,15 @@ Important Notes: searchParams: { origin: params.origin, destination: params.destination, - dateRange: `${formatDate(params.startDate)} to ${formatDate(params.endDate)}`, + dateRange: `${params.startDate} to ${params.endDate}`, tripType: params.tripType ?? "oneway", }, }; } catch (error) { + // Catch any unexpected errors return { success: false, - message: `Date search failed: ${(error as Error).message}`, + message: `Unexpected error during date search: ${(error as Error).message}. Please try again.`, dates: [], }; } diff --git a/src/ai/tools/search-flights.ts b/src/ai/tools/search-flights.ts index c0d7c01..10e1cf9 100644 --- a/src/ai/tools/search-flights.ts +++ b/src/ai/tools/search-flights.ts @@ -66,13 +66,6 @@ function toSeatTypeEnum(seat: string): SeatType { } } -/** - * Format date to YYYY-MM-DD string. - */ -function formatDate(date: Date): string { - return date.toISOString().split("T")[0] ?? ""; -} - /** * Format flight result for display. */ @@ -127,6 +120,50 @@ Important Notes: execute: async (params: SearchFlightsParams) => { try { + // Validate date is not in the past + const travelDate = new Date(params.travelDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (travelDate < today) { + return { + success: false, + message: `Travel date ${params.travelDate} is in the past. Please provide a future date.`, + flights: [], + }; + } + + // Validate airport codes exist + try { + for (const code of params.origin) { + toAirportEnum(code); + } + for (const code of params.destination) { + toAirportEnum(code); + } + } catch (error) { + return { + success: false, + message: `Invalid airport code: ${(error as Error).message}. Please use valid 3-letter IATA codes.`, + flights: [], + }; + } + + // Validate airline codes if provided + if (params.airlines) { + try { + for (const code of params.airlines) { + toAirlineEnum(code); + } + } catch (error) { + return { + success: false, + message: `Invalid airline code: ${(error as Error).message}. Please use valid 2-letter airline codes.`, + flights: [], + }; + } + } + const searchFlights = new SearchFlights(); // Convert parameters to FlightSearchFilters @@ -148,7 +185,7 @@ Important Notes: toAirportEnum(code), 0, ]), - travelDate: formatDate(params.travelDate), + travelDate: params.travelDate, }, ], stops: toMaxStopsEnum(params.maxStops ?? "any"), @@ -163,12 +200,51 @@ Important Notes: }; // Execute search - const results = await searchFlights.search(filters, params.topN ?? 5); + let results: Awaited>; + try { + results = await searchFlights.search(filters, params.topN ?? 5); + } catch (error) { + const errorMsg = (error as Error).message; + + // Provide more helpful error messages + if (errorMsg.includes("HTTP 429") || errorMsg.includes("rate limit")) { + return { + success: false, + message: + "Flight search service is currently busy. Please try again in a moment.", + flights: [], + }; + } + + if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) { + return { + success: false, + message: + "Flight search timed out. Please try searching fewer airports or dates.", + flights: [], + }; + } + + if (errorMsg.includes("network") || errorMsg.includes("ENOTFOUND")) { + return { + success: false, + message: + "Unable to reach flight search service. Please check your connection and try again.", + flights: [], + }; + } + + return { + success: false, + message: `Unable to search flights: ${errorMsg}. Please try different search criteria.`, + flights: [], + }; + } if (!results || results.length === 0) { return { success: false, - message: "No flights found matching your criteria", + message: `No flights found from ${params.origin.join("/")} to ${params.destination.join("/")} on ${params.travelDate}. Try different dates or airports.`, flights: [], }; } @@ -184,19 +260,20 @@ Important Notes: return { success: true, - message: `Found ${flights.length} flight${flights.length > 1 ? "s" : ""}`, + message: `Found ${flights.length} flight${flights.length > 1 ? "s" : ""} from ${params.origin.join("/")} to ${params.destination.join("/")}`, count: flights.length, flights, searchParams: { origin: params.origin, destination: params.destination, - travelDate: formatDate(params.travelDate), + travelDate: params.travelDate, }, }; } catch (error) { + // Catch any unexpected errors return { success: false, - message: `Flight search failed: ${(error as Error).message}`, + message: `Unexpected error during flight search: ${(error as Error).message}. Please try again.`, flights: [], }; } diff --git a/src/ai/types.ts b/src/ai/types.ts index 9299136..6a71d4f 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -22,9 +22,25 @@ export const PlannerSearchSceneSchema = z.object({ data: z.object({ origin: z.array(z.string()), destination: z.array(z.string()), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - travelDate: z.coerce.date(), + startDate: z.string(), + endDate: z.string(), + travelDate: z.string().optional(), // Optional - can search by date range only + // Optional filters for interactive search + adults: z.number().int().min(1).max(9).default(1).optional(), + children: z.number().int().min(0).max(8).default(0).optional(), + maxStops: z.enum(["any", "nonstop", "1", "2"]).default("any").optional(), + seatType: z + .enum(["economy", "premium", "business", "first"]) + .default("economy") + .optional(), + maxPrice: z.number().positive().optional(), + airlines: z.array(z.string().length(2).toUpperCase()).optional(), + departureTimeFrom: z.number().min(0).max(24).optional(), + departureTimeTo: z.number().min(0).max(24).optional(), + arrivalTimeFrom: z.number().min(0).max(24).optional(), + arrivalTimeTo: z.number().min(0).max(24).optional(), + daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(), + searchWindowDays: z.number().int().positive().optional(), }), }); export type PlannerSearchScene = z.infer; @@ -41,6 +57,7 @@ export const PlannerContextSchema = z.object({ city: z.string(), state: z.string(), country: z.string(), + zipCode: z.string(), }), }); export type PlannerContext = z.infer; @@ -107,8 +124,9 @@ const BaseFlightSearchSchema = z.object({ * Used by searchFlightsTool to find available flights on a specific date. */ export const SearchFlightsParamsSchema = BaseFlightSearchSchema.extend({ - travelDate: z.coerce - .date() + travelDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) .describe("Departure date in ISO format (YYYY-MM-DD)"), topN: z .number() @@ -126,10 +144,14 @@ export type SearchFlightsParams = z.infer; * Used by searchDatesTool to find optimal travel dates within a range. */ export const SearchDatesParamsSchema = BaseFlightSearchSchema.extend({ - startDate: z.coerce - .date() + startDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) .describe("Start of date range to search (YYYY-MM-DD)"), - endDate: z.coerce.date().describe("End of date range to search (YYYY-MM-DD)"), + endDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .describe("End of date range to search (YYYY-MM-DD)"), tripType: z .enum(["oneway", "roundtrip"]) .default("oneway") @@ -151,36 +173,57 @@ export type SearchDatesParams = z.infer; * Parameters for controlling the UI scene. * Used by controlSceneTool to switch between map and search views. */ -export const ControlSceneParamsSchema = z.discriminatedUnion("view", [ - z.object({ - view: z.literal("map").describe("Show map view"), - mode: z - .enum(["popular", "routes"]) - .describe("Map mode: popular routes or specific airport routes"), - airports: z - .array(z.string().length(3).toUpperCase()) - .optional() - .describe( - "For routes mode: array of 3-letter airport codes to display connections", - ), - }), - z.object({ - view: z.literal("search").describe("Show search results view"), - origin: z - .array(z.string().length(3).toUpperCase()) - .min(1) - .describe("Origin airport codes for search filters"), - destination: z - .array(z.string().length(3).toUpperCase()) - .min(1) - .describe("Destination airport codes for search filters"), - startDate: z.coerce - .date() - .describe("Start of date range for search filters"), - endDate: z.coerce.date().describe("End of date range for search filters"), - travelDate: z.coerce - .date() - .describe("Specific travel date to highlight in results"), - }), -]); +export const ControlSceneParamsSchema = z.object({ + view: z + .enum(["map", "search"]) + .describe("Which view to display: map or search"), + mode: z + .enum(["popular", "routes"]) + .optional() + .describe( + "Map mode: popular routes or specific airport routes (only for map view)", + ), + airports: z + .array(z.string().length(3).toUpperCase()) + .optional() + .describe( + "For map routes mode: array of 3-letter airport codes to display connections", + ), + origin: z + .array(z.string().length(3).toUpperCase()) + .optional() + .describe("Origin airport codes for search view"), + destination: z + .array(z.string().length(3).toUpperCase()) + .optional() + .describe("Destination airport codes for search view"), + startDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe("Start of date range for search view (YYYY-MM-DD)"), + endDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe("End of date range for search view (YYYY-MM-DD)"), + travelDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe("Specific travel date for search view (YYYY-MM-DD)"), + // Optional filters for search view + adults: z.number().int().min(1).max(9).optional(), + children: z.number().int().min(0).max(8).optional(), + maxStops: z.enum(["any", "nonstop", "1", "2"]).optional(), + seatType: z.enum(["economy", "premium", "business", "first"]).optional(), + maxPrice: z.number().positive().optional(), + airlines: z.array(z.string().length(2).toUpperCase()).optional(), + departureTimeFrom: z.number().min(0).max(24).optional(), + departureTimeTo: z.number().min(0).max(24).optional(), + arrivalTimeFrom: z.number().min(0).max(24).optional(), + arrivalTimeTo: z.number().min(0).max(24).optional(), + daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(), + searchWindowDays: z.number().int().positive().optional(), +}); export type ControlSceneParams = z.infer; diff --git a/src/ai/utils.ts b/src/ai/utils.ts new file mode 100644 index 0000000..9ed164c --- /dev/null +++ b/src/ai/utils.ts @@ -0,0 +1,43 @@ +import { geolocation } from "@vercel/functions"; + +/** + * Extract user context from the request. + * Uses Vercel's geolocation for location data. + * @param request - The incoming request + * @param user - Optional user data from Supabase auth + */ +export function getUserContext( + request: Request, + user?: { + id: string; + email?: string; + user_metadata?: { + name?: string; + full_name?: string; + }; + }, +): { + id: string; + name: string; + email: string; + city: string; + state: string; + country: string; + zipCode: string; +} { + const location = geolocation(request); + + return { + id: user?.id ?? "anonymous", + name: + user?.user_metadata?.full_name ?? + user?.user_metadata?.name ?? + user?.email?.split("@")[0] ?? + "Guest", + email: user?.email ?? "guest@example.com", + city: location.city ?? "Unknown", + state: location.region ?? "Unknown", + country: location.country ?? "US", + zipCode: location.postalCode ?? "Unknown", + }; +} diff --git a/src/app/api/planner/route.ts b/src/app/api/planner/route.ts new file mode 100644 index 0000000..2d833ec --- /dev/null +++ b/src/app/api/planner/route.ts @@ -0,0 +1,58 @@ +import { validateUIMessages } from "ai"; +import { createPlannerAgent, type PlannerContext } from "@/ai"; +import { getUserContext } from "@/ai/utils"; +import type { ControlSceneOutput } from "@/app/planner/types"; +import { createClient } from "@/lib/supabase/server"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + // Get user from Supabase auth + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + // Extract user context from request + const userContext = getUserContext(request, user ?? undefined); + + // Extract current scene from messages (find last controlScene tool output) + let currentScene: PlannerContext["scene"] = { + view: "map", + mode: "popular", + data: null, + }; + + // Find the last controlScene tool call with output + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role === "assistant" && message?.parts) { + const scenePart = message.parts.find( + // biome-ignore lint/suspicious/noExplicitAny: Message parts have dynamic tool types + (p: any) => + p.type === "tool-controlScene" && p.state === "output-available", + ); + if (scenePart?.output?.scene) { + const output = scenePart.output as ControlSceneOutput; + if (output.scene) { + currentScene = output.scene; + break; + } + } + } + } + + // Build context with user info and current scene + const context: PlannerContext = { + user: userContext, + scene: currentScene, + }; + + // Create agent with personalized system prompt + const agent = createPlannerAgent(context); + + // Validate and respond + return agent.respond({ + messages: await validateUIMessages({ messages }), + }); +} diff --git a/src/app/planner/components/date-calendar.tsx b/src/app/planner/components/date-calendar.tsx new file mode 100644 index 0000000..ba64abe --- /dev/null +++ b/src/app/planner/components/date-calendar.tsx @@ -0,0 +1,154 @@ +import { Calendar } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import type { DatePrice } from "../types"; + +interface DateCalendarProps { + dates: DatePrice[]; + cheapestPrice?: number; + onSelectDate?: (date: DatePrice) => void; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatDateShort(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export function DateCalendar({ + dates, + cheapestPrice, + onSelectDate, +}: DateCalendarProps) { + if (dates.length === 0) { + return ( +
+

No dates found in this range.

+
+ ); + } + + // Sort dates by price + const sortedDates = [...dates].sort((a, b) => a.price - b.price); + const minPrice = sortedDates[0]?.price ?? 0; + const maxPrice = sortedDates[sortedDates.length - 1]?.price ?? 0; + + return ( +
+ {cheapestPrice && ( +
+ + Best Price: ${cheapestPrice} + + + {dates.length} {dates.length === 1 ? "date" : "dates"} available + +
+ )} + +
+ {sortedDates.slice(0, 20).map((datePrice, index) => { + const isCheapest = datePrice.price === cheapestPrice; + const priceRatio = + maxPrice > minPrice + ? (datePrice.price - minPrice) / (maxPrice - minPrice) + : 0; + + // Color coding: green (cheap) to yellow to red (expensive) + let priceColor = "bg-muted"; + if (priceRatio < 0.33) { + priceColor = + "bg-green-100 dark:bg-green-900/20 border-green-200 dark:border-green-800"; + } else if (priceRatio < 0.66) { + priceColor = + "bg-yellow-100 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"; + } else { + priceColor = + "bg-red-100 dark:bg-red-900/20 border-red-200 dark:border-red-800"; + } + + return ( + + +
+
+
+ +
+
+
+ {datePrice.departureDate && datePrice.returnDate ? ( +
+
+ {formatDateShort(datePrice.departureDate)} +
+
+ Return: {formatDateShort(datePrice.returnDate)} +
+
+ ) : ( + formatDate(datePrice.date) + )} +
+ {isCheapest && ( + + Best Price + + )} +
+
+ +
+
+
+ ${datePrice.price} +
+ {datePrice.price !== minPrice && ( +
+ +${datePrice.price - minPrice} +
+ )} +
+ + {onSelectDate && ( + + )} +
+
+
+
+ ); + })} +
+ + {dates.length > 20 && ( +
+ Showing top 20 of {dates.length} dates +
+ )} +
+ ); +} diff --git a/src/app/planner/components/date-price-chart.tsx b/src/app/planner/components/date-price-chart.tsx new file mode 100644 index 0000000..85ebcdf --- /dev/null +++ b/src/app/planner/components/date-price-chart.tsx @@ -0,0 +1,184 @@ +import { format, parseISO } from "date-fns"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { DatePrice } from "../types"; + +interface DatePriceChartProps { + dates: DatePrice[]; + cheapestPrice?: number; + onSelectDate?: (date: DatePrice) => void; +} + +const CHART_CONFIG = { + price: { + label: "Price (USD)", + color: "hsl(var(--chart-1))", + }, +} as const; + +const USD_FORMATTER = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +function formatChartDate(dateStr: string): string { + try { + // Handle round-trip dates (contains " - ") + if (dateStr.includes(" - ")) { + const [departDate] = dateStr.split(" - "); + if (departDate) { + const parsed = parseISO(departDate); + if (!Number.isNaN(parsed.getTime())) { + return format(parsed, "MMM d"); + } + } + return dateStr; + } + + // Handle single date + const parsed = parseISO(dateStr); + if (!Number.isNaN(parsed.getTime())) { + return format(parsed, "MMM d"); + } + return dateStr; + } catch { + return dateStr; + } +} + +export function DatePriceChart({ + dates, + cheapestPrice, + onSelectDate, +}: DatePriceChartProps) { + if (dates.length === 0) { + return ( +
+

No dates found in this range.

+
+ ); + } + + // Prepare chart data + const chartData = dates.map((datePrice) => ({ + date: datePrice.departureDate ?? datePrice.date, + price: datePrice.price, + formattedDate: formatChartDate(datePrice.departureDate ?? datePrice.date), + isCheapest: datePrice.price === cheapestPrice, + original: datePrice, + })); + + // Find min/max for Y-axis domain + const prices = chartData.map((d) => d.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const priceRange = maxPrice - minPrice; + const yAxisMin = Math.max(0, Math.floor(minPrice - priceRange * 0.1)); + const yAxisMax = Math.ceil(maxPrice + priceRange * 0.1); + + return ( +
+ {/* Summary */} + {cheapestPrice && ( +
+ + {dates.length} {dates.length === 1 ? "date" : "dates"} available + + + Best Price: {USD_FORMATTER.format(cheapestPrice)} + +
+ )} + + {/* Line Chart */} + + { + if (data?.activePayload?.[0]?.payload?.original && onSelectDate) { + onSelectDate(data.activePayload[0].payload.original); + } + }} + className={onSelectDate ? "cursor-pointer" : ""} + > + + + USD_FORMATTER.format(value)} + /> + { + const dataPoint = chartData.find( + (d) => d.formattedDate === value, + ); + if ( + dataPoint?.original.departureDate && + dataPoint.original.returnDate + ) { + return `${formatChartDate(dataPoint.original.departureDate)} - ${formatChartDate(dataPoint.original.returnDate)}`; + } + return value; + }} + formatter={(value) => [ + USD_FORMATTER.format(value as number), + "Price", + ]} + /> + } + /> + { + const { cx, cy, payload } = props; + const isCheapest = payload.isCheapest; + return ( + + ); + }} + /> + + + + {/* Legend */} +
+ {onSelectDate + ? "Click a point on the chart to select that date" + : "Hover over points to see details"} +
+
+ ); +} diff --git a/src/app/planner/components/flight-card.tsx b/src/app/planner/components/flight-card.tsx new file mode 100644 index 0000000..c798ea5 --- /dev/null +++ b/src/app/planner/components/flight-card.tsx @@ -0,0 +1,141 @@ +import { Clock, MapPin, Plane } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import type { FlightResult } from "../types"; + +interface FlightCardProps { + flight: FlightResult; + onSelect?: (flight: FlightResult) => void; +} + +function formatDuration(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours}h ${mins}m`; +} + +function formatTime(dateTimeStr: string): string { + const date = new Date(dateTimeStr); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +function formatDate(dateTimeStr: string): string { + const date = new Date(dateTimeStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export function FlightCard({ flight, onSelect }: FlightCardProps) { + const firstLeg = flight.legs[0]; + const lastLeg = flight.legs[flight.legs.length - 1]; + + if (!firstLeg || !lastLeg) { + return null; + } + + return ( + + +
+
+
+ {formatDate(firstLeg.departure.dateTime)} + {flight.stops === 0 ? ( + + Nonstop + + ) : ( + + {flight.stops} {flight.stops === 1 ? "stop" : "stops"} + + )} +
+
+
+
${flight.price}
+
per person
+
+
+
+ + + {/* Flight Legs */} +
+ {flight.legs.map((leg, index) => ( +
+ {index > 0 && ( +
+
+ Layover +
+
+ )} +
+
+
+ + {formatTime(leg.departure.dateTime)} + + + {leg.departure.airport} + +
+
+ +
+
{formatDuration(leg.duration)}
+
+
+ +
+
+
+ {leg.airline} {leg.flightNumber} +
+
+ +
+
+ + {formatTime(leg.arrival.dateTime)} + + + {leg.arrival.airport} + +
+
+
+
+ ))} +
+ + {/* Summary */} +
+
+ + {formatDuration(flight.duration)} +
+
+ + + {firstLeg.departure.airport} → {lastLeg.arrival.airport} + +
+
+ + {onSelect && ( + + )} + + + ); +} diff --git a/src/app/planner/components/flights-list.tsx b/src/app/planner/components/flights-list.tsx new file mode 100644 index 0000000..b63f6a7 --- /dev/null +++ b/src/app/planner/components/flights-list.tsx @@ -0,0 +1,34 @@ +import type { FlightResult } from "../types"; +import { FlightCard } from "./flight-card"; + +interface FlightsListProps { + flights: FlightResult[]; + onSelectFlight?: (flight: FlightResult) => void; +} + +export function FlightsList({ flights, onSelectFlight }: FlightsListProps) { + if (flights.length === 0) { + return ( +
+

No flights found matching your criteria.

+
+ ); + } + + return ( +
+
+ Found {flights.length} {flights.length === 1 ? "flight" : "flights"} +
+
+ {flights.map((flight, index) => ( + + ))} +
+
+ ); +} diff --git a/src/app/planner/components/map-scene.tsx b/src/app/planner/components/map-scene.tsx new file mode 100644 index 0000000..95891e3 --- /dev/null +++ b/src/app/planner/components/map-scene.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { PlannerMapScene } from "@/ai/types"; +import { Loader } from "@/components/ai-elements/loader"; +import { + AirportMap, + type AirportMapPopularRoute, +} from "@/components/airport-map"; +import { api } from "@/lib/trpc/react"; +import type { AirportData } from "@/server/services/airports"; + +interface MapSceneProps { + scene: PlannerMapScene; +} + +export function MapScene({ scene }: MapSceneProps) { + const [selectedAirports, setSelectedAirports] = useState([]); + + // Fetch all airports for display + const { data: allAirportsData, isLoading: isLoadingAirports } = api.useQuery([ + "airports.search", + { q: "", limit: 1000 }, + ]); + + const allAirports = allAirportsData?.airports ?? []; + + // Fetch specific airports when in routes mode (we'll filter from all airports) + const airportCodes = + scene.mode === "routes" ? (scene.data?.airports ?? []) : []; + + // Update selected airports when scene changes + useEffect(() => { + if (scene.mode === "routes" && airportCodes.length > 0) { + const filtered = allAirports.filter((airport: AirportData) => + airportCodes.includes(airport.iata), + ); + setSelectedAirports(filtered); + } else { + setSelectedAirports([]); + } + }, [scene.mode, airportCodes, allAirports]); + + if (isLoadingAirports) { + return ( +
+
+ +

Loading map...

+
+
+ ); + } + + if (!allAirports || allAirports.length === 0) { + return ( +
+

No airport data available

+
+ ); + } + + // Popular routes mode - show common US routes + if (scene.mode === "popular") { + // Define some popular routes + const popularRoutePairs = [ + ["LAX", "JFK"], // LA to NYC + ["SFO", "JFK"], // SF to NYC + ["LAX", "SFO"], // LA to SF + ["ORD", "LAX"], // Chicago to LA + ["ATL", "LAX"], // Atlanta to LA + ["DFW", "LAX"], // Dallas to LA + ["MIA", "JFK"], // Miami to NYC + ["SEA", "LAX"], // Seattle to LA + ]; + + const popularRoutes: AirportMapPopularRoute[] = popularRoutePairs + .map(([origin, dest]) => { + const originAirport = allAirports.find( + (a: AirportData) => a.iata === origin, + ); + const destAirport = allAirports.find( + (a: AirportData) => a.iata === dest, + ); + + if (originAirport && destAirport) { + return { + id: `${origin}-${dest}`, + origin: originAirport, + destination: destAirport, + }; + } + return null; + }) + .filter((route): route is AirportMapPopularRoute => route !== null); + + return ( +
+ +
+ ); + } + + // Routes mode - show specific airport connections + if (scene.mode === "routes") { + if (selectedAirports.length === 0) { + return ( +
+

No airports selected

+
+ ); + } + + // Create routes between consecutive airports + const routes: AirportMapPopularRoute[] = + selectedAirports.length >= 2 + ? selectedAirports.slice(0, -1).map((origin, index) => { + const dest = selectedAirports[index + 1]; + return { + id: `${origin.iata}-${dest?.iata}`, + origin, + destination: dest as AirportData, + }; + }) + : []; + + return ( +
+ +
+ ); + } + + return null; +} diff --git a/src/app/planner/components/scene-view.tsx b/src/app/planner/components/scene-view.tsx new file mode 100644 index 0000000..fc3ab79 --- /dev/null +++ b/src/app/planner/components/scene-view.tsx @@ -0,0 +1,40 @@ +import type { AirportData } from "@/server/services/airports"; +import type { PlannerScene } from "../types"; +import { MapScene } from "./map-scene"; +import { SearchScene } from "./search-scene"; + +interface SceneViewProps { + scene: PlannerScene; + airports?: AirportData[]; + totalAirports?: number; + isLoadingAirports?: boolean; +} + +export function SceneView({ + scene, + airports = [], + totalAirports = 0, + isLoadingAirports = false, +}: SceneViewProps) { + if (scene.view === "map") { + return ; + } + + if (scene.view === "search") { + return ( + + ); + } + + // Fallback + return ( +
+

Unknown scene type

+
+ ); +} diff --git a/src/app/planner/components/search-scene.tsx b/src/app/planner/components/search-scene.tsx new file mode 100644 index 0000000..ebed423 --- /dev/null +++ b/src/app/planner/components/search-scene.tsx @@ -0,0 +1,616 @@ +"use client"; + +import { + addDays, + differenceInCalendarDays, + format, + parseISO, + startOfDay, +} from "date-fns"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { PlannerSearchScene } from "@/ai/types"; +import type { FlightPricePoint } from "@/components/flight-explorer/constants"; +import { FlightFiltersPanel } from "@/components/flight-explorer/flight-filters-panel"; +import { FlightPricePanel } from "@/components/flight-explorer/flight-price-panel"; +import { RouteSearchPanel } from "@/components/flight-explorer/route-search-panel"; +import type { + FlightExplorerFiltersState, + FlightExplorerHeaderState, + FlightExplorerPriceState, + FlightExplorerSearchState, + FlightPriceChartPoint, + FlightSearchFieldState, + TimeRangeValue, +} from "@/hooks/use-flight-explorer"; +import { + DEFAULT_TIME_RANGE, + isFullDayTimeRange, +} from "@/hooks/use-flight-explorer"; +import { MaxStops, SeatType, TripType } from "@/lib/fli/models"; +import { trpc } from "@/lib/trpc/react"; +import type { AirportData } from "@/server/services/airports"; +import type { FlightOption } from "@/server/services/flights"; + +interface SearchSceneProps { + scene: PlannerSearchScene; + airports: AirportData[]; + totalAirports: number; + isLoadingAirports: boolean; +} + +type FiltersState = { + dateRange: { from: Date; to: Date }; + departureTimeRange: TimeRangeValue; + arrivalTimeRange: TimeRangeValue; + airlines: string[]; + daysOfWeek: number[]; + seatType: SeatType; + stops: MaxStops; + searchWindowDays: number; +}; + +function ensureTimeRange(range: TimeRangeValue | null): TimeRangeValue { + if (!range) { + return { ...DEFAULT_TIME_RANGE }; + } + + let from = Math.max(0, Math.min(24, range.from || 0)); + let to = Math.max(0, Math.min(24, range.to || 24)); + + if (from > to) { + [from, to] = [to, from]; + } + + return { from, to }; +} + +function formatAirportValue(airport: AirportData): string { + return `${airport.name} (${airport.iata})`; +} + +function mapMaxStops(value: string | undefined): MaxStops { + switch (value) { + case "nonstop": + return MaxStops.NON_STOP; + case "1": + return MaxStops.ONE_STOP_OR_FEWER; + case "2": + return MaxStops.TWO_OR_FEWER_STOPS; + default: + return MaxStops.ANY; + } +} + +function mapSeatType(value: string | undefined): SeatType { + switch (value) { + case "premium": + return SeatType.PREMIUM_ECONOMY; + case "business": + return SeatType.BUSINESS; + case "first": + return SeatType.FIRST; + default: + return SeatType.ECONOMY; + } +} + +export function SearchScene({ + scene, + airports, + totalAirports, + isLoadingAirports, +}: SearchSceneProps) { + const { + origin: originCodes, + destination: destinationCodes, + startDate, + endDate, + } = scene.data; + + // Find airports from codes + const originAirport = useMemo( + () => airports.find((a) => originCodes.includes(a.iata)) || null, + [airports, originCodes], + ); + + const destinationAirport = useMemo( + () => airports.find((a) => destinationCodes.includes(a.iata)) || null, + [airports, destinationCodes], + ); + + // Initialize filters from scene data + const initialFilters = useMemo(() => { + const parsedStart = parseISO(startDate); + const parsedEnd = parseISO(endDate); + const windowDays = + scene.data.searchWindowDays ?? + Math.max( + 1, + Math.min(180, differenceInCalendarDays(parsedEnd, parsedStart) + 1), + ); + + return { + dateRange: { + from: startOfDay(parsedStart), + to: startOfDay(parsedEnd), + }, + departureTimeRange: ensureTimeRange({ + from: scene.data.departureTimeFrom ?? 0, + to: scene.data.departureTimeTo ?? 24, + }), + arrivalTimeRange: ensureTimeRange({ + from: scene.data.arrivalTimeFrom ?? 0, + to: scene.data.arrivalTimeTo ?? 24, + }), + airlines: scene.data.airlines ?? [], + daysOfWeek: scene.data.daysOfWeek ?? [], + seatType: mapSeatType(scene.data.seatType), + stops: mapMaxStops(scene.data.maxStops), + searchWindowDays: windowDays, + }; + }, [scene.data, startDate, endDate]); + + // Local state + const [originQuery, setOriginQuery] = useState(""); + const [destinationQuery, setDestinationQuery] = useState(""); + const [activeField, setActiveField] = useState< + "origin" | "destination" | null + >(null); + const [filters, setFilters] = useState(initialFilters); + const [selectedDate, setSelectedDate] = useState(null); + const [selectedPriceIndex, setSelectedPriceIndex] = useState( + null, + ); + const [flightPrices, setFlightPrices] = useState([]); + const [searchError, setSearchError] = useState(null); + const [flightOptions, setFlightOptions] = useState([]); + const [isFlightOptionsLoading, setIsFlightOptionsLoading] = useState(false); + const [flightOptionsError, setFlightOptionsError] = useState( + null, + ); + + // Update queries when airports change + useEffect(() => { + if (originAirport) { + setOriginQuery(formatAirportValue(originAirport)); + } + }, [originAirport]); + + useEffect(() => { + if (destinationAirport) { + setDestinationQuery(formatAirportValue(destinationAirport)); + } + }, [destinationAirport]); + + // Update filters when scene changes + useEffect(() => { + setFilters(initialFilters); + }, [initialFilters]); + + // tRPC mutations + const flightsDatesMutation = trpc.useMutation(["flights.dates"], { + onError: (error) => { + if ( + error?.message?.includes("AbortError") || + error?.message?.includes("aborted") + ) { + return; + } + console.error("Flight dates search error:", error); + setSearchError(error?.message ?? "Failed to search flight dates"); + }, + }); + + const flightsSearchMutation = trpc.useMutation(["flights.search"], { + onError: (error) => { + if ( + error?.message?.includes("AbortError") || + error?.message?.includes("aborted") + ) { + return; + } + console.error("Flight search error:", error); + setFlightOptionsError(error?.message ?? "Failed to search flights"); + }, + }); + + const latestSearchRequestRef = useRef(0); + const latestFlightOptionsRequestRef = useRef(0); + + // Search functionality + const performSearch = useCallback(async () => { + if (!originAirport || !destinationAirport) { + return; + } + + setSearchError(null); + const requestId = latestSearchRequestRef.current + 1; + latestSearchRequestRef.current = requestId; + + try { + const normalizedDeparture = !isFullDayTimeRange( + filters.departureTimeRange, + ) + ? { + from: filters.departureTimeRange.from, + to: filters.departureTimeRange.to, + } + : undefined; + + const normalizedArrival = !isFullDayTimeRange(filters.arrivalTimeRange) + ? { + from: filters.arrivalTimeRange.from, + to: filters.arrivalTimeRange.to, + } + : undefined; + + const payload = { + tripType: TripType.ONE_WAY, + segments: [ + { + origin: originAirport.iata, + destination: destinationAirport.iata, + departureDate: format(filters.dateRange.from, "yyyy-MM-dd"), + ...(normalizedDeparture && { + departureTimeRange: normalizedDeparture, + }), + ...(normalizedArrival && { arrivalTimeRange: normalizedArrival }), + }, + ], + passengers: { + adults: scene.data.adults ?? 1, + children: scene.data.children ?? 0, + infantsInSeat: 0, + infantsOnLap: 0, + }, + dateRange: { + from: format(filters.dateRange.from, "yyyy-MM-dd"), + to: format(filters.dateRange.to, "yyyy-MM-dd"), + }, + ...(filters.seatType !== SeatType.ECONOMY && { + seatType: filters.seatType, + }), + ...(filters.stops !== MaxStops.ANY && { stops: filters.stops }), + ...(filters.airlines.length > 0 && { airlines: filters.airlines }), + ...(filters.daysOfWeek.length > 0 && { + daysOfWeek: filters.daysOfWeek, + }), + }; + + const response = await flightsDatesMutation.mutateAsync(payload); + + if (latestSearchRequestRef.current !== requestId) { + return; + } + + const sanitized = Array.isArray(response?.prices) + ? response.prices + .filter( + (item): item is { date: string; price: number } => + item !== null && + typeof item === "object" && + typeof item.date === "string" && + typeof item.price === "number", + ) + .map((item) => ({ date: item.date, price: item.price })) + : []; + + sanitized.sort((a, b) => a.date.localeCompare(b.date)); + setFlightPrices(sanitized); + } catch (error) { + if (latestSearchRequestRef.current !== requestId) { + return; + } + + setFlightPrices([]); + setSearchError( + error instanceof Error && error.message + ? error.message + : "Failed to search flights", + ); + } + }, [ + originAirport, + destinationAirport, + filters, + scene.data.adults, + scene.data.children, + flightsDatesMutation, + ]); + + // Load flight options for a specific date + const loadFlightOptions = useCallback( + async (isoDate: string) => { + if (!originAirport || !destinationAirport) { + return; + } + + const requestId = latestFlightOptionsRequestRef.current + 1; + latestFlightOptionsRequestRef.current = requestId; + setIsFlightOptionsLoading(true); + setFlightOptionsError(null); + setFlightOptions([]); + + try { + const normalizedDeparture = !isFullDayTimeRange( + filters.departureTimeRange, + ) + ? { + from: filters.departureTimeRange.from, + to: filters.departureTimeRange.to, + } + : undefined; + + const normalizedArrival = !isFullDayTimeRange(filters.arrivalTimeRange) + ? { + from: filters.arrivalTimeRange.from, + to: filters.arrivalTimeRange.to, + } + : undefined; + + const payload = { + tripType: TripType.ONE_WAY, + segments: [ + { + origin: originAirport.iata, + destination: destinationAirport.iata, + departureDate: isoDate, + ...(normalizedDeparture && { + departureTimeRange: normalizedDeparture, + }), + ...(normalizedArrival && { arrivalTimeRange: normalizedArrival }), + }, + ], + passengers: { + adults: scene.data.adults ?? 1, + children: scene.data.children ?? 0, + infantsInSeat: 0, + infantsOnLap: 0, + }, + dateRange: { from: isoDate, to: isoDate }, + ...(filters.seatType !== SeatType.ECONOMY && { + seatType: filters.seatType, + }), + ...(filters.stops !== MaxStops.ANY && { stops: filters.stops }), + ...(filters.airlines.length > 0 && { airlines: filters.airlines }), + }; + + const result = await flightsSearchMutation.mutateAsync(payload); + if (latestFlightOptionsRequestRef.current !== requestId) { + return; + } + setFlightOptions(Array.isArray(result) ? result : []); + } catch (error) { + if (latestFlightOptionsRequestRef.current !== requestId) { + return; + } + setFlightOptions([]); + setFlightOptionsError( + error instanceof Error && error.message + ? error.message + : "Failed to load flight options", + ); + } finally { + if (latestFlightOptionsRequestRef.current === requestId) { + setIsFlightOptionsLoading(false); + } + } + }, + [ + originAirport, + destinationAirport, + filters, + scene.data.adults, + scene.data.children, + flightsSearchMutation, + ], + ); + + // Chart data + const chartData = useMemo(() => { + const sorted = [...flightPrices].sort((a, b) => + a.date.localeCompare(b.date), + ); + return sorted.map((entry) => { + const parsedDate = parseISO(entry.date); + return { + ...entry, + formattedDate: format(parsedDate, "MMM d"), + }; + }); + }, [flightPrices]); + + const cheapestEntry = useMemo(() => { + if (flightPrices.length === 0) { + return null; + } + return flightPrices.reduce((lowest, current) => + current.price < lowest.price ? current : lowest, + ); + }, [flightPrices]); + + // Header state + const headerState: FlightExplorerHeaderState = { + displayMessage: `Search: ${originCodes.join("/")} → ${destinationCodes.join("/")}`, + isInitialLoading: isLoadingAirports, + isLoadingNearby: false, + totalAirports, + onShowAllAirports: () => {}, + }; + + // Search field states (read-only for now) + const originField: FlightSearchFieldState = { + kind: "origin", + value: originQuery, + selectedAirport: originAirport, + isActive: activeField === "origin", + onChange: () => {}, + onSelect: () => {}, + onActivate: () => setActiveField("origin"), + onBlur: () => setActiveField(null), + }; + + const destinationField: FlightSearchFieldState = { + kind: "destination", + value: destinationQuery, + selectedAirport: destinationAirport, + isActive: activeField === "destination", + onChange: () => {}, + onSelect: () => {}, + onActivate: () => setActiveField("destination"), + onBlur: () => setActiveField(null), + }; + + const searchState: FlightExplorerSearchState = { + airports, + origin: originField, + destination: destinationField, + showDestinationField: true, + isEditing: activeField !== null, + shouldShowSearchAction: true, + isSearchDisabled: flightsDatesMutation.isLoading, + isSearching: flightsDatesMutation.isLoading, + onSearch: performSearch, + onReset: () => { + setFlightPrices([]); + setSelectedDate(null); + setSelectedPriceIndex(null); + setFlightOptions([]); + setSearchError(null); + }, + selectRoute: () => {}, + clearRoute: () => {}, + routeChangedSinceSearch: false, + }; + + // Filters state + const filtersState: FlightExplorerFiltersState = { + dateRange: filters.dateRange, + departureTimeRange: filters.departureTimeRange, + arrivalTimeRange: filters.arrivalTimeRange, + airlines: filters.airlines, + daysOfWeek: filters.daysOfWeek, + seatType: filters.seatType, + stops: filters.stops, + searchWindowDays: filters.searchWindowDays, + hasCustomFilters: false, + hasPendingChanges: false, + onDateRangeChange: (range) => { + const windowDays = Math.max( + 1, + Math.min(180, differenceInCalendarDays(range.to, range.from) + 1), + ); + const adjustedTo = addDays(range.from, windowDays - 1); + setFilters((prev) => ({ + ...prev, + dateRange: { from: startOfDay(range.from), to: startOfDay(adjustedTo) }, + searchWindowDays: windowDays, + })); + }, + onDepartureTimeRangeChange: (range) => { + setFilters((prev) => ({ + ...prev, + departureTimeRange: ensureTimeRange(range), + })); + }, + onArrivalTimeRangeChange: (range) => { + setFilters((prev) => ({ + ...prev, + arrivalTimeRange: ensureTimeRange(range), + })); + }, + onAirlinesChange: (codes) => { + setFilters((prev) => ({ ...prev, airlines: codes })); + }, + onDaysOfWeekChange: (days) => { + setFilters((prev) => ({ ...prev, daysOfWeek: days })); + }, + onSeatTypeChange: (seatType) => { + setFilters((prev) => ({ ...prev, seatType })); + }, + onStopsChange: (stops) => { + setFilters((prev) => ({ ...prev, stops })); + }, + onSearchWindowDaysChange: (days) => { + const adjustedTo = addDays(filters.dateRange.from, days - 1); + setFilters((prev) => ({ + ...prev, + dateRange: { ...prev.dateRange, to: startOfDay(adjustedTo) }, + searchWindowDays: days, + })); + }, + onReset: () => { + setFilters(initialFilters); + }, + }; + + // Price state + const priceState: FlightExplorerPriceState = { + shouldShowPanel: true, + chartData, + cheapestEntry, + searchError, + isSearching: flightsDatesMutation.isLoading, + searchWindowDays: filters.searchWindowDays, + selectedDate, + selectedPriceIndex, + flightOptions, + isFlightOptionsLoading, + flightOptionsError, + onSelectPriceIndex: (index) => { + if (index < 0 || index >= flightPrices.length) { + setSelectedDate(null); + setSelectedPriceIndex(null); + return; + } + const entry = flightPrices[index]; + setSelectedPriceIndex(index); + setSelectedDate(entry.date); + void loadFlightOptions(entry.date); + }, + onSelectDate: (isoDate) => { + if (!isoDate) { + setSelectedDate(null); + setSelectedPriceIndex(null); + return; + } + const normalized = format(startOfDay(parseISO(isoDate)), "yyyy-MM-dd"); + setSelectedDate(normalized); + const index = flightPrices.findIndex( + (entry) => entry.date === normalized, + ); + setSelectedPriceIndex(index >= 0 ? index : null); + void loadFlightOptions(normalized); + }, + canRefetch: false, + onRefetch: performSearch, + }; + + return ( +
+ +
+
+ + +
+
+
+ ); +} diff --git a/src/app/planner/components/tool-renderer.tsx b/src/app/planner/components/tool-renderer.tsx new file mode 100644 index 0000000..eb98e19 --- /dev/null +++ b/src/app/planner/components/tool-renderer.tsx @@ -0,0 +1,222 @@ +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from "@/components/ai-elements/tool"; +import type { + ControlSceneOutput, + SearchDatesOutput, + SearchFlightsOutput, +} from "../types"; +import { DatePriceChart } from "./date-price-chart"; +import { FlightsList } from "./flights-list"; + +interface ToolRendererProps { + // biome-ignore lint/suspicious/noExplicitAny: Tool parts have dynamic types + part: any; +} + +export function ToolRenderer({ part }: ToolRendererProps) { + // searchFlights tool + if (part.type === "tool-searchFlights") { + if (part.state === "call" || part.state === "input-available") { + return ( + + + +
+ + {`Searching for flights from ${part.input?.origin?.join(", ") ?? "origin"} to ${part.input?.destination?.join(", ") ?? "destination"} on ${part.input?.travelDate ?? "selected date"}...`} + + +
+
+
+ ); + } + + if (part.state === "output-available") { + const output = part.output as SearchFlightsOutput; + + if (!output.success) { + return ( + + + +
+
+

+ {output.message} +

+
+ + +
+
+
+ ); + } + + return ( + + + +
+ + +
+
+
+ ); + } + } + + // searchDates tool + if (part.type === "tool-searchDates") { + if (part.state === "call" || part.state === "input-available") { + return ( + + + +
+ + {`Finding cheapest dates from ${part.input?.origin?.join(", ") ?? "origin"} to ${part.input?.destination?.join(", ") ?? "destination"} between ${part.input?.startDate ?? "start"} and ${part.input?.endDate ?? "end"}...`} + + +
+
+
+ ); + } + + if (part.state === "output-available") { + const output = part.output as SearchDatesOutput; + + if (!output.success) { + return ( + + + +
+
+

+ {output.message} +

+
+ + +
+
+
+ ); + } + + return ( + + + +
+ + +
+
+
+ ); + } + } + + // controlScene tool + if (part.type === "tool-controlScene") { + if (part.state === "call" || part.state === "input-available") { + return ( + + + +
+ + {`Switching to ${part.input?.view ?? "new"} view${part.input?.mode ? ` (${part.input.mode} mode)` : ""}...`} + + +
+
+
+ ); + } + + if (part.state === "output-available") { + const output = part.output as ControlSceneOutput; + + if (!output.success) { + return ( + + + +
+
+

+ {output.message} +

+
+ + +
+
+
+ ); + } + + return ( + + + +
+

{output.message}

+ +
+
+
+ ); + } + } + + // Unknown tool or state + return null; +} diff --git a/src/app/planner/layout.tsx b/src/app/planner/layout.tsx new file mode 100644 index 0000000..53b13b4 --- /dev/null +++ b/src/app/planner/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +type PlannerLayoutProps = { + children: ReactNode; +}; + +/** + * Planner layout - No footer, full height + */ +export default function PlannerLayout({ children }: PlannerLayoutProps) { + return <>{children}; +} diff --git a/src/app/planner/page.tsx b/src/app/planner/page.tsx new file mode 100644 index 0000000..7fb6e5c --- /dev/null +++ b/src/app/planner/page.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { Eye, Map as MapIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { PlannerAgentUIMessage } from "@/ai"; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { Message, MessageContent } from "@/components/ai-elements/message"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "@/components/ai-elements/prompt-input"; +import { Response } from "@/components/ai-elements/response"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +import { Header } from "@/components/header"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { api } from "@/lib/trpc/react"; +import { SceneView } from "./components/scene-view"; +import { ToolRenderer } from "./components/tool-renderer"; +import type { ControlSceneOutput, PlannerScene } from "./types"; + +export default function PlannerPage() { + const [input, setInput] = useState(""); + const [isScenePanelOpen, setIsScenePanelOpen] = useState(false); + const [scene, setScene] = useState({ + view: "map" as const, + mode: "popular" as const, + data: null, + }); + + // Load airports for search scene + const airportSearchQuery = api.useQuery( + ["airports.search", { limit: 10000 }], + { + retry: (failureCount, error) => { + if ( + error?.message?.includes("AbortError") || + error?.message?.includes("aborted") + ) { + return false; + } + return failureCount < 3; + }, + onError: (error) => { + if ( + error?.message?.includes("AbortError") || + error?.message?.includes("aborted") + ) { + return; + } + console.error("Airport search error:", error); + }, + }, + ); + + const airports = airportSearchQuery.data?.airports ?? []; + const totalAirports = airportSearchQuery.data?.total ?? airports.length; + const isLoadingAirports = airportSearchQuery.isLoading; + + const { messages, sendMessage, status, error } = + useChat({ + transport: new DefaultChatTransport({ + api: "/api/planner", + }), + onError: (error) => { + console.error("Chat error:", error); + }, + }); + + // Update scene when controlScene tool is called + useEffect(() => { + const lastMessage = messages.at(-1); + if (lastMessage?.role === "assistant") { + const sceneTool = lastMessage.parts.find( + (p) => p.type === "tool-controlScene" && p.state === "output-available", + ); + if (sceneTool) { + const output = sceneTool.output as ControlSceneOutput; + if (output?.scene) { + setScene(output.scene); + } + } + } + }, [messages]); + + const handleSubmit = (message: { text?: string }) => { + if (!message.text?.trim()) return; + + sendMessage({ + role: "user", + parts: [{ type: "text", text: message.text }], + }); + setInput(""); + }; + + const handleSuggestionClick = (suggestion: string) => { + sendMessage({ + role: "user", + parts: [{ type: "text", text: suggestion }], + }); + }; + + const sceneTitle = + scene.view === "map" + ? scene.mode === "popular" + ? "Popular Routes" + : "Airport Routes" + : "Search Filters"; + + // Check if the last assistant message has visible/renderable content + const lastMessage = messages.at(-1); + const hasVisibleContent = + lastMessage?.role === "assistant" && + lastMessage.parts.length > 0 && + lastMessage.parts.some((part) => { + // Text parts with content + if (part.type === "text" && part.text) return true; + // Tool parts in any state that ToolRenderer renders + if (part.type.startsWith("tool-") && "state" in part) { + return true; + } + return false; + }); + + return ( +
+ {/* Header */} +
+ + {/* Main Content */} +
+ {/* Chat Panel */} +
+ {/* Conversation Area with Input at Bottom */} +
+
+ + + {messages.length === 0 && ( +
+
+
✈️
+

+ Where do you want to go? +

+

+ Ask me to find flights, compare dates, or explore + popular routes. +

+
+
+ )} + + {messages.map((message) => ( + + {message.parts.map((part, i) => { + if (part.type === "text") { + return ( + + {part.text} + + ); + } + return ( + + ); + })} + + ))} + + {(status === "submitted" || status === "streaming") && + !hasVisibleContent && ( + + + + Finding flights... + + + + )} + + {error && ( + + +
+

+ Sorry, something went wrong processing your request. +

+
+ {error.message} +
+
+
+
+ )} +
+ +
+
+ + {/* Input Area - Sticky at Bottom */} +
+ {messages.length === 0 && ( +
+ + + + + + + + + + +
+ )} + + + setInput(e.target.value)} + placeholder="Ask about flights, dates, or destinations..." + className="min-h-[60px]" + /> + + + + + + + + +
+
+
+ + {/* Scene Panel - Desktop */} +
+
+ +
+
+
+ + {/* Scene Panel - Mobile Sheet */} + + +
+ + {sceneTitle} + +
+ +
+
+
+
+
+ ); +} diff --git a/src/app/planner/types.ts b/src/app/planner/types.ts new file mode 100644 index 0000000..4512fe5 --- /dev/null +++ b/src/app/planner/types.ts @@ -0,0 +1,86 @@ +import type { PlannerMapScene, PlannerSearchScene } from "@/ai/types"; + +/** + * Scene union type for the planner UI. + * Matches the scene types from the agent. + */ +export type PlannerScene = PlannerMapScene | PlannerSearchScene; + +/** + * Flight leg information from search results. + */ +export type FlightLeg = { + airline: string; + flightNumber: string; + departure: { + airport: string; + dateTime: string; + }; + arrival: { + airport: string; + dateTime: string; + }; + duration: number; +}; + +/** + * Flight result from search tool. + */ +export type FlightResult = { + price: number; + duration: number; + stops: number; + legs: FlightLeg[]; +}; + +/** + * Date with price from date search tool. + */ +export type DatePrice = { + date: string; + price: number; + departureDate?: string; + returnDate?: string; +}; + +/** + * Search flights tool output. + */ +export type SearchFlightsOutput = { + success: boolean; + message: string; + count?: number; + flights: FlightResult[]; + searchParams?: { + origin: string[]; + destination: string[]; + travelDate: string; + }; +}; + +/** + * Search dates tool output. + */ +export type SearchDatesOutput = { + success: boolean; + message: string; + count?: number; + cheapestPrice?: number; + cheapestDate?: string; + dates: DatePrice[]; + searchParams?: { + origin: string[]; + destination: string[]; + dateRange: string; + tripType: string; + }; +}; + +/** + * Control scene tool output. + */ +export type ControlSceneOutput = { + success: boolean; + message: string; + scene?: PlannerScene; +}; diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx index e4c0ed8..0654b7d 100644 --- a/src/components/ai-elements/message.tsx +++ b/src/components/ai-elements/message.tsx @@ -15,8 +15,8 @@ export type MessageProps = HTMLAttributes & { export const Message = ({ className, from, ...props }: MessageProps) => (
{title ?? type.split("-").slice(1).join("-")} +
+
{getStatusBadge(state)} +
- ); @@ -101,7 +103,7 @@ export type ToolInputProps = ComponentProps<"div"> & { }; export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( -
+

Parameters

@@ -137,7 +139,7 @@ export const ToolOutput = ({ } return ( -
+

{errorText ? "Error" : "Result"}

diff --git a/src/components/header.tsx b/src/components/header.tsx index 969dfde..08daa19 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -4,6 +4,7 @@ import { LogIn, UserRound } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { SignOutButton } from "@/components/sign-out-button"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Popover, @@ -18,6 +19,7 @@ import { import { createClient } from "@/lib/supabase/client"; const NAV_ITEMS = [ + { label: "Search", href: "/search" }, { label: "Planner", href: "/planner" }, { label: "Alerts", href: "/alerts" }, ]; @@ -45,9 +47,13 @@ export function Header() { }, []); const renderDesktopNav = () => ( -