From a8bf55abc2b36f620d3c42280cb342007f2554ee Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 15:10:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=86=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8F=82=E6=95=B0=E7=94=A8=E4=BA=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=86=85=E7=BD=AE=E6=90=9C=E7=B4=A2=20(#10213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove local provider option files and use external packages Replace local implementation of XAI and OpenRouter provider options with external packages (@ai-sdk/xai and @openrouter/ai-sdk-provider). Update web search plugin to support additional providers including OpenAI Chat and OpenRouter, with improved configuration mapping. * Bump @cherrystudio/ai-core to v1.0.0-alpha.17 fix i18n * fix(i18n): Auto update translations for PR #10213 --------- Co-authored-by: GitHub Action --- package.json | 2 +- packages/aiCore/package.json | 2 +- packages/aiCore/src/core/options/factory.ts | 2 +- .../aiCore/src/core/options/openrouter.ts | 38 -------- packages/aiCore/src/core/options/types.ts | 5 +- packages/aiCore/src/core/options/xai.ts | 86 ------------------ .../aiCore/src/core/plugins/built-in/index.ts | 8 +- .../built-in/webSearchPlugin/helper.ts | 20 ++++- .../plugins/built-in/webSearchPlugin/index.ts | 17 +++- .../built-in/webSearchPlugin/openrouter.ts | 26 ++++++ packages/aiCore/src/core/providers/schemas.ts | 7 +- .../middleware/AiSdkMiddlewareBuilder.ts | 3 + .../src/aiCore/plugins/PluginBuilder.ts | 5 +- .../aiCore/prepareParams/parameterBuilder.ts | 27 +++++- src/renderer/src/aiCore/utils/websearch.ts | 88 +++++++++++++++++-- src/renderer/src/config/models/utils.ts | 5 ++ src/renderer/src/i18n/locales/en-us.json | 7 +- src/renderer/src/i18n/locales/zh-cn.json | 8 +- src/renderer/src/i18n/locales/zh-tw.json | 7 +- src/renderer/src/i18n/translate/el-gr.json | 10 ++- src/renderer/src/i18n/translate/es-es.json | 10 ++- src/renderer/src/i18n/translate/fr-fr.json | 10 ++- src/renderer/src/i18n/translate/ja-jp.json | 10 ++- src/renderer/src/i18n/translate/pt-pt.json | 10 ++- src/renderer/src/i18n/translate/ru-ru.json | 10 ++- .../pages/home/Inputbar/ThinkingButton.tsx | 19 +++- .../pages/home/Inputbar/WebSearchButton.tsx | 16 +++- src/renderer/src/services/ApiService.ts | 4 +- src/renderer/src/store/websearch.ts | 2 + .../__tests__/blacklistMatchPattern.test.ts | 27 +++++- .../src/utils/blacklistMatchPattern.ts | 37 ++++++++ yarn.lock | 4 +- 32 files changed, 359 insertions(+), 173 deletions(-) delete mode 100644 packages/aiCore/src/core/options/openrouter.ts delete mode 100644 packages/aiCore/src/core/options/xai.ts create mode 100644 packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts diff --git a/package.json b/package.json index bcbcbd10a5..b1bd7c8d72 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.840.0", "@aws-sdk/client-s3": "^3.840.0", "@biomejs/biome": "2.2.4", - "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16", + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.17", "@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "^0.1.31", diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 292b679d82..642feff7c1 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -1,6 +1,6 @@ { "name": "@cherrystudio/ai-core", - "version": "1.0.0-alpha.16", + "version": "1.0.0-alpha.17", "description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index 4350e9241b..ffeb15185c 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -59,7 +59,7 @@ export function createGoogleOptions(options: ExtractProviderOptions<'google'>) { /** * 创建OpenRouter供应商选项的便捷函数 */ -export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'>) { +export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'> | Record) { return createProviderOptions('openrouter', options) } diff --git a/packages/aiCore/src/core/options/openrouter.ts b/packages/aiCore/src/core/options/openrouter.ts deleted file mode 100644 index b351f8fda1..0000000000 --- a/packages/aiCore/src/core/options/openrouter.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type OpenRouterProviderOptions = { - models?: string[] - - /** - * https://openrouter.ai/docs/use-cases/reasoning-tokens - * One of `max_tokens` or `effort` is required. - * If `exclude` is true, reasoning will be removed from the response. Default is false. - */ - reasoning?: { - exclude?: boolean - } & ( - | { - max_tokens: number - } - | { - effort: 'high' | 'medium' | 'low' - } - ) - - /** - * A unique identifier representing your end-user, which can - * help OpenRouter to monitor and detect abuse. - */ - user?: string - - extraBody?: Record - - /** - * Enable usage accounting to get detailed token usage information. - * https://openrouter.ai/docs/use-cases/usage-accounting - */ - usage?: { - /** - * When true, includes token usage information in the response. - */ - include: boolean - } -} diff --git a/packages/aiCore/src/core/options/types.ts b/packages/aiCore/src/core/options/types.ts index 724dc30698..8571fd2296 100644 --- a/packages/aiCore/src/core/options/types.ts +++ b/packages/aiCore/src/core/options/types.ts @@ -2,9 +2,8 @@ import { type AnthropicProviderOptions } from '@ai-sdk/anthropic' import { type GoogleGenerativeAIProviderOptions } from '@ai-sdk/google' import { type OpenAIResponsesProviderOptions } from '@ai-sdk/openai' import { type SharedV2ProviderMetadata } from '@ai-sdk/provider' - -import { type OpenRouterProviderOptions } from './openrouter' -import { type XaiProviderOptions } from './xai' +import { type XaiProviderOptions } from '@ai-sdk/xai' +import { type OpenRouterProviderOptions } from '@openrouter/ai-sdk-provider' export type ProviderOptions = SharedV2ProviderMetadata[T] diff --git a/packages/aiCore/src/core/options/xai.ts b/packages/aiCore/src/core/options/xai.ts deleted file mode 100644 index 7fe5672778..0000000000 --- a/packages/aiCore/src/core/options/xai.ts +++ /dev/null @@ -1,86 +0,0 @@ -// copy from @ai-sdk/xai/xai-chat-options.ts -// 如果@ai-sdk/xai暴露出了xaiProviderOptions就删除这个文件 - -import { z } from 'zod' - -const webSourceSchema = z.object({ - type: z.literal('web'), - country: z.string().length(2).optional(), - excludedWebsites: z.array(z.string()).max(5).optional(), - allowedWebsites: z.array(z.string()).max(5).optional(), - safeSearch: z.boolean().optional() -}) - -const xSourceSchema = z.object({ - type: z.literal('x'), - xHandles: z.array(z.string()).optional() -}) - -const newsSourceSchema = z.object({ - type: z.literal('news'), - country: z.string().length(2).optional(), - excludedWebsites: z.array(z.string()).max(5).optional(), - safeSearch: z.boolean().optional() -}) - -const rssSourceSchema = z.object({ - type: z.literal('rss'), - links: z.array(z.url()).max(1) // currently only supports one RSS link -}) - -const searchSourceSchema = z.discriminatedUnion('type', [ - webSourceSchema, - xSourceSchema, - newsSourceSchema, - rssSourceSchema -]) - -export const xaiProviderOptions = z.object({ - /** - * reasoning effort for reasoning models - * only supported by grok-3-mini and grok-3-mini-fast models - */ - reasoningEffort: z.enum(['low', 'high']).optional(), - - searchParameters: z - .object({ - /** - * search mode preference - * - "off": disables search completely - * - "auto": model decides whether to search (default) - * - "on": always enables search - */ - mode: z.enum(['off', 'auto', 'on']), - - /** - * whether to return citations in the response - * defaults to true - */ - returnCitations: z.boolean().optional(), - - /** - * start date for search data (ISO8601 format: YYYY-MM-DD) - */ - fromDate: z.string().optional(), - - /** - * end date for search data (ISO8601 format: YYYY-MM-DD) - */ - toDate: z.string().optional(), - - /** - * maximum number of search results to consider - * defaults to 20 - */ - maxSearchResults: z.number().min(1).max(50).optional(), - - /** - * data sources to search from - * defaults to ["web", "x"] if not specified - */ - sources: z.array(searchSourceSchema).optional() - }) - .optional() -}) - -export type XaiProviderOptions = z.infer diff --git a/packages/aiCore/src/core/plugins/built-in/index.ts b/packages/aiCore/src/core/plugins/built-in/index.ts index e7dcae8738..1f8916b09a 100644 --- a/packages/aiCore/src/core/plugins/built-in/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/index.ts @@ -7,5 +7,9 @@ export const BUILT_IN_PLUGIN_PREFIX = 'built-in:' export { googleToolsPlugin } from './googleToolsPlugin' export { createLoggingPlugin } from './logging' export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin' -export type { PromptToolUseConfig, ToolUseRequestContext, ToolUseResult } from './toolUsePlugin/type' -export { webSearchPlugin } from './webSearchPlugin' +export type { + PromptToolUseConfig, + ToolUseRequestContext, + ToolUseResult +} from './toolUsePlugin/type' +export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin' diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index a7c7187bca..4845ce4ace 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -3,13 +3,16 @@ import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' import { ProviderOptionsMap } from '../../../options/types' +import { OpenRouterSearchConfig } from './openrouter' /** * 从 AI SDK 的工具函数中提取参数类型,以确保类型安全。 */ -type OpenAISearchConfig = Parameters[0] -type AnthropicSearchConfig = Parameters[0] -type GoogleSearchConfig = Parameters[0] +export type OpenAISearchConfig = NonNullable[0]> +export type OpenAISearchPreviewConfig = NonNullable[0]> +export type AnthropicSearchConfig = NonNullable[0]> +export type GoogleSearchConfig = NonNullable[0]> +export type XAISearchConfig = NonNullable /** * 插件初始化时接收的完整配置对象 @@ -18,10 +21,12 @@ type GoogleSearchConfig = Parameters[0] */ export interface WebSearchPluginConfig { openai?: OpenAISearchConfig + 'openai-chat'?: OpenAISearchPreviewConfig anthropic?: AnthropicSearchConfig xai?: ProviderOptionsMap['xai']['searchParameters'] google?: GoogleSearchConfig 'google-vertex'?: GoogleSearchConfig + openrouter?: OpenRouterSearchConfig } /** @@ -31,6 +36,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { google: {}, 'google-vertex': {}, openai: {}, + 'openai-chat': {}, xai: { mode: 'on', returnCitations: true, @@ -39,6 +45,14 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { }, anthropic: { maxUses: 5 + }, + openrouter: { + plugins: [ + { + id: 'web', + max_results: 5 + } + ] } } diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts index d941e0d0c7..abd3ce3e2c 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -6,7 +6,7 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' -import { createXaiOptions, mergeProviderOptions } from '../../../options' +import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import { definePlugin } from '../../' import type { AiRequestContext } from '../../types' import { DEFAULT_WEB_SEARCH_CONFIG, WebSearchPluginConfig } from './helper' @@ -31,6 +31,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR } break } + case 'openai-chat': { + if (config['openai-chat']) { + if (!params.tools) params.tools = {} + params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) + } + break + } case 'anthropic': { if (config.anthropic) { @@ -56,6 +63,14 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR } break } + + case 'openrouter': { + if (config.openrouter) { + const searchOptions = createOpenRouterOptions(config.openrouter) + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) + } + break + } } return params diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts new file mode 100644 index 0000000000..ebf1e7bf9a --- /dev/null +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts @@ -0,0 +1,26 @@ +export type OpenRouterSearchConfig = { + plugins?: Array<{ + id: 'web' + /** + * Maximum number of search results to include (default: 5) + */ + max_results?: number + /** + * Custom search prompt to guide the search query + */ + search_prompt?: string + }> + /** + * Built-in web search options for models that support native web search + */ + web_search_options?: { + /** + * Maximum number of search results to include + */ + max_results?: number + /** + * Custom search prompt to guide the search query + */ + search_prompt?: string + } +} diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts index e4b8d8aa64..73ea4b8c14 100644 --- a/packages/aiCore/src/core/providers/schemas.ts +++ b/packages/aiCore/src/core/providers/schemas.ts @@ -25,7 +25,8 @@ export const baseProviderIds = [ 'xai', 'azure', 'azure-responses', - 'deepseek' + 'deepseek', + 'openrouter' ] as const /** @@ -38,6 +39,10 @@ export const baseProviderIdSchema = z.enum(baseProviderIds) */ export type BaseProviderId = z.infer +export const isBaseProvider = (id: ProviderId): id is BaseProviderId => { + return baseProviderIdSchema.safeParse(id).success +} + type BaseProvider = { id: BaseProviderId name: string diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index f0d3b2eb59..ffbe66da22 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,3 +1,4 @@ +import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import type { MCPTool, Message, Model, Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' @@ -26,6 +27,8 @@ export interface AiSdkMiddlewareConfig { enableUrlContext: boolean mcpTools?: MCPTool[] uiMessages?: Message[] + // 内置搜索配置 + webSearchPluginConfig?: WebSearchPluginConfig } /** diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index d94ed4aab9..7c5478eb77 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -30,9 +30,8 @@ export function buildPlugins( } // 1. 模型内置搜索 - if (middlewareConfig.enableWebSearch) { - // 内置了默认搜索参数,如果改的话可以传config进去 - plugins.push(webSearchPlugin()) + if (middlewareConfig.enableWebSearch && middlewareConfig.webSearchPluginConfig) { + plugins.push(webSearchPlugin(middlewareConfig.webSearchPluginConfig)) } // 2. 支持工具调用时添加搜索插件 if (middlewareConfig.isSupportedToolUse || middlewareConfig.isPromptToolUse) { diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 3f9e9ff071..0a89e73c62 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -5,6 +5,8 @@ import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge' import { vertex } from '@ai-sdk/google-vertex/edge' +import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' +import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas' import { loggerService } from '@logger' import { isGenerateImageModel, @@ -16,8 +18,11 @@ import { isWebSearchModel } from '@renderer/config/models' import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' +import store from '@renderer/store' +import { CherryWebSearchConfig } from '@renderer/store/websearch' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' +import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' import type { ModelMessage, Tool } from 'ai' import { stepCountIs } from 'ai' @@ -25,6 +30,7 @@ import { getAiSdkProviderId } from '../provider/factory' import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' import { getAnthropicThinkingBudget } from '../utils/reasoning' +import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' import { getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') @@ -42,6 +48,7 @@ export async function buildStreamTextParams( options: { mcpTools?: MCPTool[] webSearchProviderId?: string + webSearchConfig?: CherryWebSearchConfig requestOptions?: { signal?: AbortSignal timeout?: number @@ -57,6 +64,7 @@ export async function buildStreamTextParams( enableGenerateImage: boolean enableUrlContext: boolean } + webSearchPluginConfig?: WebSearchPluginConfig }> { const { mcpTools } = options @@ -93,6 +101,12 @@ export async function buildStreamTextParams( // } // 构建真正的 providerOptions + const webSearchConfig: CherryWebSearchConfig = { + maxResults: store.getState().websearch.maxResults, + excludeDomains: store.getState().websearch.excludeDomains, + searchWithTime: store.getState().websearch.searchWithTime + } + const providerOptions = buildProviderOptions(assistant, model, provider, { enableReasoning, enableWebSearch, @@ -109,15 +123,21 @@ export async function buildStreamTextParams( maxTokens -= getAnthropicThinkingBudget(assistant, model) } - // google-vertex | google-vertex-anthropic + let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined if (enableWebSearch) { + if (isBaseProvider(aiSdkProviderId)) { + webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig) + } if (!tools) { tools = {} } if (aiSdkProviderId === 'google-vertex') { tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool } else if (aiSdkProviderId === 'google-vertex-anthropic') { - tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) as ProviderDefinedTool + tools.web_search = vertexAnthropic.tools.webSearch_20250305({ + maxUses: webSearchConfig.maxResults, + blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains) + }) as ProviderDefinedTool } } @@ -151,7 +171,8 @@ export async function buildStreamTextParams( return { params, modelId: model.id, - capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext } + capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext }, + webSearchPluginConfig } } diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index d2d0345826..e95f3e60cf 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -1,6 +1,13 @@ +import { + AnthropicSearchConfig, + OpenAISearchConfig, + WebSearchPluginConfig +} from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper' +import { BaseProviderId } from '@cherrystudio/ai-core/provider' import { isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models' -import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '@renderer/config/prompts' +import { CherryWebSearchConfig } from '@renderer/store/websearch' import { Model } from '@renderer/types' +import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' export function getWebSearchParams(model: Model): Record { if (model.provider === 'hunyuan') { @@ -21,11 +28,78 @@ export function getWebSearchParams(model: Model): Record { web_search_options: {} } } - - if (model.provider === 'openrouter') { - return { - plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }] - } - } return {} } + +/** + * range in [0, 100] + * @param maxResults + */ +function mapMaxResultToOpenAIContextSize(maxResults: number): OpenAISearchConfig['searchContextSize'] { + if (maxResults <= 33) return 'low' + if (maxResults <= 66) return 'medium' + return 'high' +} + +export function buildProviderBuiltinWebSearchConfig( + providerId: BaseProviderId, + webSearchConfig: CherryWebSearchConfig +): WebSearchPluginConfig { + switch (providerId) { + case 'openai': { + return { + openai: { + searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults) + } + } + } + case 'openai-chat': { + return { + 'openai-chat': { + searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults) + } + } + } + case 'anthropic': { + const anthropicSearchOptions: AnthropicSearchConfig = { + maxUses: webSearchConfig.maxResults, + blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains) + } + return { + anthropic: anthropicSearchOptions + } + } + case 'xai': { + return { + xai: { + maxSearchResults: webSearchConfig.maxResults, + returnCitations: true, + sources: [ + { + type: 'web', + excludedWebsites: mapRegexToPatterns(webSearchConfig.excludeDomains) + }, + { type: 'news' }, + { type: 'x' } + ], + mode: 'on' + } + } + } + case 'openrouter': { + return { + openrouter: { + plugins: [ + { + id: 'web', + max_results: webSearchConfig.maxResults + } + ] + } + } + } + default: { + throw new Error(`Unsupported provider: ${providerId}`) + } + } +} diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 3fd27308f8..39078e2924 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -229,6 +229,11 @@ export const isGPT5SeriesModel = (model: Model) => { return modelId.includes('gpt-5') } +export const isGPT5SeriesReasoningModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-5') && !modelId.includes('chat') +} + export const isGeminiModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) return modelId.includes('gemini') diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4ac54e374a..768525d9b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -660,7 +660,12 @@ "title": "Topics", "unpin": "Unpin Topic" }, - "translate": "Translate" + "translate": "Translate", + "web_search": { + "warning": { + "openai": "The GPT-5 model's minimal reasoning effort does not support web search." + } + } }, "code": { "auto_update_to_latest": "Automatically update to latest version", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 248dbad623..551338e9d5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -132,7 +132,6 @@ }, "title": "API 服务器" }, - "assistants": { "abbr": "助手", "clear": { @@ -661,7 +660,12 @@ "title": "话题", "unpin": "取消固定" }, - "translate": "翻译" + "translate": "翻译", + "web_search": { + "warning": { + "openai": "GPT5 模型 minimal 思考强度不支持网络搜索" + } + } }, "code": { "auto_update_to_latest": "检查更新并安装最新版本", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 4b31ca44be..fe7bf4b760 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -660,7 +660,12 @@ "title": "話題", "unpin": "取消固定" }, - "translate": "翻譯" + "translate": "翻譯", + "web_search": { + "warning": { + "openai": "GPT-5 模型的最小推理力度不支援網路搜尋。" + } + } }, "code": { "auto_update_to_latest": "檢查更新並安裝最新版本", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index f95e243e5d..615eda601e 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -362,8 +362,9 @@ "translate": "Μετάφραση στο {{target_language}}", "translating": "Μετάφραση...", "upload": { + "attachment": "Μεταφόρτωση συνημμένου", "document": "Φόρτωση έγγραφου (το μοντέλο δεν υποστηρίζει εικόνες)", - "label": "Φόρτωση εικόνας ή έγγραφου", + "image_or_document": "Μεταφόρτωση εικόνας ή εγγράφου", "upload_from_local": "Μεταφόρτωση αρχείου από τον υπολογιστή..." }, "url_context": "Περιεχόμενο ιστοσελίδας", @@ -659,7 +660,12 @@ "title": "Θέματα", "unpin": "Ξεκαρφίτσωμα" }, - "translate": "Μετάφραση" + "translate": "Μετάφραση", + "web_search": { + "warning": { + "openai": "Το μοντέλο GPT5 με ελάχιστη ένταση σκέψης δεν υποστηρίζει αναζήτηση στο διαδίκτυο" + } + } }, "code": { "auto_update_to_latest": "Έλεγχος για ενημερώσεις και εγκατάσταση της τελευταίας έκδοσης", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 536e355f55..a210344a41 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -362,8 +362,9 @@ "translate": "Traducir a {{target_language}}", "translating": "Traduciendo...", "upload": { + "attachment": "Subir archivo adjunto", "document": "Subir documento (el modelo no admite imágenes)", - "label": "Subir imagen o documento", + "image_or_document": "Subir imagen o documento", "upload_from_local": "Subir archivo local..." }, "url_context": "Contexto de la página web", @@ -659,7 +660,12 @@ "title": "Tema", "unpin": "Quitar fijación" }, - "translate": "Traducir" + "translate": "Traducir", + "web_search": { + "warning": { + "openai": "El modelo GPT5 con intensidad de pensamiento mínima no admite búsqueda en la web." + } + } }, "code": { "auto_update_to_latest": "Comprobar actualizaciones e instalar la versión más reciente", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 00daec2d3f..3795f5ab01 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -362,8 +362,9 @@ "translate": "Traduire en {{target_language}}", "translating": "Traduction en cours...", "upload": { + "attachment": "Télécharger la pièce jointe", "document": "Télécharger un document (le modèle ne prend pas en charge les images)", - "label": "Télécharger une image ou un document", + "image_or_document": "Télécharger une image ou un document", "upload_from_local": "Télécharger un fichier local..." }, "url_context": "Contexte de la page web", @@ -659,7 +660,12 @@ "title": "Sujet", "unpin": "Annuler le fixage" }, - "translate": "Traduire" + "translate": "Traduire", + "web_search": { + "warning": { + "openai": "Le modèle GPT5 avec une intensité de réflexion minimale ne prend pas en charge la recherche sur Internet." + } + } }, "code": { "auto_update_to_latest": "Vérifier les mises à jour et installer la dernière version", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 3cdeca306e..f83153d90b 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -362,8 +362,9 @@ "translate": "{{target_language}}に翻訳", "translating": "翻訳中...", "upload": { + "attachment": "添付ファイルをアップロード", "document": "ドキュメントをアップロード(モデルは画像をサポートしません)", - "label": "画像またはドキュメントをアップロード", + "image_or_document": "画像またはドキュメントをアップロード", "upload_from_local": "ローカルファイルをアップロード..." }, "url_context": "URLコンテキスト", @@ -659,7 +660,12 @@ "title": "トピック", "unpin": "固定解除" }, - "translate": "翻訳" + "translate": "翻訳", + "web_search": { + "warning": { + "openai": "GPT5モデルの最小思考強度ではネット検索はサポートされません" + } + } }, "code": { "auto_update_to_latest": "最新バージョンを自動的に更新する", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index b58593bb15..7759fc2706 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -362,8 +362,9 @@ "translate": "Traduzir para {{target_language}}", "translating": "Traduzindo...", "upload": { + "attachment": "Carregar anexo", "document": "Carregar documento (o modelo não suporta imagens)", - "label": "Carregar imagem ou documento", + "image_or_document": "Carregar imagem ou documento", "upload_from_local": "Fazer upload de arquivo local..." }, "url_context": "Contexto da Página da Web", @@ -659,7 +660,12 @@ "title": "Tópicos", "unpin": "Desfixar" }, - "translate": "Traduzir" + "translate": "Traduzir", + "web_search": { + "warning": { + "openai": "O modelo GPT5 com intensidade mínima de pensamento não suporta pesquisa na web" + } + } }, "code": { "auto_update_to_latest": "Verificar atualizações e instalar a versão mais recente", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 6364e5b83d..cec3dd0e74 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -362,8 +362,9 @@ "translate": "Перевести на {{target_language}}", "translating": "Перевод...", "upload": { + "attachment": "Загрузить вложение", "document": "Загрузить документ (модель не поддерживает изображения)", - "label": "Загрузить изображение или документ", + "image_or_document": "Загрузить изображение или документ", "upload_from_local": "Загрузить локальный файл..." }, "url_context": "Контекст страницы", @@ -659,7 +660,12 @@ "title": "Топики", "unpin": "Открепленные темы" }, - "translate": "Перевести" + "translate": "Перевести", + "web_search": { + "warning": { + "openai": "Модель GPT5 с минимальной интенсивностью мышления не поддерживает поиск в интернете" + } + } }, "code": { "auto_update_to_latest": "Автоматически обновлять до последней версии", diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 5ee0c628c8..7e2b7edd38 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -8,7 +8,13 @@ import { MdiLightbulbOn80 } from '@renderer/components/Icons/SVGIcon' import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' -import { getThinkModelType, isDoubaoThinkingAutoModel, MODEL_SUPPORTED_OPTIONS } from '@renderer/config/models' +import { + getThinkModelType, + isDoubaoThinkingAutoModel, + isGPT5SeriesReasoningModel, + isOpenAIWebSearchModel, + MODEL_SUPPORTED_OPTIONS +} from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label' import { Model, ThinkingOption } from '@renderer/types' @@ -61,6 +67,15 @@ const ThinkingButton: FC = ({ ref, model, assistantId }): ReactElement => }) return } + if ( + isOpenAIWebSearchModel(model) && + isGPT5SeriesReasoningModel(model) && + assistant.enableWebSearch && + option === 'minimal' + ) { + window.toast.warning(t('chat.web_search.warning.openai')) + return + } updateAssistantSettings({ reasoning_effort: option, reasoning_effort_cache: option, @@ -68,7 +83,7 @@ const ThinkingButton: FC = ({ ref, model, assistantId }): ReactElement => }) return }, - [updateAssistantSettings] + [updateAssistantSettings, assistant.enableWebSearch, model, t] ) const panelItems = useMemo(() => { diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index cb3c5fb6d9..807781bac4 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -3,7 +3,12 @@ import { loggerService } from '@logger' import { ActionIconButton } from '@renderer/components/Buttons' import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons' import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' -import { isGeminiModel, isWebSearchModel } from '@renderer/config/models' +import { + isGeminiModel, + isGPT5SeriesReasoningModel, + isOpenAIWebSearchModel, + isWebSearchModel +} from '@renderer/config/models' import { isGeminiWebSearchProvider } from '@renderer/config/providers' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' @@ -115,6 +120,15 @@ const WebSearchButton: FC = ({ ref, assistantId }) => { update.enableWebSearch = false window.toast.warning(t('chat.mcp.warning.gemini_web_search')) } + if ( + isOpenAIWebSearchModel(model) && + isGPT5SeriesReasoningModel(model) && + update.enableWebSearch && + assistant.settings?.reasoning_effort === 'minimal' + ) { + update.enableWebSearch = false + window.toast.warning(t('chat.web_search.warning.openai')) + } setTimeoutTimer('updateSelectedWebSearchBuiltin', () => updateAssistant(update), 200) }, [assistant, setTimeoutTimer, t, updateAssistant]) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 01807027ba..d954fc1f85 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -117,7 +117,8 @@ export async function fetchChatCompletion({ const { params: aiSdkParams, modelId, - capabilities + capabilities, + webSearchPluginConfig } = await buildStreamTextParams(messages, assistant, provider, { mcpTools: mcpTools, webSearchProviderId: assistant.webSearchProviderId, @@ -132,6 +133,7 @@ export async function fetchChatCompletion({ isPromptToolUse: isPromptToolUse(assistant), isSupportedToolUse: isSupportedToolUse(assistant), isImageGenerationEndpoint: isDedicatedImageGenerationModel(assistant.model || getDefaultModel()), + webSearchPluginConfig: webSearchPluginConfig, enableWebSearch: capabilities.enableWebSearch, enableGenerateImage: capabilities.enableGenerateImage, enableUrlContext: capabilities.enableUrlContext, diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 1e3fe2a25b..16029ccf47 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -41,6 +41,8 @@ export interface WebSearchState { providerConfig: Record } +export type CherryWebSearchConfig = Pick + export const initialState: WebSearchState = { defaultProvider: 'local-bing', providers: WEB_SEARCH_PROVIDERS, diff --git a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts index 4656e30562..a5c0983479 100644 --- a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts +++ b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts @@ -26,7 +26,7 @@ import { describe, expect, it } from 'vitest' -import { MatchPatternMap } from '../blacklistMatchPattern' +import { mapRegexToPatterns, MatchPatternMap } from '../blacklistMatchPattern' function get(map: MatchPatternMap, url: string) { return map.get(url).sort() @@ -161,3 +161,28 @@ describe('blacklistMatchPattern', () => { expect(get(map, 'https://b.mozilla.org/path/')).toEqual([0, 1, 2, 6]) }) }) + +describe('mapRegexToPatterns', () => { + it('extracts domains from regex patterns', () => { + const result = mapRegexToPatterns([ + '/example\\.com/', + '/(?:www\\.)?sub\\.example\\.co\\.uk/', + '/api\\.service\\.io/', + 'https://baidu.com' + ]) + + expect(result).toEqual(['example.com', 'sub.example.co.uk', 'api.service.io', 'baidu.com']) + }) + + it('deduplicates domains across multiple patterns', () => { + const result = mapRegexToPatterns(['/example\\.com/', '/(example\\.com|test\\.org)/']) + + expect(result).toEqual(['example.com', 'test.org']) + }) + + it('ignores patterns without domain matches', () => { + const result = mapRegexToPatterns(['', 'plain-domain.com', '/^https?:\\/\\/[^/]+$/']) + + expect(result).toEqual(['plain-domain.com']) + }) +}) diff --git a/src/renderer/src/utils/blacklistMatchPattern.ts b/src/renderer/src/utils/blacklistMatchPattern.ts index 9e78ab58fb..b00e07a785 100644 --- a/src/renderer/src/utils/blacklistMatchPattern.ts +++ b/src/renderer/src/utils/blacklistMatchPattern.ts @@ -202,6 +202,43 @@ export async function parseSubscribeContent(url: string): Promise { throw error } } + +export function mapRegexToPatterns(patterns: string[]): string[] { + const patternSet = new Set() + const domainMatcher = /[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g + + patterns.forEach((pattern) => { + if (!pattern) { + return + } + + // Handle regex patterns (wrapped in /) + if (pattern.startsWith('/') && pattern.endsWith('/')) { + const rawPattern = pattern.slice(1, -1) + const normalizedPattern = rawPattern.replace(/\\\./g, '.').replace(/\\\//g, '/') + const matches = normalizedPattern.match(domainMatcher) + + if (matches) { + matches.forEach((match) => { + patternSet.add(match.replace(/http(s)?:\/\//g, '').toLowerCase()) + }) + } + } else if (pattern.includes('://')) { + // Handle URLs with protocol (e.g., https://baidu.com) + const matches = pattern.match(domainMatcher) + if (matches) { + matches.forEach((match) => { + patternSet.add(match.replace(/http(s)?:\/\//g, '').toLowerCase()) + }) + } + } else { + patternSet.add(pattern.toLowerCase()) + } + }) + + return Array.from(patternSet) +} + export async function filterResultWithBlacklist( response: WebSearchProviderResponse, websearch: WebSearchState diff --git a/yarn.lock b/yarn.lock index 1d414c0861..71280de91a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2309,7 +2309,7 @@ __metadata: languageName: node linkType: hard -"@cherrystudio/ai-core@workspace:^1.0.0-alpha.16, @cherrystudio/ai-core@workspace:packages/aiCore": +"@cherrystudio/ai-core@workspace:^1.0.0-alpha.17, @cherrystudio/ai-core@workspace:packages/aiCore": version: 0.0.0-use.local resolution: "@cherrystudio/ai-core@workspace:packages/aiCore" dependencies: @@ -13195,7 +13195,7 @@ __metadata: "@aws-sdk/client-bedrock-runtime": "npm:^3.840.0" "@aws-sdk/client-s3": "npm:^3.840.0" "@biomejs/biome": "npm:2.2.4" - "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16" + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.17" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31"