From cd9704cf381a02044d8e60b30c0e1166b2d349c4 Mon Sep 17 00:00:00 2001 From: 0xSelenicDove <140679517+0xSelenicDove@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:04:29 +0800 Subject: [PATCH] fix(stage-ui): apply display model on character card activation (#2090) --- .../src/stores/modules/airi-card.test.ts | 121 ++++++++++++++++++ .../stage-ui/src/stores/modules/airi-card.ts | 25 +++- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/packages/stage-ui/src/stores/modules/airi-card.test.ts b/packages/stage-ui/src/stores/modules/airi-card.test.ts index 19dbc3021..9f68f9546 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -1,9 +1,26 @@ +import type { AiriCard } from './airi-card' + import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsStageModel } from '../settings/stage-model' import { useAiriCardStore } from './airi-card' +// NOTICE: +// Vitest runs these store tests in Node, where localforage cannot select a +// browser storage driver. The stage-model watcher legitimately asks the +// display-model store to resolve IDs, so provide the storage boundary with a +// deterministic no-op instead of allowing rejected driver initialization to +// escape as an unrelated test error. +vi.mock('localforage', () => ({ + default: { + getItem: vi.fn(async () => undefined), + iterate: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined), + setItem: vi.fn(async (_: string, value: T) => value), + }, +})) + vi.mock('./artistry', async () => { const { defineStore } = await import('pinia') @@ -102,6 +119,110 @@ describe('airi-card store', () => { vision: { provider: 'ollama', model: 'llava' }, speech: { provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' }, }) + expect(stageModelStore.stageModelSelected).toBe('display-model-iru-v2') + }) + + // ROOT CAUSE: + // + // Card activation changes `activeCardId`, but the previous implementation + // only observed the debounced `activeCard` object. Some card switchers keep + // the same object reference while changing the selected ID, so the runtime + // stage model stayed on the previous card's model. + // + // 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', () => { + const stageModelStore = useSettingsStageModel() + stageModelStore.stageModelSelected = 'preset-live2d-1' + + const cardStore = useAiriCardStore() + cardStore.initialize() + + const card: AiriCard = { + name: 'VRM card', + version: '1.0.0', + description: 'Card with a VRM display model', + extensions: { + airi: { + modules: { + consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' }, + vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' }, + speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' }, + displayModelId: 'preset-vrm-1', + }, + agents: {}, + }, + }, + } + const cardId = cardStore.addCard(card, 'scratch') + + cardStore.activeCardId = cardId + + expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') + }) + + it('applies edits to the currently active card display model', () => { + const stageModelStore = useSettingsStageModel() + stageModelStore.stageModelSelected = 'preset-live2d-1' + + const cardStore = useAiriCardStore() + cardStore.initialize() + + const cardId = cardStore.addCard({ + name: 'Editable card', + version: '1.0.0', + description: 'Card whose model can be edited', + extensions: { + airi: { + modules: { + consciousness: { provider: 'mock-consciousness-provider', model: 'mock-consciousness-model' }, + vision: { provider: 'mock-vision-provider', model: 'mock-vision-model' }, + speech: { provider: 'mock-speech-provider', model: 'mock-speech-model', voice_id: 'mock-speech-voice' }, + displayModelId: 'preset-live2d-1', + }, + agents: {}, + }, + }, + }, 'scratch') + cardStore.activeCardId = cardId + + const card = cardStore.getCard(cardId) + expect(card).toBeDefined() + cardStore.updateCard(cardId, { + ...card!, + extensions: { + ...card!.extensions, + airi: { + ...card!.extensions.airi, + modules: { + ...card!.extensions.airi.modules, + displayModelId: 'preset-vrm-1', + }, + }, + }, + }) + + expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1') + }) + + // ROOT CAUSE: + // + // The settings reset clears the runtime model before resetting card state. + // Resetting `activeCardId` first briefly selected the still-persisted default + // 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', () => { + const stageModelStore = useSettingsStageModel() + stageModelStore.stageModelSelected = 'preset-live2d-1' + + const cardStore = useAiriCardStore() + cardStore.initialize() + cardStore.updateActiveCardDisplayModel('preset-vrm-1') + stageModelStore.stageModelSelected = 'preset-live2d-1' + + cardStore.resetState() + expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1') }) diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 3a48e51d8..878d73cab 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -1,10 +1,9 @@ import type { Card, ccv3 } from '@proj-airi/ccc' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' -import { watchDebounced } from '@vueuse/core' import { nanoid } from 'nanoid' import { defineStore, storeToRefs } from 'pinia' -import { computed } from 'vue' +import { computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import SystemPromptV2 from '../../constants/prompts/system-v2' @@ -344,8 +343,10 @@ export const useAiriCardStore = defineStore('airi-card', () => { } function initialize() { - if (cards.value.has('default')) + if (cards.value.has('default')) { + applyActiveCardSettings() return + } cards.value.set('default', newAiriCard({ name: 'ReLU', version: '1.0.0', @@ -356,9 +357,11 @@ export const useAiriCardStore = defineStore('airi-card', () => { })) if (!activeCardId.value) activeCardId.value = 'default' + + applyActiveCardSettings() } - watchDebounced(activeCard, (newCard: AiriCard | undefined) => { + function applyActiveCardSettings(newCard = activeCard.value) { artistryStore.resetToGlobal() if (!newCard) @@ -396,11 +399,21 @@ export const useAiriCardStore = defineStore('airi-card', () => { if (extension.modules.artistry.options) artistryStore.providerOptions = extension.modules.artistry.options } - }, { debounce: 300, maxWait: 1000 }) + } + + // Activation changes the stable card ID, while card editors replace the + // active card object without changing that ID. Observe both transitions so + // switching cards and saving edits to the current card apply consistently. + watch([activeCardId, activeCard], ([, newCard]) => { + applyActiveCardSettings(newCard) + }, { flush: 'sync', immediate: true }) function resetState() { - activeCardId.reset() + // 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() } return {