feat: multi search
This commit is contained in:
parent
1c09e86fb7
commit
489e6b556b
@ -32,16 +32,6 @@ function sanitizeUrl(url: string): string {
|
||||
return url.replace(/\s+/g, '%20')
|
||||
}
|
||||
|
||||
// Helper function to geocode an address
|
||||
const geocodeAddress = async (address: string) => {
|
||||
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
|
||||
const response = await fetch(
|
||||
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${mapboxToken}`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.features[0];
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages, model } = await req.json();
|
||||
|
||||
@ -144,105 +134,89 @@ When asked a "What is" question, maintain the same format as the question and an
|
||||
- The response that include latex equations, use always follow the formats:
|
||||
- Do not wrap any equation or formulas or any sort of math related block in round brackets() as it will crash the response.`,
|
||||
tools: {
|
||||
// Update the web_search tool parameters in app/api/chat/route.ts
|
||||
web_search: tool({
|
||||
description:
|
||||
"Search the web for information with the given query, max results and search depth.",
|
||||
description: "Search the web for information with multiple queries, max results and search depth.",
|
||||
parameters: z.object({
|
||||
query: z.string().describe("The search query to look up on the web."),
|
||||
maxResults: z
|
||||
queries: z.array(z.string().describe("Array of search queries to look up on the web.")),
|
||||
maxResults: z.array(z
|
||||
.number()
|
||||
.describe(
|
||||
"The maximum number of results to return. Default to be used is 10.",
|
||||
),
|
||||
topic: z
|
||||
.describe("Array of maximum number of results to return per query. Default is 10.")),
|
||||
topic: z.array(z
|
||||
.enum(["general", "news"])
|
||||
.describe("The topic type to search for. Default is general."),
|
||||
searchDepth: z
|
||||
.describe("Array of topic types to search for. Default is general.")),
|
||||
searchDepth: z.array(z
|
||||
.enum(["basic", "advanced"])
|
||||
.describe(
|
||||
"The search depth to use for the search. Default is basic.",
|
||||
),
|
||||
.describe("Array of search depths to use. Default is basic.")),
|
||||
exclude_domains: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"A list of domains to specifically exclude from the search results. Default is None, which doesn't exclude any domains.",
|
||||
),
|
||||
.describe("A list of domains to exclude from all search results. Default is None."),
|
||||
}),
|
||||
execute: async ({
|
||||
query,
|
||||
queries,
|
||||
maxResults,
|
||||
topic,
|
||||
searchDepth,
|
||||
exclude_domains,
|
||||
}: {
|
||||
query: string;
|
||||
maxResults: number;
|
||||
topic: "general" | "news";
|
||||
searchDepth: "basic" | "advanced";
|
||||
queries: string[];
|
||||
maxResults: number[];
|
||||
topic: ("general" | "news")[];
|
||||
searchDepth: ("basic" | "advanced")[];
|
||||
exclude_domains?: string[];
|
||||
}) => {
|
||||
const apiKey = process.env.TAVILY_API_KEY;
|
||||
const tvly = tavily({ apiKey });
|
||||
const includeImageDescriptions = true
|
||||
const includeImageDescriptions = true;
|
||||
|
||||
|
||||
console.log("Query:", query);
|
||||
console.log("Queries:", queries);
|
||||
console.log("Max Results:", maxResults);
|
||||
console.log("Topic:", topic);
|
||||
console.log("Search Depth:", searchDepth);
|
||||
console.log("Topics:", topic);
|
||||
console.log("Search Depths:", searchDepth);
|
||||
console.log("Exclude Domains:", exclude_domains);
|
||||
|
||||
// Execute searches in parallel
|
||||
const searchPromises = queries.map(async (query, index) => {
|
||||
const data = await tvly.search(query, {
|
||||
topic: topic,
|
||||
days: topic === "news" ? 7 : undefined,
|
||||
maxResults: maxResults < 5 ? 5 : maxResults,
|
||||
searchDepth: searchDepth,
|
||||
topic: topic[index] || topic[0] || "general",
|
||||
days: topic[index] === "news" ? 7 : undefined,
|
||||
maxResults: maxResults[index] || maxResults[0] || 10,
|
||||
searchDepth: searchDepth[index] || searchDepth[0] || "basic",
|
||||
includeAnswer: true,
|
||||
includeImages: true,
|
||||
includeImageDescriptions: includeImageDescriptions,
|
||||
excludeDomains: exclude_domains,
|
||||
})
|
||||
});
|
||||
|
||||
let context = data.results.map(
|
||||
(obj: any, index: number) => {
|
||||
if (topic === "news") {
|
||||
return {
|
||||
query,
|
||||
results: data.results.map((obj: any) => ({
|
||||
url: obj.url,
|
||||
title: obj.title,
|
||||
content: obj.content,
|
||||
raw_content: obj.raw_content,
|
||||
published_date: obj.published_date,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: obj.url,
|
||||
title: obj.title,
|
||||
content: obj.content,
|
||||
raw_content: obj.raw_content,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
const processedImages = includeImageDescriptions
|
||||
published_date: topic[index] === "news" ? obj.published_date : undefined,
|
||||
})),
|
||||
images: includeImageDescriptions
|
||||
? data.images
|
||||
.map(({ url, description }: { url: string; description?: string }) => ({
|
||||
url: sanitizeUrl(url),
|
||||
description: description ?? ''
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
image: { url: string; description: string }
|
||||
): image is { url: string; description: string } =>
|
||||
(image: { url: string; description: string }): image is { url: string; description: string } =>
|
||||
typeof image === 'object' &&
|
||||
image.description !== undefined &&
|
||||
image.description !== ''
|
||||
)
|
||||
: data.images.map(({ url }: { url: string }) => sanitizeUrl(url))
|
||||
};
|
||||
});
|
||||
|
||||
const searchResults = await Promise.all(searchPromises);
|
||||
|
||||
return {
|
||||
results: context,
|
||||
images: processedImages
|
||||
searches: searchResults,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@ -106,6 +106,7 @@ import WeatherChart from '@/components/weather-chart';
|
||||
import InteractiveChart from '@/components/interactive-charts';
|
||||
import NearbySearchMapView from '@/components/nearby-search-map-view';
|
||||
import { MapComponent, MapContainer, MapSkeleton } from '@/components/map-components';
|
||||
import MultiSearch from '@/components/multi-search';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
@ -116,147 +117,6 @@ 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') || '';
|
||||
@ -357,31 +217,33 @@ const HomeContent = () => {
|
||||
const changelogs: Changelog[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Dark mode is here!",
|
||||
title: "New Updates!",
|
||||
images: [
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-dark-mode-promo.png",
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-new-input-bar-promo.png",
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-gpt-4o-back-Lwzx44RD4XofYLAmrEsLD3Fngnn33K.png"
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-maps-beta.png",
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-multi-run.png",
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-multi-results.png",
|
||||
"https://metwm7frkvew6tn1.public.blob.vercel-storage.com/mplx-changelogs/mplx-new-claude.png"
|
||||
],
|
||||
content:
|
||||
`## **Dark Mode**
|
||||
`## **Nearby Map Search Beta**
|
||||
|
||||
The most requested feature is finally here! You can now toggle between light and dark mode. Default is set to your system preference.
|
||||
The new Nearby Map Search tool is now available in beta! You can use it to find nearby places, restaurants, attractions, and more. Give it a try and let us know what you think!
|
||||
|
||||
## **New Input Bar Design**
|
||||
## **Multi Search is here by default**
|
||||
|
||||
The input bar has been redesigned to make it more focused, user-friendly and accessible. The model selection dropdown has been moved to the bottom left corner inside the input bar.
|
||||
The AI powered Multiple Query Search tool is now available by default. The LLM model will now automatically suggest multiple queries based on your input and run the searches in parallel.
|
||||
|
||||
## **GPT-4o is back!**
|
||||
## **Claude 3.5 Sonnet(New) and 3.5 Haiku are here!**
|
||||
|
||||
GPT-4o has been re-enabled! You can use it by selecting the model from the dropdown.`,
|
||||
The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now available on the platform.
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
const ChangeLogs: React.FC<{ open: boolean; setOpen: (open: boolean) => void }> = ({ open, setOpen }) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="no-scrollbar max-h-[80vh] overflow-y-auto rounded-xl border-none p-0 gap-0 font-sans bg-white dark:bg-neutral-900">
|
||||
<DialogContent className="no-scrollbar max-h-[80vh] overflow-y-auto rounded-xl border-none p-0 gap-0 font-sans bg-white dark:bg-neutral-900 z-[1000]">
|
||||
<div className="w-full py-3 flex justify-center items-center border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2 text-neutral-800 dark:text-neutral-100">
|
||||
<Flame size={20} /> What's new
|
||||
@ -958,33 +820,8 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
|
||||
|
||||
if (toolInvocation.toolName === 'web_search') {
|
||||
return (
|
||||
<div>
|
||||
{!result ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe className="h-5 w-5 text-neutral-700 dark:text-neutral-300 animate-spin" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300 text-lg">Running a search...</span>
|
||||
</div>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WebSearchResults result={result} args={args} />
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<MultiSearch result={result} args={args} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
404
components/multi-search.tsx
Normal file
404
components/multi-search.tsx
Normal file
@ -0,0 +1,404 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowUpRight, Calendar, ChevronLeft, ChevronRight, Clock, Globe, ImageIcon, Newspaper, Search, X } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
type SearchImage = {
|
||||
url: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type SearchResult = {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
raw_content: string;
|
||||
published_date?: string;
|
||||
};
|
||||
|
||||
type SearchQueryResult = {
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
images: SearchImage[];
|
||||
};
|
||||
|
||||
type MultiSearchResponse = {
|
||||
searches: SearchQueryResult[];
|
||||
};
|
||||
|
||||
type MultiSearchArgs = {
|
||||
queries: string[];
|
||||
maxResults: number[];
|
||||
topic: ("general" | "news")[];
|
||||
searchDepth: ("basic" | "advanced")[];
|
||||
};
|
||||
|
||||
interface ResultCardProps {
|
||||
result: SearchResult;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
images: SearchImage[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SearchResultsProps {
|
||||
searchData: SearchQueryResult;
|
||||
topicType: string;
|
||||
onImageClick: (index: number) => void;
|
||||
}
|
||||
|
||||
const SearchQueryTab: React.FC<{ query: string; count: number; isActive: boolean }> = ({ query, count, isActive }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="text-sm font-medium truncate max-w-[120px]">{query}</span>
|
||||
<Badge variant="secondary" className={isActive ? 'dark:bg-white/20 dark:text-white bg-gray-200 text-gray-700' : 'dark:bg-neutral-800 bg-gray-100'}>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ResultCard: React.FC<ResultCardProps> = ({ result, index }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
className="relative flex flex-col w-[280px] dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="w-8 h-8 rounded-lg dark:bg-neutral-800 bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
||||
alt=""
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium dark:text-neutral-100 text-gray-900 line-clamp-2 leading-tight">
|
||||
{result.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs dark:text-neutral-400 text-gray-500 hover:text-gray-700 dark:hover:text-neutral-300 flex items-center gap-1"
|
||||
>
|
||||
{new URL(result.url).hostname}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-sm dark:text-neutral-300 text-gray-600 line-clamp-3 leading-relaxed">
|
||||
{result.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{result.published_date && (
|
||||
<div className="px-3 py-2 mt-auto border-t dark:border-neutral-800 border-gray-200 dark:bg-neutral-900/50 bg-gray-50">
|
||||
<time className="text-xs dark:text-neutral-500 text-gray-500 flex items-center gap-1.5">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(result.published_date).toLocaleDateString()}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageGrid: React.FC<{ images: SearchImage[]; onImageClick: (index: number) => void }> = ({ images, onImageClick }) => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-4">
|
||||
{images.slice(0, 4).map((image, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative aspect-square rounded-xl overflow-hidden cursor-pointer group"
|
||||
onClick={() => onImageClick(index)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.description}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<p className="absolute bottom-2 left-2 right-2 text-xs text-white line-clamp-2">
|
||||
{image.description}
|
||||
</p>
|
||||
</div>
|
||||
{index === 3 && images.length > 4 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<span className="text-xl font-medium text-white">+{images.length - 4}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ searchData, topicType, onImageClick }) => (
|
||||
<div className="space-y-6">
|
||||
<div className="dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border">
|
||||
<div className="p-3 border-b dark:border-neutral-800 border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg dark:bg-neutral-800 bg-gray-100">
|
||||
<Newspaper className="h-4 w-4 dark:text-neutral-400 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="dark:text-neutral-100 text-gray-900 font-medium">Results for “{searchData.query}“</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="dark:bg-neutral-800 bg-gray-100 dark:text-neutral-300 text-gray-600">
|
||||
{topicType}
|
||||
</Badge>
|
||||
<span className="text-xs dark:text-neutral-500 text-gray-500">{searchData.results.length} results</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea>
|
||||
<div className="flex gap-3 p-3 overflow-y-scroll">
|
||||
{searchData.results.map((result, index) => (
|
||||
<ResultCard key={index} result={result} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
|
||||
{searchData.images.length > 0 && (
|
||||
<div className="dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border">
|
||||
<div className="p-4 border-b dark:border-neutral-800 border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageIcon className="h-5 w-5 dark:text-neutral-400 text-gray-500" />
|
||||
<h3 className="dark:text-neutral-100 text-gray-900 font-medium">Related Images</h3>
|
||||
</div>
|
||||
</div>
|
||||
<ImageGrid images={searchData.images} onImageClick={onImageClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ContentDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
result: SearchResult;
|
||||
}
|
||||
|
||||
const ContentDialog: React.FC<ContentDialogProps> = ({ isOpen, onClose, result }) => (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl h-fit p-0 dark:bg-neutral-900 bg-white dark:border-neutral-800 border-gray-200">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg dark:bg-neutral-800 bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
||||
alt=""
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-semibold dark:text-neutral-100 text-gray-900">
|
||||
{result.title}
|
||||
</h2>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 text-sm dark:text-neutral-400 text-gray-500 hover:text-gray-700 dark:hover:text-neutral-300 flex items-center gap-1 w-fit"
|
||||
>
|
||||
{new URL(result.url).hostname}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[60vh] w-full pr-4">
|
||||
<div className="space-y-4">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<p className="dark:text-neutral-200 text-gray-700 leading-relaxed whitespace-pre-wrap">
|
||||
{result.content}
|
||||
</p>
|
||||
</div>
|
||||
{result.published_date && (
|
||||
<div className="flex items-center gap-2 text-sm dark:text-neutral-500 text-gray-500 pt-4 border-t dark:border-neutral-800 border-gray-200">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<time>
|
||||
Published on {new Date(result.published_date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
|
||||
const MultiSearch: React.FC<{ result: MultiSearchResponse | null; args: MultiSearchArgs }> = ({ result, args }) => {
|
||||
const [activeTab, setActiveTab] = useState("0");
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const [selectedSearch, setSelectedSearch] = useState(0);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
// Replace the current loading state in MultiSearch component with this:
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg dark:bg-neutral-800 bg-gray-100">
|
||||
<Globe className="h-5 w-5 dark:text-neutral-400 text-gray-500 animate-spin" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="dark:text-neutral-100 text-gray-900 font-medium">
|
||||
Running searches...
|
||||
</span>
|
||||
<span className="text-sm dark:text-neutral-500 text-gray-500">
|
||||
Processing {args.queries.length} queries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 dark:text-neutral-400 text-gray-500" />
|
||||
<div className="flex gap-1">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="w-2 h-2 rounded-full dark:bg-neutral-700 bg-gray-300"
|
||||
initial={{ opacity: 0.3 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 0.8,
|
||||
delay: index * 0.2,
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<div className="relative w-full">
|
||||
<div className="w-full overflow-y-scroll">
|
||||
<TabsList className="inline-flex h-auto gap-2 dark:bg-neutral-900 bg-white p-2 rounded-xl dark:border-neutral-800 border-gray-200 border">
|
||||
{result.searches.map((search, index) => (
|
||||
<TabsTrigger
|
||||
key={index}
|
||||
value={index.toString()}
|
||||
className="flex-shrink-0 px-3 py-2 rounded-lg transition-all data-[state=active]:dark:bg-neutral-800 data-[state=active]:bg-gray-200 data-[state=active]:text-gray-900 data-[state=active]:dark:text-white text-gray-400 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200"
|
||||
>
|
||||
<SearchQueryTab
|
||||
query={search.query}
|
||||
count={search.results.length}
|
||||
isActive={activeTab === index.toString()}
|
||||
/>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{result.searches.map((search, index) => (
|
||||
<TabsContent key={index} value={index.toString()}>
|
||||
<SearchResults
|
||||
searchData={search}
|
||||
topicType={args.topic[index] || args.topic[0]}
|
||||
onImageClick={(imageIndex) => {
|
||||
setSelectedSearch(index);
|
||||
setSelectedImage(imageIndex);
|
||||
setGalleryOpen(true);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Tabs>
|
||||
|
||||
{galleryOpen && result.searches[selectedSearch].images && (
|
||||
<Dialog open={galleryOpen} onOpenChange={setGalleryOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] p-0 dark:bg-neutral-900 bg-white dark:border-neutral-800 border-gray-200">
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute right-3 top-3 z-50 flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-md dark:bg-neutral-800 bg-gray-100 text-xs dark:text-neutral-300 text-gray-600">
|
||||
{selectedImage + 1} / {result.searches[selectedSearch].images.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setGalleryOpen(false)}
|
||||
className="p-1.5 rounded-md dark:bg-neutral-800 bg-gray-100 dark:text-neutral-400 text-gray-500 hover:text-gray-700 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={result.searches[selectedSearch].images[selectedImage].url}
|
||||
alt={result.searches[selectedSearch].images[selectedImage].description}
|
||||
className="max-h-[70vh] object-contain rounded-md mx-auto p-4"
|
||||
/>
|
||||
{result.searches[selectedSearch].images[selectedImage].description && (
|
||||
<div className="absolute inset-x-0 bottom-4 mx-4 p-2 bg-gradient-to-t from-black/80 to-transparent rounded-b-md">
|
||||
<p className="text-xs text-white/90 text-center">
|
||||
{result.searches[selectedSearch].images[selectedImage].description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 left-0 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 ml-2 dark:bg-neutral-800 bg-gray-100 dark:hover:bg-neutral-700 hover:bg-gray-200"
|
||||
onClick={() => setSelectedImage(prev =>
|
||||
prev === 0 ? result.searches[selectedSearch].images.length - 1 : prev - 1
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 mr-2 dark:bg-neutral-800 bg-gray-100 dark:hover:bg-neutral-700 hover:bg-gray-200"
|
||||
onClick={() => setSelectedImage(prev =>
|
||||
prev === result.searches[selectedSearch].images.length - 1 ? 0 : prev + 1
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSearch;
|
||||
@ -1,4 +1,5 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
// /components/ui/form-component.tsx
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||
@ -431,7 +432,7 @@ const FormComponent: React.FC<FormComponentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative w-full flex flex-col gap-2 rounded-lg transition-all duration-300 z-[9999]",
|
||||
"relative w-full flex flex-col gap-2 rounded-lg transition-all duration-300 z-[99]",
|
||||
attachments.length > 0 || uploadQueue.length > 0
|
||||
? "bg-gray-100/70 dark:bg-neutral-800 p-1"
|
||||
: "bg-transparent"
|
||||
|
||||
@ -11,11 +11,12 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^0.0.55",
|
||||
"@ai-sdk/azure": "^0.0.51",
|
||||
"@ai-sdk/cohere": "latest",
|
||||
"@ai-sdk/cohere": "^1.0.3",
|
||||
"@ai-sdk/google": "^0.0.55",
|
||||
"@ai-sdk/groq": "^0.0.1",
|
||||
"@ai-sdk/mistral": "^0.0.41",
|
||||
"@ai-sdk/openai": "^0.0.58",
|
||||
"@ai-sdk/xai": "^1.0.3",
|
||||
"@e2b/code-interpreter": "^1.0.3",
|
||||
"@foobar404/wave": "^2.0.5",
|
||||
"@mendable/firecrawl-js": "^1.4.3",
|
||||
|
||||
@ -12,8 +12,8 @@ dependencies:
|
||||
specifier: ^0.0.51
|
||||
version: 0.0.51(zod@3.23.8)
|
||||
'@ai-sdk/cohere':
|
||||
specifier: latest
|
||||
version: 1.0.0(zod@3.23.8)
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(zod@3.23.8)
|
||||
'@ai-sdk/google':
|
||||
specifier: ^0.0.55
|
||||
version: 0.0.55(zod@3.23.8)
|
||||
@ -26,6 +26,9 @@ dependencies:
|
||||
'@ai-sdk/openai':
|
||||
specifier: ^0.0.58
|
||||
version: 0.0.58(zod@3.23.8)
|
||||
'@ai-sdk/xai':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(zod@3.23.8)
|
||||
'@e2b/code-interpreter':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
@ -279,14 +282,14 @@ packages:
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/cohere@1.0.0(zod@3.23.8):
|
||||
resolution: {integrity: sha512-iN2Ww2VeRnprQBJ7dCp65DtdzCY/53+CA3UmM7Rhn8IZCTqRHkVoZebzv3ZOTb9pikO4CUWqV9yh1oUhQDgyow==}
|
||||
/@ai-sdk/cohere@1.0.3(zod@3.23.8):
|
||||
resolution: {integrity: sha512-SDjPinUcGzTNiSMN+9zs1fuAcP8rU1/+CmDWAGu7eMhwVGDurgiOqscC0Oqs/aLsodLt/sFeOvyqj86DAknpbg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.0
|
||||
'@ai-sdk/provider-utils': 2.0.0(zod@3.23.8)
|
||||
'@ai-sdk/provider': 1.0.1
|
||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
|
||||
@ -425,8 +428,8 @@ packages:
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/provider-utils@2.0.0(zod@3.23.8):
|
||||
resolution: {integrity: sha512-uITgVJByhtzuQU2ZW+2CidWRmQqTUTp6KADevy+4aRnmILZxY2LCt+UZ/ZtjJqq0MffwkuQPPY21ExmFAQ6kKA==}
|
||||
/@ai-sdk/provider-utils@2.0.2(zod@3.23.8):
|
||||
resolution: {integrity: sha512-IAvhKhdlXqiSmvx/D4uNlFYCl8dWT+M9K+IuEcSgnE2Aj27GWu8sDIpAf4r4Voc+wOUkOECVKQhFo8g9pozdjA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
@ -434,9 +437,9 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.0
|
||||
'@ai-sdk/provider': 1.0.1
|
||||
eventsource-parser: 3.0.0
|
||||
nanoid: 5.0.8
|
||||
nanoid: 3.3.7
|
||||
secure-json-parse: 2.7.0
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
@ -469,8 +472,8 @@ packages:
|
||||
json-schema: 0.4.0
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/provider@1.0.0:
|
||||
resolution: {integrity: sha512-Sj29AzooJ7SYvhPd+AAWt/E7j63E9+AzRnoMHUaJPRYzOd/WDrVNxxv85prF9gDcQ7XPVlSk9j6oAZV9/DXYpA==}
|
||||
/@ai-sdk/provider@1.0.1:
|
||||
resolution: {integrity: sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg==}
|
||||
engines: {node: '>=18'}
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
@ -562,6 +565,17 @@ packages:
|
||||
- zod
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/xai@1.0.3(zod@3.23.8):
|
||||
resolution: {integrity: sha512-Z3ovBU21Wp87EPwkLoP0K4SNkyIzwQk+YAFuBPnRLCSVtBESeMarcI5zDVvBJ0lmQalRX1ZBAs8U1FvQ4T9mqw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.1
|
||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
|
||||
/@alloc/quick-lru@5.2.0:
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@ -5251,12 +5265,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
/nanoid@5.0.8:
|
||||
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
dev: true
|
||||
|
||||
@ -88,11 +88,6 @@ const config = {
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
safelist: [
|
||||
{
|
||||
pattern: /katex-.*/,
|
||||
},
|
||||
],
|
||||
} satisfies Config
|
||||
|
||||
export default config
|
||||
Loading…
Reference in New Issue
Block a user