mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
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:
parent
564a3a3707
commit
d1f84fa4a4
3 changed files with 285 additions and 50 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')] },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue