260 lines
9.4 KiB
TypeScript
260 lines
9.4 KiB
TypeScript
/**
|
|
* 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<string, { type: string; description: string; enum?: string[] }>;
|
|
required: string[];
|
|
};
|
|
};
|
|
execute: (args: Record<string, any>, env: { osrmUrl: string }) => Promise<any>;
|
|
actionLabel: (args: Record<string, any>) => 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<string, string> = {
|
|
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);
|
|
}
|