🔍 feat: Add SearXNG for Web Search and Enhance ApiKeyDialog (#8242)

* 🔍 feat: Add SearXNG Web Search support and enhance ApiKeyDialog

- Updated WebSearch component to include authentication data for web search functionality so it won't show badge after being revoked
- Refactored ApiKeyDialog to streamline provider, scraper, and reranker selection with new InputSection component
- Added support for SearXNG as a search provider and updated translation files accordingly
- Improved form handling in useAuthSearchTool to accommodate new API keys and URLs

* 📜 chore: remove unused i18next key

* 📦 chore: address comments (swap API key and URL fields in SearXNG config, change input fields to 'text' from 'password'

* 📦 chore: make URL fields go first in ApiKeyDialog

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

* ci: update webSearch configuration to include searxng fields in AppService.spec.js

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2025-07-05 14:58:22 -07:00 committed by GitHub
parent 91a2df4759
commit e0f468da20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 331 additions and 289 deletions

View file

@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.13", "@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.51", "@librechat/agents": "^2.4.52",
"@librechat/api": "*", "@librechat/api": "*",
"@librechat/data-schemas": "*", "@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0", "@node-saml/passport-saml": "^5.0.0",

View file

@ -152,12 +152,14 @@ describe('AppService', () => {
filteredTools: undefined, filteredTools: undefined,
includedTools: undefined, includedTools: undefined,
webSearch: { webSearch: {
safeSearch: 1,
jinaApiKey: '${JINA_API_KEY}',
cohereApiKey: '${COHERE_API_KEY}', cohereApiKey: '${COHERE_API_KEY}',
serperApiKey: '${SERPER_API_KEY}',
searxngApiKey: '${SEARXNG_API_KEY}',
firecrawlApiKey: '${FIRECRAWL_API_KEY}', firecrawlApiKey: '${FIRECRAWL_API_KEY}',
firecrawlApiUrl: '${FIRECRAWL_API_URL}', firecrawlApiUrl: '${FIRECRAWL_API_URL}',
jinaApiKey: '${JINA_API_KEY}', searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}',
safeSearch: 1,
serperApiKey: '${SERPER_API_KEY}',
}, },
memory: undefined, memory: undefined,
agents: { agents: {

View file

@ -8,7 +8,7 @@ import { useBadgeRowContext } from '~/Providers';
function WebSearch() { function WebSearch() {
const localize = useLocalize(); const localize = useLocalize();
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext(); const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
const { toggleState: webSearch, debouncedChange, isPinned } = webSearchData; const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm; const { badgeTriggerRef } = searchApiKeyForm;
const canUseWebSearch = useHasAccess({ const canUseWebSearch = useHasAccess({
@ -21,7 +21,7 @@ function WebSearch() {
} }
return ( return (
(webSearch || isPinned) && ( (isPinned || (webSearch && authData?.authenticated)) && (
<CheckboxButton <CheckboxButton
ref={badgeTriggerRef} ref={badgeTriggerRef}
className="max-w-fit" className="max-w-fit"

View file

@ -1,13 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronDown } from 'lucide-react'; import {
import * as Menu from '@ariakit/react/menu'; AuthType,
import { AuthType, SearchCategories, RerankerTypes } from 'librechat-data-provider'; SearchCategories,
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form'; RerankerTypes,
SearchProviders,
ScraperTypes,
} from 'librechat-data-provider';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool'; import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { MenuItemProps } from '~/common'; import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
import { Input, Button, OGDialog, Label } from '~/components/ui'; import InputSection, { type DropdownOption } from './InputSection';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import DropdownPopup from '~/components/ui/DropdownPopup'; import { Button, OGDialog } from '~/components/ui';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
@ -36,151 +39,119 @@ export default function ApiKeyDialog({
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { data: config } = useGetStartupConfig(); const { data: config } = useGetStartupConfig();
const [selectedReranker, setSelectedReranker] = useState<
RerankerTypes.JINA | RerankerTypes.COHERE const [selectedProvider, setSelectedProvider] = useState(
>( config?.webSearch?.searchProvider || SearchProviders.SERPER,
config?.webSearch?.rerankerType === RerankerTypes.COHERE
? RerankerTypes.COHERE
: RerankerTypes.JINA,
); );
const [selectedReranker, setSelectedReranker] = useState(
config?.webSearch?.rerankerType || RerankerTypes.JINA,
);
const [selectedScraper, setSelectedScraper] = useState(ScraperTypes.FIRECRAWL);
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false); const providerOptions: DropdownOption[] = [
const [scraperDropdownOpen, setScraperDropdownOpen] = useState(false);
const [rerankerDropdownOpen, setRerankerDropdownOpen] = useState(false);
const providerItems: MenuItemProps[] = [
{ {
key: SearchProviders.SERPER,
label: localize('com_ui_web_search_provider_serper'), label: localize('com_ui_web_search_provider_serper'),
onClick: () => {}, inputs: {
serperApiKey: {
placeholder: localize('com_ui_enter_api_key'),
type: 'password' as const,
link: {
url: 'https://serper.dev/api-keys',
text: localize('com_ui_web_search_provider_serper_key'),
},
},
},
},
{
key: SearchProviders.SEARXNG,
label: localize('com_ui_web_search_provider_searxng'),
inputs: {
searxngInstanceUrl: {
placeholder: localize('com_ui_web_search_searxng_instance_url'),
type: 'text' as const,
},
searxngApiKey: {
placeholder: localize('com_ui_web_search_searxng_api_key'),
type: 'password' as const,
},
},
}, },
]; ];
const scraperItems: MenuItemProps[] = [ const rerankerOptions: DropdownOption[] = [
{
label: localize('com_ui_web_search_scraper_firecrawl'),
onClick: () => {},
},
];
const rerankerItems: MenuItemProps[] = [
{ {
key: RerankerTypes.JINA,
label: localize('com_ui_web_search_reranker_jina'), label: localize('com_ui_web_search_reranker_jina'),
onClick: () => setSelectedReranker(RerankerTypes.JINA), inputs: {
jinaApiKey: {
placeholder: localize('com_ui_web_search_jina_key'),
type: 'password' as const,
link: {
url: 'https://jina.ai/api-dashboard/',
text: localize('com_ui_web_search_reranker_jina_key'),
},
},
},
}, },
{ {
key: RerankerTypes.COHERE,
label: localize('com_ui_web_search_reranker_cohere'), label: localize('com_ui_web_search_reranker_cohere'),
onClick: () => setSelectedReranker(RerankerTypes.COHERE), inputs: {
cohereApiKey: {
placeholder: localize('com_ui_web_search_cohere_key'),
type: 'password' as const,
link: {
url: 'https://dashboard.cohere.com/welcome/login',
text: localize('com_ui_web_search_reranker_cohere_key'),
},
},
},
}, },
]; ];
const showProviderDropdown = !config?.webSearch?.searchProvider; const scraperOptions: DropdownOption[] = [
const showScraperDropdown = !config?.webSearch?.scraperType; {
const showRerankerDropdown = !config?.webSearch?.rerankerType; key: ScraperTypes.FIRECRAWL,
label: localize('com_ui_web_search_scraper_firecrawl'),
inputs: {
firecrawlApiUrl: {
placeholder: localize('com_ui_web_search_firecrawl_url'),
type: 'text' as const,
},
firecrawlApiKey: {
placeholder: localize('com_ui_enter_api_key'),
type: 'password' as const,
link: {
url: 'https://docs.firecrawl.dev/introduction#api-key',
text: localize('com_ui_web_search_scraper_firecrawl_key'),
},
},
},
},
];
const [dropdownOpen, setDropdownOpen] = useState({
provider: false,
reranker: false,
scraper: false,
});
// Determine which categories are SYSTEM_DEFINED
const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1]; const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1];
const scraperAuthType = authTypes.find(([cat]) => cat === SearchCategories.SCRAPERS)?.[1]; const scraperAuthType = authTypes.find(([cat]) => cat === SearchCategories.SCRAPERS)?.[1];
const rerankerAuthType = authTypes.find(([cat]) => cat === SearchCategories.RERANKERS)?.[1]; const rerankerAuthType = authTypes.find(([cat]) => cat === SearchCategories.RERANKERS)?.[1];
function renderRerankerInput() { const handleProviderChange = (key: string) => {
if (config?.webSearch?.rerankerType === RerankerTypes.JINA) { setSelectedProvider(key as SearchProviders);
return ( };
<>
<Input const handleRerankerChange = (key: string) => {
type="password" setSelectedReranker(key as RerankerTypes);
placeholder={localize('com_ui_web_search_jina_key')} };
autoComplete="one-time-code"
readOnly={true} const handleScraperChange = (key: string) => {
onFocus={(e) => (e.target.readOnly = false)} setSelectedScraper(key as ScraperTypes);
{...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 ( return (
<OGDialog <OGDialog
@ -195,153 +166,56 @@ export default function ApiKeyDialog({
main={ main={
<> <>
<div className="mb-4 text-center font-medium">{localize('com_ui_web_search')}</div> <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)}> <form onSubmit={handleSubmit(onSubmit)}>
{/* Search Provider Section */} {/* Provider Section */}
{providerAuthType !== AuthType.SYSTEM_DEFINED && ( {providerAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6"> <InputSection
<div className="mb-2 flex items-center justify-between"> title={localize('com_ui_web_search_provider')}
<Label className="text-md w-fit font-medium"> selectedKey={selectedProvider}
{localize('com_ui_web_search_provider')} onSelectionChange={handleProviderChange}
</Label> dropdownOptions={providerOptions}
{showProviderDropdown ? ( showDropdown={!config?.webSearch?.searchProvider}
<DropdownPopup register={register}
menuId="search-provider-dropdown" dropdownOpen={dropdownOpen.provider}
items={providerItems} setDropdownOpen={(open) =>
isOpen={providerDropdownOpen} setDropdownOpen((prev) => ({ ...prev, provider: open }))
setIsOpen={setProviderDropdownOpen} }
trigger={ dropdownKey="provider"
<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 */} {/* Scraper Section */}
{scraperAuthType !== AuthType.SYSTEM_DEFINED && ( {scraperAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6"> <InputSection
<div className="mb-2 flex items-center justify-between"> title={localize('com_ui_web_search_scraper')}
<Label className="text-md w-fit font-medium"> selectedKey={selectedScraper}
{localize('com_ui_web_search_scraper')} onSelectionChange={handleScraperChange}
</Label> dropdownOptions={scraperOptions}
{showScraperDropdown ? ( showDropdown={!config?.webSearch?.scraperType}
<DropdownPopup register={register}
menuId="scraper-dropdown" dropdownOpen={dropdownOpen.scraper}
items={scraperItems} setDropdownOpen={(open) =>
isOpen={scraperDropdownOpen} setDropdownOpen((prev) => ({ ...prev, scraper: open }))
setIsOpen={setScraperDropdownOpen} }
trigger={ dropdownKey="scraper"
<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 */} {/* Reranker Section */}
{rerankerAuthType !== AuthType.SYSTEM_DEFINED && ( {rerankerAuthType !== AuthType.SYSTEM_DEFINED && (
<div className="mb-6"> <InputSection
<div className="mb-2 flex items-center justify-between"> title={localize('com_ui_web_search_reranker')}
<Label className="text-md w-fit font-medium"> selectedKey={selectedReranker}
{localize('com_ui_web_search_reranker')} onSelectionChange={handleRerankerChange}
</Label> dropdownOptions={rerankerOptions}
{showRerankerDropdown && ( showDropdown={!config?.webSearch?.rerankerType}
<DropdownPopup register={register}
menuId="reranker-dropdown" dropdownOpen={dropdownOpen.reranker}
isOpen={rerankerDropdownOpen} setDropdownOpen={(open) =>
setIsOpen={setRerankerDropdownOpen} setDropdownOpen((prev) => ({ ...prev, reranker: open }))
items={rerankerItems} }
trigger={ dropdownKey="reranker"
<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> </form>
</> </>
@ -353,10 +227,7 @@ export default function ApiKeyDialog({
}} }}
buttons={ buttons={
isToolAuthenticated && ( isToolAuthenticated && (
<Button <Button onClick={onRevoke} className="bg-red-500 text-white hover:bg-red-600">
onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{localize('com_ui_revoke')} {localize('com_ui_revoke')}
</Button> </Button>
) )

View file

@ -0,0 +1,144 @@
import { useState } from 'react';
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import type { UseFormRegister } from 'react-hook-form';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { MenuItemProps } from '~/common';
import { Input, Label } from '~/components/ui';
import DropdownPopup from '~/components/ui/DropdownPopup';
import { useLocalize } from '~/hooks';
interface InputConfig {
placeholder: string;
type?: 'text' | 'password';
link?: {
url: string;
text: string;
};
}
interface DropdownOption {
key: string;
label: string;
inputs?: Record<string, InputConfig>;
}
interface InputSectionProps {
title: string;
selectedKey: string;
onSelectionChange: (key: string) => void;
dropdownOptions: DropdownOption[];
showDropdown: boolean;
register: UseFormRegister<SearchApiKeyFormData>;
dropdownOpen: boolean;
setDropdownOpen: (open: boolean) => void;
dropdownKey: string;
}
export default function InputSection({
title,
selectedKey,
onSelectionChange,
dropdownOptions,
showDropdown,
register,
dropdownOpen,
setDropdownOpen,
dropdownKey,
}: InputSectionProps) {
const localize = useLocalize();
const [passwordVisibility, setPasswordVisibility] = useState<Record<string, boolean>>({});
const selectedOption = dropdownOptions.find((opt) => opt.key === selectedKey);
const dropdownItems: MenuItemProps[] = dropdownOptions.map((option) => ({
label: option.label,
onClick: () => onSelectionChange(option.key),
}));
const togglePasswordVisibility = (fieldName: string) => {
setPasswordVisibility((prev) => ({
...prev,
[fieldName]: !prev[fieldName],
}));
};
return (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
<Label className="text-md w-fit font-medium">{title}</Label>
{showDropdown ? (
<DropdownPopup
menuId={`${dropdownKey}-dropdown`}
items={dropdownItems}
isOpen={dropdownOpen}
setIsOpen={setDropdownOpen}
trigger={
<Menu.MenuButton
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary"
>
{selectedOption?.label}
<ChevronDown className="ml-1 h-4 w-4" />
</Menu.MenuButton>
}
/>
) : (
<div className="text-sm text-text-secondary">{selectedOption?.label}</div>
)}
</div>
{selectedOption?.inputs &&
Object.entries(selectedOption.inputs).map(([name, config], index) => (
<div key={name}>
<div className="relative">
<Input
type={'text'} // so password autofill doesn't show
placeholder={config.placeholder}
autoComplete={config.type === 'password' ? 'one-time-code' : 'off'}
readOnly={config.type === 'password'}
onFocus={
config.type === 'password' ? (e) => (e.target.readOnly = false) : undefined
}
className={`${index > 0 ? 'mb-2' : 'mb-2'} ${
config.type === 'password' ? 'pr-10' : ''
}`}
{...register(name as keyof SearchApiKeyFormData)}
/>
{config.type === 'password' && (
<button
type="button"
onClick={() => togglePasswordVisibility(name)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-colors hover:text-text-primary"
aria-label={
passwordVisibility[name]
? localize('com_ui_hide_password')
: localize('com_ui_show_password')
}
>
<div className="relative h-4 w-4">
{passwordVisibility[name] ? (
<EyeOff className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in" />
) : (
<Eye className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in" />
)}
</div>
</button>
)}
</div>
{config.link && (
<div className="mt-1 text-xs text-text-secondary">
<a
href={config.link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{config.link.text}
</a>
</div>
)}
</div>
))}
</div>
);
}
export type { InputConfig, DropdownOption };

View file

@ -4,7 +4,14 @@ import { AuthType, Tools, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
export type SearchApiKeyFormData = { export type SearchApiKeyFormData = {
// Selected options
selectedProvider: string;
selectedReranker: string;
selectedScraper: string;
// API keys and URLs
serperApiKey: string; serperApiKey: string;
searxngInstanceUrl: string;
searxngApiKey: string;
firecrawlApiKey: string; firecrawlApiKey: string;
firecrawlApiUrl: string; firecrawlApiUrl: string;
jinaApiKey: string; jinaApiKey: string;
@ -42,6 +49,8 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => {
(data: SearchApiKeyFormData) => { (data: SearchApiKeyFormData) => {
const auth = Object.entries({ const auth = Object.entries({
serperApiKey: data.serperApiKey, serperApiKey: data.serperApiKey,
searxngInstanceUrl: data.searxngInstanceUrl,
searxngApiKey: data.searxngApiKey,
firecrawlApiKey: data.firecrawlApiKey, firecrawlApiKey: data.firecrawlApiKey,
firecrawlApiUrl: data.firecrawlApiUrl, firecrawlApiUrl: data.firecrawlApiUrl,
jinaApiKey: data.jinaApiKey, jinaApiKey: data.jinaApiKey,

View file

@ -19,12 +19,11 @@ export default function useSearchApiKeyForm({
const onSubmitHandler = useCallback( const onSubmitHandler = useCallback(
(data: SearchApiKeyFormData) => { (data: SearchApiKeyFormData) => {
reset();
installTool(data); installTool(data);
setIsDialogOpen(false); setIsDialogOpen(false);
onSubmit?.(); onSubmit?.();
}, },
[onSubmit, reset, installTool], [onSubmit, installTool],
); );
const handleRevokeApiKey = useCallback(() => { const handleRevokeApiKey = useCallback(() => {

View file

@ -1050,13 +1050,15 @@
"com_ui_view_memory": "View Memory", "com_ui_view_memory": "View Memory",
"com_ui_view_source": "View source chat", "com_ui_view_source": "View source chat",
"com_ui_web_search": "Web Search", "com_ui_web_search": "Web Search",
"com_ui_web_search_api_subtitle": "Search the web for up-to-date information",
"com_ui_web_search_cohere_key": "Enter Cohere API Key", "com_ui_web_search_cohere_key": "Enter Cohere API Key",
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)",
"com_ui_web_search_jina_key": "Enter Jina API Key", "com_ui_web_search_jina_key": "Enter Jina API Key",
"com_ui_web_search_processing": "Processing results", "com_ui_web_search_processing": "Processing results",
"com_ui_web_search_provider": "Search Provider", "com_ui_web_search_provider": "Search Provider",
"com_ui_web_search_provider_serper": "Serper API", "com_ui_web_search_provider_serper": "Serper API",
"com_ui_web_search_provider_searxng": "SearXNG",
"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_provider_serper_key": "Get your Serper API key", "com_ui_web_search_provider_serper_key": "Get your Serper API key",
"com_ui_web_search_reading": "Reading results", "com_ui_web_search_reading": "Reading results",
"com_ui_web_search_reranker": "Reranker", "com_ui_web_search_reranker": "Reranker",
@ -1074,5 +1076,7 @@
"com_ui_x_selected": "{{0}} selected", "com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes", "com_ui_yes": "Yes",
"com_ui_zoom": "Zoom", "com_ui_zoom": "Zoom",
"com_user_message": "You" "com_user_message": "You",
"com_ui_show_password": "Show password",
"com_ui_hide_password": "Hide password"
} }

10
package-lock.json generated
View file

@ -64,7 +64,7 @@
"@langchain/google-genai": "^0.2.13", "@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.51", "@librechat/agents": "^2.4.52",
"@librechat/api": "*", "@librechat/api": "*",
"@librechat/data-schemas": "*", "@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0", "@node-saml/passport-saml": "^5.0.0",
@ -19343,9 +19343,9 @@
} }
}, },
"node_modules/@librechat/agents": { "node_modules/@librechat/agents": {
"version": "2.4.51", "version": "2.4.52",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.51.tgz", "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.52.tgz",
"integrity": "sha512-wmwas9/XvF+KSSez53iXx4f1yD4e2nDvqzv0kinGk9lbPIGIAOTCLKGOkS1lEHzSkKXUyGmYIzJFwaEqKm52fw==", "integrity": "sha512-E0CbuXZEIx3J3MjiZ7wDQuDIMaeGPMkSkcm2foOE2PmneAGiGpIjTgvxa9UjJUUWQku191fydZXr9dE826N1MA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@langchain/anthropic": "^0.3.23", "@langchain/anthropic": "^0.3.23",
@ -46494,7 +46494,7 @@
"typescript": "^5.0.4" "typescript": "^5.0.4"
}, },
"peerDependencies": { "peerDependencies": {
"@librechat/agents": "^2.4.51", "@librechat/agents": "^2.4.52",
"@librechat/data-schemas": "*", "@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.13.3", "@modelcontextprotocol/sdk": "^1.13.3",
"axios": "^1.8.2", "axios": "^1.8.2",

View file

@ -69,7 +69,7 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"peerDependencies": { "peerDependencies": {
"@librechat/agents": "^2.4.51", "@librechat/agents": "^2.4.52",
"@librechat/data-schemas": "*", "@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.13.3", "@modelcontextprotocol/sdk": "^1.13.3",
"axios": "^1.8.2", "axios": "^1.8.2",

View file

@ -647,6 +647,8 @@ export enum SafeSearchTypes {
export const webSearchSchema = z.object({ export const webSearchSchema = z.object({
serperApiKey: z.string().optional().default('${SERPER_API_KEY}'), serperApiKey: z.string().optional().default('${SERPER_API_KEY}'),
searxngInstanceUrl: z.string().optional().default('${SEARXNG_INSTANCE_URL}'),
searxngApiKey: z.string().optional().default('${SEARXNG_API_KEY}'),
firecrawlApiKey: z.string().optional().default('${FIRECRAWL_API_KEY}'), firecrawlApiKey: z.string().optional().default('${FIRECRAWL_API_KEY}'),
firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'), firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'),
jinaApiKey: z.string().optional().default('${JINA_API_KEY}'), jinaApiKey: z.string().optional().default('${JINA_API_KEY}'),

View file

@ -13,6 +13,8 @@ export function loadWebSearchConfig(
config: TCustomConfig['webSearch'], config: TCustomConfig['webSearch'],
): TCustomConfig['webSearch'] { ): TCustomConfig['webSearch'] {
const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}';
const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}';
const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}';
const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}';
const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}';
const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}';
@ -25,6 +27,8 @@ export function loadWebSearchConfig(
jinaApiKey, jinaApiKey,
cohereApiKey, cohereApiKey,
serperApiKey, serperApiKey,
searxngInstanceUrl,
searxngApiKey,
firecrawlApiKey, firecrawlApiKey,
firecrawlApiUrl, firecrawlApiUrl,
}; };
@ -32,6 +36,8 @@ export function loadWebSearchConfig(
export type TWebSearchKeys = export type TWebSearchKeys =
| 'serperApiKey' | 'serperApiKey'
| 'searxngInstanceUrl'
| 'searxngApiKey'
| 'firecrawlApiKey' | 'firecrawlApiKey'
| 'firecrawlApiUrl' | 'firecrawlApiUrl'
| 'jinaApiKey' | 'jinaApiKey'
@ -47,6 +53,11 @@ export const webSearchAuth = {
serper: { serper: {
serperApiKey: 1 as const, serperApiKey: 1 as const,
}, },
searxng: {
searxngInstanceUrl: 1 as const,
/** Optional (0) */
searxngApiKey: 0 as const,
},
}, },
scrapers: { scrapers: {
firecrawl: { firecrawl: {