Recommend Places by LLM

This commit is contained in:
adeliptr 2025-06-06 19:43:31 +09:00
parent 13f93623a5
commit 1f384c909a
8 changed files with 447 additions and 65 deletions

22
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@googlemaps/js-api-loader": "^1.16.8", "@googlemaps/js-api-loader": "^1.16.8",
"d3": "^7.9.0", "d3": "^7.9.0",
"firebase": "^11.9.0", "firebase": "^11.9.0",
"openai": "^5.1.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"topojson-server": "^3.0.1" "topojson-server": "^3.0.1"
}, },
@ -3108,6 +3109,27 @@
"node": ">=0.10.0" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@ -29,6 +29,7 @@
"@googlemaps/js-api-loader": "^1.16.8", "@googlemaps/js-api-loader": "^1.16.8",
"d3": "^7.9.0", "d3": "^7.9.0",
"firebase": "^11.9.0", "firebase": "^11.9.0",
"openai": "^5.1.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"topojson-server": "^3.0.1" "topojson-server": "^3.0.1"
} }

View File

@ -43,6 +43,18 @@
transform: scale(1.02); 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 { .blue {
width: 50%; width: 50%;
background-color: var(--planner-400); background-color: var(--planner-400);

View File

@ -0,0 +1,58 @@
<script>
import { fade } from 'svelte/transition';
export let show = false;
export let message = '';
</script>
{#if show}
<div class="overlay" transition:fade={{ duration: 200 }}>
<div class="content" transition:fade={{ duration: 150, delay: 50 }}>
<div class="spinner"></div>
<p>{message}</p>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
p {
color: white;
font-size: 1.2rem;
margin: 0;
font-family: 'Inter', sans-serif;
font-weight: 500;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--white);
border-top: 3px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@ -2,15 +2,12 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import TripCard from './TripCard.svelte'; import TripCard from './TripCard.svelte';
import { ref, onValue } from 'firebase/database';
import { db } from '../../firebase';
export let showPanel = false; export let showPanel = false;
export let destination = ''; export let destination = '';
export let pastTrips: any[] = [];
export let onClose = () => {}; export let onClose = () => {};
let pastTrips: any[] = [];
let loading = true;
let tripsContainer: HTMLElement; let tripsContainer: HTMLElement;
let showLeftButton = false; let showLeftButton = false;
let showRightButton = true; let showRightButton = true;
@ -43,35 +40,6 @@
behavior: 'smooth' 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;
});
}
</script> </script>
{#if showPanel} {#if showPanel}
@ -87,9 +55,7 @@
</div> </div>
<div class="content"> <div class="content">
{#if loading} {#if pastTrips.length === 0}
<div class="message">Loading...</div>
{:else if pastTrips.length === 0}
<div class="message">This is your first trip to {destination}!</div> <div class="message">This is your first trip to {destination}!</div>
{:else} {:else}
<div class="trips-scroll-container"> <div class="trips-scroll-container">

View File

@ -0,0 +1,67 @@
<script lang="ts">
import Button from './Button.svelte';
export let showPopup = false;
export let destination = '';
export let onSelect: (type: 'new' | 'old' | 'mix') => void;
export let onCancel: () => void;
</script>
{#if showPopup}
<div class="popup-overlay">
<div class="popup">
<h2>Get Recommendations</h2>
<p>You've been to {destination} before. What type of recommendations would you like?</p>
<div class="buttons">
<Button text="New Places" type="single" onClick={() => onSelect('new')} />
<Button text="Places I've Been to" type="single" onClick={() => onSelect('old')} />
<Button text="Mix of Both" type="single" onClick={() => onSelect('mix')} />
<Button text="Cancel" type="full-gray" onClick={onCancel} />
</div>
</div>
</div>
{/if}
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup {
background: white;
padding: 2rem;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--gray-900);
}
p {
margin: 1rem 0 1.5rem;
color: var(--gray-600);
line-height: 1.5;
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

100
src/lib/services/openai.ts Normal file
View File

@ -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<PlaceRecommendation[]> {
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;
}
}

View File

@ -7,8 +7,10 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Loader } from '@googlemaps/js-api-loader'; import { Loader } from '@googlemaps/js-api-loader';
import { browser } from '$app/environment'; 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 { db } from '../../../firebase';
import { countryMappings } from '$lib/constants/CountryMappings';
import { Colors } from '$lib/constants/Colors';
import ProfilePicture from '$lib/components/ProfilePicture.svelte'; import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import BottomBar from '$lib/components/BottomBar.svelte'; import BottomBar from '$lib/components/BottomBar.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
@ -16,8 +18,9 @@
import AddPlaces from '$lib/components/AddPlaces.svelte'; import AddPlaces from '$lib/components/AddPlaces.svelte';
import PlaceCard from '$lib/components/PlaceCard.svelte'; import PlaceCard from '$lib/components/PlaceCard.svelte';
import PastTripsPanel from '$lib/components/PastTripsPanel.svelte'; import PastTripsPanel from '$lib/components/PastTripsPanel.svelte';
import { countryMappings } from '$lib/constants/CountryMappings'; import { getPlaceRecommendations } from '$lib/services/openai';
import { Colors } from '$lib/constants/Colors'; import RecommendationPopup from '$lib/components/RecommendationPopup.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
let tripData: any = null; let tripData: any = null;
let tripDates: string[] = []; let tripDates: string[] = [];
@ -57,6 +60,73 @@
let map: google.maps.Map | null = null; let map: google.maps.Map | null = null;
let markers: google.maps.marker.AdvancedMarkerElement[] = []; let markers: google.maps.marker.AdvancedMarkerElement[] = [];
let showPastTrips = false; 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<boolean> {
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<string>();
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 * Get the ISO 3166-1 alpha-2 country code from /constants/CountryMappings.ts
@ -288,16 +358,106 @@
} }
} }
function handleCancel() { async function handleRecommendPlaces() {
console.log('cancel update'); 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() { async function handleRecommendationSelect(type: 'new' | 'old' | 'mix') {
console.log('save update'); showRecommendationPopup = false;
await getRecommendations(type);
} }
function handleRecommendPlaces() { function handleRecommendationCancel() {
console.log(`will give recommendation using OpenAI`); 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<google.maps.places.PlaceResult[]>((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() { function handleTurnIntoItinerary() {
@ -407,12 +567,6 @@
</div> </div>
{/if} {/if}
</section> </section>
<div class="button-group">
<Button text="Cancel" type="gray" onClick={handleCancel}/>
<!-- later edit this so button turns blue only when there is changes to the plan -->
<Button text="Save" type="blue" onClick={handleSave} />
</div>
</div> </div>
</div> </div>
@ -420,11 +574,24 @@
<div class="map-container" bind:this={mapContainer}></div> <div class="map-container" bind:this={mapContainer}></div>
<BottomBar title="Past Trips" desc="Click to view all past trips to {tripData?.destination?.name}" onClick={handlePastTrip} /> <BottomBar title="Past Trips" desc="Click to view all past trips to {tripData?.destination?.name}" onClick={handlePastTrip} />
<PastTripsPanel <PastTripsPanel
showPanel={showPastTrips} showPanel={showPastTrips}
destination={tripData?.destination?.name || ''} destination={tripData?.destination?.name || ''}
onClose={() => showPastTrips = false} pastTrips={pastTripsData}
onClose={() => showPastTrips = false}
/> />
</div> </div>
<RecommendationPopup
showPopup={showRecommendationPopup}
destination={tripData?.destination?.name || ''}
onSelect={handleRecommendationSelect}
onCancel={handleRecommendationCancel}
/>
<LoadingOverlay
show={isGeneratingRecommendations}
message="Generating Recommended Places"
/>
</main> </main>
<style> <style>
@ -572,15 +739,4 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
.button-group {
position: sticky;
flex-shrink: 0;
background-color: var(--white);
padding: 1.5rem 0;
bottom: 0;
display: flex;
gap: 1rem;
margin-top: auto;
}
</style> </style>