feat: 增加服务端联网搜索设置

This commit is contained in:
jxxghp
2026-08-05 19:19:26 +08:00
parent 1129d63fcc
commit 24f1580501
8 changed files with 182 additions and 7 deletions
@@ -36,20 +36,25 @@ describe('useLlmProviderDirectory', () => {
mocks.apiGet.mockReset() mocks.apiGet.mockReset()
}) })
it('为 OpenAI 兼容 runtime 显示 API 协议字段', async () => { it('为 OpenAI 兼容 runtime 或声明 Responses 工具能力的模型显示 API 协议字段', async () => {
mocks.apiGet.mockResolvedValue({ mocks.apiGet.mockResolvedValue({
success: true, success: true,
data: [createProvider('openai', 'openai_compatible'), createProvider('deepseek', 'deepseek')], data: [
createProvider('openai', 'openai_compatible'),
createProvider('deepseek', 'deepseek'),
createProvider('google', 'google'),
],
}) })
const Harness = defineComponent({ const Harness = defineComponent({
setup() { setup() {
const provider = ref('openai') const provider = ref('openai')
const model = ref('')
const directory = useLlmProviderDirectory({ const directory = useLlmProviderDirectory({
provider, provider,
apiKey: ref(''), apiKey: ref(''),
baseUrl: ref(''), baseUrl: ref(''),
model: ref(''), model,
}) })
return { return {
@@ -57,7 +62,12 @@ describe('useLlmProviderDirectory', () => {
selectProvider: (value: string) => { selectProvider: (value: string) => {
provider.value = value provider.value = value
}, },
selectModel: (value: string) => {
model.value = value
},
loadModels: directory.loadModels,
showApiProtocolField: directory.showApiProtocolField, showApiProtocolField: directory.showApiProtocolField,
supportsBuiltinWebSearch: directory.supportsBuiltinWebSearch,
} }
}, },
template: '<div />', template: '<div />',
@@ -71,6 +81,58 @@ describe('useLlmProviderDirectory', () => {
wrapper.vm.selectProvider('deepseek') wrapper.vm.selectProvider('deepseek')
await nextTick() 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) expect(wrapper.vm.showApiProtocolField).toBe(false)
wrapper.unmount() wrapper.unmount()
}) })
+18 -2
View File
@@ -62,6 +62,13 @@ export interface LlmModel {
source?: string source?: string
release_date?: string | null release_date?: string | null
status?: string | null status?: string | null
server_tools?: LlmServerToolCapability[]
}
export interface LlmServerToolCapability {
id: string
required_api_protocol?: string
client_adapter?: string
} }
export interface LlmProviderAuthSession { export interface LlmProviderAuthSession {
@@ -113,6 +120,10 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
const selectedModel = computed( const selectedModel = computed(
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null, () => 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 providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() => const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
(selectedProvider.value?.base_url_presets || []).map(item => ({ (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), Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
) )
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false) const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
// OpenAI 兼容接口才需要选择 API 协议(Chat Completions / Responses // 通用 OpenAI 兼容入口或要求 Responses 的服务端工具需要显示协议选项
const showApiProtocolField = computed(() => selectedProvider.value?.runtime === 'openai_compatible') const showApiProtocolField = computed(
() =>
selectedProvider.value?.runtime === 'openai_compatible' ||
builtinWebSearchCapability.value?.required_api_protocol === 'responses',
)
const hasUsableCredential = computed(() => { const hasUsableCredential = computed(() => {
if (providerConnected.value) return true if (providerConnected.value) return true
return Boolean(normalizeValue(options.apiKey.value)) return Boolean(normalizeValue(options.apiKey.value))
@@ -390,6 +405,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
models, models,
selectedProvider, selectedProvider,
selectedModel, selectedModel,
supportsBuiltinWebSearch,
loadingProviders, loadingProviders,
loadingModels, loadingModels,
providerConnected, providerConnected,
+10 -2
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api from '@/api' import api from '@/api'
import { copyToClipboard } from '@/@core/utils/navigator' import { copyToClipboard } from '@/@core/utils/navigator'
import { User } from '@/api/types' import type { ApiResponse, User } from '@/api/types'
export interface WizardData { export interface WizardData {
basic: { basic: {
@@ -57,6 +57,7 @@ export interface WizardData {
model: string model: string
thinkingLevel: string thinkingLevel: string
apiProtocol: string apiProtocol: string
webSearchMode: string
supportImageInput: boolean supportImageInput: boolean
supportAudioInput: boolean supportAudioInput: boolean
supportAudioOutput: boolean supportAudioOutput: boolean
@@ -249,6 +250,7 @@ const wizardData = ref<WizardData>({
model: 'deepseek-chat', model: 'deepseek-chat',
thinkingLevel: 'off', thinkingLevel: 'off',
apiProtocol: 'auto', apiProtocol: 'auto',
webSearchMode: 'local',
supportImageInput: true, supportImageInput: true,
supportAudioInput: false, supportAudioInput: false,
supportAudioOutput: false, supportAudioOutput: false,
@@ -1451,6 +1453,7 @@ export function useSetupWizard() {
LLM_MODEL: wizardData.value.agent.model, LLM_MODEL: wizardData.value.agent.model,
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel, LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
LLM_API_PROTOCOL: wizardData.value.agent.apiProtocol || 'auto', 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_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_SUPPORT_AUDIO_INPUT: wizardData.value.agent.supportAudioInput, LLM_SUPPORT_AUDIO_INPUT: wizardData.value.agent.supportAudioInput,
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput, LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
@@ -1479,7 +1482,11 @@ export function useSetupWizard() {
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems, AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
} }
await api.post('system/env', agentSettings) const response: Pick<ApiResponse<unknown>, 'success' | 'message'> = await api.post('system/env', agentSettings)
if (!response.success) {
$toast.error(response.message || t('setupWizard.saveAgentSettingsFailed'))
return false
}
return true return true
} catch (error) { } catch (error) {
console.error('Save agent settings failed:', 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.model = result.data.LLM_MODEL || ''
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data) wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
wizardData.value.agent.apiProtocol = result.data.LLM_API_PROTOCOL || 'auto' 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.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT) wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT)
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT) wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
+9
View File
@@ -1843,6 +1843,15 @@ export default {
llmApiProtocolAuto: 'Auto (auto)', llmApiProtocolAuto: 'Auto (auto)',
llmApiProtocolChatCompletions: 'Chat Completions', llmApiProtocolChatCompletions: 'Chat Completions',
llmApiProtocolResponses: 'Responses', 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', llmTemperature: 'Temperature',
llmTemperatureHint: llmTemperatureHint:
'Controls response randomness. Lower values are steadier and higher values are more varied. Backend default is 0.3; 0-2 is usually recommended.', 'Controls response randomness. Lower values are steadier and higher values are more varied. Backend default is 0.3; 0-2 is usually recommended.',
+9
View File
@@ -1827,6 +1827,15 @@ export default {
llmApiProtocolAuto: '自动 (auto)', llmApiProtocolAuto: '自动 (auto)',
llmApiProtocolChatCompletions: 'Chat Completions', llmApiProtocolChatCompletions: 'Chat Completions',
llmApiProtocolResponses: 'Responses', llmApiProtocolResponses: 'Responses',
llmWebSearchMode: '联网搜索',
llmWebSearchModeHint:
'选择 MoviePilot 本地搜索、模型服务端搜索、自动回退或完全关闭;服务端搜索仅在当前模型声明支持时可用',
llmWebSearchModeBuiltinSupportedHint:
'当前模型支持官方托管搜索;选择“模型服务端”或“自动”时会按所需协议调用,无需额外搜索密钥',
llmWebSearchModeLocal: 'MoviePilot 本地搜索',
llmWebSearchModeBuiltin: '模型服务端搜索',
llmWebSearchModeAuto: '自动(服务端优先)',
llmWebSearchModeDisabled: '关闭联网搜索',
llmTemperature: '温度参数', llmTemperature: '温度参数',
llmTemperatureHint: '控制回复随机性,数值越低越稳定,越高越发散;后端默认 0.3,通常建议 0-2', llmTemperatureHint: '控制回复随机性,数值越低越稳定,越高越发散;后端默认 0.3,通常建议 0-2',
llmProviderAuth: '提供商授权', llmProviderAuth: '提供商授权',
+9
View File
@@ -1826,6 +1826,15 @@ export default {
llmApiProtocolAuto: '自動 (auto)', llmApiProtocolAuto: '自動 (auto)',
llmApiProtocolChatCompletions: 'Chat Completions', llmApiProtocolChatCompletions: 'Chat Completions',
llmApiProtocolResponses: 'Responses', llmApiProtocolResponses: 'Responses',
llmWebSearchMode: '聯網搜尋',
llmWebSearchModeHint:
'選擇 MoviePilot 本地搜尋、模型服務端搜尋、自動回退或完全關閉;服務端搜尋僅在目前模型宣告支援時可用',
llmWebSearchModeBuiltinSupportedHint:
'目前模型支援官方代管搜尋;選擇「模型服務端」或「自動」時會按所需協議呼叫,無需額外搜尋金鑰',
llmWebSearchModeLocal: 'MoviePilot 本地搜尋',
llmWebSearchModeBuiltin: '模型服務端搜尋',
llmWebSearchModeAuto: '自動(服務端優先)',
llmWebSearchModeDisabled: '關閉聯網搜尋',
llmTemperature: '溫度參數', llmTemperature: '溫度參數',
llmTemperatureHint: '控制回覆隨機性,數值越低越穩定,越高越發散;後端預設 0.3,通常建議 0-2', llmTemperatureHint: '控制回覆隨機性,數值越低越穩定,越高越發散;後端預設 0.3,通常建議 0-2',
llmProviderAuth: '提供商授權', llmProviderAuth: '提供商授權',
@@ -54,6 +54,7 @@ const SystemSettings = ref<any>({
LLM_MODEL: 'deepseek-chat', LLM_MODEL: 'deepseek-chat',
LLM_THINKING_LEVEL: 'off', LLM_THINKING_LEVEL: 'off',
LLM_API_PROTOCOL: 'auto', LLM_API_PROTOCOL: 'auto',
LLM_WEB_SEARCH_MODE: 'local',
LLM_SUPPORT_IMAGE_INPUT: false, LLM_SUPPORT_IMAGE_INPUT: false,
LLM_SUPPORT_AUDIO_INPUT: false, LLM_SUPPORT_AUDIO_INPUT: false,
LLM_SUPPORT_AUDIO_OUTPUT: false, LLM_SUPPORT_AUDIO_OUTPUT: false,
@@ -223,6 +224,7 @@ type LlmSettingsSnapshot = {
LLM_MODEL: string LLM_MODEL: string
LLM_THINKING_LEVEL: string LLM_THINKING_LEVEL: string
LLM_API_PROTOCOL: string LLM_API_PROTOCOL: string
LLM_WEB_SEARCH_MODE: string
LLM_API_KEY: string LLM_API_KEY: string
LLM_BASE_URL: string LLM_BASE_URL: string
LLM_USE_PROXY: boolean LLM_USE_PROXY: boolean
@@ -327,6 +329,7 @@ const {
showBaseUrlField, showBaseUrlField,
showApiKeyField, showApiKeyField,
showApiProtocolField: showLlmApiProtocolField, showApiProtocolField: showLlmApiProtocolField,
supportsBuiltinWebSearch,
canRefreshModels, canRefreshModels,
setBaseUrlPreset, setBaseUrlPreset,
authDialogVisible, authDialogVisible,
@@ -409,6 +412,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_MODEL: String(SystemSettings.value.Basic.LLM_MODEL ?? ''), LLM_MODEL: String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'), LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_PROTOCOL: String(SystemSettings.value.Basic.LLM_API_PROTOCOL ?? 'auto'), 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_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''), LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY), LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
@@ -429,6 +433,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
model: snapshot.LLM_MODEL.trim(), model: snapshot.LLM_MODEL.trim(),
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(), thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_protocol: snapshot.LLM_API_PROTOCOL.trim() || 'auto', 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(), api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(), base_url: snapshot.LLM_BASE_URL.trim(),
use_proxy: snapshot.LLM_USE_PROXY, use_proxy: snapshot.LLM_USE_PROXY,
@@ -532,6 +537,23 @@ const apiProtocolItems = computed(() => [
{ title: t('setting.system.llmApiProtocolResponses'), value: 'responses' }, { 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') const activeTab = ref('system')
// 元数据语言 // 元数据语言
@@ -1376,6 +1398,16 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</div> </div>
</div> </div>
</VCol> </VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_WEB_SEARCH_MODE"
:label="t('setting.system.llmWebSearchMode')"
:hint="webSearchModeHint"
persistent-hint
:items="webSearchModeItems"
prepend-inner-icon="mdi-web"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6"> <VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VTextField <VTextField
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS" v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
+30
View File
@@ -94,6 +94,7 @@ const {
showBaseUrlField, showBaseUrlField,
showApiKeyField, showApiKeyField,
showApiProtocolField, showApiProtocolField,
supportsBuiltinWebSearch,
canRefreshModels, canRefreshModels,
setBaseUrlPreset, setBaseUrlPreset,
authDialogVisible, authDialogVisible,
@@ -198,6 +199,23 @@ const apiProtocolItems = computed(() => [
{ title: t('setting.system.llmApiProtocolResponses'), value: 'responses' }, { 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(() => [ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' }, { title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' }, { title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
@@ -483,6 +501,18 @@ onMounted(async () => {
</VAlert> </VAlert>
</VCol> </VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.webSearchMode"
:label="t('setting.system.llmWebSearchMode')"
:hint="webSearchModeHint"
:items="webSearchModeItems"
persistent-hint
prepend-inner-icon="mdi-web"
color="primary"
/>
</VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VTextField <VTextField
v-model.number="wizardData.agent.maxContextTokens" v-model.number="wizardData.agent.maxContextTokens"