diff --git a/.env.example b/.env.example index 968671ec0..737a338f4 100644 --- a/.env.example +++ b/.env.example @@ -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) -------------------------------------------------------- diff --git a/app/api/generate-classroom/route.ts b/app/api/generate-classroom/route.ts index d24704a0c..eb5131597 100644 --- a/app/api/generate-classroom/route.ts +++ b/app/api/generate-classroom/route.ts @@ -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 } : {}), diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 3b5f128d2..bd076f2dc 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -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'; @@ -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'); @@ -27,11 +30,15 @@ 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; @@ -39,14 +46,24 @@ export async function POST(req: NextRequest) { 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); @@ -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({ diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 285711292..2f7754af7 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -307,8 +307,8 @@ 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(), @@ -316,7 +316,9 @@ function GenerationPreviewContent() { withThinkingConfig({ query: currentSession.requirements.requirement, pdfText: currentSession.pdfText || undefined, - apiKey: wsApiKey || undefined, + providerId: wsProviderId, + apiKey: wsConfig?.apiKey || undefined, + baseUrl: wsConfig?.baseUrl || undefined, }), ), signal, diff --git a/components/generation/generation-toolbar.tsx b/components/generation/generation-toolbar.tsx index 8e3dc0055..a3067c1e9 100644 --- a/components/generation/generation-toolbar.tsx +++ b/components/generation/generation-toolbar.tsx @@ -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 { @@ -304,7 +304,11 @@ export function GenerationToolbar({ @@ -357,7 +361,7 @@ export function GenerationToolbar({
- {provider.name} + {getWebSearchProviderDisplayName(provider.id, t)} {cfg?.isServerConfigured && ( {t('settings.serverConfigured')} diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 94d65961e..0a2ebac41 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -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'; @@ -607,7 +607,9 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD ) : ( )} -

{wsProvider.name}

+

+ {getWebSearchProviderDisplayName(wsProvider.id, t)} +

); } @@ -864,7 +866,10 @@ export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsD {activeSection === 'web-search' && ( <> ({ + ...provider, + name: getWebSearchProviderDisplayName(provider.id, t), + }))} configs={webSearchProvidersConfig} selectedId={selectedWebSearchProviderId} onSelect={setSelectedWebSearchProviderId} diff --git a/components/settings/web-search-settings.tsx b/components/settings/web-search-settings.tsx index d5cf37761..84d3cf1e8 100644 --- a/components/settings/web-search-settings.tsx +++ b/components/settings/web-search-settings.tsx @@ -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) { @@ -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, { @@ -102,7 +109,7 @@ export function WebSearchSettings({ selectedProviderId }: WebSearchSettingsProps provider.defaultBaseUrl || ''; if (!effectiveBaseUrl) return null; - const fullUrl = effectiveBaseUrl + '/search'; + const fullUrl = buildRequestUrl(effectiveBaseUrl); return (

{t('settings.requestUrl')}: {fullUrl} diff --git a/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index de952a41b..5c178050c 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -454,7 +454,9 @@ "grok": "Grok", "tencent-hunyuan": "Tencent Hunyuan", "xiaomi": "Xiaomi MiMo", - "ollama": "Ollama (محلي)" + "ollama": "Ollama (محلي)", + "tavily": "Tavily", + "bocha": "Bocha" }, "providerTypes": { "openai": "بروتوكول OpenAI", @@ -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": { diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 46535f443..a36b0f15c 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -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", @@ -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": { diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index 1f70154d7..d59022bb1 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -454,7 +454,9 @@ "grok": "Grok", "tencent-hunyuan": "Tencent Hunyuan", "xiaomi": "Xiaomi MiMo", - "ollama": "Ollama(ローカルモデル)" + "ollama": "Ollama(ローカルモデル)", + "tavily": "Tavily", + "bocha": "Bocha" }, "providerTypes": { "openai": "OpenAIプロトコル", @@ -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": { diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index e8cc05b14..c4be26124 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -454,7 +454,9 @@ "grok": "Grok", "tencent-hunyuan": "Tencent Hunyuan", "xiaomi": "Xiaomi MiMo", - "ollama": "Ollama (Локальный)" + "ollama": "Ollama (Локальный)", + "tavily": "Tavily", + "bocha": "Bocha" }, "providerTypes": { "openai": "Протокол OpenAI", @@ -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": { diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index da4482fc3..79892a759 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -454,7 +454,9 @@ "grok": "Grok", "tencent-hunyuan": "腾讯混元", "xiaomi": "小米 MiMo", - "ollama": "Ollama(本地模型)" + "ollama": "Ollama(本地模型)", + "tavily": "Tavily", + "bocha": "博查" }, "providerTypes": { "openai": "OpenAI 协议", @@ -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": { diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 5e4b6dfa1..bf26a0cf0 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -16,10 +16,11 @@ import type { AgentInfo } from '@/lib/generation/pipeline-types'; import { getDefaultAgents } from '@/lib/orchestration/registry/store'; import { createLogger } from '@/lib/logger'; import { isProviderKeyRequired } from '@/lib/ai/providers'; -import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; +import { resolveClassroomWebSearchConfig } from '@/lib/server/web-search-config'; import { resolveModel } from '@/lib/server/resolve-model'; import { buildSearchQuery } from '@/lib/server/search-query-builder'; -import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search'; +import type { WebSearchProviderId } from '@/lib/web-search/types'; import { persistClassroom } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, @@ -36,6 +37,8 @@ export interface GenerateClassroomInput { requirement: string; pdfContent?: { text: string; images: string[] }; enableWebSearch?: boolean; + webSearchProviderId?: WebSearchProviderId; + webSearchApiKey?: string; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; @@ -234,8 +237,8 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - const tavilyKey = resolveWebSearchApiKey(); - if (tavilyKey) { + const webSearchConfig = resolveClassroomWebSearchConfig(input); + if (webSearchConfig) { try { const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); @@ -246,9 +249,11 @@ export async function generateClassroom( finalQueryLength: searchQuery.finalQueryLength, }); - const searchResult = await searchWithTavily({ + const searchResult = await searchWeb({ + providerId: webSearchConfig.providerId, query: searchQuery.query, - apiKey: tavilyKey, + apiKey: webSearchConfig.apiKey, + baseUrl: webSearchConfig.baseUrl, }); researchContext = formatSearchResultsAsContext(searchResult); if (researchContext) { @@ -258,7 +263,7 @@ export async function generateClassroom( log.warn('Web search failed, continuing without search context:', e); } } else { - log.warn('enableWebSearch is true but no Tavily API key configured, skipping web search'); + log.warn('enableWebSearch is true but no web search API key configured, skipping web search'); } } diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 8b5cf6bc5..20442029b 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -99,6 +99,7 @@ const VIDEO_ENV_MAP: Record = { const WEB_SEARCH_ENV_MAP: Record = { TAVILY: 'tavily', + BOCHA: 'bocha', }; // --------------------------------------------------------------------------- @@ -411,7 +412,7 @@ export function resolveVideoBaseUrl( } // --------------------------------------------------------------------------- -// Public API — Web Search (Tavily) +// Public API — Web Search // --------------------------------------------------------------------------- /** Returns server-configured web search providers (no apiKeys exposed) */ @@ -425,10 +426,39 @@ export function getServerWebSearchProviders(): Record server key > TAVILY_API_KEY env > empty */ -export function resolveWebSearchApiKey(clientKey?: string): string { - if (clientKey) return clientKey; - const serverKey = getConfig().webSearch.tavily?.apiKey; +/** + * Resolve web search API key. + * + * Backward-compatible call shapes: + * - resolveWebSearchApiKey(clientKey) -> Tavily key resolution + * - resolveWebSearchApiKey(providerId, clientKey) -> provider-specific resolution + */ +export function resolveWebSearchApiKey(clientKey?: string): string; +export function resolveWebSearchApiKey(providerId: string, clientKey?: string): string; +export function resolveWebSearchApiKey(providerIdOrClientKey?: string, clientKey?: string): string { + const hasProviderId = arguments.length >= 2; + const providerId = hasProviderId ? providerIdOrClientKey || 'tavily' : 'tavily'; + const effectiveClientKey = hasProviderId ? clientKey : providerIdOrClientKey; + + if (effectiveClientKey) return effectiveClientKey; + const serverKey = getConfig().webSearch[providerId]?.apiKey; if (serverKey) return serverKey; - return process.env.TAVILY_API_KEY || ''; + return ''; +} + +export function resolveWebSearchBaseUrl( + providerId: string, + clientBaseUrl?: string, +): string | undefined { + if (clientBaseUrl) return clientBaseUrl; + return getConfig().webSearch[providerId]?.baseUrl; +} + +export function resolveServerWebSearchProviderId(preferredProviderId?: string): string | undefined { + const webSearch = getConfig().webSearch; + if (preferredProviderId && webSearch[preferredProviderId]?.apiKey) { + return preferredProviderId; + } + if (webSearch.tavily?.apiKey) return 'tavily'; + return Object.keys(webSearch)[0]; } diff --git a/lib/server/web-search-config.ts b/lib/server/web-search-config.ts new file mode 100644 index 000000000..9d7c5a571 --- /dev/null +++ b/lib/server/web-search-config.ts @@ -0,0 +1,80 @@ +import { + resolveServerWebSearchProviderId, + resolveWebSearchApiKey, + resolveWebSearchBaseUrl, +} from '@/lib/server/provider-config'; +import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; +import type { WebSearchProviderId } from '@/lib/web-search/types'; + +const OFFICIAL_CLIENT_BASE_URLS: Record = { + tavily: ['https://api.tavily.com', 'https://api.tavily.com/search'], + bocha: [ + 'https://api.bocha.cn', + 'https://api.bocha.cn/v1', + 'https://api.bocha.cn/v1/web-search', + 'https://api.bochaai.com', + 'https://api.bochaai.com/v1', + 'https://api.bochaai.com/v1/web-search', + ], +}; + +function normalizeBaseUrl(value: string): string { + return value.replace(/\/+$/, ''); +} + +function assertWebSearchProviderId( + providerId: string | undefined, +): providerId is WebSearchProviderId { + return !!providerId && providerId in WEB_SEARCH_PROVIDERS; +} + +export function resolveSafeClientWebSearchBaseUrl( + providerId: WebSearchProviderId, + clientBaseUrl?: string, +): string | undefined { + const trimmed = clientBaseUrl?.trim(); + if (!trimmed) return undefined; + + let normalized: string; + try { + const parsed = new URL(trimmed); + normalized = normalizeBaseUrl(parsed.toString()); + } catch { + throw new Error(`Unsupported ${WEB_SEARCH_PROVIDERS[providerId].name} base URL`); + } + + const allowed = OFFICIAL_CLIENT_BASE_URLS[providerId].map(normalizeBaseUrl); + if (!allowed.includes(normalized)) { + throw new Error(`Unsupported ${WEB_SEARCH_PROVIDERS[providerId].name} base URL`); + } + return normalized; +} + +export function resolveWebSearchRouteBaseUrl( + providerId: WebSearchProviderId, + clientBaseUrl?: string, +): string | undefined { + const safeClientBaseUrl = resolveSafeClientWebSearchBaseUrl(providerId, clientBaseUrl); + return resolveWebSearchBaseUrl(providerId, safeClientBaseUrl); +} + +export function resolveClassroomWebSearchConfig(input: { + webSearchProviderId?: WebSearchProviderId; + webSearchApiKey?: string; +}): { providerId: WebSearchProviderId; apiKey: string; baseUrl?: string } | undefined { + const requestedProviderId = assertWebSearchProviderId(input.webSearchProviderId) + ? input.webSearchProviderId + : undefined; + const providerId = + requestedProviderId ?? (resolveServerWebSearchProviderId() as WebSearchProviderId | undefined); + if (!providerId) return undefined; + + const apiKey = resolveWebSearchApiKey(providerId, input.webSearchApiKey); + if (!apiKey) return undefined; + + return { + providerId, + apiKey, + baseUrl: resolveWebSearchBaseUrl(providerId), + }; +} diff --git a/lib/store/settings.ts b/lib/store/settings.ts index a879546a9..e8e82996a 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -411,6 +411,7 @@ const getDefaultWebSearchConfig = () => ({ webSearchProviderId: 'tavily' as WebSearchProviderId, webSearchProvidersConfig: { tavily: { apiKey: '', baseUrl: '', enabled: true }, + bocha: { apiKey: '', baseUrl: '', enabled: true }, } as Record, }); @@ -586,6 +587,21 @@ function ensureBuiltInVideoProviders(state: Partial): void { }); } +/** + * Ensure webSearchProvidersConfig includes all built-in web search providers. + * Called on every rehydrate so newly added providers appear automatically. + */ +function ensureBuiltInWebSearchProviders(state: Partial): void { + if (!state.webSearchProvidersConfig) return; + const defaultConfig = getDefaultWebSearchConfig().webSearchProvidersConfig; + Object.keys(WEB_SEARCH_PROVIDERS).forEach((pid) => { + const providerId = pid as WebSearchProviderId; + if (!state.webSearchProvidersConfig![providerId]) { + state.webSearchProvidersConfig![providerId] = defaultConfig[providerId]; + } + }); +} + // Migrate from old localStorage format const migrateFromOldStorage = () => { if (typeof window === 'undefined') return null; @@ -1187,6 +1203,7 @@ export const useSettingsStore = create()( const pdfFallback = buildFallback(newPDFConfig); const imageFallback = buildFallback(newImageConfig); const videoFallback = buildFallback(newVideoConfig); + const webSearchFallback = buildFallback(newWebSearchConfig); const validLLMProvider = validateProvider( state.providerId, @@ -1221,6 +1238,12 @@ export const useSettingsStore = create()( newVideoConfig, videoFallback, ); + const validWebSearchProvider = validateProvider( + state.webSearchProviderId, + newWebSearchConfig, + webSearchFallback, + 'tavily' as WebSearchProviderId, + ); // Auto-recover: when provider is empty but server has available ones let recoveredImageModel = ''; @@ -1384,6 +1407,9 @@ export const useSettingsStore = create()( ...(validPDFProvider !== state.pdfProviderId && { pdfProviderId: validPDFProvider as PDFProviderId, }), + ...(validWebSearchProvider !== state.webSearchProviderId && { + webSearchProviderId: validWebSearchProvider as WebSearchProviderId, + }), ...(validImageProvider !== state.imageProviderId && { imageProviderId: validImageProvider as ImageProviderId, }), @@ -1473,6 +1499,7 @@ export const useSettingsStore = create()( Object.assign(state, defaultAudioConfig); } ensureBuiltInAudioProviders(state); + ensureBuiltInWebSearchProviders(state); // Migrate global ttsModelId to per-provider if ((state as Record).ttsModelId) { @@ -1572,6 +1599,11 @@ export const useSettingsStore = create()( enabled: true, isServerConfigured: oldIsServerConfigured, }, + bocha: { + apiKey: '', + baseUrl: '', + enabled: true, + }, } as SettingsState['webSearchProvidersConfig']; delete stateRecord.webSearchApiKey; delete stateRecord.webSearchIsServerConfigured; @@ -1579,6 +1611,7 @@ export const useSettingsStore = create()( ensureValidProviderSelections(state); ensureBuiltInAudioProviders(state); + ensureBuiltInWebSearchProviders(state); state.thinkingConfigs = pruneThinkingConfigs(state.thinkingConfigs, state.providersConfig); return state; @@ -1592,6 +1625,7 @@ export const useSettingsStore = create()( ensureBuiltInAudioProviders(merged as Partial); ensureBuiltInImageProviders(merged as Partial); ensureBuiltInVideoProviders(merged as Partial); + ensureBuiltInWebSearchProviders(merged as Partial); ensureValidProviderSelections(merged as Partial); const typedMerged = merged as Partial; typedMerged.thinkingConfigs = pruneThinkingConfigs( diff --git a/lib/web-search/bocha.ts b/lib/web-search/bocha.ts new file mode 100644 index 000000000..b7f1dc2f5 --- /dev/null +++ b/lib/web-search/bocha.ts @@ -0,0 +1,121 @@ +/** + * Bocha Web Search Integration + * + * Uses raw REST API via proxyFetch for reliable proxy support. + * Bocha web search endpoint: POST https://api.bocha.cn/v1/web-search + */ + +import { proxyFetch } from '@/lib/server/proxy-fetch'; +import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; + +const BOCHA_DEFAULT_BASE_URL = 'https://api.bocha.cn'; +const BOCHA_MAX_RESULTS = 50; + +function buildBochaWebSearchUrl(baseUrl?: string): string { + const trimmed = (baseUrl || BOCHA_DEFAULT_BASE_URL).replace(/\/$/, ''); + if (trimmed.endsWith('/v1/web-search')) return trimmed; + if (trimmed.endsWith('/v1')) return `${trimmed}/web-search`; + return `${trimmed}/v1/web-search`; +} + +function clampCount(maxResults: number): number { + return Math.min(Math.max(Math.floor(maxResults), 1), BOCHA_MAX_RESULTS); +} + +function formatBochaError(status: number, statusText: string, errorText: string): string { + if (!errorText) return `Bocha API error (${status}): ${statusText}`; + + try { + const parsed = JSON.parse(errorText) as { + code?: string | number; + message?: string; + msg?: string | null; + log_id?: string; + }; + const code = parsed.code ?? status; + const message = parsed.message || parsed.msg || statusText; + const logId = parsed.log_id ? `, log_id: ${parsed.log_id}` : ''; + return `Bocha API error (${code}): ${message}${logId}`; + } catch { + return `Bocha API error (${status}): ${errorText}`; + } +} + +/** + * Search the web using Bocha Web Search API and return structured results. + */ +export async function searchWithBocha(params: { + query: string; + apiKey: string; + maxResults?: number; + baseUrl?: string; +}): Promise { + const { query, apiKey, maxResults = 10, baseUrl } = params; + const startedAt = Date.now(); + + const res = await proxyFetch(buildBochaWebSearchUrl(baseUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + query, + freshness: 'noLimit', + summary: true, + count: clampCount(maxResults), + }), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(formatBochaError(res.status, res.statusText, errorText)); + } + + const raw = (await res.json()) as { + code?: number | string; + msg?: string | null; + message?: string; + log_id?: string; + data?: BochaSearchData; + } & BochaSearchData; + + if (raw.code !== undefined && String(raw.code) !== '200') { + const message = raw.message || raw.msg || 'Request failed'; + const logId = raw.log_id ? `, log_id: ${raw.log_id}` : ''; + throw new Error(`Bocha API error (${raw.code}): ${message}${logId}`); + } + + const data = raw.data || raw; + const pages = data.webPages?.value || []; + + const sources: WebSearchSource[] = pages + .filter((page) => page.url) + .map((page) => ({ + title: page.name || page.url, + url: page.url, + content: page.summary || page.snippet || '', + score: 0, + })); + + return { + answer: '', + sources, + query: data.queryContext?.originalQuery || query, + responseTime: (Date.now() - startedAt) / 1000, + }; +} + +interface BochaSearchData { + queryContext?: { + originalQuery?: string; + }; + webPages?: { + value?: Array<{ + name?: string; + url: string; + snippet?: string; + summary?: string; + }>; + }; +} diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index 6542bbb2a..11eaf0c6c 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -13,9 +13,35 @@ export const WEB_SEARCH_PROVIDERS: Record string, +): string { + const provider = WEB_SEARCH_PROVIDERS[providerId]; + if (!provider) return providerId; + + if (t) { + const key = `settings.providerNames.${providerId}`; + const translated = t(key); + if (translated && translated !== key) return translated; + } + + return provider.name; +} + /** * Get all available web search providers */ diff --git a/lib/web-search/format.ts b/lib/web-search/format.ts new file mode 100644 index 000000000..3a7781d20 --- /dev/null +++ b/lib/web-search/format.ts @@ -0,0 +1,26 @@ +import type { WebSearchResult } from '@/lib/types/web-search'; + +/** + * Format search results into a markdown context block for LLM prompts. + */ +export function formatSearchResultsAsContext(result: WebSearchResult): string { + if (!result.answer && result.sources.length === 0) { + return ''; + } + + const lines: string[] = []; + + if (result.answer) { + lines.push(result.answer); + lines.push(''); + } + + if (result.sources.length > 0) { + lines.push('Sources:'); + for (const src of result.sources) { + lines.push(`- [${src.title}](${src.url}): ${src.content.slice(0, 200)}`); + } + } + + return lines.join('\n'); +} diff --git a/lib/web-search/index.ts b/lib/web-search/index.ts new file mode 100644 index 000000000..1fd739d74 --- /dev/null +++ b/lib/web-search/index.ts @@ -0,0 +1,27 @@ +import { searchWithBocha } from './bocha'; +import { searchWithTavily } from './tavily'; +import type { WebSearchResult } from '@/lib/types/web-search'; +import type { WebSearchProviderId } from './types'; + +export { formatSearchResultsAsContext } from './format'; + +export async function searchWeb(params: { + providerId: WebSearchProviderId; + query: string; + apiKey: string; + maxResults?: number; + baseUrl?: string; +}): Promise { + const { providerId, query, apiKey, maxResults, baseUrl } = params; + + switch (providerId) { + case 'bocha': + return searchWithBocha({ query, apiKey, maxResults, baseUrl }); + case 'tavily': + return searchWithTavily({ query, apiKey, maxResults, baseUrl }); + default: { + const exhaustive: never = providerId; + throw new Error(`Unsupported web search provider: ${exhaustive}`); + } + } +} diff --git a/lib/web-search/tavily.ts b/lib/web-search/tavily.ts index a2329d7a1..1fdca8e19 100644 --- a/lib/web-search/tavily.ts +++ b/lib/web-search/tavily.ts @@ -7,11 +7,17 @@ import { proxyFetch } from '@/lib/server/proxy-fetch'; import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; +export { formatSearchResultsAsContext } from './format'; -const TAVILY_API_URL = 'https://api.tavily.com/search'; +const TAVILY_DEFAULT_BASE_URL = 'https://api.tavily.com'; const TAVILY_MAX_QUERY_LENGTH = 400; +function buildTavilySearchUrl(baseUrl?: string): string { + const trimmed = (baseUrl || TAVILY_DEFAULT_BASE_URL).replace(/\/$/, ''); + return trimmed.endsWith('/search') ? trimmed : `${trimmed}/search`; +} + /** * Search the web using Tavily REST API and return structured results. */ @@ -19,13 +25,14 @@ export async function searchWithTavily(params: { query: string; apiKey: string; maxResults?: number; + baseUrl?: string; }): Promise { - const { query, apiKey, maxResults = 5 } = params; + const { query, apiKey, maxResults = 5, baseUrl } = params; // Tavily rejects queries over 400 characters with a 400 error const truncatedQuery = query.slice(0, TAVILY_MAX_QUERY_LENGTH); - const res = await proxyFetch(TAVILY_API_URL, { + const res = await proxyFetch(buildTavilySearchUrl(baseUrl), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -70,28 +77,3 @@ export async function searchWithTavily(params: { responseTime: data.response_time, }; } - -/** - * Format search results into a markdown context block for LLM prompts. - */ -export function formatSearchResultsAsContext(result: WebSearchResult): string { - if (!result.answer && result.sources.length === 0) { - return ''; - } - - const lines: string[] = []; - - if (result.answer) { - lines.push(result.answer); - lines.push(''); - } - - if (result.sources.length > 0) { - lines.push('Sources:'); - for (const src of result.sources) { - lines.push(`- [${src.title}](${src.url}): ${src.content.slice(0, 200)}`); - } - } - - return lines.join('\n'); -} diff --git a/lib/web-search/types.ts b/lib/web-search/types.ts index f83822c7c..ee6726ca0 100644 --- a/lib/web-search/types.ts +++ b/lib/web-search/types.ts @@ -5,7 +5,7 @@ /** * Web Search Provider IDs */ -export type WebSearchProviderId = 'tavily'; +export type WebSearchProviderId = 'tavily' | 'bocha'; /** * Web Search Provider Configuration @@ -15,5 +15,6 @@ export interface WebSearchProviderConfig { name: string; requiresApiKey: boolean; defaultBaseUrl?: string; + endpointPath: string; icon?: string; } diff --git a/public/logos/bocha.png b/public/logos/bocha.png new file mode 100644 index 000000000..ec91dca3e Binary files /dev/null and b/public/logos/bocha.png differ diff --git a/public/logos/tavily.svg b/public/logos/tavily.svg new file mode 100644 index 000000000..45d52d9e6 --- /dev/null +++ b/public/logos/tavily.svg @@ -0,0 +1,7 @@ + + Tavily + + diff --git a/tests/server/provider-config.test.ts b/tests/server/provider-config.test.ts index 90ac9c6f8..e42e77baa 100644 --- a/tests/server/provider-config.test.ts +++ b/tests/server/provider-config.test.ts @@ -48,6 +48,7 @@ const ENV_PREFIXES_TO_CLEAR = [ 'VIDEO_SORA', 'VIDEO_MINIMAX', 'VIDEO_GROK', + 'BOCHA', ]; function clearProviderEnv() { @@ -57,6 +58,8 @@ function clearProviderEnv() { delete process.env[`${prefix}_MODELS`]; } delete process.env.TAVILY_API_KEY; + delete process.env.BOCHA_API_KEY; + delete process.env.BOCHA_BASE_URL; } vi.mock('fs', async (importOriginal) => { @@ -256,6 +259,31 @@ providers: const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); expect(resolveWebSearchApiKey()).toBe('tvly-bare-env'); }); + + it('resolves Bocha API key and base URL from env vars', async () => { + vi.stubEnv('BOCHA_API_KEY', 'bocha-env-key'); + vi.stubEnv('BOCHA_BASE_URL', 'https://proxy.example.com/bocha'); + const { getServerWebSearchProviders, resolveWebSearchApiKey, resolveWebSearchBaseUrl } = + await import('@/lib/server/provider-config'); + + expect(resolveWebSearchApiKey('bocha', undefined)).toBe('bocha-env-key'); + expect(resolveWebSearchBaseUrl('bocha')).toBe('https://proxy.example.com/bocha'); + expect(getServerWebSearchProviders().bocha).toEqual({ + baseUrl: 'https://proxy.example.com/bocha', + }); + }); + + it('uses client key and base URL before Bocha server config', async () => { + vi.stubEnv('BOCHA_API_KEY', 'bocha-env-key'); + vi.stubEnv('BOCHA_BASE_URL', 'https://proxy.example.com/bocha'); + const { resolveWebSearchApiKey, resolveWebSearchBaseUrl } = + await import('@/lib/server/provider-config'); + + expect(resolveWebSearchApiKey('bocha', 'bocha-client-key')).toBe('bocha-client-key'); + expect(resolveWebSearchBaseUrl('bocha', 'https://client.example.com')).toBe( + 'https://client.example.com', + ); + }); }); describe('baseUrl-only providers (e.g. mineru)', () => { diff --git a/tests/server/web-search-config.test.ts b/tests/server/web-search-config.test.ts new file mode 100644 index 000000000..73a1cd656 --- /dev/null +++ b/tests/server/web-search-config.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +describe('server web search config', () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + delete process.env.TAVILY_API_KEY; + delete process.env.TAVILY_BASE_URL; + delete process.env.BOCHA_API_KEY; + delete process.env.BOCHA_BASE_URL; + }); + + it('rejects client-controlled base URLs outside the provider allowlist', async () => { + const { resolveSafeClientWebSearchBaseUrl } = await import('@/lib/server/web-search-config'); + + expect(() => + resolveSafeClientWebSearchBaseUrl('bocha', 'http://127.0.0.1:3000/internal'), + ).toThrow('Unsupported Bocha base URL'); + }); + + it('allows official Bocha client base URLs', async () => { + const { resolveSafeClientWebSearchBaseUrl } = await import('@/lib/server/web-search-config'); + + expect(resolveSafeClientWebSearchBaseUrl('bocha', 'https://api.bochaai.com/v1')).toBe( + 'https://api.bochaai.com/v1', + ); + }); + + it('resolves classroom web search config from selected provider and client key', async () => { + const { resolveClassroomWebSearchConfig } = await import('@/lib/server/web-search-config'); + + expect( + resolveClassroomWebSearchConfig({ + webSearchProviderId: 'bocha', + webSearchApiKey: 'bocha-client-key', + }), + ).toEqual({ + providerId: 'bocha', + apiKey: 'bocha-client-key', + baseUrl: undefined, + }); + }); + + it('uses server base URL for classroom web search config instead of client-controlled URLs', async () => { + vi.stubEnv('BOCHA_API_KEY', 'bocha-server-key'); + vi.stubEnv('BOCHA_BASE_URL', 'http://internal-proxy.local/bocha'); + + const { resolveClassroomWebSearchConfig } = await import('@/lib/server/web-search-config'); + + expect(resolveClassroomWebSearchConfig({ webSearchProviderId: 'bocha' })).toEqual({ + providerId: 'bocha', + apiKey: 'bocha-server-key', + baseUrl: 'http://internal-proxy.local/bocha', + }); + }); +}); diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index 3f43b02bd..6436dc1fa 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -627,6 +627,78 @@ describe('fetchServerProviders — ASR stale selection', () => { }); }); +describe('fetchServerProviders — Web Search provider sync', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('marks Bocha as server-configured and stores serverBaseUrl', async () => { + const store = await getStore(); + mockServerResponse({ + webSearch: { + bocha: { baseUrl: 'https://api.bocha.cn' }, + }, + }); + + await store.getState().fetchServerProviders(); + + expect(store.getState().webSearchProvidersConfig.bocha).toMatchObject({ + isServerConfigured: true, + serverBaseUrl: 'https://api.bocha.cn', + }); + }); + + it('falls back to Bocha when selected Tavily loses server config and has no client key', async () => { + const store = await getStore(); + + mockServerResponse({ + webSearch: { + tavily: { baseUrl: 'https://api.tavily.com' }, + bocha: { baseUrl: 'https://api.bocha.cn' }, + }, + }); + await store.getState().fetchServerProviders(); + store.getState().setWebSearchProvider('tavily'); + + mockServerResponse({ + webSearch: { + bocha: { baseUrl: 'https://api.bocha.cn' }, + }, + }); + await store.getState().fetchServerProviders(); + + expect(store.getState().webSearchProviderId).toBe('bocha'); + }); + + it('keeps Bocha selected when it is still server-configured', async () => { + const store = await getStore(); + + mockServerResponse({ + webSearch: { + bocha: { baseUrl: 'https://api.bocha.cn' }, + }, + }); + await store.getState().fetchServerProviders(); + store.getState().setWebSearchProvider('bocha'); + + mockServerResponse({ + webSearch: { + bocha: { baseUrl: 'https://api.bocha.cn' }, + }, + }); + await store.getState().fetchServerProviders(); + + expect(store.getState().webSearchProviderId).toBe('bocha'); + }); +}); + describe('fetchServerProviders — PDF stale selection', () => { beforeEach(() => { vi.resetModules(); diff --git a/tests/web-search/bocha.test.ts b/tests/web-search/bocha.test.ts new file mode 100644 index 000000000..609346ba7 --- /dev/null +++ b/tests/web-search/bocha.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const proxyFetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/server/proxy-fetch', () => ({ + proxyFetch: proxyFetchMock, +})); + +import { searchWithBocha } from '@/lib/web-search/bocha'; + +describe('searchWithBocha', () => { + beforeEach(() => { + proxyFetchMock.mockReset(); + }); + + it('calls Bocha Web Search API and maps web page results', async () => { + proxyFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: 200, + log_id: 'log-ok', + msg: null, + data: { + queryContext: { originalQuery: '阿里巴巴 ESG' }, + webPages: { + value: [ + { + name: 'Alibaba ESG report', + url: 'https://example.com/esg', + snippet: 'Short snippet', + summary: 'Long summary', + }, + { + name: 'Snippet only', + url: 'https://example.com/snippet', + snippet: 'Fallback snippet', + }, + ], + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + + const result = await searchWithBocha({ + query: '阿里巴巴 ESG', + apiKey: 'bocha-key', + maxResults: 100, + }); + + expect(proxyFetchMock).toHaveBeenCalledWith( + 'https://api.bocha.cn/v1/web-search', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer bocha-key', + }, + body: JSON.stringify({ + query: '阿里巴巴 ESG', + freshness: 'noLimit', + summary: true, + count: 50, + }), + }), + ); + expect(result.query).toBe('阿里巴巴 ESG'); + expect(result.answer).toBe(''); + expect(result.sources).toEqual([ + { + title: 'Alibaba ESG report', + url: 'https://example.com/esg', + content: 'Long summary', + score: 0, + }, + { + title: 'Snippet only', + url: 'https://example.com/snippet', + content: 'Fallback snippet', + score: 0, + }, + ]); + }); + + it('supports custom base URLs ending at either host, /v1, or full endpoint', async () => { + proxyFetchMock.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ code: 200, data: { webPages: { value: [] } } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + + await searchWithBocha({ query: 'q1', apiKey: 'key', baseUrl: 'https://proxy.example.com' }); + await searchWithBocha({ query: 'q2', apiKey: 'key', baseUrl: 'https://proxy.example.com/v1' }); + await searchWithBocha({ + query: 'q3', + apiKey: 'key', + baseUrl: 'https://proxy.example.com/v1/web-search', + }); + + expect(proxyFetchMock.mock.calls.map((call) => call[0])).toEqual([ + 'https://proxy.example.com/v1/web-search', + 'https://proxy.example.com/v1/web-search', + 'https://proxy.example.com/v1/web-search', + ]); + }); + + it('includes Bocha error details when requests fail', async () => { + proxyFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: '403', + message: 'You do not have enough money', + log_id: 'bocha-log-id', + }), + { status: 403, statusText: 'Forbidden', headers: { 'content-type': 'application/json' } }, + ), + ); + + await expect(searchWithBocha({ query: 'q', apiKey: 'key' })).rejects.toThrow( + 'Bocha API error (403): You do not have enough money, log_id: bocha-log-id', + ); + }); +}); diff --git a/tests/web-search/constants.test.ts b/tests/web-search/constants.test.ts new file mode 100644 index 000000000..4c19acfed --- /dev/null +++ b/tests/web-search/constants.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getWebSearchProviderDisplayName } from '@/lib/web-search/constants'; + +describe('web search provider constants', () => { + it('uses translated provider names when available', () => { + const t = (key: string) => (key === 'settings.providerNames.bocha' ? '博查' : key); + + expect(getWebSearchProviderDisplayName('bocha', t)).toBe('博查'); + }); + + it('falls back to provider metadata name when no translation exists', () => { + const t = (key: string) => key; + + expect(getWebSearchProviderDisplayName('tavily', t)).toBe('Tavily'); + }); +}); diff --git a/tests/web-search/index.test.ts b/tests/web-search/index.test.ts new file mode 100644 index 000000000..68b7814f5 --- /dev/null +++ b/tests/web-search/index.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const searchWithBochaMock = vi.hoisted(() => vi.fn()); +const searchWithTavilyMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/web-search/bocha', () => ({ + searchWithBocha: searchWithBochaMock, +})); + +vi.mock('@/lib/web-search/tavily', () => ({ + searchWithTavily: searchWithTavilyMock, +})); + +import { searchWeb } from '@/lib/web-search'; + +describe('searchWeb', () => { + beforeEach(() => { + searchWithBochaMock.mockReset(); + searchWithTavilyMock.mockReset(); + }); + + it('dispatches Tavily provider requests', async () => { + searchWithTavilyMock.mockResolvedValueOnce({ + answer: 'tavily answer', + sources: [], + query: 'q', + responseTime: 0.1, + }); + + await expect(searchWeb({ providerId: 'tavily', query: 'q', apiKey: 'key' })).resolves.toEqual({ + answer: 'tavily answer', + sources: [], + query: 'q', + responseTime: 0.1, + }); + expect(searchWithTavilyMock).toHaveBeenCalledWith({ + query: 'q', + apiKey: 'key', + maxResults: undefined, + baseUrl: undefined, + }); + expect(searchWithBochaMock).not.toHaveBeenCalled(); + }); + + it('dispatches Bocha provider requests', async () => { + searchWithBochaMock.mockResolvedValueOnce({ + answer: '', + sources: [], + query: 'q', + responseTime: 0.2, + }); + + await expect( + searchWeb({ + providerId: 'bocha', + query: 'q', + apiKey: 'key', + maxResults: 20, + baseUrl: 'https://api.bocha.cn', + }), + ).resolves.toEqual({ + answer: '', + sources: [], + query: 'q', + responseTime: 0.2, + }); + expect(searchWithBochaMock).toHaveBeenCalledWith({ + query: 'q', + apiKey: 'key', + maxResults: 20, + baseUrl: 'https://api.bocha.cn', + }); + expect(searchWithTavilyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/web-search/route.test.ts b/tests/web-search/route.test.ts new file mode 100644 index 000000000..36717ea6b --- /dev/null +++ b/tests/web-search/route.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + searchWeb: vi.fn(), + formatSearchResultsAsContext: vi.fn(() => 'formatted context'), + resolveModelFromRequest: vi.fn(), +})); + +vi.mock('@/lib/web-search', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + searchWeb: mocks.searchWeb, + formatSearchResultsAsContext: mocks.formatSearchResultsAsContext, + }; +}); + +vi.mock('@/lib/server/resolve-model', () => ({ + resolveModelFromRequest: mocks.resolveModelFromRequest, +})); + +vi.mock('@/lib/ai/llm', () => ({ + callLLM: vi.fn(), +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +async function postWebSearch(body: Record) { + const { POST } = await import('@/app/api/web-search/route'); + const request = new Request('http://localhost/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return POST(request as unknown as NextRequest); +} + +describe('POST /api/web-search', () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + delete process.env.TAVILY_API_KEY; + delete process.env.TAVILY_BASE_URL; + delete process.env.BOCHA_API_KEY; + delete process.env.BOCHA_BASE_URL; + mocks.searchWeb.mockReset(); + mocks.formatSearchResultsAsContext.mockClear(); + mocks.resolveModelFromRequest.mockReset(); + mocks.resolveModelFromRequest.mockRejectedValue(new Error('model unavailable')); + mocks.searchWeb.mockResolvedValue({ + answer: '', + sources: [], + query: 'test query', + responseTime: 0.1, + }); + }); + + it('rejects client-controlled base URLs outside the provider allowlist', async () => { + vi.stubEnv('BOCHA_API_KEY', 'bocha-server-key'); + + const res = await postWebSearch({ + query: 'test query', + providerId: 'bocha', + baseUrl: 'http://127.0.0.1:3000/internal', + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json).toMatchObject({ + success: false, + errorCode: 'INVALID_REQUEST', + }); + expect(mocks.searchWeb).not.toHaveBeenCalled(); + }); + + it('uses server-configured base URL when no client base URL is supplied', async () => { + vi.stubEnv('BOCHA_API_KEY', 'bocha-server-key'); + vi.stubEnv('BOCHA_BASE_URL', 'http://internal-proxy.local/bocha'); + + const res = await postWebSearch({ + query: 'test query', + providerId: 'bocha', + }); + + expect(res.status).toBe(200); + expect(mocks.searchWeb).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'bocha', + apiKey: 'bocha-server-key', + baseUrl: 'http://internal-proxy.local/bocha', + }), + ); + }); +});