refactor: extract LLM provider management logic into composable and add OAuth support for system settings

This commit is contained in:
jxxghp
2026-04-30 09:49:05 +08:00
parent 723eb319e1
commit 9a9a618136
7 changed files with 853 additions and 78 deletions
+360
View 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,
}
}
+5 -2
View File
@@ -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
+14
View File
@@ -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',
+12
View File
@@ -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',
+12
View File
@@ -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',
+220 -35
View File
@@ -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>
+230 -41
View File
@@ -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>