Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ VIDEO_GROK_BASE_URL=
# in chat requests. See: https://docs.x.ai/docs/guides/tools/search-tools

TAVILY_API_KEY=
BOCHA_API_KEY=
BOCHA_BASE_URL=https://api.bocha.cn

# --- Proxy (optional) --------------------------------------------------------

Expand Down
2 changes: 2 additions & 0 deletions app/api/generate-classroom/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export async function POST(req: NextRequest) {
...(rawBody.pdfContent ? { pdfContent: rawBody.pdfContent } : {}),

...(rawBody.enableWebSearch != null ? { enableWebSearch: rawBody.enableWebSearch } : {}),
...(rawBody.webSearchProviderId ? { webSearchProviderId: rawBody.webSearchProviderId } : {}),
...(rawBody.webSearchApiKey ? { webSearchApiKey: rawBody.webSearchApiKey } : {}),
...(rawBody.enableImageGeneration != null
? { enableImageGeneration: rawBody.enableImageGeneration }
: {}),
Expand Down
27 changes: 22 additions & 5 deletions app/api/web-search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
* Web Search API
*
* POST /api/web-search
* Simple JSON request/response using Tavily search.
* Simple JSON request/response using the configured web search provider.
*/

import { NextRequest } from 'next/server';
import { callLLM } from '@/lib/ai/llm';
import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily';
import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search';
import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
Expand All @@ -17,6 +17,9 @@ import {
} from '@/lib/server/search-query-builder';
import { resolveModelFromRequest } from '@/lib/server/resolve-model';
import type { AICallFn } from '@/lib/generation/pipeline-types';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import { resolveWebSearchRouteBaseUrl } from '@/lib/server/web-search-config';

const log = createLogger('WebSearch');

Expand All @@ -27,26 +30,40 @@ export async function POST(req: NextRequest) {
const {
query: requestQuery,
pdfText,
providerId: requestProviderId,
apiKey: clientApiKey,
baseUrl: clientBaseUrl,
} = body as {
query?: string;
pdfText?: string;
providerId?: WebSearchProviderId;
apiKey?: string;
baseUrl?: string;
};
query = requestQuery;

if (!query || !query.trim()) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required');
}

const apiKey = resolveWebSearchApiKey(clientApiKey);
const providerId: WebSearchProviderId =
requestProviderId && WEB_SEARCH_PROVIDERS[requestProviderId] ? requestProviderId : 'tavily';
const provider = WEB_SEARCH_PROVIDERS[providerId];
const apiKey = resolveWebSearchApiKey(providerId, clientApiKey);
if (!apiKey) {
return apiError(
'MISSING_API_KEY',
400,
'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.',
`${provider.name} API key is not configured. Set it in Settings → Web Search or configure ${providerId === 'bocha' ? 'BOCHA_API_KEY' : 'TAVILY_API_KEY'} on the server.`,
);
}
let baseUrl: string | undefined;
try {
baseUrl = resolveWebSearchRouteBaseUrl(providerId, clientBaseUrl);
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid web search base URL';
return apiError('INVALID_REQUEST', 400, message);
}

// Clamp rewrite input at the route boundary; framework body limits still apply to total request size.
const boundedPdfText = pdfText?.slice(0, SEARCH_QUERY_REWRITE_EXCERPT_LENGTH);
Expand Down Expand Up @@ -83,7 +100,7 @@ export async function POST(req: NextRequest) {
finalQueryLength: searchQuery.finalQueryLength,
});

const result = await searchWithTavily({ query: searchQuery.query, apiKey });
const result = await searchWeb({ providerId, query: searchQuery.query, apiKey, baseUrl });
const context = formatSearchResultsAsContext(result);

