fix(stage-tamagotchi): prevent auxiliary renderer request storms (#2304)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user