From d1f84fa4a4f813b7784ed5a6ff9c800dc8752887 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:19:25 +0200 Subject: [PATCH] feat(client): image, shopping, and place verticals in web search results Serper vertical results were parsed into the artifact and dropped. The expanded panel now renders them when present: an image strip of rounded thumbnails at true aspect ratios (sized from thumbnail dimensions, no layout shift), a shopping card carousel with product image, title, price and store, delivery note, and star rating, and a place list with name, category, address, and rating. All link out, use semantic lists with localized labels, and cap item counts. Expansion now keys off any renderable content, so a verticals-only search still opens. --- .../Chat/Messages/Content/WebSearch.tsx | 270 ++++++++++++++---- .../Content/__tests__/WebSearch.test.tsx | 62 ++++ client/src/locales/en/translation.json | 3 + 3 files changed, 285 insertions(+), 50 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index d7df66a0bb..a57d3e5ae4 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { Tools } from 'librechat-data-provider'; -import { Globe, ChevronDown, Info } from 'lucide-react'; +import { Globe, ChevronDown, Info, Star, MapPin } from 'lucide-react'; import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from '@librechat/client'; import type { TAttachment, @@ -9,6 +9,9 @@ import type { SearchResultData, PartMetadata, AnswerBoxResult, + ShoppingResult, + ImageResult, + PlaceResult, } from 'librechat-data-provider'; import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure'; import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard'; @@ -66,6 +69,133 @@ function getUniqueDomainSources(sources: ValidSource[], max: number): ValidSourc return result; } +const MAX_VERTICAL_ITEMS = 8; +const MAX_PLACES = 5; + +function RatingBadge({ rating, ratingCount }: { rating?: number; ratingCount?: number }) { + if (rating == null) { + return null; + } + return ( + + + ); +} + +function ImageStrip({ images, label }: { images: ImageResult[]; label: string }) { + return ( + + ); +} + +function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: string }) { + return ( + + ); +} + +function PlaceList({ places, label }: { places: PlaceResult[]; label: string }) { + return ( + + ); +} + function SourceFaviconStack({ sources }: { sources: ValidSource[] }) { const visible = getUniqueDomainSources(sources, MAX_VISIBLE_FAVICONS); return ( @@ -194,6 +324,24 @@ export default function WebSearch({ return undefined; }, [attachments]); + /** Provider verticals (Serper only): image/shopping/place results merged + * across this call's attachments. Attachment-only like the answer box. */ + const verticals = useMemo(() => { + const images: ImageResult[] = []; + const shopping: ShoppingResult[] = []; + const places: PlaceResult[] = []; + for (const att of attachments ?? []) { + const data = att.type === Tools.web_search ? att[Tools.web_search] : undefined; + if (!data) { + continue; + } + images.push(...(data.images ?? [])); + shopping.push(...(data.shopping ?? [])); + places.push(...(data.places ?? [])); + } + return { images, shopping, places }; + }, [attachments]); + // Show favicons from the raw SERP results immediately rather than waiting for // each source to flip to `processed`; the agents scrape barrier would otherwise // freeze the stack on "Searching the web" for the slowest scrape's duration. @@ -255,6 +403,9 @@ export default function WebSearch({ if (complete) { const hasSourceData = sourceCount > 0; + const hasVerticals = + verticals.images.length > 0 || verticals.shopping.length > 0 || verticals.places.length > 0; + const hasExpandable = hasSourceData || hasVerticals; const completedText = intent ?? localize('com_ui_web_searched'); const query = parseJsonField(args, 'query'); const hasDetails = !!query || !!answerBox; @@ -269,13 +420,13 @@ export default function WebSearch({ type="button" className={cn( 'tool-status-text group/disclosure flex h-5 min-w-0 items-center gap-2 rounded-full', - hasSourceData + hasExpandable ? 'text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy' : 'pointer-events-none text-text-secondary', )} - disabled={!hasSourceData} - onClick={hasSourceData ? handleToggleSources : undefined} - aria-expanded={hasSourceData ? showSourceList : undefined} + disabled={!hasExpandable} + onClick={hasExpandable ? handleToggleSources : undefined} + aria-expanded={hasExpandable ? showSourceList : undefined} aria-label={ hasSourceData ? `${completedText} - ${localize(sourceCount === 1 ? 'com_ui_web_search_source' : 'com_ui_web_search_sources', { count: sourceCount })}` @@ -288,7 +439,7 @@ export default function WebSearch({