mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
refactor: extract LLM provider management logic into composable and add OAuth support for system settings
This commit is contained in:
@@ -0,0 +1,360 @@
|
|||||||
|
import { computed, onBeforeUnmount, ref, type Ref } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
export interface LlmProviderAuthMethod {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmProviderAuthStatus {
|
||||||
|
connected: boolean
|
||||||
|
type?: string
|
||||||
|
label?: string
|
||||||
|
expires_at?: number | null
|
||||||
|
updated_at?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmProvider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
runtime: string
|
||||||
|
default_base_url: string
|
||||||
|
base_url_editable: boolean
|
||||||
|
requires_base_url: boolean
|
||||||
|
supports_api_key: boolean
|
||||||
|
api_key_label: string
|
||||||
|
api_key_hint: string
|
||||||
|
supports_model_refresh: boolean
|
||||||
|
oauth_methods: LlmProviderAuthMethod[]
|
||||||
|
description?: string
|
||||||
|
auth_status: LlmProviderAuthStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
family?: string
|
||||||
|
context_tokens?: number | null
|
||||||
|
input_tokens?: number | null
|
||||||
|
output_tokens?: number | null
|
||||||
|
context_tokens_k?: number | null
|
||||||
|
supports_reasoning?: boolean
|
||||||
|
supports_tools?: boolean
|
||||||
|
supports_image_input?: boolean
|
||||||
|
supports_audio_input?: boolean
|
||||||
|
transport?: string
|
||||||
|
source?: string
|
||||||
|
release_date?: string | null
|
||||||
|
status?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmProviderAuthSession {
|
||||||
|
session_id: string
|
||||||
|
provider_id: string
|
||||||
|
flow_type: string
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
authorize_url?: string
|
||||||
|
verification_url?: string
|
||||||
|
user_code?: string
|
||||||
|
instructions?: string
|
||||||
|
interval_seconds?: number
|
||||||
|
expires_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLlmProviderDirectoryOptions {
|
||||||
|
provider: Ref<string>
|
||||||
|
apiKey: Ref<string>
|
||||||
|
baseUrl: Ref<string>
|
||||||
|
model: Ref<string>
|
||||||
|
maxContextTokens?: Ref<number>
|
||||||
|
authConnected?: Ref<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value: unknown) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions) {
|
||||||
|
const providers = ref<LlmProvider[]>([])
|
||||||
|
const models = ref<LlmModel[]>([])
|
||||||
|
const loadingProviders = ref(false)
|
||||||
|
const loadingModels = ref(false)
|
||||||
|
const authDialogVisible = ref(false)
|
||||||
|
const authPolling = ref(false)
|
||||||
|
const authPopupBlocked = ref(false)
|
||||||
|
const authSession = ref<LlmProviderAuthSession | null>(null)
|
||||||
|
|
||||||
|
let pollTimer: number | null = null
|
||||||
|
|
||||||
|
const selectedProvider = computed(
|
||||||
|
() => providers.value.find(item => item.id === normalizeValue(options.provider.value)) || null,
|
||||||
|
)
|
||||||
|
const selectedModel = computed(
|
||||||
|
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
|
||||||
|
)
|
||||||
|
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
|
||||||
|
const providerConnected = computed(() => Boolean(selectedProvider.value?.auth_status?.connected))
|
||||||
|
const showBaseUrlField = computed(
|
||||||
|
() => Boolean(selectedProvider.value?.requires_base_url || selectedProvider.value?.base_url_editable),
|
||||||
|
)
|
||||||
|
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
|
||||||
|
const hasUsableCredential = computed(() => {
|
||||||
|
if (providerConnected.value) return true
|
||||||
|
return Boolean(normalizeValue(options.apiKey.value))
|
||||||
|
})
|
||||||
|
const canRefreshModels = computed(() => {
|
||||||
|
if (!selectedProvider.value?.supports_model_refresh) return false
|
||||||
|
if (!hasUsableCredential.value) return false
|
||||||
|
if (selectedProvider.value.requires_base_url && !normalizeValue(options.baseUrl.value)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearPollTimer() {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
window.clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAuthConnected() {
|
||||||
|
if (options.authConnected) {
|
||||||
|
options.authConnected.value = providerConnected.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBaseUrl(reset = false) {
|
||||||
|
const provider = selectedProvider.value
|
||||||
|
if (!provider) return
|
||||||
|
|
||||||
|
const currentBaseUrl = normalizeValue(options.baseUrl.value)
|
||||||
|
const defaultBaseUrl = provider.default_base_url || ''
|
||||||
|
if (reset) {
|
||||||
|
options.baseUrl.value = defaultBaseUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentBaseUrl && defaultBaseUrl) {
|
||||||
|
options.baseUrl.value = defaultBaseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProviderSelection(resetBaseUrl = true) {
|
||||||
|
ensureBaseUrl(resetBaseUrl)
|
||||||
|
options.apiKey.value = ''
|
||||||
|
if (options.maxContextTokens) {
|
||||||
|
options.maxContextTokens.value = 64
|
||||||
|
}
|
||||||
|
models.value = []
|
||||||
|
options.model.value = ''
|
||||||
|
syncAuthConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModelMetadata(modelId?: string) {
|
||||||
|
const targetId = normalizeValue(modelId ?? options.model.value)
|
||||||
|
if (!targetId) return null
|
||||||
|
|
||||||
|
const matched = models.value.find(item => item.id === targetId) || null
|
||||||
|
if (matched?.context_tokens_k && options.maxContextTokens) {
|
||||||
|
// models.dev / provider 返回的是精确 token,这里回填到现有的 K 单位配置。
|
||||||
|
options.maxContextTokens.value = matched.context_tokens_k
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProviderAuthStatus(providerId: string, authStatus?: LlmProviderAuthStatus) {
|
||||||
|
if (!authStatus) return
|
||||||
|
const index = providers.value.findIndex(item => item.id === providerId)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
providers.value[index] = {
|
||||||
|
...providers.value[index],
|
||||||
|
auth_status: authStatus,
|
||||||
|
}
|
||||||
|
syncAuthConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders(preserveBaseUrl = true) {
|
||||||
|
loadingProviders.value = true
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('llm/providers')
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Load LLM providers failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
providers.value = Array.isArray(result.data) ? result.data : []
|
||||||
|
if (!selectedProvider.value && providers.value.length > 0) {
|
||||||
|
options.provider.value = providers.value[0].id
|
||||||
|
}
|
||||||
|
ensureBaseUrl(!preserveBaseUrl)
|
||||||
|
syncAuthConnected()
|
||||||
|
return providers.value
|
||||||
|
} finally {
|
||||||
|
loadingProviders.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels(forceRefresh = false) {
|
||||||
|
if (!selectedProvider.value) return []
|
||||||
|
|
||||||
|
loadingModels.value = true
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('llm/models', {
|
||||||
|
params: {
|
||||||
|
provider: normalizeValue(options.provider.value),
|
||||||
|
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||||
|
base_url: normalizeValue(options.baseUrl.value) || undefined,
|
||||||
|
force_refresh: forceRefresh,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Load LLM models failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = result.data || {}
|
||||||
|
models.value = Array.isArray(payload.models) ? payload.models : []
|
||||||
|
updateProviderAuthStatus(normalizeValue(options.provider.value), payload.auth_status)
|
||||||
|
|
||||||
|
const currentModelId = normalizeValue(options.model.value)
|
||||||
|
const matchedModel = currentModelId
|
||||||
|
? models.value.find(item => item.id === currentModelId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (matchedModel) {
|
||||||
|
applyModelMetadata(matchedModel.id)
|
||||||
|
} else if (models.value.length > 0) {
|
||||||
|
options.model.value = models.value[0].id
|
||||||
|
applyModelMetadata(models.value[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.value
|
||||||
|
} finally {
|
||||||
|
loadingModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuthPage() {
|
||||||
|
const session = authSession.value
|
||||||
|
const targetUrl = session?.authorize_url || session?.verification_url
|
||||||
|
if (!targetUrl) return
|
||||||
|
|
||||||
|
const popup = window.open(targetUrl, '_blank', 'noopener,noreferrer,width=960,height=780')
|
||||||
|
authPopupBlocked.value = !popup
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAuthSession() {
|
||||||
|
if (!authSession.value) return null
|
||||||
|
|
||||||
|
authPolling.value = true
|
||||||
|
clearPollTimer()
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.post(
|
||||||
|
`llm/provider-auth/${authSession.value.session_id}/poll`,
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Poll LLM auth failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
authSession.value = {
|
||||||
|
...authSession.value,
|
||||||
|
...result.data,
|
||||||
|
}
|
||||||
|
const nextSession = authSession.value
|
||||||
|
if (!nextSession) return null
|
||||||
|
|
||||||
|
if (nextSession.status === 'pending') {
|
||||||
|
pollTimer = window.setTimeout(
|
||||||
|
() => pollAuthSession().catch(() => undefined),
|
||||||
|
Math.max(nextSession.interval_seconds || 5, 1) * 1000,
|
||||||
|
)
|
||||||
|
return nextSession
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadProviders()
|
||||||
|
if (nextSession.status === 'authorized') {
|
||||||
|
await loadModels(true).catch(() => undefined)
|
||||||
|
}
|
||||||
|
return nextSession
|
||||||
|
} finally {
|
||||||
|
authPolling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAuth(methodId: string) {
|
||||||
|
if (!selectedProvider.value) {
|
||||||
|
throw new Error('LLM provider is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: { [key: string]: any } = await api.post('llm/provider-auth/start', {
|
||||||
|
provider: normalizeValue(options.provider.value),
|
||||||
|
method: methodId,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Start LLM auth failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
authSession.value = {
|
||||||
|
status: 'pending',
|
||||||
|
provider_id: normalizeValue(options.provider.value),
|
||||||
|
...result.data,
|
||||||
|
}
|
||||||
|
authDialogVisible.value = true
|
||||||
|
authPopupBlocked.value = false
|
||||||
|
openAuthPage()
|
||||||
|
pollTimer = window.setTimeout(() => pollAuthSession().catch(() => undefined), 1200)
|
||||||
|
return authSession.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectAuth() {
|
||||||
|
if (!selectedProvider.value) return false
|
||||||
|
|
||||||
|
const result: { [key: string]: any } = await api.delete(
|
||||||
|
`llm/provider-auth/${normalizeValue(options.provider.value)}`,
|
||||||
|
)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Disconnect LLM auth failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadProviders()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAuthDialog() {
|
||||||
|
authDialogVisible.value = false
|
||||||
|
clearPollTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPollTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
providers,
|
||||||
|
providerItems,
|
||||||
|
models,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
loadingProviders,
|
||||||
|
loadingModels,
|
||||||
|
providerConnected,
|
||||||
|
showBaseUrlField,
|
||||||
|
showApiKeyField,
|
||||||
|
hasUsableCredential,
|
||||||
|
canRefreshModels,
|
||||||
|
authDialogVisible,
|
||||||
|
authPolling,
|
||||||
|
authPopupBlocked,
|
||||||
|
authSession,
|
||||||
|
handleProviderSelection,
|
||||||
|
applyModelMetadata,
|
||||||
|
loadProviders,
|
||||||
|
loadModels,
|
||||||
|
openAuthPage,
|
||||||
|
startAuth,
|
||||||
|
pollAuthSession,
|
||||||
|
disconnectAuth,
|
||||||
|
closeAuthDialog,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export interface WizardData {
|
|||||||
global: boolean
|
global: boolean
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
provider: string
|
provider: string
|
||||||
|
authConnected: boolean
|
||||||
model: string
|
model: string
|
||||||
thinkingLevel: string
|
thinkingLevel: string
|
||||||
supportImageInput: boolean
|
supportImageInput: boolean
|
||||||
@@ -231,6 +232,7 @@ const wizardData = ref<WizardData>({
|
|||||||
global: false,
|
global: false,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
provider: 'deepseek',
|
provider: 'deepseek',
|
||||||
|
authConnected: false,
|
||||||
model: 'deepseek-chat',
|
model: 'deepseek-chat',
|
||||||
thinkingLevel: 'off',
|
thinkingLevel: 'off',
|
||||||
supportImageInput: true,
|
supportImageInput: true,
|
||||||
@@ -717,8 +719,8 @@ export function useSetupWizard() {
|
|||||||
validationErrors.value.agent.provider = true
|
validationErrors.value.agent.provider = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wizardData.value.agent.apiKey?.trim()) {
|
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
|
||||||
errors.push(t('setupWizard.agent.apiKeyRequired'))
|
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
|
||||||
validationErrors.value.agent.apiKey = true
|
validationErrors.value.agent.apiKey = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1482,6 +1484,7 @@ export function useSetupWizard() {
|
|||||||
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
|
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
|
||||||
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
|
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
|
||||||
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
|
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
|
||||||
|
wizardData.value.agent.authConnected = false
|
||||||
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.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
|
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
|
||||||
|
|||||||
@@ -1330,6 +1330,7 @@ export default {
|
|||||||
llmProviderHint: 'Select the LLM service provider to use',
|
llmProviderHint: 'Select the LLM service provider to use',
|
||||||
llmModel: 'LLM Model Name',
|
llmModel: 'LLM Model Name',
|
||||||
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
|
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
|
||||||
|
llmModelResolvedHint: 'Max context has been auto-filled to {context}K from the model catalog. Source: {source}',
|
||||||
llmThinking: 'Thinking Mode / Depth',
|
llmThinking: 'Thinking Mode / Depth',
|
||||||
llmThinkingHint:
|
llmThinkingHint:
|
||||||
'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',
|
'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',
|
||||||
@@ -1355,6 +1356,18 @@ export default {
|
|||||||
llmApiKeyPlaceholder: 'Please enter API key',
|
llmApiKeyPlaceholder: 'Please enter API key',
|
||||||
llmBaseUrl: 'LLM Base URL',
|
llmBaseUrl: 'LLM Base URL',
|
||||||
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
||||||
|
llmProviderAuth: 'Provider Authorization',
|
||||||
|
llmProviderAuthHint:
|
||||||
|
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
|
||||||
|
llmProviderConnectedAs: 'Connected as: {label}',
|
||||||
|
llmProviderDisconnect: 'Disconnect Authorization',
|
||||||
|
llmProviderDisconnected: 'Provider authorization disconnected',
|
||||||
|
llmProviderAuthDialogTitle: 'Provider Authorization',
|
||||||
|
llmProviderPopupBlocked:
|
||||||
|
'The browser blocked the authorization popup. Use the button below to continue manually.',
|
||||||
|
llmProviderDeviceCode: 'Device Code',
|
||||||
|
llmProviderOpenAuthPage: 'Open Authorization Page',
|
||||||
|
llmProviderCheckAuthStatus: 'Check Authorization Status',
|
||||||
aiVoiceApiKey: 'Audio API Key',
|
aiVoiceApiKey: 'Audio API Key',
|
||||||
aiVoiceApiKeyHint:
|
aiVoiceApiKeyHint:
|
||||||
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
|
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
|
||||||
@@ -3345,6 +3358,7 @@ export default {
|
|||||||
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
|
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
|
||||||
providerRequired: 'LLM provider is required',
|
providerRequired: 'LLM provider is required',
|
||||||
apiKeyRequired: 'LLM API key is required',
|
apiKeyRequired: 'LLM API key is required',
|
||||||
|
authOrApiKeyRequired: 'Provide an LLM API key or complete provider authorization first',
|
||||||
modelRequired: 'LLM model name is required',
|
modelRequired: 'LLM model name is required',
|
||||||
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
|
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
|
||||||
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',
|
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',
|
||||||
|
|||||||
@@ -1323,6 +1323,7 @@ export default {
|
|||||||
llmProviderHint: '选择使用的LLM服务提供商',
|
llmProviderHint: '选择使用的LLM服务提供商',
|
||||||
llmModel: 'LLM模型名称',
|
llmModel: 'LLM模型名称',
|
||||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||||
|
llmModelResolvedHint: '已根据模型目录自动回填最大上下文为 {context}K,来源:{source}',
|
||||||
llmThinking: '思考模式 / 深度',
|
llmThinking: '思考模式 / 深度',
|
||||||
llmThinkingHint:
|
llmThinkingHint:
|
||||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支持的级别会按 provider 能力自动映射到最近值',
|
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支持的级别会按 provider 能力自动映射到最近值',
|
||||||
@@ -1348,6 +1349,16 @@ export default {
|
|||||||
llmApiKeyPlaceholder: '请输入API密钥',
|
llmApiKeyPlaceholder: '请输入API密钥',
|
||||||
llmBaseUrl: 'LLM基础URL',
|
llmBaseUrl: 'LLM基础URL',
|
||||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||||
|
llmProviderAuth: '提供商授权',
|
||||||
|
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
|
||||||
|
llmProviderConnectedAs: '当前已连接:{label}',
|
||||||
|
llmProviderDisconnect: '断开授权',
|
||||||
|
llmProviderDisconnected: '已断开提供商授权',
|
||||||
|
llmProviderAuthDialogTitle: '提供商授权',
|
||||||
|
llmProviderPopupBlocked: '浏览器拦截了授权窗口,请手动点击下方按钮继续。',
|
||||||
|
llmProviderDeviceCode: '设备码',
|
||||||
|
llmProviderOpenAuthPage: '打开授权页面',
|
||||||
|
llmProviderCheckAuthStatus: '检查授权状态',
|
||||||
aiVoiceApiKey: '音频 API密钥',
|
aiVoiceApiKey: '音频 API密钥',
|
||||||
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
|
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
|
||||||
aiVoiceBaseUrl: '音频基础URL',
|
aiVoiceBaseUrl: '音频基础URL',
|
||||||
@@ -3295,6 +3306,7 @@ export default {
|
|||||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||||
providerRequired: 'LLM 提供商不能为空',
|
providerRequired: 'LLM 提供商不能为空',
|
||||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||||
|
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||||
modelRequired: 'LLM 模型名称不能为空',
|
modelRequired: 'LLM 模型名称不能为空',
|
||||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||||
|
|||||||
@@ -1325,6 +1325,7 @@ export default {
|
|||||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||||
llmModel: 'LLM模型名稱',
|
llmModel: 'LLM模型名稱',
|
||||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||||
|
llmModelResolvedHint: '已根據模型目錄自動回填最大上下文為 {context}K,來源:{source}',
|
||||||
llmThinking: '思考模式 / 深度',
|
llmThinking: '思考模式 / 深度',
|
||||||
llmThinkingHint:
|
llmThinkingHint:
|
||||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支援的級別會按 provider 能力自動映射到最近值',
|
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支援的級別會按 provider 能力自動映射到最近值',
|
||||||
@@ -1350,6 +1351,16 @@ export default {
|
|||||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||||
llmBaseUrl: 'LLM基礎URL',
|
llmBaseUrl: 'LLM基礎URL',
|
||||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||||
|
llmProviderAuth: '提供商授權',
|
||||||
|
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
|
||||||
|
llmProviderConnectedAs: '目前已連接:{label}',
|
||||||
|
llmProviderDisconnect: '斷開授權',
|
||||||
|
llmProviderDisconnected: '已斷開提供商授權',
|
||||||
|
llmProviderAuthDialogTitle: '提供商授權',
|
||||||
|
llmProviderPopupBlocked: '瀏覽器攔截了授權視窗,請手動點擊下方按鈕繼續。',
|
||||||
|
llmProviderDeviceCode: '設備碼',
|
||||||
|
llmProviderOpenAuthPage: '開啟授權頁面',
|
||||||
|
llmProviderCheckAuthStatus: '檢查授權狀態',
|
||||||
aiVoiceApiKey: '音頻 API密鑰',
|
aiVoiceApiKey: '音頻 API密鑰',
|
||||||
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
|
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
|
||||||
aiVoiceBaseUrl: '音頻基礎URL',
|
aiVoiceBaseUrl: '音頻基礎URL',
|
||||||
@@ -3297,6 +3308,7 @@ export default {
|
|||||||
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||||
providerRequired: 'LLM 提供商不能為空',
|
providerRequired: 'LLM 提供商不能為空',
|
||||||
apiKeyRequired: 'LLM API 密鑰不能為空',
|
apiKeyRequired: 'LLM API 密鑰不能為空',
|
||||||
|
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
|
||||||
modelRequired: 'LLM 模型名稱不能為空',
|
modelRequired: 'LLM 模型名稱不能為空',
|
||||||
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
||||||
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
|
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
|
||||||
import { useDisplay, useTheme } from 'vuetify'
|
import { useDisplay, useTheme } from 'vuetify'
|
||||||
|
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -168,9 +169,6 @@ const progressDialog = ref(false)
|
|||||||
// 高级设置对话框
|
// 高级设置对话框
|
||||||
const advancedDialog = ref(false)
|
const advancedDialog = ref(false)
|
||||||
|
|
||||||
// LLM 模型列表
|
|
||||||
const llmModels = ref<string[]>([])
|
|
||||||
const loadingModels = ref(false)
|
|
||||||
const savingBasic = ref(false)
|
const savingBasic = ref(false)
|
||||||
const testingLlm = ref(false)
|
const testingLlm = ref(false)
|
||||||
|
|
||||||
@@ -186,6 +184,73 @@ type LlmSettingsSnapshot = {
|
|||||||
let llmTestRequestId = 0
|
let llmTestRequestId = 0
|
||||||
let llmTestAbortController: AbortController | null = null
|
let llmTestAbortController: AbortController | null = null
|
||||||
|
|
||||||
|
const llmProviderRef = computed({
|
||||||
|
get: () => String(SystemSettings.value.Basic.LLM_PROVIDER ?? ''),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_PROVIDER = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const llmApiKeyRef = computed({
|
||||||
|
get: () => String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_API_KEY = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const llmBaseUrlRef = computed({
|
||||||
|
get: () => String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_BASE_URL = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const llmModelRef = computed({
|
||||||
|
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_MODEL = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const llmMaxContextRef = computed({
|
||||||
|
get: () => Number(SystemSettings.value.Basic.LLM_MAX_CONTEXT_TOKENS ?? 0),
|
||||||
|
set: value => {
|
||||||
|
SystemSettings.value.Basic.LLM_MAX_CONTEXT_TOKENS = value || 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
providerItems: llmProviderItems,
|
||||||
|
models: llmModels,
|
||||||
|
selectedProvider: selectedLlmProvider,
|
||||||
|
selectedModel: selectedLlmModel,
|
||||||
|
loadingProviders: loadingLlmProviders,
|
||||||
|
loadingModels,
|
||||||
|
providerConnected,
|
||||||
|
showBaseUrlField,
|
||||||
|
showApiKeyField,
|
||||||
|
canRefreshModels,
|
||||||
|
authDialogVisible,
|
||||||
|
authPolling,
|
||||||
|
authPopupBlocked,
|
||||||
|
authSession,
|
||||||
|
handleProviderSelection,
|
||||||
|
applyModelMetadata,
|
||||||
|
loadProviders: loadLlmProviders,
|
||||||
|
loadModels: loadLlmModels,
|
||||||
|
openAuthPage,
|
||||||
|
startAuth: startLlmProviderAuth,
|
||||||
|
pollAuthSession,
|
||||||
|
disconnectAuth: disconnectLlmProviderAuth,
|
||||||
|
closeAuthDialog,
|
||||||
|
} = useLlmProviderDirectory({
|
||||||
|
provider: llmProviderRef,
|
||||||
|
apiKey: llmApiKeyRef,
|
||||||
|
baseUrl: llmBaseUrlRef,
|
||||||
|
model: llmModelRef,
|
||||||
|
maxContextTokens: llmMaxContextRef,
|
||||||
|
})
|
||||||
|
|
||||||
function buildLlmSnapshot(): LlmSettingsSnapshot {
|
function buildLlmSnapshot(): LlmSettingsSnapshot {
|
||||||
return {
|
return {
|
||||||
AI_AGENT_ENABLE: Boolean(SystemSettings.value.Basic.AI_AGENT_ENABLE),
|
AI_AGENT_ENABLE: Boolean(SystemSettings.value.Basic.AI_AGENT_ENABLE),
|
||||||
@@ -261,13 +326,22 @@ function invalidateLlmTestState() {
|
|||||||
|
|
||||||
const currentLlmSnapshot = computed(() => buildLlmSnapshot())
|
const currentLlmSnapshot = computed(() => buildLlmSnapshot())
|
||||||
const currentLlmSnapshotKey = computed(() => buildLlmSnapshotKey(currentLlmSnapshot.value))
|
const currentLlmSnapshotKey = computed(() => buildLlmSnapshotKey(currentLlmSnapshot.value))
|
||||||
|
const llmProviderAuthMethods = computed(() => selectedLlmProvider.value?.oauth_methods || [])
|
||||||
|
const llmProviderAuthLabel = computed(() => selectedLlmProvider.value?.auth_status?.label || '')
|
||||||
|
const selectedLlmModelInfo = computed(() => {
|
||||||
|
if (!selectedLlmModel.value?.context_tokens_k) return ''
|
||||||
|
return t('setting.system.llmModelResolvedHint', {
|
||||||
|
context: selectedLlmModel.value.context_tokens_k,
|
||||||
|
source: selectedLlmModel.value.source || 'models.dev',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const canTestLlm = computed(() => {
|
const canTestLlm = computed(() => {
|
||||||
const snapshot = currentLlmSnapshot.value
|
const snapshot = currentLlmSnapshot.value
|
||||||
return (
|
return (
|
||||||
snapshot.AI_AGENT_ENABLE &&
|
snapshot.AI_AGENT_ENABLE &&
|
||||||
Boolean(snapshot.LLM_PROVIDER.trim()) &&
|
Boolean(snapshot.LLM_PROVIDER.trim()) &&
|
||||||
Boolean(snapshot.LLM_API_KEY.trim()) &&
|
(Boolean(snapshot.LLM_API_KEY.trim()) || providerConnected.value) &&
|
||||||
Boolean(snapshot.LLM_MODEL.trim()) &&
|
Boolean(snapshot.LLM_MODEL.trim()) &&
|
||||||
!savingBasic.value &&
|
!savingBasic.value &&
|
||||||
!testingLlm.value
|
!testingLlm.value
|
||||||
@@ -320,28 +394,42 @@ const logLevelItems = [
|
|||||||
// 安全域名添加变量
|
// 安全域名添加变量
|
||||||
const newSecurityDomain = ref('')
|
const newSecurityDomain = ref('')
|
||||||
|
|
||||||
// 加载LLM模型列表
|
// 加载 LLM 模型列表与 provider 目录
|
||||||
async function loadLlmModels() {
|
async function refreshLlmModels(forceRefresh = true) {
|
||||||
loadingModels.value = true
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
await loadLlmModels(forceRefresh)
|
||||||
params: {
|
|
||||||
provider: SystemSettings.value.Basic.LLM_PROVIDER,
|
|
||||||
api_key: SystemSettings.value.Basic.LLM_API_KEY,
|
|
||||||
base_url: SystemSettings.value.Basic.LLM_BASE_URL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
llmModels.value = result.data
|
|
||||||
if (llmModels.value.length > 0) SystemSettings.value.Basic.LLM_MODEL = llmModels.value[0]
|
|
||||||
} else {
|
|
||||||
$toast.error(result.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
$toast.error(error instanceof Error ? error.message : String(error))
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
loadingModels.value = false
|
}
|
||||||
|
|
||||||
|
async function handleLlmProviderChanged() {
|
||||||
|
handleProviderSelection(true)
|
||||||
|
if (canRefreshModels.value) {
|
||||||
|
await refreshLlmModels(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLlmModelChanged() {
|
||||||
|
applyModelMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startProviderAuth(methodId: string) {
|
||||||
|
try {
|
||||||
|
await startLlmProviderAuth(methodId)
|
||||||
|
} catch (error) {
|
||||||
|
$toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectProviderAuth() {
|
||||||
|
try {
|
||||||
|
await disconnectLlmProviderAuth()
|
||||||
|
$toast.success(t('setting.system.llmProviderDisconnected'))
|
||||||
|
} catch (error) {
|
||||||
|
$toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加安全域名
|
// 添加安全域名
|
||||||
@@ -436,6 +524,10 @@ async function loadSystemSettings() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
|
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
|
||||||
|
await loadLlmProviders()
|
||||||
|
if (SystemSettings.value.Basic.AI_AGENT_ENABLE && canRefreshModels.value) {
|
||||||
|
await refreshLlmModels(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -483,7 +575,7 @@ async function testLlmConnection() {
|
|||||||
|
|
||||||
testingLlm.value = true
|
testingLlm.value = true
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.post('system/llm-test', payload, {
|
const result: { [key: string]: any } = await api.post('llm/test', payload, {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
if (
|
if (
|
||||||
@@ -916,15 +1008,13 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
:label="t('setting.system.llmProvider')"
|
:label="t('setting.system.llmProvider')"
|
||||||
:hint="t('setting.system.llmProviderHint')"
|
:hint="t('setting.system.llmProviderHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:items="[
|
:items="llmProviderItems"
|
||||||
{ title: 'OpenAI', value: 'openai' },
|
:loading="loadingLlmProviders"
|
||||||
{ title: 'Google', value: 'google' },
|
|
||||||
{ title: 'DeepSeek', value: 'deepseek' },
|
|
||||||
]"
|
|
||||||
prepend-inner-icon="mdi-robot"
|
prepend-inner-icon="mdi-robot"
|
||||||
|
@update:model-value="handleLlmProviderChanged"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="SystemSettings.Basic.LLM_BASE_URL"
|
v-model="SystemSettings.Basic.LLM_BASE_URL"
|
||||||
:label="t('setting.system.llmBaseUrl')"
|
:label="t('setting.system.llmBaseUrl')"
|
||||||
@@ -934,26 +1024,73 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
prepend-inner-icon="mdi-link"
|
prepend-inner-icon="mdi-link"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||||
:label="t('setting.system.llmApiKey')"
|
:label="selectedLlmProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||||
:hint="t('setting.system.llmApiKeyHint')"
|
:hint="selectedLlmProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
type="password"
|
type="password"
|
||||||
prepend-inner-icon="mdi-key-variant"
|
prepend-inner-icon="mdi-key-variant"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol
|
||||||
|
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<VAlert type="info" variant="tonal">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-space-between ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ selectedLlmProvider?.description || t('setting.system.llmProviderAuthHint') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="providerConnected" class="text-body-2 mt-2">
|
||||||
|
{{ t('setting.system.llmProviderConnectedAs', { label: llmProviderAuthLabel || selectedLlmProvider?.name }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap ga-2">
|
||||||
|
<VBtn
|
||||||
|
v-for="method in llmProviderAuthMethods"
|
||||||
|
:key="method.id"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-account-arrow-right-outline"
|
||||||
|
@click="startProviderAuth(method.id)"
|
||||||
|
>
|
||||||
|
{{ method.label }}
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
v-if="providerConnected"
|
||||||
|
color="error"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-link-off"
|
||||||
|
@click="disconnectProviderAuth"
|
||||||
|
>
|
||||||
|
{{ t('setting.system.llmProviderDisconnect') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
</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">
|
||||||
<div>
|
<div>
|
||||||
<VCombobox
|
<VCombobox
|
||||||
v-model="SystemSettings.Basic.LLM_MODEL"
|
:model-value="SystemSettings.Basic.LLM_MODEL"
|
||||||
|
@update:model-value="(val: any) => {
|
||||||
|
SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val;
|
||||||
|
handleLlmModelChanged();
|
||||||
|
}"
|
||||||
:label="t('setting.system.llmModel')"
|
:label="t('setting.system.llmModel')"
|
||||||
:hint="t('setting.system.llmModelHint')"
|
:hint="t('setting.system.llmModelHint')"
|
||||||
:placeholder="t('setting.system.llmModelHint')"
|
:placeholder="t('setting.system.llmModelHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:items="llmModels"
|
:items="llmModels"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
:loading="loadingModels"
|
:loading="loadingModels"
|
||||||
prepend-inner-icon="mdi-brain"
|
prepend-inner-icon="mdi-brain"
|
||||||
>
|
>
|
||||||
@@ -962,12 +1099,16 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
variant="text"
|
variant="text"
|
||||||
icon="mdi-refresh"
|
icon="mdi-refresh"
|
||||||
size="small"
|
size="small"
|
||||||
@click="loadLlmModels"
|
@click="refreshLlmModels(true)"
|
||||||
:disabled="!SystemSettings.Basic.LLM_API_KEY"
|
:disabled="!canRefreshModels"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VCombobox>
|
</VCombobox>
|
||||||
|
|
||||||
|
<VAlert v-if="selectedLlmModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||||
|
{{ selectedLlmModelInfo }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
<div class="d-flex justify-end mt-2">
|
<div class="d-flex justify-end mt-2">
|
||||||
<VBtn
|
<VBtn
|
||||||
color="info"
|
color="info"
|
||||||
@@ -1846,6 +1987,50 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|
||||||
|
<VDialog v-model="authDialogVisible" max-width="560">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||||
|
<VCardText class="d-flex flex-column ga-4">
|
||||||
|
<VAlert v-if="authSession?.instructions" type="info" variant="tonal">
|
||||||
|
{{ authSession.instructions }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VAlert v-if="authPopupBlocked" type="warning" variant="tonal">
|
||||||
|
{{ t('setting.system.llmProviderPopupBlocked') }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<div v-if="authSession?.user_code">
|
||||||
|
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
|
||||||
|
<div class="text-h5 font-weight-bold">{{ authSession.user_code }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authSession?.message" class="text-body-2">
|
||||||
|
{{ authSession.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap ga-2">
|
||||||
|
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
|
||||||
|
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-refresh"
|
||||||
|
:loading="authPolling"
|
||||||
|
@click="pollAuthSession"
|
||||||
|
>
|
||||||
|
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" @click="closeAuthDialog">
|
||||||
|
{{ t('common.close') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,20 +1,88 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import api from '@/api'
|
|
||||||
import { useSetupWizard } from '@/composables/useSetupWizard'
|
import { useSetupWizard } from '@/composables/useSetupWizard'
|
||||||
|
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const $toast = useToast()
|
||||||
const { wizardData, validationErrors } = useSetupWizard()
|
const { wizardData, validationErrors } = useSetupWizard()
|
||||||
|
|
||||||
const llmModels = ref<string[]>([])
|
const providerRef = computed({
|
||||||
const loadingModels = ref(false)
|
get: () => wizardData.value.agent.provider,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.provider = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const providerItems = [
|
const apiKeyRef = computed({
|
||||||
{ title: 'OpenAI', value: 'openai' },
|
get: () => wizardData.value.agent.apiKey,
|
||||||
{ title: 'Google', value: 'google' },
|
set: value => {
|
||||||
{ title: 'DeepSeek', value: 'deepseek' },
|
wizardData.value.agent.apiKey = value || ''
|
||||||
]
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseUrlRef = computed({
|
||||||
|
get: () => wizardData.value.agent.baseUrl,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.baseUrl = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelRef = computed({
|
||||||
|
get: () => wizardData.value.agent.model,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.model = value || ''
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxContextTokensRef = computed({
|
||||||
|
get: () => wizardData.value.agent.maxContextTokens,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.maxContextTokens = value || 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const authConnectedRef = computed({
|
||||||
|
get: () => wizardData.value.agent.authConnected,
|
||||||
|
set: value => {
|
||||||
|
wizardData.value.agent.authConnected = Boolean(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
providerItems,
|
||||||
|
models: llmModels,
|
||||||
|
selectedProvider,
|
||||||
|
selectedModel,
|
||||||
|
loadingProviders,
|
||||||
|
loadingModels,
|
||||||
|
providerConnected,
|
||||||
|
showBaseUrlField,
|
||||||
|
showApiKeyField,
|
||||||
|
canRefreshModels,
|
||||||
|
authDialogVisible,
|
||||||
|
authPolling,
|
||||||
|
authPopupBlocked,
|
||||||
|
authSession,
|
||||||
|
handleProviderSelection,
|
||||||
|
applyModelMetadata,
|
||||||
|
loadProviders,
|
||||||
|
loadModels,
|
||||||
|
openAuthPage,
|
||||||
|
startAuth,
|
||||||
|
pollAuthSession,
|
||||||
|
disconnectAuth,
|
||||||
|
closeAuthDialog,
|
||||||
|
} = useLlmProviderDirectory({
|
||||||
|
provider: providerRef,
|
||||||
|
apiKey: apiKeyRef,
|
||||||
|
baseUrl: baseUrlRef,
|
||||||
|
model: modelRef,
|
||||||
|
maxContextTokens: maxContextTokensRef,
|
||||||
|
authConnected: authConnectedRef,
|
||||||
|
})
|
||||||
|
|
||||||
const jobIntervalItems = computed(() => [
|
const jobIntervalItems = computed(() => [
|
||||||
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
|
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
|
||||||
@@ -38,37 +106,61 @@ const thinkingLevelItems = computed(() => [
|
|||||||
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
|
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
|
||||||
])
|
])
|
||||||
|
|
||||||
async function loadLlmModels() {
|
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
||||||
if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {
|
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
|
||||||
return
|
const selectedModelInfo = computed(() => {
|
||||||
}
|
if (!selectedModel.value?.context_tokens_k) return ''
|
||||||
|
return t('setting.system.llmModelResolvedHint', {
|
||||||
|
context: selectedModel.value.context_tokens_k,
|
||||||
|
source: selectedModel.value.source || 'models.dev',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
loadingModels.value = true
|
async function refreshModels(forceRefresh = true) {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
await loadModels(forceRefresh)
|
||||||
params: {
|
|
||||||
provider: wizardData.value.agent.provider,
|
|
||||||
api_key: wizardData.value.agent.apiKey,
|
|
||||||
base_url: wizardData.value.agent.baseUrl,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
llmModels.value = result.data || []
|
|
||||||
if (!wizardData.value.agent.model && llmModels.value.length > 0) {
|
|
||||||
wizardData.value.agent.model = llmModels.value[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
$toast.error(error instanceof Error ? error.message : String(error))
|
||||||
console.log('Load LLM models failed:', error)
|
console.log('Load LLM models failed:', error)
|
||||||
} finally {
|
|
||||||
loadingModels.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function handleProviderChanged() {
|
||||||
if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {
|
handleProviderSelection(true)
|
||||||
loadLlmModels()
|
if (canRefreshModels.value) {
|
||||||
|
await refreshModels(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModelChanged() {
|
||||||
|
applyModelMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startProviderAuth(methodId: string) {
|
||||||
|
try {
|
||||||
|
await startAuth(methodId)
|
||||||
|
} catch (error) {
|
||||||
|
$toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectProviderAuth() {
|
||||||
|
try {
|
||||||
|
await disconnectAuth()
|
||||||
|
$toast.success(t('setting.system.llmProviderDisconnected'))
|
||||||
|
} catch (error) {
|
||||||
|
$toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadProviders()
|
||||||
|
if (wizardData.value.agent.enabled && canRefreshModels.value) {
|
||||||
|
await refreshModels(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Load LLM providers failed:', error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -126,14 +218,16 @@ onMounted(() => {
|
|||||||
:label="t('setting.system.llmProvider')"
|
:label="t('setting.system.llmProvider')"
|
||||||
:hint="t('setting.system.llmProviderHint')"
|
:hint="t('setting.system.llmProviderHint')"
|
||||||
:items="providerItems"
|
:items="providerItems"
|
||||||
|
:loading="loadingProviders"
|
||||||
:error="validationErrors.agent.provider"
|
:error="validationErrors.agent.provider"
|
||||||
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
|
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-robot-outline"
|
prepend-inner-icon="mdi-robot-outline"
|
||||||
|
@update:model-value="handleProviderChanged"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
<VCol v-if="showBaseUrlField" cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="wizardData.agent.baseUrl"
|
v-model="wizardData.agent.baseUrl"
|
||||||
:label="t('setting.system.llmBaseUrl')"
|
:label="t('setting.system.llmBaseUrl')"
|
||||||
@@ -144,26 +238,73 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="wizardData.agent.apiKey"
|
v-model="wizardData.agent.apiKey"
|
||||||
:label="t('setting.system.llmApiKey')"
|
:label="selectedProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||||
:hint="t('setting.system.llmApiKeyHint')"
|
:hint="selectedProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||||
:error="validationErrors.agent.apiKey"
|
:error="validationErrors.agent.apiKey"
|
||||||
:error-messages="validationErrors.agent.apiKey ? [t('setupWizard.agent.apiKeyRequired')] : []"
|
:error-messages="
|
||||||
|
validationErrors.agent.apiKey ? [t('setupWizard.agent.authOrApiKeyRequired')] : []
|
||||||
|
"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-key-variant"
|
prepend-inner-icon="mdi-key-variant"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<VCol v-if="providerAuthMethods.length > 0" cols="12">
|
||||||
|
<VAlert type="info" variant="tonal">
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ selectedProvider?.description || t('setting.system.llmProviderAuthHint') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="providerConnected" class="text-body-2 mt-2">
|
||||||
|
{{ t('setting.system.llmProviderConnectedAs', { label: providerAuthLabel || selectedProvider?.name }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap ga-2">
|
||||||
|
<VBtn
|
||||||
|
v-for="method in providerAuthMethods"
|
||||||
|
:key="method.id"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-account-arrow-right-outline"
|
||||||
|
@click="startProviderAuth(method.id)"
|
||||||
|
>
|
||||||
|
{{ method.label }}
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
v-if="providerConnected"
|
||||||
|
color="error"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-link-off"
|
||||||
|
@click="disconnectProviderAuth"
|
||||||
|
>
|
||||||
|
{{ t('setting.system.llmProviderDisconnect') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VAlert>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VCombobox
|
<VCombobox
|
||||||
v-model="wizardData.agent.model"
|
:model-value="wizardData.agent.model"
|
||||||
|
@update:model-value="(val: any) => {
|
||||||
|
wizardData.agent.model = typeof val === 'object' && val !== null ? val.id : val;
|
||||||
|
handleModelChanged();
|
||||||
|
}"
|
||||||
:label="t('setting.system.llmModel')"
|
:label="t('setting.system.llmModel')"
|
||||||
:hint="t('setting.system.llmModelHint')"
|
:hint="t('setting.system.llmModelHint')"
|
||||||
:items="llmModels"
|
:items="llmModels"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
:loading="loadingModels"
|
:loading="loadingModels"
|
||||||
:error="validationErrors.agent.model"
|
:error="validationErrors.agent.model"
|
||||||
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
|
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
|
||||||
@@ -175,11 +316,15 @@ onMounted(() => {
|
|||||||
variant="text"
|
variant="text"
|
||||||
icon="mdi-refresh"
|
icon="mdi-refresh"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!wizardData.agent.provider || !wizardData.agent.apiKey"
|
:disabled="!canRefreshModels"
|
||||||
@click="loadLlmModels"
|
@click="refreshModels(true)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VCombobox>
|
</VCombobox>
|
||||||
|
|
||||||
|
<VAlert v-if="selectedModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||||
|
{{ selectedModelInfo }}
|
||||||
|
</VAlert>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
@@ -364,4 +509,48 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
|
<VDialog v-model="authDialogVisible" max-width="560">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||||
|
<VCardText class="d-flex flex-column ga-4">
|
||||||
|
<VAlert v-if="authSession?.instructions" type="info" variant="tonal">
|
||||||
|
{{ authSession.instructions }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VAlert v-if="authPopupBlocked" type="warning" variant="tonal">
|
||||||
|
{{ t('setting.system.llmProviderPopupBlocked') }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<div v-if="authSession?.user_code">
|
||||||
|
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
|
||||||
|
<div class="text-h5 font-weight-bold">{{ authSession.user_code }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authSession?.message" class="text-body-2">
|
||||||
|
{{ authSession.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap ga-2">
|
||||||
|
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
|
||||||
|
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-refresh"
|
||||||
|
:loading="authPolling"
|
||||||
|
@click="pollAuthSession"
|
||||||
|
>
|
||||||
|
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn variant="text" @click="closeAuthDialog">
|
||||||
|
{{ t('common.close') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user