mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
feat: 增加服务端联网搜索设置
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '提供商授权',
|
||||
|
||||
@@ -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: '提供商授權',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user