From 1f384c909a1cb72094dd54c5ccd8cca0e328fa42 Mon Sep 17 00:00:00 2001 From: adeliptr Date: Fri, 6 Jun 2025 19:43:31 +0900 Subject: [PATCH] Recommend Places by LLM --- package-lock.json | 22 ++ package.json | 1 + src/lib/components/Button.svelte | 12 + src/lib/components/LoadingOverlay.svelte | 58 +++++ src/lib/components/PastTripsPanel.svelte | 38 +--- src/lib/components/RecommendationPopup.svelte | 67 ++++++ src/lib/services/openai.ts | 100 ++++++++ src/routes/itinerary/[tid]/+page.svelte | 214 +++++++++++++++--- 8 files changed, 447 insertions(+), 65 deletions(-) create mode 100644 src/lib/components/LoadingOverlay.svelte create mode 100644 src/lib/components/RecommendationPopup.svelte create mode 100644 src/lib/services/openai.ts diff --git a/package-lock.json b/package-lock.json index 8991785..9f6acb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@googlemaps/js-api-loader": "^1.16.8", "d3": "^7.9.0", "firebase": "^11.9.0", + "openai": "^5.1.1", "topojson-client": "^3.1.0", "topojson-server": "^3.0.1" }, @@ -3108,6 +3109,27 @@ "node": ">=0.10.0" } }, + "node_modules/openai": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.1.1.tgz", + "integrity": "sha512-lgIdLqvpLpz8xPUKcEIV6ml+by74mbSBz8zv/AHHebtLn/WdpH4kdXT3/Q5uUKDHg3vHV/z9+G9wZINRX6rkDg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 60bce95..32ac571 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@googlemaps/js-api-loader": "^1.16.8", "d3": "^7.9.0", "firebase": "^11.9.0", + "openai": "^5.1.1", "topojson-client": "^3.1.0", "topojson-server": "^3.0.1" } diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index 3f17f03..79cd7ea 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -43,6 +43,18 @@ transform: scale(1.02); } + .full-gray { + width: 100%; + background-color: var(--gray-50); + color: var(--gray-400); + } + + .full-gray:hover { + background-color: var(--gray-100); + color: var(--gray-600); + transform: scale(1.02); + } + .blue { width: 50%; background-color: var(--planner-400); diff --git a/src/lib/components/LoadingOverlay.svelte b/src/lib/components/LoadingOverlay.svelte new file mode 100644 index 0000000..64eb85d --- /dev/null +++ b/src/lib/components/LoadingOverlay.svelte @@ -0,0 +1,58 @@ + + +{#if show} +
+
+
+

{message}

+
+
+{/if} + + \ No newline at end of file diff --git a/src/lib/components/PastTripsPanel.svelte b/src/lib/components/PastTripsPanel.svelte index 44fb141..86f7535 100644 --- a/src/lib/components/PastTripsPanel.svelte +++ b/src/lib/components/PastTripsPanel.svelte @@ -2,15 +2,12 @@ import { slide } from 'svelte/transition'; import { quintOut } from 'svelte/easing'; import TripCard from './TripCard.svelte'; - import { ref, onValue } from 'firebase/database'; - import { db } from '../../firebase'; export let showPanel = false; export let destination = ''; + export let pastTrips: any[] = []; export let onClose = () => {}; - let pastTrips: any[] = []; - let loading = true; let tripsContainer: HTMLElement; let showLeftButton = false; let showRightButton = true; @@ -43,35 +40,6 @@ behavior: 'smooth' }); } - - $: if (showPanel && destination) { - // Fetch past trips for this destination - const tripsRef = ref(db, 'trips'); - onValue(tripsRef, (snapshot) => { - const allTrips: any[] = []; - snapshot.forEach((childSnapshot) => { - const trip = { - tid: childSnapshot.key, - ...childSnapshot.val() - }; - allTrips.push(trip); - }); - - // Get today's date at midnight for comparison - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Filter past trips for this destination - pastTrips = allTrips - .filter(trip => { - const endDate = new Date(trip.endDate); - return endDate < today && trip.destination.name === destination; - }) - .sort((a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime()); // Most recent first - - loading = false; - }); - } {#if showPanel} @@ -87,9 +55,7 @@
- {#if loading} -
Loading...
- {:else if pastTrips.length === 0} + {#if pastTrips.length === 0}
This is your first trip to {destination}!
{:else}
diff --git a/src/lib/components/RecommendationPopup.svelte b/src/lib/components/RecommendationPopup.svelte new file mode 100644 index 0000000..6235a6f --- /dev/null +++ b/src/lib/components/RecommendationPopup.svelte @@ -0,0 +1,67 @@ + + +{#if showPopup} + +{/if} + + \ No newline at end of file diff --git a/src/lib/services/openai.ts b/src/lib/services/openai.ts new file mode 100644 index 0000000..5586f67 --- /dev/null +++ b/src/lib/services/openai.ts @@ -0,0 +1,100 @@ +import OpenAI from 'openai'; + +const OPENAI_API_KEY = import.meta.env.VITE_OPENAI_API_KEY; + +const openai = new OpenAI({ + apiKey: OPENAI_API_KEY, + dangerouslyAllowBrowser: true // might be dangerous +}); + +interface PlaceRecommendation { + name: string; + description: string; +} + +export async function getPlaceRecommendations( + destination: string, + currentPlaces: string[], + pastPlaces: string[] = [], + recommendationType: 'new' | 'old' | 'mix' +): Promise { + let prompt = `You are a travel planner expert. I need recommendations for places to visit in ${destination}. + +Current itinerary includes: ${currentPlaces.join(', ')} +`; + + if (pastPlaces.length > 0) { + prompt += `From my previous trip, I visited: ${pastPlaces.join(', ')}`; + + // Adjust recommendation type based on user preference + let recommendationGuidance = ''; + switch (recommendationType) { + case 'new': + recommendationGuidance = 'Please recommend 5 new places I haven\'t visited before.'; + break; + case 'old': + recommendationGuidance = 'Please recommend 5 places from my previous visits that are worth revisiting. If there are less than 5 places, recommend all of them.'; + break; + case 'mix': + recommendationGuidance = 'Please recommend a mix of 5 places, including both new locations and some worth revisiting from my previous trip.'; + break; + } + prompt += recommendationGuidance; + } + else { + prompt += `This is my first time going to ${destination}. Please recommend 5 places I should visit.`; + } + + prompt += ` + +Please return your answer as a JSON object with a key "recommendations", containing an array of 5 objects. Each object should have two keys: "name" and "desc". + +Example format: +{ + "recommendations": [ + { + "name": "Place Name 1", + "desc": "Brief description of why to visit Place Name 1" + }, + { + "name": "Place Name 2", + "desc": "Brief description of why to visit Place Name 2" + } + ] +} +`; + + console.log(`prompt: ${prompt}`); + try { + const response = await openai.chat.completions.create({ + model: 'gpt-4.1-mini', + messages: [ + { + role: 'system', + content: 'You are a knowledgeable travel expert providing place recommendations in JSON format.' + }, + { + role: 'user', + content: prompt + } + ], + temperature: 0.7, + response_format: { type: "json_object" } + }); + + console.log(response); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error('No recommendations received'); + } + + console.log(`content: ${content}`); + + const parsedContent = JSON.parse(content); + return parsedContent.recommendations || []; + } catch (error) { + console.error('Error getting recommendations:', error); + throw error; + } +} \ No newline at end of file diff --git a/src/routes/itinerary/[tid]/+page.svelte b/src/routes/itinerary/[tid]/+page.svelte index 0ff0b94..1c61629 100644 --- a/src/routes/itinerary/[tid]/+page.svelte +++ b/src/routes/itinerary/[tid]/+page.svelte @@ -7,8 +7,10 @@ import { onMount } from 'svelte'; import { Loader } from '@googlemaps/js-api-loader'; import { browser } from '$app/environment'; - import { ref, onValue, update } from 'firebase/database'; + import { ref, onValue, update, get } from 'firebase/database'; import { db } from '../../../firebase'; + import { countryMappings } from '$lib/constants/CountryMappings'; + import { Colors } from '$lib/constants/Colors'; import ProfilePicture from '$lib/components/ProfilePicture.svelte'; import BottomBar from '$lib/components/BottomBar.svelte'; import Button from '$lib/components/Button.svelte'; @@ -16,8 +18,9 @@ import AddPlaces from '$lib/components/AddPlaces.svelte'; import PlaceCard from '$lib/components/PlaceCard.svelte'; import PastTripsPanel from '$lib/components/PastTripsPanel.svelte'; - import { countryMappings } from '$lib/constants/CountryMappings'; - import { Colors } from '$lib/constants/Colors'; + import { getPlaceRecommendations } from '$lib/services/openai'; + import RecommendationPopup from '$lib/components/RecommendationPopup.svelte'; + import LoadingOverlay from '$lib/components/LoadingOverlay.svelte'; let tripData: any = null; let tripDates: string[] = []; @@ -57,6 +60,73 @@ let map: google.maps.Map | null = null; let markers: google.maps.marker.AdvancedMarkerElement[] = []; let showPastTrips = false; + let showRecommendationPopup = false; + let isGeneratingRecommendations = false; + + let pastTripsData: any[] = []; + + /** + * Check if there are any past trips to the same destination + * and fetch their places data + */ + async function checkPastTrips(destinationName: string): Promise { + try { + const tripsRef = ref(db, 'trips'); + const snapshot = await get(tripsRef); + if (!snapshot.exists()) return false; + + // Get today's date at midnight for comparison + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Filter past trips for this destination + pastTripsData = Object.entries(snapshot.val()) + .map(([tripId, data]) => ({ + tid: tripId, + ...data as any + })) + .filter(trip => { + const endDate = new Date(trip.endDate); + return trip.tid !== tid && // not current trip + endDate < today && // trip is in the past + trip.destination.name === destinationName; + }) + .sort((a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime()); // Most recent first + + return pastTripsData.length > 0; + } catch (error) { + console.error('Error checking past trips:', error); + return false; + } + } + + /** + * Get all places from past trips to this destination + */ + function getPastPlaces(): string[] { + const places = new Set(); + + pastTripsData.forEach(trip => { + // Add places from placesToVisit + if (trip.placesToVisit) { + trip.placesToVisit.forEach((place: any) => places.add(place.name)); + } + + // Add places from itineraryDate + if (trip.itineraryDate) { + Object.values(trip.itineraryDate).forEach((dateData: any) => { + dateData.placesPlanned?.forEach((place: any) => places.add(place.name)); + }); + } + }); + + return Array.from(places); + } + + // When tripData is loaded, check for past trips + $: if (tripData?.destination?.name) { + checkPastTrips(tripData.destination.name); + } /** * Get the ISO 3166-1 alpha-2 country code from /constants/CountryMappings.ts @@ -288,16 +358,106 @@ } } - function handleCancel() { - console.log('cancel update'); + async function handleRecommendPlaces() { + if (!tripData?.destination?.name) return; + console.log("recommend places"); + + // Check for past trips + const hasPastTrips = await checkPastTrips(tripData.destination.name); + + if (hasPastTrips) { + console.log("has past trips"); + showRecommendationPopup = true; + } else { + // If no past trips, proceed with getting new recommendations + await getRecommendations('new'); + } } - function handleSave() { - console.log('save update'); + async function handleRecommendationSelect(type: 'new' | 'old' | 'mix') { + showRecommendationPopup = false; + await getRecommendations(type); } - function handleRecommendPlaces() { - console.log(`will give recommendation using OpenAI`); + function handleRecommendationCancel() { + showRecommendationPopup = false; + } + + async function getRecommendations(recommendationType: 'new' | 'old' | 'mix') { + isGeneratingRecommendations = true; + try { + // Get current places from both placesToVisit and placesPlanned + const currentPlaces = [ + ...placesToVisit.map(p => p.name), + ...Object.values(placesPlanned).flatMap(date => + date.placesPlanned?.map(p => p.name) || [] + ) + ]; + + // Get past places from previous trips + const pastPlaces = getPastPlaces(); + + // Get recommendations from OpenAI + const recommendations = await getPlaceRecommendations( + tripData.destination.name, + currentPlaces, + pastPlaces, + recommendationType + ); + + // For each recommendation, search Google Places API for details + const placesService = new google.maps.places.PlacesService(map!); + + for (const rec of recommendations) { + const request = { + query: `${rec.name} ${tripData.destination.name}`, + fields: ['name', 'formatted_address', 'photos', 'place_id', 'geometry'] + }; + + try { + const results = await new Promise((resolve, reject) => { + placesService.textSearch(request, (results, status) => { + if (status === google.maps.places.PlacesServiceStatus.OK && results) { + resolve(results); + } else { + reject(new Error(`Places API error: ${status}`)); + } + }); + }); + + if (results.length > 0) { + const place = results[0]; + const photoUrl = place.photos?.[0]?.getUrl(); + + const newPlace = { + name: place.name || rec.name, + desc: place.formatted_address || '', + image: photoUrl || '/placeholder.jpeg', + geometry: place.geometry?.location ? { + lat: place.geometry.location.lat(), + lng: place.geometry.location.lng() + } : undefined + }; + + // Add to placesToVisit + placesToVisit = [...placesToVisit, newPlace]; + } + } catch (error) { + console.error(`Error fetching place details for ${rec.name}:`, error); + } + } + + // Update the database with new places + await update(ref(db, `trips/${tid}`), { + placesToVisit + }); + + } catch (error) { + console.error('Error getting recommendations:', error); + alert('Failed to get recommendations. Please try again.'); + } finally { + isGeneratingRecommendations = false; + } } function handleTurnIntoItinerary() { @@ -407,12 +567,6 @@
{/if} - -
-
@@ -420,11 +574,24 @@
showPastTrips = false} + showPanel={showPastTrips} + destination={tripData?.destination?.name || ''} + pastTrips={pastTripsData} + onClose={() => showPastTrips = false} /> + + + + \ No newline at end of file