diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index a5526c406..0b26667f5 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -1,6 +1,7 @@ diff --git a/apps/stage-web/src/App.vue b/apps/stage-web/src/App.vue index 03cf027d6..c19fcd92f 100644 --- a/apps/stage-web/src/App.vue +++ b/apps/stage-web/src/App.vue @@ -12,6 +12,8 @@ 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 { 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' import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding' @@ -50,9 +52,23 @@ const { isDark } = useTheme() const cardStore = useAiriCardStore() useArtistryStore() useConsciousnessStore() +useHearingStore() useSpeechStore() useSettingsStageModel() useVisionStore() + +let stopAuthenticatedSetup: (() => void) | undefined +function registerAuthenticatedSetup() { + stopAuthenticatedSetup ??= authStore.onAuthenticated(async () => { + if (!syncedPinia.isLeader()) + return + + if (await configureAsDefaultsIfEmpty()) + await cardStore.persistActiveCardModuleSelections() + await onboardingStore.closeAfterAuthentication() + }) +} + const inferencePreload = useInferencePreload() const primaryColor = computed(() => { @@ -101,6 +117,7 @@ onMounted(async () => { await authStore.initialize() await displayModelsStore.initialize() await cardStore.initialize() + registerAuthenticatedSetup() if (onboardingStore.needsOnboarding) { onboardingStore.showingSetup = true @@ -120,6 +137,7 @@ onMounted(async () => { }) onUnmounted(() => { + stopAuthenticatedSetup?.() stopLeadershipListener() contextBridgeStore.dispose() }) diff --git a/apps/stage-web/vitest.config.ts b/apps/stage-web/vitest.config.ts new file mode 100644 index 000000000..2cfb1ba4a --- /dev/null +++ b/apps/stage-web/vitest.config.ts @@ -0,0 +1,11 @@ +import Vue from '@vitejs/plugin-vue' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [Vue()], + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue index b38eda77a..048fdabdf 100644 --- a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue +++ b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue @@ -24,7 +24,7 @@ import { DialogRoot, DialogTitle, } from 'reka-ui' -import { computed, ref, toRaw, watch } from 'vue' +import { computed, nextTick, ref, toRaw, watch } from 'vue' import { useI18n } from 'vue-i18n' import CardCreationTabArtistry from './tabs/CardCreationTabArtistry.vue' @@ -98,6 +98,8 @@ const selectedArtistrySpawnMode = ref<'bg' | 'widget' | 'inline' | 'bg_widget'>( const selectedArtistryAutonomousEnabled = ref(false) const selectedArtistryAutonomousThreshold = ref(70) const selectedArtistryConfigStr = ref('{\n \n}') +let isInitializingModuleSelections = false +let hasLoadedModuleOptions = false // Computed: available display model options const displayModelOptions = computed(() => @@ -193,25 +195,36 @@ const artistryProviderOptions = computed(() => { ] }) -// Load models for current providers on init -watch(() => [consciousnessProvider.value, visionProvider.value, speechProvider.value], async ([consProvider, visProvider, spProvider]) => { - if (consProvider) { - await consciousnessStore.loadModelsForProvider(consProvider) +async function loadSelectedModuleOptions() { + if (hasLoadedModuleOptions) + return + + hasLoadedModuleOptions = true + const loads: Promise[] = [] + if (selectedConsciousnessProvider.value) + loads.push(consciousnessStore.loadModelsForProvider(selectedConsciousnessProvider.value)) + + if (selectedVisionProvider.value) + loads.push(visionStore.loadModelsForProvider(selectedVisionProvider.value)) + + if (selectedSpeechProvider.value) { + loads.push(speechStore.loadVoicesForProvider(selectedSpeechProvider.value, selectedSpeechModel.value || undefined)) + if (providersStore.supportsModelListing(selectedSpeechProvider.value)) + loads.push(providersStore.fetchModelsForProvider(selectedSpeechProvider.value)) } - if (visProvider) { - await visionStore.loadModelsForProvider(visProvider) + + try { + await Promise.all(loads) } - if (spProvider) { - await speechStore.loadVoicesForProvider(spProvider) - if (providersStore.supportsModelListing(spProvider)) { - await providersStore.fetchModelsForProvider(spProvider) - } + catch (error) { + hasLoadedModuleOptions = false + throw error } -}, { immediate: true }) +} // Watch consciousness provider changes and reload models watch(selectedConsciousnessProvider, async (newProvider, oldProvider) => { - if (oldProvider !== undefined && newProvider !== oldProvider && newProvider) { + if (props.modelValue && !isInitializingModuleSelections && oldProvider !== undefined && newProvider !== oldProvider && newProvider) { await consciousnessStore.loadModelsForProvider(newProvider) // Reset model selection to default or empty selectedConsciousnessModel.value = '' @@ -220,7 +233,7 @@ watch(selectedConsciousnessProvider, async (newProvider, oldProvider) => { // Watch vision provider changes and reload models watch(selectedVisionProvider, async (newProvider, oldProvider) => { - if (oldProvider !== undefined && newProvider !== oldProvider && newProvider) { + if (props.modelValue && !isInitializingModuleSelections && oldProvider !== undefined && newProvider !== oldProvider && newProvider) { await visionStore.loadModelsForProvider(newProvider) selectedVisionModel.value = '' } @@ -228,7 +241,7 @@ watch(selectedVisionProvider, async (newProvider, oldProvider) => { // Watch speech provider changes and reload models/voices watch(selectedSpeechProvider, async (newProvider, oldProvider) => { - if (oldProvider !== undefined && newProvider !== oldProvider && newProvider) { + if (props.modelValue && !isInitializingModuleSelections && oldProvider !== undefined && newProvider !== oldProvider && newProvider) { await speechStore.loadVoicesForProvider(newProvider) if (providersStore.supportsModelListing(newProvider)) { await providersStore.fetchModelsForProvider(newProvider) @@ -243,7 +256,7 @@ watch(selectedSpeechProvider, async (newProvider, oldProvider) => { watch(selectedSpeechModel, async (newModel, oldModel) => { // Only reset if model actually changed and we're not initializing const provider = selectedSpeechProvider.value || speechProvider.value - if (oldModel !== undefined && newModel !== oldModel && provider) { + if (props.modelValue && !isInitializingModuleSelections && oldModel !== undefined && newModel !== oldModel && provider) { // Reload voices for the current provider await speechStore.loadVoicesForProvider(provider) @@ -287,6 +300,12 @@ const activeTab = computed({ }, }) +async function selectTab(tabId: string) { + activeTab.value = tabId + if (tabId === 'modules') + await loadSelectedModuleOptions() +} + // Reset active tab when dialog opens watch(() => props.modelValue, (isOpen) => { if (isOpen) { @@ -364,6 +383,22 @@ async function saveCard(card: Card, activate: boolean): Promise { // Cards data holders : // Initialize card data - load from existing card if in edit mode +function createCardDraft(): Card { + return { + name: t('settings.pages.card.creation.defaults.name'), + nickname: undefined, + version: '1.0', + description: '', + notes: undefined, + personality: t('settings.pages.card.creation.defaults.personality'), + scenario: t('settings.pages.card.creation.defaults.scenario'), + systemPrompt: t('settings.pages.card.creation.defaults.systemprompt'), + postHistoryInstructions: t('settings.pages.card.creation.defaults.posthistoryinstructions'), + greetings: [], + messageExample: [], + } +} + function initializeCard(): Card { // Extract existing card data if in edit mode const existingCard = (isEditMode.value && props.cardId) ? cardStore.getCard(props.cardId) : undefined @@ -401,30 +436,26 @@ function initializeCard(): Card { return { ...toRaw(existingCard) } } - return { - name: t('settings.pages.card.creation.defaults.name'), - nickname: undefined, - version: '1.0', - description: '', - notes: undefined, - personality: t('settings.pages.card.creation.defaults.personality'), - scenario: t('settings.pages.card.creation.defaults.scenario'), - systemPrompt: t('settings.pages.card.creation.defaults.systemprompt'), - postHistoryInstructions: t('settings.pages.card.creation.defaults.posthistoryinstructions'), - greetings: [], - messageExample: [], - } + return createCardDraft() } -const card = ref(initializeCard()) +const card = ref(createCardDraft()) // Reinitialize when cardId changes or dialog opens -watch(() => [props.modelValue, props.cardId], () => { - if (props.modelValue) { - showError.value = false - errorMessage.value = '' - card.value = initializeCard() - } +watch(() => [props.modelValue, props.cardId], async () => { + if (!props.modelValue) + return + + showError.value = false + errorMessage.value = '' + hasLoadedModuleOptions = false + isInitializingModuleSelections = true + card.value = initializeCard() + await nextTick() + isInitializingModuleSelections = false + + if (props.modelValue && activeTab.value === 'modules') + await loadSelectedModuleOptions() }) function makeComputed(key: T) { @@ -489,7 +520,7 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 dark:border-primary-400' : 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300', ]" - @click="activeTab = tab.id" + @click="selectTab(tab.id)" >
diff --git a/packages/stage-ui/src/stores/modules/airi-card.test.ts b/packages/stage-ui/src/stores/modules/airi-card.test.ts index 64c36f1bd..35e81ab82 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -5,6 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsStageModel } from '../settings/stage-model' import { useAiriCardStore } from './airi-card' +import { useConsciousnessStore } from './consciousness' +import { useSpeechStore } from './speech' +import { useVisionStore } from './vision' const { resetArtistryToGlobal } = vi.hoisted(() => ({ resetArtistryToGlobal: vi.fn(), @@ -103,6 +106,105 @@ describe('airi-card store', () => { resetArtistryToGlobal.mockClear() }) + // ROOT CAUSE: + // + // Authentication installed the official module defaults before card startup + // completed. Initializing the default card then assigned its missing module + // fields as empty values and erased those defaults. + // + // We fixed this by applying only module fields that a card actually owns. + it('keeps runtime module selections when the active card omits them', async () => { + const consciousnessStore = useConsciousnessStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const cardStore = useAiriCardStore() + + await cardStore.initialize() + + expect(consciousnessStore.activeProvider).toBe('mock-consciousness-provider') + expect(consciousnessStore.activeModel).toBe('mock-consciousness-model') + expect(speechStore.activeSpeechProvider).toBe('mock-speech-provider') + expect(speechStore.activeSpeechModel).toBe('mock-speech-model') + expect(speechStore.activeSpeechVoiceId).toBe('mock-speech-voice') + expect(visionStore.activeProvider).toBe('mock-vision-provider') + expect(visionStore.activeModel).toBe('mock-vision-model') + }) + + // ROOT CAUSE: + // + // Each Electron window called the synchronized initialize action. The leader + // applied the active card again for every new window. An older card selection + // then replaced module defaults that the authentication hook had configured. + // + // We fixed this by making card initialization idempotent in the leader. + it('does not reapply active card settings for a second window', async () => { + const consciousnessStore = useConsciousnessStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const cardStore = useAiriCardStore() + await cardStore.initialize() + + consciousnessStore.activeProvider = 'official-provider' + consciousnessStore.activeModel = 'auto' + speechStore.activeSpeechProvider = 'official-provider-speech' + speechStore.activeSpeechModel = 'auto' + visionStore.activeProvider = 'vision-official-provider' + visionStore.activeModel = 'auto' + + await cardStore.initialize() + + expect(consciousnessStore.activeProvider).toBe('official-provider') + expect(consciousnessStore.activeModel).toBe('auto') + expect(speechStore.activeSpeechProvider).toBe('official-provider-speech') + expect(speechStore.activeSpeechModel).toBe('auto') + expect(visionStore.activeProvider).toBe('vision-official-provider') + expect(visionStore.activeModel).toBe('auto') + }) + + // ROOT CAUSE: + // + // The authentication hook updated the runtime module stores, but the active + // card kept its older empty selections. A later card activation restored + // speech-noop and erased the authenticated defaults. + // + // We fixed this by persisting the resolved runtime selections in one card + // command without applying the card back to the runtime. + it('persists runtime module selections without reapplying the active card', async () => { + const consciousnessStore = useConsciousnessStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const cardStore = useAiriCardStore() + await cardStore.initialize() + resetArtistryToGlobal.mockClear() + + consciousnessStore.activeProvider = 'official-provider' + consciousnessStore.activeModel = 'auto' + speechStore.activeSpeechProvider = 'official-provider-speech' + speechStore.activeSpeechModel = 'auto' + speechStore.activeSpeechVoiceId = '' + visionStore.activeProvider = 'vision-official-provider' + visionStore.activeModel = 'auto' + + await expect(cardStore.persistActiveCardModuleSelections()).resolves.toBe(true) + + expect(cardStore.activeCard?.extensions.airi.modules.consciousness).toEqual({ + provider: 'official-provider', + model: 'auto', + }) + expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({ + provider: 'official-provider-speech', + model: 'auto', + voice_id: '', + }) + expect(cardStore.activeCard?.extensions.airi.modules.vision).toEqual({ + provider: 'vision-official-provider', + model: 'auto', + }) + expect(resetArtistryToGlobal).not.toHaveBeenCalled() + + await expect(cardStore.persistActiveCardModuleSelections()).resolves.toBe(false) + }) + // ROOT CAUSE: // // A synchronized state snapshot replaced `activeCardId`. The old watcher diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index faf6cf629..123170822 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -45,6 +45,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { // would create a second cross-window state channel and echo cloned maps. const cards = useLocalStorageManualReset>('airi-cards', new Map(), { listenToStorageChanges: false }) const activeCardId = useLocalStorageManualReset('airi-card-active-id', 'default', { listenToStorageChanges: false }) + let initialized = false const activeCard = computed(() => cards.value.get(activeCardId.value)) function useRuntimeModuleStores() { @@ -169,6 +170,54 @@ export const useAiriCardStore = defineStore('airi-card', () => { return updated } + /** + * Persists the current inference selections in the active card. + * + * This command snapshots runtime state after a higher-level operation, such + * as authenticated default setup. It deliberately does not apply the card + * back to the runtime, so one persistence write cannot start another module + * transition. + */ + async function persistActiveCardModuleSelections() { + const card = cards.value.get(activeCardId.value) + if (!card) + return false + + const { + consciousness, + speech, + vision, + } = useRuntimeModuleStores() + const modules = card.extensions?.airi?.modules + const alreadyPersisted = modules?.consciousness?.provider === consciousness.activeProvider + && modules.consciousness.model === consciousness.activeModel + && modules?.speech?.provider === speech.activeSpeechProvider + && modules.speech.model === speech.activeSpeechModel + && modules.speech.voice_id === speech.activeSpeechVoiceId + && modules?.vision?.provider === vision.activeProvider + && modules.vision.model === vision.activeModel + + if (alreadyPersisted) + return false + + return updateActiveCardModules(({ modules }) => ({ + consciousness: { + provider: consciousness.activeProvider, + model: consciousness.activeModel, + }, + speech: { + ...modules.speech, + provider: speech.activeSpeechProvider, + model: speech.activeSpeechModel, + voice_id: speech.activeSpeechVoiceId, + }, + vision: { + provider: vision.activeProvider, + model: vision.activeModel, + }, + })) + } + function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension { const { artistry, @@ -312,6 +361,12 @@ export const useAiriCardStore = defineStore('airi-card', () => { } async function initialize() { + // This synchronized action executes in the leader. Each window calls it, + // but only the first call can apply persisted card settings to the runtime. + if (initialized) + return + + initialized = true if (!cards.value.has('default')) { cards.value.set('default', newAiriCard({ name: 'ReLU', @@ -363,15 +418,25 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (!extension) return - consciousness.activeProvider = extension?.modules?.consciousness?.provider - consciousness.activeModel = extension?.modules?.consciousness?.model + const consciousnessSettings = extension.modules?.consciousness + if (consciousnessSettings?.provider) + consciousness.activeProvider = consciousnessSettings.provider + if (consciousnessSettings?.model) + consciousness.activeModel = consciousnessSettings.model - vision.activeProvider = extension?.modules?.vision?.provider - vision.activeModel = extension?.modules?.vision?.model + const visionSettings = extension.modules?.vision + if (visionSettings?.provider) + vision.activeProvider = visionSettings.provider + if (visionSettings?.model) + vision.activeModel = visionSettings.model - speech.activeSpeechProvider = extension?.modules?.speech?.provider - speech.activeSpeechModel = extension?.modules?.speech?.model - speech.activeSpeechVoiceId = extension?.modules?.speech?.voice_id + const speechSettings = extension.modules?.speech + if (speechSettings?.provider) + speech.activeSpeechProvider = speechSettings.provider + if (speechSettings?.model) + speech.activeSpeechModel = speechSettings.model + if (speechSettings?.voice_id) + speech.activeSpeechVoiceId = speechSettings.voice_id // Apply body model if the card has a display model configured. // NOTICE: must set via store property directly (not storeToRefs .value) so Pinia's @@ -393,6 +458,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { } function resetState() { + initialized = false cards.reset() activeCardId.reset() } @@ -406,6 +472,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { updateCard, updateActiveCardConsciousness, updateActiveCardDisplayModel, + persistActiveCardModuleSelections, updateActiveCardSpeech, updateActiveCardVision, getCard, @@ -448,6 +515,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { 'addCard', 'initialize', 'removeCard', + 'persistActiveCardModuleSelections', 'updateActiveCardConsciousness', 'updateActiveCardDisplayModel', 'updateActiveCardSpeech', diff --git a/packages/stage-ui/src/stores/modules/consciousness.test.ts b/packages/stage-ui/src/stores/modules/consciousness.test.ts index 929f79de8..b8bf62e2c 100644 --- a/packages/stage-ui/src/stores/modules/consciousness.test.ts +++ b/packages/stage-ui/src/stores/modules/consciousness.test.ts @@ -1,5 +1,8 @@ +// @vitest-environment jsdom + import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' @@ -14,6 +17,7 @@ vi.mock('vue-i18n', () => ({ describe('consciousness store provider selection', () => { beforeEach(() => { + localStorage.clear() setActivePinia(createPinia()) }) @@ -121,4 +125,31 @@ describe('consciousness store provider selection', () => { expect(store.activeModel).toBe('gpt-4o-mini') }) + + // ROOT CAUSE: + // + // The store used Pinia synchronization and storage event synchronization + // for the same state. An incoming Pinia snapshot changed the provider and + // reset the model. A stale storage event then restored the previous model. + // Both windows published each reflected change and created an endless loop. + // + // We fixed this by keeping localStorage as one-way persistence. Pinia is the + // only channel that can update live state across windows. + it('does not apply storage events as a second cross-window state channel', async () => { + const store = useConsciousnessStore() + + store.activeProvider = 'official-provider' + store.activeModel = 'auto' + await nextTick() + + window.dispatchEvent(new StorageEvent('storage', { + key: 'settings/consciousness/active-model', + newValue: '', + storageArea: localStorage, + })) + await nextTick() + await nextTick() + + expect(store.activeModel).toBe('auto') + }) }) diff --git a/packages/stage-ui/src/stores/modules/consciousness.ts b/packages/stage-ui/src/stores/modules/consciousness.ts index e5b43972d..f3b63d387 100644 --- a/packages/stage-ui/src/stores/modules/consciousness.ts +++ b/packages/stage-ui/src/stores/modules/consciousness.ts @@ -10,10 +10,15 @@ import { useProviderStore } from '../providers/provider' export const useConsciousnessStore = defineStore('consciousness', () => { const providersStore = useProviderStore() + // Pinia synchronization owns live cross-window state. localStorage remains + // durable persistence, but storage events must not reflect state back into + // the store and publish another synchronized snapshot. + const persistenceOptions = { listenToStorageChanges: false } + // State - const activeProvider = useLocalStorageManualReset('settings/consciousness/active-provider', '') - const activeModel = useLocalStorageManualReset('settings/consciousness/active-model', '') - const activeCustomModelName = useLocalStorageManualReset('settings/consciousness/active-custom-model', '') + const activeProvider = useLocalStorageManualReset('settings/consciousness/active-provider', '', persistenceOptions) + const activeModel = useLocalStorageManualReset('settings/consciousness/active-model', '', persistenceOptions) + const activeCustomModelName = useLocalStorageManualReset('settings/consciousness/active-custom-model', '', persistenceOptions) const expandedDescriptions = refManualReset>(() => ({})) const modelSearchQuery = refManualReset('') diff --git a/packages/stage-ui/src/stores/modules/default.test.ts b/packages/stage-ui/src/stores/modules/default.test.ts new file mode 100644 index 000000000..e2524819c --- /dev/null +++ b/packages/stage-ui/src/stores/modules/default.test.ts @@ -0,0 +1,257 @@ +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 { useProviderConfigStore } from '../providers/config' +import { useProviderStore } from '../providers/provider' +import { useConsciousnessStore } from './consciousness' +import { configureAsDefaultsIfEmpty } from './default' +import { useHearingStore } from './hearing' +import { useSpeechStore } from './speech' +import { useVisionStore } from './vision' + +vi.mock('../../composables/use-analytics', () => ({ + useAnalytics: () => ({ + trackAudioDeviceUnavailable: vi.fn(), + trackMicrophonePermissionDenied: vi.fn(), + trackSttFailed: vi.fn(), + trackSttStarted: vi.fn(), + trackSttSucceeded: vi.fn(), + trackVoiceInputStarted: vi.fn(), + }), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + locale: { value: 'en-US' }, + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})) + +describe('official provider module defaults', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + recommended: {}, + voices: [], + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }))) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + // ROOT CAUSE: + // + // The previous auth synchronization reacted to each module independently. + // One login could repeat provider setup and could overwrite part of a + // user's configuration. The replacement command now changes only empty or + // incomplete official modules and keeps custom modules unchanged. + it('applies each official provider once when all module selections are empty', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerStore = useProviderStore() + const providerConfigStore = useProviderConfigStore() + const initializeProvider = vi.spyOn(providerStore, 'initializeProvider') + const forceProviderConfigured = vi.spyOn(providerStore, 'forceProviderConfigured') + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + + expect(consciousnessStore.activeProvider).toBe('official-provider') + expect(consciousnessStore.activeModel).toBe('auto') + expect(hearingStore.activeTranscriptionProvider).toBe(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(hearingStore.activeTranscriptionModel).toBe('auto') + expect(speechStore.activeSpeechProvider).toBe(OFFICIAL_SPEECH_PROVIDER_ID) + expect(speechStore.activeSpeechModel).toBe('auto') + expect(visionStore.activeProvider).toBe('vision-official-provider') + expect(visionStore.activeModel).toBe('auto') + + 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') + 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) + expect(providerConfigStore.addedProviders['vision-official-provider']).toBe(true) + expect(initializeProvider).toHaveBeenCalledTimes(4) + expect(forceProviderConfigured).toHaveBeenCalledTimes(4) + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(false) + expect(initializeProvider).toHaveBeenCalledTimes(4) + expect(forceProviderConfigured).toHaveBeenCalledTimes(4) + }) + + // ROOT CAUSE: + // + // A provider can be empty while an old model or voice remains in storage. + // The settings page hides these child values, but the default command treated + // them as a configured module and skipped every official provider. + // + // We fixed this by using provider selection as the module ownership signal. + it('applies defaults when only stale child selections remain', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerConfigStore = useProviderConfigStore() + consciousnessStore.customModelName = 'my-model' + hearingStore.activeTranscriptionModel = 'old-transcription-model' + speechStore.activeSpeechModel = 'old-speech-model' + speechStore.activeSpeechVoiceId = 'old-speech-voice' + visionStore.customModelName = 'old-vision-model' + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + + expect(consciousnessStore.activeProvider).toBe('official-provider') + expect(consciousnessStore.activeModel).toBe('auto') + expect(consciousnessStore.customModelName).toBe('') + expect(hearingStore.activeTranscriptionProvider).toBe(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(hearingStore.activeTranscriptionModel).toBe('auto') + expect(hearingStore.activeCustomModelName).toBe('') + expect(speechStore.activeSpeechProvider).toBe(OFFICIAL_SPEECH_PROVIDER_ID) + expect(speechStore.activeSpeechModel).toBe('auto') + expect(speechStore.activeSpeechVoiceId).toBe('') + expect(visionStore.activeProvider).toBe('vision-official-provider') + expect(visionStore.activeModel).toBe('auto') + expect(visionStore.customModelName).toBe('') + expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') + }) + + // ROOT CAUSE: + // + // The default command returned when any module had a provider. A previous + // run could leave one official module configured while all other modules + // and provider records stayed empty. Later authentication hooks then kept + // the partial state forever. + // + // We fixed this by repairing official modules and configuring each empty + // module independently. + it('repairs a partial official configuration after authentication', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerStore = useProviderStore() + const providerConfigStore = useProviderConfigStore() + const initializeProvider = vi.spyOn(providerStore, 'initializeProvider') + const forceProviderConfigured = vi.spyOn(providerStore, 'forceProviderConfigured') + hearingStore.activeTranscriptionProvider = OFFICIAL_TRANSCRIPTION_PROVIDER_ID + hearingStore.activeTranscriptionModel = 'auto' + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + + expect(consciousnessStore.activeProvider).toBe('official-provider') + expect(consciousnessStore.activeModel).toBe('auto') + expect(hearingStore.activeTranscriptionProvider).toBe(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(hearingStore.activeTranscriptionModel).toBe('auto') + expect(speechStore.activeSpeechProvider).toBe(OFFICIAL_SPEECH_PROVIDER_ID) + expect(speechStore.activeSpeechModel).toBe('auto') + expect(visionStore.activeProvider).toBe('vision-official-provider') + expect(visionStore.activeModel).toBe('auto') + 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') + expect(initializeProvider).toHaveBeenCalledTimes(4) + expect(forceProviderConfigured).toHaveBeenCalledTimes(4) + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(false) + expect(initializeProvider).toHaveBeenCalledTimes(4) + expect(forceProviderConfigured).toHaveBeenCalledTimes(4) + }) + + it('fills an empty model for an existing official provider', 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 = OFFICIAL_SPEECH_PROVIDER_ID + speechStore.activeSpeechModel = '' + speechStore.activeSpeechVoiceId = 'stale-voice' + 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(OFFICIAL_SPEECH_PROVIDER_ID) + expect(speechStore.activeSpeechModel).toBe('auto') + expect(speechStore.activeSpeechVoiceId).toBe('') + expect(visionStore.activeProvider).toBe('custom-vision') + expect(visionStore.activeModel).toBe('custom-vision-model') + 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() + }) + + it('adds a configured official provider to the visible provider list', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerStore = useProviderStore() + const providerConfigStore = useProviderConfigStore() + const forceProviderConfigured = vi.spyOn(providerStore, 'forceProviderConfigured') + consciousnessStore.activeProvider = 'official-provider' + consciousnessStore.activeModel = 'auto' + 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' + providerConfigStore.ensureProvider('official-provider', 'official-provider') + providerConfigStore.setProviderStatus('official-provider', 'configured') + + expect(providerConfigStore.addedProviders['official-provider']).toBeUndefined() + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + + expect(providerConfigStore.providers['official-provider']?.status).toBe('configured') + expect(providerConfigStore.addedProviders['official-provider']).toBe(true) + expect(forceProviderConfigured).toHaveBeenCalledOnce() + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(false) + expect(forceProviderConfigured).toHaveBeenCalledOnce() + }) + + it('keeps a custom module and configures the other empty modules', async () => { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + const providerConfigStore = useProviderConfigStore() + consciousnessStore.activeProvider = 'custom-provider' + consciousnessStore.activeModel = 'custom-model' + + await expect(configureAsDefaultsIfEmpty()).resolves.toBe(true) + + expect(consciousnessStore.activeProvider).toBe('custom-provider') + expect(consciousnessStore.activeModel).toBe('custom-model') + expect(hearingStore.activeTranscriptionProvider).toBe(OFFICIAL_TRANSCRIPTION_PROVIDER_ID) + expect(hearingStore.activeTranscriptionModel).toBe('auto') + expect(speechStore.activeSpeechProvider).toBe(OFFICIAL_SPEECH_PROVIDER_ID) + 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_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') + }) +}) diff --git a/packages/stage-ui/src/stores/modules/default.ts b/packages/stage-ui/src/stores/modules/default.ts new file mode 100644 index 000000000..b684cb0ad --- /dev/null +++ b/packages/stage-ui/src/stores/modules/default.ts @@ -0,0 +1,114 @@ +import { OFFICIAL_SPEECH_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 { useHearingStore } from './hearing' +import { useSpeechStore } from './speech' +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' }, + hearing: { provider: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, model: 'auto' }, + speech: { provider: OFFICIAL_SPEECH_PROVIDER_ID, model: 'auto' }, + vision: { provider: 'vision-official-provider', 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. + * + * @returns `true` when the command changes module or provider state. + */ +export async function configureAsDefaultsIfEmpty(): Promise { + const consciousnessStore = useConsciousnessStore() + const hearingStore = useHearingStore() + const speechStore = useSpeechStore() + const visionStore = useVisionStore() + + const needsConsciousnessDefault = !consciousnessStore.activeProvider + const usesOfficialConsciousness = consciousnessStore.activeProvider === officialModuleDefaults.consciousness.provider + const needsHearingDefault = !hearingStore.activeTranscriptionProvider + const usesOfficialHearing = hearingStore.activeTranscriptionProvider === officialModuleDefaults.hearing.provider + const needsSpeechDefault = !speechStore.activeSpeechProvider || speechStore.activeSpeechProvider === 'speech-noop' + const usesOfficialSpeech = speechStore.activeSpeechProvider === officialModuleDefaults.speech.provider + const needsVisionDefault = !visionStore.activeProvider + const usesOfficialVision = visionStore.activeProvider === officialModuleDefaults.vision.provider + + 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) + + let changed = false + for (const provider of managedOfficialProviders) { + if (providerConfigStore.configuredProviders[provider] && providerConfigStore.addedProviders[provider]) + continue + + await providerStore.initializeProvider(provider) + await providerStore.forceProviderConfigured(provider) + changed = true + } + + if (needsConsciousnessDefault) { + consciousnessStore.customModelName = '' + consciousnessStore.activeProvider = officialModuleDefaults.consciousness.provider + consciousnessStore.activeModel = officialModuleDefaults.consciousness.model + changed = true + } + else if (usesOfficialConsciousness && !consciousnessStore.activeModel) { + consciousnessStore.customModelName = '' + consciousnessStore.activeModel = officialModuleDefaults.consciousness.model + changed = true + } + + if (needsHearingDefault) { + hearingStore.activeCustomModelName = '' + hearingStore.activeTranscriptionProvider = officialModuleDefaults.hearing.provider + hearingStore.activeTranscriptionModel = officialModuleDefaults.hearing.model + changed = true + } + else if (usesOfficialHearing && !hearingStore.activeTranscriptionModel) { + hearingStore.activeCustomModelName = '' + hearingStore.activeTranscriptionModel = officialModuleDefaults.hearing.model + changed = true + } + + if (needsSpeechDefault) { + speechStore.activeSpeechVoiceId = '' + speechStore.activeSpeechProvider = officialModuleDefaults.speech.provider + speechStore.activeSpeechModel = officialModuleDefaults.speech.model + changed = true + } + else if (usesOfficialSpeech && !speechStore.activeSpeechModel) { + speechStore.activeSpeechVoiceId = '' + speechStore.activeSpeechModel = officialModuleDefaults.speech.model + changed = true + } + + if (needsVisionDefault) { + visionStore.customModelName = '' + visionStore.activeProvider = officialModuleDefaults.vision.provider + visionStore.activeModel = officialModuleDefaults.vision.model + changed = true + } + else if (usesOfficialVision && !visionStore.activeModel) { + visionStore.customModelName = '' + visionStore.activeModel = officialModuleDefaults.vision.model + changed = true + } + + return changed +} diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 0b638338c..5f78830cd 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -324,14 +324,18 @@ export const useHearingStore = defineStore('hearing-store', () => { trackVoiceInputStarted, } = useAnalytics() + // Pinia synchronization owns live cross-window state. localStorage only + // loads and saves durable values for this synchronized store. + const persistenceOptions = { listenToStorageChanges: false } + // State - const activeTranscriptionProvider = useLocalStorageManualReset('settings/hearing/active-provider', '') - const activeTranscriptionModel = useLocalStorageManualReset('settings/hearing/active-model', '') - const activeCustomModelName = useLocalStorageManualReset('settings/hearing/active-custom-model', '') + const activeTranscriptionProvider = useLocalStorageManualReset('settings/hearing/active-provider', '', persistenceOptions) + const activeTranscriptionModel = useLocalStorageManualReset('settings/hearing/active-model', '', persistenceOptions) + const activeCustomModelName = useLocalStorageManualReset('settings/hearing/active-custom-model', '', persistenceOptions) const transcriptionModelSearchQuery = refManualReset('') - const autoSendEnabled = useLocalStorageManualReset('settings/hearing/auto-send-enabled', false) - const autoSendDelay = useLocalStorageManualReset('settings/hearing/auto-send-delay', 2000) // Default 2 seconds - const confidenceThreshold = useLocalStorageManualReset('settings/hearing/confidence-threshold', CONFIDENCE_THRESHOLD_DISABLED) + const autoSendEnabled = useLocalStorageManualReset('settings/hearing/auto-send-enabled', false, persistenceOptions) + const autoSendDelay = useLocalStorageManualReset('settings/hearing/auto-send-delay', 2000, persistenceOptions) // Default 2 seconds + const confidenceThreshold = useLocalStorageManualReset('settings/hearing/confidence-threshold', CONFIDENCE_THRESHOLD_DISABLED, persistenceOptions) const verboseJsonNotSupported = ref(false) watch(activeTranscriptionProvider, () => { diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index efb9eacdc..ef80e1efc 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -1,7 +1,9 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, providerOfficialSpeech } from '../../libs/providers/providers/official' +import { useProviderConfigStore } from '../providers/config' import { useProviderStore } from '../providers/provider' import { toSignedPercent, useSpeechStore } from './speech' @@ -35,6 +37,92 @@ describe('speech store helpers', () => { expect(toSignedPercent(0)).toBe('0%') }) + // ROOT CAUSE: + // + // The speech store watched its model-list projection even when no UI used + // that projection. Each synced provider snapshot invalidated the projection. + // The watcher then called getModelsForProvider twice when the cache was empty. + // + // We fixed this by keeping model selection behind explicit operations. A UI + // consumer can still read providerModels when it needs the cached catalog. + it('does not query the model cache when only provider state changes', async () => { + const providersStore = useProviderStore() + vi.spyOn(providersStore, 'listProviderVoices').mockResolvedValue([]) + const speechStore = useSpeechStore() + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + await nextTick() + + let modelQueries = 0 + providersStore.$onAction(({ name }) => { + if (name === 'getModelsForProvider') + modelQueries += 1 + }) + + providersStore.providerRuntimeState = {} + await nextTick() + + expect(modelQueries).toBe(0) + }) + + // ROOT CAUSE: + // + // A synced snapshot replaced the empty voice catalog with another empty + // object. The voice watcher then assigned undefined to an undefined ref. + // refManualReset reported that no-op assignment as another Pinia mutation. + // + // We fixed this by writing the selected voice only when a matching voice + // exists and its identity differs from the current selection. + it('does not publish a second mutation for an unresolved voice', async () => { + const providersStore = useProviderStore() + vi.spyOn(providersStore, 'listProviderVoices').mockResolvedValue([]) + const speechStore = useSpeechStore() + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speechStore.activeSpeechVoiceId = 'missing-voice' + speechStore.activeSpeechVoice = undefined + speechStore.availableVoices = {} + await nextTick() + + let mutations = 0 + speechStore.$subscribe(() => mutations += 1, { flush: 'sync' }) + + speechStore.availableVoices = {} + await nextTick() + + expect(mutations).toBe(1) + }) + + // ROOT CAUSE: + // + // Synced stores arrive in separate snapshots. The speech store can receive + // its selected provider before the matching provider configuration snapshot. + // A metadata watcher treated this temporary state as provider deletion and + // replaced the synchronized selection with speech-noop. + // + // We fixed this by keeping provider selection command-driven. A provider + // configuration snapshot no longer edits the speech module selection. + it('keeps the selected provider while provider snapshots are incomplete', async () => { + const providersStore = useProviderStore() + const providerConfigStore = useProviderConfigStore() + vi.spyOn(providersStore, 'listProviderVoices').mockResolvedValue([]) + const speechStore = useSpeechStore() + providersStore.initializeProvider(OFFICIAL_SPEECH_PROVIDER_ID) + providersStore.forceProviderConfigured(OFFICIAL_SPEECH_PROVIDER_ID) + speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID + speechStore.activeSpeechModel = 'auto' + await vi.waitFor(() => { + expect(providersStore.configuredSpeechProvidersMetadata.map(provider => provider.id)).toContain(OFFICIAL_SPEECH_PROVIDER_ID) + }) + + providersStore.providerRuntimeState = {} + providerConfigStore.providers = {} + await vi.waitFor(() => { + expect(providersStore.configuredSpeechProvidersMetadata.map(provider => provider.id)).not.toContain(OFFICIAL_SPEECH_PROVIDER_ID) + }) + + expect(speechStore.activeSpeechProvider).toBe(OFFICIAL_SPEECH_PROVIDER_ID) + expect(speechStore.activeSpeechModel).toBe('auto') + }) + /** * @example * speechStore.resolveSpeechInput({ text, voice, providerConfig: { voice: 'plain' } }) diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index f4bbffa40..83459baa1 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -7,6 +7,7 @@ import { errorMessageFrom } from '@moeru/std' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { refManualReset } from '@vueuse/core' import { generateSpeech } from '@xsai/generate-speech' +import { isEqual } from 'es-toolkit' import { defineStore, storeToRefs } from 'pinia' import { computed, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -50,15 +51,19 @@ export const useSpeechStore = defineStore('speech', () => { const { allAudioSpeechProvidersMetadata } = storeToRefs(providersStore) const { locale } = useI18n() + // Pinia synchronization owns live cross-window state. localStorage only + // loads and saves durable values for this synchronized store. + const persistenceOptions = { listenToStorageChanges: false } + // State - const activeSpeechProvider = useLocalStorageManualReset('settings/speech/active-provider', 'speech-noop') - const activeSpeechModel = useLocalStorageManualReset('settings/speech/active-model', '') - const activeSpeechVoiceId = useLocalStorageManualReset('settings/speech/voice', '') + const activeSpeechProvider = useLocalStorageManualReset('settings/speech/active-provider', 'speech-noop', persistenceOptions) + const activeSpeechModel = useLocalStorageManualReset('settings/speech/active-model', '', persistenceOptions) + const activeSpeechVoiceId = useLocalStorageManualReset('settings/speech/voice', '', persistenceOptions) const activeSpeechVoice = refManualReset(undefined) - const pitch = useLocalStorageManualReset('settings/speech/pitch', 0) - const rate = useLocalStorageManualReset('settings/speech/rate', 1) - const ssmlEnabled = useLocalStorageManualReset('settings/speech/ssml-enabled', false) + const pitch = useLocalStorageManualReset('settings/speech/pitch', 0, persistenceOptions) + const rate = useLocalStorageManualReset('settings/speech/rate', 1, persistenceOptions) + const ssmlEnabled = useLocalStorageManualReset('settings/speech/ssml-enabled', false, persistenceOptions) const isLoadingSpeechProviderVoices = refManualReset(false) const speechProviderError = refManualReset(null) const availableVoices = refManualReset>(() => ({})) @@ -215,34 +220,6 @@ export const useSpeechStore = defineStore('speech', () => { activeSpeechProvider.value = 'speech-noop' } - watch( - () => providersStore.configuredSpeechProvidersMetadata.map(provider => provider.id), - (configuredProviderIds) => { - if (!activeSpeechProvider.value || activeSpeechProvider.value === 'speech-noop') - return - - // NOTICE: only reset when the provider has actually been validated and found unconfigured. - // Skip reset if validation hasn't run yet (validatedCredentialHash is undefined) - // to avoid a race condition where immediate watcher fires before async validation completes. - const runtimeState = providersStore.providerRuntimeState[activeSpeechProvider.value] - if (runtimeState && runtimeState.validatedCredentialHash === undefined) - return - - // NOTICE: clear stale selection when the currently selected speech provider - // is no longer configured to avoid implicit fallback behavior from persisted state. - // NOTE: Do NOT use { immediate: true } here — providers.ts validates credentials - // asynchronously on startup, so firing immediately would see an empty - // configuredSpeechProvidersMetadata and incorrectly reset activeSpeechProvider - // to 'speech-noop', permanently wiping the persisted selection from localStorage. - if (!configuredProviderIds.includes(activeSpeechProvider.value)) { - activeSpeechProvider.value = 'speech-noop' - activeSpeechModel.value = '' - activeSpeechVoiceId.value = '' - activeSpeechVoice.value = undefined - } - }, - ) - setupOfficialSpeechAutoPick({ activeSpeechProvider, activeSpeechVoiceId, @@ -250,34 +227,30 @@ export const useSpeechStore = defineStore('speech', () => { uiLocale: locale, }) - watch(providerModels, () => { - ensureActiveSpeechModel() - }) - watch([activeSpeechVoiceId, availableVoices], ([voiceId, voices]) => { - if (voiceId) { - // For OpenAI Compatible, create a custom voice object (no voices available from API) - if (activeSpeechProvider.value === 'openai-compatible-audio-speech') { - // Always update to match voiceId (in case it changed) - activeSpeechVoice.value = { - id: voiceId, - name: voiceId, - description: voiceId, - previewURL: '', - languages: [{ code: 'en', title: 'English' }], - provider: activeSpeechProvider.value, - gender: 'neutral', - } - } - else { - // For other providers, find voice in available voices - const foundVoice = voices[activeSpeechProvider.value]?.find(voice => voice.id === voiceId) - // Only update if we found a voice, or if activeSpeechVoice is not set - if (foundVoice || !activeSpeechVoice.value) { - activeSpeechVoice.value = foundVoice - } + if (!voiceId) + return + + let nextVoice: VoiceInfo | undefined + if (activeSpeechProvider.value === 'openai-compatible-audio-speech') { + nextVoice = { + id: voiceId, + name: voiceId, + description: voiceId, + previewURL: '', + languages: [{ code: 'en', title: 'English' }], + provider: activeSpeechProvider.value, + gender: 'neutral', } } + else { + nextVoice = voices[activeSpeechProvider.value]?.find(voice => voice.id === voiceId) + } + + if (!nextVoice || isEqual(activeSpeechVoice.value, nextVoice)) + return + + activeSpeechVoice.value = nextVoice }, { immediate: true, deep: true, diff --git a/packages/stage-ui/src/stores/modules/vision/store.ts b/packages/stage-ui/src/stores/modules/vision/store.ts index 282f36808..492cf5e13 100644 --- a/packages/stage-ui/src/stores/modules/vision/store.ts +++ b/packages/stage-ui/src/stores/modules/vision/store.ts @@ -10,10 +10,14 @@ import { useProviderStore } from '../../providers/provider' export const useVisionStore = defineStore('vision', () => { const providersStore = useProviderStore() - const activeProvider = useLocalStorageManualReset('settings/vision/active-provider', '') - const activeModel = useLocalStorageManualReset('settings/vision/active-model', '') - const activeCustomModelName = useLocalStorageManualReset('settings/vision/active-custom-model', '') - const ollamaThinkingEnabled = useLocalStorageManualReset('settings/vision/ollama-thinking-enabled', false) + // Pinia synchronization owns live cross-window state. localStorage only + // loads and saves durable values for this synchronized store. + const persistenceOptions = { listenToStorageChanges: false } + + const activeProvider = useLocalStorageManualReset('settings/vision/active-provider', '', persistenceOptions) + const activeModel = useLocalStorageManualReset('settings/vision/active-model', '', persistenceOptions) + const activeCustomModelName = useLocalStorageManualReset('settings/vision/active-custom-model', '', persistenceOptions) + const ollamaThinkingEnabled = useLocalStorageManualReset('settings/vision/ollama-thinking-enabled', false, persistenceOptions) const modelSearchQuery = refManualReset('') const supportsModelListing = computed(() => { diff --git a/packages/stage-ui/src/stores/onboarding.test.ts b/packages/stage-ui/src/stores/onboarding.test.ts new file mode 100644 index 000000000..8984e5cad --- /dev/null +++ b/packages/stage-ui/src/stores/onboarding.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useOnboardingStore } from './onboarding' + +vi.mock('./auth', async () => { + const { defineStore } = await import('pinia') + + return { + useAuthStore: defineStore('auth', { + state: () => ({ + isAuthenticated: false, + token: null, + }), + }), + } +}) + +vi.mock('./providers/config', async () => { + const { defineStore } = await import('pinia') + + return { + useProviderConfigStore: defineStore('provider-config', { + state: () => ({ + configuredProviders: {}, + }), + actions: { + getProviderConfig: () => undefined, + }, + }), + } +}) + +describe('onboarding store', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + }) + + // ROOT CAUSE: + // + // The standalone onboarding renderer previously depended on a localStorage + // event to discover that another renderer had completed authentication. Once + // storage stopped acting as a state bus, the BrowserWindow stayed open. + // + // The authenticated command now persists completion and publishes a + // monotonic close request through synchronized Pinia state. + it('publishes a close request after authentication', () => { + const store = useOnboardingStore() + + store.closeAfterAuthentication() + + expect(store.hasCompletedSetup).toBe(true) + expect(store.hasSkippedSetup).toBe(false) + expect(store.closeRequestId).toBe(1) + expect(store.$state).not.toHaveProperty('closeRequestId') + expect(store.$state).not.toHaveProperty('hasCompletedSetup') + expect(localStorage.getItem('onboarding/completed')).toBe('true') + expect(localStorage.getItem('onboarding/skipped')).toBe('false') + }) +}) diff --git a/packages/stage-ui/src/stores/onboarding.ts b/packages/stage-ui/src/stores/onboarding.ts index 08efb88d3..e79849771 100644 --- a/packages/stage-ui/src/stores/onboarding.ts +++ b/packages/stage-ui/src/stores/onboarding.ts @@ -1,4 +1,5 @@ -import { useLocalStorage } from '@vueuse/core' +import type {} from 'pinia-plugin-synced' + import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' @@ -12,15 +13,85 @@ function hasNonEmptyText(value: unknown): boolean { return typeof value === 'string' && value.trim().length > 0 } +function createLocalStorageForOnboarding() { + const keys = { + completed: 'onboarding/completed', + skipped: 'onboarding/skipped', + } as const + + return { + getCompleted: () => localStorage.getItem(keys.completed) === 'true', + getSkipped: () => localStorage.getItem(keys.skipped) === 'true', + setCompleted: (value: boolean) => localStorage.setItem(keys.completed, String(value)), + setSkipped: (value: boolean) => localStorage.setItem(keys.skipped, String(value)), + } +} + +const useOnboardingStateStore = defineStore('onboarding-state', () => { + const storage = createLocalStorageForOnboarding() + + // Pinia owns live cross-window state. Persistence is command-driven so + // storage events cannot become a second state propagation channel. + const hasCompletedSetup = ref(storage.getCompleted()) + const hasSkippedSetup = ref(storage.getSkipped()) + // This counter is a transient cross-window command. The Electron onboarding + // renderer owns the actual BrowserWindow close side effect. + const closeRequestId = ref(0) + + function markSetupCompleted() { + hasCompletedSetup.value = true + hasSkippedSetup.value = false + storage.setCompleted(true) + storage.setSkipped(false) + } + + function closeAfterAuthentication() { + markSetupCompleted() + closeRequestId.value += 1 + } + + function markSetupSkipped() { + hasSkippedSetup.value = true + storage.setSkipped(true) + } + + function resetSetupState() { + hasCompletedSetup.value = false + hasSkippedSetup.value = false + storage.setCompleted(false) + storage.setSkipped(false) + } + + return { + closeAfterAuthentication, + closeRequestId, + hasCompletedSetup, + hasSkippedSetup, + markSetupCompleted, + markSetupSkipped, + resetSetupState, + } +}, { + synced: { + actions: [ + 'closeAfterAuthentication', + 'markSetupCompleted', + 'markSetupSkipped', + 'resetSetupState', + ], + state: true, + }, +}) + export const useOnboardingStore = defineStore('onboarding', () => { const providerStore = useProviderConfigStore() const authStore = useAuthStore() + const onboardingStateStore = useOnboardingStateStore() + const closeRequestId = computed(() => onboardingStateStore.closeRequestId) + const hasCompletedSetup = computed(() => onboardingStateStore.hasCompletedSetup) + const hasSkippedSetup = computed(() => onboardingStateStore.hasSkippedSetup) - // Track if first-time setup has been completed or skipped - const hasCompletedSetup = useLocalStorage('onboarding/completed', false) - const hasSkippedSetup = useLocalStorage('onboarding/skipped', false) - - // Track if we should show the setup dialog + // This is renderer-local view state and never crosses the Pinia channel. const showingSetup = ref(false) // Check if any essential provider is configured @@ -60,24 +131,24 @@ export const useOnboardingStore = defineStore('onboarding', () => { } }) - // Mark setup as completed function markSetupCompleted() { - hasCompletedSetup.value = true - hasSkippedSetup.value = false showingSetup.value = false + return onboardingStateStore.markSetupCompleted() + } + + function closeAfterAuthentication() { + showingSetup.value = false + return onboardingStateStore.closeAfterAuthentication() } - // Mark setup as skipped function markSetupSkipped() { - hasSkippedSetup.value = true showingSetup.value = false + return onboardingStateStore.markSetupSkipped() } - // Reset setup state (for testing or re-showing setup) function resetSetupState() { - hasCompletedSetup.value = false - hasSkippedSetup.value = false showingSetup.value = false + return onboardingStateStore.resetSetupState() } // Force show setup dialog @@ -89,10 +160,12 @@ export const useOnboardingStore = defineStore('onboarding', () => { hasCompletedSetup, hasSkippedSetup, showingSetup, + closeRequestId, hasEssentialProviderConfigured, hasEssentialProviderCredentialConfigured, needsOnboarding, + closeAfterAuthentication, markSetupCompleted, markSetupSkipped, resetSetupState, diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index 0f5de33f2..e9cc6df1f 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -87,6 +87,22 @@ describe('provider store synchronization boundary', () => { expect(getProviderCalls).toBe(0) }) + // ROOT CAUSE: + // + // getModelsForProvider created a new empty array for every cache miss. + // Reactive consumers observed a false list change after each synced patch. + // + // We fixed this by returning one frozen fallback until a catalog exists. + it('reuses the empty model-list fallback', () => { + const store = useProviderStore() + + const first = store.getModelsForProvider('missing-provider') + const second = store.getModelsForProvider('missing-provider') + + expect(second).toBe(first) + expect(second).toEqual([]) + }) + // ROOT CAUSE: // // A model request kept a reference to its runtime entry across an await. diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index 915d600e5..0cc797c4f 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -43,6 +43,10 @@ export interface ProviderRuntimeState { modelError: string | null } +/** Stable fallback for reactive consumers when a provider has no cached catalog. */ +const emptyProviderModels: ModelInfo[] = [] +Object.freeze(emptyProviderModels) + // Only the provider data plane crosses renderer boundaries. Async derived refs // stay in useProviderStore and recompute locally instead of being patched as // authoritative state by pinia-plugin-synced. @@ -631,7 +635,7 @@ export const useProviderStore = defineStore('provider', () => { // Get models for a specific provider function getModelsForProvider(providerId: string) { - return providerRuntimeState.value[providerId]?.models || [] + return providerRuntimeState.value[providerId]?.models ?? emptyProviderModels } // Load models for all configured providers diff --git a/packages/stage-ui/src/stores/settings/stage-model.ts b/packages/stage-ui/src/stores/settings/stage-model.ts index 399f65fa9..582b7199a 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.ts @@ -13,7 +13,11 @@ export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | type BuiltInStageModelRenderer = Exclude const useStageModelSelectionStore = defineStore('settings-stage-model-selection', () => { - const selected = useLocalStorageManualReset('settings/stage/model', 'preset-live2d-1') + // Pinia synchronization owns live cross-window state. localStorage only + // loads and saves the durable model selection. + const selected = useLocalStorageManualReset('settings/stage/model', 'preset-live2d-1', { + listenToStorageChanges: false, + }) function resetState() { selected.reset()