feat(stage-ui): periodically check Ollama connectivity

This commit is contained in:
Neko Ayaka
2026-03-05 20:52:56 +08:00
parent 86c0d9d28a
commit 3f12d714b5
6 changed files with 134 additions and 26 deletions
@@ -53,6 +53,10 @@ export const providerLmStudio = defineProvider<LMStudioConfig>({
validators: { validators: {
...createOpenAICompatibleValidators({ ...createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list'], checks: ['connectivity', 'model_list'],
schedule: {
mode: 'interval',
intervalMs: 15_000,
},
connectivityFailureReason: ({ errorMessage }) => 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'.`, `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 }) => modelListFailureReason: ({ errorMessage }) =>
@@ -166,7 +166,11 @@ export const providerOllama = defineProvider<OllamaConfig>({
}), }),
], ],
validateProvider: createOpenAICompatibleValidators({ validateProvider: createOpenAICompatibleValidators({
checks: ['connectivity', 'model_list', 'chat_completions'], checks: ['connectivity', 'model_list'],
schedule: {
mode: 'interval',
intervalMs: 15_000,
},
connectivityFailureReason: ({ errorMessage }) => 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.`, `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, })!.validateProvider,
+21 -2
View File
@@ -48,6 +48,25 @@ export interface ProviderValidationResult {
valid: boolean 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 { export interface ModelInfo {
id: string id: string
name: string name: string
@@ -121,8 +140,8 @@ export interface ProviderDefinition<TConfig extends any = any> {
extraMethods?: ProviderExtraMethods<TConfig> extraMethods?: ProviderExtraMethods<TConfig>
validationRequiredWhen?: (config: TConfig) => boolean validationRequiredWhen?: (config: TConfig) => boolean
validators?: { validators?: {
validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => { id: string, name: string, validator: (config: TConfig, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }> validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderConfigValidator<TConfig>>
validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => { id: string, name: string, validator: (config: TConfig, provider: ProviderInstance, providerExtra: ProviderExtraMethods<TConfig>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }> validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderRuntimeValidator<TConfig>>
} }
capabilities?: { capabilities?: {
transcription?: { transcription?: {
@@ -15,6 +15,10 @@ type OpenAICompatibleValidationCheck = 'connectivity' | 'model_list' | 'chat_com
interface OpenAICompatibleValidationOptions<TConfig extends { apiKey?: string, baseUrl?: string }> { interface OpenAICompatibleValidationOptions<TConfig extends { apiKey?: string, baseUrl?: string }> {
checks?: OpenAICompatibleValidationCheck[] checks?: OpenAICompatibleValidationCheck[]
additionalHeaders?: Record<string, string> additionalHeaders?: Record<string, string>
schedule?: {
mode: 'once' | 'interval'
intervalMs?: number
}
connectivityFailureReason?: (input: { config: TConfig, error: unknown, errorMessage: string }) => string connectivityFailureReason?: (input: { config: TConfig, error: unknown, errorMessage: string }) => string
modelListFailureReason?: (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 }) => ({ validatorConfig.validateProvider?.push(({ t }) => ({
id: 'openai-compatible:check-connectivity', id: 'openai-compatible:check-connectivity',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'), name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-connectivity.title'),
schedule: options?.schedule,
validator: async (config, provider, providerExtra, contextOptions) => { validator: async (config, provider, providerExtra, contextOptions) => {
const errors: Array<{ error: unknown }> = [] const errors: Array<{ error: unknown }> = []
const result = await getChatCheckResult( const result = await getChatCheckResult(
@@ -266,6 +271,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
validatorConfig.validateProvider?.push(({ t }) => ({ validatorConfig.validateProvider?.push(({ t }) => ({
id: 'openai-compatible:check-chat-completions', id: 'openai-compatible:check-chat-completions',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-chat-completion.title'), name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-chat-completion.title'),
schedule: options?.schedule,
validator: async (config, provider, providerExtra, contextOptions) => { validator: async (config, provider, providerExtra, contextOptions) => {
const errors: Array<{ error: unknown }> = [] const errors: Array<{ error: unknown }> = []
const result = await getChatCheckResult( const result = await getChatCheckResult(
@@ -292,6 +298,7 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
validatorConfig.validateProvider?.push(({ t }) => ({ validatorConfig.validateProvider?.push(({ t }) => ({
id: 'openai-compatible:check-model-list', id: 'openai-compatible:check-model-list',
name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-model-listing.title'), name: t('settings.pages.providers.catalog.edit.validators.openai-compatible.check-supports-model-listing.title'),
schedule: options?.schedule,
validator: async (config, provider, providerExtra) => { validator: async (config, provider, providerExtra) => {
const errors: Array<{ error: unknown }> = [] const errors: Array<{ error: unknown }> = []
try { try {
@@ -1,7 +1,12 @@
import type { MaybePromise } from 'clustr'
import type { ComposerTranslation } from 'vue-i18n' 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' import { errorMessageFrom, merge } from '@moeru/std'
@@ -19,8 +24,8 @@ export interface ProviderValidationPlan {
steps: ProviderValidationStep[] steps: ProviderValidationStep[]
config: Record<string, unknown> config: Record<string, unknown>
definition: ProviderDefinition definition: ProviderDefinition
configValidators: Array<{ id: string, name: string, validator: (config: Record<string, unknown>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }> configValidators: ProviderConfigValidator<Record<string, unknown>>[]
providerValidators: Array<{ id: string, name: string, validator: (config: Record<string, unknown>, provider: ProviderInstance, providerExtra: ProviderExtraMethods<Record<string, unknown>>, contextOptions: { t: ComposerTranslation }) => MaybePromise<ProviderValidationResult> }> providerValidators: ProviderRuntimeValidator<Record<string, unknown>>[]
providerExtra: ProviderExtraMethods<Record<string, unknown>> | undefined providerExtra: ProviderExtraMethods<Record<string, unknown>> | undefined
shouldValidate: boolean shouldValidate: boolean
} }
@@ -31,7 +36,7 @@ export interface ProviderValidationCallbacks {
onValidatorError?: (info: { kind: ProviderValidationStepKind, index: number, step: ProviderValidationStep, error: unknown }) => void 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 => ({ return configValidators.map(validator => ({
id: validator.id, id: validator.id,
label: validator.name, 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 => ({ return providerValidators.map(validator => ({
id: validator.id, id: validator.id,
label: validator.name, 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: { export function getValidatorsOfProvider(options: {
definition: ProviderDefinition definition: ProviderDefinition
config: Record<string, unknown> config: Record<string, unknown>
+68 -17
View File
@@ -21,7 +21,7 @@ import type {
import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription' import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription'
import { isStageTamagotchi, isUrl } from '@proj-airi/stage-shared' import { isStageTamagotchi, isUrl } from '@proj-airi/stage-shared'
import { computedAsync, useLocalStorage } from '@vueuse/core' import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core'
import { import {
createOpenAI, createOpenAI,
} from '@xsai-ext/providers/create' } from '@xsai-ext/providers/create'
@@ -47,6 +47,7 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { listProviders as listDefinedProviders } from '../libs/providers' import { listProviders as listDefinedProviders } from '../libs/providers'
import { getProviderValidationIntervalMs } from '../libs/providers/validators/run'
import { getKokoroWorker } from '../workers/kokoro' import { getKokoroWorker } from '../workers/kokoro'
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants' import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription' import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
@@ -1676,12 +1677,25 @@ export const useProvidersStore = defineStore('providers', () => {
// Progressive migration bridge: // Progressive migration bridge:
// translate unified provider definitions from libs/providers to legacy store metadata. // translate unified provider definitions from libs/providers to legacy store metadata.
// Existing metadata remains as fallback for providers not yet migrated. // Existing metadata remains as fallback for providers not yet migrated.
const definedProviders = listDefinedProviders()
const translatedProviderMetadata = convertProviderDefinitionsToMetadata( const translatedProviderMetadata = convertProviderDefinitionsToMetadata(
listDefinedProviders(), definedProviders,
t, t,
providerMetadata, 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. // Keep only legacy ASR/TTS providers as hand-written metadata.
// All other categories are sourced from unified definitions in libs/providers. // All other categories are sourced from unified definitions in libs/providers.
for (const [providerId, existing] of Object.entries(providerMetadata)) { for (const [providerId, existing] of Object.entries(providerMetadata)) {
@@ -1700,6 +1714,8 @@ export const useProvidersStore = defineStore('providers', () => {
// const validatedCredentials = ref<Record<string, string>>({}) // const validatedCredentials = ref<Record<string, string>>({})
const providerRuntimeState = ref<Record<string, ProviderRuntimeState>>({}) const providerRuntimeState = ref<Record<string, ProviderRuntimeState>>({})
const providerValidationInFlight = new Map<string, Promise<boolean>>()
const providerRevalidationLoops = new Map<string, { resume: () => void }>()
const configuredProviders = computed(() => { const configuredProviders = computed(() => {
const result: Record<string, boolean> = {} const result: Record<string, boolean> = {}
@@ -1719,7 +1735,7 @@ export const useProvidersStore = defineStore('providers', () => {
} }
// Configuration validation functions // Configuration validation functions
async function validateProvider(providerId: string): Promise<boolean> { async function validateProvider(providerId: string, options: { force?: boolean } = {}): Promise<boolean> {
const metadata = providerMetadata[providerId] const metadata = providerMetadata[providerId]
if (!metadata) if (!metadata)
return false return false
@@ -1737,26 +1753,43 @@ export const useProvidersStore = defineStore('providers', () => {
const configString = JSON.stringify(config || {}) const configString = JSON.stringify(config || {})
const runtimeState = providerRuntimeState.value[providerId] 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 return runtimeState.isConfigured
// Always cache the current config string to prevent re-validating the same config if (!forceValidation) {
if (providerRuntimeState.value[providerId]) { const pending = providerValidationInFlight.get(cacheKey)
providerRuntimeState.value[providerId].validatedCredentialHash = configString if (pending) {
} return pending
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)
} }
} }
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 // Create computed properties for each provider's configuration status
@@ -1788,6 +1821,23 @@ export const useProvidersStore = defineStore('providers', () => {
// Initialize all providers // Initialize all providers
Object.keys(providerMetadata).forEach(initializeProvider) 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 // Update configuration status for all configured providers
async function updateConfigurationStatus() { async function updateConfigurationStatus() {
await Promise.all(Object.entries(providerMetadata) await Promise.all(Object.entries(providerMetadata)
@@ -1810,6 +1860,7 @@ export const useProvidersStore = defineStore('providers', () => {
// Call initially and watch for changes // Call initially and watch for changes
watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true }) watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true })
startPeriodicRuntimeValidation()
// Available providers (only those that are properly configured) // Available providers (only those that are properly configured)
const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured)) const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured))