diff --git a/api/package.json b/api/package.json index 1fe8cff2fc..cc1a51936c 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 7edccc2c0d..678e8a90db 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -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: { diff --git a/client/src/components/Chat/Input/WebSearch.tsx b/client/src/components/Chat/Input/WebSearch.tsx index 44b5c4a28c..f5139509fc 100644 --- a/client/src/components/Chat/Input/WebSearch.tsx +++ b/client/src/components/Chat/Input/WebSearch.tsx @@ -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)) && ( ( - 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 ( - <> - (e.target.readOnly = false)} - {...register('jinaApiKey')} - /> -
- - {localize('com_ui_web_search_reranker_jina_key')} - -
- - ); - } - if (config?.webSearch?.rerankerType === RerankerTypes.COHERE) { - return ( - <> - (e.target.readOnly = false)} - {...register('cohereApiKey')} - /> -
- - {localize('com_ui_web_search_reranker_cohere_key')} - -
- - ); - } - if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.JINA) { - return ( - <> - (e.target.readOnly = false)} - {...register('jinaApiKey')} - /> -
- - {localize('com_ui_web_search_reranker_jina_key')} - -
- - ); - } - if (!config?.webSearch?.rerankerType && selectedReranker === RerankerTypes.COHERE) { - return ( - <> - (e.target.readOnly = false)} - {...register('cohereApiKey')} - /> -
- - {localize('com_ui_web_search_reranker_cohere_key')} - -
- - ); - } - 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 (
{localize('com_ui_web_search')}
-
- {localize('com_ui_web_search_api_subtitle')} -
- {/* Search Provider Section */} + {/* Provider Section */} {providerAuthType !== AuthType.SYSTEM_DEFINED && ( -
-
- - {showProviderDropdown ? ( - 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')} - - - } - /> - ) : ( -
- {localize('com_ui_web_search_provider_serper')} -
- )} -
- (e.target.readOnly = false)} - {...register('serperApiKey', { required: true })} - /> -
- - {localize('com_ui_web_search_provider_serper_key')} - -
-
+ + setDropdownOpen((prev) => ({ ...prev, provider: open })) + } + dropdownKey="provider" + /> )} {/* Scraper Section */} {scraperAuthType !== AuthType.SYSTEM_DEFINED && ( -
-
- - {showScraperDropdown ? ( - 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')} - - - } - /> - ) : ( -
- {localize('com_ui_web_search_scraper_firecrawl')} -
- )} -
- (e.target.readOnly = false)} - className="mb-2" - {...register('firecrawlApiKey')} - /> - - -
+ + setDropdownOpen((prev) => ({ ...prev, scraper: open })) + } + dropdownKey="scraper" + /> )} {/* Reranker Section */} {rerankerAuthType !== AuthType.SYSTEM_DEFINED && ( -
-
- - {showRerankerDropdown && ( - 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')} - - - } - /> - )} - {!showRerankerDropdown && ( -
- {config?.webSearch?.rerankerType === RerankerTypes.COHERE - ? localize('com_ui_web_search_reranker_cohere') - : localize('com_ui_web_search_reranker_jina')} -
- )} -
- {renderRerankerInput()} -
+ + setDropdownOpen((prev) => ({ ...prev, reranker: open })) + } + dropdownKey="reranker" + /> )} @@ -353,10 +227,7 @@ export default function ApiKeyDialog({ }} buttons={ isToolAuthenticated && ( - ) diff --git a/client/src/components/SidePanel/Agents/Search/InputSection.tsx b/client/src/components/SidePanel/Agents/Search/InputSection.tsx new file mode 100644 index 0000000000..e80e442603 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Search/InputSection.tsx @@ -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; +} + +interface InputSectionProps { + title: string; + selectedKey: string; + onSelectionChange: (key: string) => void; + dropdownOptions: DropdownOption[]; + showDropdown: boolean; + register: UseFormRegister; + 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>({}); + 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 ( +
+
+ + {showDropdown ? ( + setDropdownOpen(!dropdownOpen)} + className="flex items-center rounded-md border border-border-light px-3 py-1 text-sm text-text-secondary" + > + {selectedOption?.label} + + + } + /> + ) : ( +
{selectedOption?.label}
+ )} +
+ {selectedOption?.inputs && + Object.entries(selectedOption.inputs).map(([name, config], index) => ( +
+
+ (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' && ( + + )} +
+ {config.link && ( + + )} +
+ ))} +
+ ); +} + +export type { InputConfig, DropdownOption }; diff --git a/client/src/hooks/Plugins/useAuthSearchTool.ts b/client/src/hooks/Plugins/useAuthSearchTool.ts index cca56485dc..4b9649ac2a 100644 --- a/client/src/hooks/Plugins/useAuthSearchTool.ts +++ b/client/src/hooks/Plugins/useAuthSearchTool.ts @@ -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, diff --git a/client/src/hooks/Plugins/useSearchApiKeyForm.ts b/client/src/hooks/Plugins/useSearchApiKeyForm.ts index 86543e303a..9133abf3af 100644 --- a/client/src/hooks/Plugins/useSearchApiKeyForm.ts +++ b/client/src/hooks/Plugins/useSearchApiKeyForm.ts @@ -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(() => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c8964cded0..00024d8f37 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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" } diff --git a/package-lock.json b/package-lock.json index 64b7639e32..d606db5125 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/api/package.json b/packages/api/package.json index 9dd34aee83..c71f7983b3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 7ee2efbf42..2e042e2681 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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}'), diff --git a/packages/data-provider/src/web.ts b/packages/data-provider/src/web.ts index 0b0ab63c86..18ce2d8db9 100644 --- a/packages/data-provider/src/web.ts +++ b/packages/data-provider/src/web.ts @@ -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: {