feat: AI-enabled trip planning with real search integration

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 14:01:25 -07:00
parent 62945fc3a8
commit 36a7829c7a
11 changed files with 1181 additions and 35 deletions

View File

@ -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"

View File

@ -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 } },
},
});

View File

@ -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 }
);
}
}

View File

@ -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 } },
},
});

View File

@ -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<ParsedTrip | null>(null);
const [parsed, setParsed] = useState<EnrichedTrip | null>(null);
const [rawInput, setRawInput] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(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);

View File

@ -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<string | null>(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) {
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Describe your trip... (e.g., 'Fly from Toronto to Bali for 2 weeks in March 2026, budget $3000. Want to visit Ubud temples and Seminyak beaches. Need a hotel in each area.')"
placeholder="Describe your trip... (e.g., 'Fly from Toronto to Lisbon for 2 weeks in June 2026, budget $4000 for 2 people. Want to visit Sintra and Porto too. Need hotels in each city.')"
className="w-full h-40 bg-slate-800/50 border border-slate-700/50 rounded-xl p-4 text-white placeholder-slate-500 resize-none focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50"
/>
{error && (
<p className="text-red-400 text-sm">{error}</p>
)}
<button
onClick={handleParse}
onClick={handlePlan}
disabled={loading || !text.trim()}
className="w-full py-3 bg-teal-600 hover:bg-teal-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-xl font-medium transition-all flex items-center justify-center gap-2"
>
@ -61,12 +78,17 @@ export function NLInput({ onParsed }: NLInputProps) {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Parsing your trip...
{STATUS_MESSAGES[statusIdx]}
</>
) : (
'Parse Trip'
'Plan Trip with AI'
)}
</button>
{loading && (
<p className="text-xs text-slate-500 text-center">
This may take 15-30 seconds while we search for real flights, hotels, and routes.
</p>
)}
</div>
);
}

View File

