mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-29 02:31:49 +08:00
fix(stage-tamagotchi): prevent auxiliary renderer request storms (#2304)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
Generated
+5
-2
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user