rspace-online/lib/trip-ai-tools.ts

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