feat(chat): add Querit web search provider (#17813)

This commit is contained in:
EthanZhang
2026-08-05 09:54:46 +08:00
committed by GitHub
parent 4d68e154ce
commit bdcd8aadde
32 changed files with 1253 additions and 134 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,51 +0,0 @@
import { useTranslate } from '@/hooks/common-hooks';
import { useFormContext } from 'react-hook-form';
import PasswordInput from './originui/password-input';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
interface IProps {
name?: string;
}
export function TavilyFormField({
name = 'prompt_config.tavily_api_key',
}: IProps) {
const form = useFormContext();
const { t } = useTranslate('chat');
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('tavilyApiKeyTip')}>Tavily API Key</FormLabel>
<FormControl>
<PasswordInput
{...field}
placeholder={t('tavilyApiKeyMessage')}
autoComplete="new-password"
></PasswordInput>
</FormControl>
<FormDescription>
<a
href="https://app.tavily.com/home"
target={'_blank'}
rel="noreferrer"
>
{t('tavilyApiKeyHelp')}
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -0,0 +1,131 @@
import queritLogo from '@/assets/querit.png';
import tavilyLogo from '@/assets/svg/tavily.svg';
import { RAGFlowSelect } from '@/components/ui/select';
import { WebSearchProvider } from '@/constants/chat';
import { useTranslate } from '@/hooks/common-hooks';
import { prefixName } from '@/utils/form';
import { useFormContext, useWatch } from 'react-hook-form';
import PasswordInput from './originui/password-input';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './ui/form';
interface IProps {
prefix?: string;
}
const providerOptions = [
{
name: 'Tavily',
logo: tavilyLogo,
value: WebSearchProvider.Tavily,
},
{
name: 'Querit',
logo: queritLogo,
value: WebSearchProvider.Querit,
},
]
.sort((left, right) => left.name.localeCompare(right.name))
.map(({ name, logo, value }) => ({
label: (
<span className="flex items-center gap-2">
<img
src={logo}
alt=""
aria-hidden="true"
className="size-4 shrink-0 object-contain"
/>
{name}
</span>
),
value,
}));
const providerKeyConfig = {
[WebSearchProvider.Tavily]: {
name: 'prompt_config.tavily_api_key',
label: 'Tavily API Key',
tip: 'tavilyApiKeyTip',
placeholder: 'tavilyApiKeyMessage',
helpUrl: 'https://app.tavily.com/home',
},
[WebSearchProvider.Querit]: {
name: 'prompt_config.querit_api_key',
label: 'Querit API Key',
tip: 'queritApiKeyTip',
placeholder: 'queritApiKeyMessage',
helpUrl: 'https://querit.ai',
},
} as const;
export function WebSearchFormField({ prefix = '' }: IProps) {
const form = useFormContext();
const { t } = useTranslate('chat');
const providerName = prefixName(prefix, 'prompt_config.web_search_provider');
const selectedProvider = useWatch({
control: form.control,
name: providerName,
});
const keyConfig = providerKeyConfig[selectedProvider as WebSearchProvider];
return (
<>
<FormField
control={form.control}
name={providerName}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('webSearchProviderTip')}>
{t('webSearchProvider')}
</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
value={field.value}
options={providerOptions}
placeholder={t('webSearchProviderPlaceholder')}
triggerTestId="web-search-provider"
optionTestIdPrefix="web-search-provider-option"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{keyConfig && (
<FormField
key={selectedProvider}
control={form.control}
name={prefixName(prefix, keyConfig.name)}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t(keyConfig.tip)}>
{keyConfig.label}
</FormLabel>
<FormControl>
<PasswordInput
{...field}
value={field.value ?? ''}
placeholder={t(keyConfig.placeholder)}
autoComplete="new-password"
/>
</FormControl>
<FormDescription>
<a href={keyConfig.helpUrl} target="_blank" rel="noreferrer">
{t('tavilyApiKeyHelp')}
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
);
}

View File

@@ -39,3 +39,8 @@ export enum DatasetMetadata {
SemiAutomatic = 'semi_auto',
Manual = 'manual',
}
export enum WebSearchProvider {
Tavily = 'tavily',
Querit = 'querit',
}

View File

@@ -1,4 +1,4 @@
import { MessageType } from '@/constants/chat';
import { MessageType, WebSearchProvider } from '@/constants/chat';
import { IAttachment } from '@/hooks/use-send-message';
export interface IDocumentDownloadInfo {
@@ -21,6 +21,8 @@ export interface PromptConfig {
reasoning?: boolean;
cross_languages?: Array<string>;
tavily_api_key?: string;
querit_api_key?: string;
web_search_provider?: WebSearchProvider;
toc_enhance?: boolean;
reference_metadata?: {
include?: boolean;
@@ -202,6 +204,7 @@ export interface IExternalChatInfo {
title: string;
prologue?: string;
has_tavily_key?: boolean;
has_web_search_provider?: boolean;
llm_id?: string;
}

View File

@@ -1244,6 +1244,13 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
tavilyApiKeyTip:
'If an API Key is correctly set here, Tavily-based web searches will be used to supplement dataset retrieval.',
tavilyApiKeyMessage: 'Please enter your Tavily API Key',
webSearchProvider: 'Web search provider',
webSearchProviderTip:
'Select the service used when Internet search is enabled.',
webSearchProviderPlaceholder: 'Select a web search provider',
queritApiKeyTip:
'When Querit is selected, its web search results supplement dataset retrieval.',
queritApiKeyMessage: 'Please enter your Querit API Key',
tavilyApiKeyHelp: 'How to get it?',
crossLanguage: 'Cross-language search',
crossLanguagePlaceholder: 'Select value',

View File

@@ -1131,6 +1131,12 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
tavilyApiKeyTip:
'如果 API 密钥设置正确,它将利用 Tavily 进行网络搜索作为知识库的补充。',
tavilyApiKeyMessage: '请输入你的 Tavily API Key',
webSearchProvider: '网络搜索服务',
webSearchProviderTip: '选择启用联网搜索时使用的搜索服务。',
webSearchProviderPlaceholder: '请选择网络搜索服务',
queritApiKeyTip:
'选择 Querit 后,将使用 Querit 的网络搜索结果补充知识库检索。',
queritApiKeyMessage: '请输入你的 Querit API Key',
tavilyApiKeyHelp: '如何获取?',
crossLanguage: '跨语言搜索',
crossLanguagePlaceholder: '请选择',

View File

@@ -6,8 +6,6 @@ import { MetadataFilter } from '@/components/metadata-filter';
import { RerankFormFields } from '@/components/rerank';
import { SimilaritySliderFormField } from '@/components/similarity-slider';
import { SwitchFormField } from '@/components/switch-fom-field';
import { TavilyFormField } from '@/components/tavily-form-field';
import { TopNFormField } from '@/components/top-n-item';
import {
FormControl,
@@ -19,7 +17,7 @@ import {
import { MultiSelect } from '@/components/ui/multi-select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { WebSearchFormField } from '@/components/web-search-form-field';
import { useFetchKnowledgeMetadataKeys } from '@/hooks/use-knowledge-request';
import { prefixName } from '@/utils/form';
import { getDirAttribute } from '@/utils/text-direction';
@@ -127,9 +125,7 @@ export function ChatPromptEngine({ prefix = '' }: ChatPromptEngineProps) {
label={t('chat.tts')}
tooltip={t('chat.ttsTip')}
></SwitchFormField>
<TavilyFormField
name={prefixName(prefix, 'prompt_config.tavily_api_key')}
></TavilyFormField>
<WebSearchFormField prefix={prefix} />
<MetadataFilter></MetadataFilter>
<FormField
control={form.control}

View File

@@ -22,6 +22,7 @@ import ChatBasicSetting from './chat-basic-settings';
import { ChatPromptEngine } from './chat-prompt-engine';
import { SavingButton } from './saving-button';
import { useChatSettingSchema } from './use-chat-setting-schema';
import { getWebSearchProvider } from '../web-search-api-key';
type ChatSettingsProps = { hasSingleChatBox: boolean };
@@ -135,6 +136,7 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
...data,
prompt_config: {
...data.prompt_config,
web_search_provider: getWebSearchProvider(data.prompt_config),
reference_metadata: normalizedReferenceMetadata,
},
...llmSettingEnabledValues,

View File

@@ -9,6 +9,7 @@ import {
vectorSimilarityWeightSchema,
} from '@/components/similarity-slider';
import { topnSchema } from '@/components/top-n-item';
import { WebSearchProvider } from '@/constants/chat';
import { useTranslate } from '@/hooks/common-hooks';
import { z } from 'zod';
@@ -32,6 +33,10 @@ export function useChatSettingSchema() {
)
.optional(),
tavily_api_key: z.string().optional(),
querit_api_key: z.string().optional(),
web_search_provider: z
.enum([WebSearchProvider.Tavily, WebSearchProvider.Querit])
.optional(),
reasoning: z.boolean().optional(),
cross_languages: z.array(z.string()).optional(),
reference_metadata: z

View File

@@ -0,0 +1,73 @@
import { WebSearchProvider } from '@/constants/chat';
import type { PromptConfig } from '@/interfaces/database/chat';
import { getWebSearchApiKey, getWebSearchProvider } from './web-search-api-key';
describe('getWebSearchProvider', () => {
it('does not select a provider for a new unconfigured dialog', () => {
expect(getWebSearchProvider({} as PromptConfig)).toBeUndefined();
});
it('selects Tavily for a legacy dialog with a Tavily key', () => {
const promptConfig = {
tavily_api_key: 'tvly-test',
} as PromptConfig;
expect(getWebSearchProvider(promptConfig)).toBe(WebSearchProvider.Tavily);
});
});
describe('getWebSearchApiKey', () => {
it('uses Tavily for dialogs saved before provider selection existed', () => {
const promptConfig = {
tavily_api_key: 'tvly-test',
} as PromptConfig;
expect(getWebSearchApiKey(promptConfig)).toBe('tvly-test');
});
it('uses only the selected Querit key', () => {
const promptConfig = {
web_search_provider: WebSearchProvider.Querit,
querit_api_key: 'querit-test',
tavily_api_key: 'tvly-test',
} as PromptConfig;
expect(getWebSearchApiKey(promptConfig)).toBe('querit-test');
});
it('does not fall back to Tavily when Querit is selected without a key', () => {
const promptConfig = {
web_search_provider: WebSearchProvider.Querit,
tavily_api_key: 'tvly-test',
} as PromptConfig;
expect(getWebSearchApiKey(promptConfig)).toBeUndefined();
});
it('treats a whitespace-only key as unconfigured', () => {
const promptConfig = {
web_search_provider: WebSearchProvider.Querit,
querit_api_key: ' ',
} as PromptConfig;
expect(getWebSearchApiKey(promptConfig)).toBe('');
});
it('does not fall back to Tavily for an unsupported provider', () => {
const promptConfig = {
web_search_provider: 'unsupported',
tavily_api_key: 'tvly-test',
} as unknown as PromptConfig;
expect(getWebSearchApiKey(promptConfig)).toBeUndefined();
});
it('treats a non-string key as unconfigured', () => {
const promptConfig = {
web_search_provider: WebSearchProvider.Querit,
querit_api_key: 123,
} as unknown as PromptConfig;
expect(getWebSearchApiKey(promptConfig)).toBeUndefined();
});
});

View File

@@ -1,8 +1,9 @@
import { useFetchChat } from '@/hooks/use-chat-request';
import { isEmpty } from 'lodash';
import { getWebSearchApiKey } from './web-search-api-key';
export function useShowInternet() {
const { data: currentDialog } = useFetchChat();
return !isEmpty(currentDialog?.prompt_config?.tavily_api_key);
return !isEmpty(getWebSearchApiKey(currentDialog?.prompt_config));
}

View File

@@ -0,0 +1,41 @@
import { WebSearchProvider } from '@/constants/chat';
import type { PromptConfig } from '@/interfaces/database/chat';
export function getWebSearchProvider(promptConfig?: PromptConfig) {
const provider = promptConfig?.web_search_provider;
if (
provider === WebSearchProvider.Tavily ||
provider === WebSearchProvider.Querit
) {
return provider;
}
if (
provider === undefined &&
typeof promptConfig?.tavily_api_key === 'string' &&
promptConfig.tavily_api_key.trim()
) {
return WebSearchProvider.Tavily;
}
return undefined;
}
export function getWebSearchApiKey(promptConfig?: PromptConfig) {
const provider = getWebSearchProvider(promptConfig);
let apiKey: unknown;
switch (provider) {
case WebSearchProvider.Tavily:
apiKey = promptConfig?.tavily_api_key;
break;
case WebSearchProvider.Querit:
apiKey = promptConfig?.querit_api_key;
break;
default:
return undefined;
}
return typeof apiKey === 'string' ? apiKey.trim() : undefined;
}

View File

@@ -115,7 +115,9 @@ const ChatContainer = () => {
showUploadIcon={false}
stopOutputMessage={stopOutputMessage}
showReasoning
showInternet={chatInfo?.has_tavily_key}
showInternet={
chatInfo?.has_web_search_provider ?? chatInfo?.has_tavily_key
}
></NextMessageInput>
</div>
</div>