From 0a7bf99f9c936218d40e03b8f2e9cbb2bd1a05a8 Mon Sep 17 00:00:00 2001 From: Teo Date: Tue, 29 Apr 2025 16:31:41 +0800 Subject: [PATCH] refactor: network search module, support quick menu (#5291) * refactor: Reconstruct the network search module to support quick menu switching between different suppliers. * refactor(GeminiProvider): simplify web search enablement logic * refactor(settings): remove unnecessary SettingDivider for cleaner layout * refactor(SelectModelButton): remove unused setModel function for improved performance * refactor(ApiService): simplify web search condition by removing redundant check --- src/renderer/src/config/models.ts | 2 +- src/renderer/src/i18n/locales/en-us.json | 7 +- src/renderer/src/i18n/locales/ja-jp.json | 7 +- src/renderer/src/i18n/locales/ru-ru.json | 7 +- src/renderer/src/i18n/locales/zh-cn.json | 5 +- src/renderer/src/i18n/locales/zh-tw.json | 7 +- .../src/pages/home/Inputbar/Inputbar.tsx | 59 +++----- .../pages/home/Inputbar/WebSearchButton.tsx | 129 ++++++++++++++++++ .../home/components/SelectModelButton.tsx | 13 +- .../WebSearchSettings/BasicSettings.tsx | 13 +- .../WebSearchProviderSetting.tsx | 2 +- .../providers/AiProvider/GeminiProvider.ts | 3 +- .../providers/AiProvider/OpenAIProvider.ts | 5 +- src/renderer/src/services/ApiService.ts | 9 +- src/renderer/src/services/WebSearchService.ts | 27 ++-- src/renderer/src/store/websearch.ts | 2 + src/renderer/src/types/index.ts | 2 + 17 files changed, 204 insertions(+), 95 deletions(-) create mode 100644 src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 47727e35d4..c1ed7e84ed 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2393,7 +2393,7 @@ export function isGenerateImageModel(model: Model): boolean { } export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record { - if (WebSearchService.isWebSearchEnabled() && WebSearchService.isOverwriteEnabled()) { + if (WebSearchService.isWebSearchEnabled()) { return {} } if (isWebSearchModel(model)) { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 296b4ab1d0..5367da7419 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -136,7 +136,7 @@ "input.translate": "Translate to {{target_language}}", "input.upload": "Upload image or document file", "input.upload.document": "Upload document file (model does not support images)", - "input.web_search": "Enable web search", + "input.web_search": "Web search", "input.web_search.button.ok": "Go to Settings", "input.web_search.enable": "Enable web search", "input.web_search.enable_content": "Need to check web search connectivity in settings first", @@ -247,7 +247,10 @@ "topics.export.title_naming_success": "Title generated successfully", "topics.export.title_naming_failed": "Failed to generate title, using default title", "input.translating": "Translating...", - "input.upload.upload_from_local": "Upload local file..." + "input.upload.upload_from_local": "Upload local file...", + "input.web_search.builtin": "Model Built-in", + "input.web_search.builtin.enabled_content": "Use the built-in web search function of the model", + "input.web_search.builtin.disabled_content": "The current model does not support web search" }, "code_block": { "collapse": "Collapse", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b15688cf79..8c2f431b7a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -136,7 +136,7 @@ "input.translate": "{{target_language}}に翻訳", "input.upload": "画像またはドキュメントをアップロード", "input.upload.document": "ドキュメントをアップロード(モデルは画像をサポートしません)", - "input.web_search": "ウェブ検索を有効にする", + "input.web_search": "ウェブ検索", "input.web_search.button.ok": "設定に移動", "input.web_search.enable": "ウェブ検索を有効にする", "input.web_search.enable_content": "ウェブ検索の接続性を先に設定で確認する必要があります", @@ -247,7 +247,10 @@ "topics.export.title_naming_success": "タイトルの生成に成功しました", "topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します", "input.translating": "翻訳中...", - "input.upload.upload_from_local": "ローカルファイルをアップロード..." + "input.upload.upload_from_local": "ローカルファイルをアップロード...", + "input.web_search.builtin": "モデル内蔵", + "input.web_search.builtin.enabled_content": "モデル内蔵のウェブ検索機能を使用", + "input.web_search.builtin.disabled_content": "現在のモデルはウェブ検索をサポートしていません" }, "code_block": { "collapse": "折りたたむ", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 058486286b..28b3d623e3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -136,7 +136,7 @@ "input.translate": "Перевести на {{target_language}}", "input.upload": "Загрузить изображение или документ", "input.upload.document": "Загрузить документ (модель не поддерживает изображения)", - "input.web_search": "Включить веб-поиск", + "input.web_search": "Веб-поиск", "input.web_search.button.ok": "Перейти в Настройки", "input.web_search.enable": "Включить веб-поиск", "input.web_search.enable_content": "Необходимо предварительно проверить подключение к веб-поиску в настройках", @@ -247,7 +247,10 @@ "topics.export.title_naming_success": "Заголовок успешно создан", "topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию", "input.translating": "Перевод...", - "input.upload.upload_from_local": "Загрузить локальный файл..." + "input.upload.upload_from_local": "Загрузить локальный файл...", + "input.web_search.builtin": "Модель встроена", + "input.web_search.builtin.enabled_content": "Используйте встроенную функцию веб-поиска модели", + "input.web_search.builtin.disabled_content": "Текущая модель не поддерживает веб-поиск" }, "code_block": { "collapse": "Свернуть", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b2f81fa149..a87493263d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -138,10 +138,13 @@ "input.upload": "上传图片或文档", "input.upload.upload_from_local": "上传本地文件...", "input.upload.document": "上传文档(模型不支持图片)", - "input.web_search": "开启网络搜索", + "input.web_search": "网络搜索", "input.web_search.button.ok": "去设置", "input.web_search.enable": "开启网络搜索", "input.web_search.enable_content": "需要先在设置中检查网络搜索连通性", + "input.web_search.builtin": "模型内置", + "input.web_search.builtin.enabled_content": "使用模型内置的网络搜索功能", + "input.web_search.builtin.disabled_content": "当前模型不支持网络搜索功能", "message.new.branch": "分支", "message.new.branch.created": "新分支已创建", "message.new.context": "清除上下文", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 89144e93cf..105e699ae8 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -136,7 +136,7 @@ "input.translate": "翻譯成{{target_language}}", "input.upload": "上傳圖片或文件", "input.upload.document": "上傳文件(模型不支援圖片)", - "input.web_search": "開啟網路搜尋", + "input.web_search": "網路搜尋", "input.web_search.button.ok": "去設定", "input.web_search.enable": "開啟網路搜尋", "input.web_search.enable_content": "需要先在設定中開啟網路搜尋", @@ -247,7 +247,10 @@ "topics.export.title_naming_success": "標題生成成功", "topics.export.title_naming_failed": "標題生成失敗,使用預設標題", "input.translating": "翻譯中...", - "input.upload.upload_from_local": "上傳本地文件..." + "input.upload.upload_from_local": "上傳本地文件...", + "input.web_search.builtin": "模型內置", + "input.web_search.builtin.enabled_content": "使用模型內置的網路搜尋功能", + "input.web_search.builtin.disabled_content": "當前模型不支持網路搜尋功能" }, "code_block": { "collapse": "折疊", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 4c7c470e1a..6cb8559af8 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -51,7 +51,6 @@ import { // import { CompletionUsage } from 'openai/resources' import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' import styled from 'styled-components' import NarrowLayout from '../Messages/NarrowLayout' @@ -67,6 +66,7 @@ import NewContextButton from './NewContextButton' import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton' import SendMessageButton from './SendMessageButton' import TokenCount from './TokenCount' +import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton' interface Props { assistant: Assistant @@ -116,7 +116,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const currentMessageId = useRef('') const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) - const navigate = useNavigate() const { activedMcpServers } = useMCPServers() const { bases: knowledgeBases } = useKnowledgeBases() @@ -131,6 +130,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const knowledgeBaseButtonRef = useRef(null) const mcpToolsButtonRef = useRef(null) const attachmentButtonRef = useRef(null) + const webSearchButtonRef = useRef(null) // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedEstimate = useCallback( @@ -379,6 +379,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = mcpToolsButtonRef.current?.openResourcesList() } }, + { + label: t('chat.input.web_search'), + description: '', + icon: , + isMenu: true, + action: () => { + webSearchButtonRef.current?.openQuickPanel() + } + }, { label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), description: '', @@ -770,46 +779,17 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(newKnowledgeBases ?? []) } - const showWebSearchEnableModal = () => { - window.modal.confirm({ - title: t('chat.input.web_search.enable'), - content: t('chat.input.web_search.enable_content'), - centered: true, - okText: t('chat.input.web_search.button.ok'), - onOk: () => { - navigate('/settings/web-search') - } - }) - } - - const shouldShowEnableModal = () => { - // 网络搜索功能是否未启用 - const webSearchNotEnabled = !WebSearchService.isWebSearchEnabled() - // 非网络搜索模型:仅当网络搜索功能未启用时显示启用提示 - if (!isWebSearchModel(model)) { - return webSearchNotEnabled - } - // 网络搜索模型:当允许覆盖但网络搜索功能未启用时显示启用提示 - return WebSearchService.isOverwriteEnabled() && webSearchNotEnabled - } - - const onEnableWebSearch = () => { - if (shouldShowEnableModal()) { - showWebSearchEnableModal() - return - } - - updateAssistant({ ...assistant, enableWebSearch: !assistant.enableWebSearch }) - } - const onEnableGenerateImage = () => { updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage }) } useEffect(() => { - if (!isWebSearchModel(model) && !WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch) { + if (!isWebSearchModel(model) && assistant.enableWebSearch) { updateAssistant({ ...assistant, enableWebSearch: false }) } + if (assistant.webSearchProviderId && !WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId)) { + updateAssistant({ ...assistant, webSearchProviderId: undefined }) + } if (!isGenerateImageModel(model) && assistant.enableGenerateImage) { updateAssistant({ ...assistant, enableGenerateImage: false }) } @@ -938,14 +918,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setFiles={setFiles} ToolbarButton={ToolbarButton} /> - - - - - + {showKnowledgeIcon && ( void +} + +interface Props { + ref?: React.RefObject + assistant: Assistant + ToolbarButton: any +} + +const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { + const { t } = useTranslation() + const navigate = useNavigate() + const quickPanel = useQuickPanel() + const { providers } = useWebSearchProviders() + const { updateAssistant } = useAssistant(assistant.id) + + const updateSelectedWebSearchProvider = useCallback( + (providerId: WebSearchProvider['id']) => { + // TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿 + setTimeout(() => { + const currentWebSearchProviderId = assistant.webSearchProviderId + const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId + updateAssistant({ ...assistant, webSearchProviderId: newWebSearchProviderId, enableWebSearch: false }) + }, 200) + }, + [assistant, updateAssistant] + ) + + const updateSelectedWebSearchBuiltin = useCallback(() => { + // TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿 + setTimeout(() => { + updateAssistant({ ...assistant, webSearchProviderId: undefined, enableWebSearch: !assistant.enableWebSearch }) + }, 200) + }, [assistant, updateAssistant]) + + const providerItems = useMemo(() => { + const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model) + + const items: QuickPanelListItem[] = providers.map((p) => ({ + label: p.name, + description: WebSearchService.isWebSearchEnabled(p.id) + ? hasObjectKey(p, 'apiKey') + ? t('settings.websearch.apikey') + : t('settings.websearch.free') + : t('chat.input.web_search.enable_content'), + icon: , + isSelected: p.id === assistant?.webSearchProviderId, + disabled: !WebSearchService.isWebSearchEnabled(p.id), + action: () => updateSelectedWebSearchProvider(p.id) + })) + + items.unshift({ + label: t('chat.input.web_search.builtin'), + description: isWebSearchModelEnabled + ? t('chat.input.web_search.builtin.enabled_content') + : t('chat.input.web_search.builtin.disabled_content'), + icon: , + isSelected: assistant.enableWebSearch, + disabled: !isWebSearchModelEnabled, + action: () => updateSelectedWebSearchBuiltin() + }) + items.push({ + label: '前往设置' + '...', + icon: , + action: () => navigate('/settings/web-search') + }) + + return items + }, [ + assistant.model, + assistant.enableWebSearch, + assistant.webSearchProviderId, + providers, + t, + updateSelectedWebSearchProvider, + updateSelectedWebSearchBuiltin, + navigate + ]) + + const openQuickPanel = useCallback(() => { + quickPanel.open({ + title: t('chat.input.web_search'), + list: providerItems, + symbol: '?' + }) + }, [quickPanel, providerItems, t]) + + const handleOpenQuickPanel = useCallback(() => { + if (quickPanel.isVisible && quickPanel.symbol === '?') { + quickPanel.close() + } else { + openQuickPanel() + } + }, [openQuickPanel, quickPanel]) + + useImperativeHandle(ref, () => ({ + openQuickPanel + })) + + return ( + + + + + + ) +} + +export default WebSearchButton diff --git a/src/renderer/src/pages/home/components/SelectModelButton.tsx b/src/renderer/src/pages/home/components/SelectModelButton.tsx index ce05c18ed8..27cc392969 100644 --- a/src/renderer/src/pages/home/components/SelectModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectModelButton.tsx @@ -1,6 +1,7 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { isLocalAi } from '@renderer/config/env' +import { isWebSearchModel } from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' import { getProviderName } from '@renderer/services/ProviderService' import { Assistant } from '@renderer/types' @@ -14,7 +15,7 @@ interface Props { } const SelectModelButton: FC = ({ assistant }) => { - const { model, setModel } = useAssistant(assistant.id) + const { model, updateAssistant } = useAssistant(assistant.id) const { t } = useTranslation() if (isLocalAi) { @@ -25,7 +26,15 @@ const SelectModelButton: FC = ({ assistant }) => { event.currentTarget.blur() const selectedModel = await SelectModelPopup.show({ model }) if (selectedModel) { - setModel(selectedModel) + // 避免更新数据造成关闭弹框的卡顿 + setTimeout(() => { + const enabledWebSearch = isWebSearchModel(selectedModel) + updateAssistant({ + ...assistant, + model: selectedModel, + enableWebSearch: enabledWebSearch && assistant.enableWebSearch + }) + }, 200) } } diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx index 6dcb71fea8..098c007ca8 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useAppDispatch, useAppSelector } from '@renderer/store' -import { setContentLimit, setMaxResult, setOverwrite, setSearchWithTime } from '@renderer/store/websearch' +import { setContentLimit, setMaxResult, setSearchWithTime } from '@renderer/store/websearch' import { Input, Slider, Switch, Tooltip } from 'antd' import { t } from 'i18next' import { Info } from 'lucide-react' @@ -11,7 +11,6 @@ import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle const BasicSettings: FC = () => { const { theme } = useTheme() const searchWithTime = useAppSelector((state) => state.websearch.searchWithTime) - const overwrite = useAppSelector((state) => state.websearch.overwrite) const maxResults = useAppSelector((state) => state.websearch.maxResults) const contentLimit = useAppSelector((state) => state.websearch.contentLimit) @@ -26,16 +25,6 @@ const BasicSettings: FC = () => { {t('settings.websearch.search_with_time')} dispatch(setSearchWithTime(checked))} /> - - - - {t('settings.websearch.overwrite')} - - - - - dispatch(setOverwrite(checked))} /> - {t('settings.websearch.search_max_result')} diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index f19f192eb9..7e058278b7 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -185,7 +185,7 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {t('settings.provider.api_host')} - + => { @@ -120,7 +120,7 @@ async function fetchExternalTool( // Use the consolidated processWebsearch function WebSearchService.createAbortSignal(lastUserMessage.id) return { - results: await WebSearchService.processWebsearch(webSearchProvider, extractResults), + results: await WebSearchService.processWebsearch(webSearchProvider!, extractResults), source: WebSearchSource.WEBSEARCH } } catch (error) { @@ -157,8 +157,7 @@ async function fetchExternalTool( } } - const shouldWebSearch = - assistant.enableWebSearch && (!isWebSearchModel(assistant.model!) || WebSearchService.isOverwriteEnabled()) + const shouldWebSearch = !!assistant.webSearchProviderId // --- Execute Extraction and Searches --- const extractResults = await extract() diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index 20c4787d20..acdade4c4b 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -1,6 +1,6 @@ import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider' import store from '@renderer/store' -import { setDefaultProvider, WebSearchState } from '@renderer/store/websearch' +import { WebSearchState } from '@renderer/store/websearch' import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { addAbortController } from '@renderer/utils/abortController' @@ -43,9 +43,9 @@ class WebSearchService { * @public * @returns 如果默认搜索提供商已启用则返回true,否则返回false */ - public isWebSearchEnabled(): boolean { - const { defaultProvider, providers } = this.getWebSearchState() - const provider = providers.find((provider) => provider.id === defaultProvider) + public isWebSearchEnabled(providerId?: WebSearchProvider['id']): boolean { + const { providers } = this.getWebSearchState() + const provider = providers.find((provider) => provider.id === providerId) if (!provider) { return false @@ -67,6 +67,8 @@ class WebSearchService { } /** + * @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 + * * 检查是否启用覆盖搜索 * @public * @returns 如果启用覆盖搜索则返回true,否则返回false @@ -80,21 +82,10 @@ class WebSearchService { * 获取当前默认的网络搜索提供商 * @public * @returns 网络搜索提供商 - * @throws 如果找不到默认提供商则抛出错误 */ - public getWebSearchProvider(): WebSearchProvider { - const { defaultProvider, providers } = this.getWebSearchState() - let provider = providers.find((provider) => provider.id === defaultProvider) - - if (!provider) { - provider = providers[0] - if (provider) { - // 可选:自动更新默认提供商 - store.dispatch(setDefaultProvider(provider.id)) - } else { - throw new Error(`No web search providers available`) - } - } + public getWebSearchProvider(providerId?: string): WebSearchProvider | undefined { + const { providers } = this.getWebSearchState() + const provider = providers.find((provider) => provider.id === providerId) return provider } diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 4fa61ce4c3..2a4a999b13 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -9,6 +9,7 @@ export interface SubscribeSource { export interface WebSearchState { // 默认搜索提供商的ID + /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ defaultProvider: string // 所有可用的搜索提供商列表 providers: WebSearchProvider[] @@ -21,6 +22,7 @@ export interface WebSearchState { // 订阅源列表 subscribeSources: SubscribeSource[] // 是否覆盖服务商搜索 + /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ overwrite: boolean contentLimit?: number } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index d7dfe4960d..6180ada35a 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -18,7 +18,9 @@ export type Assistant = { defaultModel?: Model settings?: Partial messages?: AssistantMessage[] + /** enableWebSearch 代表使用模型内置网络搜索功能 */ enableWebSearch?: boolean + webSearchProviderId?: WebSearchProvider['id'] enableGenerateImage?: boolean mcpServers?: MCPServer[] }