From 3f12d714b58d24b2e2347ea5f91ea537273eba09 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 5 Mar 2026 20:52:33 +0800 Subject: [PATCH] feat(stage-ui): periodically check Ollama connectivity --- .../providers/providers/lm-studio/index.ts | 4 + .../libs/providers/providers/ollama/index.ts | 6 +- packages/stage-ui/src/libs/providers/types.ts | 23 ++++- .../providers/validators/openai-compatible.ts | 7 ++ .../src/libs/providers/validators/run.ts | 35 ++++++-- packages/stage-ui/src/stores/providers.ts | 85 +++++++++++++++---- 6 files changed, 134 insertions(+), 26 deletions(-) diff --git a/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts b/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts index 1e2fd8f89..dee928105 100644 --- a/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/lm-studio/index.ts @@ -53,6 +53,10 @@ export const providerLmStudio = defineProvider({ 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 }) => diff --git a/packages/stage-ui/src/libs/providers/providers/ollama/index.ts b/packages/stage-ui/src/libs/providers/providers/ollama/index.ts index 44a72b38e..d5478da4b 100644 --- a/packages/stage-ui/src/libs/providers/providers/ollama/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/ollama/index.ts @@ -166,7 +166,11 @@ export const providerOllama = defineProvider({ }), ], 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, diff --git a/packages/stage-ui/src/libs/providers/types.ts b/packages/stage-ui/src/libs/providers/types.ts index c3e4c15e6..3ff0ad50d 100644 --- a/packages/stage-ui/src/libs/providers/types.ts +++ b/packages/stage-ui/src/libs/providers/types.ts @@ -48,6 +48,25 @@ export interface ProviderValidationResult { valid: boolean } +export interface ProviderValidatorSchedule { + mode: 'once' | 'interval' + intervalMs?: number +} + +export interface ProviderConfigValidator { + id: string + name: string + validator: (config: TConfig, contextOptions: { t: ComposerTranslation }) => MaybePromise + schedule?: ProviderValidatorSchedule +} + +export interface ProviderRuntimeValidator { + id: string + name: string + validator: (config: TConfig, provider: ProviderInstance, providerExtra: ProviderExtraMethods, contextOptions: { t: ComposerTranslation }) => MaybePromise + schedule?: ProviderValidatorSchedule +} + export interface ModelInfo { id: string name: string @@ -121,8 +140,8 @@ export interface ProviderDefinition { extraMethods?: ProviderExtraMethods validationRequiredWhen?: (config: TConfig) => boolean validators?: { - validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => { id: string, name: string, validator: (config: TConfig, contextOptions: { t: ComposerTranslation }) => MaybePromise }> - validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => { id: string, name: string, validator: (config: TConfig, provider: ProviderInstance, providerExtra: ProviderExtraMethods, contextOptions: { t: ComposerTranslation }) => MaybePromise }> + validateConfig?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderConfigValidator> + validateProvider?: Array<(contextOptions: { t: ComposerTranslation }) => ProviderRuntimeValidator> } capabilities?: { transcription?: { diff --git a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts index dda3e92dd..b635e01a3 100644 --- a/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts +++ b/packages/stage-ui/src/libs/providers/validators/openai-compatible.ts @@ -15,6 +15,10 @@ type OpenAICompatibleValidationCheck = 'connectivity' | 'model_list' | 'chat_com interface OpenAICompatibleValidationOptions { checks?: OpenAICompatibleValidationCheck[] additionalHeaders?: Record + 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 ({ 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 ({ 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 ({ 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 { diff --git a/packages/stage-ui/src/libs/providers/validators/run.ts b/packages/stage-ui/src/libs/providers/validators/run.ts index 2ecafbb1e..17f0bae95 100644 --- a/packages/stage-ui/src/libs/providers/validators/run.ts +++ b/packages/stage-ui/src/libs/providers/validators/run.ts @@ -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 definition: ProviderDefinition - configValidators: Array<{ id: string, name: string, validator: (config: Record, contextOptions: { t: ComposerTranslation }) => MaybePromise }> - providerValidators: Array<{ id: string, name: string, validator: (config: Record, provider: ProviderInstance, providerExtra: ProviderExtraMethods>, contextOptions: { t: ComposerTranslation }) => MaybePromise }> + configValidators: ProviderConfigValidator>[] + providerValidators: ProviderRuntimeValidator>[] providerExtra: ProviderExtraMethods> | 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, contextOptions: { t: ComposerTranslation }) => MaybePromise }>): ProviderValidationStep[] { +export function createConfigValidationSteps(configValidators: ProviderConfigValidator>[]): 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, provider: ProviderInstance, providerExtra: ProviderExtraMethods>, contextOptions: { t: ComposerTranslation }) => MaybePromise }>): ProviderValidationStep[] { +export function createProviderValidationSteps(providerValidators: ProviderRuntimeValidator>[]): 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 diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 150397dd5..0c86283b8 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -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() + 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>({}) const providerRuntimeState = ref>({}) + const providerValidationInFlight = new Map>() + const providerRevalidationLoops = new Map void }>() const configuredProviders = computed(() => { const result: Record = {} @@ -1719,7 +1735,7 @@ export const useProvidersStore = defineStore('providers', () => { } // Configuration validation functions - async function validateProvider(providerId: string): Promise { + async function validateProvider(providerId: string, options: { force?: boolean } = {}): Promise { 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))