Refactor MapComponent and complete find place tool with Mapbox API

This commit is contained in:
zaidmukaddam 2024-11-06 20:22:26 +05:30
parent 0ae2e4a2c6
commit 4583c93730
3 changed files with 618 additions and 913 deletions

View File

@ -31,14 +31,6 @@ function sanitizeUrl(url: string): string {
return url.replace(/\s+/g, '%20')
}
type SearchResultImage =
| string
| {
url: string
description: string
number_of_results?: number
}
// Helper function to geocode an address
const geocodeAddress = async (address: string) => {
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
@ -61,7 +53,7 @@ export async function POST(req: Request) {
topP: 0.5,
frequencyPenalty: 0,
presencePenalty: 0,
experimental_activeTools: ["get_weather_data", "programming", "web_search", "text_translate"],
experimental_activeTools: ["get_weather_data", "programming", "web_search", "text_translate", "find_place"],
system: `
You are an expert AI web search engine called MiniPerplx, that helps users find information on the internet with no bullshit talks.
Always start with running the tool(s) and then and then only write your response AT ALL COSTS!!
@ -444,47 +436,28 @@ When asked a "What is" question, maintain the same format as the question and an
},
}),
find_place: tool({
description: "Find a specific place using Mapbox API.",
description: "Find a place using Mapbox v6 reverse geocoding API.",
parameters: z.object({
input: z.string().describe("The place to search for (e.g., 'Museum of Contemporary Art Australia')."),
inputtype: z.enum(["textquery", "phonenumber"]).describe("The type of input (textquery or phonenumber)."),
latitude: z.number().describe("The latitude of the location."),
longitude: z.number().describe("The longitude of the location."),
}),
execute: async ({ input, inputtype }: {
input: string;
inputtype: "textquery" | "phonenumber";
}) => {
execute: async ({ latitude, longitude }: { latitude: number; longitude: number }) => {
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
let searchEndpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(input)}.json`;
if (inputtype === "phonenumber") {
// Note: Mapbox doesn't support phone number search directly
// We'll just search the number as text
searchEndpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(input)}.json`;
}
const response = await fetch(`${searchEndpoint}?types=poi&access_token=${mapboxToken}`);
const response = await fetch(
`https://api.mapbox.com/search/geocode/v6/reverse?longitude=${longitude}&latitude=${latitude}&access_token=${mapboxToken}`
);
const data = await response.json();
if (!data.features || data.features.length === 0) {
return { candidates: [] };
return { features: [] };
}
const place = data.features[0];
return {
candidates: [{
name: place.text,
formatted_address: place.place_name,
geometry: {
location: {
lat: place.center[1],
lng: place.center[0]
}
},
// Note: Mapbox doesn't provide these fields
rating: null,
opening_hours: null
}]
features: data.features.map((feature: any) => ({
name: feature.properties.name_preferred || feature.properties.name,
formatted_address: feature.properties.full_address,
geometry: feature.geometry,
})),
};
},
}),

View File

