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') return url.replace(/\s+/g, '%20')
} }
type SearchResultImage =
| string
| {
url: string
description: string
number_of_results?: number
}
// Helper function to geocode an address // Helper function to geocode an address
const geocodeAddress = async (address: string) => { const geocodeAddress = async (address: string) => {
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN; const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
@ -61,7 +53,7 @@ export async function POST(req: Request) {
topP: 0.5, topP: 0.5,
frequencyPenalty: 0, frequencyPenalty: 0,
presencePenalty: 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: ` system: `
You are an expert AI web search engine called MiniPerplx, that helps users find information on the internet with no bullshit talks. 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!! 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({ find_place: tool({
description: "Find a specific place using Mapbox API.", description: "Find a place using Mapbox v6 reverse geocoding API.",
parameters: z.object({ parameters: z.object({
input: z.string().describe("The place to search for (e.g., 'Museum of Contemporary Art Australia')."), latitude: z.number().describe("The latitude of the location."),
inputtype: z.enum(["textquery", "phonenumber"]).describe("The type of input (textquery or phonenumber)."), longitude: z.number().describe("The longitude of the location."),
}), }),
execute: async ({ input, inputtype }: { execute: async ({ latitude, longitude }: { latitude: number; longitude: number }) => {
input: string;
inputtype: "textquery" | "phonenumber";
}) => {
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN; const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
const response = await fetch(
let searchEndpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(input)}.json`; `https://api.mapbox.com/search/geocode/v6/reverse?longitude=${longitude}&latitude=${latitude}&access_token=${mapboxToken}`
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 data = await response.json(); const data = await response.json();
if (!data.features || data.features.length === 0) { if (!data.features || data.features.length === 0) {
return { candidates: [] }; return { features: [] };
} }
const place = data.features[0];
return { return {
candidates: [{ features: data.features.map((feature: any) => ({
name: place.text, name: feature.properties.name_preferred || feature.properties.name,
formatted_address: place.place_name, formatted_address: feature.properties.full_address,
geometry: { geometry: feature.geometry,
location: { })),
lat: place.center[1],
lng: place.center[0]
}
},
// Note: Mapbox doesn't provide these fields
rating: null,
opening_hours: null
}]
}; };
}, },
}), }),

View File

@ -10,7 +10,6 @@ React,
useState, useState,
useEffect, useEffect,
useMemo, useMemo,
memo,
Suspense Suspense
} from 'react'; } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -95,7 +94,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { GitHubLogoIcon, PlusCircledIcon } from '@radix-ui/react-icons'; import { GitHubLogoIcon, PlusCircledIcon } from '@radix-ui/react-icons';
import { Skeleton } from '@/components/ui/skeleton';
import Link from 'next/link'; import Link from 'next/link';
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
@ -109,18 +107,11 @@ import {
import Autoplay from 'embla-carousel-autoplay'; import Autoplay from 'embla-carousel-autoplay';
import FormComponent from '@/components/ui/form-component'; import FormComponent from '@/components/ui/form-component';
import WeatherChart from '@/components/weather-chart'; 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'; import InteractiveChart from '@/components/interactive-charts';
export const maxDuration = 60; export const maxDuration = 60;
declare global {
interface Window {
google: any;
initMap: () => void;
}
}
interface Attachment { interface Attachment {
name: string; name: string;
contentType: string; contentType: string;
@ -128,6 +119,147 @@ interface Attachment {
size: number; 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 HomeContent = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const initialQuery = searchParams.get('query') || ''; 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 // 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 TranslationTool: React.FC<{ toolInvocation: ToolInvocation; result: any }> = ({ toolInvocation, result }) => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null); 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 { interface TableData {
title: string; 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'; 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 args = JSON.parse(JSON.stringify(toolInvocation.args));
const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null; 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 (toolInvocation.toolName === 'find_place') {
if (!result) { if (!result) {
return ( return (
<div className="flex items-center justify-between w-full"> <div key={index}>
<div className="flex items-center gap-2"> <MapSkeleton />
<MapPin className="h-5 w-5 text-neutral-700 dark:text-neutral-300 animate-pulse" /> <p>Loading place information...</p>
<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> </div>
); );
} }
const place = result.candidates[0]; const place = result.features[0];
if (!place) return null;
return ( return (
<MapContainer <div key={index}>
title={place.name} <MapComponent
center={place.geometry.location} center={{
places={[{ lat: place.geometry.coordinates[1],
lng: place.geometry.coordinates[0],
}}
places={[
{
name: place.name, name: place.name,
location: place.geometry.location, location: {
rating: place.rating, lat: place.geometry.coordinates[1],
vicinity: place.formatted_address 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) { if (!result) {
return ( return (
<div className="flex items-center justify-between w-full"> <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" /> <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> <span className="text-neutral-700 dark:text-neutral-300 text-lg">Searching places...</span>
</div> </div>
@ -1299,7 +1055,9 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
} }
return null; return null;
}; },
[ResultsOverview, theme]
);
interface MarkdownRendererProps { interface MarkdownRendererProps {
content: string; 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 ( 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"> <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 /> <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"> <div className="space-y-4 sm:space-y-6 mb-32">
{messages.map((message, index) => ( {memoizedMessages.map((message, index) => (
<div key={index}> <div key={index}>
{message.role === 'user' && ( {message.role === 'user' && (
<motion.div <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 mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css'; 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 { Badge } from "@/components/ui/badge";
import { MapPin, Star } from 'lucide-react'; import { Star } from 'lucide-react';
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ''; mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || '';
@ -28,89 +26,83 @@ interface MapProps {
zoom?: number; zoom?: number;
} }
const MapComponent = React.memo(({ center, places = [], zoom = 14 }: MapProps) => { const MapComponent = ({ center, places = [], zoom = 14 }: MapProps) => {
const mapContainer = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null); const mapInstance = useRef<mapboxgl.Map | null>(null);
const markers = useRef<mapboxgl.Marker[]>([]); const markersRef = useRef<mapboxgl.Marker[]>([]);
const [mapError, setMapError] = useState<string | null>(null);
// Initialize the map only once
useEffect(() => { useEffect(() => {
if (!mapContainer.current) return; if (!mapRef.current || mapInstance.current) return;
try { if (!mapboxgl.accessToken) {
map.current = new mapboxgl.Map({ console.error('Mapbox access token is not set');
container: mapContainer.current, return;
style: 'mapbox://styles/mapbox/streets-v12', }
mapInstance.current = new mapboxgl.Map({
container: mapRef.current,
style: 'mapbox://styles/mapbox/standard',
center: [center.lng, center.lat], 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 () => { return () => {
markers.current.forEach(marker => marker.remove()); mapInstance.current?.remove();
map.current?.remove(); mapInstance.current = null;
}; };
} catch (error) {
console.error('Error initializing map:', error);
setMapError('Failed to initialize map');
}
}, [center.lat, center.lng, zoom]); }, [center.lat, center.lng, zoom]);
// Update map center when 'center' prop changes
useEffect(() => { useEffect(() => {
if (!map.current) return; if (mapInstance.current) {
mapInstance.current.flyTo({
// Update center when it changes
map.current.flyTo({
center: [center.lng, center.lat], center: [center.lng, center.lat],
essential: true zoom,
essential: true,
}); });
}
}, [center, zoom]);
// Clear existing markers // Update markers when 'places' prop changes
markers.current.forEach(marker => marker.remove()); useEffect(() => {
markers.current = []; if (!mapInstance.current) return;
// Remove existing markers
markersRef.current.forEach((marker) => marker.remove());
markersRef.current = [];
// Add new markers // Add new markers
places.forEach(place => { places.forEach((place) => {
const el = document.createElement('div'); const marker = new mapboxgl.Marker()
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)
.setLngLat([place.location.lng, place.location.lat]) .setLngLat([place.location.lng, place.location.lat])
.setPopup( .setPopup(
new mapboxgl.Popup({ offset: 25 }) new mapboxgl.Popup({ offset: 25 }).setText(
.setHTML( `${place.name}${place.vicinity ? `\n${place.vicinity}` : ''}`
`<strong>${place.name}</strong>${place.rating ?
`<br>Rating: ${place.rating} ⭐ (${place.user_ratings_total} reviews)` :
''}`
) )
) )
.addTo(map.current!); .addTo(mapInstance.current!);
markers.current.push(marker); markersRef.current.push(marker);
}); });
}, [center, places]); }, [places]);
if (mapError) {
return ( return (
<div className="h-64 flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200"> <div className="w-full h-64 rounded-lg overflow-hidden shadow-lg">
{mapError} <div ref={mapRef} className="w-full h-full" />
</div> </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 = () => ( const MapSkeleton = () => (
<Skeleton className="w-full h-64 bg-neutral-200 dark:bg-neutral-700" /> <Skeleton className="w-full h-64 bg-neutral-200 dark:bg-neutral-700" />
); );
@ -118,15 +110,14 @@ const MapSkeleton = () => (
const PlaceDetails = ({ place }: { place: Place }) => ( const PlaceDetails = ({ place }: { place: Place }) => (
<div className="flex justify-between items-start py-2"> <div className="flex justify-between items-start py-2">
<div> <div>
<h4 className="font-semibold text-neutral-800 dark:text-neutral-200">{place.name}</h4> <h2 className="text-lg font-semibold">{place.name}</h2>
{place.vicinity && ( {place.vicinity && <p className="text-sm text-gray-600">{place.vicinity}</p>}
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-[200px]" title={place.vicinity}>
{place.vicinity}
</p>
)}
</div> </div>
{place.rating && ( {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" /> <Star className="h-3 w-3 mr-1 text-yellow-400" />
{place.rating} ({place.user_ratings_total}) {place.rating} ({place.user_ratings_total})
</Badge> </Badge>
@ -141,48 +132,29 @@ interface MapContainerProps {
loading?: boolean; loading?: boolean;
} }
const MapContainer: React.FC<MapContainerProps> = ({ title, center, places = [], loading = false }) => { const MapContainer: React.FC<MapContainerProps> = ({
title,
center,
places = [],
loading = false,
}) => {
if (loading) { if (loading) {
return ( return (
<Card className="w-full my-4 bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700"> <div className="my-4">
<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">
<MapSkeleton /> <MapSkeleton />
</CardContent> <p>Loading map...</p>
</Card> </div>
); );
} }
return ( return (
<Card className="w-full my-4 overflow-hidden bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700"> <div className="my-4">
<CardHeader> <h2 className="text-xl font-semibold mb-2">{title}</h2>
<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">
<MapComponent center={center} places={places} /> <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) => ( {places.map((place, index) => (
<PlaceDetails key={index} place={place} /> <PlaceDetails key={index} place={place} />
))} ))}
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
); );
}; };