mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00: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-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",
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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';
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}'),
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue