fix(stage-ui): staled loaded model, desync character card model selection
This commit is contained in:
+18
-5
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user