@ -1,18 +1,84 @@
'use client';
import { useState } from 'react';
import { ParsedTrip } from '@/lib/types';
import { useState, useMemo } from 'react';
import dynamic from 'next/dynamic';
import type { EnrichedTrip, FlightOption, AccommodationOption } from '@/lib/types';
// Dynamic import for map (SSR-safe)
const PreviewMap = dynamic(() => import('./PreviewMap').then((m) => m.PreviewMap), {
ssr: false,
loading: () => (
<div className="h-64 bg-slate-800/50 rounded-xl border border-slate-700/50 animate-pulse flex items-center justify-center text-slate-500 text-sm">
Loading map...
</div>
),
});
interface ParsedTripPreviewProps {
parsed: ParsedTrip;
parsed: EnrichedTrip;
rawInput: string;
onConfirm: (parsed: ParsedTrip) => void;
onConfirm: (parsed: EnrichedTrip) => void;
onBack: () => void;
loading?: boolean;
}
export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading }: ParsedTripPreviewProps) {
const [edited, setEdited] = useState<ParsedTrip>(parsed);
const [edited, setEdited] = useState<EnrichedTrip>(parsed);
const [selectedFlights, setSelectedFlights] = useState<(number | null)[]>(
() => (parsed.flightOptions || []).map(() => 0)
);
const [selectedAccom, setSelectedAccom] = useState<(number | null)[]>(
() => (parsed.accommodationOptions || []).map(() => 0)
);
const hasEnrichedData = !!(
parsed.flightOptions?.some((leg) => leg.length > 0) ||
parsed.accommodationOptions?.some((dest) => dest.length > 0) ||
parsed.routes?.length
);
const hasGeoData = parsed.destinations?.some((d) => d.lat != null && d.lng != null);
// Compute total route stats
const routeStats = useMemo(() => {
if (!parsed.routes?.length) return null;
const totalDist = parsed.routes.reduce((sum, r) => sum + r.distanceMeters, 0);
const totalDur = parsed.routes.reduce((sum, r) => sum + r.durationSeconds, 0);
return { totalDist, totalDur };
}, [parsed.routes]);
// Build bookings from selected options for the confirm payload
const handleConfirm = () => {
const enrichedBookings = [...edited.bookings];
// Add selected flights as bookings
selectedFlights.forEach((idx, legIdx) => {
if (idx != null && parsed.flightOptions?.[legIdx]?.[idx]) {
const f = parsed.flightOptions[legIdx][idx];
enrichedBookings.push({
type: 'FLIGHT',
provider: f.airline,
details: `${f.departure}${f.arrival} (${formatDuration(f.durationMinutes * 60)})`,
cost: f.price,
});
}
});
// Add selected accommodations as bookings
selectedAccom.forEach((idx, destIdx) => {
if (idx != null && parsed.accommodationOptions?.[destIdx]?.[idx]) {
const a = parsed.accommodationOptions[destIdx][idx];
enrichedBookings.push({
type: 'HOTEL',
provider: 'Airbnb',
details: a.name,
cost: a.price,
});
}
});
onConfirm({ ...edited, bookings: enrichedBookings });
};
return (
<div className="space-y-6">
@ -64,6 +130,14 @@ export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading
</div>
</div>
{/* Mini Map */}
{hasGeoData && (
<div>
<h3 className="text-sm font-medium text-slate-300 mb-2">Trip Overview</h3>
<PreviewMap destinations={parsed.destinations} routes={parsed.routes || []} />
</div>
)}
{/* Destinations */}
{edited.destinations.length > 0 && (
<div>
@ -76,7 +150,12 @@ export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading
</div>
<div className="flex-1">
<p className="text-white font-medium">{dest.name}</p>
{dest.country && <p className="text-xs text-slate-400">{dest.country}</p>}
<div className="flex gap-2 items-center">
{dest.country && <span className="text-xs text-slate-400">{dest.country}</span>}
{dest.lat != null && dest.lng != null && (
<span className="text-xs text-slate-600">{dest.lat.toFixed(2)}, {dest.lng.toFixed(2)}</span>
)}
</div>
</div>
{(dest.arrivalDate || dest.departureDate) && (
<p className="text-xs text-slate-500">
@ -89,6 +168,92 @@ export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading
</div>
)}
{/* Route Summary */}
{parsed.routes && parsed.routes.length > 0 && (
<div>
<h3 className="text-sm font-medium text-slate-300 mb-2">Routes</h3>
<div className="space-y-1">
{parsed.routes.map((route, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="text-slate-400">
{parsed.destinations[route.fromIndex]?.name} {parsed.destinations[route.toIndex]?.name}
</span>
<span className="text-teal-400 font-medium">
{formatDuration(route.durationSeconds)} · {formatDistance(route.distanceMeters)}
</span>
</div>
))}
{routeStats && parsed.routes.length > 1 && (
<div className="text-xs text-slate-500 mt-1 pt-1 border-t border-slate-700/50">
Total: {formatDuration(routeStats.totalDur)} · {formatDistance(routeStats.totalDist)}
</div>
)}
</div>
</div>
)}
{/* Flight Options */}
{parsed.flightOptions && parsed.flightOptions.some((leg) => leg.length > 0) && (
<div>
<h3 className="text-sm font-medium text-slate-300 mb-2">Flight Options</h3>
{parsed.flightOptions.map((leg, legIdx) => {
if (leg.length === 0) return null;
return (
<div key={legIdx} className="mb-3">
<p className="text-xs text-slate-500 mb-1.5">
Leg {legIdx + 1}
</p>
<div className="space-y-1.5">
{leg.map((flight, fIdx) => (
<FlightCard
key={fIdx}
flight={flight}
selected={selectedFlights[legIdx] === fIdx}
onSelect={() => {
const next = [...selectedFlights];
next[legIdx] = next[legIdx] === fIdx ? null : fIdx;
setSelectedFlights(next);
}}
/>
))}
</div>
</div>
);
})}
</div>
)}
{/* Accommodation Options */}
{parsed.accommodationOptions && parsed.accommodationOptions.some((dest) => dest.length > 0) && (
<div>
<h3 className="text-sm font-medium text-slate-300 mb-2">Accommodation Options</h3>
{parsed.accommodationOptions.map((dest, destIdx) => {
if (dest.length === 0) return null;
return (
<div key={destIdx} className="mb-3">
<p className="text-xs text-slate-500 mb-1.5">
{parsed.destinations[destIdx]?.name || `Destination ${destIdx + 1}`}
</p>
<div className="space-y-1.5">
{dest.map((listing, lIdx) => (
<AccommodationCard
key={lIdx}
listing={listing}
selected={selectedAccom[destIdx] === lIdx}
onSelect={() => {
const next = [...selectedAccom];
next[destIdx] = next[destIdx] === lIdx ? null : lIdx;
setSelectedAccom(next);
}}
/>
))}
</div>
</div>
);
})}
</div>
)}
{/* Itinerary Items */}
{edited.itineraryItems.length > 0 && (
<div>
@ -121,6 +286,15 @@ export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading
</div>
)}
{/* Enriched data indicator */}
{hasEnrichedData && (
<div className="bg-teal-900/20 border border-teal-700/30 rounded-lg p-3">
<p className="text-xs text-teal-400">
AI-enriched with real search data. Select your preferred flights and accommodations above they&apos;ll be saved as bookings when you create the trip.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
@ -130,7 +304,7 @@ export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading
Edit Description
</button>
<button
onClick={() => onConfirm(edited)}
onClick={handleConfirm}
disabled={loading}
className="flex-1 py-3 bg-teal-600 hover:bg-teal-500 disabled:bg-slate-700 rounded-xl font-medium transition-all flex items-center justify-center gap-2"
>
@ -150,3 +324,128 @@ export function ParsedTripPreview({ parsed, rawInput, onConfirm, onBack, loading
</div>
);
}
// ─── Sub-components ─────────────────────────────────────────────
function FlightCard({ flight, selected, onSelect }: {
flight: FlightOption;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
onClick={onSelect}
className={`rounded-lg p-3 border cursor-pointer transition-all ${
selected
? 'bg-teal-900/30 border-teal-500/50'
: 'bg-slate-800/50 border-slate-700/50 hover:border-slate-600/50'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
selected ? 'border-teal-400' : 'border-slate-600'
}`}>
{selected && <div className="w-2 h-2 rounded-full bg-teal-400" />}
</div>
<div>
<p className="text-white text-sm font-medium">{flight.airline}</p>
<p className="text-xs text-slate-400">
{formatTime(flight.departure)} {formatTime(flight.arrival)} · {formatDuration(flight.durationMinutes * 60)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-teal-400 font-bold">${flight.price}</p>
{flight.deepLink && (
<a
href={flight.deepLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-cyan-400 hover:underline"
>
Book
</a>
)}
</div>
</div>
</div>
);
}
function AccommodationCard({ listing, selected, onSelect }: {
listing: AccommodationOption;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
onClick={onSelect}
className={`rounded-lg p-3 border cursor-pointer transition-all ${
selected
? 'bg-teal-900/30 border-teal-500/50'
: 'bg-slate-800/50 border-slate-700/50 hover:border-slate-600/50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
selected ? 'border-teal-400' : 'border-slate-600'
}`}>
{selected && <div className="w-2 h-2 rounded-full bg-teal-400" />}
</div>
{listing.thumbnail && (
<img
src={listing.thumbnail}
alt={listing.name}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{listing.name}</p>
<div className="flex items-center gap-2 text-xs text-slate-400">
{listing.roomType && <span>{listing.roomType}</span>}
{listing.rating && <span> {listing.rating.toFixed(1)}</span>}
</div>
</div>
<div className="text-right flex-shrink-0">
{listing.price != null && (
<p className="text-teal-400 font-bold">${listing.price}<span className="text-xs font-normal text-slate-500">/night</span></p>
)}
<a
href={listing.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-cyan-400 hover:underline"
>
View
</a>
</div>
</div>
</div>
);
}
// ─── Formatting helpers ─────────────────────────────────────────
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatDistance(meters: number): string {
if (meters >= 1000) return `${(meters / 1000).toFixed(0)} km`;
return `${meters} m`;
}
function formatTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
} catch {
return iso;
}
}

View File

@ -0,0 +1,103 @@
'use client';
import { useRef, useCallback, useMemo } from 'react';
import Map, { Marker, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { EnrichedDestination, RouteInfo } from '@/lib/types';
interface PreviewMapProps {
destinations: EnrichedDestination[];
routes: RouteInfo[];
}
export function PreviewMap({ destinations, routes }: PreviewMapProps) {
const mapRef = useRef(null);
const destsWithCoords = useMemo(
() => destinations.filter((d) => d.lat != null && d.lng != null),
[destinations]
);
const bounds = useMemo(() => {
if (destsWithCoords.length === 0) return null;
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
for (const d of destsWithCoords) {
minLng = Math.min(minLng, d.lng!);
maxLng = Math.max(maxLng, d.lng!);
minLat = Math.min(minLat, d.lat!);
maxLat = Math.max(maxLat, d.lat!);
}
const pad = 1;
return [
[minLng - pad, minLat - pad],
[maxLng + pad, maxLat + pad],
] as [[number, number], [number, number]];
}, [destsWithCoords]);
// Build straight-line connections between consecutive destinations
const lineGeoJSON = useMemo(() => {
const coords = destsWithCoords.map((d) => [d.lng!, d.lat!]);
if (coords.length < 2) return null;
return {
type: 'FeatureCollection' as const,
features: [{
type: 'Feature' as const,
properties: {},
geometry: {
type: 'LineString' as const,
coordinates: coords,
},
}],
};
}, [destsWithCoords]);
const onMapLoad = useCallback(() => {
if (bounds && mapRef.current) {
(mapRef.current as { fitBounds: (b: [[number, number], [number, number]], o: object) => void })
.fitBounds(bounds, { padding: 40, duration: 0 });
}
}, [bounds]);
if (destsWithCoords.length === 0) return null;
return (
<div className="rounded-xl overflow-hidden border border-slate-700/50" style={{ height: 256 }}>
<Map
ref={mapRef}
onLoad={onMapLoad}
initialViewState={{
longitude: destsWithCoords[0]?.lng || 0,
latitude: destsWithCoords[0]?.lat || 0,
zoom: 4,
}}
style={{ width: '100%', height: '100%' }}
mapStyle="https://tiles.openfreemap.org/styles/liberty"
interactive={false}
>
<NavigationControl position="top-right" showCompass={false} />
{lineGeoJSON && (
<Source id="preview-route" type="geojson" data={lineGeoJSON}>
<Layer
id="preview-route-line"
type="line"
paint={{
'line-color': '#2dd4bf',
'line-width': 2,
'line-dasharray': [4, 3],
'line-opacity': 0.6,
}}
/>
</Source>
)}
{destsWithCoords.map((d, i) => (
<Marker key={i} longitude={d.lng!} latitude={d.lat!} anchor="center">
<div className="w-6 h-6 bg-teal-500 rounded-full flex items-center justify-center text-white text-xs font-bold shadow-lg border border-white">
{i + 1}
</div>
</Marker>
))}
</Map>
</div>
);
}

326
src/lib/ai-planner.ts Normal file
View File

@ -0,0 +1,326 @@
// AI Trip Planner — Gemini 2.0 Flash function-calling agent loop
//
// Sends user's trip description + tool declarations to Gemini.
// Gemini calls tools (geocode, flights, accommodation, routing) in a loop.
// Returns an EnrichedTrip with real prices, coordinates, and routes.
import {
geocodeLocation,
searchFlights,
searchAccommodation,
computeRoute,
} from './ai-tools';
import type { EnrichedTrip } from './types';
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
const MAX_ITERATIONS = 8;
// ─── System prompt ──────────────────────────────────────────────
const SYSTEM_PROMPT = `You are a trip planning assistant with access to real search tools. Given a natural language trip description:
1. Parse the basic trip info: title, destinations, dates, budget, travelers
2. Call geocodeLocation for each destination to get real coordinates
3. Call searchFlights for travel between origin/destinations when flying makes sense
4. Call searchAccommodation for each destination using the dates
5. Call computeRoute between consecutive destinations that are driveable (same continent, < 1000km apart)
6. Return the final structured trip as JSON
CRITICAL RULES:
- ALWAYS call tools to get real data. Never invent prices, coordinates, or listings.
- Call geocodeLocation for EVERY destination.
- For flights: search between the origin city and first destination, and between destinations if the user implies flying.
- For accommodation: search at each destination where the traveler stays overnight.
- For routes: only compute driving routes between geographically close destinations (same region/country).
- If a tool returns empty results, include an empty array don't make up data.
When you have gathered all data, return ONLY a JSON object matching this schema:
{
"title": "string",
"destinations": [{ "name": "string", "country": "string|null", "arrivalDate": "ISO|null", "departureDate": "ISO|null", "lat": number|null, "lng": number|null }],
"startDate": "ISO|null",
"endDate": "ISO|null",
"budgetTotal": number|null,
"budgetCurrency": "USD",
"travelers": number|null,
"itineraryItems": [{ "title": "string", "category": "FLIGHT|TRANSPORT|ACCOMMODATION|ACTIVITY|MEAL|OTHER", "date": "ISO|null", "description": "string|null" }],
"bookings": [{ "type": "FLIGHT|HOTEL|CAR_RENTAL|TRAIN|BUS|FERRY|ACTIVITY|RESTAURANT|OTHER", "provider": "string|null", "details": "string|null", "cost": number|null }],
"flightOptions": [[{ "price": number, "currency": "string", "airline": "string", "departure": "ISO", "arrival": "ISO", "durationMinutes": number, "deepLink": "string|null" }]],
"accommodationOptions": [[{ "id": "string", "name": "string", "price": number|null, "currency": "string", "rating": number|null, "roomType": "string|null", "thumbnail": "string|null", "url": "string" }]],
"routes": [{ "fromIndex": number, "toIndex": number, "distanceMeters": number, "durationSeconds": number }]
}`;
// ─── Tool declarations (Gemini function calling format) ─────────
const TOOL_DECLARATIONS = {
tools: [{
function_declarations: [
{
name: 'geocodeLocation',
description: 'Geocode a location name to lat/lng coordinates using Nominatim',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Location name (city, region, or address)' },
},
required: ['name'],
},
},
{
name: 'searchFlights',
description: 'Search for real flights between two cities using Kiwi Tequila API. Returns top 5 cheapest flights.',
parameters: {
type: 'object',
properties: {
from: { type: 'string', description: 'Departure city or airport code' },
to: { type: 'string', description: 'Arrival city or airport code' },
dateFrom: { type: 'string', description: 'Departure date (ISO format YYYY-MM-DD)' },
dateTo: { type: 'string', description: 'Latest departure date (ISO format YYYY-MM-DD), can be same as dateFrom for one-way' },
adults: { type: 'number', description: 'Number of adult passengers (default 1)' },
},
required: ['from', 'to', 'dateFrom', 'dateTo'],
},
},
{
name: 'searchAccommodation',
description: 'Search for real Airbnb listings at a location. Returns top 5 listings with prices and ratings.',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'Location to search (city name, optionally with country)' },
checkin: { type: 'string', description: 'Check-in date (YYYY-MM-DD)' },
checkout: { type: 'string', description: 'Check-out date (YYYY-MM-DD)' },
guests: { type: 'number', description: 'Number of guests (default 2)' },
maxPrice: { type: 'number', description: 'Maximum price per night in USD (optional)' },
},
required: ['location'],
},
},
{
name: 'computeRoute',
description: 'Compute driving route between two points. Only use for driveable distances (same continent, reasonable distance).',
parameters: {
type: 'object',
properties: {
fromLng: { type: 'number', description: 'Departure longitude' },
fromLat: { type: 'number', description: 'Departure latitude' },
toLng: { type: 'number', description: 'Arrival longitude' },
toLat: { type: 'number', description: 'Arrival latitude' },
profile: { type: 'string', description: 'Routing profile: driving-car, cycling-regular, or foot-walking' },
},
required: ['fromLng', 'fromLat', 'toLng', 'toLat'],
},
},
],
}],
};
// ─── Tool executor ──────────────────────────────────────────────
type ToolCallArgs = Record<string, unknown>;
async function executeTool(name: string, args: ToolCallArgs): Promise<unknown> {
switch (name) {
case 'geocodeLocation':
return await geocodeLocation(args.name as string);
case 'searchFlights':
return await searchFlights(
args.from as string,
args.to as string,
args.dateFrom as string,
args.dateTo as string,
(args.adults as number) || 1
);
case 'searchAccommodation':
return await searchAccommodation(
args.location as string,
(args.checkin as string) || null,
(args.checkout as string) || null,
(args.guests as number) || 2,
args.maxPrice as number | undefined,
);
case 'computeRoute':
return await computeRoute(
[args.fromLng as number, args.fromLat as number],
[args.toLng as number, args.toLat as number],
(args.profile as string) || 'driving-car'
);
default:
return { error: `Unknown tool: ${name}` };
}
}
// ─── Status callback type ───────────────────────────────────────
export type PlannerStatus = (message: string) => void;
// ─── Main agent loop ────────────────────────────────────────────
export async function planTrip(
text: string,
onStatus?: PlannerStatus
): Promise<EnrichedTrip> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) throw new Error('GEMINI_API_KEY not configured');
// Build initial conversation
const contents: GeminiContent[] = [
{ role: 'user', parts: [{ text }] },
];
for (let i = 0; i < MAX_ITERATIONS; i++) {
onStatus?.(`AI planning iteration ${i + 1}...`);
const response = await callGemini(apiKey, contents);
const candidate = response.candidates?.[0];
if (!candidate?.content?.parts) {
throw new Error('No response from Gemini');
}
const parts = candidate.content.parts;
// Add assistant response to conversation
contents.push({ role: 'model', parts });
// Check for function calls
const functionCalls = parts.filter(
(p: GeminiPart) => p.functionCall
);
if (functionCalls.length === 0) {
// No more tool calls — extract final JSON from text parts
const textParts = parts
.filter((p: GeminiPart) => p.text)
.map((p: GeminiPart) => p.text)
.join('');
return parseEnrichedTrip(textParts);
}
// Execute tool calls (in parallel where possible)
onStatus?.(describeToolCalls(functionCalls));
const toolResults = await Promise.all(
functionCalls.map(async (part: GeminiPart) => {
const { name, args } = part.functionCall!;
const result = await executeTool(name, args);
return {
functionResponse: {
name,
response: { result: result ?? null },
},
};
})
);
// Add tool results to conversation
contents.push({ role: 'user', parts: toolResults });
}
throw new Error('AI planner exceeded maximum iterations');
}
// ─── Gemini API call ────────────────────────────────────────────
interface GeminiPart {
text?: string;
functionCall?: { name: string; args: ToolCallArgs };
functionResponse?: { name: string; response: unknown };
}
interface GeminiContent {
role: 'user' | 'model';
parts: GeminiPart[];
}
interface GeminiResponse {
candidates?: Array<{
content?: { parts: GeminiPart[] };
finishReason?: string;
}>;
}
async function callGemini(apiKey: string, contents: GeminiContent[]): Promise<GeminiResponse> {
const res = await fetch(`${GEMINI_URL}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents,
...TOOL_DECLARATIONS,
generationConfig: {
temperature: 0.1,
},
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Gemini API error (${res.status}): ${err}`);
}
return res.json();
}
// ─── Parse final response ───────────────────────────────────────
function parseEnrichedTrip(text: string): EnrichedTrip {
const jsonStr = extractJSON(text);
const parsed = JSON.parse(jsonStr);
// Ensure all required fields with defaults
return {
title: parsed.title || 'Untitled Trip',
destinations: (parsed.destinations || []).map((d: Record<string, unknown>) => ({
name: d.name || '',
country: d.country || null,
arrivalDate: d.arrivalDate || null,
departureDate: d.departureDate || null,
lat: typeof d.lat === 'number' ? d.lat : null,
lng: typeof d.lng === 'number' ? d.lng : null,
})),
startDate: parsed.startDate || null,
endDate: parsed.endDate || null,
budgetTotal: parsed.budgetTotal || null,
budgetCurrency: parsed.budgetCurrency || 'USD',
travelers: parsed.travelers || null,
itineraryItems: parsed.itineraryItems || [],
bookings: parsed.bookings || [],
flightOptions: parsed.flightOptions || [],
accommodationOptions: parsed.accommodationOptions || [],
routes: parsed.routes || [],
};
}
function extractJSON(text: string): string {
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fenceMatch) return fenceMatch[1].trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) return jsonMatch[0];
return text;
}
// ─── Status description helpers ─────────────────────────────────
function describeToolCalls(calls: GeminiPart[]): string {
const descriptions = calls.map((c) => {
const name = c.functionCall!.name;
const args = c.functionCall!.args;
switch (name) {
case 'geocodeLocation':
return `Geocoding "${args.name}"`;
case 'searchFlights':
return `Searching flights ${args.from}${args.to}`;
case 'searchAccommodation':
return `Finding accommodation in ${args.location}`;
case 'computeRoute':
return 'Computing route';
default:
return name;
}
});
return descriptions.join(', ');
}

