From ef60c157dcd0c20d3d1d66d04809117928e161e8 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 7 May 2026 17:27:34 +0800 Subject: [PATCH] fix(stage-ui): staled loaded model, desync character card model selection --- .../dialogs/model-selector/model-selector.vue | 23 +++-- .../settings/model-settings/panel.vue | 3 + .../src/stores/display-models.test.ts | 45 ++++++++++ .../stage-ui/src/stores/display-models.ts | 21 ++++- .../src/stores/modules/airi-card.test.ts | 88 +++++++++++++++++++ .../stage-ui/src/stores/modules/airi-card.ts | 27 ++++++ 6 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 packages/stage-ui/src/stores/display-models.test.ts create mode 100644 packages/stage-ui/src/stores/modules/airi-card.test.ts diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue index 7f51aec6b..76fa929dd 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue @@ -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 = { diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue index a95750e93..d342c1001 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue @@ -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(() => 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) diff --git a/packages/stage-ui/src/stores/display-models.test.ts b/packages/stage-ui/src/stores/display-models.test.ts new file mode 100644 index 000000000..15de0ee3d --- /dev/null +++ b/packages/stage-ui/src/stores/display-models.test.ts @@ -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) + }) +}) diff --git a/packages/stage-ui/src/stores/display-models.ts b/packages/stage-ui/src/stores/display-models.ts index d7bdf703d..3395f44c7 100644 --- a/packages/stage-ui/src/stores/display-models.ts +++ b/packages/stage-ui/src/stores/display-models.ts @@ -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(id) if (modelFromFile) { return modelFromFile @@ -111,8 +121,17 @@ export const useDisplayModelsStore = defineStore('display-models', () => { displayModels.value.unshift(newDisplayModel) - localforage.setItem(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(newDisplayModel.id, newDisplayModel) .catch(err => console.error(err)) + + return newDisplayModel } async function renameDisplayModel(id: string, name: string) { diff --git a/packages/stage-ui/src/stores/modules/airi-card.test.ts b/packages/stage-ui/src/stores/modules/airi-card.test.ts new file mode 100644 index 000000000..885c0297d --- /dev/null +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -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') + }) +}) diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 2921084e9..4761e0238 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -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,