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
+5 -1
View File
@@ -2,6 +2,7 @@
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
@@ -35,6 +36,7 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
const syncedPinia = usePiniaSynced()
const primaryColor = computed(() => {
return isDark.value
@@ -74,7 +76,8 @@ watch(settings.themeColorsHueDynamic, () => {
onMounted(async () => {
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
@@ -93,6 +96,7 @@ onMounted(async () => {
})
onUnmounted(() => {
cardStore.disposeRuntime()
contextBridgeStore.dispose()
})
+4
View File
@@ -8,6 +8,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
import { setupSynced } from '@proj-airi/stage-ui/libs/pinia'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -34,6 +35,8 @@ configureAnalyticsAdapter(async (options) => {
})
const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia)
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
const routeRecords = setupLayouts(routes as RouteRecordRaw[])
@@ -60,6 +63,7 @@ window.addEventListener('unhandledrejection', (event) => {
installDeepLinks(router)
createApp(App)
.use(synced.vue)
.use(MotionPlugin)
// TODO: Fix autoAnimatePlugin type error
.use(autoAnimatePlugin as unknown as Plugin)
@@ -25,4 +25,24 @@ describe('withHashRoute', () => {
const result = withHashRoute({ url: 'file:////home/workspace/project/index.html' }, '/test/inner-test')
expect(result).toEqual({ url: `file:////home/workspace/project/index.html#/test/inner-test` })
})
it('adds query options before the hash route for development URLs', () => {
expect(withHashRoute({ url: 'http://localhost:5173' }, '/about', {
query: { 'synced-leader': 'false' },
})).toEqual({
url: 'http://localhost:5173/?synced-leader=false#/about',
})
})
it('passes query options to Electron for packaged renderer URLs', () => {
expect(withHashRoute({ file: '/opt/airi/renderer/index.html' }, '/settings', {
query: { 'synced-leader': 'false' },
})).toEqual({
file: '/opt/airi/renderer/index.html',
options: {
hash: '/settings',
query: { 'synced-leader': 'false' },
},
})
})
})
@@ -92,9 +92,19 @@ export async function load(window: BrowserWindow, url: string | { url: string, o
}
/**
* A helper function to construct URL with hash route, which is commonly used in our app since we are using hash-based routing in renderer.
* Adds a hash route and optional query to an Electron renderer location.
*
* @example
* withHashRoute({ url: 'http://localhost:5173' }, '/about', {
* query: { 'synced-leader': 'false' },
* })
* // => { url: 'http://localhost:5173/?synced-leader=false#/about' }
*/
export function withHashRoute(baseUrl: string | { url: string } | { file: string }, hashRoute: string) {
export function withHashRoute(
baseUrl: string | { url: string } | { file: string },
hashRoute: string,
options: Pick<LoadFileOptions, 'query'> = {},
) {
if (typeof baseUrl === 'object' && 'url' in baseUrl) {
// trim `/` suffix
const baseURLinURL = new URL(baseUrl.url)
@@ -103,12 +113,15 @@ export function withHashRoute(baseUrl: string | { url: string } | { file: string
const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
baseURLinURL.pathname = trimmedPathname
for (const [key, value] of Object.entries(options.query ?? {}))
baseURLinURL.searchParams.set(key, value)
baseURLinURL.hash = hashRoute
return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
}
if (typeof baseUrl === 'object' && 'file' in baseUrl) {
return { file: `${baseUrl.file}`, options: { hash: hashRoute } } satisfies { file: string, options?: LoadFileOptions }
return { file: `${baseUrl.file}`, options: { hash: hashRoute, ...options } } satisfies { file: string, options?: LoadFileOptions }
}
// trim `/` suffix
@@ -118,6 +131,9 @@ export function withHashRoute(baseUrl: string | { url: string } | { file: string
const trimmedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
baseURLinURL.pathname = trimmedPathname
for (const [key, value] of Object.entries(options.query ?? {}))
baseURLinURL.searchParams.set(key, value)
baseURLinURL.hash = hashRoute
return { url: baseURLinURL.toString() } satisfies { url: string, options?: LoadURLOptions }
@@ -44,7 +44,9 @@ export function setupAboutWindowReusable(params: {
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about', {
query: { 'synced-leader': 'false' },
}))
return window
}).getWindow
@@ -344,7 +344,9 @@ export function setupCaptionWindowManager(params: {
const cleanupGetAttached = defineInvokeHandler(context, captionGetIsFollowingWindow, async () => isFollowing)
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/caption', {
query: { 'synced-leader': 'false' },
}))
try {
context.emit(captionIsFollowingWindowChanged, isFollowing)
@@ -44,7 +44,12 @@ export function setupChatWindowReusableFunc(params: {
i18n: params.i18n,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/chat', {
query: {
'stage-runtime': 'minimal',
'synced-leader': 'false',
},
}))
return window
}).getWindow
@@ -137,7 +137,9 @@ export async function setupDashboardWindow(params: {
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard', {
query: { 'synced-leader': 'false' },
}))
/**
* This is a know issue (or expected behavior maybe) to Electron.
@@ -123,6 +123,7 @@ export async function setupDesktopOverlayWindow(params: {
isDesktopOverlayPollHeartbeatEnabled()
? `/desktop-overlay?${desktopOverlayPollHeartbeatQueryParam}=1`
: '/desktop-overlay',
{ query: { 'synced-leader': 'false' } },
),
)
@@ -50,7 +50,9 @@ export function setupDevtoolsWindow(): DevtoolsWindowManager {
})
protectPrivilegedWindowNavigation(window)
await load(window, withHashRoute(rendererBase, route))
await load(window, withHashRoute(rendererBase, route, {
query: { 'synced-leader': 'false' },
}))
return window
})
@@ -55,7 +55,12 @@ export function setupEditorWindowManager(params: {
i18n: params.i18n,
serverChannel: params.serverChannel,
})
await load(window, withHashRoute(rendererBase, '/editor'))
await load(window, withHashRoute(rendererBase, '/editor', {
query: {
'stage-runtime': 'minimal',
'synced-leader': 'false',
},
}))
return window
})
@@ -66,7 +66,9 @@ export async function setupInlayWindow(params: {
await setupInlayWindowInvokes({ inlayWindow: window, serverChannel: params.serverChannel, i18n: params.i18n })
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay'))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/inlay', {
query: { 'synced-leader': 'false' },
}))
return window
}
@@ -29,7 +29,7 @@ import icon from '../../../../resources/icon.png?asset'
import { electronStartDraggingWindow } from '../../../shared/eventa'
import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { protectPrivilegedWindowNavigation, transparentWindowConfig } from '../shared'
import { setupMainWindowElectronInvokes } from './rpc/index.electron'
@@ -186,7 +186,9 @@ export async function setupMainWindow(params: {
onboardingWindowManager: params.onboardingWindowManager,
})
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')))
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', {
query: { 'synced-leader': 'true' },
}))
/**
* This is a know issue (or expected behavior maybe) to Electron.
@@ -47,7 +47,9 @@ export function setupNoticeWindowManager(params: {
async function loadNoticeRoute(window: BrowserWindow, payload: RequestWindowPayload & { id: string }) {
const routeWithId = `${payload.route}?id=${payload.id}`
await load(window, withHashRoute(rendererBase, routeWithId))
await load(window, withHashRoute(rendererBase, routeWithId, {
query: { 'synced-leader': 'false' },
}))
}
const manager = createReferencedWindowManager({
@@ -74,7 +74,9 @@ export function setupOnboardingWindowManager(params: {
await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel })
createAuthService({ context, window: newWindow })
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding'))
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding', {
query: { 'synced-leader': 'false' },
}))
newWindow.on('closed', () => {
for (const cb of closeCallbacks) {
@@ -78,7 +78,9 @@ export function setupSettingsWindowReusableFunc(params: {
spotlightWindow: params.spotlightWindow,
})
await load(window, withHashRoute(rendererBase, currentRoute))
await load(window, withHashRoute(rendererBase, currentRoute, {
query: { 'synced-leader': 'false' },
}))
window.on('closed', () => {
if (settingsContext)
@@ -140,7 +140,12 @@ export function setupSpotlightWindowManager(params: {
showNotification(payload.body, () => void openChatWindowFromNotification())
})
await load(window, withHashRoute(rendererBase, '/spotlight'))
await load(window, withHashRoute(rendererBase, '/spotlight', {
query: {
'stage-runtime': 'minimal',
'synced-leader': 'false',
},
}))
return window
})
@@ -440,7 +440,9 @@ export function setupWidgetsWindowManager(params: {
}
async function loadWithRoute(window: BrowserWindow, route: string) {
await load(window, withHashRoute(rendererBase, route))
await load(window, withHashRoute(rendererBase, route, {
query: { 'synced-leader': 'false' },
}))
currentRoute = route
}
+27 -29
View File
@@ -62,7 +62,7 @@ import {
useTamagotchiMcpToolsStore,
useTamagotchiPluginToolsStore,
} from './stores/tools'
import { resolveInitialWindowRoutePath } from './window-route'
import { resolveInitialRendererRoutePath, resolveRendererWindowContext } from './window-context'
const { isDark: dark } = useTheme()
const settingsStore = useSettings()
@@ -73,20 +73,17 @@ const chatSessionStore = useChatSessionStore()
const context = useElectronEventaContext()
const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
const setLocale = useElectronEventaInvoke(i18nSetLocale)
const initialWindowRoutePath = resolveInitialWindowRoutePath(route.path)
const windowContext = resolveRendererWindowContext()
const initialRoutePath = resolveInitialRendererRoutePath(route.path)
useChatStore()
const builtinToolsStore = useTamagotchiBuiltinToolsStore()
const mcpToolsStore = useTamagotchiMcpToolsStore()
const pluginToolsStore = useTamagotchiPluginToolsStore()
const syncedPinia = usePiniaSynced()
chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/')
const isEditorWindowRoute = initialWindowRoutePath === '/editor'
const isSpotlightWindow = initialRoutePath === '/spotlight'
const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/')
// Every renderer participates in leader election. Keep provider state ready in
// auxiliary windows so a newly elected leader can execute chat actions after
// the previous window closes.
useAuthProviderSync()
async function refreshPluginRuntimeTools() {
@@ -98,8 +95,8 @@ async function refreshPluginRuntimeTools() {
}
}
// Every renderer creates the runtime tool stores because every renderer can
// become the leader. Only the leader discovers tools and keeps executors.
// Every renderer creates the runtime tool stores for synchronized state. Only
// the main Stage renderer discovers tools and keeps executors.
const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
chatSessionStore.setCloudSyncOwnership(isLeader)
if (!isLeader)
@@ -139,9 +136,8 @@ function createFullStageRuntime() {
const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
const isAuxiliaryChatRoute = initialWindowRoutePath === '/chat'
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
const isWidgetsWindowRoute = () => route.path === '/widgets'
const usesGodotStage = initialRoutePath === '/' || initialRoutePath.startsWith('/settings')
const isWidgetsWindow = initialRoutePath === '/widgets'
function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) {
if (state.state === 'running') {
@@ -155,12 +151,13 @@ function createFullStageRuntime() {
usePerfTracerBridgeStore()
initializeStageThreeRuntimeTraceBridge()
// The main process returns the callback only to the renderer that started
// sign-in. Each login-capable window listens locally, while the synchronized
// auth action still executes once in the main Stage leader.
initializeElectronAuthCallbackBridge()
void stageWindowLifecycleStore.initializeWindowLifecycleBridge()
watch(() => route.path, () => {
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindowRoute() ? 'client' : 'main')
}, { immediate: true })
contextBridgeStore.setSparkNotifyHostRole(isWidgetsWindow ? 'client' : 'main')
// NOTICE: register plugin host bridge during setup to avoid race with pages using it in immediate watchers.
pluginHostInspectorStore.setBridge({
@@ -217,13 +214,14 @@ function createFullStageRuntime() {
async initialize() {
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await settingsAudioDeviceStore.initialize()
if (isGodotStageRoute()) {
if (usesGodotStage) {
try {
syncGodotStageRenderer(await getGodotStageStatus())
}
@@ -241,12 +239,10 @@ function createFullStageRuntime() {
token: serverChannelConfig.authToken || undefined,
possibleEvents: ['ui:configure'],
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
if (!isAuxiliaryChatRoute) {
contextBridgeStore.initialize()
if (!isWidgetsWindowRoute()) {
characterOrchestratorStore.initialize()
await startTrackingCursorPoint()
}
contextBridgeStore.initialize()
if (!isWidgetsWindow) {
characterOrchestratorStore.initialize()
await startTrackingCursorPoint()
}
defineInvokeHandler(context.value, pluginProtocolListProviders, async () => listProvidersForPluginHost())
@@ -264,13 +260,15 @@ function createFullStageRuntime() {
inferencePreload.triggerPreload()
},
dispose() {
if (!isAuxiliaryChatRoute)
contextBridgeStore.dispose()
cardStore.disposeRuntime()
contextBridgeStore.dispose()
},
}
}
const fullStageRuntime = isSpotlightWindowRoute || isEditorWindowRoute ? null : createFullStageRuntime()
const fullStageRuntime = windowContext.stageRuntime === 'full'
? createFullStageRuntime()
: null
const { restore: restoreLocale } = useLanguage(language, getMainLocale, setLocale)
@@ -279,7 +277,7 @@ watch(dark, () => updateThemeColor(), { immediate: true })
watch(route, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
if (isSettingsWindowRoute) {
if (isSettingsWindow) {
context.value.on(electronSettingsNavigate, (event) => {
const targetRoute = event?.body?.route
if (!targetRoute || route.fullPath === targetRoute) {
@@ -324,7 +322,7 @@ onUnmounted(() => {
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster />
</ToasterRoot>
<ResizeHandler v-if="!isSpotlightWindowRoute" />
<ResizeHandler v-if="!isSpotlightWindow" />
<RouterView />
</template>
+4 -1
View File
@@ -18,6 +18,7 @@ import { handleHotUpdate, routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { i18n } from './modules/i18n'
import { resolveRendererWindowContext } from './window-context'
import '@unocss/reset/tailwind.css'
import 'splitpanes/dist/splitpanes.css'
@@ -45,7 +46,9 @@ configureAnalyticsAdapter(async (options) => {
})
const pinia = createPinia()
const synced = setupSynced()
const synced = setupSynced({
leadership: resolveRendererWindowContext().leadership,
})
pinia.use(synced.pinia)
pinia.use(piniaPluginTracing)
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { resolveInitialRendererRoutePath, resolveRendererWindowContext } from './window-context'
describe('resolveInitialRendererRoutePath', () => {
// ROOT CAUSE:
//
// Vue Router reports `/` during App setup, before it hydrates the hash
// route. Window-specific setup therefore used the main-window behavior in
// widgets and settings renderers.
//
// https://github.com/moeru-ai/airi/pull/2304
it('uses the hash route before Vue Router hydrates', () => {
expect(resolveInitialRendererRoutePath('/', '#/widgets')).toBe('/widgets')
expect(resolveInitialRendererRoutePath('/', '#/settings/providers?source=tray')).toBe('/settings/providers')
})
it('uses the router path when no hash route exists', () => {
expect(resolveInitialRendererRoutePath('/settings/data', '')).toBe('/settings/data')
})
})
describe('resolveRendererWindowContext', () => {
it('assigns synchronized leadership from the explicit query', () => {
expect(resolveRendererWindowContext('?synced-leader=true')).toMatchObject({
leadership: 'leader-only',
})
expect(resolveRendererWindowContext('?synced-leader=false')).toMatchObject({
leadership: 'follower-only',
})
})
it('uses the full Stage runtime unless the query selects the minimal runtime', () => {
expect(resolveRendererWindowContext('?synced-leader=true').stageRuntime).toBe('full')
expect(resolveRendererWindowContext('?synced-leader=false').stageRuntime).toBe('full')
expect(resolveRendererWindowContext('?synced-leader=false&stage-runtime=minimal').stageRuntime).toBe('minimal')
})
it('rejects a renderer URL without an explicit leadership query', () => {
expect(() => resolveRendererWindowContext('')).toThrow('Missing synced-leader query')
expect(() => resolveRendererWindowContext('?synced-leader=unknown')).toThrow('Invalid synced-leader query: unknown')
expect(() => resolveRendererWindowContext('?synced-leader=false&stage-runtime=unknown')).toThrow('Invalid stage-runtime query: unknown')
})
})
@@ -0,0 +1,51 @@
import type { LeadershipMode } from '@proj-airi/stage-ui/libs/pinia'
/** Describes the synchronization and Stage runtime policy for one renderer. */
export interface RendererWindowContext {
/** Determines whether this renderer can own synchronized actions. */
leadership: LeadershipMode
/** Determines whether this renderer initializes Stage integrations. */
stageRuntime: 'full' | 'minimal'
}
function normalizeRoutePath(routePath: string) {
const [path = ''] = routePath.split(/[?#]/)
return path || '/'
}
/**
* Resolves the initial renderer route before Vue Router hydrates the hash.
*
* @example
* resolveInitialRendererRoutePath('/', '#/widgets?source=tray')
* // => '/widgets'
*/
export function resolveInitialRendererRoutePath(routePath: string, hash = globalThis.location?.hash ?? ''): string {
const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
return normalizeRoutePath(hashPath || routePath)
}
/**
* Resolves renderer ownership from the query that the main process supplies.
*
* @example
* resolveRendererWindowContext('?synced-leader=false&stage-runtime=minimal')
* // => { leadership: 'follower-only', stageRuntime: 'minimal' }
*/
export function resolveRendererWindowContext(search = globalThis.location?.search ?? ''): RendererWindowContext {
const query = new URLSearchParams(search)
const syncedLeader = query.get('synced-leader')
if (syncedLeader === null)
throw new TypeError('Missing synced-leader query')
if (syncedLeader !== 'true' && syncedLeader !== 'false')
throw new TypeError(`Invalid synced-leader query: ${syncedLeader}`)
const stageRuntime = query.get('stage-runtime')
if (stageRuntime !== null && stageRuntime !== 'minimal')
throw new TypeError(`Invalid stage-runtime query: ${stageRuntime}`)
return {
leadership: syncedLeader === 'true' ? 'leader-only' : 'follower-only',
stageRuntime: stageRuntime === 'minimal' ? 'minimal' : 'full',
}
}
@@ -1,13 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveInitialWindowRoutePath } from './window-route'
describe('resolveInitialWindowRoutePath', () => {
it('uses the hash route before Vue Router hydrates', () => {
expect(resolveInitialWindowRoutePath('/', '#/chat?source=tray')).toBe('/chat')
})
it('uses the router path when no hash route exists', () => {
expect(resolveInitialWindowRoutePath('/settings/data', '')).toBe('/settings/data')
})
})
@@ -1,10 +0,0 @@
function normalizeRoutePath(routePath: string) {
const [path = ''] = routePath.split(/[?#]/)
return path || '/'
}
/** Resolves the initial hash route before Vue Router hydrates `route.path`. */
export function resolveInitialWindowRoutePath(routePath: string, hash = globalThis.location?.hash ?? '') {
const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
return normalizeRoutePath(hashPath || routePath)
}
+3 -1
View File
@@ -89,7 +89,8 @@ watch(settings.themeColorsHueDynamic, () => {
onMounted(async () => {
initializeAnalytics()
await displayModelsStore.initialize()
cardStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
@@ -110,6 +111,7 @@ onMounted(async () => {
onUnmounted(() => {
stopLeadershipListener()
cardStore.disposeRuntime()
contextBridgeStore.dispose()
})
@@ -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()
+107
View File
@@ -0,0 +1,107 @@
diff --git a/dist/tab.js b/dist/tab.js
index 9fe1d1b6fe8b0172251ce7688f742bbb6c3b6da9..5dba20d6f551ec644eb168db95b45e48935879db 100644
--- a/dist/tab.js
+++ b/dist/tab.js
@@ -111,8 +111,8 @@ export class Tab extends EventTarget {
// leader when it died — we are the leader now, so dispatch them to
// ourselves (see the matching re-delivery for non-leader tabs in
// `_onLeader`; the same at-least-once caveat applies).
- this._callDeferreds.forEach(({ name, rest }, callNumber) => {
- if (queued.has(`${this._id}:${callNumber}`))
+ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => {
+ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`))
return;
this._clearSentCall(callNumber);
this._onCall(this._id, callNumber, name, ...rest);
@@ -141,8 +141,12 @@ export class Tab extends EventTarget {
this._clearSentCall(callNumber);
reject(new Error('Call timed out'));
}, this._callTimeout);
- this._callDeferreds.set(callNumber, { resolve, reject, timeout, name, rest });
+ this._callDeferreds.set(callNumber, { awaitingLeader: true, resolve, reject, timeout, name, rest });
const hasLeader = await this.hasLeader();
+ const deferred = this._callDeferreds.get(callNumber);
+ if (!deferred)
+ return;
+ deferred.awaitingLeader = false;
try {
if (this.isLeader && this._isLeaderReady) {
this._onCall(this._id, callNumber, name, ...rest);
@@ -294,8 +298,9 @@ export class Tab extends EventTarget {
if (this._sentCalls.get(callNumber))
this._callReceived(callNumber);
const deferred = this._callDeferreds.get(callNumber);
+ // Delivery is at-least-once, so a duplicate or late return can arrive after the call has already settled.
if (!deferred)
- return console.error('No deferred found for call', callNumber);
+ return;
clearTimeout(deferred.timeout);
this._callDeferreds.delete(callNumber);
if (error)
@@ -345,8 +350,8 @@ export class Tab extends EventTarget {
// The same duplication happens inside a single election: a call queued while the leader was still initializing
// is dispatched by that leader AND re-sent from here, because the leader's `onLeader` broadcast goes out
// synchronously while the queued call's `onReturn` is still awaiting the handler.
- this._callDeferreds.forEach(({ name, rest }, callNumber) => {
- if (queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber))
+ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => {
+ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber))
return;
try {
this._sendCall(callNumber, name, rest);
diff --git a/src/tab.ts b/src/tab.ts
index 3d42674d6276ff707b890d8009d0af22298f00c3..57d4fa50d46a7e2195ea9e51de4586c90a62de2e 100644
--- a/src/tab.ts
+++ b/src/tab.ts
@@ -7,4 +7,6 @@ interface Deferred {
interface Deferred {
+ /** The original call is still waiting for `hasLeader()` to select its first delivery path. */
+ awaitingLeader: boolean;
resolve: (value: any) => void;
reject: (reason?: any) => void;
timeout: number;
@@ -166,8 +168,8 @@ export class Tab<T = Record<string, any>> extends EventTarget implements Tab {
// leader when it died — we are the leader now, so dispatch them to
// ourselves (see the matching re-delivery for non-leader tabs in
// `_onLeader`; the same at-least-once caveat applies).
- this._callDeferreds.forEach(({ name, rest }, callNumber) => {
- if (queued.has(`${this._id}:${callNumber}`)) return;
+ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => {
+ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`)) return;
this._clearSentCall(callNumber);
this._onCall(this._id, callNumber, name, ...rest);
});
@@ -194,8 +196,11 @@ export class Tab<T = Record<string, any>> extends EventTarget implements Tab {
this._clearSentCall(callNumber);
reject(new Error('Call timed out'));
}, this._callTimeout);
- this._callDeferreds.set(callNumber, { resolve, reject, timeout, name, rest });
+ this._callDeferreds.set(callNumber, { awaitingLeader: true, resolve, reject, timeout, name, rest });
const hasLeader = await this.hasLeader();
+ const deferred = this._callDeferreds.get(callNumber);
+ if (!deferred) return;
+ deferred.awaitingLeader = false;
try {
if (this.isLeader && this._isLeaderReady) {
this._onCall(this._id, callNumber, name, ...rest);
@@ -337,7 +342,8 @@ export class Tab<T = Record<string, any>> extends EventTarget implements Tab {
_onReturn(callNumber: number, error: any, results: any) {
if (this._sentCalls.get(callNumber)) this._callReceived(callNumber);
const deferred = this._callDeferreds.get(callNumber);
- if (!deferred) return console.error('No deferred found for call', callNumber);
+ // Delivery is at-least-once, so a duplicate or late return can arrive after the call has already settled.
+ if (!deferred) return;
clearTimeout(deferred.timeout);
this._callDeferreds.delete(callNumber);
if (error) deferred.reject(error);
@@ -386,8 +392,8 @@ export class Tab<T = Record<string, any>> extends EventTarget implements Tab {
// The same duplication happens inside a single election: a call queued while the leader was still initializing
// is dispatched by that leader AND re-sent from here, because the leader's `onLeader` broadcast goes out
// synchronously while the queued call's `onReturn` is still awaiting the handler.
- this._callDeferreds.forEach(({ name, rest }, callNumber) => {
- if (queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber)) return;
+ this._callDeferreds.forEach(({ awaitingLeader, name, rest }, callNumber) => {
+ if (awaitingLeader || queued.has(`${this._id}:${callNumber}`) || this._sentCalls.has(callNumber)) return;
try {
this._sendCall(callNumber, name, rest);
} catch (e) {
+5 -2
View File
@@ -1220,6 +1220,9 @@ patchedDependencies:
sponsorkit@17.1.0:
hash: f5887da52a29ae85732e01644d05c0c274231fe76b95aebeafbfab6dc4444f88
path: patches/sponsorkit@17.1.0.patch
tab-election@4.6.2:
hash: ff8fe3c9aed66469fa00180f039fbf9f4f8e682430bfb3f6a07d9baf3f850674
path: patches/tab-election@4.6.2.patch
uiohook-napi@1.5.5:
hash: 012c5e5ad881d5ae40fc0af58832e31a0772ab0a74c038c8f4dfe51efaf398dd
path: patches/uiohook-napi@1.5.5.patch
@@ -33029,7 +33032,7 @@ snapshots:
'@moeru/std': 0.1.0-beta.20
es-toolkit: 1.50.0
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
tab-election: 4.6.2
tab-election: 4.6.2(patch_hash=ff8fe3c9aed66469fa00180f039fbf9f4f8e682430bfb3f6a07d9baf3f850674)
vue: 3.5.32(typescript@5.9.3)
pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)):
@@ -34673,7 +34676,7 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.9
tab-election@4.6.2: {}
tab-election@4.6.2(patch_hash=ff8fe3c9aed66469fa00180f039fbf9f4f8e682430bfb3f6a07d9baf3f850674): {}
tabbable@6.4.0: {}
+1
View File
@@ -34,6 +34,7 @@ patchedDependencies:
mineflayer-pathfinder: patches/mineflayer-pathfinder.patch
pixi-live2d-display: patches/pixi-live2d-display.patch
sponsorkit@17.1.0: patches/sponsorkit@17.1.0.patch
tab-election@4.6.2: patches/tab-election@4.6.2.patch
uiohook-napi@1.5.5: patches/uiohook-napi@1.5.5.patch
catalog:
'@alexanderolsen/libsamplerate-js': ^2.1.2