fix(stage-ui): staled loaded model, desync character card model selection

This commit is contained in:
Neko Ayaka
2026-05-07 17:27:34 +08:00
parent 35c3471ecd
commit ef60c157dc
6 changed files with 201 additions and 6 deletions
@@ -49,18 +49,25 @@ async function handleAddLive2DModel(file: FileList | null) {
pendingFile.value = file[0]
if (report.status === 'VALID' && report.errors.length === 0) {
confirmImport()
await confirmImport()
return
}
showReportModal.value = true
}
function confirmImport() {
async function confirmImport() {
if (pendingFile.value === null)
return
displayModelStore.addDisplayModel(DisplayModelFormat.Live2dZip, pendingFile.value)
// NOTICE:
// Keep this await. Model picking can happen immediately after import from this dialog.
// If addDisplayModel is fire-and-forget, updateStageModel may read the new display-model id
// before IndexedDB or the in-memory displayModels list is ready and fall back to the default model.
// Source/context: model selector import flow -> settings model pick -> settings-stage-model.getDisplayModel().
// Removal condition: addDisplayModel becomes a synchronous transaction or pick is blocked by explicit import state.
const displayModel = await displayModelStore.addDisplayModel(DisplayModelFormat.Live2dZip, pendingFile.value)
highlightDisplayModelCard.value = displayModel.id
pendingFile.value = null
}
@@ -79,13 +86,19 @@ function handleMobilePick() {
emits('close', undefined)
}
function handleAddVRMModel(file: FileList | null) {
async function handleAddVRMModel(file: FileList | null) {
if (file === null || file.length === 0)
return
if (!file[0].name.endsWith('.vrm'))
return
displayModelStore.addDisplayModel(DisplayModelFormat.VRM, file[0])
// NOTICE:
// Keep this await for the same import-then-pick race as Live2D imports above.
// The returned model id is only safe to highlight after addDisplayModel has updated the store.
// Source/context: model selector import flow -> settings model pick -> settings-stage-model.getDisplayModel().
// Removal condition: addDisplayModel becomes a synchronous transaction or pick is blocked by explicit import state.
const displayModel = await displayModelStore.addDisplayModel(DisplayModelFormat.VRM, file[0])
highlightDisplayModelCard.value = displayModel.id
}
const mapFormatRenderer: Record<DisplayModelFormat, string> = {
@@ -12,6 +12,7 @@ import Live2D from './live2d.vue'
import VRM from './vrm.vue'
import { DisplayModelFormat } from '../../../../stores/display-models'
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
import { useSettings } from '../../../../stores/settings'
import { ModelSelectorDialog } from '../../dialogs/model-selector'
@@ -30,6 +31,7 @@ defineEmits<{
const modelSelectorOpen = ref(false)
const settingsStore = useSettings()
const airiCardStore = useAiriCardStore()
const { stageModelSelected, stageModelSelectedDisplayModel } = storeToRefs(settingsStore)
const currentSelectedDisplayModel = computed<DisplayModel | undefined>(() => stageModelSelectedDisplayModel.value)
@@ -43,6 +45,7 @@ const settingsClassList = computed(() => {
async function handleModelPick(selectedModel: DisplayModel | undefined) {
stageModelSelected.value = selectedModel?.id ?? ''
airiCardStore.updateActiveCardDisplayModel(selectedModel?.id)
await settingsStore.updateStageModel()
if (selectedModel?.format === DisplayModelFormat.Live2dZip)
@@ -0,0 +1,45 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DisplayModelFormat, useDisplayModelsStore } from './display-models'
vi.mock('localforage', () => ({
default: {
getItem: vi.fn(async () => undefined),
iterate: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
setItem: vi.fn(async (_key: string, value: unknown) => value),
},
}))
/**
* @example
* describe('display models store', () => {})
*/
describe('display models store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
/**
* @example
* it('resolves newly imported display models from memory before IndexedDB', async () => {})
*/
it('resolves newly imported display models from memory before IndexedDB', async () => {
const store = useDisplayModelsStore()
const model = {
id: 'display-model-pending-idb-write',
format: DisplayModelFormat.Live2dZip,
type: 'file' as const,
file: new File(['model'], 'model.zip'),
name: 'model.zip',
importedAt: 1,
}
store.displayModels = [model]
const resolved = await store.getDisplayModel(model.id)
expect(resolved).toEqual(model)
})
})
+20 -1
View File
@@ -84,6 +84,16 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
async function getDisplayModel(id: string) {
await until(displayModelsFromIndexedDBLoading).toBe(false)
// NOTICE:
// Newly imported file models are inserted into displayModels before callers pick them.
// Reading memory first keeps updateStageModel from racing an IndexedDB write and treating
// a just-imported display-model id as missing, which used to fall back to the default model.
// Source/context: model-selector confirmImport/handleAddVRMModel -> model-settings handleModelPick.
// Removal condition: custom model imports and selection are handled by a single transactional API.
const modelFromMemory = displayModels.value.find(model => model.id === id)
if (modelFromMemory)
return modelFromMemory
const modelFromFile = await localforage.getItem<DisplayModelFile>(id)
if (modelFromFile) {
return modelFromFile
@@ -111,8 +121,17 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
displayModels.value.unshift(newDisplayModel)
localforage.setItem<DisplayModelFile>(newDisplayModel.id, newDisplayModel)
// NOTICE:
// Keep this awaited. The settings model pick flow can call getDisplayModel immediately
// after import; fire-and-forget persistence creates a race where the selected custom model
// exists in the UI but is not yet readable from IndexedDB in a later route/render pass.
// Source/context: model-selector import flow -> settings-stage-model.updateStageModel().
// Removal condition: imported display models are persisted through a transactional queue
// that blocks pick/navigation until the write is durably complete.
await localforage.setItem<DisplayModelFile>(newDisplayModel.id, newDisplayModel)
.catch(err => console.error(err))
return newDisplayModel
}
async function renameDisplayModel(id: string, name: string) {
@@ -0,0 +1,88 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStageModel } from '../settings/stage-model'
import { useAiriCardStore } from './airi-card'
vi.mock('./artistry', async () => {
const { defineStore } = await import('pinia')
return {
useArtistryStore: defineStore('artistry', {
state: () => ({
globalProvider: 'mock-artistry-provider',
globalModel: 'mock-artistry-model',
globalPromptPrefix: 'mock-artistry-prefix',
globalProviderOptions: {},
activeProvider: 'mock-artistry-provider',
activeModel: 'mock-artistry-model',
defaultPromptPrefix: 'mock-artistry-prefix',
providerOptions: {},
}),
actions: {
resetToGlobal() {},
},
}),
}
})
vi.mock('./consciousness', async () => {
const { defineStore } = await import('pinia')
return {
useConsciousnessStore: defineStore('consciousness', {
state: () => ({
activeProvider: 'mock-consciousness-provider',
activeModel: 'mock-consciousness-model',
}),
}),
}
})
vi.mock('./speech', async () => {
const { defineStore } = await import('pinia')
return {
useSpeechStore: defineStore('speech', {
state: () => ({
activeSpeechProvider: 'mock-speech-provider',
activeSpeechModel: 'mock-speech-model',
activeSpeechVoiceId: 'mock-speech-voice',
}),
}),
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}))
/**
* @example
* describe('airi-card store', () => {})
*/
describe('airi-card store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
/**
* @example
* it('persists selected display model on active card', () => {})
*/
it('persists selected display model on active card', () => {
const stageModelStore = useSettingsStageModel()
stageModelStore.stageModelSelected = 'preset-live2d-1'
const cardStore = useAiriCardStore()
cardStore.initialize()
const updated = cardStore.updateActiveCardDisplayModel('display-model-iru-v2')
expect(updated).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules.displayModelId).toBe('display-model-iru-v2')
expect(stageModelStore.stageModelSelected).toBe('preset-live2d-1')
})
})
@@ -130,6 +130,32 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return cards.value.get(id)
}
function updateActiveCardDisplayModel(displayModelId: string | undefined) {
const cardId = activeCardId.value
const card = cards.value.get(cardId)
if (!card)
return false
const extension = resolveAiriExtension(card)
const modules: AiriExtension['modules'] = {
...extension.modules,
displayModelId,
}
cards.value.set(cardId, {
...card,
extensions: {
...card.extensions,
airi: {
...extension,
modules,
},
},
})
return true
}
function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension {
// Get existing extension if available
const existingExtension = ('data' in card
@@ -320,6 +346,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
addCard,
removeCard,
updateCard,
updateActiveCardDisplayModel,
getCard,
resetState,
initialize,