295
src/lib/ai-tools.ts Normal file
View File

@ -0,0 +1,295 @@
// AI tool functions called by the Gemini planner agent
//
// Four tools: geocode, flight search (Kiwi), accommodation search (Airbnb scraper),
// route computation (ORS/OSRM). Each returns plain JSON for the LLM to consume.
import { getRoute } from './ors';
import type { FlightOption, AccommodationOption } from './types';
// ─── Rate limiting for Nominatim (1 req/sec) ───────────────────
let lastNominatimCall = 0;
async function nominatimThrottle() {
const now = Date.now();
const elapsed = now - lastNominatimCall;
if (elapsed < 1100) {
await new Promise((r) => setTimeout(r, 1100 - elapsed));
}
lastNominatimCall = Date.now();
}
// ─── 1. Geocode ─────────────────────────────────────────────────
export interface GeocodeResult {
lat: number;
lng: number;
displayName: string;
}
export async function geocodeLocation(name: string): Promise<GeocodeResult | null> {
await nominatimThrottle();
const url = new URL('https://nominatim.openstreetmap.org/search');
url.searchParams.set('q', name);
url.searchParams.set('format', 'json');
url.searchParams.set('limit', '1');
const res = await fetch(url.toString(), {
headers: { 'User-Agent': 'rTrips/1.0 (trip-planner)' },
});
if (!res.ok) return null;
const data = await res.json();
if (!data.length) return null;
return {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
displayName: data[0].display_name,
};
}
// ─── 2. Flight Search (Kiwi Tequila) ───────────────────────────
const KIWI_BASE = 'https://api.tequila.kiwi.com';
async function resolveIataCode(query: string, apiKey: string): Promise<string | null> {
const url = new URL(`${KIWI_BASE}/locations/query`);
url.searchParams.set('term', query);
url.searchParams.set('location_types', 'airport,city');
url.searchParams.set('limit', '1');
const res = await fetch(url.toString(), {
headers: { apikey: apiKey },
});
if (!res.ok) return null;
const data = await res.json();
return data.locations?.[0]?.code || null;
}
export async function searchFlights(
from: string,
to: string,
dateFrom: string,
dateTo: string,
adults: number = 1
): Promise<FlightOption[]> {
const apiKey = process.env.KIWI_API_KEY;
if (!apiKey) {
console.warn('KIWI_API_KEY not set — skipping flight search');
return [];
}
// Resolve IATA codes if city names provided
const [fromCode, toCode] = await Promise.all([
resolveIataCode(from, apiKey),
resolveIataCode(to, apiKey),
]);
if (!fromCode || !toCode) return [];
const url = new URL(`${KIWI_BASE}/v2/search`);
url.searchParams.set('fly_from', fromCode);
url.searchParams.set('fly_to', toCode);
url.searchParams.set('date_from', formatKiwiDate(dateFrom));
url.searchParams.set('date_to', formatKiwiDate(dateTo));
url.searchParams.set('adults', adults.toString());
url.searchParams.set('limit', '5');
url.searchParams.set('sort', 'price');
url.searchParams.set('curr', 'USD');
const res = await fetch(url.toString(), {
headers: { apikey: apiKey },
});
if (!res.ok) {
console.warn(`Kiwi search failed (${res.status})`);
return [];
}
const data = await res.json();
return (data.data || []).slice(0, 5).map((f: Record<string, unknown>): FlightOption => ({
price: f.price as number,
currency: (f.currency as string) || 'USD',
airline: ((f.airlines as string[]) || []).join(', '),
departure: f.local_departure as string,
arrival: f.local_arrival as string,
durationMinutes: Math.round(((f.duration as Record<string, number>)?.total || 0) / 60),
deepLink: (f.deep_link as string) || null,
}));
}
function formatKiwiDate(iso: string): string {
// Kiwi expects dd/mm/yyyy
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
// ─── 3. Accommodation Search (Airbnb scraper) ──────────────────
const AIRBNB_BASE = 'https://www.airbnb.com/s/homes';
export async function searchAccommodation(
location: string,
checkin: string | null,
checkout: string | null,
guests: number = 2,
maxPrice?: number,
currency: string = 'USD'
): Promise<AccommodationOption[]> {
const urlParams = new URLSearchParams({
query: location,
adults: guests.toString(),
currency,
});
if (checkin) urlParams.set('checkin', checkin.split('T')[0]);
if (checkout) urlParams.set('checkout', checkout.split('T')[0]);
if (maxPrice) urlParams.set('price_max', maxPrice.toString());
const searchUrl = `${AIRBNB_BASE}?${urlParams.toString()}`;
try {
const res = await fetch(searchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (!res.ok) return [];
const html = await res.text();
return parseAirbnbListings(html, currency).slice(0, 5);
} catch {
return [];
}
}
// Extracted from the original accommodations/search API route
function parseAirbnbListings(html: string, currency: string): AccommodationOption[] {
const listings: AccommodationOption[] = [];
try {
const dataMatch = html.match(/data-deferred-state-(\d+)="([^"]+)"/);
if (!dataMatch) {
const nextDataMatch = html.match(/<script[^>]*id="data-deferred-state[^"]*"[^>]*>([^<]+)<\/script>/);
if (!nextDataMatch) return listings;
try {
const data = JSON.parse(nextDataMatch[1]);
return extractListingsFromData(data, currency);
} catch {
return listings;
}
}
try {
const decoded = dataMatch[2]
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
const data = JSON.parse(decoded);
return extractListingsFromData(data, currency);
} catch {
return listings;
}
} catch {
return listings;
}
}
function extractListingsFromData(data: Record<string, unknown>, currency: string): AccommodationOption[] {
const listings: AccommodationOption[] = [];
const walk = (obj: unknown): void => {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) { obj.forEach(walk); return; }
const o = obj as Record<string, unknown>;
if (o.listing && typeof o.listing === 'object') {
const l = o.listing as Record<string, unknown>;
const id = String(l.id || '');
if (id && !listings.find((x) => x.id === id)) {
listings.push({
id,
name: String(l.name || l.title || ''),
url: `https://www.airbnb.com/rooms/${id}`,
price: extractPrice(o),
currency,
rating: typeof l.avgRating === 'number' ? l.avgRating : null,
roomType: typeof l.roomType === 'string' ? l.roomType : null,
thumbnail: extractThumbnail(l),
});
}
}
Object.values(o).forEach(walk);
};
walk(data);
return listings.slice(0, 10);
}
function extractPrice(obj: Record<string, unknown>): number | null {
const pricing = obj.pricingQuote || obj.pricing;
if (pricing && typeof pricing === 'object') {
const p = pricing as Record<string, unknown>;
const rate = p.rate || p.priceString || p.price;
if (typeof rate === 'number') return rate;
if (typeof rate === 'string') {
const match = rate.match(/[\d,]+/);
if (match) return parseInt(match[0].replace(/,/g, ''));
}
const structuredAmount = p.structuredStayDisplayPrice;
if (structuredAmount && typeof structuredAmount === 'object') {
const s = structuredAmount as Record<string, unknown>;
const primary = s.primaryLine;
if (primary && typeof primary === 'object') {
const pr = primary as Record<string, unknown>;
if (typeof pr.price === 'string') {
const m = pr.price.match(/[\d,]+/);
if (m) return parseInt(m[0].replace(/,/g, ''));
}
}
}
}
return null;
}
function extractThumbnail(listing: Record<string, unknown>): string | null {
const pics = listing.contextualPictures || listing.pictures;
if (Array.isArray(pics) && pics.length > 0) {
const first = pics[0];
if (typeof first === 'string') return first;
if (typeof first === 'object' && first) {
const p = first as Record<string, unknown>;
return String(p.picture || p.url || p.baseUrl || '');
}
}
if (typeof listing.pictureUrl === 'string') return listing.pictureUrl;
if (typeof listing.thumbnail === 'string') return listing.thumbnail;
return null;
}
// ─── 4. Route Computation ───────────────────────────────────────
export interface RouteResult {
distanceMeters: number;
durationSeconds: number;
}
export async function computeRoute(
fromCoord: [number, number], // [lng, lat]
toCoord: [number, number],
profile: string = 'driving-car'
): Promise<RouteResult | null> {
try {
const result = await getRoute(fromCoord, toCoord, profile);
return {
distanceMeters: result.distance,
durationSeconds: result.duration,
};
} catch (err) {
console.warn('Route computation failed:', err);
return null;
}
}

View File

@ -30,3 +30,45 @@ export interface ParsedBooking {
details: string | null;
cost: number | null;
}
// ─── Enriched types (AI planner output) ──────────────────────────
export interface EnrichedDestination extends ParsedDestination {
lat: number | null;
lng: number | null;
}
export interface FlightOption {
price: number;
currency: string;
airline: string;
departure: string;
arrival: string;
durationMinutes: number;
deepLink: string | null;
}
export interface AccommodationOption {
id: string;
name: string;
price: number | null;
currency: string;
rating: number | null;
roomType: string | null;
thumbnail: string | null;
url: string;
}
export interface RouteInfo {
fromIndex: number;
toIndex: number;
distanceMeters: number;
durationSeconds: number;
}
export interface EnrichedTrip extends ParsedTrip {
destinations: EnrichedDestination[];
flightOptions: FlightOption[][]; // per leg
accommodationOptions: AccommodationOption[][]; // per destination
routes: RouteInfo[]; // between consecutive destinations
}