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

View File

@@ -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: '<div />',
@@ -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()
})

View File

@@ -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<LlmProviderUrlPresetItem[]>(() =>
(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,

View File

@@ -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<WizardData>({
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<ApiResponse<unknown>, '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)

View File

@@ -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.',

View File

@@ -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: '提供商授权',

View File

@@ -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: '提供商授權',

View File

@@ -54,6 +54,7 @@ const SystemSettings = ref<any>({
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) => {
</div>
</div>
</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">
<VTextField
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"

View File

@@ -94,6 +94,7 @@ const {
showBaseUrlField,
showApiKeyField,
showApiProtocolField,
supportsBuiltinWebSearch,
canRefreshModels,
setBaseUrlPreset,
authDialogVisible,
@@ -198,6 +199,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 audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
@@ -483,6 +501,18 @@ onMounted(async () => {
</VAlert>
</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">
<VTextField
v-model.number="wizardData.agent.maxContextTokens"