Recommend Places by LLM
This commit is contained in:
parent
13f93623a5
commit
1f384c909a
22
package-lock.json
generated
22
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
58
src/lib/components/LoadingOverlay.svelte
Normal file
58
src/lib/components/LoadingOverlay.svelte
Normal 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>
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showPanel}
|
||||
|
@ -87,9 +55,7 @@
|
|||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="message">Loading...</div>
|
||||
{:else if pastTrips.length === 0}
|
||||
{#if pastTrips.length === 0}
|
||||
<div class="message">This is your first trip to {destination}!</div>
|
||||
{:else}
|
||||
<div class="trips-scroll-container">
|
||||
|
|
67
src/lib/components/RecommendationPopup.svelte
Normal file
67
src/lib/components/RecommendationPopup.svelte
Normal 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
100
src/lib/services/openai.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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<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
|
||||
|
@ -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<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() {
|
||||
|
@ -407,12 +567,6 @@
|
|||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
|
@ -420,11 +574,24 @@
|
|||
<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} />
|
||||
<PastTripsPanel
|
||||
showPanel={showPastTrips}
|
||||
destination={tripData?.destination?.name || ''}
|
||||
onClose={() => showPastTrips = false}
|
||||
showPanel={showPastTrips}
|
||||
destination={tripData?.destination?.name || ''}
|
||||
pastTrips={pastTripsData}
|
||||
onClose={() => showPastTrips = false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RecommendationPopup
|
||||
showPopup={showRecommendationPopup}
|
||||
destination={tripData?.destination?.name || ''}
|
||||
onSelect={handleRecommendationSelect}
|
||||
onCancel={handleRecommendationCancel}
|
||||
/>
|
||||
|
||||
<LoadingOverlay
|
||||
show={isGeneratingRecommendations}
|
||||
message="Generating Recommended Places"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
@ -572,15 +739,4 @@
|
|||
display: flex;
|
||||
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>
|
Loading…
Reference in New Issue
Block a user