🔎 feat: Native Web Search with Citation References (#7516)

* WIP: search tool integration

* WIP: Add web search capabilities and API key management to agent actions

* WIP: web search capability to agent configuration and selection

* WIP: Add web search capability to backend agent configuration

* WIP: add web search option to default agent form values

* WIP: add attachments for web search

* feat: add plugin for processing web search citations

* WIP: first pass, Citation UI

* chore: remove console.log

* feat: Add AnimatedTabs component for tabbed UI functionality

* refactor: AnimatedTabs component with CSS animations and stable ID generation

* WIP example content

* feat: SearchContext for managing search results apart from MessageContext

* feat: Enhance AnimatedTabs with underline animation and state management

* WIP: first pass, Implement dynamic tab functionality in Sources component with search results integration

* fix: Update class names for improved styling in Sources and AnimatedTabs components

* feat: Improve styling and layout in Sources component with enhanced button and item designs

* feat: Refactor Sources component to integrate OGDialog for source display and improve layout

* style: Update background color in SourceItem and SourcesGroup components for improved visibility

* refactor: Sources component to enhance SourceItem structure and improve favicon handling

* style: Adjust font size of domain text in SourceItem for better readability

* feat: Add localization for citation source and details in CompositeCitation component

* style: add theming to Citation components

* feat: Enhance SourceItem component with dialog support and improved hovercard functionality

* feat: Add localization for sources tab and image alt text in Sources component

* style: Replace divs with spans for better semantic structure in CompositeCitation and Citation components

* refactor: Sources component to use useMemo for tab generation and improve performance

* chore: bump @librechat/agents to v2.4.318

* chore: update search result types

* fix: search results retrieval in ContentParts component, re-render attachments when expected

* feat: update sources style/types to use latest search result structure

* style: enhance Dialog (expanded) SourceItem component with link wrapping and improved styling

* style: update ImageItem component styling for improved title visibility

* refactor: remove SourceItemBase component and adjust SourceItem layout for improved styling

* chore: linting twcss order

* fix: prevent FileAttachment from rendering search attachments

* fix: append underscore to responseMessageId for unique identification to prevent mapping of previous latest message's attachments

* chore: remove unused parameter 'useSpecs' from loadTools function

* chore: twcss order

* WIP: WebSearch Tool UI

* refactor: add limit parameter to StackedFavicons for customizable source display

* refactor: optimize search results memoization by making more granular and separate conerns

* refactor: integrated StackedFavicons to WebSearch mid-run

* chore: bump @librechat/agents to expose handleToolCallChunks

* chore: use typedefs from dedicated file instead of defining them in AgentClient module

* WIP: first pass, search progress results

* refactor: move createOnSearchResults function to a dedicated search module

* chore: bump @librechat/agents to v2.4.320

* WIP: first pass, search results processed UX

* refactor: consolidate context variables in createOnSearchResults function

* chore: bump @librechat/agents to v2.4.321

* feat: add guidelines for web search tool response formatting in loadTools function

* feat: add isLast prop to Part component and update WebSearch logic for improved state handling

* style: update Hovercard styles for improved UI consistency

* feat: export FaviconImage component for improved accessibility in other modules

* refactor: export getCleanDomain function and use FaviconImage in Citation component for improved source representation

* refactor: implement SourceHovercard component for consistency and DRY compliance

* fix: replace <p> with <span> for snippet and title in SourceItem and SourceHovercard for consistency

* style: `not-prose`

* style: remove 'not-prose' class for consistency in SourceItem, Citation, and SourceHovercard components, adjust style classes

* refactor: `imageUrl` on hover and prevent duplicate sources

* refactor: enhance SourcesGroup dialog layout and improve source item presentation

* refactor: reorganize Web Components, save in same directory

* feat: add 'news' refType to refTypeMap for citation sources

* style: adjust Hovercard width for improved layout

* refactor: update tool usage guidelines for improved clarity and execution

* chore: linting

* feat: add Web Search badge with initial permissions and local storage logic

* feat: add webSearch support to interface and permissions schemas

* feat: implement Web Search API key management and localization updates

* feat: refactor Web Search API key handling and integrate new search API key form

* fix: remove unnecessary visibility state from FileAttachment component

* feat: update WebSearch component to use Globe icon and localized search label

* feat: enhance ApiKeyDialog with dropdown for reranker selection and update translations

* feat: implement dropdown menus for engine, scraper, and reranker selection in ApiKeyDialog

* chore: linting and add unknown instead of `any` type

* feat: refactor ApiKeyDialog and useAuthSearchTool for improved API key management

* refactor: update ocrSchema to use template literals for default apiKey and baseURL

* feat: add web search configuration and utility functions for environment variable extraction

* fix: ensure filepath is defined before checking its prefix in useAttachmentHandler

* feat: enhance web search functionality with improved configuration and environment variable extraction for authFields

* fix: update auth type in TPluginAction and TUpdateUserPlugins to use Partial<Record<string, string>>

* feat: implement web search authentication verification and enhance webSearchAuth structure

* feat: enhance ephemeral agent handling with new web search capability and type definition

* feat: enhance isEphemeralAgent function to include web search selection

* feat: refactor verifyWebSearchAuth to improve key handling and authentication checks

* feat: implement loadWebSearchAuth function for improved web search authentication handling

* feat: enhance web search authentication with new configuration options and refactor related types

* refactor: rename search engine to search provider and update related localization keys

* feat: update verifyWebSearchAuth to handle multiple authentication types and improve error handling

* feat: update ApiKeyDialog to accept authTypes prop and remove isUserProvided check

* feat: add tests for extractWebSearchEnvVars and loadWebSearchAuth functions

* feat: enhance loadWebSearchAuth to support specific service checks for providers, scrapers, and rerankers

* fix: update web search configuration key and adjust auth result handling in loadTools function

* feat: add new progress key for repeated web searching and update localization

* chore: bump @librechat/agents to 2.4.322

* feat: enhance loadTools function to include ISO time and improve search tool logging

* feat: update StackedFavicons to handle negative start index and improve citation attribution styling and text

* chore: update .gitignore to categorize AI-related files

* fix: mobile responsiveness of sources/citations hovercards

* feat: enhance source display with improved line clamping for better readability

* chore: bump @librechat/agents to v2.4.33

* feat: add handling for image sources in references mapping

* chore: bump librechat-data-provider version to 0.7.84

* chore: bump @librechat/agents version to 2.4.34

* fix: update auth handling to support multiple auth types in tools and allow key configuration in agent panel

* chore: remove redundant agent attribution text from search form

* fix: web search auth uninstall

* refactor: convert CheckboxButton to a forwardRef component and update setValue callback signature

* feat: add triggerRef prop to ApiKeyDialog components for improved dialog control

* feat: integrate triggerRef in CodeInterpreter and WebSearch components for enhanced dialog management

* feat: enhance ApiKeyDialog with additional links for Firecrawl and Jina API key guidance

* feat: implement web search configuration handling in ApiKeyDialog and add tests for dropdown visibility

* fix: update webSearchConfig reference in config route for correct payload assignment

* feat: update ApiKeyDialog to conditionally render sections based on authTypes and modify loadWebSearchAuth to correctly categorize authentication types

* feat: refactor ApiKeyDialog and related tests to use SearchCategories and RerankerTypes enums and remove nested ternaries

* refactor: move ThinkingButton rendering to improve layout consistency in ContentParts

* feat: integrate search context into Markdown component to conditionally include unicodeCitation plugin

* chore: bump @librechat/agents to v2.4.35

* chore: remove unused 18n key

* ci: add WEB_SEARCH permission testing and update AppService tests for new webSearch configuration

* ci: add more comprehensive tests for loadWebSearchAuth to validate authentication handling and authTypes structure

* chore: remove debugging console log from web.spec.ts to clean up test output
This commit is contained in:
Danny Avila 2025-05-23 00:14:04 -04:00
parent bf80cf30b3
commit 0dbbf7de04
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
73 changed files with 6366 additions and 2003 deletions

View file

@ -13,6 +13,7 @@ import { processAgentOption } from '~/utils';
import Instructions from './Instructions';
import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
import SearchForm from './Search/Form';
import { useLocalize } from '~/hooks';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
@ -73,6 +74,10 @@ export default function AgentConfig({
() => agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) ?? false,
[agentsConfig],
);
const webSearchEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.web_search) ?? false,
[agentsConfig],
);
const codeEnabled = useMemo(
() => agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) ?? false,
[agentsConfig],
@ -257,13 +262,19 @@ export default function AgentConfig({
</div>
</button>
</div>
{(codeEnabled || fileSearchEnabled || artifactsEnabled || ocrEnabled) && (
{(codeEnabled ||
fileSearchEnabled ||
artifactsEnabled ||
ocrEnabled ||
webSearchEnabled) && (
<div className="mb-4 flex w-full flex-col items-start gap-3">
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_capabilities')}
</label>
{/* Code Execution */}
{codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />}
{/* Web Search */}
{webSearchEnabled && <SearchForm />}
{/* File Context (OCR) */}
{ocrEnabled && <FileContext agent_id={agent_id} files={context_files} />}
{/* Artifacts */}

View file

@ -162,6 +162,9 @@ export default function AgentPanel({
if (data.file_search === true) {
tools.push(Tools.file_search);
}
if (data.web_search === true) {
tools.push(Tools.web_search);
}
const {
name,

View file

@ -52,6 +52,7 @@ export default function AgentSelect({
};
const capabilities: TAgentCapabilities = {
[AgentCapabilities.web_search]: false,
[AgentCapabilities.file_search]: false,
[AgentCapabilities.execute_code]: false,
[AgentCapabilities.end_after_tools]: false,

View file

@ -3,6 +3,7 @@ import type { ApiKeyFormData } from '~/common';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { Input, Button, OGDialog } from '~/components/ui';
import { useLocalize } from '~/hooks';
import type { RefObject } from 'react';
export default function ApiKeyDialog({
isOpen,
@ -13,6 +14,7 @@ export default function ApiKeyDialog({
isToolAuthenticated,
register,
handleSubmit,
triggerRef,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@ -22,6 +24,7 @@ export default function ApiKeyDialog({
isToolAuthenticated: boolean;
register: UseFormRegister<ApiKeyFormData>;
handleSubmit: UseFormHandleSubmit<ApiKeyFormData>;
triggerRef?: RefObject<HTMLInputElement>;
}) {
const localize = useLocalize();
const languageIcons = [
@ -38,7 +41,7 @@ export default function ApiKeyDialog({
];
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialogTemplate
className="w-11/12 sm:w-[450px]"
title=""

View file

@ -0,0 +1,121 @@
import { KeyRoundIcon } from 'lucide-react';
import { AuthType, AgentCapabilities } from 'librechat-data-provider';
import { useFormContext, Controller, useWatch } from 'react-hook-form';
import type { AgentForm } from '~/common';
import {
Checkbox,
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
} from '~/components/ui';
import { useLocalize, useSearchApiKeyForm } from '~/hooks';
import { CircleHelpIcon } from '~/components/svg';
import ApiKeyDialog from './ApiKeyDialog';
import { ESide } from '~/common';
export default function Action({
authTypes = [],
isToolAuthenticated = false,
}: {
authTypes?: [string, AuthType][];
isToolAuthenticated?: boolean;
}) {
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
const {
onSubmit,
isDialogOpen,
setIsDialogOpen,
handleRevokeApiKey,
methods: keyFormMethods,
} = useSearchApiKeyForm({
onSubmit: () => {
setValue(AgentCapabilities.web_search, true, { shouldDirty: true });
},
onRevoke: () => {
setValue(AgentCapabilities.web_search, false, { shouldDirty: true });
},
});
const webSearchIsEnabled = useWatch({ control, name: AgentCapabilities.web_search });
const isUserProvided = authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED);
const handleCheckboxChange = (checked: boolean) => {
if (isToolAuthenticated) {
setValue(AgentCapabilities.web_search, checked, { shouldDirty: true });
} else if (webSearchIsEnabled) {
setValue(AgentCapabilities.web_search, false, { shouldDirty: true });
} else {
setIsDialogOpen(true);
}
};
return (
<>
<HoverCard openDelay={50}>
<div className="flex items-center">
<Controller
name={AgentCapabilities.web_search}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={
webSearchIsEnabled ? webSearchIsEnabled : isToolAuthenticated && field.value
}
onCheckedChange={handleCheckboxChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()}
disabled={webSearchIsEnabled ? false : !isToolAuthenticated}
/>
)}
/>
<button
type="button"
className="flex items-center space-x-2"
onClick={() => {
const value = !getValues(AgentCapabilities.web_search);
handleCheckboxChange(value);
}}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={AgentCapabilities.web_search}
>
{localize('com_ui_web_search')}
</label>
</button>
<div className="ml-2 flex gap-2">
{isUserProvided && (isToolAuthenticated || webSearchIsEnabled) && (
<button type="button" onClick={() => setIsDialogOpen(true)}>
<KeyRoundIcon className="h-5 w-5 text-text-primary" />
</button>
)}
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_agents_search_info')}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</div>
</HoverCard>
<ApiKeyDialog
onSubmit={onSubmit}
authTypes={authTypes}
isOpen={isDialogOpen}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
register={keyFormMethods.register}
isToolAuthenticated={isToolAuthenticated}
handleSubmit={keyFormMethods.handleSubmit}
/>
</>
);
}

View file

@ -0,0 +1,148 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ApiKeyDialog from './ApiKeyDialog';
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider';
// Mock useLocalize to just return the key
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/data-provider', () => ({
useGetStartupConfig: jest.fn(),
}));
const mockRegister = (name: string) => ({
onChange: jest.fn(),
onBlur: jest.fn(),
ref: jest.fn(),
name,
});
const defaultProps = {
isOpen: true,
onOpenChange: jest.fn(),
onSubmit: jest.fn(),
onRevoke: jest.fn(),
authTypes: [
[SearchCategories.PROVIDERS, AuthType.USER_PROVIDED] as [string, AuthType],
[SearchCategories.SCRAPERS, AuthType.USER_PROVIDED] as [string, AuthType],
[SearchCategories.RERANKERS, AuthType.USER_PROVIDED] as [string, AuthType],
],
isToolAuthenticated: false,
register: mockRegister as any,
handleSubmit: (fn: any) => (e: any) => fn(e),
};
describe('ApiKeyDialog', () => {
const mockUseGetStartupConfig = useGetStartupConfig as jest.Mock;
afterEach(() => jest.clearAllMocks());
it('shows all dropdowns and both reranker fields when no config is set', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
render(<ApiKeyDialog {...defaultProps} />);
// Provider dropdown button
expect(
screen.getByRole('button', { name: 'com_ui_web_search_provider_serper' }),
).toBeInTheDocument();
// Scraper dropdown button
expect(
screen.getByRole('button', { name: 'com_ui_web_search_scraper_firecrawl' }),
).toBeInTheDocument();
// Reranker dropdown button
expect(
screen.getByRole('button', { name: 'com_ui_web_search_reranker_jina' }),
).toBeInTheDocument();
// Reranker fields (default is Jina)
expect(screen.getByPlaceholderText('com_ui_web_search_jina_key')).toBeInTheDocument();
// Switch to Cohere
fireEvent.click(screen.getByText('com_ui_web_search_reranker_cohere'));
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_key')).toBeInTheDocument();
});
it('shows static text for provider and only provider input if provider is set', () => {
mockUseGetStartupConfig.mockReturnValue({ data: { webSearch: { searchProvider: 'serper' } } });
render(<ApiKeyDialog {...defaultProps} />);
expect(screen.getByText('com_ui_web_search_provider_serper')).toBeInTheDocument();
// Should not find a dropdown button for provider
expect(screen.queryByRole('button', { name: /provider/i })).not.toBeInTheDocument();
});
it('shows only Jina reranker field if rerankerType is set to jina', () => {
mockUseGetStartupConfig.mockReturnValue({
data: { webSearch: { rerankerType: RerankerTypes.JINA } },
});
render(<ApiKeyDialog {...defaultProps} />);
expect(screen.getByPlaceholderText('com_ui_web_search_jina_key')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_cohere_key')).not.toBeInTheDocument();
});
it('shows only Cohere reranker field if rerankerType is set to cohere', () => {
mockUseGetStartupConfig.mockReturnValue({
data: { webSearch: { rerankerType: RerankerTypes.COHERE } },
});
render(<ApiKeyDialog {...defaultProps} />);
expect(screen.getByPlaceholderText('com_ui_web_search_cohere_key')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('com_ui_web_search_jina_key')).not.toBeInTheDocument();
});
it('shows documentation link for the visible reranker', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
render(<ApiKeyDialog {...defaultProps} />);
// Default is Jina
expect(screen.getByText('com_ui_web_search_reranker_jina_key')).toBeInTheDocument();
// Switch to Cohere
fireEvent.click(screen.getByText('com_ui_web_search_reranker_cohere'));
expect(screen.getByText('com_ui_web_search_reranker_cohere_key')).toBeInTheDocument();
});
it('does not render provider section if SYSTEM_DEFINED', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
const props = {
...defaultProps,
authTypes: [
[SearchCategories.PROVIDERS, AuthType.SYSTEM_DEFINED],
[SearchCategories.SCRAPERS, AuthType.USER_PROVIDED],
[SearchCategories.RERANKERS, AuthType.USER_PROVIDED],
] as [string, AuthType][],
};
render(<ApiKeyDialog {...props} />);
expect(screen.queryByText('com_ui_web_search_provider')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_web_search_scraper')).toBeInTheDocument();
expect(screen.getByText('com_ui_web_search_reranker')).toBeInTheDocument();
});
it('does not render scraper section if SYSTEM_DEFINED', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
const props = {
...defaultProps,
authTypes: [
[SearchCategories.PROVIDERS, AuthType.USER_PROVIDED],
[SearchCategories.SCRAPERS, AuthType.SYSTEM_DEFINED],
[SearchCategories.RERANKERS, AuthType.USER_PROVIDED],
] as [string, AuthType][],
};
render(<ApiKeyDialog {...props} />);
expect(screen.getByText('com_ui_web_search_provider')).toBeInTheDocument();
expect(screen.queryByText('com_ui_web_search_scraper')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_web_search_reranker')).toBeInTheDocument();
});
it('does not render reranker section if SYSTEM_DEFINED', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
const props = {
...defaultProps,
authTypes: [
[SearchCategories.PROVIDERS, AuthType.USER_PROVIDED],
[SearchCategories.SCRAPERS, AuthType.USER_PROVIDED],
[SearchCategories.RERANKERS, AuthType.SYSTEM_DEFINED],
] as [string, AuthType][],
};
render(<ApiKeyDialog {...props} />);
expect(screen.getByText('com_ui_web_search_provider')).toBeInTheDocument();
expect(screen.getByText('com_ui_web_search_scraper')).toBeInTheDocument();
expect(screen.queryByText('com_ui_web_search_reranker')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,361 @@
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider';
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { MenuItemProps } from '~/common';
import { Input, Button, OGDialog, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import DropdownPopup from '~/components/ui/DropdownPopup';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
export default function ApiKeyDialog({
isOpen,
onSubmit,
onRevoke,
onOpenChange,
authTypes,
isToolAuthenticated,
register,
handleSubmit,
triggerRef,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: SearchApiKeyFormData) => void;
onRevoke: () => void;
authTypes: [string, AuthType][];
isToolAuthenticated: boolean;
register: UseFormRegister<SearchApiKeyFormData>;
handleSubmit: UseFormHandleSubmit<SearchApiKeyFormData>;
triggerRef?: React.RefObject<HTMLInputElement>;
}) {
const localize = useLocalize();
const { data: config } = useGetStartupConfig();
const [selectedReranker, setSelectedReranker] = useState<
RerankerTypes.JINA | RerankerTypes.COHERE
>(
config?.webSearch?.rerankerType === RerankerTypes.COHERE
? RerankerTypes.COHERE
: RerankerTypes.JINA,
);
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
const [scraperDropdownOpen, setScraperDropdownOpen] = useState(false);
const [rerankerDropdownOpen, setRerankerDropdownOpen] = useState(false);
const providerItems: MenuItemProps[] = [
{
label: localize('com_ui_web_search_provider_serper'),
onClick: () => {},
},
];
const scraperItems: MenuItemProps[] = [
{
label: localize('com_ui_web_search_scraper_firecrawl'),
onClick: () => {},
},
];
const rerankerItems: MenuItemProps[] = [
{
label: localize('com_ui_web_search_reranker_jina'),
onClick: () => setSelectedReranker(RerankerTypes.JINA),
},
{
label: localize('com_ui_web_search_reranker_cohere'),
onClick: () => setSelectedReranker(RerankerTypes.COHERE),
},
];
const showProviderDropdown = !config?.webSearch?.searchProvider;
const showScraperDropdown = !config?.webSearch?.scraperType;
const showRerankerDropdown = !config?.webSearch?.rerankerType;
// Determine which categories are SYSTEM_DEFINED
const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1];
const scraperAuthType = authTypes.find(([cat]) => cat === SearchCategories.SCRAPERS)?.[1];
const rerankerAuthType = authTypes.find(([cat]) => cat === SearchCategories.RERANKERS)?.[1];
function renderRerankerInput() {
if (config?.webSearch?.rerankerType === RerankerTypes.JINA) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_jina_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('jinaApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://jina.ai/api-dashboard/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_jina_key')}
</a>
</div>
</>
);
}
if (config?.webSearch?.rerankerType === RerankerTypes.COHERE) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_cohere_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('cohereApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://dashboard.cohere.com/welcome/login"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_cohere_key')}
</a>
</div>
</>
);
}
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.JINA) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_jina_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('jinaApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://jina.ai/api-dashboard/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_jina_key')}
</a>
</div>
</>
);
}
if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.COHERE) {
return (
<>
<Input
type="password"
placeholder={localize('com_ui_web_search_cohere_key')}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('cohereApiKey')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://dashboard.cohere.com/welcome/login"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_reranker_cohere_key')}
</a>
</div>
</>
);
}
return null;
}
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialogTemplate
className="w-11/12 sm:w-[500px]"
title=""
main={
<>
<div className="mb-4 text-center font-medium">{localize('com_ui_web_search')}</div>
<div className="mb-4 text-center text-sm">
{localize('com_ui_web_search_api_subtitle')}
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Search Provider Section */}
{providerAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">
{localize('com_ui_web_search_provider')}
</Label>
{showProviderDropdown ? (
<DropdownPopup
menuId="search-provider-dropdown"
items={providerItems}
isOpen={providerDropdownOpen}
setIsOpen={setProviderDropdownOpen}
trigger={
<Menu.MenuButton
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{localize('com_ui_web_search_provider_serper')}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
) : (
<div className="text-sm text-text-secondary">
{localize('com_ui_web_search_provider_serper')}
</div>
)}
</div>
<Input
type="password"
placeholder={`${localize('com_ui_enter_api_key')}`}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
{...register('serperApiKey', { required: true })}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://serper.dev/api-key"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_provider_serper_key')}
</a>
</div>
</div>
)}
{/* Scraper Section */}
{scraperAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">
{localize('com_ui_web_search_scraper')}
</Label>
{showScraperDropdown ? (
<DropdownPopup
menuId="scraper-dropdown"
items={scraperItems}
isOpen={scraperDropdownOpen}
setIsOpen={setScraperDropdownOpen}
trigger={
<Menu.MenuButton
onClick={() => setScraperDropdownOpen(!scraperDropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{localize('com_ui_web_search_scraper_firecrawl')}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
) : (
<div className="text-sm text-text-secondary">
{localize('com_ui_web_search_scraper_firecrawl')}
</div>
)}
</div>
<Input
type="password"
placeholder={`${localize('com_ui_enter_api_key')}`}
autoComplete="one-time-code"
readOnly={true}
onFocus={(e) => (e.target.readOnly = false)}
className="mb-2"
{...register('firecrawlApiKey')}
/>
<Input
type="text"
placeholder={localize('com_ui_web_search_firecrawl_url')}
className="mb-1"
{...register('firecrawlApiUrl')}
/>
<div className="mt-1 text-xs text-text-secondary">
<a
href="https://docs.firecrawl.dev/introduction#api-key"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{localize('com_ui_web_search_scraper_firecrawl_key')}
</a>
</div>
</div>
)}
{/* Reranker Section */}
{rerankerAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">
{localize('com_ui_web_search_reranker')}
</Label>
{showRerankerDropdown && (
<DropdownPopup
menuId="reranker-dropdown"
isOpen={rerankerDropdownOpen}
setIsOpen={setRerankerDropdownOpen}
items={rerankerItems}
trigger={
<Menu.MenuButton
onClick={() => setRerankerDropdownOpen(!rerankerDropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{selectedReranker === RerankerTypes.JINA
? localize('com_ui_web_search_reranker_jina')
: localize('com_ui_web_search_reranker_cohere')}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
)}
{!showRerankerDropdown && (
<div className="text-sm text-text-secondary">
{config?.webSearch?.rerankerType === RerankerTypes.COHERE
? localize('com_ui_web_search_reranker_cohere')
: localize('com_ui_web_search_reranker_jina')}
</div>
)}
</div>
{renderRerankerInput()}
</div>
)}
</form>
</>
}
selection={{
selectHandler: handleSubmit(onSubmit),
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
selectText: localize('com_ui_save'),
}}
buttons={
isToolAuthenticated && (
<Button
onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{localize('com_ui_revoke')}
</Button>
)
}
showCancelButton={true}
/>
</OGDialog>
);
}

View file

@ -0,0 +1,31 @@
import { Tools } from 'librechat-data-provider';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { useLocalize } from '~/hooks';
import Action from './Action';
export default function SearchForm() {
const localize = useLocalize();
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.web_search },
{
retry: 1,
},
);
return (
<div className="w-full">
<div className="mb-1.5 flex items-center gap-2">
<div className="flex flex-row items-center gap-1">
<div className="flex items-center gap-1">
<span className="text-token-text-primary block font-medium">
{localize('com_ui_web_search')}
</span>
</div>
</div>
</div>
<div className="flex flex-col items-start gap-2">
<Action authTypes={data?.authTypes} isToolAuthenticated={data?.authenticated} />
</div>
</div>
);
}