mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
feat(client): richer web search source list
The panel only showed favicon, title, and domain while the search artifact carries much more. Source rows now include the result snippet (clamped to two lines) and date under the domain, and a Serper answer box renders as a lead card above the list with its title and snippet. Streaming behavior is unchanged; the answer box is attachment-only since it never carries citation anchors.
This commit is contained in:
parent
ae07f45aad
commit
aff8f62882
2 changed files with 89 additions and 6 deletions
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ValidSource,
|
||||
SearchResultData,
|
||||
PartMetadata,
|
||||
AnswerBoxResult,
|
||||
} from 'librechat-data-provider';
|
||||
import { disclosureChevronClassName, toolPanelSpacingClassName } from './disclosure';
|
||||
import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard';
|
||||
|
|
@ -175,6 +176,22 @@ export default function WebSearch({
|
|||
return [];
|
||||
}, [searchResults, attachments, ownTurn]);
|
||||
|
||||
/** Direct-answer card shown at the top of the source list. Attachment-only:
|
||||
* answer boxes are provider extras (Serper) that never carry citations, so
|
||||
* the streaming path does not need them. */
|
||||
const answerBox = useMemo((): AnswerBoxResult | undefined => {
|
||||
if (!attachments) {
|
||||
return undefined;
|
||||
}
|
||||
for (const att of attachments) {
|
||||
const answer = att.type === Tools.web_search ? att[Tools.web_search]?.answerBox : undefined;
|
||||
if (answer) {
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [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.
|
||||
|
|
@ -284,8 +301,23 @@ export default function WebSearch({
|
|||
'mt-1.5 max-h-[280px] overflow-y-auto rounded-lg border border-border-light',
|
||||
)}
|
||||
>
|
||||
{answerBox && (answerBox.title || answerBox.snippet) && (
|
||||
<div className="border-b border-border-light px-3 py-2.5">
|
||||
{answerBox.title && (
|
||||
<div className="text-xs font-semibold text-text-primary">
|
||||
{answerBox.title}
|
||||
</div>
|
||||
)}
|
||||
{answerBox.snippet && (
|
||||
<div className="mt-0.5 text-xs leading-relaxed text-text-secondary">
|
||||
{answerBox.snippet}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{allSources.map((source, i) => {
|
||||
const domain = getCleanDomain(source.link);
|
||||
const snippet = 'snippet' in source ? source.snippet : undefined;
|
||||
return (
|
||||
<a
|
||||
key={source.link}
|
||||
|
|
@ -293,15 +325,33 @@ export default function WebSearch({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
|
||||
i > 0 && 'border-t border-border-light',
|
||||
'flex gap-2.5 px-3 py-2 transition-colors hover:bg-surface-hover',
|
||||
snippet ? 'items-start' : 'items-center',
|
||||
(i > 0 || !!answerBox) && 'border-t border-border-light',
|
||||
)}
|
||||
>
|
||||
<FaviconImage domain={domain} className="size-4 shrink-0 rounded-sm" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-text-primary">
|
||||
{source.title || domain}
|
||||
<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 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 className="shrink-0 text-[11px] text-text-secondary">{domain}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,39 @@ describe('WebSearch', () => {
|
|||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders snippets, dates, and the answer box in the source list', () => {
|
||||
const attachment = {
|
||||
type: Tools.web_search,
|
||||
[Tools.web_search]: {
|
||||
turn: 0,
|
||||
organic: [
|
||||
{
|
||||
link: 'https://example.com/context',
|
||||
title: 'Context windows explained',
|
||||
snippet: 'How context windows change what assistants can do.',
|
||||
date: 'Jun 12, 2026',
|
||||
},
|
||||
],
|
||||
answerBox: {
|
||||
title: 'What is a context window?',
|
||||
snippet: 'The amount of text a model can consider at once.',
|
||||
},
|
||||
},
|
||||
} as unknown as TAttachment;
|
||||
|
||||
renderWebSearch({ attachments: [attachment] });
|
||||
|
||||
expect(screen.getByText('What is a context window?')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The amount of text a model can consider at once.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('How context windows change what assistants can do.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Jun 12, 2026')).toBeInTheDocument();
|
||||
expect(screen.getByText('example.com')).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')] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue