fix: restrict using gemini native tools and mcp tools simultaneously (#9361)

* feat(providers): 添加对支持URL上下文的提供者类型的检查

新增 `isSupportUrlContextProvider` 函数用于检查提供者是否支持URL上下文功能

* fix(InputbarTools): 修复URL上下文按钮显示条件判断

添加对模型提供者是否支持URL上下文的检查

* fix(gemini): 修复原生工具与函数调用同时启用时的冲突

当同时启用web搜索和URL上下文工具时,如果已存在函数调用工具,则添加警告日志提示当前不支持同时使用

* feat(i18n): 限制 Gemini 同时使用网页上下文与 MCP 工具

添加多语言翻译文案和功能实现,当用户尝试同时启用网页上下文和 MCP 工具时,显示警告提示并自动禁用网页上下文

* perf(WebSearchButton): 使用定时器优化更新性能避免卡顿

移除startTransition并使用useTimer的setTimeoutTimer来延迟更新操作,解决updateAssistant导致的快捷面板关闭卡顿问题

* feat(i18n): 限制 Gemini 原生搜索工具与函数调用的同时使用

添加对 Gemini 原生搜索工具与函数调用同时使用时的冲突检测
更新相关国际化文案和功能实现

* fix(GeminiAPIClient): 修复工具使用模式判断逻辑

当工具使用模式为'prompt'时应该允许使用native tool

* reafactor: 简化 Gemini 模型下工具使用模式的 URL 上下文和网页搜索检查逻辑

* fix(WebSearchButton): 修复条件判断

* refactor(utils): 提取函数工具使用模式判断逻辑到单独函数

* test(assistant): 添加工具使用模式功能的单元测试

* refactor(InputbarTools): 使用isGeminiModel函数替代字符串检查

简化模型类型检查逻辑,提高代码可读性和维护性

* perf(Inputbar): 使用setTimeoutTimer替代startTransition解决性能问题
This commit is contained in:
Phantom 2025-08-27 14:37:46 +08:00 committed by GitHub
parent fac8e91d3a
commit 941f86008b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 192 additions and 40 deletions

View File

@ -52,6 +52,7 @@ import {
GeminiSdkRawOutput,
GeminiSdkToolCall
} from '@renderer/types/sdk'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import {
geminiFunctionCallToMcpTool,
isEnabledToolUse,
@ -476,16 +477,20 @@ export class GeminiAPIClient extends BaseApiClient<
}
}
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (tools.length === 0 || !isToolUseModeFunction(assistant)) {
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (enableUrlContext) {
tools.push({
urlContext: {}
})
if (enableUrlContext) {
tools.push({
urlContext: {}
})
}
} else if (enableWebSearch || enableUrlContext) {
logger.warn('Native tools cannot be used with function calling for now.')
}
if (isGemmaModel(model) && assistant.prompt) {

View File

@ -3307,6 +3307,11 @@ export const isGPT5SeriesModel = (model: Model) => {
return modelId.includes('gpt-5')
}
export const isGeminiModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gemini')
}
export const isOpenAIOpenWeightModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gpt-oss')

View File

@ -56,6 +56,7 @@ import {
isSystemProvider,
OpenAIServiceTiers,
Provider,
ProviderType,
SystemProvider,
SystemProviderId
} from '@renderer/types'
@ -1303,3 +1304,16 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
(isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id))
)
}
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
export const isSupportUrlContextProvider = (provider: Provider) => {
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
}
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
/** 判断是否是使用 Gemini 原生搜索工具的 provider. 目前假设只有官方 API 使用原生工具 */
export const isGeminiWebSearchProvider = (provider: Provider) => {
return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id)
}

View File

