feat(rtrips): interactive AI workspace with maps, flights & route planning
Transform the AI planner right panel into an interactive workspace: - Persistent MapLibre GL map (40% height) with pin accumulation and route lines - Flight search results with prices, airlines, and Kiwi.com booking links - Route cards with distance/duration from OSRM - Nearby POI discovery via Overpass API (restaurants, hotels, attractions) - Server-side trip tool execution via new /api/trips/ai-prompt endpoint - Conversational system prompt that progressively builds understanding - New lib/trip-ai-tools.ts with 4 executable tools (search_flights, get_route, geocode_place, find_nearby) - Enhanced demo mode with realistic mock flights, routes, and geocode data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
193588443e
commit
5915daf8a0
|
|
@ -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<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);
|
||||
}
|
||||
|
|
@ -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 = `
|
||||
<style>
|
||||
:host { display: flex; flex-direction: column; height: calc(100vh - 112px); font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); overflow: hidden; }
|
||||
|
|
@ -536,6 +544,35 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
@keyframes ai-spin { to { transform: rotate(360deg); } }
|
||||
.ai-empty { text-align: center; color: var(--rs-text-muted); padding: 40px 20px; }
|
||||
.ai-empty p { margin: 4px 0; }
|
||||
|
||||
/* Workspace layout */
|
||||
.ai-workspace { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.ai-map-container { height: 40%; min-height: 180px; border-bottom: 1px solid var(--rs-border, #333); position: relative; background: #0c1222; }
|
||||
.ai-map-container .map-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--rs-text-muted); font-size: 13px; }
|
||||
.ai-cards-scroll { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* Flight results card */
|
||||
.ai-card--flights .flight-results { display: flex; flex-direction: column; gap: 4px; }
|
||||
.flight-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; background: var(--rs-bg-surface-sunken, #1a1f2e); }
|
||||
.flight-airline { font-weight: 600; width: 50px; flex-shrink: 0; }
|
||||
.flight-times { flex: 1; font-size: 11px; }
|
||||
.flight-duration { color: var(--rs-text-muted); font-size: 11px; white-space: nowrap; }
|
||||
.flight-price { font-weight: 700; color: #14b8a6; white-space: nowrap; }
|
||||
.flight-book { color: #0ea5e9; text-decoration: none; font-size: 11px; font-weight: 500; white-space: nowrap; }
|
||||
.flight-book:hover { text-decoration: underline; }
|
||||
.flight-search-link { display: block; text-align: center; margin-top: 8px; font-size: 11px; color: #0ea5e9; text-decoration: none; }
|
||||
.flight-search-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Route card */
|
||||
.ai-card--route .route-info { font-size: 13px; color: var(--rs-text-secondary); display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* Nearby POI card */
|
||||
.ai-card--nearby .nearby-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.nearby-item { display: flex; gap: 8px; align-items: center; font-size: 12px; padding: 4px 0; border-bottom: 1px solid var(--rs-border-strong, #222); }
|
||||
.nearby-item:last-child { border-bottom: none; }
|
||||
.nearby-name { flex: 1; font-weight: 500; }
|
||||
.nearby-dist { color: var(--rs-text-muted); font-size: 11px; }
|
||||
.nearby-type { font-size: 10px; color: var(--rs-text-muted); background: var(--rs-bg-surface-sunken, #1a1f2e); padding: 1px 6px; border-radius: 4px; }
|
||||
</style>
|
||||
|
||||
${this.error ? `<div style="color:var(--rs-error);text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
||||
|
|
@ -544,7 +581,11 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
</div>
|
||||
`;
|
||||
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 {
|
|||
<div class="ai-empty">
|
||||
<p style="font-size:20px">✨</p>
|
||||
<p style="font-size:14px;font-weight:500">Plan your trip with AI</p>
|
||||
<p style="font-size:12px">Describe your trip and I'll generate destinations, itineraries, budgets, and more.</p>
|
||||
<p style="font-size:12px">Tell me where you want to go and I'll search flights, find routes, discover nearby places, and build your full itinerary.</p>
|
||||
${tripName ? `<p style="font-size:11px;margin-top:8px;color:var(--rs-text-secondary)">Context: ${this.esc(tripName)}</p>` : ''}
|
||||
</div>
|
||||
` : this._aiMessages.map(m => {
|
||||
if (m.toolCalls?.length) {
|
||||
return `<div class="ai-msg tool-action">\u{1F6E0}\uFE0F Created ${m.toolCalls.length} item${m.toolCalls.length > 1 ? 's' : ''}</div>`;
|
||||
const labels = m.toolCalls.map((tc: any) => tc.label || tc.name.replace(/_/g, ' ')).slice(0, 4);
|
||||
return `<div class="ai-msg tool-action">\u{1F6E0}\uFE0F ${labels.join(' \u00B7 ')}${m.toolCalls.length > 4 ? ` +${m.toolCalls.length - 4} more` : ''}</div>`;
|
||||
}
|
||||
return `<div class="ai-msg ${m.role}">${this.esc(m.content)}</div>`;
|
||||
}).join('')}
|
||||
|
|
@ -853,16 +903,20 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
${this._aiMessages.length > 0 ? '<button class="ai-clear-btn" id="ai-clear">Clear</button>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-cards">
|
||||
<div class="ai-cards-header">
|
||||
<h3>Generated Items${totalCount > 0 ? ` (${acceptedCount}/${totalCount} accepted)` : ''}</h3>
|
||||
<div class="ai-workspace">
|
||||
<div class="ai-map-container" id="ai-map">
|
||||
<div class="map-placeholder">${this._mapPins.length === 0 ? '\u{1F5FA}\uFE0F Destinations will appear on the map' : ''}</div>
|
||||
</div>
|
||||
<div class="ai-cards-scroll">
|
||||
${this._aiToolResults.map(tr => this.renderToolResultCard(tr)).join('')}
|
||||
${totalCount > 0 ? `
|
||||
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);margin:4px 0">Generated Items (${acceptedCount}/${totalCount} accepted)</div>
|
||||
${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')}
|
||||
` : (this._aiToolResults.length === 0 ? `<div class="ai-empty">
|
||||
<p style="font-size:28px">\u{1F5FA}\uFE0F</p>
|
||||
<p style="font-size:13px">AI-generated trip items will appear here</p>
|
||||
</div>` : '')}
|
||||
</div>
|
||||
${totalCount > 0 ? `<div class="ai-cards-grid">
|
||||
${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')}
|
||||
</div>` : `<div class="ai-empty">
|
||||
<p style="font-size:28px">\u{1F5FA}\uFE0F</p>
|
||||
<p style="font-size:13px">AI-generated trip items will appear here</p>
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 `
|
||||
<div class="ai-card ai-card--flights" data-card-id="${id}">
|
||||
<div class="ai-card-type">\u2708\uFE0F Flights${from && to ? `: ${this.esc(from)} \u2192 ${this.esc(to)}` : ''}</div>
|
||||
${flights.length > 0 ? `<div class="flight-results">
|
||||
${flights.map((f: any) => `
|
||||
<div class="flight-row">
|
||||
<span class="flight-airline">${this.esc(f.airline || '?')}</span>
|
||||
<span class="flight-times">${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'}) : '?'}</span>
|
||||
<span class="flight-duration">${f.duration || '?'} \u00B7 ${f.stops === 0 ? 'Direct' : f.stops + ' stop' + (f.stops > 1 ? 's' : '')}</span>
|
||||
<span class="flight-price">${f.currency || '\u20AC'}${f.price}</span>
|
||||
${f.bookingUrl ? `<a class="flight-book" href="${f.bookingUrl}" target="_blank">Book \u2192</a>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>` : `<div class="ai-card-detail">No flight results found</div>`}
|
||||
${data.searchUrl ? `<a class="flight-search-link" href="${data.searchUrl}" target="_blank">See all results on Kiwi \u2192</a>` : ''}
|
||||
${data.note ? `<div class="ai-card-detail" style="margin-top:4px;font-style:italic">${this.esc(data.note)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
case 'get_route': {
|
||||
if (data.error) return `<div class="ai-card ai-card--route"><div class="ai-card-type">\u{1F6E3}\uFE0F Route</div><div class="ai-card-detail">Error: ${this.esc(data.error)}</div></div>`;
|
||||
return `
|
||||
<div class="ai-card ai-card--route" data-card-id="${id}">
|
||||
<div class="ai-card-type">\u{1F6E3}\uFE0F Route${data._label ? `: ${this.esc(data._label)}` : ''}</div>
|
||||
<div class="route-info">
|
||||
<span>${data.distanceKm || '?'} km</span>
|
||||
<span>\u00B7</span>
|
||||
<span>${data.durationFormatted || '?'}</span>
|
||||
<span>\u00B7</span>
|
||||
<span>${data._mode || 'driving'}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
case 'geocode_place': {
|
||||
if (data.error) return '';
|
||||
return `
|
||||
<div class="ai-card" data-card-id="${id}">
|
||||
<div class="ai-card-type">\u{1F4CD} Location</div>
|
||||
<div class="ai-card-title">${this.esc(data.displayName || data._query || 'Unknown')}</div>
|
||||
<div class="ai-card-detail">${data.lat?.toFixed(4)}, ${data.lng?.toFixed(4)}</div>
|
||||
</div>`;
|
||||
}
|
||||
case 'find_nearby': {
|
||||
const results = data.results || [];
|
||||
return `
|
||||
<div class="ai-card ai-card--nearby" data-card-id="${id}">
|
||||
<div class="ai-card-type">\u{1F4CD} ${this.esc((data.category || 'Places').charAt(0).toUpperCase() + (data.category || 'places').slice(1))}s nearby</div>
|
||||
${results.length > 0 ? `<div class="nearby-list">
|
||||
${results.map((r: any) => `
|
||||
<div class="nearby-item">
|
||||
<span class="nearby-name">${this.esc(r.name)}</span>
|
||||
${r.distance != null ? `<span class="nearby-dist">${r.distance}m</span>` : ''}
|
||||
<span class="nearby-type">${this.esc(r.type || data.category)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>` : '<div class="ai-card-detail">No results found</div>'}
|
||||
</div>`;
|
||||
}
|
||||
default:
|
||||
return `<div class="ai-card"><div class="ai-card-type">${this.esc(type)}</div><div class="ai-card-detail">${this.esc(JSON.stringify(data).slice(0, 120))}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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<string, any> };
|
||||
|
||||
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<string, any> } {
|
||||
const lower = text.toLowerCase();
|
||||
const toolCalls: any[] = [];
|
||||
const toolResults: Record<string, any> = {};
|
||||
|
||||
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<void>((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 || "";
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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 } }>();
|
||||
|
|
|
|||
Loading…
Reference in New Issue