From e2376736af84e2ecc87d5a7920710ff8a6c93e8c Mon Sep 17 00:00:00 2001 From: Neko Date: Mon, 24 Aug 2026 21:27:58 +0800 Subject: [PATCH] fix(stage-ui): align module provider visibility with auth (#2353) --- apps/stage-pocket/src/App.vue | 16 +- apps/stage-tamagotchi/src/renderer/App.vue | 16 +- apps/stage-web/src/App.vue | 16 +- .../pages/settings/modules/consciousness.vue | 6 +- .../src/pages/settings/modules/hearing.vue | 6 +- .../src/pages/settings/modules/speech.vue | 4 +- .../src/pages/settings/modules/vision.vue | 6 +- .../providers/browser-web-speech-api/index.ts | 5 +- .../providers/providers/official/index.ts | 10 +- packages/stage-ui/src/libs/providers/types.ts | 10 ++ .../inference-service-providers.test.ts | 9 ++ .../services/inference-service-providers.ts | 2 + .../src/stores/modules/default.test.ts | 137 +++++++++++++++++- .../stage-ui/src/stores/modules/default.ts | 86 +++++++++-- .../src/stores/providers/config.test.ts | 1 + .../stage-ui/src/stores/providers/config.ts | 15 ++ .../src/stores/providers/provider.test.ts | 69 ++++++++- .../stage-ui/src/stores/providers/provider.ts | 68 ++++++++- 18 files changed, 435 insertions(+), 47 deletions(-) diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index 0b26667f5..825cf9e05 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -10,7 +10,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { configureAsDefaultsIfEmpty } from '@proj-airi/stage-ui/stores/modules/default' +import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' @@ -51,6 +51,16 @@ useSettingsStageModel() useVisionStore() let stopAuthenticatedSetup: (() => void) | undefined +let stopLoggedOutSetup: (() => void) | undefined + +async function removeAuthenticationProviderConfiguration() { + if (!syncedPinia.isLeader()) + return + + if (await unconfigureAuthenticationProviders()) + await cardStore.persistActiveCardModuleSelections() +} + function registerAuthenticatedSetup() { stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { if (!syncedPinia.isLeader()) @@ -60,6 +70,7 @@ function registerAuthenticatedSetup() { await cardStore.persistActiveCardModuleSelections() await onboardingStore.closeAfterAuthentication() }) + stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration) } const primaryColor = computed(() => { @@ -103,6 +114,8 @@ onMounted(async () => { await displayModelsStore.initialize() await cardStore.initialize() registerAuthenticatedSetup() + if (!authStore.isAuthenticated) + await removeAuthenticationProviderConfiguration() if (onboardingStore.needsOnboarding) { onboardingStore.showingSetup = true @@ -122,6 +135,7 @@ onMounted(async () => { onUnmounted(() => { stopAuthenticatedSetup?.() + stopLoggedOutSetup?.() contextBridgeStore.dispose() }) diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 1745977f5..2585fb38f 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -20,7 +20,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { configureAsDefaultsIfEmpty } from '@proj-airi/stage-ui/stores/modules/default' +import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' @@ -140,6 +140,16 @@ function createFullStageRuntime() { useVisionStore() let stopAuthenticatedSetup: (() => void) | undefined + let stopLoggedOutSetup: (() => void) | undefined + + async function removeAuthenticationProviderConfiguration() { + if (!syncedPinia.isLeader()) + return + + if (await unconfigureAuthenticationProviders()) + await cardStore.persistActiveCardModuleSelections() + } + function registerAuthenticatedSetup() { stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { if (!syncedPinia.isLeader()) @@ -149,6 +159,7 @@ function createFullStageRuntime() { await cardStore.persistActiveCardModuleSelections() await onboardingStore.closeAfterAuthentication() }) + stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration) } const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore) @@ -254,6 +265,8 @@ function createFullStageRuntime() { await displayModelsStore.initialize() await cardStore.initialize() registerAuthenticatedSetup() + if (!authStore.isAuthenticated) + await removeAuthenticationProviderConfiguration() await displayModelsStore.loadDisplayModelsFromIndexedDB() await settingsStore.initializeStageModel() @@ -299,6 +312,7 @@ function createFullStageRuntime() { }, dispose() { stopAuthenticatedSetup?.() + stopLoggedOutSetup?.() contextBridgeStore.dispose() }, } diff --git a/apps/stage-web/src/App.vue b/apps/stage-web/src/App.vue index c19fcd92f..75d4a652e 100644 --- a/apps/stage-web/src/App.vue +++ b/apps/stage-web/src/App.vue @@ -12,7 +12,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { configureAsDefaultsIfEmpty } from '@proj-airi/stage-ui/stores/modules/default' +import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' @@ -58,6 +58,16 @@ useSettingsStageModel() useVisionStore() let stopAuthenticatedSetup: (() => void) | undefined +let stopLoggedOutSetup: (() => void) | undefined + +async function removeAuthenticationProviderConfiguration() { + if (!syncedPinia.isLeader()) + return + + if (await unconfigureAuthenticationProviders()) + await cardStore.persistActiveCardModuleSelections() +} + function registerAuthenticatedSetup() { stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { if (!syncedPinia.isLeader()) @@ -67,6 +77,7 @@ function registerAuthenticatedSetup() { await cardStore.persistActiveCardModuleSelections() await onboardingStore.closeAfterAuthentication() }) + stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration) } const inferencePreload = useInferencePreload() @@ -118,6 +129,8 @@ onMounted(async () => { await displayModelsStore.initialize() await cardStore.initialize() registerAuthenticatedSetup() + if (!authStore.isAuthenticated) + await removeAuthenticationProviderConfiguration() if (onboardingStore.needsOnboarding) { onboardingStore.showingSetup = true @@ -138,6 +151,7 @@ onMounted(async () => { onUnmounted(() => { stopAuthenticatedSetup?.() + stopLoggedOutSetup?.() stopLeadershipListener() contextBridgeStore.dispose() }) diff --git a/packages/stage-pages/src/pages/settings/modules/consciousness.vue b/packages/stage-pages/src/pages/settings/modules/consciousness.vue index 03c32f10a..959395502 100644 --- a/packages/stage-pages/src/pages/settings/modules/consciousness.vue +++ b/packages/stage-pages/src/pages/settings/modules/consciousness.vue @@ -18,7 +18,7 @@ const airiCardStore = useAiriCardStore() const consciousnessStore = useConsciousnessStore() const consciousnessSettingsStore = useConsciousnessSettingsStore() const { configuredProviders } = storeToRefs(providerStore) -const { persistedChatProvidersMetadata } = storeToRefs(providersStore) +const { moduleChatProvidersMetadata } = storeToRefs(providersStore) const { reasoning } = storeToRefs(consciousnessSettingsStore) const { activeProvider, @@ -88,13 +88,13 @@ async function updateReasoning(value: boolean) { See also: https://stackoverflow.com/a/33737340 -->
{ See also: https://stackoverflow.com/a/33737340 -->
{ - const configuredSources = configuredSpeechProvidersMetadata.value + const configuredSources = moduleSpeechProvidersMetadata.value .filter(metadata => metadata.id !== 'speech-noop' && metadata.id !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, diff --git a/packages/stage-pages/src/pages/settings/modules/vision.vue b/packages/stage-pages/src/pages/settings/modules/vision.vue index 0c49ee325..865d5d081 100644 --- a/packages/stage-pages/src/pages/settings/modules/vision.vue +++ b/packages/stage-pages/src/pages/settings/modules/vision.vue @@ -17,7 +17,7 @@ const airiCardStore = useAiriCardStore() const visionStore = useVisionStore() const visionProcessingStore = useVisionProcessingStore() const { configuredProviders } = storeToRefs(providerStore) -const { persistedVisionProvidersMetadata } = storeToRefs(providersStore) +const { moduleVisionProvidersMetadata } = storeToRefs(providersStore) const { activeProvider, activeModel, @@ -106,12 +106,12 @@ function formatRelativeTime(timestamp: number | null) {
!isStageTamagotchi() && isWebSpeechApiAvailable(), + isAvailableBy: isWebSpeechApiAvailable, createProviderConfig: () => webSpeechApiConfigSchema, createProvider: createWebSpeechAPIProvider, validationRequiredWhen: () => false, diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index 925b7f12c..007c8aa6d 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -10,9 +10,11 @@ import { SERVER_URL } from '../../../../libs/server' import { defineProvider } from '../registry' import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICON, withCredentials } from './shared' +export const OFFICIAL_CHAT_PROVIDER_ID = 'official-provider' export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech' export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming' export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcription' +export const OFFICIAL_VISION_PROVIDER_ID = 'vision-official-provider' // Locale → voice id map recommended by the server, keyed by provider id. // Populated by each speech provider's listVoices() from the response's @@ -63,7 +65,7 @@ function authHeaders(): Record { } export const providerOfficialChat = defineProvider({ - id: 'official-provider', + id: OFFICIAL_CHAT_PROVIDER_ID, order: -1, name: 'Official Provider', nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.title'), @@ -72,6 +74,7 @@ export const providerOfficialChat = defineProvider({ tasks: ['text-generation'], icon: OFFICIAL_ICON, requiresCredentials: false, + configuredBy: 'authentication', createProviderConfig: () => officialConfigSchema, createProvider(_config) { @@ -92,7 +95,7 @@ export const providerOfficialChat = defineProvider({ { id: 'auto', name: 'Auto', - provider: 'official-provider', + provider: OFFICIAL_CHAT_PROVIDER_ID, description: 'Automatically routed by AI Gateway', }, ], @@ -109,6 +112,7 @@ export const providerOfficialSpeech = defineProvider({ tasks: ['text-to-speech'], icon: OFFICIAL_ICON, requiresCredentials: false, + configuredBy: 'authentication', createProviderConfig: () => officialConfigSchema, createProvider(_config) { const provider = createOfficialAudioProvider() @@ -226,6 +230,7 @@ export const providerOfficialSpeechStreaming = defineProvider({ tasks: ['text-to-speech'], icon: OFFICIAL_ICON, requiresCredentials: false, + configuredBy: 'authentication', // Mark this provider as speaking the bidirectional ws TTS protocol so the // session adapter (`tts-session.ts`) picks the streaming path without // hard-coding provider id. Default for every other provider is `'rest'`. @@ -350,6 +355,7 @@ export const providerOfficialTranscription = defineProvider({ tasks: ['speech-to-text', 'automatic-speech-recognition', 'asr', 'stt', 'streaming-transcription'], icon: OFFICIAL_ICON, requiresCredentials: false, + configuredBy: 'authentication', capabilities: { transcription: { protocol: 'http', diff --git a/packages/stage-ui/src/libs/providers/types.ts b/packages/stage-ui/src/libs/providers/types.ts index eafb3aca3..37abd3aaa 100644 --- a/packages/stage-ui/src/libs/providers/types.ts +++ b/packages/stage-ui/src/libs/providers/types.ts @@ -29,6 +29,7 @@ export type ProviderInstance /** Validation lifecycle for one serializable provider configuration. */ export type ProviderValidationStatus = 'unconfigured' | 'validating' | 'configured' | 'invalid' | 'bypassed' +export type ProviderConfiguredBy = 'user' | 'authentication' /** Serializable configuration for one provider instance. */ export interface InferenceServiceProvider { @@ -40,6 +41,8 @@ export interface InferenceServiceProvider { config: Record /** Current validation state for this provider configuration. */ status: ProviderValidationStatus + /** Lifecycle owner that creates and revokes this provider configuration. */ + configuredBy: ProviderConfiguredBy } export function isModelProvider(providerInstance: ProviderInstance): providerInstance is ModelProvider | ModelProviderWithExtraOptions { @@ -194,6 +197,13 @@ export interface ProviderDefinition { */ requiresCredentials?: boolean + /** + * Lifecycle owner for provider configurations created from this definition. + * + * @default 'user' + */ + configuredBy?: ProviderConfiguredBy + createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType onboardingFields?: (ctx: { t: ComposerTranslation }) => ProviderOnboardingField[] createProvider: (config: TConfig) => ProviderInstance diff --git a/packages/stage-ui/src/services/inference-service-providers.test.ts b/packages/stage-ui/src/services/inference-service-providers.test.ts index 6a91a8bd7..791c2563a 100644 --- a/packages/stage-ui/src/services/inference-service-providers.test.ts +++ b/packages/stage-ui/src/services/inference-service-providers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { parse as parseSchema } from 'zod/v4/core' import { ATLASCLOUD_DEFAULT_BASE_URL, providerAtlasCloud } from '../libs/providers/providers/atlascloud' +import { OFFICIAL_CHAT_PROVIDER_ID } from '../libs/providers/providers/official' import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible' import { inferenceServiceProvidersService } from './inference-service-providers' @@ -21,6 +22,13 @@ describe('services inference-service-providers', () => { expect(provider.definitionId).toBe(providerOpenAICompatible.id) expect(provider.config).toEqual({}) expect(provider.status).toBe('unconfigured') + expect(provider.configuredBy).toBe('user') + }) + + it('preserves definition-owned authentication configuration', () => { + const provider = inferenceServiceProvidersService.buildLocal(OFFICIAL_CHAT_PROVIDER_ID, {}) + + expect(provider.configuredBy).toBe('authentication') }) /** @@ -104,6 +112,7 @@ describe('services inference-service-providers', () => { config: { baseUrl: 'https://example.com/v1/' }, id: 'provider-1', status: 'configured', + configuredBy: 'user', }), }) }) diff --git a/packages/stage-ui/src/services/inference-service-providers.ts b/packages/stage-ui/src/services/inference-service-providers.ts index dd2e43b78..e1ba5a6de 100644 --- a/packages/stage-ui/src/services/inference-service-providers.ts +++ b/packages/stage-ui/src/services/inference-service-providers.ts @@ -107,6 +107,7 @@ export function createInferenceServiceProvidersService(): InferenceServiceProvid definitionId, config: initialConfig, status: 'unconfigured', + configuredBy: definition.configuredBy ?? 'user', } } @@ -126,6 +127,7 @@ export function createInferenceServiceProvidersService(): InferenceServiceProvid definitionId: item.definitionId, config: item.config, status, + configuredBy: getDefinedProvider(item.definitionId)?.configuredBy ?? 'user', } } diff --git a/packages/stage-ui/src/stores/modules/default.test.ts b/packages/stage-ui/src/stores/modules/default.test.ts index e2524819c..6b22055c4 100644 --- a/packages/stage-ui/src/stores/modules/default.test.ts +++ b/packages/stage-ui/src/stores/modules/default.test.ts @@ -1,11 +1,11 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official' +import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official' import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' import { useConsciousnessStore } from './consciousness' -import { configureAsDefaultsIfEmpty } from './default' +import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from './default' import { useHearingStore } from './hearing' import { useSpeechStore } from './speech' import { useVisionStore } from './vision' @@ -72,9 +72,13 @@ describe('official provider module defaults', () => { expect(visionStore.activeModel).toBe('auto') expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') + expect(providerConfigStore.providers['official-provider']?.configuredBy).toBe('authentication') expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured') + expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.configuredBy).toBe('authentication') expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') + expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.configuredBy).toBe('authentication') expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured') + expect(providerConfigStore.providers['vision-official-provider']?.configuredBy).toBe('authentication') expect(providerConfigStore.addedProviders['official-provider']).toBe(true) expect(providerConfigStore.addedProviders[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBe(true) expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBe(true) @@ -193,11 +197,11 @@ describe('official provider module defaults', () => { expect(speechStore.activeSpeechVoiceId).toBe('') expect(visionStore.activeProvider).toBe('custom-vision') expect(visionStore.activeModel).toBe('custom-vision-model') + expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') + expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBe(true) - expect(providerConfigStore.providers['official-provider']).toBeUndefined() - expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBeUndefined() - expect(providerConfigStore.providers['vision-official-provider']).toBeUndefined() + expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured') }) it('adds a configured official provider to the visible provider list', async () => { @@ -224,10 +228,10 @@ describe('official provider module defaults', () => { expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') expect(providerConfigStore.addedProviders['official-provider']).toBe(true) - expect(forceProviderConfigured).toHaveBeenCalledOnce() + expect(forceProviderConfigured).toHaveBeenCalledTimes(4) await expect(configureAsDefaultsIfEmpty()).resolves.toBe(false) - expect(forceProviderConfigured).toHaveBeenCalledOnce() + expect(forceProviderConfigured).toHaveBeenCalledTimes(4) }) it('keeps a custom module and configures the other empty modules', async () => { @@ -249,9 +253,126 @@ describe('official provider module defaults', () => { expect(speechStore.activeSpeechModel).toBe('auto') expect(visionStore.activeProvider).toBe('vision-official-provider') expect(visionStore.activeModel).toBe('auto') - expect(providerConfigStore.providers['official-provider']).toBeUndefined() + expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured') }) + + // ROOT CAUSE: + // + // Authenticated setup only created an official provider record when its + // module was empty or already used that provider. A custom module selection + // therefore hid the official choice even while the user was signed in. + // + // We fixed this by configuring every standard official provider after login + // while applying official module selections only to empty modules. + it('configures official choices without replacing custom module selections', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerConfigStore = useProviderConfigStore() + + consciousnessStore.activeProvider = 'custom-chat' + consciousnessStore.activeModel = 'custom-chat-model' + hearingStore.activeTranscriptionProvider = 'custom-transcription' + hearingStore.activeTranscriptionModel = 'custom-transcription-model' + speechStore.activeSpeechProvider = 'custom-speech' + speechStore.activeSpeechModel = 'custom-speech-model' + visionStore.activeProvider = 'custom-vision' + visionStore.activeModel = 'custom-vision-model' + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + + expect(consciousnessStore.activeProvider).toBe('custom-chat') + expect(consciousnessStore.activeModel).toBe('custom-chat-model') + expect(hearingStore.activeTranscriptionProvider).toBe('custom-transcription') + expect(hearingStore.activeTranscriptionModel).toBe('custom-transcription-model') + expect(speechStore.activeSpeechProvider).toBe('custom-speech') + expect(speechStore.activeSpeechModel).toBe('custom-speech-model') + expect(visionStore.activeProvider).toBe('custom-vision') + expect(visionStore.activeModel).toBe('custom-vision-model') + expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') + expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('configured') + expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('configured') + expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('configured') + }) + + // ROOT CAUSE: + // + // Official provider records and module selections survived logout. Module + // pages therefore still treated official services as configured even though + // the authenticated session that grants access no longer existed. + // + // We fixed this by unconfiguring official records on logout and clearing only + // module selections that belong to those records. Custom selections remain. + it('removes authenticated official configuration on logout', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerStore = useProviderStore() + const providerConfigStore = useProviderConfigStore() + + await configureAsDefaultsIfEmpty() + await providerStore.initializeProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + await providerStore.forceProviderConfigured(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_STREAMING_PROVIDER_ID + speechStore.activeSpeechModel = 'streaming-model' + + await expect(unconfigureAuthenticationProviders()).resolves.toBe(true) + + expect(consciousnessStore.activeProvider).toBe('') + expect(consciousnessStore.activeModel).toBe('') + expect(hearingStore.activeTranscriptionProvider).toBe('') + expect(hearingStore.activeTranscriptionModel).toBe('') + expect(speechStore.activeSpeechProvider).toBe('speech-noop') + expect(speechStore.activeSpeechModel).toBe('') + expect(speechStore.activeSpeechVoiceId).toBe('') + expect(visionStore.activeProvider).toBe('') + expect(visionStore.activeModel).toBe('') + + expect(providerConfigStore.providers['official-provider']?.status).toBe('unconfigured') + expect(providerConfigStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]?.status).toBe('unconfigured') + expect(providerConfigStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]?.status).toBe('unconfigured') + expect(providerConfigStore.providers[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID]?.status).toBe('unconfigured') + expect(providerConfigStore.providers['vision-official-provider']?.status).toBe('unconfigured') + expect(providerConfigStore.addedProviders['official-provider']).toBeUndefined() + expect(providerConfigStore.addedProviders[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBeUndefined() + expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_PROVIDER_ID]).toBeUndefined() + expect(providerConfigStore.addedProviders[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID]).toBeUndefined() + expect(providerConfigStore.addedProviders['vision-official-provider']).toBeUndefined() + + await expect(unconfigureAuthenticationProviders()).resolves.toBe(false) + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') + }) + + it('keeps custom module selections when official providers are unconfigured', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + + consciousnessStore.activeProvider = 'custom-chat' + consciousnessStore.activeModel = 'custom-chat-model' + hearingStore.activeTranscriptionProvider = 'custom-transcription' + hearingStore.activeTranscriptionModel = 'custom-transcription-model' + speechStore.activeSpeechProvider = 'speech-noop' + speechStore.activeSpeechModel = '' + visionStore.activeProvider = 'custom-vision' + visionStore.activeModel = 'custom-vision-model' + + await expect(unconfigureAuthenticationProviders()).resolves.toBe(false) + + expect(consciousnessStore.activeProvider).toBe('custom-chat') + expect(consciousnessStore.activeModel).toBe('custom-chat-model') + expect(hearingStore.activeTranscriptionProvider).toBe('custom-transcription') + expect(hearingStore.activeTranscriptionModel).toBe('custom-transcription-model') + expect(speechStore.activeSpeechProvider).toBe('speech-noop') + expect(speechStore.activeSpeechModel).toBe('') + expect(visionStore.activeProvider).toBe('custom-vision') + expect(visionStore.activeModel).toBe('custom-vision-model') + }) }) diff --git a/packages/stage-ui/src/stores/modules/default.ts b/packages/stage-ui/src/stores/modules/default.ts index b684cb0ad..f4286520d 100644 --- a/packages/stage-ui/src/stores/modules/default.ts +++ b/packages/stage-ui/src/stores/modules/default.ts @@ -1,4 +1,4 @@ -import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official' +import { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from '../../libs/providers/providers/official' import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' import { useConsciousnessStore } from './consciousness' @@ -8,20 +8,20 @@ import { useVisionStore } from './vision' // Vision provider instances use a category prefix but share the official chat definition. const officialModuleDefaults = { - consciousness: { provider: 'official-provider', model: 'auto' }, + consciousness: { provider: OFFICIAL_CHAT_PROVIDER_ID, model: 'auto' }, hearing: { provider: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, model: 'auto' }, speech: { provider: OFFICIAL_SPEECH_PROVIDER_ID, model: 'auto' }, - vision: { provider: 'vision-official-provider', model: 'auto' }, + vision: { provider: OFFICIAL_VISION_PROVIDER_ID, model: 'auto' }, } as const /** * Applies official defaults to empty inference modules. * * The caller must own synchronized Pinia leadership. This command keeps the - * configuration of each custom provider unchanged. It also repairs missing - * provider records for modules that already use an official provider. - * Provider setup completes before module selections become visible to other - * windows. + * configuration of each custom provider unchanged. It configures the official + * choice for every module, then applies it only to empty or incomplete + * official selections. Provider setup completes before module selections + * become visible to other windows. * * @returns `true` when the command changes module or provider state. */ @@ -42,15 +42,7 @@ export async function configureAsDefaultsIfEmpty(): Promise { const providerStore = useProviderStore() const providerConfigStore = useProviderConfigStore() - const managedOfficialProviders = new Set() - if (needsConsciousnessDefault || usesOfficialConsciousness) - managedOfficialProviders.add(officialModuleDefaults.consciousness.provider) - if (needsHearingDefault || usesOfficialHearing) - managedOfficialProviders.add(officialModuleDefaults.hearing.provider) - if (needsSpeechDefault || usesOfficialSpeech) - managedOfficialProviders.add(officialModuleDefaults.speech.provider) - if (needsVisionDefault || usesOfficialVision) - managedOfficialProviders.add(officialModuleDefaults.vision.provider) + const managedOfficialProviders = new Set(Object.values(officialModuleDefaults).map(module => module.provider)) let changed = false for (const provider of managedOfficialProviders) { @@ -112,3 +104,65 @@ export async function configureAsDefaultsIfEmpty(): Promise { return changed } + +/** + * Removes provider state that is valid only during an authenticated session. + * + * The caller must own synchronized Pinia leadership. Custom module selections + * remain unchanged. An official speech selection falls back to No Speech so + * the speech pipeline keeps its account-free disabled state after logout. + * + * @returns `true` when the command changes module or provider state. + */ +export async function unconfigureAuthenticationProviders(): Promise { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerStore = useProviderStore() + const providerConfigStore = useProviderConfigStore() + let changed = false + + if (providerConfigStore.providers[consciousnessStore.activeProvider]?.configuredBy === 'authentication') { + consciousnessStore.activeProvider = '' + consciousnessStore.activeModel = '' + consciousnessStore.customModelName = '' + changed = true + } + + if (providerConfigStore.providers[hearingStore.activeTranscriptionProvider]?.configuredBy === 'authentication') { + hearingStore.activeTranscriptionProvider = '' + hearingStore.activeTranscriptionModel = '' + hearingStore.activeCustomModelName = '' + changed = true + } + + if (providerConfigStore.providers[speechStore.activeSpeechProvider]?.configuredBy === 'authentication') { + speechStore.activeSpeechProvider = 'speech-noop' + speechStore.activeSpeechModel = '' + speechStore.activeSpeechVoiceId = '' + speechStore.activeSpeechVoice = undefined + changed = true + } + + if (providerConfigStore.providers[visionStore.activeProvider]?.configuredBy === 'authentication') { + visionStore.activeProvider = '' + visionStore.activeModel = '' + visionStore.customModelName = '' + changed = true + } + + for (const [providerId, provider] of Object.entries(providerConfigStore.providers)) { + if (provider.configuredBy !== 'authentication') + continue + + const isPersistedAsAdded = providerConfigStore.addedProviders[providerId] + if (provider.status === 'unconfigured' && !isPersistedAsAdded) + continue + + await providerStore.setProviderUnconfigured(providerId) + changed = true + } + + return changed +} diff --git a/packages/stage-ui/src/stores/providers/config.test.ts b/packages/stage-ui/src/stores/providers/config.test.ts index 95c4d1436..821d90450 100644 --- a/packages/stage-ui/src/stores/providers/config.test.ts +++ b/packages/stage-ui/src/stores/providers/config.test.ts @@ -29,6 +29,7 @@ const localProvider = { definitionId: 'openai-compatible', config: {}, status: 'unconfigured', + configuredBy: 'user', } satisfies InferenceServiceProvider const remoteProvider = { diff --git a/packages/stage-ui/src/stores/providers/config.ts b/packages/stage-ui/src/stores/providers/config.ts index 9a5df983f..79aa5f640 100644 --- a/packages/stage-ui/src/stores/providers/config.ts +++ b/packages/stage-ui/src/stores/providers/config.ts @@ -65,6 +65,20 @@ export const useProviderConfigStore = defineStore('provider-config', () => { definitionId, config, status: 'unconfigured', + configuredBy: definition.configuredBy ?? 'user', + } + } + + // Provider definitions own configuration lifecycle policy. Apply that + // policy to persisted snapshots before module pages consume them. Providers + // without an owner declaration remain user-configured. + for (const provider of Object.values(providers.value)) { + const configuredByDefinition = getDefinedProvider(provider.definitionId)?.configuredBy + if (configuredByDefinition) { + provider.configuredBy = configuredByDefinition + } + else if (!provider.configuredBy) { + provider.configuredBy = 'user' } } @@ -119,6 +133,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => { definitionId, config, status: 'unconfigured' as const, + configuredBy: definition.configuredBy ?? 'user', } providers.value[providerId] = provider return provider diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index 4f7aa86fd..67e9da3f0 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -1,10 +1,12 @@ import type { ChatProvider } from '@xsai-ext/providers/utils' +import type { Session, User } from 'better-auth' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' -import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official' +import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../../libs/providers/providers/official' +import { useAuthStore } from '../auth' import { useProviderConfigStore } from './config' import { useProviderStore } from './provider' @@ -89,6 +91,71 @@ describe('provider store synchronization boundary', () => { expect(getProviderCalls).toBe(0) }) + // ROOT CAUSE: + // + // Module pages treated every credential-free provider as available before + // configuration. The official providers also have credential-free local + // definitions, but their availability belongs to the authenticated session. + // + // We fixed this by requiring a configured record for official providers + // while keeping account-free browser and local providers available. + it('lists official providers only after authenticated setup configures them', async () => { + const store = useProviderStore() + const configStore = useProviderConfigStore() + + await vi.waitFor(() => { + expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).toContain('speech-noop') + expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).not.toContain('official-provider') + expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_PROVIDER_ID) + expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).not.toContain('vision-official-provider') + }) + + expect(configStore.providers['official-provider']).toBeUndefined() + expect(configStore.providers[OFFICIAL_SPEECH_PROVIDER_ID]).toBeUndefined() + expect(configStore.providers[OFFICIAL_TRANSCRIPTION_PROVIDER_ID]).toBeUndefined() + expect(configStore.providers['vision-official-provider']).toBeUndefined() + + await store.initializeProvider('official-provider') + await store.forceProviderConfigured('official-provider') + await store.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID) + await store.forceProviderConfigured(OFFICIAL_SPEECH_PROVIDER_ID) + await store.initializeProvider(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + await store.forceProviderConfigured(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + await store.initializeProvider('vision-official-provider') + await store.forceProviderConfigured('vision-official-provider') + + expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).not.toContain('official-provider') + expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_PROVIDER_ID) + expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).not.toContain('vision-official-provider') + + const user: User = { + id: 'user-1', + name: 'AIRI User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const session: Session = { + id: 'session-1', + token: 'server-session-token', + userId: user.id, + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + useAuthStore().$patch({ user, session }) + + expect(store.moduleChatProvidersMetadata.map(provider => provider.id)).toContain('official-provider') + expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).toContain(OFFICIAL_SPEECH_PROVIDER_ID) + expect(store.moduleSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) + expect(store.moduleTranscriptionProvidersMetadata.map(provider => provider.id)).toContain(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(store.moduleVisionProvidersMetadata.map(provider => provider.id)).toContain('vision-official-provider') + }) + // ROOT CAUSE: // // getModelsForProvider created a new empty array for every cache miss. diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index 127d127bd..318de65eb 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -31,6 +31,7 @@ import { validateProvider as runProviderValidation, } from '../../libs/providers' import { selectProviderMetadata, selectProvidersMetadata } from '../../libs/providers/metadata' +import { useAuthStore } from '../auth' import { useProviderConfigStore } from './config' import { normalizeProviderConfigDefaults } from './config-defaults' @@ -86,6 +87,7 @@ const useProviderStateStore = defineStore('provider-state', () => { * configuration remains in {@link useProviderConfigStore}. */ export const useProviderStore = defineStore('provider', () => { + const authStore = useAuthStore() const providerConfigStore = useProviderConfigStore() const providerStateStore = useProviderStateStore() const providerCredentials = computed(() => providerConfigStore.configs) @@ -714,10 +716,17 @@ export const useProviderStore = defineStore('provider', () => { .filter(d => providerMetadata[d.id]) .map(d => projectProvider(d.id)) .filter(metadata => metadata !== undefined) + // Vision providers reuse chat definitions under separate instance ids. + // Include these generated definitions before configured custom instances. + const visionDefinitions = definedProviders + .filter(definition => providerMetadata[definition.id]?.category === 'chat') + .map(definition => projectProvider(`${VISION_PROVIDER_ID_PREFIX}${definition.id}`)) + .filter(metadata => metadata !== undefined) + const definitionIds = new Set([...definitions, ...visionDefinitions].map(metadata => metadata.id)) const configuredInstances: ProviderMetadata[] = [] for (const providerId of Object.keys(providerConfigStore.providers)) { - if (providerMetadata[providerId]) + if (definitionIds.has(providerId)) continue const metadata = projectProvider(providerId) @@ -725,7 +734,7 @@ export const useProviderStore = defineStore('provider', () => { configuredInstances.push(metadata) } - return [...definitions, ...configuredInstances] + return [...definitions, ...visionDefinitions, ...configuredInstances] }) function getTranscriptionFeatures(providerId: string) { @@ -875,6 +884,57 @@ export const useProviderStore = defineStore('provider', () => { return !!addedProviders.value[providerId] || isProviderConfigDirty(providerId) } + function isProviderAvailableWithoutConfiguration(providerId: string) { + return providerConfiguredBy(providerId) !== 'authentication' + && getProviderDefinition(providerId).requiresCredentials === false + } + + function providerConfiguredBy(providerId: string) { + const configuredProvider = providerConfigStore.providers[providerId] + if (configuredProvider) + return configuredProvider.configuredBy + + return getProviderDefinition(providerId).configuredBy ?? 'user' + } + + function isProviderConfiguredForModule(providerId: string) { + return providerConfigStore.configuredProviders[providerId] + && (providerConfiguredBy(providerId) !== 'authentication' || authStore.isAuthenticated) + } + + // Authentication-owned providers do not require a user-supplied API key, + // but they do require an authenticated session. Browser and local providers + // remain available without a persisted configuration record. + const moduleChatProvidersMetadata = computed(() => { + return allChatProvidersMetadata.value.filter(metadata => + isProviderConfiguredForModule(metadata.id) + || (providerConfiguredBy(metadata.id) !== 'authentication' && shouldListProvider(metadata.id)) + || isProviderAvailableWithoutConfiguration(metadata.id), + ) + }) + + const moduleSpeechProvidersMetadata = computed(() => { + return allAudioSpeechProvidersMetadata.value.filter(metadata => + isProviderConfiguredForModule(metadata.id) + || isProviderAvailableWithoutConfiguration(metadata.id), + ) + }) + + const moduleTranscriptionProvidersMetadata = computed(() => { + return allAudioTranscriptionProvidersMetadata.value.filter(metadata => + isProviderConfiguredForModule(metadata.id) + || isProviderAvailableWithoutConfiguration(metadata.id), + ) + }) + + const moduleVisionProvidersMetadata = computed(() => { + return allVisionProvidersMetadata.value.filter(metadata => + isProviderConfiguredForModule(metadata.id) + || (providerConfiguredBy(metadata.id) !== 'authentication' && shouldListProvider(metadata.id)) + || isProviderAvailableWithoutConfiguration(metadata.id), + ) + }) + const persistedProvidersMetadata = computed(() => { return availableProvidersMetadata.value.filter(metadata => shouldListProvider(metadata.id)) }) @@ -925,6 +985,10 @@ export const useProviderStore = defineStore('provider', () => { configuredSpeechProvidersMetadata, configuredTranscriptionProvidersMetadata, configuredVisionProvidersMetadata, + moduleChatProvidersMetadata, + moduleSpeechProvidersMetadata, + moduleTranscriptionProvidersMetadata, + moduleVisionProvidersMetadata, persistedChatProvidersMetadata, persistedVisionProvidersMetadata, }