mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔍 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:
parent
91a2df4759
commit
e0f468da20
12 changed files with 331 additions and 289 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
144
client/src/components/SidePanel/Agents/Search/InputSection.tsx
Normal file
144
client/src/components/SidePanel/Agents/Search/InputSection.tsx
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}'),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue