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: {
...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,
+21 -2
View File
@@ -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>
+68 -17
View File
@@ -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))