diff --git a/src/composables/__tests__/useLlmProviderDirectory.spec.ts b/src/composables/__tests__/useLlmProviderDirectory.spec.ts
index b9716541..38a602fd 100644
--- a/src/composables/__tests__/useLlmProviderDirectory.spec.ts
+++ b/src/composables/__tests__/useLlmProviderDirectory.spec.ts
@@ -36,20 +36,25 @@ describe('useLlmProviderDirectory', () => {
mocks.apiGet.mockReset()
})
- it('仅为 OpenAI 兼容 runtime 显示 API 协议字段', async () => {
+ it('只为 OpenAI 兼容 runtime 或声明 Responses 工具能力的模型显示 API 协议字段', async () => {
mocks.apiGet.mockResolvedValue({
success: true,
- data: [createProvider('openai', 'openai_compatible'), createProvider('deepseek', 'deepseek')],
+ data: [
+ createProvider('openai', 'openai_compatible'),
+ createProvider('deepseek', 'deepseek'),
+ createProvider('google', 'google'),
+ ],
})
const Harness = defineComponent({
setup() {
const provider = ref('openai')
+ const model = ref('')
const directory = useLlmProviderDirectory({
provider,
apiKey: ref(''),
baseUrl: ref(''),
- model: ref(''),
+ model,
})
return {
@@ -57,7 +62,12 @@ describe('useLlmProviderDirectory', () => {
selectProvider: (value: string) => {
provider.value = value
},
+ selectModel: (value: string) => {
+ model.value = value
+ },
+ loadModels: directory.loadModels,
showApiProtocolField: directory.showApiProtocolField,
+ supportsBuiltinWebSearch: directory.supportsBuiltinWebSearch,
}
},
template: '
',
@@ -71,6 +81,58 @@ describe('useLlmProviderDirectory', () => {
wrapper.vm.selectProvider('deepseek')
await nextTick()
+ expect(wrapper.vm.showApiProtocolField).toBe(false)
+
+ mocks.apiGet.mockResolvedValueOnce({
+ success: true,
+ data: {
+ models: [
+ {
+ id: 'deepseek-v4-flash',
+ name: 'deepseek-v4-flash',
+ server_tools: [
+ {
+ id: 'web_search',
+ required_api_protocol: 'responses',
+ client_adapter: 'openai_responses',
+ },
+ ],
+ },
+ ],
+ },
+ })
+ await wrapper.vm.loadModels()
+ wrapper.vm.selectModel('deepseek-v4-flash')
+ await nextTick()
+
+ expect(wrapper.vm.supportsBuiltinWebSearch).toBe(true)
+ expect(wrapper.vm.showApiProtocolField).toBe(true)
+
+ wrapper.vm.selectProvider('google')
+ await nextTick()
+ mocks.apiGet.mockResolvedValueOnce({
+ success: true,
+ data: {
+ models: [
+ {
+ id: 'gemini-3.6-flash-preview',
+ name: 'gemini-3.6-flash-preview',
+ server_tools: [
+ {
+ id: 'web_search',
+ required_api_protocol: 'native',
+ client_adapter: 'google_native',
+ },
+ ],
+ },
+ ],
+ },
+ })
+ await wrapper.vm.loadModels()
+ wrapper.vm.selectModel('gemini-3.6-flash-preview')
+ await nextTick()
+
+ expect(wrapper.vm.supportsBuiltinWebSearch).toBe(true)
expect(wrapper.vm.showApiProtocolField).toBe(false)
wrapper.unmount()
})
diff --git a/src/composables/useLlmProviderDirectory.ts b/src/composables/useLlmProviderDirectory.ts
index eba0789f..930e798b 100644
--- a/src/composables/useLlmProviderDirectory.ts
+++ b/src/composables/useLlmProviderDirectory.ts
@@ -62,6 +62,13 @@ export interface LlmModel {
source?: string
release_date?: string | null
status?: string | null
+ server_tools?: LlmServerToolCapability[]
+}
+
+export interface LlmServerToolCapability {
+ id: string
+ required_api_protocol?: string
+ client_adapter?: string
}
export interface LlmProviderAuthSession {
@@ -113,6 +120,10 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
const selectedModel = computed(
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
)
+ const builtinWebSearchCapability = computed(() =>
+ selectedModel.value?.server_tools?.find(tool => tool.id === 'web_search'),
+ )
+ const supportsBuiltinWebSearch = computed(() => Boolean(builtinWebSearchCapability.value))
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
const baseUrlPresetItems = computed(() =>
(selectedProvider.value?.base_url_presets || []).map(item => ({
@@ -127,8 +138,12 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
)
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
- // OpenAI 兼容接口才需要选择 API 协议(Chat Completions / Responses)。
- const showApiProtocolField = computed(() => selectedProvider.value?.runtime === 'openai_compatible')
+ // 通用 OpenAI 兼容入口或要求 Responses 的服务端工具需要显示协议选项。
+ const showApiProtocolField = computed(
+ () =>
+ selectedProvider.value?.runtime === 'openai_compatible' ||
+ builtinWebSearchCapability.value?.required_api_protocol === 'responses',
+ )
const hasUsableCredential = computed(() => {
if (providerConnected.value) return true
return Boolean(normalizeValue(options.apiKey.value))
@@ -390,6 +405,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
models,
selectedProvider,
selectedModel,
+ supportsBuiltinWebSearch,
loadingProviders,
loadingModels,
providerConnected,
diff --git a/src/composables/useSetupWizard.ts b/src/composables/useSetupWizard.ts
index 28d3ece6..668c5e2b 100644
--- a/src/composables/useSetupWizard.ts
+++ b/src/composables/useSetupWizard.ts
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { copyToClipboard } from '@/@core/utils/navigator'
-import { User } from '@/api/types'
+import type { ApiResponse, User } from '@/api/types'
export interface WizardData {
basic: {
@@ -57,6 +57,7 @@ export interface WizardData {
model: string
thinkingLevel: string
apiProtocol: string
+ webSearchMode: string
supportImageInput: boolean
supportAudioInput: boolean
supportAudioOutput: boolean
@@ -249,6 +250,7 @@ const wizardData = ref({
model: 'deepseek-chat',
thinkingLevel: 'off',
apiProtocol: 'auto',
+ webSearchMode: 'local',
supportImageInput: true,
supportAudioInput: false,
supportAudioOutput: false,
@@ -1451,6 +1453,7 @@ export function useSetupWizard() {
LLM_MODEL: wizardData.value.agent.model,
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
LLM_API_PROTOCOL: wizardData.value.agent.apiProtocol || 'auto',
+ LLM_WEB_SEARCH_MODE: wizardData.value.agent.webSearchMode || 'local',
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_SUPPORT_AUDIO_INPUT: wizardData.value.agent.supportAudioInput,
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
@@ -1479,7 +1482,11 @@ export function useSetupWizard() {
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
}
- await api.post('system/env', agentSettings)
+ const response: Pick, 'success' | 'message'> = await api.post('system/env', agentSettings)
+ if (!response.success) {
+ $toast.error(response.message || t('setupWizard.saveAgentSettingsFailed'))
+ return false
+ }
return true
} catch (error) {
console.error('Save agent settings failed:', error)
@@ -1567,6 +1574,7 @@ export function useSetupWizard() {
wizardData.value.agent.model = result.data.LLM_MODEL || ''
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
wizardData.value.agent.apiProtocol = result.data.LLM_API_PROTOCOL || 'auto'
+ wizardData.value.agent.webSearchMode = result.data.LLM_WEB_SEARCH_MODE || 'local'
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT)
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index 9791b136..58253c66 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -1843,6 +1843,15 @@ export default {
llmApiProtocolAuto: 'Auto (auto)',
llmApiProtocolChatCompletions: 'Chat Completions',
llmApiProtocolResponses: 'Responses',
+ llmWebSearchMode: 'Web Search',
+ llmWebSearchModeHint:
+ 'Use MoviePilot local search, model-hosted search, automatic fallback, or disable web access. Hosted search is available only for models that declare support.',
+ llmWebSearchModeBuiltinSupportedHint:
+ 'This model supports provider-hosted search. Built-in and Auto use the required protocol without a separate search API key.',
+ llmWebSearchModeLocal: 'MoviePilot local search',
+ llmWebSearchModeBuiltin: 'Model-hosted search',
+ llmWebSearchModeAuto: 'Auto (hosted first)',
+ llmWebSearchModeDisabled: 'Disable web search',
llmTemperature: 'Temperature',
llmTemperatureHint:
'Controls response randomness. Lower values are steadier and higher values are more varied. Backend default is 0.3; 0-2 is usually recommended.',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index 74278504..0113d49f 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -1827,6 +1827,15 @@ export default {
llmApiProtocolAuto: '自动 (auto)',
llmApiProtocolChatCompletions: 'Chat Completions',
llmApiProtocolResponses: 'Responses',
+ llmWebSearchMode: '联网搜索',
+ llmWebSearchModeHint:
+ '选择 MoviePilot 本地搜索、模型服务端搜索、自动回退或完全关闭;服务端搜索仅在当前模型声明支持时可用',
+ llmWebSearchModeBuiltinSupportedHint:
+ '当前模型支持官方托管搜索;选择“模型服务端”或“自动”时会按所需协议调用,无需额外搜索密钥',
+ llmWebSearchModeLocal: 'MoviePilot 本地搜索',
+ llmWebSearchModeBuiltin: '模型服务端搜索',
+ llmWebSearchModeAuto: '自动(服务端优先)',
+ llmWebSearchModeDisabled: '关闭联网搜索',
llmTemperature: '温度参数',
llmTemperatureHint: '控制回复随机性,数值越低越稳定,越高越发散;后端默认 0.3,通常建议 0-2',
llmProviderAuth: '提供商授权',
diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts
index 705bbd28..4e4c9230 100644
--- a/src/locales/zh-TW.ts
+++ b/src/locales/zh-TW.ts
@@ -1826,6 +1826,15 @@ export default {
llmApiProtocolAuto: '自動 (auto)',
llmApiProtocolChatCompletions: 'Chat Completions',
llmApiProtocolResponses: 'Responses',
+ llmWebSearchMode: '聯網搜尋',
+ llmWebSearchModeHint:
+ '選擇 MoviePilot 本地搜尋、模型服務端搜尋、自動回退或完全關閉;服務端搜尋僅在目前模型宣告支援時可用',
+ llmWebSearchModeBuiltinSupportedHint:
+ '目前模型支援官方代管搜尋;選擇「模型服務端」或「自動」時會按所需協議呼叫,無需額外搜尋金鑰',
+ llmWebSearchModeLocal: 'MoviePilot 本地搜尋',
+ llmWebSearchModeBuiltin: '模型服務端搜尋',
+ llmWebSearchModeAuto: '自動(服務端優先)',
+ llmWebSearchModeDisabled: '關閉聯網搜尋',
llmTemperature: '溫度參數',
llmTemperatureHint: '控制回覆隨機性,數值越低越穩定,越高越發散;後端預設 0.3,通常建議 0-2',
llmProviderAuth: '提供商授權',
diff --git a/src/views/setting/AccountSettingSystem.vue b/src/views/setting/AccountSettingSystem.vue
index c1bfa7d2..0b3c77b8 100644
--- a/src/views/setting/AccountSettingSystem.vue
+++ b/src/views/setting/AccountSettingSystem.vue
@@ -54,6 +54,7 @@ const SystemSettings = ref({
LLM_MODEL: 'deepseek-chat',
LLM_THINKING_LEVEL: 'off',
LLM_API_PROTOCOL: 'auto',
+ LLM_WEB_SEARCH_MODE: 'local',
LLM_SUPPORT_IMAGE_INPUT: false,
LLM_SUPPORT_AUDIO_INPUT: false,
LLM_SUPPORT_AUDIO_OUTPUT: false,
@@ -223,6 +224,7 @@ type LlmSettingsSnapshot = {
LLM_MODEL: string
LLM_THINKING_LEVEL: string
LLM_API_PROTOCOL: string
+ LLM_WEB_SEARCH_MODE: string
LLM_API_KEY: string
LLM_BASE_URL: string
LLM_USE_PROXY: boolean
@@ -327,6 +329,7 @@ const {
showBaseUrlField,
showApiKeyField,
showApiProtocolField: showLlmApiProtocolField,
+ supportsBuiltinWebSearch,
canRefreshModels,
setBaseUrlPreset,
authDialogVisible,
@@ -409,6 +412,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_MODEL: String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_PROTOCOL: String(SystemSettings.value.Basic.LLM_API_PROTOCOL ?? 'auto'),
+ LLM_WEB_SEARCH_MODE: String(SystemSettings.value.Basic.LLM_WEB_SEARCH_MODE ?? 'local'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
@@ -429,6 +433,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
model: snapshot.LLM_MODEL.trim(),
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_protocol: snapshot.LLM_API_PROTOCOL.trim() || 'auto',
+ web_search_mode: snapshot.LLM_WEB_SEARCH_MODE.trim() || 'local',
api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(),
use_proxy: snapshot.LLM_USE_PROXY,
@@ -532,6 +537,23 @@ const apiProtocolItems = computed(() => [
{ title: t('setting.system.llmApiProtocolResponses'), value: 'responses' },
])
+const webSearchModeItems = computed(() => [
+ { title: t('setting.system.llmWebSearchModeLocal'), value: 'local' },
+ {
+ title: t('setting.system.llmWebSearchModeBuiltin'),
+ value: 'builtin',
+ disabled: !supportsBuiltinWebSearch.value,
+ },
+ { title: t('setting.system.llmWebSearchModeAuto'), value: 'auto' },
+ { title: t('setting.system.llmWebSearchModeDisabled'), value: 'disabled' },
+])
+
+const webSearchModeHint = computed(() =>
+ supportsBuiltinWebSearch.value
+ ? t('setting.system.llmWebSearchModeBuiltinSupportedHint')
+ : t('setting.system.llmWebSearchModeHint'),
+)
+
const activeTab = ref('system')
// 元数据语言
@@ -1376,6 +1398,16 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
+
+
+
[
{ title: t('setting.system.llmApiProtocolResponses'), value: 'responses' },
])
+const webSearchModeItems = computed(() => [
+ { title: t('setting.system.llmWebSearchModeLocal'), value: 'local' },
+ {
+ title: t('setting.system.llmWebSearchModeBuiltin'),
+ value: 'builtin',
+ disabled: !supportsBuiltinWebSearch.value,
+ },
+ { title: t('setting.system.llmWebSearchModeAuto'), value: 'auto' },
+ { title: t('setting.system.llmWebSearchModeDisabled'), value: 'disabled' },
+])
+
+const webSearchModeHint = computed(() =>
+ supportsBuiltinWebSearch.value
+ ? t('setting.system.llmWebSearchModeBuiltinSupportedHint')
+ : t('setting.system.llmWebSearchModeHint'),
+)
+
const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
@@ -483,6 +501,18 @@ onMounted(async () => {
+
+
+
+