From 24f1580501b2996c36e4cf147f693f962b6588bb Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 5 Aug 2026 19:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E8=81=94=E7=BD=91=E6=90=9C=E7=B4=A2=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useLlmProviderDirectory.spec.ts | 68 ++++++++++++++++++- src/composables/useLlmProviderDirectory.ts | 20 +++++- src/composables/useSetupWizard.ts | 12 +++- src/locales/en-US.ts | 9 +++ src/locales/zh-CN.ts | 9 +++ src/locales/zh-TW.ts | 9 +++ src/views/setting/AccountSettingSystem.vue | 32 +++++++++ src/views/setup/AgentSettingsStep.vue | 30 ++++++++ 8 files changed, 182 insertions(+), 7 deletions(-) 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 () => { + + + +