diff --git a/lib/trip-ai-tools.ts b/lib/trip-ai-tools.ts new file mode 100644 index 0000000..9d84e9f --- /dev/null +++ b/lib/trip-ai-tools.ts @@ -0,0 +1,259 @@ +/** + * 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); +} diff --git a/modules/rtrips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts index bc5ee06..9094d63 100644 --- a/modules/rtrips/components/folk-trips-planner.ts +++ b/modules/rtrips/components/folk-trips-planner.ts @@ -28,6 +28,11 @@ class FolkTripsPlanner extends HTMLElement { private _aiLoading = false; private _aiModel = 'gemini-flash'; private _aiTripContext: any = null; + private _mapInstance: any = null; + private _mapMarkers: any[] = []; + private _mapPins: { lat: number; lng: number; label: string; color?: string }[] = []; + private _mapRoutes: any[] = []; + private _aiToolResults: { type: string; data: any; id: string }[] = []; private _tour!: TourEngine; private static readonly TOUR_STEPS = [ { target: '#create-trip', title: "Plan a Trip", message: "Start planning a new trip — add destinations, itinerary, and budget.", advanceOnClick: false }, @@ -64,6 +69,7 @@ class FolkTripsPlanner extends HTMLElement { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; this._stopPresence?.(); + this.destroyMap(); } private async subscribeOffline() { @@ -419,6 +425,8 @@ class FolkTripsPlanner extends HTMLElement { } private render() { + // Map container is destroyed by innerHTML — release the instance + if (this._mapInstance) { try { this._mapInstance.remove(); } catch {} this._mapInstance = null; this._mapMarkers = []; } this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} @@ -544,7 +581,11 @@ class FolkTripsPlanner extends HTMLElement { `; this.attachListeners(); - if (this.view !== 'ai-planner') this._tour.renderOverlay(); + if (this.view === 'ai-planner') { + this.initOrUpdateMap(); + } else { + this._tour.renderOverlay(); + } } startTour() { @@ -738,7 +779,11 @@ class FolkTripsPlanner extends HTMLElement { this._aiTripContext = this.trip || null; this._aiMessages = []; this._aiGeneratedItems = []; + this._aiToolResults = []; + this._mapPins = []; + this._mapRoutes = []; this._aiLoading = false; + this.destroyMap(); this._history.push(this.view as any); this.view = 'ai-planner'; this.render(); @@ -802,6 +847,10 @@ class FolkTripsPlanner extends HTMLElement { this.shadow.getElementById('ai-clear')?.addEventListener('click', () => { this._aiMessages = []; this._aiGeneratedItems = []; + this._aiToolResults = []; + this._mapPins = []; + this._mapRoutes = []; + this.destroyMap(); this.render(); }); this.shadow.getElementById('btn-export-canvas')?.addEventListener('click', () => this.exportToCanvas()); @@ -832,12 +881,13 @@ class FolkTripsPlanner extends HTMLElement {

Plan your trip with AI

-

Describe your trip and I'll generate destinations, itineraries, budgets, and more.

+

Tell me where you want to go and I'll search flights, find routes, discover nearby places, and build your full itinerary.

${tripName ? `

Context: ${this.esc(tripName)}

` : ''}
` : this._aiMessages.map(m => { if (m.toolCalls?.length) { - return `
\u{1F6E0}\uFE0F Created ${m.toolCalls.length} item${m.toolCalls.length > 1 ? 's' : ''}
`; + const labels = m.toolCalls.map((tc: any) => tc.label || tc.name.replace(/_/g, ' ')).slice(0, 4); + return `
\u{1F6E0}\uFE0F ${labels.join(' \u00B7 ')}${m.toolCalls.length > 4 ? ` +${m.toolCalls.length - 4} more` : ''}
`; } return `
${this.esc(m.content)}
`; }).join('')} @@ -853,16 +903,20 @@ class FolkTripsPlanner extends HTMLElement { ${this._aiMessages.length > 0 ? '' : ''} -
-
-

Generated Items${totalCount > 0 ? ` (${acceptedCount}/${totalCount} accepted)` : ''}

+
+
+
${this._mapPins.length === 0 ? '\u{1F5FA}\uFE0F Destinations will appear on the map' : ''}
+
+
+ ${this._aiToolResults.map(tr => this.renderToolResultCard(tr)).join('')} + ${totalCount > 0 ? ` +
Generated Items (${acceptedCount}/${totalCount} accepted)
+ ${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')} + ` : (this._aiToolResults.length === 0 ? `
+

\u{1F5FA}\uFE0F

+

AI-generated trip items will appear here

+
` : '')}
- ${totalCount > 0 ? `
- ${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')} -
` : `
-

\u{1F5FA}\uFE0F

-

AI-generated trip items will appear here

-
`}
`; @@ -943,6 +997,75 @@ class FolkTripsPlanner extends HTMLElement { `; } + private renderToolResultCard(tr: { type: string; data: any; id: string }): string { + const { type, data, id } = tr; + switch (type) { + case 'search_flights': { + const flights = data.flights || []; + const from = data._from || ''; + const to = data._to || ''; + return ` +
+
\u2708\uFE0F Flights${from && to ? `: ${this.esc(from)} \u2192 ${this.esc(to)}` : ''}
+ ${flights.length > 0 ? `
+ ${flights.map((f: any) => ` +
+ ${this.esc(f.airline || '?')} + ${f.departTime ? new Date(f.departTime).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '?'} \u2192 ${f.arriveTime ? new Date(f.arriveTime).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '?'} + ${f.duration || '?'} \u00B7 ${f.stops === 0 ? 'Direct' : f.stops + ' stop' + (f.stops > 1 ? 's' : '')} + ${f.currency || '\u20AC'}${f.price} + ${f.bookingUrl ? `Book \u2192` : ''} +
+ `).join('')} +
` : `
No flight results found
`} + ${data.searchUrl ? `See all results on Kiwi \u2192` : ''} + ${data.note ? `
${this.esc(data.note)}
` : ''} +
`; + } + case 'get_route': { + if (data.error) return `
\u{1F6E3}\uFE0F Route
Error: ${this.esc(data.error)}
`; + return ` +
+
\u{1F6E3}\uFE0F Route${data._label ? `: ${this.esc(data._label)}` : ''}
+
+ ${data.distanceKm || '?'} km + \u00B7 + ${data.durationFormatted || '?'} + \u00B7 + ${data._mode || 'driving'} +
+
`; + } + case 'geocode_place': { + if (data.error) return ''; + return ` +
+
\u{1F4CD} Location
+
${this.esc(data.displayName || data._query || 'Unknown')}
+
${data.lat?.toFixed(4)}, ${data.lng?.toFixed(4)}
+
`; + } + case 'find_nearby': { + const results = data.results || []; + return ` +
+
\u{1F4CD} ${this.esc((data.category || 'Places').charAt(0).toUpperCase() + (data.category || 'places').slice(1))}s nearby
+ ${results.length > 0 ? `
+ ${results.map((r: any) => ` +
+ ${this.esc(r.name)} + ${r.distance != null ? `${r.distance}m` : ''} + ${this.esc(r.type || data.category)} +
+ `).join('')} +
` : '
No results found
'} +
`; + } + default: + return `
${this.esc(type)}
${this.esc(JSON.stringify(data).slice(0, 120))}
`; + } + } + private async sendAiMessage(text: string) { if (!text.trim() || this._aiLoading) return; this._aiMessages.push({ role: 'user', content: text.trim() }); @@ -954,7 +1077,7 @@ class FolkTripsPlanner extends HTMLElement { if (msgBox) msgBox.scrollTop = msgBox.scrollHeight; try { - let result: { content: string; toolCalls?: any[] }; + let result: { content: string; toolCalls?: any[]; toolResults?: Record }; if (this.space === 'demo') { result = this.mockAiResponse(text); @@ -963,27 +1086,45 @@ class FolkTripsPlanner extends HTMLElement { const messages = this._aiMessages .filter(m => m.role === 'user' || (m.role === 'assistant' && m.content)) .map(m => ({ role: m.role, content: m.content })); - const res = await fetch('/api/prompt', { + const res = await fetch('/api/trips/ai-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages, model: this._aiModel, useTools: true, systemPrompt }), + body: JSON.stringify({ messages, model: this._aiModel, systemPrompt }), }); if (!res.ok) throw new Error(`API error: ${res.status}`); result = await res.json(); } - // Process tool calls into generated items + // Process tool calls — separate canvas items from live tool results if (result.toolCalls?.length) { + const canvasTools = ['create_destination', 'create_itinerary', 'create_booking', 'create_budget', 'create_packing_list', 'create_map', 'create_note']; for (const tc of result.toolCalls) { - this._aiGeneratedItems.push({ - type: tc.name, - props: tc.args || {}, - accepted: false, - id: `ai-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - }); + if (canvasTools.includes(tc.name)) { + this._aiGeneratedItems.push({ + type: tc.name, + props: tc.args || {}, + accepted: false, + id: `ai-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + }); + // Also add destination pins to map + if (tc.name === 'create_destination' && tc.args?.lat && tc.args?.lng) { + this._mapPins.push({ lat: tc.args.lat, lng: tc.args.lng, label: tc.args.destName || 'Destination', color: '#14b8a6' }); + } + } } this._aiMessages.push({ role: 'assistant', content: '', toolCalls: result.toolCalls }); } + + // Process tool results from server-executed trip tools + if (result.toolResults) { + for (const [key, data] of Object.entries(result.toolResults)) { + const toolName = key.replace(/_\d+$/, ''); + const id = `tr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + this._aiToolResults.push({ type: toolName, data, id }); + this.processToolResultForMap(toolName, data); + } + } + if (result.content) { this._aiMessages.push({ role: 'assistant', content: result.content }); } @@ -997,17 +1138,54 @@ class FolkTripsPlanner extends HTMLElement { if (msgBox2) msgBox2.scrollTop = msgBox2.scrollHeight; } + private processToolResultForMap(toolName: string, data: any) { + if (toolName === 'geocode_place' && data.lat && data.lng) { + this._mapPins.push({ lat: data.lat, lng: data.lng, label: data.displayName?.split(',')[0] || 'Pin', color: '#14b8a6' }); + } + if (toolName === 'get_route' && data.geometry) { + this._mapRoutes.push(data.geometry); + } + if (toolName === 'find_nearby' && data.results) { + for (const r of data.results.slice(0, 4)) { + if (r.lat && r.lng) { + this._mapPins.push({ lat: r.lat, lng: r.lng, label: r.name, color: '#f59e0b' }); + } + } + } + } + private buildAiSystemPrompt(): string { - let prompt = `You are a travel planning AI in rTrips. Help plan trips by creating structured items using tools. + let prompt = `You are a travel planning assistant in rTrips. Help users plan trips by understanding their needs first, then organizing the practical details. -When the user describes a trip, proactively create: -- Destination cards for each city/place (with coordinates and dates) -- An itinerary with activities by date -- Booking suggestions for flights, hotels, transport -- A budget tracker with estimated costs -- A packing list tailored to the destination +CONVERSATION APPROACH: +1. First, understand the trip: Ask about destinations, dates, group size, budget, interests, and constraints +2. Then research: Use geocode_place for each destination to get coordinates, search_flights when dates are known, get_route for distances between places, find_nearby for restaurants/hotels/attractions +3. Then organize: Create structured destination cards, itineraries, budgets, and packing lists +4. Finally, refine: Ask if anything needs adjusting + +AVAILABLE TOOLS: +Research tools (these execute live and return real data): +- search_flights: Search real flights between cities with prices and booking links +- get_route: Get driving/walking distance and duration between coordinates +- geocode_place: Look up coordinates for a city or place name +- find_nearby: Find restaurants, hotels, cafes, museums near a location + +Planning tools (these create items the user can accept or discard): +- create_destination: Create a destination card with dates and coordinates +- create_itinerary: Create a day-by-day activity plan +- create_booking: Record a booking with confirmation details +- create_budget: Create a budget breakdown with estimated costs +- create_packing_list: Create a packing checklist + +GUIDELINES: +- Always use geocode_place for destinations to get real coordinates for the map +- Use search_flights when the user confirms dates and origin city +- Use get_route to show distances between consecutive destinations +- Use find_nearby to suggest restaurants, hotels, or attractions at destinations +- Ask follow-up questions — don't generate everything at once +- Be specific with dates, prices, and logistics +- Use YYYY-MM-DD for dates, ISO currency codes (EUR, USD, JPY)`; -Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and practical.`; if (this._aiTripContext) { const t = this._aiTripContext; @@ -1026,77 +1204,147 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and prac return prompt; } - private mockAiResponse(text: string): { content: string; toolCalls: any[] } { + private mockAiResponse(text: string): { content: string; toolCalls: any[]; toolResults: Record } { const lower = text.toLowerCase(); const toolCalls: any[] = []; + const toolResults: Record = {}; if (lower.includes('japan') || lower.includes('tokyo') || lower.includes('kyoto')) { + // Geocode results for map pins + toolResults['geocode_place_1'] = { lat: 35.6762, lng: 139.6503, displayName: 'Tokyo, Japan', _query: 'Tokyo' }; + toolResults['geocode_place_2'] = { lat: 35.0116, lng: 135.7681, displayName: 'Kyoto, Japan', _query: 'Kyoto' }; + + // Flight results + toolCalls.push({ name: 'search_flights', args: { from: 'Berlin', to: 'Tokyo', departDate: '2026-06-01' }, label: 'Searching flights Berlin \u2192 Tokyo' }); + toolResults['search_flights_3'] = { + _from: 'Berlin', _to: 'Tokyo', + flights: [ + { airline: 'ANA', duration: '12h 20m', stops: 1, price: 480, currency: '\u20AC', departTime: '2026-06-01T10:30:00Z', arriveTime: '2026-06-02T06:50:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' }, + { airline: 'JAL', duration: '11h 45m', stops: 1, price: 520, currency: '\u20AC', departTime: '2026-06-01T13:15:00Z', arriveTime: '2026-06-02T09:00:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' }, + { airline: 'Lufthansa+ANA', duration: '14h 10m', stops: 1, price: 445, currency: '\u20AC', departTime: '2026-06-01T08:00:00Z', arriveTime: '2026-06-02T06:10:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' }, + { airline: 'Turkish', duration: '16h 30m', stops: 1, price: 390, currency: '\u20AC', departTime: '2026-06-01T18:20:00Z', arriveTime: '2026-06-02T14:50:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo' }, + ], + searchUrl: 'https://www.kiwi.com/en/search/results/berlin/tokyo/2026-06-01', + }; + + // Route between Tokyo and Kyoto + toolCalls.push({ name: 'get_route', args: { startLat: 35.6762, startLng: 139.6503, endLat: 35.0116, endLng: 135.7681 }, label: 'Getting route Tokyo \u2192 Kyoto' }); + toolResults['get_route_4'] = { + _label: 'Tokyo \u2192 Kyoto', _mode: 'driving', + distanceKm: 476.3, durationFormatted: '5h 12m', + distance: 476300, duration: 18720, + geometry: { type: 'LineString', coordinates: [[139.6503, 35.6762], [138.5, 35.3], [137.0, 35.1], [136.0, 35.0], [135.7681, 35.0116]] }, + }; + + // Nearby restaurants in Tokyo + toolCalls.push({ name: 'find_nearby', args: { lat: 35.6762, lng: 139.6503, category: 'restaurant' }, label: 'Finding restaurants near Tokyo' }); + toolResults['find_nearby_5'] = { + category: 'restaurant', + results: [ + { name: 'Ichiran Ramen Shibuya', type: 'ramen', lat: 35.6598, lng: 139.7006, distance: 350 }, + { name: 'Sukiyabashi Jiro', type: 'sushi', lat: 35.6733, lng: 139.7638, distance: 780 }, + { name: 'Narisawa', type: 'fine dining', lat: 35.6656, lng: 139.7216, distance: 520 }, + { name: 'Tsuta Ramen', type: 'ramen', lat: 35.7189, lng: 139.7226, distance: 1200 }, + ], + }; + + // Canvas items toolCalls.push( - { name: 'create_destination', args: { destName: 'Tokyo', country: 'Japan', lat: 35.6762, lng: 139.6503, arrivalDate: '2026-06-01', departureDate: '2026-06-04', notes: 'Explore Shibuya, Akihabara, and Tsukiji Market' } }, - { name: 'create_destination', args: { destName: 'Kyoto', country: 'Japan', lat: 35.0116, lng: 135.7681, arrivalDate: '2026-06-04', departureDate: '2026-06-06', notes: 'Visit Fushimi Inari, Arashiyama bamboo grove' } }, + { name: 'create_destination', args: { destName: 'Tokyo', country: 'Japan', lat: 35.6762, lng: 139.6503, arrivalDate: '2026-06-01', departureDate: '2026-06-04', notes: 'Explore Shibuya, Akihabara, and Tsukiji Market' }, label: 'Created destination: Tokyo' }, + { name: 'create_destination', args: { destName: 'Kyoto', country: 'Japan', lat: 35.0116, lng: 135.7681, arrivalDate: '2026-06-04', departureDate: '2026-06-06', notes: 'Visit Fushimi Inari, Arashiyama bamboo grove' }, label: 'Created destination: Kyoto' }, { name: 'create_itinerary', args: { tripTitle: 'Japan Adventure', itemsJson: JSON.stringify([ { id: 'it1', title: 'Arrive Tokyo \u2014 Narita Express', date: '2026-06-01', startTime: '15:00', category: 'TRANSPORT' }, { id: 'it2', title: 'Shibuya & Harajuku exploration', date: '2026-06-02', startTime: '10:00', category: 'ACTIVITY' }, { id: 'it3', title: 'Tsukiji Market & teamLab', date: '2026-06-03', startTime: '08:00', category: 'ACTIVITY' }, { id: 'it4', title: 'Shinkansen to Kyoto', date: '2026-06-04', startTime: '10:00', category: 'TRANSPORT' }, { id: 'it5', title: 'Fushimi Inari & Kiyomizu-dera', date: '2026-06-05', startTime: '07:00', category: 'ACTIVITY' }, - ]) } }, + ]) }, label: 'Created itinerary: Japan Adventure' }, { name: 'create_budget', args: { budgetTotal: 3500, currency: 'USD', expensesJson: JSON.stringify([ { id: 'e1', category: 'TRANSPORT', description: 'Round-trip flights', amount: 1200, date: '2026-06-01' }, { id: 'e2', category: 'ACCOMMODATION', description: 'Hotels (5 nights)', amount: 800, date: '2026-06-01' }, { id: 'e3', category: 'TRANSPORT', description: 'JR Pass (7 day)', amount: 280, date: '2026-06-01' }, { id: 'e4', category: 'FOOD', description: 'Food & dining', amount: 500, date: '2026-06-01' }, { id: 'e5', category: 'ACTIVITY', description: 'Activities & entrance fees', amount: 300, date: '2026-06-01' }, - ]) } }, + ]) }, label: 'Created budget: $3,500' }, { name: 'create_packing_list', args: { itemsJson: JSON.stringify([ { id: 'p1', name: 'Comfortable walking shoes', category: 'FOOTWEAR', quantity: 1, packed: false }, { id: 'p2', name: 'Rain jacket (light)', category: 'CLOTHING', quantity: 1, packed: false }, { id: 'p3', name: 'Portable WiFi hotspot', category: 'ELECTRONICS', quantity: 1, packed: false }, { id: 'p4', name: 'Power adapter (Type A)', category: 'ELECTRONICS', quantity: 1, packed: false }, { id: 'p5', name: 'Passport + visa', category: 'DOCUMENTS', quantity: 1, packed: false }, - ]) } }, + ]) }, label: 'Created packing list' }, ); - return { content: "Here's a 5-day Japan plan covering Tokyo and Kyoto! I've created destination cards, a day-by-day itinerary, an estimated budget of $3,500, and a packing list. You can accept items you like and discard the rest.", toolCalls }; + return { content: "Here's a 5-day Japan plan! I found flights from \u20AC390 (Turkish with 1 stop) to \u20AC520 (JAL direct). Tokyo to Kyoto is 476km by car, but I'd recommend the Shinkansen bullet train (2h 15m). I also found some great restaurants near Shibuya. Accept the items you like and I can refine further!", toolCalls, toolResults }; } if (lower.includes('europe') || lower.includes('paris') || lower.includes('rome') || lower.includes('barcelona')) { + toolResults['geocode_place_1'] = { lat: 48.8566, lng: 2.3522, displayName: 'Paris, France', _query: 'Paris' }; + toolResults['geocode_place_2'] = { lat: 41.3874, lng: 2.1686, displayName: 'Barcelona, Spain', _query: 'Barcelona' }; + + toolCalls.push({ name: 'search_flights', args: { from: 'Paris', to: 'Barcelona', departDate: '2026-07-04' }, label: 'Searching flights Paris \u2192 Barcelona' }); + toolResults['search_flights_3'] = { + _from: 'Paris', _to: 'Barcelona', + flights: [ + { airline: 'Vueling', duration: '1h 55m', stops: 0, price: 65, currency: '\u20AC', departTime: '2026-07-04T07:30:00Z', arriveTime: '2026-07-04T09:25:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona' }, + { airline: 'Air France', duration: '2h 00m', stops: 0, price: 95, currency: '\u20AC', departTime: '2026-07-04T12:00:00Z', arriveTime: '2026-07-04T14:00:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona' }, + { airline: 'Ryanair', duration: '2h 05m', stops: 0, price: 42, currency: '\u20AC', departTime: '2026-07-04T06:15:00Z', arriveTime: '2026-07-04T08:20:00Z', bookingUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona' }, + ], + searchUrl: 'https://www.kiwi.com/en/search/results/paris/barcelona/2026-07-04', + }; + + toolCalls.push({ name: 'get_route', args: { startLat: 48.8566, startLng: 2.3522, endLat: 41.3874, endLng: 2.1686 }, label: 'Getting route Paris \u2192 Barcelona' }); + toolResults['get_route_4'] = { + _label: 'Paris \u2192 Barcelona', _mode: 'driving', + distanceKm: 1035, durationFormatted: '9h 45m', + distance: 1035000, duration: 35100, + geometry: { type: 'LineString', coordinates: [[2.3522, 48.8566], [2.5, 46.0], [2.0, 43.0], [2.1686, 41.3874]] }, + }; + toolCalls.push( - { name: 'create_destination', args: { destName: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522, arrivalDate: '2026-07-01', departureDate: '2026-07-04' } }, - { name: 'create_destination', args: { destName: 'Barcelona', country: 'Spain', lat: 41.3874, lng: 2.1686, arrivalDate: '2026-07-04', departureDate: '2026-07-07' } }, - { name: 'create_booking', args: { bookingType: 'FLIGHT', provider: 'Air France CDG\u2192BCN', cost: 180, currency: 'EUR', startDate: '2026-07-04' } }, + { name: 'create_destination', args: { destName: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522, arrivalDate: '2026-07-01', departureDate: '2026-07-04' }, label: 'Created destination: Paris' }, + { name: 'create_destination', args: { destName: 'Barcelona', country: 'Spain', lat: 41.3874, lng: 2.1686, arrivalDate: '2026-07-04', departureDate: '2026-07-07' }, label: 'Created destination: Barcelona' }, { name: 'create_budget', args: { budgetTotal: 4000, currency: 'EUR', expensesJson: JSON.stringify([ { id: 'e1', category: 'TRANSPORT', description: 'Flights', amount: 900, date: '2026-07-01' }, { id: 'e2', category: 'ACCOMMODATION', description: 'Hotels (6 nights)', amount: 1200, date: '2026-07-01' }, { id: 'e3', category: 'FOOD', description: 'Dining', amount: 700, date: '2026-07-01' }, - ]) } }, + ]) }, label: 'Created budget: \u20AC4,000' }, ); - return { content: "Here's a week in Europe \u2014 Paris and Barcelona! I've created destination cards, a connecting flight, and a budget estimate.", toolCalls }; + return { content: "Here's a week in Europe \u2014 Paris and Barcelona! Flights between the two start at just \u20AC42 (Ryanair). It's 1,035km by car but only 2 hours by plane. I've set up destinations and a \u20AC4,000 budget.", toolCalls, toolResults }; } if (lower.includes('beach') || lower.includes('tropical') || lower.includes('bali') || lower.includes('thailand')) { + toolResults['geocode_place_1'] = { lat: -8.3405, lng: 115.0920, displayName: 'Bali, Indonesia', _query: 'Bali' }; + + toolCalls.push({ name: 'find_nearby', args: { lat: -8.3405, lng: 115.0920, category: 'hotel' }, label: 'Finding hotels near Bali' }); + toolResults['find_nearby_2'] = { + category: 'hotel', + results: [ + { name: 'Four Seasons Jimbaran', type: 'resort', lat: -8.7900, lng: 115.1600, distance: 2100 }, + { name: 'Hanging Gardens of Bali', type: 'boutique', lat: -8.4200, lng: 115.3800, distance: 3400 }, + { name: 'Alila Seminyak', type: 'beach hotel', lat: -8.6800, lng: 115.1500, distance: 1800 }, + ], + }; + toolCalls.push( - { name: 'create_destination', args: { destName: 'Bali', country: 'Indonesia', lat: -8.3405, lng: 115.0920, arrivalDate: '2026-08-10', departureDate: '2026-08-17', notes: 'Ubud rice terraces, Seminyak beach, temple tours' } }, + { name: 'create_destination', args: { destName: 'Bali', country: 'Indonesia', lat: -8.3405, lng: 115.0920, arrivalDate: '2026-08-10', departureDate: '2026-08-17', notes: 'Ubud rice terraces, Seminyak beach, temple tours' }, label: 'Created destination: Bali' }, { name: 'create_itinerary', args: { tripTitle: 'Bali Beach Retreat', itemsJson: JSON.stringify([ { id: 'it1', title: 'Arrive Ngurah Rai Airport', date: '2026-08-10', startTime: '14:00', category: 'TRANSPORT' }, { id: 'it2', title: 'Ubud rice terrace trek', date: '2026-08-11', startTime: '08:00', category: 'ACTIVITY' }, { id: 'it3', title: 'Temple tour \u2014 Tirta Empul', date: '2026-08-12', startTime: '09:00', category: 'ACTIVITY' }, { id: 'it4', title: 'Surf lesson at Seminyak', date: '2026-08-13', startTime: '07:00', category: 'ACTIVITY' }, { id: 'it5', title: 'Beach day & spa', date: '2026-08-14', startTime: '10:00', category: 'FREE_TIME' }, - ]) } }, + ]) }, label: 'Created itinerary' }, { name: 'create_budget', args: { budgetTotal: 2000, currency: 'USD', expensesJson: JSON.stringify([ { id: 'e1', category: 'TRANSPORT', description: 'Flights', amount: 800, date: '2026-08-10' }, { id: 'e2', category: 'ACCOMMODATION', description: 'Villa (7 nights)', amount: 600, date: '2026-08-10' }, { id: 'e3', category: 'FOOD', description: 'Food & drinks', amount: 300, date: '2026-08-10' }, - ]) } }, + ]) }, label: 'Created budget: $2,000' }, ); - return { content: "Here's a relaxing week in Bali! Includes rice terraces, temples, surfing, and plenty of beach time. Budget is very affordable at $2,000.", toolCalls }; + return { content: "Here's a relaxing week in Bali! I found some great hotels nearby including Four Seasons and Alila Seminyak. Budget is very affordable at $2,000 including flights, villa, and food.", toolCalls, toolResults }; } - // Generic fallback - toolCalls.push( - { name: 'create_note', args: { title: 'Trip Planning Notes', content: `Planning ideas based on: "${text}"\n\n- Research destinations\n- Check flight prices\n- Look for accommodation\n- Plan day-by-day itinerary\n- Set budget` } }, - ); - return { content: "I've created a planning note to get started. Tell me more about where you'd like to go, your travel dates, budget, and interests \u2014 and I'll generate detailed destination cards, itineraries, and budget estimates!", toolCalls }; + // Generic fallback — conversational + return { content: "I'd love to help you plan a trip! To give you the best suggestions, could you tell me:\n\n1. **Where** do you want to go? (or what kind of trip \u2014 beach, city, adventure?)\n2. **When** are you thinking of traveling?\n3. **How many days** do you have?\n4. **Budget range** \u2014 backpacker, mid-range, or luxury?\n5. **Traveling solo** or with others?\n\nOnce I know more, I'll search for flights, find great spots, and build a full itinerary!", toolCalls: [], toolResults: {} }; } private acceptItem(id: string) { @@ -1118,6 +1366,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and prac } private goBack() { + this.destroyMap(); const prev = this._history.back(); if (!prev) return; this.view = prev.view; @@ -1128,6 +1377,120 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and prac } } + /* ── MapLibre GL ── */ + + private async initOrUpdateMap() { + const container = this.shadow.getElementById('ai-map'); + if (!container) return; + + // Load MapLibre CSS + JS if not already loaded + const maplibreGL = (window as any).maplibregl; + if (!maplibreGL) { + // Load CSS into document head (for global elements) + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css'; + document.head.appendChild(link); + // Load JS + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js'; + script.onload = () => resolve(); + script.onerror = reject; + document.head.appendChild(script); + }); + } + + // Inject MapLibre CSS into shadow DOM too (needed for popups, controls) + if (!this.shadow.querySelector('link[data-maplibre-css]')) { + const shadowLink = document.createElement('link'); + shadowLink.rel = 'stylesheet'; + shadowLink.href = 'https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css'; + shadowLink.setAttribute('data-maplibre-css', ''); + this.shadow.prepend(shadowLink); + } + + const ml = (window as any).maplibregl; + if (!ml) return; + + // Clear placeholder + const placeholder = container.querySelector('.map-placeholder'); + if (placeholder) placeholder.remove(); + + if (!this._mapInstance) { + this._mapInstance = new ml.Map({ + container, + style: { + version: 8, + sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap' } }, + layers: [{ id: 'osm', type: 'raster', source: 'osm' }], + }, + center: [0, 20], + zoom: 1.5, + }); + // Dark filter + this._mapInstance.on('load', () => { + this._mapInstance?.getCanvas()?.style && (this._mapInstance.getCanvas().style.filter = 'brightness(0.7) contrast(1.1)'); + this.updateMapPinsAndRoutes(); + }); + } else { + this.updateMapPinsAndRoutes(); + } + } + + private updateMapPinsAndRoutes() { + const ml = (window as any).maplibregl; + if (!this._mapInstance || !ml) return; + + // Clear existing markers + for (const m of this._mapMarkers) m.remove(); + this._mapMarkers = []; + + // Add pins + for (const pin of this._mapPins) { + const el = document.createElement('div'); + el.style.cssText = `width:12px;height:12px;background:${pin.color || '#14b8a6'};border:2px solid white;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,0.3);cursor:pointer;`; + const marker = new ml.Marker({ element: el }) + .setLngLat([pin.lng, pin.lat]) + .setPopup(new ml.Popup({ offset: 8, closeButton: false }).setText(pin.label)) + .addTo(this._mapInstance); + this._mapMarkers.push(marker); + } + + // Add route lines + for (let i = 0; i < this._mapRoutes.length; i++) { + const sourceId = `route-${i}`; + const layerId = `route-line-${i}`; + if (this._mapInstance.getSource(sourceId)) { + (this._mapInstance.getSource(sourceId) as any).setData({ type: 'Feature', geometry: this._mapRoutes[i], properties: {} }); + } else { + this._mapInstance.addSource(sourceId, { + type: 'geojson', + data: { type: 'Feature', geometry: this._mapRoutes[i], properties: {} }, + }); + this._mapInstance.addLayer({ + id: layerId, type: 'line', source: sourceId, + paint: { 'line-color': '#14b8a6', 'line-width': 3, 'line-opacity': 0.8, 'line-dasharray': [2, 1] }, + }); + } + } + + // Fit bounds if we have pins + if (this._mapPins.length > 0) { + const bounds = new ml.LngLatBounds(); + for (const pin of this._mapPins) bounds.extend([pin.lng, pin.lat]); + this._mapInstance.fitBounds(bounds, { padding: 40, maxZoom: 12, duration: 800 }); + } + } + + private destroyMap() { + if (this._mapInstance) { + try { this._mapInstance.remove(); } catch {} + this._mapInstance = null; + } + this._mapMarkers = []; + } + private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 6ee7ae3..16fd0c5 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -415,6 +415,86 @@ routes.patch("/api/packing/:id", async (c) => { return c.json({ error: "Not found" }, 404); }); +// ── Trip AI proxy endpoints ── + +routes.post("/api/trips/search-flights", async (c) => { + const KIWI_API_KEY = process.env.KIWI_API_KEY || ""; + const body = await c.req.json<{ from: string; to: string; departDate: string; returnDate?: string; passengers?: number; currency?: string }>(); + const { from, to, departDate, returnDate, passengers = 1, currency = "EUR" } = body; + if (!from || !to || !departDate) return c.json({ error: "from, to, departDate required" }, 400); + + const dateOut = departDate.replace(/-/g, "/"); + 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 c.json({ flights: [], searchUrl, note: "KIWI_API_KEY not configured" }); + + try { + const params = new URLSearchParams({ + fly_from: from, fly_to: to, + date_from: dateOut, date_to: dateOut, + 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 ${res.status}`); + const data = await res.json(); + return c.json({ flights: (data.data || []).slice(0, 5), searchUrl }); + } catch (e: any) { + return c.json({ flights: [], searchUrl, error: e.message }, 502); + } +}); + +routes.post("/api/trips/geocode", async (c) => { + const body = await c.req.json<{ query: string }>(); + if (!body.query) return c.json({ error: "query required" }, 400); + + try { + const url = `https://nominatim.openstreetmap.org/search?format=json&limit=3&q=${encodeURIComponent(body.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(); + return c.json(data.map((p: any) => ({ lat: parseFloat(p.lat), lng: parseFloat(p.lon), displayName: p.display_name, type: p.type }))); + } catch (e: any) { + return c.json({ error: e.message }, 502); + } +}); + +routes.post("/api/trips/nearby", async (c) => { + const body = await c.req.json<{ lat: number; lng: number; category: string; radius?: number }>(); + const { lat, lng, category, radius = 1000 } = body; + if (!lat || !lng || !category) return c.json({ error: "lat, lng, category required" }, 400); + + const tagMap: Record = { + restaurant: '["amenity"="restaurant"]', hotel: '["tourism"="hotel"]', + cafe: '["amenity"="cafe"]', museum: '["tourism"="museum"]', + temple: '["amenity"="place_of_worship"]', park: '["leisure"="park"]', + 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 8;`; + 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 || []).filter((el: any) => el.tags?.name).slice(0, 8).map((el: any) => ({ + name: el.tags.name, + type: el.tags.cuisine || el.tags.tourism || el.tags.amenity || category, + lat: el.lat || el.center?.lat, + lng: el.lon || el.center?.lon, + })); + return c.json({ results, category }); + } catch (e: any) { + return c.json({ results: [], error: e.message }, 502); + } +}); + // ── OSRM proxy for route planner ── routes.post("/api/route", async (c) => { const body = await c.req.json<{ start: { lng: number; lat: number }; end: { lng: number; lat: number } }>();