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:
parent
62945fc3a8
commit
36a7829c7a
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(', ');
|
||||
}
|
||||
|
|
@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue