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.
This commit is contained in:
Marco Beretta 2026-07-31 04:19:25 +02:00
parent 564a3a3707
commit d1f84fa4a4
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 285 additions and 50 deletions

View file

@ -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 (
<span className="flex items-center gap-1 text-[11px] text-text-secondary">
<Star className="size-3 fill-current" aria-hidden="true" />
{rating}
{ratingCount != null && <span className="text-text-secondary">({ratingCount})</span>}
</span>
);
}
function ImageStrip({ images, label }: { images: ImageResult[]; label: string }) {
return (
<ul className="flex list-none gap-2 overflow-x-auto pb-1" aria-label={label}>
{images.slice(0, MAX_VERTICAL_ITEMS).map((image, i) => {
const href = image.link || image.googleUrl || image.imageUrl;
const ratio =
image.thumbnailWidth && image.thumbnailHeight
? `${image.thumbnailWidth} / ${image.thumbnailHeight}`
: '1 / 1';
return (
<li key={image.thumbnailUrl || image.imageUrl || i} className="shrink-0">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={image.title || image.domain || label}
className="block h-28 overflow-hidden rounded-xl border border-border-light focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
style={{ aspectRatio: ratio }}
>
<img
src={image.thumbnailUrl || image.imageUrl}
alt={image.title || ''}
className="h-full w-full object-cover"
loading="lazy"
/>
</a>
</li>
);
})}
</ul>
);
}
function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: string }) {
return (
<ul className="flex list-none gap-2.5 overflow-x-auto pb-1" aria-label={label}>
{items.slice(0, MAX_VERTICAL_ITEMS).map((item, i) => (
<li key={item.productId || item.link || i} className="flex w-40 shrink-0">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="flex w-full flex-col rounded-xl border border-border-light p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
>
{item.imageUrl && (
<div className="mb-2 aspect-square w-full shrink-0 overflow-hidden rounded-lg bg-surface-tertiary">
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
)}
<span className="mt-auto block truncate text-xs font-medium text-text-primary">
{item.title}
</span>
{(item.price || item.source) && (
<span className="mt-0.5 block truncate text-[11px] text-text-secondary">
{[item.price, item.source].filter(Boolean).join(' · ')}
</span>
)}
{item.delivery && (
<span className="block truncate text-[11px] text-text-secondary">
{item.delivery}
</span>
)}
<span className="mt-0.5 block">
<RatingBadge rating={item.rating} ratingCount={item.ratingCount} />
</span>
</a>
</li>
))}
</ul>
);
}
function PlaceList({ places, label }: { places: PlaceResult[]; label: string }) {
return (
<ul
className="list-none overflow-hidden rounded-lg border border-border-light"
aria-label={label}
>
{places.slice(0, MAX_PLACES).map((place, i) => (
<li
key={place.identifier || place.name || i}
className={cn(
'flex items-center gap-2.5 px-3 py-2',
i > 0 && 'border-t border-border-light',
)}
>
<MapPin className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-text-primary">
{place.name}
</span>
{(place.category || place.address) && (
<span className="block truncate text-[11px] text-text-secondary">
{[place.category, place.address].filter(Boolean).join(' · ')}
</span>
)}
</span>
<span className="shrink-0">
<RatingBadge rating={place.rating} ratingCount={place.ratingCount} />
</span>
</li>
))}
</ul>
);
}
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({
<Globe className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
)}
<span className="min-w-0 truncate font-medium">{completedText}</span>
{hasSourceData && (
{hasExpandable && (
<ChevronDown
className={cn(
disclosureChevronClassName,
@ -354,56 +505,75 @@ export default function WebSearch({
</HoverCard>
)}
</div>
{hasSourceData && (
{hasExpandable && (
<div style={sourceExpandStyle} onTransitionEnd={handleTransitionEnd}>
<div className="overflow-hidden" ref={sourceExpandRef}>
{shouldRenderBody && (
<div
className={cn(
toolPanelSpacingClassName,
'mt-1.5 max-h-[280px] overflow-y-auto rounded-lg border border-border-light',
<div className={cn(toolPanelSpacingClassName, 'mt-1.5 space-y-2')}>
{verticals.images.length > 0 && (
<ImageStrip
images={verticals.images}
label={localize('com_ui_web_search_images')}
/>
)}
>
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
const snippet = 'snippet' in source ? source.snippet : undefined;
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
snippet ? 'items-start' : 'items-center',
i > 0 && 'border-t border-border-light',
)}
>
<FaviconImage
domain={domain}
className={cn('size-4 shrink-0 rounded-sm', snippet && 'mt-0.5')}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-text-primary">
{source.title || domain}
</span>
{snippet && (
<span className="mt-0.5 line-clamp-2 block text-[11px] leading-relaxed text-text-secondary">
{snippet}
{verticals.shopping.length > 0 && (
<ShoppingStrip
items={verticals.shopping}
label={localize('com_ui_web_search_shopping')}
/>
)}
{verticals.places.length > 0 && (
<PlaceList
places={verticals.places}
label={localize('com_ui_web_search_places')}
/>
)}
{hasSourceData && (
<div className="max-h-[280px] overflow-y-auto rounded-lg border border-border-light">
{allSources.map((source, i) => {
const domain = getCleanDomain(source.link);
const snippet = 'snippet' in source ? source.snippet : undefined;
return (
<a
key={source.link}
href={source.link}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
snippet ? 'items-start' : 'items-center',
i > 0 && 'border-t border-border-light',
)}
>
<FaviconImage
domain={domain}
className={cn('size-4 shrink-0 rounded-sm', snippet && 'mt-0.5')}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-text-primary">
{source.title || domain}
</span>
{snippet && (
<span className="mt-0.5 line-clamp-2 block text-[11px] leading-relaxed text-text-secondary">
{snippet}
</span>
)}
</span>
)}
</span>
<span className="shrink-0 text-right">
<span className="block text-[11px] text-text-secondary">{domain}</span>
{source.date && (
<span className="block text-[10px] text-text-secondary">
{source.date}
<span className="shrink-0 text-right">
<span className="block text-[11px] text-text-secondary">
{domain}
</span>
{source.date && (
<span className="block text-[10px] text-text-secondary">
{source.date}
</span>
)}
</span>
)}
</span>
</a>
);
})}
</a>
);
})}
</div>
)}
</div>
)}
</div>

View file

@ -59,6 +59,8 @@ jest.mock('~/components/Web/Sources', () => ({
jest.mock('lucide-react', () => ({
Globe: () => <span data-testid="globe-icon" />,
Info: () => <span data-testid="info-icon" />,
Star: () => <span data-testid="star-icon" />,
MapPin: () => <span data-testid="map-pin-icon" />,
ChevronDown: ({ className }: { className?: string }) => (
<span data-testid="chevron-icon" className={className} />
),
@ -353,6 +355,66 @@ describe('WebSearch', () => {
expect(screen.getByText('1 source')).toBeInTheDocument();
});
it('renders shopping, image, and place verticals in the expanded panel', () => {
const attachment = {
type: Tools.web_search,
[Tools.web_search]: {
turn: 0,
organic: [{ link: 'https://example.com/a', title: 'A source' }],
images: [
{
title: 'Rain vortex',
imageUrl: 'https://img.example.com/full.jpg',
thumbnailUrl: 'https://img.example.com/thumb.jpg',
thumbnailWidth: 300,
thumbnailHeight: 200,
link: 'https://host.example.com/page',
},
],
shopping: [
{
title: 'Gingko Mini Smart Book',
link: 'https://shop.example.com/book',
price: '35,70 \u20ac',
source: 'Amazon',
rating: 4.7,
ratingCount: 1284,
delivery: 'Free delivery',
},
],
places: [
{
name: 'Blue Bottle Coffee',
category: 'Coffee shop',
address: '300 S Broadway',
rating: 4.6,
ratingCount: 812,
},
],
},
} as unknown as TAttachment;
renderWebSearch({ attachments: [attachment] });
const imageLink = screen.getByLabelText('Rain vortex');
expect(imageLink).toHaveAttribute('href', 'https://host.example.com/page');
expect(imageLink.querySelector('img')).toHaveAttribute(
'src',
'https://img.example.com/thumb.jpg',
);
const product = screen.getByText('Gingko Mini Smart Book').closest('a');
expect(product).toHaveAttribute('href', 'https://shop.example.com/book');
expect(screen.getByText('35,70 \u20ac \u00b7 Amazon')).toBeInTheDocument();
expect(screen.getByText('Free delivery')).toBeInTheDocument();
expect(screen.getByText('4.7')).toBeInTheDocument();
expect(screen.getByText('Blue Bottle Coffee')).toBeInTheDocument();
expect(screen.getByText('Coffee shop \u00b7 300 S Broadway')).toBeInTheDocument();
expect(screen.getByText('4.6')).toBeInTheDocument();
expect(screen.getByText('(812)')).toBeInTheDocument();
});
it('uses standard tool-row spacing and reveals its chevron on hover or focus', () => {
const searchResults = makeSearchResults({
0: { organic: [makeSource('https://example.com', 'Example')] },

View file

@ -2562,6 +2562,9 @@
"com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)",
"com_ui_web_search_searxng_instance_url": "SearXNG Instance URL",
"com_ui_web_search_details": "Search details",
"com_ui_web_search_images": "Image results",
"com_ui_web_search_places": "Places",
"com_ui_web_search_shopping": "Shopping results",
"com_ui_web_search_source": "{{count}} source",
"com_ui_web_search_sources": "{{count}} sources",
"com_ui_web_searched": "Searched the web",