"use client"; import { ToolUIPart } from "ai"; import { ExaSearchResponse } from "lib/ai/tools/web/web-search"; import equal from "lib/equal"; import { notify } from "lib/notify"; import { toAny, truncateString } from "lib/utils"; import { motion } from "framer-motion"; import { AlertTriangleIcon, CheckIcon, LoaderIcon, SearchIcon, } from "lucide-react"; import { useTranslations } from "next-intl"; import { memo, useMemo, useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import { Skeleton } from "ui/skeleton"; import { TextShimmer } from "ui/text-shimmer"; import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; interface WebSearchToolInvocationProps { part: ToolUIPart; } function PureWebSearchToolInvocation({ part }: WebSearchToolInvocationProps) { const t = useTranslations(); const result = useMemo(() => { if (!part.state.startsWith("output")) return null; return part.output as ExaSearchResponse; }, [part.state]); const [errorSrc, setErrorSrc] = useState([]); const query = useMemo(() => { return (part.input as { query?: string })?.query; }, [part.input]); const onError = (e: React.SyntheticEvent) => { const target = e.currentTarget; if (errorSrc.includes(target.src)) return; setErrorSrc([...errorSrc, target.src]); }; const images = useMemo(() => { return ( result?.results ?.filter((r) => r.image && !errorSrc.includes(r.image)) .map((r) => ({ url: r.image!, description: r.title })) ?? [] ); }, [result?.results, errorSrc]); if (!part.state.startsWith("output")) return (
{query?.length ? `"${truncateString(query, 30)}" ${t("Chat.Tool.webSearching")}` : t("Chat.Tool.webSearching")}
{/* Browser Header Simulation */}
{/* Search Results Simulation */}
{Array.from({ length: 10 }).map((_, i) => (
))}
); return (
{query?.length ? `"${truncateString(query, 30)}" ${t("Chat.Tool.searchedTheWeb")}` : t("Chat.Tool.searchedTheWeb")}
{/* Browser Header Simulation */}
{/* Bottom gradient overlay */}
{Boolean(images?.length) && (
{images.map((image, i) => { if (!image.url) return null; return (
{ notify.component({ className: "max-w-[90vw]! max-h-[90vh]! p-6!", children: (
{/* eslint-disable-next-line @next/next/no-img-element */} {image.description}
), }); }} className="block rounded-lg overflow-hidden ring-1 ring-border/50 cursor-pointer group" > {/* eslint-disable-next-line @next/next/no-img-element */} {image.description}
{image.description || image.url}
); })}
)}
{part.errorText ? (

{t("Chat.Tool.webSearchError")}

) : ( (result as ExaSearchResponse)?.results?.map((result, i) => { return (
{result.title?.slice(0, 1).toUpperCase() || "?"}
{result.title || result.url} {new URL(result.url).hostname.replace("www.", "")}

{result.url}

); }) )}
); } function areEqual( { part: prevPart }: WebSearchToolInvocationProps, { part: nextPart }: WebSearchToolInvocationProps, ) { if (prevPart.state != nextPart.state) return false; if (!equal(prevPart.input, nextPart.input)) return false; if ( prevPart.state.startsWith("output") && !equal(prevPart.output, toAny(nextPart).output) ) return false; return true; } export const WebSearchToolInvocation = memo( PureWebSearchToolInvocation, areEqual, ); // Grouped web search parts component export function WebSearchGroupPart({ parts }: { parts: ToolUIPart[] }) { const t = useTranslations(); const [errorSrc, setErrorSrc] = useState([]); const allCompleted = useMemo( () => parts.every((p) => p.state.startsWith("output")), [parts], ); const partStatuses = useMemo(() => { return parts.map((part) => { const query = (part.input as { query?: string })?.query; const isPending = !part.state.startsWith("output"); const hasError = Boolean(part.errorText); const result = isPending ? null : (part.output as ExaSearchResponse); return { query, isPending, hasError, result }; }); }, [parts]); const flatResults = useMemo(() => { return partStatuses .filter((s) => s.result?.results) .flatMap((s) => s.result!.results); }, [partStatuses]); const images = useMemo(() => { return flatResults .filter((r) => r.image && !errorSrc.includes(r.image)) .map((r) => ({ url: r.image!, description: r.title })); }, [flatResults, errorSrc]); const onError = (e: React.SyntheticEvent) => { const target = e.currentTarget; if (errorSrc.includes(target.src)) return; setErrorSrc([...errorSrc, target.src]); }; const completedCount = partStatuses.filter((s) => !s.isPending).length; const totalCount = parts.length; // For single part, use the standard component if (parts.length === 1) { return ; } // Multiple parts, pending state - show unified loading UI if (!allCompleted) { return (
{`${t("Chat.Tool.webSearching")} (${completedCount}/${totalCount})`}
{Array.from({ length: 10 }).map((_, i) => (
))}
{/* Search query status list */}
{partStatuses.map((status, idx) => (
{status.hasError ? ( ) : status.isPending ? ( ) : ( )} {status.query ? `"${truncateString(status.query, 40)}"` : t("Chat.Tool.webSearching")}
))}
); } // All completed - show flat results return (
{t("Common.resultsFound", { count: flatResults.length })} {` (${totalCount})`}
{Boolean(images?.length) && (
{images.map((image, i) => { if (!image.url) return null; return (
{ notify.component({ className: "max-w-[90vw]! max-h-[90vh]! p-6!", children: (
{/* eslint-disable-next-line @next/next/no-img-element */} {image.description}
), }); }} className="block rounded-lg overflow-hidden ring-1 ring-border/50 cursor-pointer group" > {/* eslint-disable-next-line @next/next/no-img-element */} {image.description}
{image.description || image.url}
); })}
)}
); }