From 36a7829c7aef47ef87004f462a2a0a193322da9a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 14:01:25 -0700 Subject: [PATCH] feat: AI-enabled trip planning with real search integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace simple NL→JSON parse with Gemini function-calling agent that geocodes destinations (Nominatim), searches flights (Kiwi Tequila), finds Airbnb listings, and computes routes (ORS/OSRM) during planning. - New ai-tools.ts with 4 tool functions (geocode, flights, accommodation, routes) - New ai-planner.ts with Gemini 2.0 Flash agentic loop (max 8 iterations) - New /api/trips/plan endpoint (60s timeout for external API calls) - Enhanced ParsedTripPreview with flight/accommodation carousels, route summary, mini map - Trip creation now persists lat/lng, route segments, and selected bookings - NLInput shows rotating status messages during planning - Kiwi flight search gracefully degrades when API key not configured Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 + src/app/api/trips/[id]/canvas/route.ts | 1 + src/app/api/trips/plan/route.ts | 30 +++ src/app/api/trips/route.ts | 51 +++- src/app/trips/new/page.tsx | 8 +- src/components/NLInput.tsx | 44 +++- src/components/ParsedTripPreview.tsx | 313 +++++++++++++++++++++++- src/components/PreviewMap.tsx | 103 ++++++++ src/lib/ai-planner.ts | 326 +++++++++++++++++++++++++ src/lib/ai-tools.ts | 295 ++++++++++++++++++++++ src/lib/types.ts | 42 ++++ 11 files changed, 1181 insertions(+), 35 deletions(-) create mode 100644 src/app/api/trips/plan/route.ts create mode 100644 src/components/PreviewMap.tsx create mode 100644 src/lib/ai-planner.ts create mode 100644 src/lib/ai-tools.ts diff --git a/.env.example b/.env.example index 62c35a0..63ca28e 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ RSPACE_INTERNAL_URL="http://rspace-online:3000" # EncryptID NEXT_PUBLIC_ENCRYPTID_SERVER_URL="https://auth.ridentity.online" +# Kiwi Tequila (flight search — free tier: 100 searches/month) +KIWI_API_KEY="your-kiwi-tequila-api-key" + # OpenRouteService (routing, optimization, isochrones) ORS_BASE_URL="https://routing.jeffemmett.com" ORS_PUBLIC_URL="https://api.openrouteservice.org" diff --git a/src/app/api/trips/[id]/canvas/route.ts b/src/app/api/trips/[id]/canvas/route.ts index 7f88ee4..6d04820 100644 --- a/src/app/api/trips/[id]/canvas/route.ts +++ b/src/app/api/trips/[id]/canvas/route.ts @@ -27,6 +27,7 @@ export async function POST( itineraryItems: { orderBy: [{ date: 'asc' }, { sortOrder: 'asc' }] }, bookings: { orderBy: { startDate: 'asc' } }, packingItems: { orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }] }, + routeSegments: { include: { fromDest: true, toDest: true } }, }, }); diff --git a/src/app/api/trips/plan/route.ts b/src/app/api/trips/plan/route.ts new file mode 100644 index 0000000..c9de22b --- /dev/null +++ b/src/app/api/trips/plan/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { planTrip } from '@/lib/ai-planner'; +import { requireAuth, isAuthed } from '@/lib/auth'; + +export const maxDuration = 60; // Allow up to 60s for multiple external API calls + +export async function POST(request: NextRequest) { + try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const { text } = await request.json(); + + if (!text || typeof text !== 'string' || text.trim().length === 0) { + return NextResponse.json( + { error: 'Please provide a trip description' }, + { status: 400 } + ); + } + + const enriched = await planTrip(text.trim()); + return NextResponse.json(enriched); + } catch (error) { + console.error('Plan error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to plan trip' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/trips/route.ts b/src/app/api/trips/route.ts index 5ba065f..272ccde 100644 --- a/src/app/api/trips/route.ts +++ b/src/app/api/trips/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { generateSlug } from '@/lib/slug'; -import { ParsedTrip } from '@/lib/types'; +import { EnrichedTrip } from '@/lib/types'; import { getAuthUser, requireAuth, isAuthed } from '@/lib/auth'; export async function GET(request: NextRequest) { @@ -64,7 +64,7 @@ export async function POST(request: NextRequest) { if (!isAuthed(auth)) return auth; const body = await request.json(); - const { parsed, rawInput }: { parsed: ParsedTrip; rawInput: string } = body; + const { parsed, rawInput }: { parsed: EnrichedTrip; rawInput: string } = body; if (!parsed?.title) { return NextResponse.json( @@ -105,18 +105,27 @@ export async function POST(request: NextRequest) { }, }); - // Create destinations + // Create destinations (with lat/lng from enriched data) + let destinationIds: string[] = []; if (parsed.destinations.length > 0) { - await tx.destination.createMany({ - data: parsed.destinations.map((d, i) => ({ - tripId: newTrip.id, - name: d.name, - country: d.country, - arrivalDate: d.arrivalDate ? new Date(d.arrivalDate) : null, - departureDate: d.departureDate ? new Date(d.departureDate) : null, - sortOrder: i, - })), - }); + // Use individual creates to get IDs back (needed for route segments) + destinationIds = await Promise.all( + parsed.destinations.map(async (d, i) => { + const dest = await tx.destination.create({ + data: { + tripId: newTrip.id, + name: d.name, + country: d.country, + lat: 'lat' in d && typeof d.lat === 'number' ? d.lat : null, + lng: 'lng' in d && typeof d.lng === 'number' ? d.lng : null, + arrivalDate: d.arrivalDate ? new Date(d.arrivalDate) : null, + departureDate: d.departureDate ? new Date(d.departureDate) : null, + sortOrder: i, + }, + }); + return dest.id; + }) + ); } // Create itinerary items @@ -147,6 +156,21 @@ export async function POST(request: NextRequest) { }); } + // Create route segments from enriched data + if (parsed.routes && parsed.routes.length > 0 && destinationIds.length >= 2) { + await tx.routeSegment.createMany({ + data: parsed.routes + .filter((r) => destinationIds[r.fromIndex] && destinationIds[r.toIndex]) + .map((r) => ({ + tripId: newTrip.id, + fromDestId: destinationIds[r.fromIndex], + toDestId: destinationIds[r.toIndex], + distanceMeters: r.distanceMeters, + durationSeconds: r.durationSeconds, + })), + }); + } + return newTrip; }); @@ -157,6 +181,7 @@ export async function POST(request: NextRequest) { destinations: { orderBy: { sortOrder: 'asc' } }, itineraryItems: { orderBy: { sortOrder: 'asc' } }, bookings: true, + routeSegments: true, collaborators: { include: { user: true } }, }, }); diff --git a/src/app/trips/new/page.tsx b/src/app/trips/new/page.tsx index c6ad165..83a24e0 100644 --- a/src/app/trips/new/page.tsx +++ b/src/app/trips/new/page.tsx @@ -6,22 +6,22 @@ import Link from 'next/link'; import { AppSwitcher } from '@/components/AppSwitcher'; import { NLInput } from '@/components/NLInput'; import { ParsedTripPreview } from '@/components/ParsedTripPreview'; -import { ParsedTrip } from '@/lib/types'; +import { EnrichedTrip } from '@/lib/types'; import { apiUrl } from '@/lib/api'; export default function NewTrip() { const router = useRouter(); - const [parsed, setParsed] = useState(null); + const [parsed, setParsed] = useState(null); const [rawInput, setRawInput] = useState(''); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); - const handleParsed = (data: ParsedTrip, raw: string) => { + const handleParsed = (data: EnrichedTrip, raw: string) => { setParsed(data); setRawInput(raw); }; - const handleConfirm = async (finalParsed: ParsedTrip) => { + const handleConfirm = async (finalParsed: EnrichedTrip) => { setCreating(true); setError(null); diff --git a/src/components/NLInput.tsx b/src/components/NLInput.tsx index 1a4bd5e..0ca6d17 100644 --- a/src/components/NLInput.tsx +++ b/src/components/NLInput.tsx @@ -1,25 +1,41 @@ 'use client'; import { useState } from 'react'; -import { ParsedTrip } from '@/lib/types'; +import { EnrichedTrip } from '@/lib/types'; import { apiUrl } from '@/lib/api'; interface NLInputProps { - onParsed: (parsed: ParsedTrip, rawInput: string) => void; + onParsed: (parsed: EnrichedTrip, rawInput: string) => void; } +const STATUS_MESSAGES = [ + 'Analyzing your trip description...', + 'Geocoding destinations...', + 'Searching for flights...', + 'Finding accommodations...', + 'Computing routes...', + 'Assembling your trip plan...', +]; + export function NLInput({ onParsed }: NLInputProps) { const [text, setText] = useState(''); const [loading, setLoading] = useState(false); + const [statusIdx, setStatusIdx] = useState(0); const [error, setError] = useState(null); - const handleParse = async () => { + const handlePlan = async () => { if (!text.trim()) return; setLoading(true); setError(null); + setStatusIdx(0); + + // Cycle through status messages while waiting + const interval = setInterval(() => { + setStatusIdx((prev) => Math.min(prev + 1, STATUS_MESSAGES.length - 1)); + }, 4000); try { - const res = await fetch(apiUrl('/api/trips/parse'), { + const res = await fetch(apiUrl('/api/trips/plan'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.trim() }), @@ -27,14 +43,15 @@ export function NLInput({ onParsed }: NLInputProps) { if (!res.ok) { const data = await res.json(); - throw new Error(data.error || 'Failed to parse'); + throw new Error(data.error || 'Failed to plan trip'); } - const parsed: ParsedTrip = await res.json(); - onParsed(parsed, text.trim()); + const enriched: EnrichedTrip = await res.json(); + onParsed(enriched, text.trim()); } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong'); } finally { + clearInterval(interval); setLoading(false); } }; @@ -44,14 +61,14 @@ export function NLInput({ onParsed }: NLInputProps) {