/** * Trip AI Tool Registry — server-executable tools for rTrips AI planner. * Unlike canvas-tools (display-only), these execute server-side and return real data. */ export interface TripToolDefinition { declaration: { name: string; description: string; parameters: { type: "object"; properties: Record; required: string[]; }; }; execute: (args: Record, env: { osrmUrl: string }) => Promise; actionLabel: (args: Record) => string; } const KIWI_API_KEY = process.env.KIWI_API_KEY || ""; const tripTools: TripToolDefinition[] = [ { declaration: { name: "search_flights", description: "Search for flights between two cities. Returns real flight options with prices and booking links.", parameters: { type: "object", properties: { from: { type: "string", description: "Departure city or IATA code (e.g. 'Paris' or 'CDG')" }, to: { type: "string", description: "Arrival city or IATA code (e.g. 'Tokyo' or 'NRT')" }, departDate: { type: "string", description: "Departure date in YYYY-MM-DD format" }, returnDate: { type: "string", description: "Return date in YYYY-MM-DD (optional for one-way)" }, passengers: { type: "number", description: "Number of passengers (default 1)" }, currency: { type: "string", description: "Currency code (default EUR)" }, }, required: ["from", "to", "departDate"], }, }, async execute(args) { const { from, to, departDate, returnDate, passengers = 1, currency = "EUR" } = args; const dateOut = departDate.replace(/-/g, "/"); // Build Kiwi search URL (always works, no API key needed) const searchUrl = `https://www.kiwi.com/en/search/results/${encodeURIComponent(from)}/${encodeURIComponent(to)}/${dateOut}${returnDate ? "/" + returnDate.replace(/-/g, "/") : ""}/${passengers}adults`; if (!KIWI_API_KEY) { return { flights: [], searchUrl, note: "Flight search API not configured — use the link to search on Kiwi.com", }; } try { const params = new URLSearchParams({ fly_from: from, fly_to: to, date_from: departDate.replace(/-/g, "/"), date_to: departDate.replace(/-/g, "/"), curr: currency, adults: String(passengers), limit: "5", sort: "price", max_stopovers: "2", }); if (returnDate) { params.set("return_from", returnDate.replace(/-/g, "/")); params.set("return_to", returnDate.replace(/-/g, "/")); } const res = await fetch(`https://api.tequila.kiwi.com/v2/search?${params}`, { headers: { apikey: KIWI_API_KEY }, }); if (!res.ok) throw new Error(`Kiwi API ${res.status}`); const data = await res.json(); const flights = (data.data || []).slice(0, 5).map((f: any) => ({ price: f.price, currency: f.currency || currency, airline: f.airlines?.join(", ") || "Unknown", duration: formatDuration(f.duration?.total || 0), stops: f.route ? Math.max(0, f.route.length - 1) : 0, departTime: f.dTime ? new Date(f.dTime * 1000).toISOString() : null, arriveTime: f.aTime ? new Date(f.aTime * 1000).toISOString() : null, bookingUrl: f.deep_link || searchUrl, })); return { flights, searchUrl }; } catch (e: any) { return { flights: [], searchUrl, error: e.message }; } }, actionLabel: (args) => `Searching flights ${args.from} → ${args.to}`, }, { declaration: { name: "get_route", description: "Get driving route between two points with distance and duration.", parameters: { type: "object", properties: { startLat: { type: "number", description: "Start latitude" }, startLng: { type: "number", description: "Start longitude" }, endLat: { type: "number", description: "End latitude" }, endLng: { type: "number", description: "End longitude" }, mode: { type: "string", description: "Routing mode: driving (default), walking, cycling", enum: ["driving", "walking", "cycling"] }, }, required: ["startLat", "startLng", "endLat", "endLng"], }, }, async execute(args, env) { const { startLat, startLng, endLat, endLng, mode = "driving" } = args; const profile = mode === "walking" ? "foot" : mode === "cycling" ? "bicycle" : "driving"; const url = `${env.osrmUrl}/route/v1/${profile}/${startLng},${startLat};${endLng},${endLat}?geometries=geojson&overview=full`; try { const res = await fetch(url); if (!res.ok) throw new Error(`OSRM ${res.status}`); const data = await res.json(); const route = data.routes?.[0]; if (!route) return { error: "No route found" }; return { distance: route.distance, // meters duration: route.duration, // seconds distanceKm: Math.round(route.distance / 100) / 10, durationFormatted: formatDuration(route.duration), geometry: route.geometry, // GeoJSON LineString }; } catch (e: any) { return { error: e.message || "OSRM unavailable" }; } }, actionLabel: (args) => `Getting route (${args.mode || "driving"})`, }, { declaration: { name: "geocode_place", description: "Look up the coordinates (latitude/longitude) of a city, address, or place name.", parameters: { type: "object", properties: { query: { type: "string", description: "Place name, city, or address to geocode" }, }, required: ["query"], }, }, async execute(args) { const { query } = args; try { const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(query)}`; const res = await fetch(url, { headers: { "User-Agent": "rTrips/1.0 (rspace.online)" }, }); if (!res.ok) throw new Error(`Nominatim ${res.status}`); const data = await res.json(); if (!data.length) return { error: "Place not found" }; const place = data[0]; return { lat: parseFloat(place.lat), lng: parseFloat(place.lon), displayName: place.display_name, type: place.type, }; } catch (e: any) { return { error: e.message || "Geocoding failed" }; } }, actionLabel: (args) => `Looking up "${args.query}"`, }, { declaration: { name: "find_nearby", description: "Find nearby points of interest (restaurants, hotels, attractions, etc.) around a location.", parameters: { type: "object", properties: { lat: { type: "number", description: "Latitude of the search center" }, lng: { type: "number", description: "Longitude of the search center" }, category: { type: "string", description: "POI category to search for", enum: ["restaurant", "hotel", "cafe", "museum", "temple", "park", "hospital", "supermarket", "attraction"] }, radius: { type: "number", description: "Search radius in meters (default 1000)" }, }, required: ["lat", "lng", "category"], }, }, async execute(args) { const { lat, lng, category, radius = 1000 } = args; const tagMap: Record = { restaurant: '["amenity"="restaurant"]', hotel: '["tourism"="hotel"]', cafe: '["amenity"="cafe"]', museum: '["tourism"="museum"]', temple: '["amenity"="place_of_worship"]', park: '["leisure"="park"]', hospital: '["amenity"="hospital"]', supermarket: '["shop"="supermarket"]', attraction: '["tourism"="attraction"]', }; const tag = tagMap[category] || `["amenity"="${category}"]`; try { const query = `[out:json][timeout:10];(node${tag}(around:${radius},${lat},${lng});way${tag}(around:${radius},${lat},${lng}););out center 10;`; const res = await fetch("https://overpass-api.de/api/interpreter", { method: "POST", body: `data=${encodeURIComponent(query)}`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); if (!res.ok) throw new Error(`Overpass ${res.status}`); const data = await res.json(); const results = (data.elements || []).slice(0, 8).map((el: any) => { const elLat = el.lat || el.center?.lat; const elLng = el.lon || el.center?.lon; const dist = elLat && elLng ? haversineDistance(lat, lng, elLat, elLng) : null; return { name: el.tags?.name || "Unnamed", type: el.tags?.cuisine || el.tags?.tourism || el.tags?.amenity || category, lat: elLat, lng: elLng, distance: dist ? Math.round(dist) : null, tags: el.tags || {}, }; }).filter((r: any) => r.name !== "Unnamed") .sort((a: any, b: any) => (a.distance || 9999) - (b.distance || 9999)); return { results, count: results.length, category, center: { lat, lng } }; } catch (e: any) { return { results: [], error: e.message || "Overpass query failed" }; } }, actionLabel: (args) => `Finding ${args.category}s nearby`, }, ]; 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 `${h}h${m > 0 ? ` ${m}m` : ""}`; } function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } export const TRIP_TOOLS: TripToolDefinition[] = [...tripTools]; export const TRIP_TOOL_DECLARATIONS = TRIP_TOOLS.map((t) => t.declaration); export function findTripTool(name: string): TripToolDefinition | undefined { return TRIP_TOOLS.find((t) => t.declaration.name === name); }