fix(stage-ui): apply display model on character card activation (#2090)

This commit is contained in:
0xSelenicDove
2026-07-26 18:04:29 +08:00
committed by GitHub
parent 20355670a9
commit cd9704cf38
2 changed files with 140 additions and 6 deletions
@@ -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 <T>(_: 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')
})
@@ -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 {