fix(stage-ui): apply display model on character card activation (#2090)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user