@ -390,8 +390,10 @@
"parse_tool_call": "Unable to convert to a valid tool call format: {{toolCall}}"
},
"warning": {
"gemini_web_search": "Gemini does not support using native web search tools and function calling simultaneously",
"multiple_tools": "Multiple matching MCP tools exist, {{tool}} has been selected",
"no_tool": "No matching MCP tool found for {{tool}}"
"no_tool": "No matching MCP tool found for {{tool}}",
"url_context": "Gemini does not support using url context and function calling simultaneously"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "有効なツール呼び出し形式に変換できません:{{toolCall}}"
},
"warning": {
"gemini_web_search": "Geminiは、ネイティブのネットワーク検索ツールと関数呼び出しを同時に使用することをサポートしていません。",
"multiple_tools": "複数の一致するMCPツールが存在するため、{{tool}} が選択されました",
"no_tool": "必要なMCPツール {{tool}} が見つかりません"
"no_tool": "必要なMCPツール {{tool}} が見つかりません",
"url_context": "Geminiは、URLコンテキストと関数呼び出しを同時に使用することをサポートしていません。"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "Не удалось преобразовать в действительный формат вызова инструмента: {{toolCall}}"
},
"warning": {
"gemini_web_search": "Gemini не поддерживает одновременное использование встроенного инструмента поиска в сети и вызова функций",
"multiple_tools": "Существует несколько совпадающих инструментов MCP, выбран {{tool}}",
"no_tool": "Не удалось сопоставить требуемый инструмент MCP {{tool}}"
"no_tool": "Не удалось сопоставить требуемый инструмент MCP {{tool}}",
"url_context": "Gemini не поддерживает одновременное использование контекста веб-страницы и вызова функций"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "无法转换为有效的工具调用格式:{{toolCall}}"
},
"warning": {
"gemini_web_search": "Gemini 不支持同时使用原生网络搜索工具与函数调用",
"multiple_tools": "存在多个匹配的MCP工具已选择 {{tool}}",
"no_tool": "未匹配到所需的MCP工具 {{tool}}"
"no_tool": "未匹配到所需的MCP工具 {{tool}}",
"url_context": "Gemini 不支持同时使用网页上下文与函数调用"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "無法轉換為有效的工具呼叫格式:{{toolCall}}"
},
"warning": {
"gemini_web_search": "Gemini 不支援同時使用原生網路搜尋工具與函數呼叫",
"multiple_tools": "存在多個匹配的MCP工具已選擇 {{tool}}",
"no_tool": "未匹配到所需的MCP工具 {{tool}}"
"no_tool": "未匹配到所需的MCP工具 {{tool}}",
"url_context": "Gemini 不支援同時使用網頁內容與函數呼叫"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "Δεν είναι δυνατή η μετατροπή σε έγκυρη μορφή κλήσης εργαλείου: {{toolCall}}"
},
"warning": {
"gemini_web_search": "Το Gemini δεν υποστηρίζει την ταυτόχρονη χρήση του εργαλείου αυτόματης αναζήτησης και της κλήσης συναρτήσεων",
"multiple_tools": "Υπάρχουν πολλαπλά εργαλεία MCP που ταιριάζουν, επιλέχθηκε το {{tool}}",
"no_tool": "Δεν βρέθηκε το απαιτούμενο εργαλείο MCP {{tool}}"
"no_tool": "Δεν βρέθηκε το απαιτούμενο εργαλείο MCP {{tool}}",
"url_context": "Το Gemini δεν υποστηρίζει την ταυτόχρονη χρήση πλοήγησης στον ιστό και κλήσης συναρτήσεων"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "No se puede convertir al formato de llamada de herramienta válido: {{toolCall}}"
},
"warning": {
"gemini_web_search": "Gemini no admite el uso simultáneo de herramientas de búsqueda nativa y llamadas de funciones",
"multiple_tools": "Existen múltiples herramientas MCP coincidentes, se ha seleccionado {{tool}}",
"no_tool": "No se encontró la herramienta MCP requerida {{tool}}"
"no_tool": "No se encontró la herramienta MCP requerida {{tool}}",
"url_context": "Gemini no admite el uso simultáneo del contexto de la página web y las llamadas a funciones."
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "Impossible de convertir au format d'appel d'outil valide : {{toolCall}}"
},
"warning": {
"gemini_web_search": "Gemini ne prend pas en charge l'utilisation simultanée de l'outil de recherche natif et de l'appel de fonctions",
"multiple_tools": "Il existe plusieurs outils MCP correspondants, {{tool}} a été sélectionné",
"no_tool": "Aucun outil MCP requis {{tool}} n'a été trouvé"
"no_tool": "Aucun outil MCP requis {{tool}} n'a été trouvé",
"url_context": "Gemini ne prend pas en charge l'utilisation simultanée du contexte de la page Web et des appels de fonction"
}
},
"message": {

View File

@ -390,8 +390,10 @@
"parse_tool_call": "Não é possível converter para um formato de chamada de ferramenta válido: {{toolCall}}"
},
"warning": {
"gemini_web_search": "O Gemini não suporta o uso simultâneo da ferramenta de pesquisa nativa e da chamada de funções.",
"multiple_tools": "Existem várias ferramentas MCP correspondentes, a ferramenta {{tool}} foi selecionada",
"no_tool": "Nenhuma ferramenta MCP necessária correspondente encontrada {{tool}}"
"no_tool": "Nenhuma ferramenta MCP necessária correspondente encontrada {{tool}}",
"url_context": "O Gemini não suporta o uso simultâneo de contexto da página da web e chamadas de função"
}
},
"message": {

View File

@ -1,6 +1,8 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
import { isGeminiModel, isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
import { isSupportUrlContextProvider } from '@renderer/config/providers'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
@ -347,7 +349,7 @@ const InputbarTools = ({
key: 'url_context',
label: t('chat.input.url_context'),
component: <UrlContextButton ref={urlContextButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
condition: model.id.toLowerCase().includes('gemini')
condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model))
},
{
key: 'knowledge_base',

View File

@ -1,9 +1,13 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { isGeminiModel } from '@renderer/config/models'
import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useTimer } from '@renderer/hooks/useTimer'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { EventEmitter } from '@renderer/services/EventService'
import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Form, Input, Tooltip } from 'antd'
import { CircleX, Hammer, Plus } from 'lucide-react'
import React, { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
@ -117,6 +121,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
const [form] = Form.useForm()
const { updateAssistant, assistant } = useAssistant(props.assistant.id)
const model = assistant.model
const { setTimeoutTimer } = useTimer()
// 使用 useRef 存储不需要触发重渲染的值
@ -135,13 +140,33 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
)
const handleMcpServerSelect = useCallback(
(server: MCPServer) => {
const update = { ...assistant }
if (assistantMcpServers.some((s) => s.id === server.id)) {
updateAssistant({ ...assistant, mcpServers: mcpServers?.filter((s) => s.id !== server.id) })
update.mcpServers = mcpServers.filter((s) => s.id !== server.id)
} else {
updateAssistant({ ...assistant, mcpServers: [...mcpServers, server] })
update.mcpServers = [...mcpServers, server]
}
// only for gemini
if (update.mcpServers.length > 0 && isGeminiModel(model) && isToolUseModeFunction(assistant)) {
const provider = getProviderByModel(model)
if (isSupportUrlContextProvider(provider) && assistant.enableUrlContext) {
window.message.warning(t('chat.mcp.warning.url_context'))
update.enableUrlContext = false
}
if (
// 非官方 API (openrouter etc.) 可能支持同时启用内置搜索和函数调用
// 这里先假设 gemini type 和 vertexai type 不支持
isGeminiWebSearchProvider(provider) &&
assistant.enableWebSearch
) {
window.message.warning(t('chat.mcp.warning.gemini_web_search'))
update.enableWebSearch = false
}
}
updateAssistant(update)
},
[assistant, assistantMcpServers, mcpServers, updateAssistant]
[assistant, assistantMcpServers, mcpServers, model, t, updateAssistant]
)
// 使用 useRef 缓存事件处理函数

View File

@ -1,6 +1,7 @@
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { Assistant } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Tooltip } from 'antd'
import { Link } from 'lucide-react'
import { FC, memo, useCallback } from 'react'
@ -27,11 +28,23 @@ const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
setTimeoutTimer(
'handleToggle',
() => {
updateAssistant({ ...assistant, enableUrlContext: urlContentNewState })
const update = { ...assistant }
if (
assistant.mcpServers &&
assistant.mcpServers.length > 0 &&
urlContentNewState === true &&
isToolUseModeFunction(assistant)
) {
update.enableUrlContext = false
window.message.warning(t('chat.mcp.warning.url_context'))
} else {
update.enableUrlContext = urlContentNewState
}
updateAssistant(update)
},
100
)
}, [setTimeoutTimer, updateAssistant, assistant, urlContentNewState])
}, [setTimeoutTimer, assistant, urlContentNewState, updateAssistant, t])
return (
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>

View File

@ -1,15 +1,20 @@
import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo } from '@renderer/components/Icons'
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { isWebSearchModel } from '@renderer/config/models'
import { isGeminiModel, isWebSearchModel } from '@renderer/config/models'
import { isGeminiWebSearchProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
import { getProviderByModel } from '@renderer/services/AssistantService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Assistant, WebSearchProvider, WebSearchProviderId } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Tooltip } from 'antd'
import { Globe } from 'lucide-react'
import { FC, memo, startTransition, useCallback, useImperativeHandle, useMemo } from 'react'
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export interface WebSearchButtonRef {
@ -22,11 +27,14 @@ interface Props {
ToolbarButton: any
}
const logger = loggerService.withContext('WebSearchButton')
const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const { providers } = useWebSearchProviders()
const { updateAssistant } = useAssistant(assistant.id)
const { setTimeoutTimer } = useTimer()
// 注意assistant.enableWebSearch 有不同的语义
/** 表示是否启用网络搜索 */
@ -54,13 +62,12 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
return <Globe size={size} style={{ color, fontSize: size }} />
}
},
[enableWebSearch]
[]
)
const updateWebSearchProvider = useCallback(
async (providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
startTransition(() => {
setTimeoutTimer('updateWebSearchProvider', () => {
updateAssistant({
...assistant,
webSearchProviderId: providerId,
@ -68,12 +75,11 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
})
})
},
[assistant, updateAssistant]
[assistant, setTimeoutTimer, updateAssistant]
)
const updateQuickPanelItem = useCallback(
async (providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
if (providerId === assistant.webSearchProviderId) {
updateWebSearchProvider(undefined)
} else {
@ -84,11 +90,31 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
)
const updateToModelBuiltinWebSearch = useCallback(async () => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
startTransition(() => {
updateAssistant({ ...assistant, webSearchProviderId: undefined, enableWebSearch: !assistant.enableWebSearch })
})
}, [assistant, updateAssistant])
const update = {
...assistant,
webSearchProviderId: undefined,
enableWebSearch: !assistant.enableWebSearch
}
const model = assistant.model
const provider = getProviderByModel(model)
if (!model) {
logger.error('Model does not exist.')
window.message.error(t('error.model.not_exists'))
return
}
if (
isGeminiWebSearchProvider(provider) &&
isGeminiModel(model) &&
isToolUseModeFunction(assistant) &&
update.enableWebSearch &&
assistant.mcpServers &&
assistant.mcpServers.length > 0
) {
update.enableWebSearch = false
window.message.warning(t('chat.mcp.warning.gemini_web_search'))
}
setTimeoutTimer('updateSelectedWebSearchBuiltin', () => updateAssistant(update), 200)
}, [assistant, setTimeoutTimer, t, updateAssistant])
const providerItems = useMemo<QuickPanelListItem[]>(() => {
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)

View File

@ -0,0 +1,40 @@
import { Assistant } from '@renderer/types'
import { cloneDeep } from 'lodash'
import { describe, expect, it } from 'vitest'
import { isToolUseModeFunction } from '../assistant'
describe('assistant', () => {
const assistant: Assistant = {
id: 'assistant',
name: 'assistant',
prompt: '',
topics: [],
type: ''
}
describe('isToolUseModeFunction', () => {
it('should detect function tool use mode', () => {
const mockAssistant = cloneDeep(assistant)
mockAssistant.settings = { toolUseMode: 'function' }
expect(isToolUseModeFunction(mockAssistant)).toBe(true)
})
it('should detect non-function tool use mode', () => {
const mockAssistant = cloneDeep(assistant)
mockAssistant.settings = { toolUseMode: 'prompt' }
expect(isToolUseModeFunction(mockAssistant)).toBe(false)
})
it('should handle undefined settings', () => {
const mockAssistant = cloneDeep(assistant)
expect(isToolUseModeFunction(mockAssistant)).toBe(false)
})
it('should handle undefined toolUseMode', () => {
const mockAssistant = cloneDeep(assistant)
mockAssistant.settings = {}
expect(isToolUseModeFunction(mockAssistant)).toBe(false)
})
})
})

View File

@ -0,0 +1,5 @@
import { Assistant } from '@renderer/types'
export const isToolUseModeFunction = (assistant: Assistant) => {
return assistant.settings?.toolUseMode === 'function'
}

View File

@ -28,6 +28,7 @@ import {
ChatCompletionTool
} from 'openai/resources'
import { isToolUseModeFunction } from './assistant'
import { convertBase64ImageToAwsBedrockFormat } from './aws-bedrock-utils'
import { filterProperties, processSchemaForO3 } from './mcp-schema'
@ -823,9 +824,7 @@ export function mcpToolCallResponseToAwsBedrockMessage(
export function isEnabledToolUse(assistant: Assistant) {
if (assistant.model) {
if (isFunctionCallingModel(assistant.model)) {
return assistant.settings?.toolUseMode === 'function'
}
return isFunctionCallingModel(assistant.model) && isToolUseModeFunction(assistant)
}
return false