@ -10,7 +10,6 @@ React,
useState,
useEffect,
useMemo,
memo,
Suspense
} from 'react';
import ReactMarkdown from 'react-markdown';
@ -95,7 +94,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import { GitHubLogoIcon, PlusCircledIcon } from '@radix-ui/react-icons';
import { Skeleton } from '@/components/ui/skeleton';
import Link from 'next/link';
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
@ -109,18 +107,11 @@ import {
import Autoplay from 'embla-carousel-autoplay';
import FormComponent from '@/components/ui/form-component';
import WeatherChart from '@/components/weather-chart';
import { MapContainer } from '@/components/map-components';
import { MapComponent, MapContainer, MapSkeleton, PlaceDetails } from '@/components/map-components';
import InteractiveChart from '@/components/interactive-charts';
export const maxDuration = 60;
declare global {
interface Window {
google: any;
initMap: () => void;
}
}
interface Attachment {
name: string;
contentType: string;
@ -128,6 +119,147 @@ interface Attachment {
size: number;
}
interface SearchImage {
url: string;
description: string;
}
const ImageCarousel = ({ images, onClose }: { images: SearchImage[], onClose: () => void }) => {
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[90vw] max-h-[90vh] p-0 bg-white dark:bg-neutral-900">
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10"
>
<X className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
<span className="sr-only">Close</span>
</button>
<Carousel className="w-full h-full">
<CarouselContent>
{images.map((image, index) => (
<CarouselItem key={index} className="flex flex-col items-center justify-center p-4">
<img
src={image.url}
alt={image.description}
className="max-w-full max-h-[70vh] object-contain mb-4"
/>
<p className="text-center text-sm text-neutral-800 dark:text-neutral-200">{image.description}</p>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-4" />
<CarouselNext className="right-4" />
</Carousel>
</DialogContent>
</Dialog>
);
};
const WebSearchResults = ({ result, args }: { result: any, args: any }) => {
const [openDialog, setOpenDialog] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const handleImageClick = (index: number) => {
setSelectedImageIndex(index);
setOpenDialog(true);
};
const handleCloseDialog = () => {
setOpenDialog(false);
};
return (
<div>
<Accordion type="single" collapsible className="w-full mt-4">
<AccordionItem value="item-1" className='border-none'>
<AccordionTrigger className="hover:no-underline py-2">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Newspaper className="h-5 w-5 text-primary" />
<h2 className='text-base font-semibold text-neutral-800 dark:text-neutral-200'>Sources Found</h2>
</div>
{result && (
<Badge variant="secondary" className='rounded-full bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200'>{result.results.length} results</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent>
{args?.query && (
<Badge variant="secondary" className="mb-4 text-xs sm:text-sm font-light rounded-full bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200">
<SearchIcon className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
{args.query}
</Badge>
)}
{result && (
<div className="flex flex-row gap-4 overflow-x-auto pb-2">
{result.results.map((item: any, itemIndex: number) => (
<div key={itemIndex} className="flex flex-col w-[280px] flex-shrink-0 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-3">
<div className="flex items-start gap-3 mb-2">
<img
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(item.url).hostname}`}
alt="Favicon"
className="w-8 h-8 sm:w-12 sm:h-12 flex-shrink-0 rounded-sm"
/>
<div className="flex-grow min-w-0">
<h3 className="text-sm font-semibold line-clamp-2 text-neutral-800 dark:text-neutral-200">{item.title}</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 line-clamp-2 mt-1">{item.content}</p>
</div>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary truncate hover:underline"
>
{item.url}
</a>
</div>
))}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
{result && result.images && result.images.length > 0 && (
<div className="mt-4">
<div className='flex items-center gap-2 cursor-pointer mb-2'>
<ImageIcon className="h-5 w-5 text-primary" />
<h3 className="text-base font-semibold text-neutral-800 dark:text-neutral-200">Images</h3>
</div>
<div className="grid grid-cols-4 gap-2">
{result.images.slice(0, 4).map((image: SearchImage, itemIndex: number) => (
<div
key={itemIndex}
className="relative aspect-square cursor-pointer overflow-hidden rounded-md"
onClick={() => handleImageClick(itemIndex)}
>
<img
src={image.url}
alt={image.description}
className="w-full h-full object-cover transition-transform duration-300 hover:scale-110"
/>
{itemIndex === 3 && result.images.length > 4 && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
<PlusCircledIcon className="w-8 h-8 text-white" />
</div>
)}
</div>
))}
</div>
</div>
)}
{openDialog && result.images && (
<ImageCarousel
images={result.images}
onClose={handleCloseDialog}
/>
)}
</div>
);
};
const HomeContent = () => {
const searchParams = useSearchParams();
const initialQuery = searchParams.get('query') || '';
@ -312,223 +444,6 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
// Google Maps components
const isValidCoordinate = (coord: number) => {
return typeof coord === 'number' && !isNaN(coord) && isFinite(coord);
};
const loadGoogleMapsScript = (callback: () => void) => {
if (window.google && window.google.maps) {
callback();
return;
}
const existingScript = document.getElementById('googleMapsScript');
if (existingScript) {
existingScript.remove();
}
window.initMap = callback;
const script = document.createElement('script');
script.id = 'googleMapsScript';
script.src = `https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&libraries=places,marker&callback=initMap`;
script.async = true;
script.defer = true;
document.head.appendChild(script);
};
const MapComponent = React.memo(({ center, places }: { center: { lat: number; lng: number }, places: any[] }) => {
const mapRef = useRef<HTMLDivElement>(null);
const [mapError, setMapError] = useState<string | null>(null);
const googleMapRef = useRef<google.maps.Map | null>(null);
const markersRef = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
const memoizedCenter = useMemo(() => center, [center]);
const memoizedPlaces = useMemo(() => places, [places]);
const initializeMap = useCallback(async () => {
if (mapRef.current && isValidCoordinate(memoizedCenter.lat) && isValidCoordinate(memoizedCenter.lng)) {
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
if (!googleMapRef.current) {
googleMapRef.current = new Map(mapRef.current, {
center: memoizedCenter,
zoom: 14,
mapId: "347ff92e0c7225cf",
});
} else {
googleMapRef.current.setCenter(memoizedCenter);
}
// Clear existing markers
markersRef.current.forEach(marker => marker.map = null);
markersRef.current = [];
memoizedPlaces.forEach((place) => {
if (isValidCoordinate(place.location.lat) && isValidCoordinate(place.location.lng)) {
const marker = new AdvancedMarkerElement({
map: googleMapRef.current,
position: place.location,
title: place.name,
});
markersRef.current.push(marker);
}
});
} else {
setMapError('Invalid coordinates provided');
}
}, [memoizedCenter, memoizedPlaces]);
useEffect(() => {
loadGoogleMapsScript(() => {
try {
initializeMap();
} catch (error) {
console.error('Error initializing map:', error);
setMapError('Failed to initialize Google Maps');
}
});
return () => {
// Clean up markers when component unmounts
markersRef.current.forEach(marker => marker.map = null);
};
}, [initializeMap]);
if (mapError) {
return <div className="h-64 flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200">{mapError}</div>;
}
return <div ref={mapRef} className="w-full h-64" />;
});
MapComponent.displayName = 'MapComponent';
const MapSkeleton = () => (
<Skeleton className="w-full h-64 bg-neutral-200 dark:bg-neutral-700" />
);
const PlaceDetails = ({ place }: { place: any }) => (
<div className="flex justify-between items-start py-2">
<div>
<h4 className="font-semibold text-neutral-800 dark:text-neutral-200">{place.name}</h4>
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-[200px]" title={place.vicinity}>
{place.vicinity}
</p>
</div>
{place.rating && (
<Badge variant="secondary" className="flex items-center bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200">
<Star className="h-3 w-3 mr-1 text-yellow-400" />
{place.rating} ({place.user_ratings_total})
</Badge>
)}
</div>
);
const MapEmbed = memo(({ location, zoom = 15 }: { location: string, zoom?: number }) => {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
const mapUrl = `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(location)}&zoom=${zoom}`;
return (
<div className="aspect-video w-full">
<iframe
width="100%"
height="100%"
style={{ border: 0 }}
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
src={mapUrl}
className='rounded-xl'
></iframe>
</div>
);
});
MapEmbed.displayName = 'MapEmbed';
const FindPlaceResult = memo(({ result }: { result: any }) => {
const place = result.candidates[0];
const location = `${place.geometry.location.lat},${place.geometry.location.lng}`;
return (
<Card className="w-full my-4 overflow-hidden shadow-none bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-neutral-800 dark:text-neutral-100">
<MapPin className="h-5 w-5 text-primary" />
<span>{place.name}</span>
</CardTitle>
</CardHeader>
<CardContent>
<MapEmbed location={location} />
<div className="mt-4 space-y-2 text-neutral-800 dark:text-neutral-200">
<p><strong>Address:</strong> {place.formatted_address}</p>
{place.rating && (
<div className="flex items-center">
<strong className="mr-2">Rating:</strong>
<Badge variant="secondary" className="flex items-center bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200">
<Star className="h-3 w-3 mr-1 text-yellow-400" />
{place.rating}
</Badge>
</div>
)}
{place.opening_hours && (
<p><strong>Open now:</strong> {place.opening_hours.open_now ? 'Yes' : 'No'}</p>
)}
</div>
</CardContent>
</Card>
);
});
FindPlaceResult.displayName = 'FindPlaceResult';
const TextSearchResult = memo(({ result }: { result: any }) => {
const centerLocation = result.results[0]?.geometry?.location;
const mapLocation = centerLocation ? `${centerLocation.lat},${centerLocation.lng}` : '';
return (
<Card className="w-full my-4 overflow-hidden shadow-none bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-neutral-800 dark:text-neutral-100">
<MapPin className="h-5 w-5 text-primary" />
<span>Text Search Results</span>
</CardTitle>
</CardHeader>
<CardContent>
{mapLocation && <MapEmbed location={mapLocation} zoom={13} />}
<Accordion type="single" collapsible className="w-full mt-4">
<AccordionItem value="place-details">
<AccordionTrigger className="text-neutral-800 dark:text-neutral-200">Place Details</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 max-h-64 overflow-y-auto">
{result.results.map((place: any, index: number) => (
<div key={index} className="flex justify-between items-start py-2 border-b border-neutral-200 dark:border-neutral-700 last:border-b-0">
<div>
<h4 className="font-semibold text-neutral-800 dark:text-neutral-200">{place.name}</h4>
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-[200px]" title={place.formatted_address}>
{place.formatted_address}
</p>
</div>
{place.rating && (
<Badge variant="secondary" className="flex items-center bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200">
<Star className="h-3 w-3 mr-1 text-yellow-400" />
{place.rating} ({place.user_ratings_total})
</Badge>
)}
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
);
});
TextSearchResult.displayName = 'TextSearchResult';
const TranslationTool: React.FC<{ toolInvocation: ToolInvocation; result: any }> = ({ toolInvocation, result }) => {
const [isPlaying, setIsPlaying] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
@ -644,145 +559,8 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
);
};
interface SearchImage {
url: string;
description: string;
}
const ImageCarousel = ({ images, onClose }: { images: SearchImage[], onClose: () => void }) => {
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[90vw] max-h-[90vh] p-0 bg-white dark:bg-neutral-900">
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10"
>
<X className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
<span className="sr-only">Close</span>
</button>
<Carousel className="w-full h-full">
<CarouselContent>
{images.map((image, index) => (
<CarouselItem key={index} className="flex flex-col items-center justify-center p-4">
<img
src={image.url}
alt={image.description}
className="max-w-full max-h-[70vh] object-contain mb-4"
/>
<p className="text-center text-sm text-neutral-800 dark:text-neutral-200">{image.description}</p>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-4" />
<CarouselNext className="right-4" />
</Carousel>
</DialogContent>
</Dialog>
);
};
const WebSearchResults = ({ result, args }: { result: any, args: any }) => {
const [openDialog, setOpenDialog] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const handleImageClick = (index: number) => {
setSelectedImageIndex(index);
setOpenDialog(true);
};
const handleCloseDialog = () => {
setOpenDialog(false);
};
return (
<div>
<Accordion type="single" collapsible className="w-full mt-4">
<AccordionItem value="item-1" className='border-none'>
<AccordionTrigger className="hover:no-underline py-2">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Newspaper className="h-5 w-5 text-primary" />
<h2 className='text-base font-semibold text-neutral-800 dark:text-neutral-200'>Sources Found</h2>
</div>
{result && (
<Badge variant="secondary" className='rounded-full bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200'>{result.results.length} results</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent>
{args?.query && (
<Badge variant="secondary" className="mb-4 text-xs sm:text-sm font-light rounded-full bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200">
<SearchIcon className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
{args.query}
</Badge>
)}
{result && (
<div className="flex flex-row gap-4 overflow-x-auto pb-2">
{result.results.map((item: any, itemIndex: number) => (
<div key={itemIndex} className="flex flex-col w-[280px] flex-shrink-0 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-3">
<div className="flex items-start gap-3 mb-2">
<img
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(item.url).hostname}`}
alt="Favicon"
className="w-8 h-8 sm:w-12 sm:h-12 flex-shrink-0 rounded-sm"
/>
<div className="flex-grow min-w-0">
<h3 className="text-sm font-semibold line-clamp-2 text-neutral-800 dark:text-neutral-200">{item.title}</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 line-clamp-2 mt-1">{item.content}</p>
</div>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary truncate hover:underline"
>
{item.url}
</a>
</div>
))}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
{result && result.images && result.images.length > 0 && (
<div className="mt-4">
<div className='flex items-center gap-2 cursor-pointer mb-2'>
<ImageIcon className="h-5 w-5 text-primary" />
<h3 className="text-base font-semibold text-neutral-800 dark:text-neutral-200">Images</h3>
</div>
<div className="grid grid-cols-4 gap-2">
{result.images.slice(0, 4).map((image: SearchImage, itemIndex: number) => (
<div
key={itemIndex}
className="relative aspect-square cursor-pointer overflow-hidden rounded-md"
onClick={() => handleImageClick(itemIndex)}
>
<img
src={image.url}
alt={image.description}
className="w-full h-full object-cover transition-transform duration-300 hover:scale-110"
/>
{itemIndex === 3 && result.images.length > 4 && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
<PlusCircledIcon className="w-8 h-8 text-white" />
</div>
)}
</div>
))}
</div>
</div>
)}
{openDialog && result.images && (
<ImageCarousel
images={result.images}
onClose={handleCloseDialog}
/>
)}
</div>
);
};
interface TableData {
title: string;
@ -859,88 +637,66 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
ResultsOverview.displayName = 'ResultsOverview';
const renderToolInvocation = (toolInvocation: ToolInvocation, index: number) => {
const renderToolInvocation = useCallback(
(toolInvocation: ToolInvocation, index: number) => {
const args = JSON.parse(JSON.stringify(toolInvocation.args));
const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null;
if (toolInvocation.toolName === 'nearby_search') {
if (!result) {
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-neutral-700 dark:text-neutral-300 animate-pulse" />
<span className="text-neutral-700 dark:text-neutral-300 text-lg">Searching nearby places...</span>
</div>
<motion.div className="flex space-x-1">
{[0, 1, 2].map((index) => (
<motion.div
key={index}
className="w-2 h-2 bg-neutral-400 dark:bg-neutral-600 rounded-full"
initial={{ opacity: 0.3 }}
animate={{ opacity: 1 }}
transition={{
repeat: Infinity,
duration: 0.8,
delay: index * 0.2,
repeatType: "reverse",
}}
/>
))}
</motion.div>
</div>
);
}
return (
<MapContainer
title={`Nearby ${args.type ? args.type.charAt(0).toUpperCase() + args.type.slice(1) + 's' : 'Places'}`}
center={result.center}
places={result.results}
loading={isLoading}
/>
);
}
if (toolInvocation.toolName === 'find_place') {
if (!result) {
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-neutral-700 dark:text-neutral-300 animate-pulse" />
<span className="text-neutral-700 dark:text-neutral-300 text-lg">Finding place...</span>
</div>
<motion.div className="flex space-x-1">
{[0, 1, 2].map((index) => (
<motion.div
key={index}
className="w-2 h-2 bg-neutral-400 dark:bg-neutral-600 rounded-full"
initial={{ opacity: 0.3 }}
animate={{ opacity: 1 }}
transition={{
repeat: Infinity,
duration: 0.8,
delay: index * 0.2,
repeatType: "reverse",
}}
/>
))}
</motion.div>
<div key={index}>
<MapSkeleton />
<p>Loading place information...</p>
</div>
);
}
const place = result.candidates[0];
const place = result.features[0];
if (!place) return null;
return (
<MapContainer
title={place.name}
center={place.geometry.location}
places={[{
<div key={index}>
<MapComponent
center={{
lat: place.geometry.coordinates[1],
lng: place.geometry.coordinates[0],
}}
places={[
{
name: place.name,
location: place.geometry.location,
rating: place.rating,
vicinity: place.formatted_address
}]}
location: {
lat: place.geometry.coordinates[1],
lng: place.geometry.coordinates[0],
},
vicinity: place.formatted_address,
},
]}
zoom={15}
/>
<PlaceDetails place={place} />
</div>
);
}
if (toolInvocation.toolName === 'nearby_search') {
if (!result) {
return (
<div key={index}>
<MapSkeleton />
<p>Loading nearby places...</p>
</div>
);
}
return (
<div key={index}>
<MapComponent
center={result.center}
places={result.results}
zoom={14}
/>
</div>
);
}
@ -948,7 +704,7 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
if (!result) {
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<MapPin className="h-5 w-5 text-neutral-700 dark:text-neutral-300 animate-pulse" />
<span className="text-neutral-700 dark:text-neutral-300 text-lg">Searching places...</span>
</div>
@ -1299,7 +1055,9 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
}
return null;
};
},
[ResultsOverview, theme]
);
interface MarkdownRendererProps {
content: string;
@ -1660,6 +1418,8 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
}, []);
const memoizedMessages = useMemo(() => messages, [messages]);
return (
<div className="flex flex-col font-sans items-center justify-center p-2 sm:p-4 bg-background text-foreground transition-all duration-500">
<Navbar />
@ -1712,7 +1472,7 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
<div className="space-y-4 sm:space-y-6 mb-32">
{messages.map((message, index) => (
{memoizedMessages.map((message, index) => (
<div key={index}>
{message.role === 'user' && (
<motion.div

View File

@ -1,10 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { MapPin, Star } from 'lucide-react';
import { Star } from 'lucide-react';
import { Skeleton } from "@/components/ui/skeleton";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || '';
@ -28,89 +26,83 @@ interface MapProps {
zoom?: number;
}
const MapComponent = React.memo(({ center, places = [], zoom = 14 }: MapProps) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const markers = useRef<mapboxgl.Marker[]>([]);
const [mapError, setMapError] = useState<string | null>(null);
const MapComponent = ({ center, places = [], zoom = 14 }: MapProps) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstance = useRef<mapboxgl.Map | null>(null);
const markersRef = useRef<mapboxgl.Marker[]>([]);
// Initialize the map only once
useEffect(() => {
if (!mapContainer.current) return;
if (!mapRef.current || mapInstance.current) return;
try {
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v12',
if (!mapboxgl.accessToken) {
console.error('Mapbox access token is not set');
return;
}
mapInstance.current = new mapboxgl.Map({
container: mapRef.current,
style: 'mapbox://styles/mapbox/standard',
center: [center.lng, center.lat],
zoom: zoom
zoom,
});
// Add navigation control
map.current.addControl(new mapboxgl.NavigationControl(), 'top-right');
// Clean up markers when component unmounts
return () => {
markers.current.forEach(marker => marker.remove());
map.current?.remove();
mapInstance.current?.remove();
mapInstance.current = null;
};
} catch (error) {
console.error('Error initializing map:', error);
setMapError('Failed to initialize map');
}
}, [center.lat, center.lng, zoom]);
// Update map center when 'center' prop changes
useEffect(() => {
if (!map.current) return;
// Update center when it changes
map.current.flyTo({
if (mapInstance.current) {
mapInstance.current.flyTo({
center: [center.lng, center.lat],
essential: true
zoom,
essential: true,
});
}
}, [center, zoom]);
// Clear existing markers
markers.current.forEach(marker => marker.remove());
markers.current = [];
// Update markers when 'places' prop changes
useEffect(() => {
if (!mapInstance.current) return;
// Remove existing markers
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
// Add new markers
places.forEach(place => {
const el = document.createElement('div');
el.className = 'marker';
el.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>';
el.style.color = 'hsl(var(--primary))';
el.style.width = '24px';
el.style.height = '24px';
el.style.cursor = 'pointer';
const marker = new mapboxgl.Marker(el)
places.forEach((place) => {
const marker = new mapboxgl.Marker()
.setLngLat([place.location.lng, place.location.lat])
.setPopup(
new mapboxgl.Popup({ offset: 25 })
.setHTML(
`<strong>${place.name}</strong>${place.rating ?
`<br>Rating: ${place.rating} ⭐ (${place.user_ratings_total} reviews)` :
''}`
new mapboxgl.Popup({ offset: 25 }).setText(
`${place.name}${place.vicinity ? `\n${place.vicinity}` : ''}`
)
)
.addTo(map.current!);
.addTo(mapInstance.current!);
markers.current.push(marker);
markersRef.current.push(marker);
});
}, [center, places]);
}, [places]);
if (mapError) {
return (
<div className="h-64 flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200">
{mapError}
<div className="w-full h-64 rounded-lg overflow-hidden shadow-lg">
<div ref={mapRef} className="w-full h-full" />
</div>
);
}
};
return <div ref={mapContainer} className="w-full h-64" />;
export default React.memo(MapComponent, (prevProps, nextProps) => {
return (
prevProps.center.lat === nextProps.center.lat &&
prevProps.center.lng === nextProps.center.lng &&
prevProps.zoom === nextProps.zoom &&
JSON.stringify(prevProps.places) === JSON.stringify(nextProps.places)
);
});
MapComponent.displayName = 'MapComponent';
const MapSkeleton = () => (
<Skeleton className="w-full h-64 bg-neutral-200 dark:bg-neutral-700" />
);
@ -118,15 +110,14 @@ const MapSkeleton = () => (
const PlaceDetails = ({ place }: { place: Place }) => (
<div className="flex justify-between items-start py-2">
<div>
<h4 className="font-semibold text-neutral-800 dark:text-neutral-200">{place.name}</h4>
{place.vicinity && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-[200px]" title={place.vicinity}>
{place.vicinity}
</p>
)}
<h2 className="text-lg font-semibold">{place.name}</h2>
{place.vicinity && <p className="text-sm text-gray-600">{place.vicinity}</p>}
</div>
{place.rating && (
<Badge variant="secondary" className="flex items-center bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200">
<Badge
variant="secondary"
className="flex items-center bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200"
>
<Star className="h-3 w-3 mr-1 text-yellow-400" />
{place.rating} ({place.user_ratings_total})
</Badge>
@ -141,48 +132,29 @@ interface MapContainerProps {
loading?: boolean;
}
const MapContainer: React.FC<MapContainerProps> = ({ title, center, places = [], loading = false }) => {
const MapContainer: React.FC<MapContainerProps> = ({
title,
center,
places = [],
loading = false,
}) => {
if (loading) {
return (
<Card className="w-full my-4 bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700">
<CardHeader>
<Skeleton className="h-6 w-3/4 bg-neutral-200 dark:bg-neutral-700" />
</CardHeader>
<CardContent className="p-0 rounded-t-none rounded-b-xl">
<div className="my-4">
<MapSkeleton />
</CardContent>
</Card>
<p>Loading map...</p>
</div>
);
}
return (
<Card className="w-full my-4 overflow-hidden bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-neutral-800 dark:text-neutral-100">
<MapPin className="h-5 w-5 text-primary" />
<span>{title}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="my-4">
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<MapComponent center={center} places={places} />
{places.length > 0 && (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="place-details">
<AccordionTrigger className="px-4 text-neutral-800 dark:text-neutral-200">
Place Details
</AccordionTrigger>
<AccordionContent>
<div className="px-4 space-y-4 max-h-64 overflow-y-auto">
{places.map((place, index) => (
<PlaceDetails key={index} place={place} />
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
);
};