mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-13 00:11:37 +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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user