fix(stage-ui,stage-tamagotchi,stage-web): infinite write config causing forever loading

This commit is contained in:
Neko Ayaka
2026-08-19 01:07:30 +08:00
parent 677329427f
commit d2b837fa01
33 changed files with 728 additions and 1051 deletions
+13 -7
View File
@@ -1,15 +1,19 @@
<script setup lang="ts">
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 { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { useTheme } from '@proj-airi/ui'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { storeToRefs } from 'pinia'
@@ -22,9 +26,8 @@ import OnboardingPermissionsStep from './components/onboarding/step-permissions.
import { getHostWebSocketConnector } from './modules/websocket-bridge'
useAuthProviderSync()
const contextBridgeStore = useContextBridgeStore()
const authStore = useAuthStore()
const i18n = useI18n()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
@@ -36,7 +39,11 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
const syncedPinia = usePiniaSynced()
useArtistryStore()
useConsciousnessStore()
useSpeechStore()
useSettingsStageModel()
useVisionStore()
const primaryColor = computed(() => {
return isDark.value
@@ -75,8 +82,8 @@ watch(settings.themeColorsHueDynamic, () => {
// Initialize first-time setup check when app mounts
onMounted(async () => {
initializeAnalytics()
await authStore.initialize()
await displayModelsStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
if (onboardingStore.needsOnboarding) {
@@ -96,7 +103,6 @@ onMounted(async () => {
})
onUnmounted(() => {
cardStore.disposeRuntime()
contextBridgeStore.dispose()
})
+32 -14
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { ArtistrySyncPayload } from '@proj-airi/stage-shared'
import { defineInvokeHandler } from '@moeru/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { themeColorFromValue, useThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
import { artistrySyncConfig } from '@proj-airi/stage-shared'
import { ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { initializeAnalytics } from '@proj-airi/stage-ui/libs/analytics'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@@ -17,10 +19,15 @@ import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/c
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { usePerfTracerBridgeStore } from '@proj-airi/stage-ui/stores/perf-tracer-bridge'
import { listProvidersForPluginHost, shouldPublishPluginHostCapabilities } from '@proj-airi/stage-ui/stores/plugin-host-capabilities'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { useTheme } from '@proj-airi/ui'
import { isEqual } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
@@ -84,8 +91,6 @@ chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
const isSpotlightWindow = initialRoutePath === '/spotlight'
const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/')
useAuthProviderSync()
async function refreshPluginRuntimeTools() {
try {
await pluginToolsStore.refresh()
@@ -112,6 +117,7 @@ const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
})
function createFullStageRuntime() {
const authStore = useAuthStore()
const contextBridgeStore = useContextBridgeStore()
const displayModelsStore = useDisplayModelsStore()
const serverChannelSettingsStore = useServerChannelSettingsStore()
@@ -123,6 +129,10 @@ function createFullStageRuntime() {
const stageWindowLifecycleStore = useStageWindowLifecycleStore()
const settingsAudioDeviceStore = useSettingsAudioDevice()
const artistryStore = useArtistryStore()
useConsciousnessStore()
useSpeechStore()
useSettingsStageModel()
useVisionStore()
const { activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions } = storeToRefs(artistryStore)
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const listPlugins = useElectronEventaInvoke(electronPluginList)
@@ -186,16 +196,25 @@ function createFullStageRuntime() {
inspect: () => inspectPluginHost(),
})
let lastSyncedArtistryConfig: ArtistrySyncPayload | undefined
watch([activeProvider, artistryGlobals, activeModel, defaultPromptPrefix, providerOptions], () => {
if (activeProvider.value) {
void syncArtistryConfig({
provider: activeProvider.value as string,
globals: JSON.parse(JSON.stringify(artistryGlobals.value)),
model: activeModel.value,
promptPrefix: defaultPromptPrefix.value,
options: providerOptions.value,
})
}
if (!activeProvider.value)
return
const config = JSON.parse(JSON.stringify({
provider: activeProvider.value,
globals: artistryGlobals.value,
model: activeModel.value,
promptPrefix: defaultPromptPrefix.value,
options: providerOptions.value,
})) as ArtistrySyncPayload
if (isEqual(config, lastSyncedArtistryConfig))
return
// Pinia synchronization applies cloned snapshots in every renderer. Keep
// this IPC bridge edge-triggered so equal snapshots do not repeat IO.
lastSyncedArtistryConfig = config
void syncArtistryConfig(config)
}, { deep: true, immediate: true })
context.value.on(electronGodotStageStatusChanged, (event) => {
@@ -213,8 +232,8 @@ function createFullStageRuntime() {
return {
async initialize() {
initializeAnalytics()
await authStore.initialize()
await displayModelsStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
@@ -260,7 +279,6 @@ function createFullStageRuntime() {
inferencePreload.triggerPreload()
},
dispose() {
cardStore.disposeRuntime()
contextBridgeStore.dispose()
},
}
+2 -1
View File
@@ -50,7 +50,8 @@ const synced = setupSynced({
leadership: resolveRendererWindowContext().leadership,
})
pinia.use(synced.pinia)
pinia.use(piniaPluginTracing)
if (import.meta.env.DEV)
pinia.use(piniaPluginTracing)
const router = createRouter({
history: createWebHashHistory(),
@@ -133,7 +133,7 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?:
if (!extension.airi.modules)
extension.airi.modules = {}
extension.airi.modules.activeBackgroundId = entryId
cardStore.updateCard(cardId, { ...card, extensions: extension })
await cardStore.updateCard(cardId, { ...card, extensions: extension })
}
}
}
@@ -204,7 +204,7 @@ async function executeSetAsBackground(params: { query?: string }) {
if (!extension.airi.modules)
extension.airi.modules = {}
extension.airi.modules.activeBackgroundId = entry.id
cardStore.updateCard(cardId, { ...card, extensions: extension })
await cardStore.updateCard(cardId, { ...card, extensions: extension })
}
}
return `Background set to "${entry.title}".`
+13 -4
View File
@@ -1,17 +1,22 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
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 { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { ErrorBoundary, useTheme } from '@proj-airi/ui'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { storeToRefs } from 'pinia'
@@ -25,9 +30,9 @@ import PerformanceOverlay from './components/Devtools/PerformanceOverlay.vue'
import { usePWAStore } from './stores/pwa'
usePWAStore()
useAuthProviderSync()
const contextBridgeStore = useContextBridgeStore()
const authStore = useAuthStore()
const i18n = useI18n()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
@@ -43,6 +48,11 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
useArtistryStore()
useConsciousnessStore()
useSpeechStore()
useSettingsStageModel()
useVisionStore()
const inferencePreload = useInferencePreload()
const primaryColor = computed(() => {
@@ -88,8 +98,8 @@ watch(settings.themeColorsHueDynamic, () => {
// Initialize first-time setup check when app mounts
onMounted(async () => {
initializeAnalytics()
await authStore.initialize()
await displayModelsStore.initialize()
cardStore.startRuntime(syncedPinia)
await cardStore.initialize()
if (onboardingStore.needsOnboarding) {
@@ -111,7 +121,6 @@ onMounted(async () => {
onUnmounted(() => {
stopLeadershipListener()
cardStore.disposeRuntime()
contextBridgeStore.dispose()
})
+2 -1
View File
@@ -37,7 +37,8 @@ configureAnalyticsAdapter(async (options) => {
const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia)
pinia.use(piniaPluginTracing)
if (import.meta.env.DEV)
pinia.use(piniaPluginTracing)
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
const routeRecords = setupLayouts(routes as RouteRecordRaw[])
@@ -302,7 +302,7 @@ watch(() => props.modelValue, (isOpen) => {
const showError = ref<boolean>(false)
const errorMessage = ref<string>('')
function saveCard(card: Card, activate: boolean): boolean {
async function saveCard(card: Card, activate: boolean): Promise<boolean> {
const draftResult = safeParseAiriCardDraft(toRaw(card), selectedArtistryConfigStr.value)
if (!draftResult.success) {
showError.value = true
@@ -342,7 +342,7 @@ function saveCard(card: Card, activate: boolean): boolean {
let savedCardId: string
if (isEditMode.value && props.cardId) {
// Edit mode: update existing card
if (!cardStore.updateCard(props.cardId, cardWithModules)) {
if (!await cardStore.updateCard(props.cardId, cardWithModules)) {
showError.value = true
errorMessage.value = t('settings.pages.card.card_not_found')
return false
@@ -351,11 +351,11 @@ function saveCard(card: Card, activate: boolean): boolean {
trackCardEdited({ card_id: props.cardId })
}
else {
savedCardId = cardStore.addCard(cardWithModules, 'scratch')
savedCardId = await cardStore.addCard(cardWithModules, 'scratch')
}
if (activate)
cardStore.activeCardId = savedCardId
await cardStore.activateCard(savedCardId)
modelValue.value = false // Close this
return true
@@ -115,8 +115,8 @@ const isActivating = ref(false)
function handleActivate() {
isActivating.value = true
setTimeout(() => {
activeCardId.value = props.cardId
setTimeout(async () => {
await cardStore.activateCard(props.cardId)
isActivating.value = false
}, 300)
}
@@ -149,9 +149,9 @@ function highlightTagToHtml(text: string) {
// Delete confirmation
const showDeleteConfirm = ref(false)
function handleDeleteConfirm() {
async function handleDeleteConfirm() {
if (selectedCard.value) {
removeCard(props.cardId)
await removeCard(props.cardId)
emit('update:modelValue', false)
}
showDeleteConfirm.value = false
@@ -180,7 +180,7 @@ const activeBackgroundId = computed({
extension.airi.modules.activeBackgroundId = val
cardStore.updateCard(props.cardId, {
await cardStore.updateCard(props.cardId, {
...selectedCard.value,
extensions: extension,
})
@@ -59,7 +59,7 @@ watch(inputFiles, async (newFiles) => {
return
try {
addCard(await importAiriCardPackage({ file, displayModelsStore }), 'import')
await addCard(await importAiriCardPackage({ file, displayModelsStore }), 'import')
toast(t('settings.pages.card.imported'))
}
catch (error) {
@@ -113,9 +113,9 @@ const sortedFilteredCards = computed<CardItem[]>(() => {
const showDeleteConfirm = ref(false)
const cardToDelete = ref<string | null>(null)
function handleDeleteConfirm() {
async function handleDeleteConfirm() {
if (cardToDelete.value) {
removeCard(cardToDelete.value)
await removeCard(cardToDelete.value)
cardToDelete.value = null
showDeleteConfirm.value = false
}
@@ -154,7 +154,7 @@ function handleCardCreationDialog() {
// Card activation
function activateCard(id: string) {
activeCardId.value = id
void cardStore.activateCard(id)
}
watch(activeCardId, (cardId, previousCardId) => {
@@ -60,7 +60,7 @@ watch(activeProvider, async (provider, oldProvider) => {
}, { immediate: true })
watch([activeProvider, activeModel], ([provider, model]) => {
airiCardStore.updateActiveCardConsciousness({ provider, model })
void airiCardStore.updateActiveCardConsciousness({ provider, model })
})
function updateCustomModelName(value: string) {
@@ -405,7 +405,7 @@ watch(activeSpeechModel, async (model) => {
})
watch([activeSpeechProvider, activeSpeechModel, activeSpeechVoiceId], ([provider, model, voiceId]) => {
airiCardStore.updateActiveCardSpeech({ provider, model, voice_id: voiceId })
void airiCardStore.updateActiveCardSpeech({ provider, model, voice_id: voiceId })
})
// Function to generate speech
@@ -53,7 +53,7 @@ watch(activeProvider, async (provider, oldProvider) => {
}, { immediate: true })
watch([activeProvider, activeModel], ([provider, model]) => {
airiCardStore.updateActiveCardVision({ provider, model })
void airiCardStore.updateActiveCardVision({ provider, model })
})
function updateCustomModelName(value: string) {
@@ -29,7 +29,7 @@ const activeBackgroundId = computed({
extension.airi.modules.activeBackgroundId = val
cardStore.updateCard(cardStore.activeCardId, {
void cardStore.updateCard(cardStore.activeCardId, {
...cardStore.activeCard,
extensions: extension,
})
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick, watch } from 'vue'
import { useLocalStorageManualReset } from '.'
afterEach(() => {
localStorage.clear()
})
describe('useLocalStorageManualReset', () => {
// ROOT CAUSE:
//
// A synchronized store replaced a Map with a structured clone. Persistence
// serialized that value, then the storage ref reflected another Map clone
// into the store. Pinia saw the reflection as a new direct mutation and
// published another domain snapshot.
//
// We fixed this by making `listenToStorageChanges: false` one-way: state is
// still persisted, but the storage ref cannot write back into live state.
it('does not reflect persisted values when storage listening is disabled', async () => {
const state = useLocalStorageManualReset('cards', new Map<string, string>(), {
listenToStorageChanges: false,
})
let changes = 0
const stop = watch(state, () => {
changes += 1
}, { flush: 'sync' })
state.value = new Map([['card-1', 'ReLU']])
await nextTick()
expect(changes).toBe(1)
expect(state.value).toEqual(new Map([['card-1', 'ReLU']]))
stop()
})
})
@@ -2,7 +2,7 @@ import type { ManualResetRefReturn, UseStorageOptions } from '@vueuse/core'
import type { MaybeRefOrGetter, WatchOptions } from 'vue'
import { refManualReset, useLocalStorage } from '@vueuse/core'
import { unref, watch } from 'vue'
import { toRaw, unref, watch } from 'vue'
export function useLocalStorageManualReset<T>(
key: MaybeRefOrGetter<string>,
@@ -14,11 +14,20 @@ export function useLocalStorageManualReset<T>(
const state = refManualReset<T>(localStorageState)
const { resume, pause } = watch(state, newValue => localStorageState.value = newValue, options)
watch(localStorageState, (newValue) => {
pause()
state.value = newValue
resume()
}, options)
if (options?.listenToStorageChanges !== false) {
watch(localStorageState, (newValue) => {
// Writing state to useStorage updates this ref with the same value. A
// manual ref triggers even when assigned the same reference, so reflecting
// that write would publish a second Pinia mutation. Only storage-originated
// values need to cross this boundary.
if (toRaw(newValue) === toRaw(state.value))
return
pause()
state.value = newValue
resume()
}, options)
}
return state
}
@@ -92,7 +92,7 @@ onClickOutside(containerRef, () => {
cancelCreate()
})
function handleSelection(value: ProfileSelectValue) {
async function handleSelection(value: ProfileSelectValue) {
if (value === CREATE_PROFILE_ACTION) {
selectedProfile.value = activeCardId.value
void showCreateInput()
@@ -105,7 +105,7 @@ function handleSelection(value: ProfileSelectValue) {
return
}
activeCardId.value = value
await cardStore.activateCard(value)
}
async function showCreateInput() {
@@ -117,19 +117,19 @@ async function showCreateInput() {
nameInputRef.value?.select()
}
function confirmCreate() {
async function confirmCreate() {
const current = activeCard.value
const name = newProfileName.value.trim()
if (!current || !name || isDuplicateName.value) {
return
}
const newId = cardStore.addCard({
const newId = await cardStore.addCard({
...structuredClone(toRaw(current)),
name,
}, 'duplicate')
activeCardId.value = newId
await cardStore.activateCard(newId)
cancelCreate()
}
@@ -61,7 +61,7 @@ const effectiveRenderer = computed(() => resolveModelSettingsPanelRenderer({
async function handleModelPick(selectedModel: DisplayModel | undefined) {
stageModelSelected.value = selectedModel?.id ?? ''
airiCardStore.updateActiveCardDisplayModel(selectedModel?.id)
await airiCardStore.updateActiveCardDisplayModel(selectedModel?.id)
await settingsStore.updateStageModel()
}
</script>
@@ -1,238 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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: '',
activeModel: '',
activeVisionProvider: '',
activeVisionModel: '',
activeSpeechProvider: 'speech-noop',
activeSpeechModel: '',
activeSpeechVoiceId: '',
activeTranscriptionProvider: '',
activeTranscriptionModel: '',
}))
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(),
fetchModelsForProvider: vi.fn(async (_providerId: string): Promise<unknown[]> => []),
loadConsciousnessModels: vi.fn(async () => []),
loadVisionModels: vi.fn(async () => []),
trackOfficialProviderSelected: vi.fn(),
}))
vi.mock('../libs/auth', () => ({
initializeAuth: syncMocks.initializeAuth,
}))
vi.mock('../libs/pinia', () => ({
usePiniaSynced: () => ({
isLeader: () => syncState.isLeader,
onLeadershipChange: (hook: (isLeader: boolean) => void) => {
syncMocks.leadershipHook = hook
return vi.fn()
},
}),
}))
vi.mock('../libs/providers', () => ({
getStreamingTtsAvailable: () => true,
OFFICIAL_TRANSCRIPTION_PROVIDER_ID: 'official-provider-transcription',
}))
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()
}
},
}),
}))
vi.mock('../stores/providers/provider', () => ({
useProviderStore: () => ({
findProviderDefinition: () => ({}),
forceProviderConfigured: syncMocks.forceProviderConfigured,
setProviderUnconfigured: syncMocks.setProviderUnconfigured,
setProviderAvailabilityOverride: syncMocks.setProviderAvailabilityOverride,
fetchModelsForProvider: syncMocks.fetchModelsForProvider,
}),
}))
vi.mock('../stores/modules/consciousness', () => ({
useConsciousnessStore: () => ({
get activeProvider() { return syncState.activeProvider },
set activeProvider(value: string) { syncState.activeProvider = value },
get activeModel() { return syncState.activeModel },
set activeModel(value: string) { syncState.activeModel = value },
loadModelsForProvider: syncMocks.loadConsciousnessModels,
}),
}))
vi.mock('../stores/modules/vision', () => ({
useVisionStore: () => ({
get activeProvider() { return syncState.activeVisionProvider },
set activeProvider(value: string) { syncState.activeVisionProvider = value },
get activeModel() { return syncState.activeVisionModel },
set activeModel(value: string) { syncState.activeVisionModel = value },
loadModelsForProvider: syncMocks.loadVisionModels,
}),
}))
vi.mock('../stores/modules/speech', () => ({
useSpeechStore: () => ({
get activeSpeechProvider() { return syncState.activeSpeechProvider },
set activeSpeechProvider(value: string) { syncState.activeSpeechProvider = value },
get activeSpeechModel() { return syncState.activeSpeechModel },
set activeSpeechModel(value: string) { syncState.activeSpeechModel = value },
get activeSpeechVoiceId() { return syncState.activeSpeechVoiceId },
set activeSpeechVoiceId(value: string) { syncState.activeSpeechVoiceId = value },
ensureStreamingDefaultModel: vi.fn(),
loadVoicesForProvider: vi.fn(async () => []),
}),
}))
vi.mock('../stores/modules/hearing', () => ({
useHearingStore: () => ({
get activeTranscriptionProvider() { return syncState.activeTranscriptionProvider },
set activeTranscriptionProvider(value: string) { syncState.activeTranscriptionProvider = value },
get activeTranscriptionModel() { return syncState.activeTranscriptionModel },
set activeTranscriptionModel(value: string) { syncState.activeTranscriptionModel = value },
}),
}))
vi.mock('./use-analytics', () => ({
useAnalytics: () => ({
trackOfficialProviderSelected: syncMocks.trackOfficialProviderSelected,
}),
}))
describe('useAuthProviderSync', () => {
beforeEach(() => {
syncState.isLeader = false
syncState.authenticatedHook = undefined
syncState.logoutHook = undefined
syncMocks.leadershipHook = undefined
syncState.activeProvider = ''
syncState.activeModel = ''
syncState.activeVisionProvider = ''
syncState.activeVisionModel = ''
syncState.activeSpeechProvider = 'speech-noop'
syncState.activeSpeechModel = ''
syncState.activeSpeechVoiceId = ''
syncState.activeTranscriptionProvider = ''
syncState.activeTranscriptionModel = ''
vi.clearAllMocks()
syncMocks.fetchModelsForProvider.mockResolvedValue([])
})
it('starts auth initialization when this renderer becomes the leader', async () => {
useAuthProviderSync()
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?.()
expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith('official-provider')
expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith('vision-official-provider')
expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith('official-provider-speech')
expect(syncMocks.forceProviderConfigured).toHaveBeenCalledWith(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
expect(syncState.activeProvider).toBe('official-provider')
expect(syncState.activeModel).toBe('auto')
expect(syncState.activeSpeechProvider).toBe('official-provider-speech')
expect(syncState.activeTranscriptionProvider).toBe(OFFICIAL_TRANSCRIPTION_PROVIDER_ID)
})
it('retries provider activation when the first authenticated sync fails', async () => {
// ROOT CAUSE:
//
// 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')
return []
})
useAuthProviderSync()
await expect(syncState.authenticatedHook?.()).rejects.toThrow('temporary catalog failure')
syncMocks.fetchModelsForProvider.mockResolvedValue([])
await expect(syncState.authenticatedHook?.()).resolves.toBeUndefined()
expect(syncMocks.forceProviderConfigured).toHaveBeenCalledTimes(9)
expect(syncMocks.forceProviderConfigured.mock.calls.filter(([providerId]) => providerId === 'official-provider')).toHaveLength(2)
expect(syncMocks.forceProviderConfigured.mock.calls.filter(([providerId]) => providerId === 'official-provider-speech')).toHaveLength(2)
expect(syncMocks.forceProviderConfigured.mock.calls.filter(([providerId]) => providerId === OFFICIAL_TRANSCRIPTION_PROVIDER_ID)).toHaveLength(2)
})
})
@@ -1,282 +0,0 @@
import { nextTick } from 'vue'
import { initializeAuth } from '../libs/auth'
import { usePiniaSynced } from '../libs/pinia'
import { getStreamingTtsAvailable, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from '../libs/providers'
import { useAuthStore } from '../stores/auth'
import { useConsciousnessStore } from '../stores/modules/consciousness'
import { useHearingStore } from '../stores/modules/hearing'
import { useSpeechStore } from '../stores/modules/speech'
import { useVisionStore } from '../stores/modules/vision'
import { useProviderStore } from '../stores/providers/provider'
import { useAnalytics } from './use-analytics'
/**
* Provider IDs to auto-activate on sign-in.
* Edit this list to enable/disable official providers.
*/
const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 'speech' | 'hearing' | 'vision' }> = [
{ id: 'official-provider', module: 'consciousness' },
{ id: 'vision-official-provider', module: 'vision' },
{ id: 'official-provider-speech', module: 'speech' },
{ id: OFFICIAL_TRANSCRIPTION_PROVIDER_ID, module: 'hearing' },
]
// The streaming TTS provider is NOT in the static list above because its
// visibility is operator-controlled: `UNSPEECH_UPSTREAM.streaming` may be
// unconfigured server-side. It's bootstrapped separately (see
// `syncStreamingSpeechProvider`) — probed on sign-in, then force-configured
// only when the server reports it available, mirroring how the HTTP TTS
// provider uses `forceProviderConfigured` but gating it on a server signal.
const STREAMING_SPEECH_PROVIDER_ID = 'official-provider-speech-streaming'
/**
* Glue layer: uses auth lifecycle hooks to activate/deactivate
* official providers. Providers themselves know nothing about auth.
*
* Call once from each app renderer root so direct sign-in routes and
* auxiliary windows do not depend on the transient Stage scene lifecycle.
*/
export function useAuthProviderSync() {
const syncedPinia = usePiniaSynced()
let leaderSyncInitialized = false
let disposeAuthenticatedProviderSync: (() => void) | undefined
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)
initializeLeaderSync()
else
disposeLeaderSync()
})
initializeLeaderSync()
}
function setupAuthenticatedProviderSync() {
const authStore = useAuthStore()
const providersStore = useProviderStore()
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const hearingStore = useHearingStore()
const { trackOfficialProviderSelected } = useAnalytics()
// Track the completed and in-flight work separately. Authentication can be
// announced more than once while a catalog request is still pending; those
// notifications share one task, while a failed task remains retryable.
let hasSynced = false
let authGeneration = 0
let syncInFlight: Promise<void> | undefined
const stopAuthenticatedHook = authStore.onAuthenticated(async () => {
if (hasSynced)
return
if (syncInFlight)
return syncInFlight
const generation = authGeneration
const task = syncAuthenticatedProviders(generation)
syncInFlight = task
try {
await task
if (generation === authGeneration)
hasSynced = true
}
finally {
if (syncInFlight === task)
syncInFlight = undefined
}
})
async function syncAuthenticatedProviders(generation: number) {
if (generation !== authGeneration)
return
const toActivate = AUTH_ACTIVATED_PROVIDERS.filter(
p => providersStore.findProviderDefinition(p.id) != null,
)
for (const { id } of toActivate) {
providersStore.forceProviderConfigured(id)
}
// Only set official provider as active when the user hasn't configured
// any provider for that module yet.
for (const { id, module } of toActivate) {
switch (module) {
case 'consciousness':
if (!consciousnessStore.activeProvider) {
consciousnessStore.activeProvider = id
consciousnessStore.activeModel = 'auto'
trackOfficialProviderSelected({
provider_id: id,
provider_mode: 'official',
source: 'default_auto',
auto_selected: true,
model_id: 'auto',
})
}
break
case 'vision':
if (!visionStore.activeProvider) {
visionStore.activeProvider = id
visionStore.activeModel = 'auto'
}
break
case 'speech':
if (!speechStore.activeSpeechProvider || speechStore.activeSpeechProvider === 'speech-noop') {
speechStore.activeSpeechProvider = id
speechStore.activeSpeechModel = ''
}
break
case 'hearing':
if (!hearingStore.activeTranscriptionProvider) {
hearingStore.activeTranscriptionProvider = id
hearingStore.activeTranscriptionModel = 'auto'
}
break
}
}
await nextTick()
try {
await Promise.all(
toActivate.map(({ id, module }) =>
module === 'consciousness'
? consciousnessStore.loadModelsForProvider(id)
: module === 'vision'
? visionStore.loadModelsForProvider(id)
: providersStore.fetchModelsForProvider(id),
),
)
}
catch (err) {
console.error('error loading models for official providers', err)
}
// Logout may happen while provider catalogs are loading. The logout hook
// owns cleanup, so stale work must not re-enable streaming TTS afterward.
if (generation !== authGeneration)
return
await syncStreamingSpeechProvider()
}
// Bootstrap the streaming TTS provider from the server's availability signal.
// Probing populates `getStreamingTtsAvailable()` (and the default model /
// voices) via the provider's listModels(). The availability override drives
// the provider's presence in the available/configured lists (and thus the
// settings card + picker); force-configure makes it selectable. It is never
// set as the active speech provider — the HTTP TTS provider stays default.
async function syncStreamingSpeechProvider() {
if (providersStore.findProviderDefinition(STREAMING_SPEECH_PROVIDER_ID) == null)
return
await providersStore.fetchModelsForProvider(STREAMING_SPEECH_PROVIDER_ID)
const available = getStreamingTtsAvailable()
providersStore.setProviderAvailabilityOverride(STREAMING_SPEECH_PROVIDER_ID, available)
if (available) {
providersStore.forceProviderConfigured(STREAMING_SPEECH_PROVIDER_ID)
// The speech-module watcher skips voice loading for streaming until it's
// confirmed configured (avoids a pre-probe request on reload), so when
// streaming is the persisted active provider, load its voices now that
// it's confirmed available.
if (speechStore.activeSpeechProvider === STREAMING_SPEECH_PROVIDER_ID) {
speechStore.ensureStreamingDefaultModel()
await speechStore.loadVoicesForProvider(STREAMING_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel || undefined)
}
return
}
providersStore.setProviderUnconfigured(STREAMING_SPEECH_PROVIDER_ID)
// `setProviderUnconfigured` blanks `validatedCredentialHash`, which makes
// the speech-module reset watcher skip its own clear. So when the server
// now reports streaming unavailable on an authenticated reload (no logout
// event fires), clear a stale active streaming selection here.
clearActiveStreamingSelection()
}
function clearActiveStreamingSelection() {
if (speechStore.activeSpeechProvider !== STREAMING_SPEECH_PROVIDER_ID)
return
speechStore.activeSpeechProvider = ''
speechStore.activeSpeechModel = ''
speechStore.activeSpeechVoiceId = ''
}
const stopLogoutHook = authStore.onLogout(() => {
authGeneration++
hasSynced = false
for (const { id } of AUTH_ACTIVATED_PROVIDERS) {
providersStore.setProviderUnconfigured(id)
}
// Streaming TTS is bootstrapped outside AUTH_ACTIVATED_PROVIDERS, so reset
// it explicitly. `setProviderUnconfigured` blanks `validatedCredentialHash`,
// which makes the speech-module watcher skip its own reset (it guards
// against racing initial validation), so clear the active selection here
// too when streaming was the active provider.
clearActiveStreamingSelection()
providersStore.setProviderUnconfigured(STREAMING_SPEECH_PROVIDER_ID)
providersStore.setProviderAvailabilityOverride(STREAMING_SPEECH_PROVIDER_ID, false)
// Reset active provider/model if they belong to an auth-activated provider
for (const { id, module } of AUTH_ACTIVATED_PROVIDERS) {
switch (module) {
case 'consciousness':
if (consciousnessStore.activeProvider === id) {
consciousnessStore.activeProvider = ''
consciousnessStore.activeModel = ''
}
break
case 'vision':
if (visionStore.activeProvider === id) {
visionStore.activeProvider = ''
visionStore.activeModel = ''
}
break
case 'speech':
if (speechStore.activeSpeechProvider === id) {
speechStore.activeSpeechProvider = ''
speechStore.activeSpeechModel = ''
}
break
case 'hearing':
if (hearingStore.activeTranscriptionProvider === id) {
hearingStore.activeTranscriptionProvider = ''
hearingStore.activeTranscriptionModel = ''
}
break
}
}
})
return () => {
authGeneration++
stopAuthenticatedHook()
stopLogoutHook()
}
}
@@ -5,13 +5,105 @@ import { errorMessageFrom } from '@moeru/std'
import { piniaActionTracingChannelName } from '@proj-airi/stage-shared/types/pinia-action-event'
import { nanoid } from 'nanoid/non-secure'
const piniaActionChannel = new BroadcastChannel(piniaActionTracingChannelName)
const rateTraceStorageKey = 'airi:debug:pinia-tracing'
const rateTraceIntervalMs = 5_000
const rateTraceLimit = 10
let piniaActionChannel: BroadcastChannel | undefined
interface RateTraceWindow {
actionFailures: number
actions: Map<string, number>
mutations: Map<string, number>
mutationTypes: Map<string, number>
startedAt: number
}
let rateTraceTimer: ReturnType<typeof setInterval> | undefined
let rateTraceWindow: RateTraceWindow | undefined
function createRateTraceWindow(): RateTraceWindow {
return {
actionFailures: 0,
actions: new Map(),
mutations: new Map(),
mutationTypes: new Map(),
startedAt: performance.now(),
}
}
function incrementRateTraceCount(counts: Map<string, number>, key: string): void {
counts.set(key, (counts.get(key) ?? 0) + 1)
}
function totalRateTraceCount(counts: Map<string, number>): number {
let total = 0
for (const count of counts.values())
total += count
return total
}
function topRateTraceCounts(counts: Map<string, number>): Array<{ count: number, name: string }> {
return [...counts.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, rateTraceLimit)
.map(([name, count]) => ({ count, name }))
}
function reportRateTraceWindow(): void {
const window = rateTraceWindow
if (!window)
return
const actionCount = totalRateTraceCount(window.actions)
const mutationCount = totalRateTraceCount(window.mutations)
if (actionCount === 0 && mutationCount === 0)
return
const elapsedMs = performance.now() - window.startedAt
const summary = {
actionCount,
actionFailures: window.actionFailures,
actionsPerSecond: Number((actionCount * 1_000 / elapsedMs).toFixed(1)),
elapsedMs: Math.round(elapsedMs),
mutationCount,
mutationsPerSecond: Number((mutationCount * 1_000 / elapsedMs).toFixed(1)),
mutationTypes: topRateTraceCounts(window.mutationTypes),
sourceUrl: location.href,
topActions: topRateTraceCounts(window.actions),
topMutations: topRateTraceCounts(window.mutations),
}
console.info(`[DEBUG-pinia-rate] ${JSON.stringify(summary)}`)
window.actionFailures = 0
window.actions.clear()
window.mutations.clear()
window.mutationTypes.clear()
window.startedAt = performance.now()
}
function startRateTracing(): RateTraceWindow | undefined {
if (!import.meta.env.DEV || typeof window === 'undefined' || window.localStorage.getItem(rateTraceStorageKey) !== 'true')
return
rateTraceWindow ??= createRateTraceWindow()
if (!rateTraceTimer) {
rateTraceTimer = setInterval(reportRateTraceWindow, rateTraceIntervalMs)
window.addEventListener('pagehide', () => {
clearInterval(rateTraceTimer)
rateTraceTimer = undefined
}, { once: true })
}
return rateTraceWindow
}
function emitActionEvent(
event: Omit<PiniaActionEvent, 'status' | 'timestamp'>,
status: PiniaActionEventStatus,
error?: unknown,
): void {
piniaActionChannel ??= new BroadcastChannel(piniaActionTracingChannelName)
piniaActionChannel.postMessage({
...event,
status,
@@ -24,9 +116,23 @@ function emitActionEvent(
* Traces Pinia action lifecycle events through a broadcast channel.
*
* The plugin never retains action arguments, results, or state snapshots.
* In development, set `airi:debug:pinia-tracing` to `true` in local storage
* before a reload to print one action and mutation rate summary every five seconds.
*/
export const piniaPluginTracing: PiniaPlugin = ({ store }) => {
const tracedWindow = startRateTracing()
if (tracedWindow) {
store.$subscribe((mutation) => {
incrementRateTraceCount(tracedWindow.mutations, mutation.storeId)
incrementRateTraceCount(tracedWindow.mutationTypes, mutation.type)
}, { detached: true, flush: 'sync' })
}
store.$onAction(({ name, after, onError }) => {
if (tracedWindow)
incrementRateTraceCount(tracedWindow.actions, `${store.$id}.${name}`)
const event = {
invocationId: nanoid(),
storeId: store.$id,
@@ -36,6 +142,10 @@ export const piniaPluginTracing: PiniaPlugin = ({ store }) => {
emitActionEvent(event, 'started')
after(() => emitActionEvent(event, 'completed'))
onError(error => emitActionEvent(event, 'failed', error))
onError((error) => {
if (tracedWindow)
tracedWindow.actionFailures += 1
emitActionEvent(event, 'failed', error)
})
})
}
+73 -1
View File
@@ -1,7 +1,7 @@
import type { Session, User } from 'better-auth'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { triggerSignIn } from '../libs/auth'
@@ -23,6 +23,34 @@ vi.mock('../libs/auth-oidc', () => ({
refreshAccessToken: vi.fn(),
}))
class MemoryStorage implements Storage {
readonly values = new Map<string, string>()
get length() {
return this.values.size
}
clear() {
this.values.clear()
}
getItem(key: string) {
return this.values.get(key) ?? null
}
key(index: number) {
return [...this.values.keys()][index] ?? null
}
removeItem(key: string) {
this.values.delete(key)
}
setItem(key: string, value: string) {
this.values.set(key, value)
}
}
const user: User = {
id: 'user-1',
name: 'AIRI User',
@@ -42,7 +70,11 @@ const session: Session = {
}
describe('auth store sign-in requests', () => {
let storage: MemoryStorage
beforeEach(() => {
storage = new MemoryStorage()
vi.stubGlobal('localStorage', storage)
setActivePinia(createPinia())
vi.mocked(triggerSignIn).mockReset()
vi.mocked(triggerSignIn).mockResolvedValue()
@@ -50,6 +82,10 @@ describe('auth store sign-in requests', () => {
vi.mocked(requestAuthSession).mockResolvedValue({ user, session })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('allows sign-in to be requested again after an external flow is canceled', async () => {
const authStore = useAuthStore()
@@ -94,7 +130,43 @@ describe('auth store sign-in requests', () => {
expect(authStore.idToken).toBe('new-id-token')
expect(authStore.user).toEqual(user)
expect(authStore.session).toEqual(session)
expect(storage.getItem('auth/v1/token')).toBe('new-access-token')
expect(storage.getItem('auth/v1/refresh-token')).toBe('new-refresh-token')
expect(storage.getItem('auth/v1/oidc-id-token')).toBe('new-id-token')
expect(storage.getItem('auth/v1/oidc-client-id')).toBe('airi-stage-electron')
expect(storage.getItem('auth/v1/oidc-token-expiry')).not.toBeNull()
await authStore.clearAllAuthState()
})
it('loads persisted credentials only when the store initializes', async () => {
storage.values.set('auth/v1/token', 'persisted-access-token')
storage.values.set('auth/v1/refresh-token', 'persisted-refresh-token')
storage.values.set('auth/v1/oidc-id-token', 'persisted-id-token')
storage.values.set('auth/v1/oidc-client-id', 'airi-stage-web')
storage.values.set('auth/v1/oidc-token-expiry', String(Date.now() + 60_000))
const authStore = useAuthStore()
expect(authStore.token).toBeNull()
await authStore.initialize()
expect(requestAuthSession).toHaveBeenCalledWith('persisted-access-token')
expect(authStore.token).toBe('persisted-access-token')
})
it('does not persist state patches received from another window', async () => {
const authStore = useAuthStore()
// ROOT CAUSE:
//
// `useLocalStorage` observed every Pinia patch and wrote it to storage.
// The storage event then reached another window, whose synced Pinia patch
// wrote the same value back. This formed an unbounded cross-window loop.
// Auth persistence now runs only inside auth commands.
authStore.$patch({ token: 'synced-access-token' })
await nextTick()
expect(storage.values.size).toBe(0)
})
})
+92 -42
View File
@@ -3,7 +3,7 @@ import type {} from 'pinia-plugin-synced'
import { errorMessageFrom } from '@moeru/std'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { StorageSerializers, useLocalStorage, useTimeoutFn, whenever } from '@vueuse/core'
import { useTimeoutFn, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
@@ -14,6 +14,45 @@ import { authClient, requestAuthSession } from '../libs/auth-client'
import { refreshAccessToken } from '../libs/auth-oidc'
import { SERVER_URL } from '../libs/server'
function createLocalStorageForAuth() {
const keys = {
accessToken: 'auth/v1/token',
refreshToken: 'auth/v1/refresh-token',
idToken: 'auth/v1/oidc-id-token',
oidcClientId: 'auth/v1/oidc-client-id',
tokenExpiry: 'auth/v1/oidc-token-expiry',
} as const
function setOptional(key: string, value: string | null): void {
if (value === null) {
localStorage.removeItem(key)
return
}
localStorage.setItem(key, value)
}
return {
clear() {
for (const key of Object.values(keys))
localStorage.removeItem(key)
},
getAccessToken: () => localStorage.getItem(keys.accessToken),
getRefreshToken: () => localStorage.getItem(keys.refreshToken),
getIdToken: () => localStorage.getItem(keys.idToken),
getOidcClientId: () => localStorage.getItem(keys.oidcClientId),
getTokenExpiry() {
const expiry = Number.parseInt(localStorage.getItem(keys.tokenExpiry) ?? '', 10)
return Number.isFinite(expiry) ? expiry : null
},
setAccessToken: (value: string | null) => setOptional(keys.accessToken, value),
setRefreshToken: (value: string | null) => setOptional(keys.refreshToken, value),
setIdToken: (value: string | null) => setOptional(keys.idToken, value),
setOidcClientId: (value: string | null) => setOptional(keys.oidcClientId, value),
setTokenExpiry: (value: number | null) => setOptional(keys.tokenExpiry, value?.toString() ?? null),
}
}
/** Tokens that complete one OIDC sign-in flow. */
export interface AuthTokenSet {
accessToken: string
@@ -30,29 +69,29 @@ export interface AuthTokenSet {
* `providers` to safely depend on it without creating a circular import.
*/
export const useAuthStore = defineStore('auth', () => {
const user = useLocalStorage<User | null>('auth/v1/user', null, {
// Why: https://github.com/vueuse/vueuse/pull/614#issuecomment-875450160
serializer: StorageSerializers.object,
})
const session = useLocalStorage<Session | null>('auth/v1/session', null, { serializer: StorageSerializers.object })
const token = useLocalStorage<string | null>('auth/v1/token', null)
const refreshToken = useLocalStorage<string | null>('auth/v1/refresh-token', null)
const storage = createLocalStorageForAuth()
// Pinia owns live auth state. Persistence is command-driven so a state patch
// received from another window cannot write back into the transport.
const user = ref<User | null>(null)
const session = ref<Session | null>(null)
const token = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
// NOTICE:
// Persisted to drive `id_token_hint` on RP-Initiated Logout
// (`/api/auth/oauth2/end-session`). The `sid` claim inside the ID token is
// what lets the OIDC provider locate the server-side session row to delete
// — without this we'd be back to relying on cross-site session cookies.
const idToken = useLocalStorage<string | null>('auth/v1/oidc-id-token', null)
const idToken = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value && !!session.value)
const userId = computed(() => user.value?.id ?? 'local')
// --- OIDC token refresh state ---
// Persisted so refresh scheduling survives page reloads.
const oidcClientId = useLocalStorage<string | null>('auth/v1/oidc-client-id', null)
const tokenExpiry = useLocalStorage<number | null>('auth/v1/oidc-token-expiry', null)
const oidcClientId = ref<string | null>(null)
const tokenExpiry = ref<number | null>(null)
const initialized = ref(false)
const credits = useLocalStorage<number>('user/v1/flux', 0)
const credits = ref(0)
// Cross-app "user must log in" flag. Setting this to true triggers an
// immediate OIDC redirect on web (mobile + desktop). Electron skips this
@@ -104,30 +143,6 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Dispatch hooks when auth state changes
watch(isAuthenticated, async (val, oldVal) => {
if (val && !oldVal) {
for (const hook of authenticatedHooks) {
try {
await hook()
}
catch (e) {
console.error('auth hook error', e)
}
}
}
if (!val && oldVal) {
for (const hook of logoutHooks) {
try {
await hook()
}
catch (e) {
console.error('logout hook error', e)
}
}
}
})
// --- OIDC token refresh scheduling ---
// Uses useTimeoutFn for automatic cleanup on store teardown.
// The delay ref is updated by scheduleTokenRefresh before calling start().
@@ -148,10 +163,14 @@ export const useAuthStore = defineStore('auth', () => {
try {
const tokens = await refreshAccessToken(oidcClientId.value!, refreshToken.value!)
token.value = tokens.access_token
if (tokens.refresh_token)
storage.setAccessToken(tokens.access_token)
if (tokens.refresh_token) {
refreshToken.value = tokens.refresh_token
storage.setRefreshToken(tokens.refresh_token)
}
if (tokens.expires_in) {
tokenExpiry.value = Date.now() + tokens.expires_in * 1000
storage.setTokenExpiry(tokenExpiry.value)
scheduleTokenRefresh(tokens.expires_in)
}
@@ -217,6 +236,12 @@ export const useAuthStore = defineStore('auth', () => {
initialized.value = true
token.value = storage.getAccessToken()
refreshToken.value = storage.getRefreshToken()
idToken.value = storage.getIdToken()
oidcClientId.value = storage.getOidcClientId()
tokenExpiry.value = storage.getTokenExpiry()
const hasRefreshToken = !!refreshToken.value
const hasClientId = !!oidcClientId.value
if (hasRefreshToken !== hasClientId) {
@@ -238,6 +263,11 @@ export const useAuthStore = defineStore('auth', () => {
? Date.now() + tokens.expiresIn * 1000
: null
scheduleTokenRefresh(tokens.expiresIn)
storage.setAccessToken(token.value)
storage.setRefreshToken(refreshToken.value)
storage.setIdToken(idToken.value)
storage.setOidcClientId(oidcClientId.value)
storage.setTokenExpiry(tokenExpiry.value)
return await fetchSession(tokens.accessToken)
}
@@ -316,6 +346,7 @@ export const useAuthStore = defineStore('auth', () => {
oidcClientId.value = null
tokenExpiry.value = null
idToken.value = null
storage.clear()
}
async function clearAllAuthState(): Promise<void> {
@@ -332,17 +363,36 @@ export const useAuthStore = defineStore('auth', () => {
}
}
watch(isAuthenticated, async (val) => {
if (val) {
updateCredits()
// This is the only watcher that reacts to an auth-state transition. Each
// window runs its own lifecycle hooks, while persistence remains owned by
// the auth commands above.
watch(isAuthenticated, async (authenticated, wasAuthenticated) => {
if (authenticated) {
void updateCredits()
needsLogin.value = false
if (!wasAuthenticated)
await dispatchHooks(authenticatedHooks, 'auth hook error')
}
else {
credits.value = 0
if (wasAuthenticated)
await dispatchHooks(logoutHooks, 'logout hook error')
}
}, { immediate: true })
async function dispatchHooks(hooks: AuthHook[], errorLabel: string): Promise<void> {
for (const hook of hooks) {
try {
await hook()
}
catch (error) {
console.error(errorLabel, error)
}
}
}
return {
user,
userId,
@@ -1,5 +1,3 @@
import type { SyncedPiniaRuntime } from 'pinia-plugin-synced'
import type { AiriCard } from './airi-card'
import { createPinia, setActivePinia } from 'pinia'
@@ -8,21 +6,9 @@ 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'>
const { resetArtistryToGlobal } = vi.hoisted(() => ({
resetArtistryToGlobal: vi.fn(),
}))
// NOTICE:
// Vitest runs these store tests in Node, where localforage cannot select a
@@ -55,7 +41,7 @@ vi.mock('./artistry', async () => {
providerOptions: {},
}),
actions: {
resetToGlobal() {},
resetToGlobal: resetArtistryToGlobal,
},
}),
}
@@ -114,24 +100,23 @@ vi.mock('vue-i18n', () => ({
describe('airi-card store', () => {
beforeEach(() => {
setActivePinia(createPinia())
syncedRuntime.isLeader = false
syncedRuntime.leadershipListener = undefined
syncedRuntime.stopLeadershipListener.mockClear()
resetArtistryToGlobal.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.
// A synchronized state snapshot replaced `activeCardId`. The old watcher
// interpreted that replicated state as a user command and applied module
// settings, which produced another synchronized snapshot.
//
// https://github.com/moeru-ai/airi/pull/2304
it('reinstalls the card watcher when a follower becomes the leader', async () => {
// We fixed this by applying settings only from the synchronized activation
// action. State replication remains free of runtime side effects.
it('applies card settings only through the activation command', async () => {
const stageModelStore = useSettingsStageModel()
const cardStore = useAiriCardStore()
await cardStore.initialize()
const vrmCardId = cardStore.addCard({
const vrmCardId = await cardStore.addCard({
name: 'VRM card',
version: '1.0.0',
description: 'Card for the promoted leader.',
@@ -147,7 +132,7 @@ describe('airi-card store', () => {
},
},
}, 'scratch')
const live2dCardId = cardStore.addCard({
const live2dCardId = await cardStore.addCard({
name: 'Live2D card',
version: '1.0.0',
description: 'Card for the active leader.',
@@ -165,24 +150,14 @@ describe('airi-card store', () => {
}, 'scratch')
stageModelStore.stageModelSelected = 'preset-live2d-1'
cardStore.startRuntime(syncedPinia)
cardStore.activeCardId = vrmCardId
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
syncedRuntime.leadershipListener?.(true)
await cardStore.activateCard(vrmCardId)
expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1')
cardStore.activeCardId = live2dCardId
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
cardStore.$patch({ activeCardId: live2dCardId })
expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1')
syncedRuntime.leadershipListener?.(false)
cardStore.activeCardId = vrmCardId
await cardStore.activateCard(live2dCardId)
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', () => {
@@ -205,17 +180,17 @@ describe('airi-card store', () => {
* @example
* it('persists selected module config on active card', () => {})
*/
it('persists selected module config on active card', () => {
it('persists selected module config on active card', async () => {
const stageModelStore = useSettingsStageModel()
stageModelStore.stageModelSelected = 'preset-live2d-1'
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
expect(cardStore.updateActiveCardDisplayModel('display-model-iru-v2')).toBe(true)
expect(cardStore.updateActiveCardConsciousness({ provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' })).toBe(true)
expect(cardStore.updateActiveCardVision({ provider: 'ollama', model: 'llava' })).toBe(true)
expect(cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true)
expect(await cardStore.updateActiveCardDisplayModel('display-model-iru-v2')).toBe(true)
expect(await cardStore.updateActiveCardConsciousness({ provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' })).toBe(true)
expect(await cardStore.updateActiveCardVision({ provider: 'ollama', model: 'llava' })).toBe(true)
expect(await cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules).toMatchObject({
displayModelId: 'display-model-iru-v2',
consciousness: { provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' },
@@ -234,12 +209,12 @@ describe('airi-card store', () => {
//
// We fixed this by applying card settings from the stable activation key.
// https://github.com/moeru-ai/airi/issues/2089
it('issue #2089: applies the activated card display model to the stage runtime', () => {
it('issue #2089: applies the activated card display model to the stage runtime', async () => {
const stageModelStore = useSettingsStageModel()
stageModelStore.stageModelSelected = 'preset-live2d-1'
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
const card: AiriCard = {
name: 'VRM card',
@@ -257,21 +232,21 @@ describe('airi-card store', () => {
},
},
}
const cardId = cardStore.addCard(card, 'scratch')
const cardId = await cardStore.addCard(card, 'scratch')
cardStore.activeCardId = cardId
await cardStore.activateCard(cardId)
expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1')
})
it('applies edits to the currently active card display model', () => {
it('applies edits to the currently active card display model', async () => {
const stageModelStore = useSettingsStageModel()
stageModelStore.stageModelSelected = 'preset-live2d-1'
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
const cardId = cardStore.addCard({
const cardId = await cardStore.addCard({
name: 'Editable card',
version: '1.0.0',
description: 'Card whose model can be edited',
@@ -287,11 +262,11 @@ describe('airi-card store', () => {
},
},
}, 'scratch')
cardStore.activeCardId = cardId
await cardStore.activateCard(cardId)
const card = cardStore.getCard(cardId)
expect(card).toBeDefined()
cardStore.updateCard(cardId, {
await cardStore.updateCard(cardId, {
...card!,
extensions: {
...card!.extensions,
@@ -308,6 +283,44 @@ describe('airi-card store', () => {
expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1')
})
// ROOT CAUSE:
//
// pinia-plugin-synced applies a structured clone of every synchronized
// store. The clone replaced the active card object, so the runtime watcher
// treated unchanged card settings as an edit. Applying those settings
// mutated other synchronized stores and committed another full snapshot.
//
// We prevent the feedback loop by applying runtime settings only through an
// explicit card command, never in response to a state snapshot.
it('does not reapply runtime settings for an unchanged synchronized card snapshot', async () => {
const cardStore = useAiriCardStore()
await cardStore.initialize()
const cardId = await cardStore.addCard({
name: 'Artistry card',
version: '1.0.0',
description: 'A card with object-valued runtime settings.',
extensions: {
airi: {
modules: {
artistry: {
options: { steps: 20 },
},
},
agents: {},
},
},
}, 'scratch')
await cardStore.activateCard(cardId)
const applicationsBeforeSnapshot = resetArtistryToGlobal.mock.calls.length
const synchronizedCards = new Map<string, AiriCard>(JSON.parse(JSON.stringify([...cardStore.cards])))
cardStore.$patch({ cards: synchronizedCards })
expect(resetArtistryToGlobal).toHaveBeenCalledTimes(applicationsBeforeSnapshot)
})
// ROOT CAUSE:
//
// The settings reset clears the runtime model before resetting card state.
@@ -315,13 +328,13 @@ describe('airi-card store', () => {
// card, allowing its display model to overwrite the reset runtime value.
//
// https://github.com/moeru-ai/airi/pull/2090#discussion_r3610810272
it('does not restore a stale card model during card state reset', () => {
it('does not restore a stale card model during card state reset', async () => {
const stageModelStore = useSettingsStageModel()
stageModelStore.stageModelSelected = 'preset-live2d-1'
const cardStore = useAiriCardStore()
cardStore.initialize()
cardStore.updateActiveCardDisplayModel('preset-vrm-1')
await cardStore.initialize()
await cardStore.updateActiveCardDisplayModel('preset-vrm-1')
stageModelStore.stageModelSelected = 'preset-live2d-1'
cardStore.resetState()
@@ -333,11 +346,11 @@ describe('airi-card store', () => {
* @example
* it('updates speech config on the active card', () => {})
*/
it('updates speech config on the active card', () => {
it('updates speech config on the active card', async () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
expect(cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true)
expect(await cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({
provider: 'elevenlabs',
model: 'eleven_multilingual_v2',
@@ -345,11 +358,11 @@ describe('airi-card store', () => {
})
})
it('keeps position-sensitive CCv3 fields separate from the stable system prompt', () => {
it('keeps position-sensitive CCv3 fields separate from the stable system prompt', async () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
const cardId = cardStore.addCard({
const cardId = await cardStore.addCard({
name: 'Runtime context card',
version: '1.0.0',
systemPrompt: 'Follow the character rules.',
@@ -374,7 +387,7 @@ describe('airi-card store', () => {
},
}, 'scratch')
cardStore.activeCardId = cardId
await cardStore.activateCard(cardId)
expect(cardStore.systemPrompt).toBe([
'Follow the character rules.',
@@ -388,53 +401,53 @@ describe('airi-card store', () => {
expect(cardStore.systemPrompt).not.toContain('What did you find?')
})
it('falls back to the default card when the active custom card is deleted', () => {
it('falls back to the default card when the active custom card is deleted', async () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
const cardId = cardStore.addCard({
const cardId = await cardStore.addCard({
name: 'Custom card',
version: '1.0.0',
description: 'A removable card.',
}, 'scratch')
cardStore.activeCardId = cardId
await cardStore.activateCard(cardId)
cardStore.removeCard(cardId)
await cardStore.removeCard(cardId)
expect(cardStore.cards.has(cardId)).toBe(false)
expect(cardStore.activeCardId).toBe('default')
expect(cardStore.activeCard?.name).toBe('ReLU')
})
it('keeps the built-in fallback card when deletion is requested directly', () => {
it('keeps the built-in fallback card when deletion is requested directly', async () => {
const cardStore = useAiriCardStore()
cardStore.initialize()
await cardStore.initialize()
expect(cardStore.removeCard('default')).toBe(false)
expect(await cardStore.removeCard('default')).toBe(false)
expect(cardStore.cards.has('default')).toBe(true)
expect(cardStore.activeCardId).toBe('default')
})
it('preserves a valid persisted active card during initialization', () => {
it('preserves a valid persisted active card during initialization', async () => {
const cardStore = useAiriCardStore()
const cardId = cardStore.addCard({
const cardId = await cardStore.addCard({
name: 'Persisted active card',
version: '1.0.0',
description: 'Keep this selection.',
}, 'scratch')
cardStore.activeCardId = cardId
cardStore.initialize()
await cardStore.initialize()
expect(cardStore.activeCardId).toBe(cardId)
expect(cardStore.activeCard?.name).toBe('Persisted active card')
})
it('repairs a dangling persisted active card during initialization', () => {
it('repairs a dangling persisted active card during initialization', async () => {
const cardStore = useAiriCardStore()
cardStore.activeCardId = 'missing-card'
cardStore.initialize()
await cardStore.initialize()
expect(cardStore.activeCardId).toBe('default')
expect(cardStore.activeCard?.name).toBe('ReLU')
@@ -1,12 +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 } from 'pinia'
import { computed, watch } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
@@ -41,11 +40,13 @@ function resolveSystemPrompt(card: AiriCard | undefined): string {
export const useAiriCardStore = defineStore('airi-card', () => {
const { t } = useI18n()
const cards = useLocalStorageManualReset<Map<string, AiriCard>>('airi-cards', new Map())
const activeCardId = useLocalStorageManualReset<string>('airi-card-active-id', 'default')
// Pinia synchronization owns cross-window updates. Local storage only loads
// and saves this renderer's durable copy; listening to storage events here
// would create a second cross-window state channel and echo cloned maps.
const cards = useLocalStorageManualReset<Map<string, AiriCard>>('airi-cards', new Map(), { listenToStorageChanges: false })
const activeCardId = useLocalStorageManualReset<string>('airi-card-active-id', 'default', { listenToStorageChanges: false })
const activeCard = computed(() => cards.value.get(activeCardId.value))
function useRuntimeModuleStores() {
return {
artistry: useArtistryStore(),
@@ -62,14 +63,14 @@ export const useAiriCardStore = defineStore('airi-card', () => {
* from an existing card (profile switcher). Required so a new call site
* can't silently degrade creation attribution.
*/
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => {
const addCard = async (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => {
const newCardId = nanoid()
cards.value.set(newCardId, newAiriCard(card))
captureAnalyticsEvent('card_created', { card_id: newCardId, source })
return newCardId
}
const removeCard = (id: string) => {
const removeCard = async (id: string) => {
// The built-in card is the guaranteed fallback for every runtime profile.
if (id === 'default')
return false
@@ -80,14 +81,16 @@ export const useAiriCardStore = defineStore('airi-card', () => {
// The active id is persisted independently from the card map. Reset it
// before consumers observe a dangling runtime profile after deletion.
if (activeCardId.value === id)
if (activeCardId.value === id) {
activeCardId.value = 'default'
applyActiveCardSettings()
}
captureAnalyticsEvent('character_deleted', { character_id: id })
return true
}
const updateCard = (id: string, updates: AiriCard | Card | ccv3.CharacterCardV3) => {
const updateCard = async (id: string, updates: AiriCard | Card | ccv3.CharacterCardV3) => {
const existingCard = cards.value.get(id)
if (!existingCard)
return false
@@ -97,7 +100,11 @@ export const useAiriCardStore = defineStore('airi-card', () => {
...updates,
}
cards.value.set(id, newAiriCard(updatedCard))
const card = newAiriCard(updatedCard)
cards.value.set(id, card)
if (id === activeCardId.value)
applyActiveCardSettings(card)
return true
}
@@ -129,25 +136,37 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return true
}
function updateActiveCardDisplayModel(displayModelId: string | undefined) {
return updateActiveCardModules(() => ({ displayModelId }))
async function updateActiveCardDisplayModel(displayModelId: string | undefined) {
const updated = updateActiveCardModules(() => ({ displayModelId }))
if (updated)
applyActiveCardSettings()
return updated
}
function updateActiveCardConsciousness(consciousness: AiriExtension['modules']['consciousness']) {
return updateActiveCardModules(() => ({ consciousness }))
async function updateActiveCardConsciousness(consciousness: AiriExtension['modules']['consciousness']) {
const updated = updateActiveCardModules(() => ({ consciousness }))
if (updated)
applyActiveCardSettings()
return updated
}
function updateActiveCardVision(vision: AiriExtension['modules']['vision']) {
return updateActiveCardModules(() => ({ vision }))
async function updateActiveCardVision(vision: AiriExtension['modules']['vision']) {
const updated = updateActiveCardModules(() => ({ vision }))
if (updated)
applyActiveCardSettings()
return updated
}
function updateActiveCardSpeech(speech: Pick<AiriExtension['modules']['speech'], 'provider' | 'model' | 'voice_id'>) {
return updateActiveCardModules(({ modules }) => ({
async function updateActiveCardSpeech(speech: Pick<AiriExtension['modules']['speech'], 'provider' | 'model' | 'voice_id'>) {
const updated = updateActiveCardModules(({ modules }) => ({
speech: {
...modules.speech,
...speech,
},
}))
if (updated)
applyActiveCardSettings()
return updated
}
function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension {
@@ -309,7 +328,20 @@ export const useAiriCardStore = defineStore('airi-card', () => {
if (!cards.value.has(activeCardId.value))
activeCardId.value = 'default'
initializeRuntimeModules()
applyActiveCardSettings()
}
/**
* Selects a card and applies its module settings in the synchronization
* leader. Replicated state snapshots never invoke this command.
*/
async function activateCard(id: string) {
if (!cards.value.has(id))
return false
activeCardId.value = id
applyActiveCardSettings()
return true
}
function applyActiveCardSettings(newCard = activeCard.value) {
@@ -360,55 +392,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}
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
// activation watcher can briefly resolve the old default card and restore
// its display model during a full settings reset.
cards.reset()
activeCardId.reset()
}
@@ -427,8 +411,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
getCard,
resetState,
initialize,
startRuntime,
disposeRuntime,
activateCard,
currentModels: computed(() => {
const {
@@ -460,7 +443,17 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}, {
synced: {
actions: ['initialize'],
actions: [
'activateCard',
'addCard',
'initialize',
'removeCard',
'updateActiveCardConsciousness',
'updateActiveCardDisplayModel',
'updateActiveCardSpeech',
'updateActiveCardVision',
'updateCard',
],
state: true,
},
})
@@ -271,7 +271,7 @@ LATEST ${target === 'assistant' ? 'COMPANION RESPONSE' : 'USER INPUT'}:
switch (spawnMode) {
case 'bg':
// Update character's active background
cardStore.updateCard(cardId, {
await cardStore.updateCard(cardId, {
extensions: {
...activeCard.extensions,
airi: {
@@ -322,7 +322,7 @@ LATEST ${target === 'assistant' ? 'COMPANION RESPONSE' : 'USER INPUT'}:
case 'bg_widget':
default:
// Both: Update background AND spawn widget
cardStore.updateCard(cardId, {
await cardStore.updateCard(cardId, {
extensions: {
...activeCard.extensions,
airi: {
@@ -1,5 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useArtistryStore } from './artistry'
@@ -26,4 +27,51 @@ describe('artistry store', () => {
// @example
expect(artistryStore.configured).toBe(false)
})
// ROOT CAUSE:
//
// pinia-plugin-synced restores the whole store with structured clones. The
// global and active provider options then have equal values but different
// object identities. Watching the global object and assigning it to the
// active object creates a second mutation after the synchronized patch.
// That mutation broadcasts another full snapshot and repeats indefinitely.
//
// We fixed this by making global-to-active resolution an explicit store
// operation and by preventing persistence from reflecting its own write.
it('does not mutate active options after applying an equal synchronized snapshot', async () => {
const artistryStore = useArtistryStore()
artistryStore.globalProviderOptions = { steps: 20 }
artistryStore.providerOptions = { steps: 20 }
artistryStore.comfyuiSavedWorkflows = [{
id: 'workflow-1',
name: 'Workflow',
workflow: {},
exposedFields: {},
}]
await nextTick()
let mutationCount = 0
const stopSubscription = artistryStore.$subscribe(() => {
mutationCount += 1
}, { flush: 'sync' })
artistryStore.$patch((currentState) => {
Object.assign(currentState, {
globalProviderOptions: { steps: 20 },
providerOptions: { steps: 20 },
comfyuiSavedWorkflows: [{
id: 'workflow-1',
name: 'Workflow',
workflow: {},
exposedFields: {},
}],
})
})
const mutationCountAfterSnapshot = mutationCount
await nextTick()
expect(mutationCount).toBe(mutationCountAfterSnapshot)
stopSubscription()
})
})
@@ -20,11 +20,16 @@ export interface ComfyUIWorkflowTemplate {
}
export const useArtistryStore = defineStore('artistry', () => {
// Pinia synchronization is the only cross-window state channel. These refs
// still load and save durable values, but storage events must not echo a
// second copy of the same state between Electron renderers.
const persistenceOptions = { listenToStorageChanges: false }
// --- Persistent Global Settings (User Preferences) ---
const globalProvider = useLocalStorageManualReset<string>('artistry-provider', 'none')
const globalModel = useLocalStorageManualReset<string>('artistry-model', '')
const globalPromptPrefix = useLocalStorageManualReset<string>('artistry-prompt-prefix', '')
const globalProviderOptions = useLocalStorageManualReset<Record<string, any> | undefined>('artistry-provider-options', undefined)
const globalProvider = useLocalStorageManualReset<string>('artistry-provider', 'none', persistenceOptions)
const globalModel = useLocalStorageManualReset<string>('artistry-model', '', persistenceOptions)
const globalPromptPrefix = useLocalStorageManualReset<string>('artistry-prompt-prefix', '', persistenceOptions)
const globalProviderOptions = useLocalStorageManualReset<Record<string, any> | undefined>('artistry-provider-options', undefined, persistenceOptions)
// --- Active settings (transient, can be overridden by cards) ---
const activeProvider = ref(globalProvider.value)
@@ -36,40 +41,48 @@ export const useArtistryStore = defineStore('artistry', () => {
const comfyuiServerUrl = useLocalStorageManualReset<string>(
'artistry-comfyui-server-url',
'http://localhost:8188',
persistenceOptions,
)
const comfyuiSavedWorkflows = useLocalStorageManualReset<ComfyUIWorkflowTemplate[]>(
'artistry-comfyui-saved-workflows',
[],
persistenceOptions,
)
const comfyuiActiveWorkflow = useLocalStorageManualReset<string>(
'artistry-comfyui-active-workflow',
'',
persistenceOptions,
)
// --- Replicate provider settings ---
const replicateApiKey = useLocalStorageManualReset<string>('artistry-replicate-api-key', '')
const replicateApiKey = useLocalStorageManualReset<string>('artistry-replicate-api-key', '', persistenceOptions)
const replicateDefaultModel = useLocalStorageManualReset<string>(
'artistry-replicate-default-model',
'black-forest-labs/flux-schnell',
persistenceOptions,
)
const replicateAspectRatio = useLocalStorageManualReset<string>(
'artistry-replicate-aspect-ratio',
'16:9',
persistenceOptions,
)
const replicateInferenceSteps = useLocalStorageManualReset<number>(
'artistry-replicate-inference-steps',
4,
persistenceOptions,
)
// --- Nano Banana (Google AI Studio) provider settings ---
const nanobananaApiKey = useLocalStorageManualReset<string>('artistry-nanobanana-api-key', '')
const nanobananaApiKey = useLocalStorageManualReset<string>('artistry-nanobanana-api-key', '', persistenceOptions)
const nanobananaModel = useLocalStorageManualReset<string>(
'artistry-nanobanana-model',
'gemini-3.1-flash-image-preview',
persistenceOptions,
)
const nanobananaResolution = useLocalStorageManualReset<string>(
'artistry-nanobanana-resolution',
'1K',
persistenceOptions,
)
/**
@@ -115,7 +128,6 @@ export const useArtistryStore = defineStore('artistry', () => {
watch(globalProvider, val => activeProvider.value = val)
watch(globalModel, val => activeModel.value = val)
watch(globalPromptPrefix, val => defaultPromptPrefix.value = val)
watch(globalProviderOptions, val => providerOptions.value = val)
const configured = computed(() => {
if (!activeProvider.value)
@@ -61,7 +61,7 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
// provider's model and chat requests failed upstream with model_not_found.
//
// The watcher is synchronous on purpose: call sites assign the provider
// first and a new model right after (e.g. use-auth-provider-sync), so a
// first and a new model right after, so a
// deferred reset would wipe the model they just chose. Synchronous flush
// makes "set provider, then set model" a safe, ordered operation.
//
@@ -102,7 +102,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
}
function getProviderConfig(providerId: string) {
return getProvider(providerId)?.config
return providers.value[providerId]?.config
}
function ensureProvider(providerId: string, definitionId: string, config: Record<string, unknown> = {}) {
@@ -133,7 +133,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
}
function setProviderStatus(providerId: string, status: ProviderValidationStatus) {
const provider = getProvider(providerId)
const provider = providers.value[providerId]
if (provider)
provider.status = status
}
@@ -1,7 +1,9 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { OFFICIAL_SPEECH_PROVIDER_ID } from '../../libs/providers/providers/official'
import { useProviderConfigStore } from './config'
import { useProviderStore } from './provider'
vi.mock('vue-i18n', () => ({
@@ -39,6 +41,52 @@ describe('provider store synchronization boundary', () => {
expect(store.providerRuntimeState.openai).toEqual(runtimeState)
})
// ROOT CAUSE:
//
// The provider store installed immediate watchers that called synchronized
// background actions. Every renderer created the same watchers, so one
// shared state transition produced one routed action per renderer.
//
// We fixed this by keeping background work behind explicit action calls.
it('does not start background provider actions when shared configuration changes', async () => {
const store = useProviderStore()
const configStore = useProviderConfigStore()
await nextTick()
await new Promise<void>(resolve => queueMicrotask(resolve))
const refreshValidation = vi.spyOn(store, 'refreshListedProviderValidation').mockResolvedValue()
const refreshModels = vi.spyOn(store, 'refreshModelsForChangedCredentials').mockResolvedValue()
configStore.ensureProvider('openai', 'openai', { apiKey: 'test-key' })
await nextTick()
await new Promise<void>(resolve => queueMicrotask(resolve))
expect(refreshValidation).not.toHaveBeenCalled()
expect(refreshModels).not.toHaveBeenCalled()
})
// ROOT CAUSE:
//
// Provider metadata projection called the config store's `getProvider`
// action once for every registered provider. Pinia tracing and plugins then
// processed hundreds of action lifecycle events during renderer startup,
// even though each call was only a read.
// Internal provider projections now read the reactive provider map directly.
it('does not dispatch config actions while projecting provider metadata', async () => {
const configStore = useProviderConfigStore()
let getProviderCalls = 0
configStore.$onAction(({ name }) => {
if (name === 'getProvider')
getProviderCalls += 1
})
useProviderStore()
await nextTick()
expect(getProviderCalls).toBe(0)
})
// ROOT CAUSE:
//
// A model request kept a reference to its runtime entry across an await.
@@ -19,7 +19,7 @@ import { computedAsync, useIntervalFn } from '@vueuse/core'
import { listModels } from '@xsai/model'
import { uniqBy } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
@@ -31,7 +31,6 @@ import {
validateProvider as runProviderValidation,
} from '../../libs/providers'
import { selectProviderMetadata, selectProvidersMetadata } from '../../libs/providers/metadata'
import { useAuthStore } from '../auth'
import { useProviderConfigStore } from './config'
export type { ModelInfo, VoiceInfo } from '../../libs/providers/types'
@@ -72,20 +71,15 @@ export const useProviderStore = defineStore('provider', () => {
const providerStateStore = useProviderStateStore()
const providerCredentials = computed(() => providerConfigStore.configs)
const addedProviders = computed(() => providerConfigStore.addedProviders)
// Synced state applies fresh object snapshots. Compare serialized values so
// an equivalent snapshot does not restart validation and publish more state.
const providerCredentialsSignature = computed(() => JSON.stringify(providerCredentials.value))
const addedProvidersSignature = computed(() => JSON.stringify(addedProviders.value))
// Provider instances contain functions and transport handles. Keep this map
// private so it never enters Pinia state.
const providerInstanceCache = new Map<string, unknown>()
const { t } = useI18n()
const authState = useAuthStore()
const VISION_PROVIDER_ID_PREFIX = 'vision-'
function getProviderDefinitionId(providerId: string) {
const configuredProvider = providerConfigStore.getProvider(providerId)
const configuredProvider = providerConfigStore.providers[providerId]
if (configuredProvider)
return configuredProvider.definitionId
@@ -262,7 +256,7 @@ export const useProviderStore = defineStore('provider', () => {
initializeProviderRuntimeState(providerId)
const configString = JSON.stringify(config || {})
const runtimeState = providerRuntimeState.value[providerId]
const configuredProvider = providerConfigStore.getProvider(providerId)
const configuredProvider = providerConfigStore.providers[providerId]
const cacheKey = `${providerId}:${configString}`
const forceValidation = options.force === true
@@ -414,17 +408,6 @@ export const useProviderStore = defineStore('provider', () => {
startPeriodicRuntimeValidation()
}
function requestListedProviderValidation() {
// Store setup runs before pinia-plugin-synced installs its action wrappers.
// Defer the public-store lookup so background validation is routed to the
// leader instead of running independently in every renderer.
queueMicrotask(() => void useProviderStore().refreshListedProviderValidation())
}
watch(providerCredentialsSignature, requestListedProviderValidation, { immediate: true })
watch(addedProvidersSignature, requestListedProviderValidation)
watch(() => authState.isAuthenticated, requestListedProviderValidation)
// Available providers (only those that are properly configured)
const availableProviders = computed(() => Object.values(providerConfigStore.providers)
.filter(provider => provider.status === 'configured')
@@ -679,18 +662,14 @@ export const useProviderStore = defineStore('provider', () => {
await disposeProviderInstance(providerId)
// If the provider is configured and has the capability, refetch its models
if (providerConfigStore.getProvider(providerId)?.status === 'configured' && supportsModelListing(providerId)) {
if (providerConfigStore.providers[providerId]?.status === 'configured' && supportsModelListing(providerId)) {
await fetchModelsForProvider(providerId)
}
}
}
watch(providerCredentialsSignature, () => {
queueMicrotask(() => void useProviderStore().refreshModelsForChangedCredentials())
}, { immediate: true })
function projectProvider(providerId: string): ProviderMetadata | undefined {
const configuredProvider = providerConfigStore.getProvider(providerId)
const configuredProvider = providerConfigStore.providers[providerId]
const metadata = providerMetadata[providerId]
?? providerMetadata[configuredProvider?.definitionId ?? '']
+29 -239
View File
@@ -877,8 +877,8 @@ catalogs:
specifier: ^3.0.4
version: 3.0.4
pinia-plugin-synced:
specifier: ^0.1.0
version: 0.1.0
specifier: ^0.1.3
version: 0.1.3
pixi-filters:
specifier: ^4.2.0
version: 4.2.0
@@ -2127,7 +2127,7 @@ importers:
version: 3.0.2(electron@41.2.1)
'@electron-toolkit/tsconfig':
specifier: 'catalog:'
version: 2.0.0(@types/node@24.12.2)
version: 2.0.0(@types/node@25.6.0)
'@electron-toolkit/utils':
specifier: 'catalog:'
version: 4.0.0(electron@41.2.1)
@@ -2166,7 +2166,7 @@ importers:
version: 3.1.0
'@intlify/unplugin-vue-i18n':
specifier: 'catalog:'
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3)
@@ -2202,10 +2202,10 @@ importers:
version: link:../../packages/ui-transitions
'@proj-airi/unplugin-fetch':
specifier: 'catalog:'
version: 0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@proj-airi/unplugin-live2d-sdk':
specifier: 'catalog:'
version: 0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@types/audioworklet':
specifier: 'catalog:'
version: 0.0.97
@@ -2232,7 +2232,7 @@ importers:
version: 2.10.3
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar':
specifier: 'catalog:'
version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
@@ -2265,7 +2265,7 @@ importers:
version: 6.8.3
electron-vite:
specifier: 'catalog:'
version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
get-port-please:
specifier: 'catalog:'
version: 3.2.0
@@ -2286,31 +2286,31 @@ importers:
version: 2.2.6
unocss-preset-scrollbar:
specifier: 'catalog:'
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
unplugin-info:
specifier: 'catalog:'
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-yaml:
specifier: 'catalog:'
version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite:
specifier: 'catalog:'
version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-bundle-visualizer:
specifier: 'catalog:'
version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1)
vite-plugin-mkcert:
specifier: 'catalog:'
version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
vite-plugin-vue-layouts:
specifier: 'catalog:'
version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-macros:
specifier: 'catalog:'
version: 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
version: 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3)
@@ -4437,7 +4437,7 @@ importers:
version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
pinia-plugin-synced:
specifier: 'catalog:'
version: 0.1.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.1.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
posthog-js:
specifier: 'catalog:'
version: 1.306.1
@@ -17170,8 +17170,8 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pinia-plugin-synced@0.1.0:
resolution: {integrity: sha512-53LEjp6A5ub16fwPdM01ZwYtLF+uG5i3JOfwbA5/m1j7cmiIAdlb4dSd5NcY+fZDw51f/DXWBiLAqV/C1qyKTg==}
pinia-plugin-synced@0.1.3:
resolution: {integrity: sha512-qisOMMeMClWqHMdJSBZzIGxLQKRTWA2dqF48l++Ys1ay91fOUuT0UcNZWYsrs4eP1MvAR3DMXskknen8iYyq6Q==}
peerDependencies:
pinia: '>=3.0.4 <5'
vue: ^3.5.0
@@ -21699,9 +21699,9 @@ snapshots:
dependencies:
electron: 41.2.1
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)':
dependencies:
'@types/node': 24.12.2
'@types/node': 25.6.0
'@electron-toolkit/utils@4.0.0(electron@41.2.1)':
dependencies:
@@ -22638,31 +22638,6 @@ snapshots:
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
'@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
picocolors: 1.1.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/compiler-dom'
- eslint
- rollup
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.7.0))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0))
@@ -24793,35 +24768,11 @@ snapshots:
'@proj-airi/unocss-preset-chromatic@1.1.1': {}
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
yauzl: 3.3.0
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
- esbuild
- jiti
- less
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
@@ -26556,12 +26507,6 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
@@ -26837,15 +26782,6 @@ snapshots:
transitivePeerDependencies:
- vue
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- typescript
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
@@ -29000,7 +28936,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@@ -29008,7 +28944,7 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -32941,7 +32877,7 @@ snapshots:
pify@4.0.1:
optional: true
pinia-plugin-synced@0.1.0(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
pinia-plugin-synced@0.1.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@moeru/std': 0.1.0-beta.20
es-toolkit: 1.50.0
@@ -35168,6 +35104,11 @@ snapshots:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss@66.6.8(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@unocss/cli': 66.6.8
@@ -35234,14 +35175,6 @@ snapshots:
unplugin: 2.3.11
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
@@ -35263,19 +35196,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
git-url-parse: 16.1.0
simple-git: 3.36.0
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.2
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
@@ -35369,17 +35289,6 @@ snapshots:
rollup: 2.80.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
unplugin: 3.0.0
yaml: 2.8.3
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
@@ -35618,22 +35527,12 @@ snapshots:
- rollup
- supports-color
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@@ -35679,21 +35578,6 @@ snapshots:
- tsx
- yaml
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
debug: 4.4.3(supports-color@10.2.2)
error-stack-parser-es: 1.0.5
ohash: 2.0.11
open: 10.2.0
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
@@ -35725,13 +35609,6 @@ snapshots:
- typescript
- ws
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
supports-color: 10.2.2
undici: 8.1.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -35750,20 +35627,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
'@vue/devtools-kit': 8.1.1
'@vue/devtools-shared': 8.1.1
sirv: 3.0.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
@@ -35778,21 +35641,6 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.32
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
@@ -35808,16 +35656,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -36083,54 +35921,6 @@ snapshots:
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-prop': 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3)
'@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
unplugin: 2.3.11
unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- '@rspack/core'
- '@vueuse/core'
- esbuild
- rolldown
- rollup
- typescript
- vite
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.7.0)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.32(typescript@5.9.3))
+1 -1
View File
@@ -328,7 +328,7 @@ catalog:
pathe: ^2.0.3
pg: ^8.20.0
pinia: ^3.0.4
pinia-plugin-synced: ^0.1.0
pinia-plugin-synced: ^0.1.3
pixi-filters: ^4.2.0
pixi-live2d-display: ^0.4.0
playwright: ^1.60.0