🔍 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-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.51",
"@librechat/agents": "^2.4.52",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",

View file

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

View file

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

View file

@ -1,13 +1,16 @@
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 {
AuthType,
SearchCategories,
RerankerTypes,
SearchProviders,
ScraperTypes,
} from 'librechat-data-provider';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { MenuItemProps } from '~/common';
import { Input, Button, OGDialog, Label } from '~/components/ui';
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
import InputSection, { type DropdownOption } from './InputSection';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import DropdownPopup from '~/components/ui/DropdownPopup';
import { Button, OGDialog } from '~/components/ui';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
@ -36,151 +39,119 @@ export default function ApiKeyDialog({
}) {
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 [selectedProvider, setSelectedProvider] = useState(
config?.webSearch?.searchProvider || SearchProviders.SERPER,
);
const [selectedReranker, setSelectedReranker] = useState(
config?.webSearch?.rerankerType || RerankerTypes.JINA,
);
const [selectedScraper, setSelectedScraper] = useState(ScraperTypes.FIRECRAWL);
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
const [scraperDropdownOpen, setScraperDropdownOpen] = useState(false);
const [rerankerDropdownOpen, setRerankerDropdownOpen] = useState(false);
const providerItems: MenuItemProps[] = [
const providerOptions: DropdownOption[] = [
{
key: SearchProviders.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[] = [
{
label: localize('com_ui_web_search_scraper_firecrawl'),
onClick: () => {},
},
];
const rerankerItems: MenuItemProps[] = [
const rerankerOptions: DropdownOption[] = [
{
key: RerankerTypes.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'),
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 showScraperDropdown = !config?.webSearch?.scraperType;
const showRerankerDropdown = !config?.webSearch?.rerankerType;
const scraperOptions: DropdownOption[] = [
{
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 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;
}
const handleProviderChange = (key: string) => {
setSelectedProvider(key as SearchProviders);
};
const handleRerankerChange = (key: string) => {
setSelectedReranker(key as RerankerTypes);
};
const handleScraperChange = (key: string) => {
setSelectedScraper(key as ScraperTypes);
};
return (
<OGDialog
@ -195,153 +166,56 @@ export default function ApiKeyDialog({
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 */}
{/* 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>
<InputSection
title={localize('com_ui_web_search_provider')}
selectedKey={selectedProvider}
onSelectionChange={handleProviderChange}
dropdownOptions={providerOptions}
showDropdown={!config?.webSearch?.searchProvider}
register={register}
dropdownOpen={dropdownOpen.provider}
setDropdownOpen={(open) =>
setDropdownOpen((prev) => ({ ...prev, provider: open }))
}
dropdownKey="provider"
/>
)}
{/* 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>
<InputSection
title={localize('com_ui_web_search_scraper')}
selectedKey={selectedScraper}
onSelectionChange={handleScraperChange}
dropdownOptions={scraperOptions}
showDropdown={!config?.webSearch?.scraperType}
register={register}
dropdownOpen={dropdownOpen.scraper}
setDropdownOpen={(open) =>
setDropdownOpen((prev) => ({ ...prev, scraper: open }))
}
dropdownKey="scraper"
/>
)}
{/* 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>
<InputSection
title={localize('com_ui_web_search_reranker')}
selectedKey={selectedReranker}
onSelectionChange={handleRerankerChange}
dropdownOptions={rerankerOptions}
showDropdown={!config?.webSearch?.rerankerType}
register={register}
dropdownOpen={dropdownOpen.reranker}
setDropdownOpen={(open) =>
setDropdownOpen((prev) => ({ ...prev, reranker: open }))
}
dropdownKey="reranker"
/>
)}
</form>
</>
@ -353,10 +227,7 @@ export default function ApiKeyDialog({
}}
buttons={
isToolAuthenticated && (
<Button
onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
<Button onClick={onRevoke} className="bg-red-500 text-white hover:bg-red-600">
{localize('com_ui_revoke')}
</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';
export type SearchApiKeyFormData = {
// Selected options
selectedProvider: string;
selectedReranker: string;
selectedScraper: string;
// API keys and URLs
serperApiKey: string;
searxngInstanceUrl: string;
searxngApiKey: string;
firecrawlApiKey: string;
firecrawlApiUrl: string;
jinaApiKey: string;
@ -42,6 +49,8 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => {
(data: SearchApiKeyFormData) => {
const auth = Object.entries({
serperApiKey: data.serperApiKey,
searxngInstanceUrl: data.searxngInstanceUrl,
searxngApiKey: data.searxngApiKey,
firecrawlApiKey: data.firecrawlApiKey,
firecrawlApiUrl: data.firecrawlApiUrl,
jinaApiKey: data.jinaApiKey,

View file

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

View file

@ -1050,13 +1050,15 @@
"com_ui_view_memory": "View Memory",
"com_ui_view_source": "View source chat",
"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_firecrawl_url": "Firecrawl API URL (optional)",
"com_ui_web_search_jina_key": "Enter Jina API Key",
"com_ui_web_search_processing": "Processing results",
"com_ui_web_search_provider": "Search Provider",
"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_reading": "Reading results",
"com_ui_web_search_reranker": "Reranker",
@ -1074,5 +1076,7 @@
"com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes",
"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-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.51",
"@librechat/agents": "^2.4.52",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",
@ -19343,9 +19343,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "2.4.51",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.51.tgz",
"integrity": "sha512-wmwas9/XvF+KSSez53iXx4f1yD4e2nDvqzv0kinGk9lbPIGIAOTCLKGOkS1lEHzSkKXUyGmYIzJFwaEqKm52fw==",
"version": "2.4.52",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.52.tgz",
"integrity": "sha512-E0CbuXZEIx3J3MjiZ7wDQuDIMaeGPMkSkcm2foOE2PmneAGiGpIjTgvxa9UjJUUWQku191fydZXr9dE826N1MA==",
"license": "MIT",
"dependencies": {
"@langchain/anthropic": "^0.3.23",
@ -46494,7 +46494,7 @@
"typescript": "^5.0.4"
},
"peerDependencies": {
"@librechat/agents": "^2.4.51",
"@librechat/agents": "^2.4.52",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.13.3",
"axios": "^1.8.2",

View file

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

View file

@ -647,6 +647,8 @@ export enum SafeSearchTypes {
export const webSearchSchema = z.object({
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}'),
firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'),
jinaApiKey: z.string().optional().default('${JINA_API_KEY}'),

View file

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