fix(stage-tamagotchi): prevent auxiliary renderer request storms (#2304)

This commit is contained in:
Neko
2026-08-16 22:08:18 +08:00
committed by GitHub
parent d768f5a334
commit 73c05d37eb
41 changed files with 772 additions and 171 deletions
@@ -4,6 +4,7 @@ import { OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers'
import { useAuthProviderSync } from './use-auth-provider-sync'
const syncState = vi.hoisted(() => ({
isLeader: false,
authenticatedHook: undefined as (() => Promise<void>) | undefined,
logoutHook: undefined as (() => void) | undefined,
activeProvider: '',
@@ -20,6 +21,8 @@ const syncState = vi.hoisted(() => ({
const syncMocks = vi.hoisted(() => ({
initializeAuth: vi.fn(async () => {}),
leadershipHook: undefined as ((isLeader: boolean) => void) | undefined,
disposeAuthenticatedHook: vi.fn(),
disposeLogoutHook: vi.fn(),
forceProviderConfigured: vi.fn(),
setProviderUnconfigured: vi.fn(),
setProviderAvailabilityOverride: vi.fn(),
@@ -35,6 +38,7 @@ vi.mock('../libs/auth', () => ({
vi.mock('../libs/pinia', () => ({
usePiniaSynced: () => ({
isLeader: () => syncState.isLeader,
onLeadershipChange: (hook: (isLeader: boolean) => void) => {
syncMocks.leadershipHook = hook
return vi.fn()
@@ -51,9 +55,17 @@ vi.mock('../stores/auth', () => ({
useAuthStore: () => ({
onAuthenticated: (hook: () => Promise<void>) => {
syncState.authenticatedHook = hook
return () => {
syncState.authenticatedHook = undefined
syncMocks.disposeAuthenticatedHook()
}
},
onLogout: (hook: () => void) => {
syncState.logoutHook = hook
return () => {
syncState.logoutHook = undefined
syncMocks.disposeLogoutHook()
}
},
}),
}))
@@ -118,6 +130,7 @@ vi.mock('./use-analytics', () => ({
describe('useAuthProviderSync', () => {
beforeEach(() => {
syncState.isLeader = false
syncState.authenticatedHook = undefined
syncState.logoutHook = undefined
syncMocks.leadershipHook = undefined
@@ -134,17 +147,57 @@ describe('useAuthProviderSync', () => {
syncMocks.fetchModelsForProvider.mockResolvedValue([])
})
it('restores auth initialization when this renderer becomes the leader', async () => {
it('starts auth initialization when this renderer becomes the leader', async () => {
useAuthProviderSync()
expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(1)
expect(syncMocks.initializeAuth).not.toHaveBeenCalled()
syncState.isLeader = true
syncMocks.leadershipHook?.(true)
await Promise.resolve()
expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(1)
})
it('does not activate providers in a follower renderer', () => {
useAuthProviderSync()
expect(syncState.authenticatedHook).toBeUndefined()
expect(syncMocks.initializeAuth).not.toHaveBeenCalled()
expect(syncMocks.forceProviderConfigured).not.toHaveBeenCalled()
})
// ROOT CAUSE:
//
// A renderer kept its auth hooks after it lost leadership. Those hooks
// could mutate provider state while the new leader handled the same auth
// transition.
//
// https://github.com/moeru-ai/airi/pull/2304
it('removes auth hooks on demotion and restores them after reacquiring leadership', () => {
syncState.isLeader = true
useAuthProviderSync()
expect(syncState.authenticatedHook).toBeDefined()
expect(syncState.logoutHook).toBeDefined()
syncState.isLeader = false
syncMocks.leadershipHook?.(false)
expect(syncMocks.disposeAuthenticatedHook).toHaveBeenCalledTimes(1)
expect(syncMocks.disposeLogoutHook).toHaveBeenCalledTimes(1)
expect(syncState.authenticatedHook).toBeUndefined()
expect(syncState.logoutHook).toBeUndefined()
syncState.isLeader = true
syncMocks.leadershipHook?.(true)
expect(syncMocks.initializeAuth).toHaveBeenCalledTimes(2)
expect(syncState.authenticatedHook).toBeDefined()
expect(syncState.logoutHook).toBeDefined()
})
it('activates every official provider after direct sign-in when no custom provider is selected', async () => {
syncState.isLeader = true
useAuthProviderSync()
await syncState.authenticatedHook?.()
@@ -165,6 +218,7 @@ describe('useAuthProviderSync', () => {
// The auth hook marked the session synchronized before model and streaming
// provider bootstrap completed. A transient failure therefore made every
// later authentication notification return early for the whole session.
syncState.isLeader = true
syncMocks.fetchModelsForProvider.mockImplementation(async (providerId: string) => {
if (providerId === 'official-provider-speech-streaming')
throw new Error('temporary catalog failure')
@@ -38,15 +38,39 @@ const STREAMING_SPEECH_PROVIDER_ID = 'official-provider-speech-streaming'
* auxiliary windows do not depend on the transient Stage scene lifecycle.
*/
export function useAuthProviderSync() {
void initializeAuth()
const syncedPinia = usePiniaSynced()
let leaderSyncInitialized = false
let disposeAuthenticatedProviderSync: (() => void) | undefined
// A replacement leader has no active refresh timer. Restore the auth
// lifecycle when this renderer acquires leadership after another closes.
usePiniaSynced().onLeadershipChange((isLeader) => {
function initializeLeaderSync() {
if (!syncedPinia.isLeader() || leaderSyncInitialized)
return
leaderSyncInitialized = true
void initializeAuth()
disposeAuthenticatedProviderSync = setupAuthenticatedProviderSync()
}
function disposeLeaderSync() {
if (!leaderSyncInitialized)
return
disposeAuthenticatedProviderSync?.()
disposeAuthenticatedProviderSync = undefined
leaderSyncInitialized = false
}
syncedPinia.onLeadershipChange((isLeader) => {
if (isLeader)
void initializeAuth()
initializeLeaderSync()
else
disposeLeaderSync()
})
initializeLeaderSync()
}
function setupAuthenticatedProviderSync() {
const authStore = useAuthStore()
const providersStore = useProviderStore()
const consciousnessStore = useConsciousnessStore()
@@ -62,7 +86,7 @@ export function useAuthProviderSync() {
let authGeneration = 0
let syncInFlight: Promise<void> | undefined
authStore.onAuthenticated(async () => {
const stopAuthenticatedHook = authStore.onAuthenticated(async () => {
if (hasSynced)
return
@@ -202,7 +226,7 @@ export function useAuthProviderSync() {
speechStore.activeSpeechVoiceId = ''
}
authStore.onLogout(() => {
const stopLogoutHook = authStore.onLogout(() => {
authGeneration++
hasSynced = false
@@ -249,4 +273,10 @@ export function useAuthProviderSync() {
}
}
})
return () => {
authGeneration++
stopAuthenticatedHook()
stopLogoutHook()
}
}
@@ -1,10 +1,12 @@
import type { PiniaPlugin } from 'pinia'
import type { SyncedPiniaRuntime } from 'pinia-plugin-synced'
import type { SyncedOptions, SyncedPiniaRuntime } from 'pinia-plugin-synced'
import type { InjectionKey, Plugin } from 'vue'
import { createSyncedPiniaPlugin } from 'pinia-plugin-synced'
import { inject } from 'vue'
export type { LeadershipMode } from 'pinia-plugin-synced'
/** Provides the synchronization runtime installed by {@link setupSynced}. */
export const injectKeyPiniaSynced: InjectionKey<SyncedPiniaRuntime> = Symbol('stage-synced-pinia-runtime')
@@ -14,13 +16,17 @@ export const injectKeyPiniaSynced: InjectionKey<SyncedPiniaRuntime> = Symbol('st
* Install both plugins on the same application. The Vue plugin provides the
* runtime to components and releases its election channel when the page or
* Vue application ends.
*
* @param options Leadership policy for this renderer. Defaults to the plugin's
* follower-preferred mode.
*/
export function setupSynced(): { pinia: PiniaPlugin, vue: Plugin } {
export function setupSynced(options: Pick<SyncedOptions, 'leadership'> = {}): { pinia: PiniaPlugin, vue: Plugin } {
const runtime = createSyncedPiniaPlugin({
namespace: 'airi:stage:pinia',
// Chat and image-generation actions can outlive the plugin's 30-second
// default. Keep the timeout aligned with the previous Electron coordinator.
callTimeout: 5 * 60 * 1000,
...options,
onError(error) {
console.error('[stage-synced-pinia] Synchronization failed:', error)
},
@@ -1,3 +1,5 @@
import type { SyncedPiniaRuntime } from 'pinia-plugin-synced'
import type { AiriCard } from './airi-card'
import { createPinia, setActivePinia } from 'pinia'
@@ -6,6 +8,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStageModel } from '../settings/stage-model'
import { useAiriCardStore } from './airi-card'
const syncedRuntime = {
isLeader: false,
leadershipListener: undefined as ((isLeader: boolean) => void) | undefined,
stopLeadershipListener: vi.fn(() => {
syncedRuntime.leadershipListener = undefined
}),
}
const syncedPinia = {
onLeadershipChange(listener) {
syncedRuntime.leadershipListener = listener
listener(syncedRuntime.isLeader)
return syncedRuntime.stopLeadershipListener
},
} satisfies Pick<SyncedPiniaRuntime, 'onLeadershipChange'>
// NOTICE:
// Vitest runs these store tests in Node, where localforage cannot select a
// browser storage driver. The stage-model watcher legitimately asks the
@@ -96,6 +114,91 @@ vi.mock('vue-i18n', () => ({
describe('airi-card store', () => {
beforeEach(() => {
setActivePinia(createPinia())
syncedRuntime.isLeader = false
syncedRuntime.leadershipListener = undefined
syncedRuntime.stopLeadershipListener.mockClear()
})
// ROOT CAUSE:
//
// A follower forwards its startup initialization to the current leader.
// The follower therefore has no local active-card watcher when it becomes
// the next leader.
//
// https://github.com/moeru-ai/airi/pull/2304
it('reinstalls the card watcher when a follower becomes the leader', async () => {
const stageModelStore = useSettingsStageModel()
const cardStore = useAiriCardStore()
await cardStore.initialize()
const vrmCardId = cardStore.addCard({
name: 'VRM card',
version: '1.0.0',
description: 'Card for the promoted leader.',
extensions: {
airi: {
modules: {
consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' },
vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' },
speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' },
displayModelId: 'preset-vrm-1',
},
agents: {},
},
},
}, 'scratch')
const live2dCardId = cardStore.addCard({
name: 'Live2D card',
version: '1.0.0',
description: 'Card for the active leader.',
extensions: {
airi: {
modules: {
consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' },
vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' },
speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' },
displayModelId: 'preset-live2d-1',
},
agents: {},
},
},
}, 'scratch')
stageModelStore.stageModelSelected = 'preset-live2d-1'
cardStore.startRuntime(syncedPinia)
cardStore.activeCardId = vrmCardId
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
syncedRuntime.leadershipListener?.(true)
expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1')
cardStore.activeCardId = live2dCardId
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
syncedRuntime.leadershipListener?.(false)
cardStore.activeCardId = vrmCardId
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
cardStore.disposeRuntime()
expect(syncedRuntime.stopLeadershipListener).toHaveBeenCalledTimes(1)
expect(syncedRuntime.leadershipListener).toBeUndefined()
})
it('does not create runtime module stores for metadata-only consumers', () => {
const pinia = createPinia()
setActivePinia(pinia)
// ROOT CAUSE:
//
// The chat session store only reads the active card ID and system prompt,
// but creating the card store also created every runtime module store.
// The speech store then loaded provider voices in each auxiliary window.
useAiriCardStore(pinia)
expect(pinia.state.value.speech).toBeUndefined()
expect(pinia.state.value.consciousness).toBeUndefined()
expect(pinia.state.value.vision).toBeUndefined()
})
/**
+120 -63
View File
@@ -1,10 +1,11 @@
import type { Card, ccv3 } from '@proj-airi/ccc'
import type { SyncedPiniaRuntime } from 'pinia-plugin-synced'
import type { AiriCard, AiriExtension } from '../../types/airiCard'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { nanoid } from 'nanoid'
import { defineStore, storeToRefs } from 'pinia'
import { defineStore } from 'pinia'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -45,27 +46,15 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const activeCard = computed(() => cards.value.get(activeCardId.value))
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const artistryStore = useArtistryStore()
const stageModelStore = useSettingsStageModel()
const {
activeProvider: activeConsciousnessProvider,
activeModel: activeConsciousnessModel,
} = storeToRefs(consciousnessStore)
const {
activeProvider: activeVisionProvider,
activeModel: activeVisionModel,
} = storeToRefs(visionStore)
const {
activeSpeechProvider,
activeSpeechVoiceId,
activeSpeechModel,
} = storeToRefs(speechStore)
function useRuntimeModuleStores() {
return {
artistry: useArtistryStore(),
consciousness: useConsciousnessStore(),
speech: useSpeechStore(),
stageModel: useSettingsStageModel(),
vision: useVisionStore(),
}
}
/**
* `source` feeds the `card_created` analytics event: `scratch` = built in
@@ -162,6 +151,14 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension {
const {
artistry,
consciousness,
speech,
stageModel,
vision,
} = useRuntimeModuleStores()
// Get existing extension if available
const existingExtension = ('data' in card
? card.data?.extensions?.airi
@@ -170,27 +167,27 @@ export const useAiriCardStore = defineStore('airi-card', () => {
// Create default modules config
const defaultModules = {
consciousness: {
provider: activeConsciousnessProvider.value,
model: activeConsciousnessModel.value,
provider: consciousness.activeProvider,
model: consciousness.activeModel,
},
vision: {
provider: activeVisionProvider.value,
model: activeVisionModel.value,
provider: vision.activeProvider,
model: vision.activeModel,
},
speech: {
provider: activeSpeechProvider.value,
model: activeSpeechModel.value,
voice_id: activeSpeechVoiceId.value,
provider: speech.activeSpeechProvider,
model: speech.activeSpeechModel,
voice_id: speech.activeSpeechVoiceId,
},
displayModelId: stageModelStore.stageModelSelected,
displayModelId: stageModel.stageModelSelected,
artistry: {
enabled: false,
provider: artistryStore.globalProvider,
model: artistryStore.globalModel,
promptPrefix: artistryStore.globalPromptPrefix,
provider: artistry.globalProvider,
model: artistry.globalModel,
promptPrefix: artistry.globalPromptPrefix,
widgetInstruction: DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT,
spawnMode: 'bg_widget' as const,
options: artistryStore.globalProviderOptions,
options: artistry.globalProviderOptions,
autonomousEnabled: false,
autonomousThreshold: 70,
autonomousTarget: 'assistant' as const,
@@ -295,7 +292,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}
function initialize() {
async function initialize() {
if (!cards.value.has('default')) {
cards.value.set('default', newAiriCard({
name: 'ReLU',
@@ -312,11 +309,19 @@ export const useAiriCardStore = defineStore('airi-card', () => {
if (!cards.value.has(activeCardId.value))
activeCardId.value = 'default'
applyActiveCardSettings()
initializeRuntimeModules()
}
function applyActiveCardSettings(newCard = activeCard.value) {
artistryStore.resetToGlobal()
const {
artistry,
consciousness,
speech,
stageModel,
vision,
} = useRuntimeModuleStores()
artistry.resetToGlobal()
if (!newCard)
return
@@ -326,41 +331,79 @@ export const useAiriCardStore = defineStore('airi-card', () => {
if (!extension)
return
activeConsciousnessProvider.value = extension?.modules?.consciousness?.provider
activeConsciousnessModel.value = extension?.modules?.consciousness?.model
consciousness.activeProvider = extension?.modules?.consciousness?.provider
consciousness.activeModel = extension?.modules?.consciousness?.model
activeVisionProvider.value = extension?.modules?.vision?.provider
activeVisionModel.value = extension?.modules?.vision?.model
vision.activeProvider = extension?.modules?.vision?.provider
vision.activeModel = extension?.modules?.vision?.model
activeSpeechProvider.value = extension?.modules?.speech?.provider
activeSpeechModel.value = extension?.modules?.speech?.model
activeSpeechVoiceId.value = extension?.modules?.speech?.voice_id
speech.activeSpeechProvider = extension?.modules?.speech?.provider
speech.activeSpeechModel = extension?.modules?.speech?.model
speech.activeSpeechVoiceId = extension?.modules?.speech?.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
// proxy correctly calls the writable computed setter → stageModelSelectedState → updateStageModel().
if (extension.modules?.displayModelId) {
stageModelStore.stageModelSelected = extension.modules.displayModelId
stageModel.stageModelSelected = extension.modules.displayModelId
}
if (extension.modules?.artistry) {
if (extension.modules.artistry.provider)
artistryStore.activeProvider = extension.modules.artistry.provider
artistry.activeProvider = extension.modules.artistry.provider
if (extension.modules.artistry.model)
artistryStore.activeModel = extension.modules.artistry.model
artistry.activeModel = extension.modules.artistry.model
if (extension.modules.artistry.promptPrefix)
artistryStore.defaultPromptPrefix = extension.modules.artistry.promptPrefix
artistry.defaultPromptPrefix = extension.modules.artistry.promptPrefix
if (extension.modules.artistry.options)
artistryStore.providerOptions = extension.modules.artistry.options
artistry.providerOptions = extension.modules.artistry.options
}
}
// Activation changes the stable card ID, while card editors replace the
// active card object without changing that ID. Observe both transitions so
// switching cards and saving edits to the current card apply consistently.
watch([activeCardId, activeCard], ([, newCard]) => {
applyActiveCardSettings(newCard)
}, { flush: 'sync', immediate: true })
let stopLeadershipListener: (() => void) | undefined
let stopRuntimeModuleWatcher: (() => void) | undefined
function initializeRuntimeModules() {
if (stopRuntimeModuleWatcher)
return
applyActiveCardSettings()
// Activation changes the stable card ID, while card editors replace the
// active card object without changing that ID. Only the Stage lifecycle
// owner applies those settings; metadata-only consumers stay lightweight.
stopRuntimeModuleWatcher = watch([activeCardId, activeCard], ([, newCard]) => {
applyActiveCardSettings(newCard)
}, { flush: 'sync' })
}
function stopRuntimeModules() {
stopRuntimeModuleWatcher?.()
stopRuntimeModuleWatcher = undefined
}
/**
* Keeps renderer-local card settings active only in the current leader.
* Repeated calls keep the first listener until {@link disposeRuntime} runs.
*/
function startRuntime(syncedPinia: Pick<SyncedPiniaRuntime, 'onLeadershipChange'>) {
if (stopLeadershipListener)
return
stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
if (isLeader)
initializeRuntimeModules()
else
stopRuntimeModules()
})
}
/** Stops renderer-local card settings and leadership tracking. */
function disposeRuntime() {
stopRuntimeModules()
stopLeadershipListener?.()
stopLeadershipListener = undefined
}
function resetState() {
// Clear card data before the selected ID. Otherwise the synchronous
@@ -384,26 +427,40 @@ export const useAiriCardStore = defineStore('airi-card', () => {
getCard,
resetState,
initialize,
startRuntime,
disposeRuntime,
currentModels: computed(() => {
const {
consciousness,
speech,
stageModel,
vision,
} = useRuntimeModuleStores()
return {
consciousness: {
provider: activeConsciousnessProvider.value,
model: activeConsciousnessModel.value,
provider: consciousness.activeProvider,
model: consciousness.activeModel,
},
vision: {
provider: activeVisionProvider.value,
model: activeVisionModel.value,
provider: vision.activeProvider,
model: vision.activeModel,
},
speech: {
provider: activeSpeechProvider.value,
model: activeSpeechModel.value,
voice_id: activeSpeechVoiceId.value,
provider: speech.activeSpeechProvider,
model: speech.activeSpeechModel,
voice_id: speech.activeSpeechVoiceId,
},
displayModelId: stageModelStore.stageModelSelected,
displayModelId: stageModel.stageModelSelected,
activeBackgroundId: activeCard.value?.extensions?.airi?.modules?.activeBackgroundId,
} satisfies AiriExtension['modules']
}),
systemPrompt: computed(() => resolveSystemPrompt(activeCard.value)),
}
}, {
synced: {
actions: ['initialize'],
state: true,
},
})
@@ -1,3 +1,5 @@
import type {} from 'pinia-plugin-synced'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
import { computed, isRef, ref, watch } from 'vue'
@@ -184,6 +186,10 @@ export const useArtistryStore = defineStore('artistry', () => {
resetToGlobal,
resetState,
}
}, {
synced: {
state: true,
},
})
/**
@@ -1,3 +1,5 @@
import type {} from 'pinia-plugin-synced'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { refManualReset } from '@vueuse/core'
import { defineStore } from 'pinia'
@@ -117,4 +119,8 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
getModelsForProvider,
resetState,
}
}, {
synced: {
state: true,
},
})
@@ -2,6 +2,7 @@ import type { Span } from '@opentelemetry/api'
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type { WithUnknown } from '@xsai/shared'
import type { StreamTranscriptionOptions as XSAIStreamTranscriptionOptions } from '@xsai/stream-transcription'
import type {} from 'pinia-plugin-synced'
import type { AIRIStreamTranscriptionResult } from '../../libs/providers/stream-transcription'
import type { StreamingTranscriptionCallbacks, StreamingTranscriptionConsumer } from './streaming-transcription-consumers'
@@ -561,6 +562,10 @@ export const useHearingStore = defineStore('hearing-store', () => {
getModelsForProvider,
resetState,
}
}, {
synced: {
state: true,
},
})
export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech:audio-input-pipeline', () => {
+6 -10
View File
@@ -1,4 +1,5 @@
import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type {} from 'pinia-plugin-synced'
import type { VoiceInfo } from '../providers/provider'
@@ -7,7 +8,7 @@ import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { refManualReset } from '@vueuse/core'
import { generateSpeech } from '@xsai/generate-speech'
import { defineStore, storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toXml } from 'xast-util-to-xml'
import { x } from 'xastscript'
@@ -242,15 +243,6 @@ export const useSpeechStore = defineStore('speech', () => {
},
)
onMounted(() => {
ensureActiveSpeechModel()
loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined).then(() => {
if (activeSpeechVoiceId.value) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value)
}
})
})
setupOfficialSpeechAutoPick({
activeSpeechProvider,
activeSpeechVoiceId,
@@ -469,4 +461,8 @@ export const useSpeechStore = defineStore('speech', () => {
resolveSpeechInput,
resetState,
}
}, {
synced: {
state: true,
},
})
@@ -1,3 +1,5 @@
import type {} from 'pinia-plugin-synced'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { refManualReset } from '@vueuse/core'
import { defineStore } from 'pinia'
@@ -86,4 +88,8 @@ export const useVisionStore = defineStore('vision', () => {
getModelsForProvider,
resetState,
}
}, {
synced: {
state: true,
},
})
@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useProviderStore } from './provider'
vi.mock('vue-i18n', () => ({
@@ -64,4 +65,38 @@ describe('provider store synchronization boundary', () => {
expect.objectContaining({ id: 'auto' }),
])
})
// ROOT CAUSE:
//
// Speech startup previously had both an immediate watcher and a mounted
// refresh. Multiple renderers could also request the same catalog through
// the synchronized provider action. Each caller created its own request.
//
// We keep one leader-owned request per provider, model, and configuration
// until it settles, so concurrent callers share the same result.
it('shares concurrent voice catalog requests', async () => {
const store = useProviderStore()
let resolveRequest: ((response: Response) => void) | undefined
const fetchMock = vi.fn(() => new Promise<Response>((resolve) => {
resolveRequest = resolve
}))
vi.stubGlobal('fetch', fetchMock)
try {
const first = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto')
const second = store.listProviderVoices(OFFICIAL_SPEECH_PROVIDER_ID, 'auto')
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
resolveRequest?.(new Response(JSON.stringify({ voices: [], recommended: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await expect(Promise.all([first, second])).resolves.toEqual([[], []])
expect(fetchMock).toHaveBeenCalledTimes(1)
}
finally {
vi.unstubAllGlobals()
}
})
})
@@ -11,7 +11,7 @@ import type {
import type {} from 'pinia-plugin-synced'
import type { ProviderMetadata, ProviderValidationPlan } from '../../libs/providers'
import type { ModelInfo, ProviderDefinition, ProviderInstance } from '../../libs/providers/types'
import type { ModelInfo, ProviderDefinition, ProviderInstance, VoiceInfo } from '../../libs/providers/types'
import { errorMessageFrom } from '@moeru/std'
import { isCustomProvidersDisabled, isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
@@ -202,6 +202,7 @@ export const useProviderStore = defineStore('provider', () => {
set: value => providerStateStore.runtime = value,
})
const providerValidationInFlight = new Map<string, Promise<boolean>>()
const providerVoiceListInFlight = new Map<string, Promise<VoiceInfo[]>>()
const providerRevalidationLoops = new Map<string, { pause: () => void, resume: () => void }>()
// Server-driven availability overrides for providers whose visibility can
@@ -610,13 +611,25 @@ export const useProviderStore = defineStore('provider', () => {
return []
const config = providerConfigStore.getProviderConfig(providerId) ?? {}
const provider = await definition.createProvider(config)
try {
return await listVoices(config, provider, model)
}
finally {
await disposeTemporaryProvider(provider)
}
const requestKey = JSON.stringify([providerId, model ?? null, config])
const pending = providerVoiceListInFlight.get(requestKey)
if (pending)
return pending
const task = (async () => {
const provider = await definition.createProvider(config)
try {
return await listVoices(config, provider, model)
}
finally {
await disposeTemporaryProvider(provider)
}
})()
providerVoiceListInFlight.set(requestKey, task)
return task.finally(() => {
providerVoiceListInFlight.delete(requestKey)
})
}
async function loadProviderModel(
@@ -1,8 +1,10 @@
import type {} from 'pinia-plugin-synced'
import type { DisplayModel } from '../display-models'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { refManualReset, useEventListener } from '@vueuse/core'
import { defineStore } from 'pinia'
import { defineStore, storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { DisplayModelFormat, useDisplayModelsStore } from '../display-models'
@@ -10,13 +12,29 @@ import { DisplayModelFormat, useDisplayModelsStore } from '../display-models'
export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot' | 'disabled' | undefined
type BuiltInStageModelRenderer = Exclude<StageModelRenderer, 'godot'>
const useStageModelSelectionStore = defineStore('settings-stage-model-selection', () => {
const selected = useLocalStorageManualReset<string>('settings/stage/model', 'preset-live2d-1')
function resetState() {
selected.reset()
}
return {
selected,
resetState,
}
}, {
synced: {
state: true,
},
})
export const useSettingsStageModel = defineStore('settings-stage-model', () => {
const displayModelsStore = useDisplayModelsStore()
const stageModelSelectionStore = useStageModelSelectionStore()
const { selected: stageModelSelectedState } = storeToRefs(stageModelSelectionStore)
let stageModelUpdateSequence = 0
const stageModelStorageKey = 'settings/stage/model'
const defaultStageModelId = 'preset-live2d-1'
const stageModelSelectedState = useLocalStorageManualReset<string>(stageModelStorageKey, defaultStageModelId)
const stageModelSelected = computed<string>({
get: () => stageModelSelectedState.value,
set: (value) => {
@@ -142,7 +160,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
async function resetState() {
revokeStageModelUrl(stageModelSelectedUrl.value)
stageModelSelectedState.reset()
stageModelSelectionStore.resetState()
stageModelSelectedDisplayModel.reset()
stageModelSelectedUrl.reset()
stageModelRenderer.reset()