mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-29 03:21:39 +08:00
refactor: extract LLM provider management logic into composable and add OAuth support for system settings
This commit is contained in:
360
src/composables/useLlmProviderDirectory.ts
Normal file
360
src/composables/useLlmProviderDirectory.ts
Normal file
@@ -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
|
||||
verbose: boolean
|
||||
provider: string
|
||||
authConnected: boolean
|
||||
model: string
|
||||
thinkingLevel: string
|
||||
supportImageInput: boolean
|
||||
@@ -231,6 +232,7 @@ const wizardData = ref<WizardData>({
|
||||
global: false,
|
||||
verbose: false,
|
||||
provider: 'deepseek',
|
||||
authConnected: false,
|
||||
model: 'deepseek-chat',
|
||||
thinkingLevel: 'off',
|
||||
supportImageInput: true,
|
||||
@@ -717,8 +719,8 @@ export function useSetupWizard() {
|
||||
validationErrors.value.agent.provider = true
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.apiKey?.trim()) {
|
||||
errors.push(t('setupWizard.agent.apiKeyRequired'))
|
||||
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
|
||||
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
|
||||
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.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
|
||||
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.thinkingLevel = resolveThinkingLevelValue(result.data)
|
||||
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',
|
||||
llmModel: 'LLM Model Name',
|
||||
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',
|
||||
llmThinkingHint:
|
||||
'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',
|
||||
llmBaseUrl: 'LLM Base URL',
|
||||
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',
|
||||
aiVoiceApiKeyHint:
|
||||
'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.',
|
||||
providerRequired: 'LLM provider 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',
|
||||
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
|
||||
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',
|
||||
|
||||
@@ -1323,6 +1323,7 @@ export default {
|
||||
llmProviderHint: '选择使用的LLM服务提供商',
|
||||
llmModel: 'LLM模型名称',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmModelResolvedHint: '已根据模型目录自动回填最大上下文为 {context}K,来源:{source}',
|
||||
llmThinking: '思考模式 / 深度',
|
||||
llmThinkingHint:
|
||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支持的级别会按 provider 能力自动映射到最近值',
|
||||
@@ -1348,6 +1349,16 @@ export default {
|
||||
llmApiKeyPlaceholder: '请输入API密钥',
|
||||
llmBaseUrl: 'LLM基础URL',
|
||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||
llmProviderAuth: '提供商授权',
|
||||
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
|
||||
llmProviderConnectedAs: '当前已连接:{label}',
|
||||
llmProviderDisconnect: '断开授权',
|
||||
llmProviderDisconnected: '已断开提供商授权',
|
||||
llmProviderAuthDialogTitle: '提供商授权',
|
||||
llmProviderPopupBlocked: '浏览器拦截了授权窗口,请手动点击下方按钮继续。',
|
||||
llmProviderDeviceCode: '设备码',
|
||||
llmProviderOpenAuthPage: '打开授权页面',
|
||||
llmProviderCheckAuthStatus: '检查授权状态',
|
||||
aiVoiceApiKey: '音频 API密钥',
|
||||
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
|
||||
aiVoiceBaseUrl: '音频基础URL',
|
||||
@@ -3295,6 +3306,7 @@ export default {
|
||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
providerRequired: 'LLM 提供商不能为空',
|
||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||
modelRequired: 'LLM 模型名称不能为空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||
|
||||
@@ -1325,6 +1325,7 @@ export default {
|
||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||
llmModel: 'LLM模型名稱',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmModelResolvedHint: '已根據模型目錄自動回填最大上下文為 {context}K,來源:{source}',
|
||||
llmThinking: '思考模式 / 深度',
|
||||
llmThinkingHint:
|
||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支援的級別會按 provider 能力自動映射到最近值',
|
||||
@@ -1350,6 +1351,16 @@ export default {
|
||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||
llmBaseUrl: 'LLM基礎URL',
|
||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||
llmProviderAuth: '提供商授權',
|
||||
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
|
||||
llmProviderConnectedAs: '目前已連接:{label}',
|
||||
llmProviderDisconnect: '斷開授權',
|
||||
llmProviderDisconnected: '已斷開提供商授權',
|
||||
llmProviderAuthDialogTitle: '提供商授權',
|
||||
llmProviderPopupBlocked: '瀏覽器攔截了授權視窗,請手動點擊下方按鈕繼續。',
|
||||
llmProviderDeviceCode: '設備碼',
|
||||
llmProviderOpenAuthPage: '開啟授權頁面',
|
||||
llmProviderCheckAuthStatus: '檢查授權狀態',
|
||||
aiVoiceApiKey: '音頻 API密鑰',
|
||||
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
|
||||
aiVoiceBaseUrl: '音頻基礎URL',
|
||||
@@ -3297,6 +3308,7 @@ export default {
|
||||
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||
providerRequired: 'LLM 提供商不能為空',
|
||||
apiKeyRequired: 'LLM API 密鑰不能為空',
|
||||
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
|
||||
modelRequired: 'LLM 模型名稱不能為空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
||||
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
||||
|
||||
@@ -12,6 +12,7 @@ import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||
|
||||
const display = useDisplay()
|
||||
const theme = useTheme()
|
||||
@@ -168,9 +169,6 @@ const progressDialog = ref(false)
|
||||
// 高级设置对话框
|
||||
const advancedDialog = ref(false)
|
||||
|
||||
// LLM 模型列表
|
||||
const llmModels = ref<string[]>([])
|
||||
const loadingModels = ref(false)
|
||||
const savingBasic = ref(false)
|
||||
const testingLlm = ref(false)
|
||||
|
||||
@@ -186,6 +184,73 @@ type LlmSettingsSnapshot = {
|
||||
let llmTestRequestId = 0
|
||||
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 {
|
||||
return {
|
||||
AI_AGENT_ENABLE: Boolean(SystemSettings.value.Basic.AI_AGENT_ENABLE),
|
||||
@@ -261,13 +326,22 @@ function invalidateLlmTestState() {
|
||||
|
||||
const currentLlmSnapshot = computed(() => buildLlmSnapshot())
|
||||
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 snapshot = currentLlmSnapshot.value
|
||||
return (
|
||||
snapshot.AI_AGENT_ENABLE &&
|
||||
Boolean(snapshot.LLM_PROVIDER.trim()) &&
|
||||
Boolean(snapshot.LLM_API_KEY.trim()) &&
|
||||
(Boolean(snapshot.LLM_API_KEY.trim()) || providerConnected.value) &&
|
||||
Boolean(snapshot.LLM_MODEL.trim()) &&
|
||||
!savingBasic.value &&
|
||||
!testingLlm.value
|
||||
@@ -320,28 +394,42 @@ const logLevelItems = [
|
||||
// 安全域名添加变量
|
||||
const newSecurityDomain = ref('')
|
||||
|
||||
// 加载LLM模型列表
|
||||
async function loadLlmModels() {
|
||||
loadingModels.value = true
|
||||
// 加载 LLM 模型列表与 provider 目录
|
||||
async function refreshLlmModels(forceRefresh = true) {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
||||
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)
|
||||
}
|
||||
await loadLlmModels(forceRefresh)
|
||||
} catch (error) {
|
||||
$toast.error(error instanceof Error ? error.message : String(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)
|
||||
await loadLlmProviders()
|
||||
if (SystemSettings.value.Basic.AI_AGENT_ENABLE && canRefreshModels.value) {
|
||||
await refreshLlmModels(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -483,7 +575,7 @@ async function testLlmConnection() {
|
||||
|
||||
testingLlm.value = true
|
||||
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,
|
||||
})
|
||||
if (
|
||||
@@ -916,15 +1008,13 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
persistent-hint
|
||||
:items="[
|
||||
{ title: 'OpenAI', value: 'openai' },
|
||||
{ title: 'Google', value: 'google' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
]"
|
||||
:items="llmProviderItems"
|
||||
:loading="loadingLlmProviders"
|
||||
prepend-inner-icon="mdi-robot"
|
||||
@update:model-value="handleLlmProviderChanged"
|
||||
/>
|
||||
</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
|
||||
v-model="SystemSettings.Basic.LLM_BASE_URL"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
@@ -934,26 +1024,73 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
prepend-inner-icon="mdi-link"
|
||||
/>
|
||||
</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
|
||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||
:label="t('setting.system.llmApiKey')"
|
||||
:hint="t('setting.system.llmApiKeyHint')"
|
||||
:label="selectedLlmProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||
:hint="selectedLlmProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||
persistent-hint
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</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">
|
||||
<div>
|
||||
<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')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:placeholder="t('setting.system.llmModelHint')"
|
||||
persistent-hint
|
||||
:items="llmModels"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:loading="loadingModels"
|
||||
prepend-inner-icon="mdi-brain"
|
||||
>
|
||||
@@ -962,12 +1099,16 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
@click="loadLlmModels"
|
||||
:disabled="!SystemSettings.Basic.LLM_API_KEY"
|
||||
@click="refreshLlmModels(true)"
|
||||
:disabled="!canRefreshModels"
|
||||
/>
|
||||
</template>
|
||||
</VCombobox>
|
||||
|
||||
<VAlert v-if="selectedLlmModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||
{{ selectedLlmModelInfo }}
|
||||
</VAlert>
|
||||
|
||||
<div class="d-flex justify-end mt-2">
|
||||
<VBtn
|
||||
color="info"
|
||||
@@ -1846,6 +1987,50 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,20 +1,88 @@
|
||||
<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 api from '@/api'
|
||||
import { useSetupWizard } from '@/composables/useSetupWizard'
|
||||
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const { wizardData, validationErrors } = useSetupWizard()
|
||||
|
||||
const llmModels = ref<string[]>([])
|
||||
const loadingModels = ref(false)
|
||||
const providerRef = computed({
|
||||
get: () => wizardData.value.agent.provider,
|
||||
set: value => {
|
||||
wizardData.value.agent.provider = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const providerItems = [
|
||||
{ title: 'OpenAI', value: 'openai' },
|
||||
{ title: 'Google', value: 'google' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
]
|
||||
const apiKeyRef = computed({
|
||||
get: () => wizardData.value.agent.apiKey,
|
||||
set: value => {
|
||||
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(() => [
|
||||
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
|
||||
@@ -38,37 +106,61 @@ const thinkingLevelItems = computed(() => [
|
||||
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
|
||||
])
|
||||
|
||||
async function loadLlmModels() {
|
||||
if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {
|
||||
return
|
||||
}
|
||||
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
||||
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
|
||||
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 {
|
||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
||||
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]
|
||||
}
|
||||
}
|
||||
await loadModels(forceRefresh)
|
||||
} catch (error) {
|
||||
$toast.error(error instanceof Error ? error.message : String(error))
|
||||
console.log('Load LLM models failed:', error)
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {
|
||||
loadLlmModels()
|
||||
async function handleProviderChanged() {
|
||||
handleProviderSelection(true)
|
||||
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>
|
||||
@@ -126,14 +218,16 @@ onMounted(() => {
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
:items="providerItems"
|
||||
:loading="loadingProviders"
|
||||
:error="validationErrors.agent.provider"
|
||||
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-robot-outline"
|
||||
@update:model-value="handleProviderChanged"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCol v-if="showBaseUrlField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.baseUrl"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
@@ -144,26 +238,73 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.apiKey"
|
||||
:label="t('setting.system.llmApiKey')"
|
||||
:hint="t('setting.system.llmApiKeyHint')"
|
||||
:label="selectedProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||
:hint="selectedProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||
:error="validationErrors.agent.apiKey"
|
||||
:error-messages="validationErrors.agent.apiKey ? [t('setupWizard.agent.apiKeyRequired')] : []"
|
||||
:error-messages="
|
||||
validationErrors.agent.apiKey ? [t('setupWizard.agent.authOrApiKeyRequired')] : []
|
||||
"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</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">
|
||||
<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')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:items="llmModels"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:loading="loadingModels"
|
||||
:error="validationErrors.agent.model"
|
||||
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
|
||||
@@ -175,11 +316,15 @@ onMounted(() => {
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
:disabled="!wizardData.agent.provider || !wizardData.agent.apiKey"
|
||||
@click="loadLlmModels"
|
||||
:disabled="!canRefreshModels"
|
||||
@click="refreshModels(true)"
|
||||
/>
|
||||
</template>
|
||||
</VCombobox>
|
||||
|
||||
<VAlert v-if="selectedModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||
{{ selectedModelInfo }}
|
||||
</VAlert>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
@@ -364,4 +509,48 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user