feat(stage-ui): periodically check Ollama connectivity
This commit is contained in:
@@ -53,6 +53,10 @@ export const providerLmStudio = defineProvider<LMStudioConfig>({
|
||||
validators: {
|
||||
...createOpenAICompatibleValidators({
|
||||
checks: ['connectivity', 'model_list'],
|
||||
schedule: {
|
||||
mode: 'interval',
|
||||
intervalMs: 15_000,
|
||||
},
|
||||
connectivityFailureReason: ({ errorMessage }) =>
|
||||
`Failed to reach LM Studio server, error: ${errorMessage} occurred.\n\nMake sure LM Studio is running and the local server is started. You can start the local server in LM Studio by going to the 'Local Server' tab and clicking 'Start Server'.`,
|
||||
modelListFailureReason: ({ errorMessage }) =>
|
||||
|
||||
@@ -166,7 +166,11 @@ export const providerOllama = defineProvider<OllamaConfig>({
|
||||
}),
|
||||
],
|
||||
validateProvider: createOpenAICompatibleValidators({
|
||||
checks: ['connectivity', 'model_list', 'chat_completions'],
|
||||
checks: ['connectivity', 'model_list'],
|
||||
schedule: {
|
||||
mode: 'interval',
|
||||
intervalMs: 15_000,
|
||||
},
|
||||
connectivityFailureReason: ({ errorMessage }) =>
|
||||
`Failed to reach Ollama server, error: ${errorMessage} occurred.\n\nIf you are using Ollama locally, this is likely the CORS (Cross-Origin Resource Sharing) security issue, where you will need to set OLLAMA_ORIGINS=* or OLLAMA_ORIGINS=https://airi.moeru.ai,http://localhost environment variable before launching Ollama server to make this work.`,
|
||||
})!.validateProvider,
|
||||
|
||||
@@ -48,6 +48,25 @@ export interface ProviderValidationResult {
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
export interface ProviderValidatorSchedule {
|
||||
mode: 'once' | 'interval'
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface ProviderConfigValidator<TConfig> {
|
||||
id: string
|
||||
name: string
|
||||
validator: (config: TConfig, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult>
|
||||
schedule?: ProviderValidatorSchedule
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeValidator<TConfig> {
|
||||
id: string
|
||||
name: string
|
||||
validator: (config: TConfig, provider: ProviderInstance, providerExtra: ProviderExtraMethods<TConfig>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult>
|
||||
schedule?: ProviderValidatorSchedule
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
@@ -121,8 +140,8 @@ export interface ProviderDefinition<TConfig extends any = any> {
|
||||
extraMethods?: ProviderExtraMethods<TConfig>
|
||||
validationRequiredWhen?: (config: TConfig) => boolean
|
||||
validators?: {
|
||||
validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => { id: string, name: string, validator: (config: TConfig, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }>
|
||||
validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => { id: string, name: string, validator: (config: TConfig, provider: ProviderInstance, providerExtra: ProviderExtraMethods<TConfig>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }>
|
||||
validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderConfigValidator<TConfig>>
|
||||
validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderRuntimeValidator<TConfig>>
|
||||
}
|
||||
capabilities?: {
|
||||
transcription?: {
|
||||
|
||||
@@ -15,6 +15,10 @@ type OpenAICompatibleValidationCheck = 'connectivity' | 'model_list' | 'chat_com
|
||||
interface OpenAICompatibleValidationOptions<TConfig extends { apiKey?: string, baseUrl?: string }> {
|
||||
checks?: OpenAICompatibleValidationCheck[]
|
||||
additionalHeaders?: Record<string, string>
|
||||
schedule?: {
|
||||
mode: 'once' | 'interval'
|
||||
intervalMs?: number
|
||||
}
|
||||
connectivityFailureReason?: (input: { config: TConfig, error: unknown, errorMessage: string }) => string
|
||||
modelListFailureReason?: (input: { config: TConfig, error: unknown, errorMessage: string }) => string
|
||||
}
|
||||
@@ -236,6 +240,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
validatorConfig.validateProvider?.push(({ t }) => ({
|
||||
id: 'openai-compatible:check-connectivity',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
|
||||
schedule: options?.schedule,
|
||||
validator: async (config, provider, providerExtra, contextOptions) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
const result = await getChatCheckResult(
|
||||
@@ -266,6 +271,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
validatorConfig.validateProvider?.push(({ t }) => ({
|
||||
id: 'openai-compatible:check-chat-completions',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-chat-completion.title'),
|
||||
schedule: options?.schedule,
|
||||
validator: async (config, provider, providerExtra, contextOptions) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
const result = await getChatCheckResult(
|
||||
@@ -292,6 +298,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
||||
validatorConfig.validateProvider?.push(({ t }) => ({
|
||||
id: 'openai-compatible:check-model-list',
|
||||
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-model-listing.title'),
|
||||
schedule: options?.schedule,
|
||||
validator: async (config, provider, providerExtra) => {
|
||||
const errors: Array<{ error: unknown }> = []
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { MaybePromise } from 'clustr'
|
||||
import type { ComposerTranslation } from 'vue-i18n'
|
||||
|
||||
import type { ProviderDefinition, ProviderExtraMethods, ProviderInstance, ProviderValidationResult } from '../types'
|
||||
import type {
|
||||
ProviderConfigValidator,
|
||||
ProviderDefinition,
|
||||
ProviderExtraMethods,
|
||||
ProviderInstance,
|
||||
ProviderRuntimeValidator,
|
||||
} from '../types'
|
||||
|
||||
import { errorMessageFrom, merge } from '@moeru/std'
|
||||
|
||||
@@ -19,8 +24,8 @@ export interface ProviderValidationPlan {
|
||||
steps: ProviderValidationStep[]
|
||||
config: Record<string, unknown>
|
||||
definition: ProviderDefinition
|
||||
configValidators: Array<{ id: string, name: string, validator: (config: Record<string, unknown>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }>
|
||||
providerValidators: Array<{ id: string, name: string, validator: (config: Record<string, unknown>, provider: ProviderInstance, providerExtra: ProviderExtraMethods<Record<string, unknown>>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }>
|
||||
configValidators: ProviderConfigValidator<Record<string, unknown>>[]
|
||||
providerValidators: ProviderRuntimeValidator<Record<string, unknown>>[]
|
||||
providerExtra: ProviderExtraMethods<Record<string, unknown>> | undefined
|
||||
shouldValidate: boolean
|
||||
}
|
||||
@@ -31,7 +36,7 @@ export interface ProviderValidationCallbacks {
|
||||
onValidatorError?: (info: { kind: ProviderValidationStepKind, index: number, step: ProviderValidationStep, error: unknown }) => void
|
||||
}
|
||||
|
||||
export function createConfigValidationSteps(configValidators: Array<{ id: string, name: string, validator: (config: Record<string, unknown>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }>): ProviderValidationStep[] {
|
||||
export function createConfigValidationSteps(configValidators: ProviderConfigValidator<Record<string, unknown>>[]): ProviderValidationStep[] {
|
||||
return configValidators.map(validator => ({
|
||||
id: validator.id,
|
||||
label: validator.name,
|
||||
@@ -41,7 +46,7 @@ export function createConfigValidationSteps(configValidators: Array<{ id: string
|
||||
}))
|
||||
}
|
||||
|
||||
export function createProviderValidationSteps(providerValidators: Array<{ id: string, name: string, validator: (config: Record<string, unknown>, provider: ProviderInstance, providerExtra: ProviderExtraMethods<Record<string, unknown>>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }>): ProviderValidationStep[] {
|
||||
export function createProviderValidationSteps(providerValidators: ProviderRuntimeValidator<Record<string, unknown>>[]): ProviderValidationStep[] {
|
||||
return providerValidators.map(validator => ({
|
||||
id: validator.id,
|
||||
label: validator.name,
|
||||
@@ -51,6 +56,24 @@ export function createProviderValidationSteps(providerValidators: Array<{ id: st
|
||||
}))
|
||||
}
|
||||
|
||||
export function getProviderValidationIntervalMs(options: {
|
||||
definition: ProviderDefinition
|
||||
contextOptions: { t: ComposerTranslation }
|
||||
defaultIntervalMs?: number
|
||||
}) {
|
||||
const validators = (options.definition.validators?.validateProvider || []).map(creator => creator(options.contextOptions))
|
||||
const defaultIntervalMs = options.defaultIntervalMs ?? 15_000
|
||||
const intervals = validators
|
||||
.filter(validator => validator.schedule?.mode === 'interval')
|
||||
.map(validator => validator.schedule?.intervalMs || defaultIntervalMs)
|
||||
|
||||
if (intervals.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Math.min(...intervals)
|
||||
}
|
||||
|
||||
export function getValidatorsOfProvider(options: {
|
||||
definition: ProviderDefinition
|
||||
config: Record<string, unknown>
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription'
|
||||
|
||||
import { isStageTamagotchi, isUrl } from '@proj-airi/stage-shared'
|
||||
import { computedAsync, useLocalStorage } from '@vueuse/core'
|
||||
import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
createOpenAI,
|
||||
} from '@xsai-ext/providers/create'
|
||||
@@ -47,6 +47,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { listProviders as listDefinedProviders } from '../libs/providers'
|
||||
import { getProviderValidationIntervalMs } from '../libs/providers/validators/run'
|
||||
import { getKokoroWorker } from '../workers/kokoro'
|
||||
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
|
||||
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
|
||||
@@ -1676,12 +1677,25 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
// Progressive migration bridge:
|
||||
// translate unified provider definitions from libs/providers to legacy store metadata.
|
||||
// Existing metadata remains as fallback for providers not yet migrated.
|
||||
const definedProviders = listDefinedProviders()
|
||||
|
||||
const translatedProviderMetadata = convertProviderDefinitionsToMetadata(
|
||||
listDefinedProviders(),
|
||||
definedProviders,
|
||||
t,
|
||||
providerMetadata,
|
||||
)
|
||||
|
||||
const providerValidationIntervalMsById = new Map<string, number>()
|
||||
for (const definition of definedProviders) {
|
||||
const intervalMs = getProviderValidationIntervalMs({
|
||||
definition,
|
||||
contextOptions: { t },
|
||||
})
|
||||
if (intervalMs && intervalMs > 0) {
|
||||
providerValidationIntervalMsById.set(definition.id, intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only legacy ASR/TTS providers as hand-written metadata.
|
||||
// All other categories are sourced from unified definitions in libs/providers.
|
||||
for (const [providerId, existing] of Object.entries(providerMetadata)) {
|
||||
@@ -1700,6 +1714,8 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
|
||||
// const validatedCredentials = ref<Record<string, string>>({})
|
||||
const providerRuntimeState = ref<Record<string, ProviderRuntimeState>>({})
|
||||
const providerValidationInFlight = new Map<string, Promise<boolean>>()
|
||||
const providerRevalidationLoops = new Map<string, { resume: () => void }>()
|
||||
|
||||
const configuredProviders = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
@@ -1719,7 +1735,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
}
|
||||
|
||||
// Configuration validation functions
|
||||
async function validateProvider(providerId: string): Promise<boolean> {
|
||||
async function validateProvider(providerId: string, options: { force?: boolean } = {}): Promise<boolean> {
|
||||
const metadata = providerMetadata[providerId]
|
||||
if (!metadata)
|
||||
return false
|
||||
@@ -1737,26 +1753,43 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
|
||||
const configString = JSON.stringify(config || {})
|
||||
const runtimeState = providerRuntimeState.value[providerId]
|
||||
const cacheKey = `${providerId}:${configString}`
|
||||
const forceValidation = options.force === true
|
||||
|
||||
if (runtimeState?.validatedCredentialHash === configString && typeof runtimeState.isConfigured === 'boolean')
|
||||
if (!forceValidation && runtimeState?.validatedCredentialHash === configString && typeof runtimeState.isConfigured === 'boolean')
|
||||
return runtimeState.isConfigured
|
||||
|
||||
// Always cache the current config string to prevent re-validating the same config
|
||||
if (providerRuntimeState.value[providerId]) {
|
||||
providerRuntimeState.value[providerId].validatedCredentialHash = configString
|
||||
}
|
||||
|
||||
const validationResult = await metadata.validators.validateProviderConfig(config || {})
|
||||
|
||||
if (providerRuntimeState.value[providerId]) {
|
||||
providerRuntimeState.value[providerId].isConfigured = validationResult.valid
|
||||
// Auto-mark Web Speech API as added if valid and available
|
||||
if (validationResult.valid && ['browser-web-speech-api', 'player2'].includes(providerId)) {
|
||||
markProviderAdded(providerId)
|
||||
if (!forceValidation) {
|
||||
const pending = providerValidationInFlight.get(cacheKey)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
return validationResult.valid
|
||||
const runValidation = async () => {
|
||||
const validationResult = await metadata.validators.validateProviderConfig(config || {})
|
||||
|
||||
if (providerRuntimeState.value[providerId]) {
|
||||
providerRuntimeState.value[providerId].isConfigured = validationResult.valid
|
||||
providerRuntimeState.value[providerId].validatedCredentialHash = configString
|
||||
// Auto-mark Web Speech API as added if valid and available
|
||||
if (validationResult.valid && ['browser-web-speech-api', 'player2'].includes(providerId)) {
|
||||
markProviderAdded(providerId)
|
||||
}
|
||||
}
|
||||
|
||||
return validationResult.valid
|
||||
}
|
||||
|
||||
if (forceValidation) {
|
||||
return runValidation()
|
||||
}
|
||||
|
||||
const task = runValidation()
|
||||
providerValidationInFlight.set(cacheKey, task)
|
||||
return task.finally(() => {
|
||||
providerValidationInFlight.delete(cacheKey)
|
||||
})
|
||||
}
|
||||
|
||||
// Create computed properties for each provider's configuration status
|
||||
@@ -1788,6 +1821,23 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
// Initialize all providers
|
||||
Object.keys(providerMetadata).forEach(initializeProvider)
|
||||
|
||||
function startPeriodicRuntimeValidation() {
|
||||
for (const [providerId, intervalMs] of providerValidationIntervalMsById.entries()) {
|
||||
if (!providerMetadata[providerId] || intervalMs <= 0)
|
||||
continue
|
||||
|
||||
if (providerRevalidationLoops.has(providerId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const loop = useIntervalFn(() => {
|
||||
void validateProvider(providerId, { force: true })
|
||||
}, intervalMs, { immediate: false, immediateCallback: false })
|
||||
loop.resume()
|
||||
providerRevalidationLoops.set(providerId, loop)
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration status for all configured providers
|
||||
async function updateConfigurationStatus() {
|
||||
await Promise.all(Object.entries(providerMetadata)
|
||||
@@ -1810,6 +1860,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
|
||||
// Call initially and watch for changes
|
||||
watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true })
|
||||
startPeriodicRuntimeValidation()
|
||||
|
||||
// Available providers (only those that are properly configured)
|
||||
const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured))
|
||||
|
||||
Reference in New Issue
Block a user