From 49ae59bdc8c638c44da981037ecb98e62d7f8951 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 3 May 2026 17:01:59 +0000 Subject: [PATCH 1/2] Add Bocha web search provider --- .env.example | 2 + app/api/web-search/route.ts | 22 +++- app/generation-preview/page.tsx | 8 +- components/settings/web-search-settings.tsx | 11 +- lib/i18n/locales/ar-SA.json | 8 +- lib/i18n/locales/en-US.json | 8 +- lib/i18n/locales/ja-JP.json | 8 +- lib/i18n/locales/ru-RU.json | 8 +- lib/i18n/locales/zh-CN.json | 8 +- lib/server/classroom-generation.ts | 22 ++-- lib/server/provider-config.ts | 42 ++++++- lib/store/settings.ts | 34 ++++++ lib/web-search/bocha.ts | 121 +++++++++++++++++++ lib/web-search/constants.ts | 8 ++ lib/web-search/format.ts | 26 ++++ lib/web-search/index.ts | 27 +++++ lib/web-search/tavily.ts | 38 ++---- lib/web-search/types.ts | 3 +- tests/server/provider-config.test.ts | 28 +++++ tests/web-search/bocha.test.ts | 127 ++++++++++++++++++++ tests/web-search/index.test.ts | 75 ++++++++++++ 21 files changed, 561 insertions(+), 73 deletions(-) create mode 100644 lib/web-search/bocha.ts create mode 100644 lib/web-search/format.ts create mode 100644 lib/web-search/index.ts create mode 100644 tests/web-search/bocha.test.ts create mode 100644 tests/web-search/index.test.ts 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/web-search/route.ts b/app/api/web-search/route.ts index 3b5f128d2..f07bbc2e9 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -2,13 +2,13 @@ * 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 { resolveWebSearchApiKey } from '@/lib/server/provider-config'; +import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search'; +import { resolveWebSearchApiKey, resolveWebSearchBaseUrl } from '@/lib/server/provider-config'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { @@ -17,6 +17,8 @@ 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'; const log = createLogger('WebSearch'); @@ -27,11 +29,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 +45,18 @@ 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.`, ); } + const baseUrl = resolveWebSearchBaseUrl(providerId, clientBaseUrl); // 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 +93,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/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..954ebbd63 100644 --- a/lib/i18n/locales/ar-SA.json +++ b/lib/i18n/locales/ar-SA.json @@ -954,12 +954,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..0e11cdec0 100644 --- a/lib/i18n/locales/en-US.json +++ b/lib/i18n/locales/en-US.json @@ -954,12 +954,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..d8495db0a 100644 --- a/lib/i18n/locales/ja-JP.json +++ b/lib/i18n/locales/ja-JP.json @@ -954,12 +954,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..4000b7129 100644 --- a/lib/i18n/locales/ru-RU.json +++ b/lib/i18n/locales/ru-RU.json @@ -954,12 +954,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..b1e46b438 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -954,12 +954,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..252b97f96 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -16,10 +16,15 @@ 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 { + resolveServerWebSearchProviderId, + resolveWebSearchApiKey, + resolveWebSearchBaseUrl, +} from '@/lib/server/provider-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, @@ -234,8 +239,9 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - const tavilyKey = resolveWebSearchApiKey(); - if (tavilyKey) { + const providerId = resolveServerWebSearchProviderId() as WebSearchProviderId | undefined; + const webSearchKey = providerId ? resolveWebSearchApiKey(providerId, undefined) : ''; + if (providerId && webSearchKey) { try { const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); @@ -246,9 +252,11 @@ export async function generateClassroom( finalQueryLength: searchQuery.finalQueryLength, }); - const searchResult = await searchWithTavily({ + const searchResult = await searchWeb({ + providerId, query: searchQuery.query, - apiKey: tavilyKey, + apiKey: webSearchKey, + baseUrl: resolveWebSearchBaseUrl(providerId), }); researchContext = formatSearchResultsAsContext(searchResult); if (researchContext) { @@ -258,7 +266,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/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..a0b1a533c 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -13,6 +13,14 @@ export const WEB_SEARCH_PROVIDERS: Record 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/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/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/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(); + }); +}); From 41636b20f88b558de66019039f881649c08e3911 Mon Sep 17 00:00:00 2001 From: wyuc Date: Sun, 3 May 2026 17:43:57 +0000 Subject: [PATCH 2/2] Address Bocha web search review feedback --- app/api/generate-classroom/route.ts | 2 + app/api/web-search/route.ts | 11 +- components/generation/generation-toolbar.tsx | 10 +- components/settings/index.tsx | 11 +- lib/i18n/locales/ar-SA.json | 4 +- lib/i18n/locales/en-US.json | 4 +- lib/i18n/locales/ja-JP.json | 4 +- lib/i18n/locales/ru-RU.json | 4 +- lib/i18n/locales/zh-CN.json | 4 +- lib/server/classroom-generation.ts | 19 ++-- lib/server/web-search-config.ts | 80 +++++++++++++++ lib/web-search/constants.ts | 18 ++++ public/logos/bocha.png | Bin 0 -> 13246 bytes public/logos/tavily.svg | 7 ++ tests/server/web-search-config.test.ts | 56 ++++++++++ tests/store/settings-server-sync.test.ts | 72 +++++++++++++ tests/web-search/constants.test.ts | 16 +++ tests/web-search/route.test.ts | 102 +++++++++++++++++++ 18 files changed, 400 insertions(+), 24 deletions(-) create mode 100644 lib/server/web-search-config.ts create mode 100644 public/logos/bocha.png create mode 100644 public/logos/tavily.svg create mode 100644 tests/server/web-search-config.test.ts create mode 100644 tests/web-search/constants.test.ts create mode 100644 tests/web-search/route.test.ts 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 f07bbc2e9..bd076f2dc 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -8,7 +8,7 @@ import { NextRequest } from 'next/server'; import { callLLM } from '@/lib/ai/llm'; import { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search'; -import { resolveWebSearchApiKey, resolveWebSearchBaseUrl } from '@/lib/server/provider-config'; +import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { @@ -19,6 +19,7 @@ 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'); @@ -56,7 +57,13 @@ export async function POST(req: NextRequest) { `${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.`, ); } - const baseUrl = resolveWebSearchBaseUrl(providerId, clientBaseUrl); + 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); 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/lib/i18n/locales/ar-SA.json b/lib/i18n/locales/ar-SA.json index 954ebbd63..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", diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json index 0e11cdec0..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", diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json index d8495db0a..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プロトコル", diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json index 4000b7129..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", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b1e46b438..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 协议", diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index 252b97f96..bf26a0cf0 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -16,11 +16,7 @@ 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 { - resolveServerWebSearchProviderId, - resolveWebSearchApiKey, - resolveWebSearchBaseUrl, -} 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 { formatSearchResultsAsContext, searchWeb } from '@/lib/web-search'; @@ -41,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; @@ -239,9 +237,8 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - const providerId = resolveServerWebSearchProviderId() as WebSearchProviderId | undefined; - const webSearchKey = providerId ? resolveWebSearchApiKey(providerId, undefined) : ''; - if (providerId && webSearchKey) { + const webSearchConfig = resolveClassroomWebSearchConfig(input); + if (webSearchConfig) { try { const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); @@ -253,10 +250,10 @@ export async function generateClassroom( }); const searchResult = await searchWeb({ - providerId, + providerId: webSearchConfig.providerId, query: searchQuery.query, - apiKey: webSearchKey, - baseUrl: resolveWebSearchBaseUrl(providerId), + apiKey: webSearchConfig.apiKey, + baseUrl: webSearchConfig.baseUrl, }); researchContext = formatSearchResultsAsContext(searchResult); if (researchContext) { 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/web-search/constants.ts b/lib/web-search/constants.ts index a0b1a533c..11eaf0c6c 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -14,6 +14,7 @@ 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/public/logos/bocha.png b/public/logos/bocha.png new file mode 100644 index 0000000000000000000000000000000000000000..ec91dca3ed4738378fd33de55961c4451c4e386e GIT binary patch literal 13246 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 V0lR-4{~gEyD9WnIG<`G=|9=v2kDmYl literal 0 HcmV?d00001 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/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/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/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', + }), + ); + }); +});