fix(stage-ui,stage-tamagotchi,stage-web,stage-pocket): incorrectly initialized

This commit is contained in:
Neko Ayaka
2026-08-19 04:17:21 +08:00
parent 8322727f9b
commit 5c0916baa3
21 changed files with 1066 additions and 136 deletions
+19
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
@@ -9,6 +10,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'
@@ -33,6 +36,7 @@ const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const settings = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore()
const syncedPinia = usePiniaSynced()
const serverChannelStore = useModsServerChannelStore()
const characterOrchestratorStore = useCharacterOrchestratorStore()
const settingsAudioDeviceStore = useSettingsAudioDevice()
@@ -41,10 +45,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 primaryColor = computed(() => {
return isDark.value
? `color-mix(in srgb, oklch(95% var(--chromatic-chroma-900) calc(var(--chromatic-hue) + ${0})) 70%, oklch(50% 0 360))`
@@ -85,6 +102,7 @@ onMounted(async () => {
await authStore.initialize()
await displayModelsStore.initialize()
await cardStore.initialize()
registerAuthenticatedSetup()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
@@ -103,6 +121,7 @@ onMounted(async () => {
})
onUnmounted(() => {
stopAuthenticatedSetup?.()
contextBridgeStore.dispose()
})
@@ -20,8 +20,11 @@ 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'
import { usePerfTracerBridgeStore } from '@proj-airi/stage-ui/stores/perf-tracer-bridge'
import { listProvidersForPluginHost, shouldPublishPluginHostCapabilities } from '@proj-airi/stage-ui/stores/plugin-host-capabilities'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
@@ -118,6 +121,7 @@ const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
function createFullStageRuntime() {
const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const contextBridgeStore = useContextBridgeStore()
const displayModelsStore = useDisplayModelsStore()
const serverChannelSettingsStore = useServerChannelSettingsStore()
@@ -130,9 +134,23 @@ function createFullStageRuntime() {
const settingsAudioDeviceStore = useSettingsAudioDevice()
const artistryStore = 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 { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore)
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const listPlugins = useElectronEventaInvoke(electronPluginList)
@@ -235,6 +253,7 @@ function createFullStageRuntime() {
await authStore.initialize()
await displayModelsStore.initialize()
await cardStore.initialize()
registerAuthenticatedSetup()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
@@ -279,6 +298,7 @@ function createFullStageRuntime() {
inferencePreload.triggerPreload()
},
dispose() {
stopAuthenticatedSetup?.()
contextBridgeStore.dispose()
},
}
@@ -13,9 +13,34 @@ import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/ev
const authStore = useAuthStore()
const { needsLogin, isAuthenticated } = storeToRefs(authStore)
const onboardingStore = useOnboardingStore()
const { closeRequestId } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
const closeWindow = useElectronEventaInvoke(electronOnboardingClose)
let closing = false
async function closeOnboardingWindow() {
if (closing)
return
closing = true
try {
await closeWindow()
}
catch (error) {
closing = false
console.error('[Onboarding] Failed to close the onboarding window.', error)
}
}
// The shared action publishes a close request from the renderer that finishes
// authentication. This renderer remains the sole owner of the Electron close
// side effect. The auth check also handles a window mounted after the request.
watch([isAuthenticated, closeRequestId], ([authenticated, requestId], previous) => {
const previousRequestId = previous?.[1]
if (authenticated || (previousRequestId !== undefined && requestId !== previousRequestId))
void closeOnboardingWindow()
}, { immediate: true })
// The onboarding window is a separate Electron process with its own Pinia instance.
// When step-welcome sets needsLogin=true, we must invoke the IPC login from here
@@ -24,7 +49,7 @@ watch(needsLogin, async (val) => {
if (val && !isAuthenticated.value) {
await startLogin()
needsLogin.value = false
await closeWindow()
await closeOnboardingWindow()
}
})
@@ -37,12 +62,12 @@ const extraSteps = computed(() => {
async function handleSkipped() {
onboardingStore.markSetupSkipped()
await closeWindow()
await closeOnboardingWindow()
}
async function handleConfigured() {
onboardingStore.markSetupCompleted()
await closeWindow()
await closeOnboardingWindow()
}
</script>
+18
View File
@@ -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()
})
+11
View File
@@ -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'],
},
})
@@ -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<boolean>(false)
const selectedArtistryAutonomousThreshold = ref<number>(70)
const selectedArtistryConfigStr = ref<string>('{\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<unknown>[] = []
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<boolean> {
// 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<Card>(initializeCard())
const card = ref<Card>(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<T extends keyof Card>(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)"
>
<div class="flex items-center gap-1">
<div :class="tab.icon" />
@@ -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
@@ -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<Map<string, AiriCard>>('airi-cards', new Map(), { listenToStorageChanges: false })
const activeCardId = useLocalStorageManualReset<string>('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',
@@ -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')
})
})
@@ -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<string>('settings/consciousness/active-provider', '')
const activeModel = useLocalStorageManualReset<string>('settings/consciousness/active-model', '')
const activeCustomModelName = useLocalStorageManualReset<string>('settings/consciousness/active-custom-model', '')
const activeProvider = useLocalStorageManualReset<string>('settings/consciousness/active-provider', '', persistenceOptions)
const activeModel = useLocalStorageManualReset<string>('settings/consciousness/active-model', '', persistenceOptions)
const activeCustomModelName = useLocalStorageManualReset<string>('settings/consciousness/active-custom-model', '', persistenceOptions)
const expandedDescriptions = refManualReset<Record<string, boolean>>(() => ({}))
const modelSearchQuery = refManualReset<string>('')
@@ -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')
})
})
@@ -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<boolean> {
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<string>()
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
}
@@ -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<string>('')
const autoSendEnabled = useLocalStorageManualReset<boolean>('settings/hearing/auto-send-enabled', false)
const autoSendDelay = useLocalStorageManualReset<number>('settings/hearing/auto-send-delay', 2000) // Default 2 seconds
const confidenceThreshold = useLocalStorageManualReset<number>('settings/hearing/confidence-threshold', CONFIDENCE_THRESHOLD_DISABLED)
const autoSendEnabled = useLocalStorageManualReset<boolean>('settings/hearing/auto-send-enabled', false, persistenceOptions)
const autoSendDelay = useLocalStorageManualReset<number>('settings/hearing/auto-send-delay', 2000, persistenceOptions) // Default 2 seconds
const confidenceThreshold = useLocalStorageManualReset<number>('settings/hearing/confidence-threshold', CONFIDENCE_THRESHOLD_DISABLED, persistenceOptions)
const verboseJsonNotSupported = ref(false)
watch(activeTranscriptionProvider, () => {
@@ -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' } })
+32 -59
View File
@@ -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<string>('settings/speech/active-provider', 'speech-noop')
const activeSpeechModel = useLocalStorageManualReset<string>('settings/speech/active-model', '')
const activeSpeechVoiceId = useLocalStorageManualReset<string>('settings/speech/voice', '')
const activeSpeechProvider = useLocalStorageManualReset<string>('settings/speech/active-provider', 'speech-noop', persistenceOptions)
const activeSpeechModel = useLocalStorageManualReset<string>('settings/speech/active-model', '', persistenceOptions)
const activeSpeechVoiceId = useLocalStorageManualReset<string>('settings/speech/voice', '', persistenceOptions)
const activeSpeechVoice = refManualReset<VoiceInfo | undefined>(undefined)
const pitch = useLocalStorageManualReset<number>('settings/speech/pitch', 0)
const rate = useLocalStorageManualReset<number>('settings/speech/rate', 1)
const ssmlEnabled = useLocalStorageManualReset<boolean>('settings/speech/ssml-enabled', false)
const pitch = useLocalStorageManualReset<number>('settings/speech/pitch', 0, persistenceOptions)
const rate = useLocalStorageManualReset<number>('settings/speech/rate', 1, persistenceOptions)
const ssmlEnabled = useLocalStorageManualReset<boolean>('settings/speech/ssml-enabled', false, persistenceOptions)
const isLoadingSpeechProviderVoices = refManualReset<boolean>(false)
const speechProviderError = refManualReset<string | null>(null)
const availableVoices = refManualReset<Record<string, VoiceInfo[]>>(() => ({}))
@@ -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,
@@ -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(() => {
@@ -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')
})
})
+87 -14
View File
@@ -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,
@@ -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.
@@ -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
@@ -13,7 +13,11 @@ export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' |
type BuiltInStageModelRenderer = Exclude<StageModelRenderer, 'godot'>
const useStageModelSelectionStore = defineStore('settings-stage-model-selection', () => {
const selected = useLocalStorageManualReset<string>('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<string>('settings/stage/model', 'preset-live2d-1', {
listenToStorageChanges: false,
})
function resetState() {
selected.reset()