fix(stage-ui,stage-tamagotchi,stage-web,stage-pocket): incorrectly initialized
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
+69
-38
@@ -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' } })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user