return apiSuccess({
Expand Down
8 changes: 5 additions & 3 deletions app/generation-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,18 @@ function GenerationPreviewContent() {
setWebSearchSources([]);

const wsSettings = useSettingsStore.getState();
const wsApiKey =
wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey;
const wsProviderId = wsSettings.webSearchProviderId;
const wsConfig = wsSettings.webSearchProvidersConfig?.[wsProviderId];
const res = await fetch('/api/web-search', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(
withThinkingConfig({
query: currentSession.requirements.requirement,
pdfText: currentSession.pdfText || undefined,
apiKey: wsApiKey || undefined,
providerId: wsProviderId,
apiKey: wsConfig?.apiKey || undefined,
baseUrl: wsConfig?.baseUrl || undefined,
}),
),
signal,
Expand Down
10 changes: 7 additions & 3 deletions components/generation/generation-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
import type { PDFProviderId } from '@/lib/pdf/types';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import { WEB_SEARCH_PROVIDERS, getWebSearchProviderDisplayName } from '@/lib/web-search/constants';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import type { ProviderId } from '@/lib/ai/providers';
import type {
Expand Down Expand Up @@ -304,7 +304,11 @@ export function GenerationToolbar({
<button className={webSearch ? pillActive : pillMuted}>
<Globe2 className={cn('size-3.5', webSearch && 'animate-pulse')} />
{webSearch && (
<span>{WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || 'Search'}</span>
<span>
{WEB_SEARCH_PROVIDERS[webSearchProviderId]
? getWebSearchProviderDisplayName(webSearchProviderId, t)
: 'Search'}
</span>
)}
</button>
</PopoverTrigger>
Expand Down Expand Up @@ -357,7 +361,7 @@ export function GenerationToolbar({
<div
className={cn('flex items-center gap-1.5', !available && 'opacity-50')}
>
{provider.name}
{getWebSearchProviderDisplayName(provider.id, t)}
{cfg?.isServerConfigured && (
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
{t('settings.serverConfigured')}
Expand Down
11 changes: 8 additions & 3 deletions components/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { ASRSettings } from './asr-settings';
import { ASR_PROVIDERS } from '@/lib/audio/constants';
import type { ASRProviderId } from '@/lib/audio/types';
import { WebSearchSettings } from './web-search-settings';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import { WEB_SEARCH_PROVIDERS, getWebSearchProviderDisplayName } from '@/lib/web-search/constants';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import { GeneralSettings } from './general-settings';
import { ModelEditDialog } from './model-edit-dialog';
Expand Down Expand Up @@ -607,7 +607,9 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
) : (
<Box className="h-8 w-8 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">{wsProvider.name}</h2>
<h2 className="text-lg font-semibold">
{getWebSearchProviderDisplayName(wsProvider.id, t)}
</h2>
</>
);
}
Expand Down Expand Up @@ -864,7 +866,10 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD
{activeSection === 'web-search' && (
<>
<ProviderListColumn
providers={Object.values(WEB_SEARCH_PROVIDERS)}
providers={Object.values(WEB_SEARCH_PROVIDERS).map((provider) => ({
...provider,
name: getWebSearchProviderDisplayName(provider.id, t),
}))}
configs={webSearchProvidersConfig}
selectedId={selectedWebSearchProviderId}
onSelect={setSelectedWebSearchProviderId}
Expand Down
11 changes: 9 additions & 2 deletions components/settings/web-search-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
const provider = WEB_SEARCH_PROVIDERS[selectedProviderId];
const isServerConfigured = !!webSearchProvidersConfig[selectedProviderId]?.isServerConfigured;

const buildRequestUrl = (baseUrl: string) => {
const trimmed = baseUrl.replace(/\/$/, '');
if (!provider.endpointPath) return trimmed;
if (trimmed.endsWith(provider.endpointPath)) return trimmed;
return `${trimmed}${provider.endpointPath}`;
};

// Reset showApiKey when provider changes (derived state pattern)
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
if (selectedProviderId !== prevSelectedProviderId) {
Expand Down Expand Up @@ -83,7 +90,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={provider.defaultBaseUrl || 'https://api.tavily.com'}
placeholder={provider.defaultBaseUrl || ''}
value={webSearchProvidersConfig[selectedProviderId]?.baseUrl || ''}
onChange={(e) =>
setWebSearchProviderConfig(selectedProviderId, {
Expand All @@ -102,7 +109,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps
provider.defaultBaseUrl ||
'';
if (!effectiveBaseUrl) return null;
const fullUrl = effectiveBaseUrl + '/search';
const fullUrl = buildRequestUrl(effectiveBaseUrl);
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {fullUrl}
Expand Down
12 changes: 7 additions & 5 deletions lib/i18n/locales/ar-SA.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
"ollama": "Ollama (محلي)"
"ollama": "Ollama (محلي)",
"tavily": "Tavily",
"bocha": "Bocha"
},
"providerTypes": {
"openai": "بروتوكول OpenAI",
Expand Down Expand Up @@ -954,12 +956,12 @@
"clearCacheSuccess": "تم مسح الذاكرة المؤقتة، ستتم إعادة تحميل الصفحة قريبًا",
"clearCacheFailed": "فشل مسح الذاكرة المؤقتة، يرجى المحاولة مرة أخرى",
"webSearchSettings": "بحث الإنترنت",
"webSearchApiKey": "مفتاح API لـ Tavily",
"webSearchApiKeyPlaceholder": "أدخل مفتاح API لـ Tavily",
"webSearchApiKey": "مفتاح API للبحث",
"webSearchApiKeyPlaceholder": "أدخل مفتاح API للبحث",
"webSearchApiKeyPlaceholderServer": "تم تكوين مفتاح الخادم، يمكنك التجاوز اختياريًا",
"webSearchApiKeyHint": "احصل على مفتاح API من tavily.com للبحث في الإنترنت",
"webSearchApiKeyHint": "احصل على مفتاح API من مزود البحث المحدد",
"webSearchBaseUrl": "العنوان الأساسي",
"webSearchServerConfigured": "تم تكوين مفتاح API لـ Tavily على الخادم",
"webSearchServerConfigured": "تم تكوين مفتاح API للبحث على الخادم",
"optional": "اختياري"
},
"profile": {
Expand Down
12 changes: 7 additions & 5 deletions lib/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
"ollama": "Ollama (Local)"
"ollama": "Ollama (Local)",
"tavily": "Tavily",
"bocha": "Bocha"
},
"providerTypes": {
"openai": "OpenAI Protocol",
Expand Down Expand Up @@ -954,12 +956,12 @@
"clearCacheSuccess": "Cache cleared, page will refresh shortly",
"clearCacheFailed": "Failed to clear cache, please try again",
"webSearchSettings": "Web Search",
"webSearchApiKey": "Tavily API Key",
"webSearchApiKeyPlaceholder": "Enter your Tavily API Key",
"webSearchApiKey": "Search API Key",
"webSearchApiKeyPlaceholder": "Enter your search API key",
"webSearchApiKeyPlaceholderServer": "Server key configured, optionally override",
"webSearchApiKeyHint": "Get an API key from tavily.com for web search",
"webSearchApiKeyHint": "Get an API key from the selected search provider",
"webSearchBaseUrl": "Base URL",
"webSearchServerConfigured": "Server-side Tavily API key is configured",
"webSearchServerConfigured": "Server-side search API key is configured",
"optional": "Optional"
},
"profile": {
Expand Down
12 changes: 7 additions & 5 deletions lib/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
"ollama": "Ollama(ローカルモデル)"
"ollama": "Ollama(ローカルモデル)",
"tavily": "Tavily",
"bocha": "Bocha"
},
"providerTypes": {
"openai": "OpenAIプロトコル",
Expand Down Expand Up @@ -954,12 +956,12 @@
"clearCacheSuccess": "キャッシュをクリアしました。まもなくページが更新されます",
"clearCacheFailed": "キャッシュのクリアに失敗しました。もう一度お試しください",
"webSearchSettings": "ウェブ検索",
"webSearchApiKey": "Tavily APIキー",
"webSearchApiKeyPlaceholder": "Tavily APIキーを入力",
"webSearchApiKey": "検索APIキー",
"webSearchApiKeyPlaceholder": "検索APIキーを入力",
"webSearchApiKeyPlaceholderServer": "サーバーキー設定済み、任意で上書き",
"webSearchApiKeyHint": "ウェブ検索用のAPIキーをtavily.comで取得してください",
"webSearchApiKeyHint": "選択した検索プロバイダーからAPIキーを取得してください",
"webSearchBaseUrl": "ベースURL",
"webSearchServerConfigured": "サーバー側でTavily APIキーが設定済みです",
"webSearchServerConfigured": "サーバー側で検索APIキーが設定済みです",
"optional": "任意"
},
"profile": {
Expand Down
12 changes: 7 additions & 5 deletions lib/i18n/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "Tencent Hunyuan",
"xiaomi": "Xiaomi MiMo",
"ollama": "Ollama (Локальный)"
"ollama": "Ollama (Локальный)",
"tavily": "Tavily",
"bocha": "Bocha"
},
"providerTypes": {
"openai": "Протокол OpenAI",
Expand Down Expand Up @@ -954,12 +956,12 @@
"clearCacheSuccess": "Кэш очищен, страница скоро обновится",
"clearCacheFailed": "Не удалось очистить кэш, попробуйте снова",
"webSearchSettings": "Веб-поиск",
"webSearchApiKey": "Tavily API-ключ",
"webSearchApiKeyPlaceholder": "Введите ваш Tavily API-ключ",
"webSearchApiKey": "API-ключ поиска",
"webSearchApiKeyPlaceholder": "Введите API-ключ поиска",
"webSearchApiKeyPlaceholderServer": "Серверный ключ настроен, можно ввести свой",
"webSearchApiKeyHint": "Получите API-ключ на tavily.com для веб-поиска",
"webSearchApiKeyHint": "Получите API-ключ у выбранного поискового провайдера",
"webSearchBaseUrl": "Base URL",
"webSearchServerConfigured": "Серверный Tavily API-ключ настроен",
"webSearchServerConfigured": "Серверный API-ключ поиска настроен",
"optional": "Необязательно"
},
"profile": {
Expand Down
12 changes: 7 additions & 5 deletions lib/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@
"grok": "Grok",
"tencent-hunyuan": "腾讯混元",
"xiaomi": "小米 MiMo",
"ollama": "Ollama(本地模型)"
"ollama": "Ollama(本地模型)",
"tavily": "Tavily",
"bocha": "博查"
},
"providerTypes": {
"openai": "OpenAI 协议",
Expand Down Expand Up @@ -954,12 +956,12 @@
"clearCacheSuccess": "缓存已清空,页面即将刷新",
"clearCacheFailed": "清空缓存失败,请重试",
"webSearchSettings": "网络搜索",
"webSearchApiKey": "Tavily API Key",
"webSearchApiKeyPlaceholder": "输入你的 Tavily API Key",
"webSearchApiKey": "搜索 API Key",
"webSearchApiKeyPlaceholder": "输入你的搜索 API Key",
"webSearchApiKeyPlaceholderServer": "已配置服务端密钥,可选填覆盖",
"webSearchApiKeyHint": "从 tavily.com 获取 API Key,用于网络搜索",
"webSearchApiKeyHint": "从所选搜索服务商获取 API Key,用于网络搜索",
"webSearchBaseUrl": "Base URL",
"webSearchServerConfigured": "服务端已配置 Tavily API Key",
"webSearchServerConfigured": "服务端已配置搜索 API Key",
"optional": "可选"
},
"profile": {
Expand Down
Loading
Loading