From 05ac66edfab61a9546a838b46273c3987e55620e Mon Sep 17 00:00:00 2001 From: leafyy Date: Mon, 7 Sep 2026 11:14:44 +0800 Subject: [PATCH] fix(stage-tamagotchi): show Live2D expressions in settings (#2451) ## Summary - Send a serializable Live2D expression snapshot from the stage renderer to the separate Settings window. - Route expression changes back to the renderer that owns the loaded model, with owner ID checks for stale-window isolation. - Add browser regressions for the component boundary and the complete BroadcastChannel round trip. Fixes #2450 ## Verification - `pnpm typecheck` - `pnpm lint` - `../../node_modules/.bin/vitest run --project browser src/components/scenarios/settings/model-settings/live2d.browser.test.ts` - `node_modules/.bin/vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts` - `./node_modules/.bin/electron-vite build` - Vishot Electron capture with the issue Live2D fixture on the merge base and this branch - `sem diff upstream/main...HEAD --no-cosmetics -v --file-exts .ts .tsx` ## Visual changes | Before | After | |---|---| | ![](https://github.com/user-attachments/assets/e383ebf3-d60e-4f66-8550-4cab0dc61261) | ![](https://github.com/user-attachments/assets/b8aee712-c227-44d0-a60c-2dd24cc0b0cd) | | Settings / Models / Live2D expressions: empty list | Settings / Models / Live2D expressions: model entries are available | --- .../model-settings-runtime-owner.ts | 90 +++++++++ .../model-settings-runtime-snapshot.ts | 83 ++++++--- .../model-settings-runtime.browser.test.ts | 171 ++++++++++++++++++ .../src/renderer/pages/index.vue | 39 ++-- .../renderer/pages/settings/models/index.vue | 3 +- .../src/shared/model-settings-runtime.ts | 44 ++++- .../src/components/scenes/live2d/Model.vue | 23 ++- .../live2d/expression-controller.test.ts | 41 +++++ .../live2d/expression-controller.ts | 11 +- .../src/stores/expression-store.ts | 81 ++++++++- .../model-settings/live2d.browser.test.ts | 83 +++++++++ .../settings/model-settings/live2d.vue | 65 +++---- .../settings/model-settings/panel.vue | 3 + .../settings/model-settings/runtime.ts | 5 + 14 files changed, 628 insertions(+), 114 deletions(-) create mode 100644 apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts create mode 100644 apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts create mode 100644 packages/stage-ui-live2d/src/composables/live2d/expression-controller.test.ts create mode 100644 packages/stage-ui/src/components/scenarios/settings/model-settings/live2d.browser.test.ts diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts new file mode 100644 index 000000000..a9ff76df6 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-owner.ts @@ -0,0 +1,90 @@ +import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store' +import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' +import type { MaybeRefOrGetter } from 'vue' + +import type { ModelSettingsRuntimeContext } from '../../shared/model-settings-runtime' + +import { defineInvokeHandler } from '@moeru/eventa' +import { onBeforeUnmount, toValue, watch } from 'vue' + +import { + applyLive2DExpressionSettingsCommand, + getModelSettingsRuntimeContext, + modelSettingsRuntimeOwnerGone, + modelSettingsRuntimeSnapshotChanged, + modelSettingsRuntimeSnapshotRequested, +} from '../../shared/model-settings-runtime' + +interface UseModelSettingsRuntimeOwnerOptions { + ownerInstanceId: string + renderer: MaybeRefOrGetter + runtimeSnapshot: MaybeRefOrGetter + applyLive2DExpressionCommand: (command: Live2DExpressionSettingsCommand) => void + context?: ModelSettingsRuntimeContext +} + +/** + * Owns the model-settings channel for the renderer that controls the active model. + * + * The owner publishes runtime snapshots and accepts commands for its current owner ID. + * Commands for stale owners or non-Live2D renderers do not change the expression store. + */ +export function useModelSettingsRuntimeOwner(options: UseModelSettingsRuntimeOwnerOptions) { + const context = options.context ?? getModelSettingsRuntimeContext() + + function postSnapshot(snapshot: ModelSettingsRuntimeSnapshot) { + void context.emit(modelSettingsRuntimeSnapshotChanged, snapshot).catch((error) => { + console.warn('[Model Settings Runtime] Failed to publish the runtime snapshot:', error) + }) + } + + watch(() => toValue(options.runtimeSnapshot), (snapshot) => { + postSnapshot(snapshot) + }, { immediate: true }) + + const stopSnapshotRequests = context.on(modelSettingsRuntimeSnapshotRequested, () => { + postSnapshot(toValue(options.runtimeSnapshot)) + }) + const stopExpressionCommands = defineInvokeHandler(context, applyLive2DExpressionSettingsCommand, (request) => { + const currentSnapshot = toValue(options.runtimeSnapshot) + if (request.ownerInstanceId !== options.ownerInstanceId) { + return { + applied: false, + snapshot: currentSnapshot, + rejectionReason: 'owner-changed', + } + } + + if (!request.modelId || request.modelId !== currentSnapshot.modelId) { + return { + applied: false, + snapshot: currentSnapshot, + rejectionReason: 'model-changed', + } + } + + if (toValue(options.renderer) !== 'live2d' || currentSnapshot.controlsLocked) { + return { + applied: false, + snapshot: currentSnapshot, + rejectionReason: 'runtime-unavailable', + } + } + + options.applyLive2DExpressionCommand(request.command) + return { + applied: true, + snapshot: toValue(options.runtimeSnapshot), + } + }) + + onBeforeUnmount(() => { + stopSnapshotRequests() + stopExpressionCommands() + void context.emit(modelSettingsRuntimeOwnerGone, { + ownerInstanceId: options.ownerInstanceId, + }).catch((error) => { + console.warn('[Model Settings Runtime] Failed to publish owner shutdown:', error) + }) + }) +} diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts index ee7e8d615..7c74ce6cb 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime-snapshot.ts @@ -1,27 +1,61 @@ +import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store' import type { ModelSettingsRuntimeSnapshot, } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' -import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime' +import type { ModelSettingsRuntimeContext } from '../../shared/model-settings-runtime' +import { defineInvoke } from '@moeru/eventa' import { createEmptyModelSettingsRuntimeSnapshot, } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' -import { useBroadcastChannel } from '@vueuse/core' -import { onMounted, onUnmounted, ref, watch } from 'vue' +import { onMounted, onUnmounted, ref } from 'vue' import { - modelSettingsRuntimeSnapshotChannelName, + applyLive2DExpressionSettingsCommand, + getModelSettingsRuntimeContext, + modelSettingsRuntimeOwnerGone, + modelSettingsRuntimeSnapshotChanged, + modelSettingsRuntimeSnapshotRequested, } from '../../shared/model-settings-runtime' -export function useModelSettingsRuntimeSnapshot() { +interface UseModelSettingsRuntimeSnapshotOptions { + context?: ModelSettingsRuntimeContext + /** Maximum wait for the stage owner to respond. @default 1000 */ + commandTimeoutMs?: number +} + +export function useModelSettingsRuntimeSnapshot(options: UseModelSettingsRuntimeSnapshotOptions = {}) { const runtimeSnapshot = ref(createEmptyModelSettingsRuntimeSnapshot()) - const { data, post } = useBroadcastChannel({ - name: modelSettingsRuntimeSnapshotChannelName, - }) + const context = options.context ?? getModelSettingsRuntimeContext() + const invokeExpressionCommand = defineInvoke(context, applyLive2DExpressionSettingsCommand) const requestCurrent = () => { - post({ type: 'request-current' }) + void context.emit(modelSettingsRuntimeSnapshotRequested, undefined) + } + + const sendLive2DExpressionCommand = async (command: Live2DExpressionSettingsCommand) => { + const snapshot = runtimeSnapshot.value + if (!snapshot.ownerInstanceId || !snapshot.modelId || snapshot.renderer !== 'live2d' || snapshot.controlsLocked) + return false + + try { + const response = await invokeExpressionCommand({ + ownerInstanceId: snapshot.ownerInstanceId, + modelId: snapshot.modelId, + command, + }, { + signal: AbortSignal.timeout(options.commandTimeoutMs ?? 1000), + }) + runtimeSnapshot.value = response.snapshot + return response.applied + } + catch (error) { + runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot() + requestCurrent() + console.warn('[Model Settings Runtime] Failed to apply the Live2D expression command:', error) + return false + } } const syncFromOwner = () => { @@ -32,6 +66,17 @@ export function useModelSettingsRuntimeSnapshot() { requestCurrent() } + const stopSnapshots = context.on(modelSettingsRuntimeSnapshotChanged, (event) => { + if (event.body) + runtimeSnapshot.value = event.body + }) + const stopOwnerGone = context.on(modelSettingsRuntimeOwnerGone, (event) => { + if (!event.body || runtimeSnapshot.value.ownerInstanceId !== event.body.ownerInstanceId) + return + + runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot() + }) + onMounted(() => { requestCurrent() window.addEventListener('focus', syncFromOwner) @@ -41,27 +86,13 @@ export function useModelSettingsRuntimeSnapshot() { onUnmounted(() => { window.removeEventListener('focus', syncFromOwner) document.removeEventListener('visibilitychange', syncFromOwnerWhenVisible) - }) - - watch(data, (event) => { - if (!event) - return - - if (event.type === 'snapshot') { - runtimeSnapshot.value = event.snapshot - return - } - - if (event.type === 'owner-gone') { - if (runtimeSnapshot.value.ownerInstanceId !== event.ownerInstanceId) - return - - runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot() - } + stopSnapshots() + stopOwnerGone() }) return { runtimeSnapshot, requestCurrent, + sendLive2DExpressionCommand, } } diff --git a/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts new file mode 100644 index 000000000..dabcd9380 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts @@ -0,0 +1,171 @@ +import type { ExpressionEntry, ExpressionGroupDefinition } from '@proj-airi/stage-ui-live2d/stores/expression-store' +import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' + +import { defineInvoke } from '@moeru/eventa' +import { createContext as createBroadcastChannelContext } from '@moeru/eventa/adapters/broadcast-channel' +import { useExpressionStore } from '@proj-airi/stage-ui-live2d/stores/expression-store' +import { createEmptyModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' +import { createPinia } from 'pinia' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from 'vitest-browser-vue' +import { computed, defineComponent, shallowRef } from 'vue' + +import { applyLive2DExpressionSettingsCommand, modelSettingsRuntimeChannelName } from '../../shared/model-settings-runtime' +import { useModelSettingsRuntimeOwner } from './model-settings-runtime-owner' +import { useModelSettingsRuntimeSnapshot } from './model-settings-runtime-snapshot' + +const expressionGroups: ExpressionGroupDefinition[] = [{ + name: 'happy', + parameters: [{ parameterId: 'ParamHappy', blend: 'Add', value: 1 }], +}] + +const expressionEntries: ExpressionEntry[] = [{ + name: 'ParamHappy', + parameterId: 'ParamHappy', + blend: 'Add', + currentValue: 0, + defaultValue: 0, + modelDefault: 0, + targetValue: 1, +}] + +describe('model settings runtime channel', () => { + const channelContexts: Array> = [] + + afterEach(() => { + cleanup() + for (const channelContext of channelContexts) + channelContext.dispose() + channelContexts.length = 0 + localStorage.clear() + }) + + // https://github.com/moeru-ai/airi/issues/2450 + it('applies an expression command through Eventa and rejects stale runtime identities for Issue #2450', async () => { + // ROOT CAUSE: + // + // The settings window and the stage window have separate Pinia stores. + // A component-only test did not cover the channel, owner check, store update, or returned snapshot. + // + // We fixed this with an Eventa RPC that returns the current owner snapshot. + const ownerInstanceId = 'stage-owner' + const ownerPinia = createPinia() + const settingsPinia = createPinia() + const ownerExpressionStore = useExpressionStore(ownerPinia) + ownerExpressionStore.registerExpressions('model-a', expressionGroups, expressionEntries) + + const ownerChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true }) + const settingsChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true }) + channelContexts.push(ownerChannelContext, settingsChannelContext) + + const renderer = shallowRef('live2d') + const ownerSnapshot = computed(() => createEmptyModelSettingsRuntimeSnapshot({ + ownerInstanceId, + modelId: ownerExpressionStore.modelId, + renderer: renderer.value, + phase: 'mounted', + controlsLocked: false, + previewAvailable: true, + canCapturePreview: false, + live2dExpressions: ownerExpressionStore.settingsSnapshot, + updatedAt: Date.now(), + })) + + let settingsRuntime: ReturnType | undefined + const TestHost = defineComponent({ + setup() { + useModelSettingsRuntimeOwner({ + ownerInstanceId, + renderer: () => renderer.value, + runtimeSnapshot: ownerSnapshot, + applyLive2DExpressionCommand: command => ownerExpressionStore.applySettingsCommand(command), + context: ownerChannelContext.context, + }) + settingsRuntime = useModelSettingsRuntimeSnapshot({ + context: settingsChannelContext.context, + }) + + return () => null + }, + }) + + await render(TestHost, { + global: { + plugins: [settingsPinia], + }, + }) + + if (!settingsRuntime) + throw new Error('The settings runtime did not mount.') + const mountedSettingsRuntime = settingsRuntime + + await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.live2dExpressions?.groups).toEqual([{ + name: 'happy', + active: false, + exposedToLlm: false, + }])) + + const invokeExpressionCommand = defineInvoke(settingsChannelContext.context, applyLive2DExpressionSettingsCommand) + const staleOwnerResponse = await invokeExpressionCommand({ + ownerInstanceId: 'stale-owner', + modelId: 'model-a', + command: { type: 'toggle', name: 'happy' }, + }, { + signal: AbortSignal.timeout(1000), + }) + + expect(staleOwnerResponse.applied).toBe(false) + expect(staleOwnerResponse.rejectionReason).toBe('owner-changed') + expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(false) + + const applied = await mountedSettingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' }) + + await vi.waitFor(() => expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(true)) + await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.live2dExpressions?.groups[0].active).toBe(true)) + expect(applied).toBe(true) + expect(useExpressionStore(settingsPinia).expressionGroups.size).toBe(0) + + ownerExpressionStore.registerExpressions('model-b', expressionGroups, expressionEntries) + const rejected = await mountedSettingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' }) + + expect(rejected).toBe(false) + expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(false) + await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.modelId).toBe('model-b')) + }) + + // https://github.com/moeru-ai/airi/issues/2450 + it('clears a stale snapshot when the stage owner does not answer', async () => { + const settingsChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true }) + channelContexts.push(settingsChannelContext) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + let settingsRuntime: ReturnType | undefined + const TestHost = defineComponent({ + setup() { + settingsRuntime = useModelSettingsRuntimeSnapshot({ + context: settingsChannelContext.context, + commandTimeoutMs: 20, + }) + return () => null + }, + }) + + await render(TestHost) + if (!settingsRuntime) + throw new Error('The settings runtime did not mount.') + + settingsRuntime.runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot({ + ownerInstanceId: 'stale-owner', + modelId: 'model-a', + renderer: 'live2d', + phase: 'mounted', + controlsLocked: false, + }) + + const applied = await settingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' }) + + expect(applied).toBe(false) + expect(settingsRuntime.runtimeSnapshot.value.ownerInstanceId).toBe('') + expect(warn).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index 9757bd613..f3c3b1ad1 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -2,8 +2,6 @@ import type { CaptionChannelEvent, HearingInputChannelEvent } from '@proj-airi/stage-shared' import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime' -import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime' - import { errorMessageFrom, tryCatch } from '@moeru/std' import { electron } from '@proj-airi/electron-eventa' import { @@ -15,6 +13,7 @@ import { } from '@proj-airi/electron-vueuse' import { createTranscriptBuffer } from '@proj-airi/pipelines-audio' import { hearingInputChannelName } from '@proj-airi/stage-shared' +import { useExpressionStore } from '@proj-airi/stage-ui-live2d/stores/expression-store' import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three' import { HoloCoupon } from '@proj-airi/stage-ui/components' import { @@ -40,7 +39,7 @@ import ControlsIsland from '../components/stage-islands/controls-island/index.vu import ResourceStatusIsland from '../components/stage-islands/resource-status-island/index.vue' import { electronOpenOnboarding } from '../../shared/eventa' -import { modelSettingsRuntimeSnapshotChannelName } from '../../shared/model-settings-runtime' +import { useModelSettingsRuntimeOwner } from '../composables/model-settings-runtime-owner' import { useControlsIslandStore } from '../stores/controls-island' import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle' import { resolveFadeOnHoverInteraction } from '../utils/fade-on-hover' @@ -100,11 +99,11 @@ const isTransparentByThreeExact = useThreeSceneIsTransparentAtPoint( const settingsStore = useSettings() const { stageModelRenderer, stageModelSelectedUrl } = storeToRefs(settingsStore) const modelStore = useModelStore() +const expressionStore = useExpressionStore() const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore) const { stagePaused } = storeToRefs(useStageWindowLifecycleStore()) const { fadeOnHoverEnabled } = storeToRefs(useControlsIslandStore()) const modelSettingsRuntimeOwnerInstanceId = `tamagotchi-main-stage:${Math.random().toString(36).slice(2, 10)}` -const { data: modelSettingsRuntimeChannelEvent, post: postModelSettingsRuntimeChannelEvent } = useBroadcastChannel({ name: modelSettingsRuntimeSnapshotChannelName }) const shouldUseThreeTransparencyHitTest = computed(() => shouldSampleStageTransparency({ componentState: componentStateStage.value, fadeOnHoverEnabled: fadeOnHoverEnabled.value, @@ -151,11 +150,13 @@ const modelSettingsRuntimeSnapshot = computed(() = return createEmptyModelSettingsRuntimeSnapshot({ ownerInstanceId: modelSettingsRuntimeOwnerInstanceId, + modelId: expressionStore.modelId, renderer: 'live2d', phase, controlsLocked: hasModel ? phase !== 'mounted' : false, previewAvailable: hasModel, canCapturePreview: false, + live2dExpressions: expressionStore.settingsSnapshot, updatedAt: Date.now(), }) } @@ -297,25 +298,13 @@ watch( { immediate: true }, ) -// Emit runtime snapshot on change and on request from settings panel -/** - * Sends model-settings runtime events without letting closed HMR channels break the stage. - */ -function postModelSettingsRuntimeEvent(event: ModelSettingsRuntimeChannelEvent) { - const { error } = tryCatch(() => postModelSettingsRuntimeChannelEvent(event)) - if (error) - console.warn('[Main Page] Failed to post model settings runtime event:', error) -} - -watch(modelSettingsRuntimeSnapshot, (snapshot) => { - postModelSettingsRuntimeEvent({ type: 'snapshot', snapshot }) -}, { immediate: true }) - -watch(modelSettingsRuntimeChannelEvent, (event) => { - if (event?.type !== 'request-current') - return - - postModelSettingsRuntimeEvent({ type: 'snapshot', snapshot: modelSettingsRuntimeSnapshot.value }) +useModelSettingsRuntimeOwner({ + ownerInstanceId: modelSettingsRuntimeOwnerInstanceId, + renderer: () => stageModelRenderer.value, + runtimeSnapshot: modelSettingsRuntimeSnapshot, + applyLive2DExpressionCommand: (command) => { + expressionStore.applySettingsCommand(command) + }, }) const settingsAudioDeviceStore = useSettingsAudioDevice() @@ -756,10 +745,6 @@ onUnmounted(() => { } hearingInputClearTimers.clear() clearHearingInput() - postModelSettingsRuntimeEvent({ - type: 'owner-gone', - ownerInstanceId: modelSettingsRuntimeOwnerInstanceId, - }) clearAssistantSpeechResumeTimer() void voiceInputInteractionLifecycle.stop().catch(error => reportVoiceInputFailure('stop listening', error)) }) diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue index 9a801458d..96499076a 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue @@ -61,7 +61,7 @@ const godotStageStatus = ref({ const switchingGodotStage = ref(false) const godotViewError = ref() const godotViewSnapshot = ref(null) -const { runtimeSnapshot } = useModelSettingsRuntimeSnapshot() +const { runtimeSnapshot, sendLive2DExpressionCommand } = useModelSettingsRuntimeSnapshot() let sceneSyncGeneration = 0 let godotSessionEpoch = 0 @@ -385,6 +385,7 @@ onUnmounted(() => { 'sm:max-h-[80dvh]', 'relative', ]" + @live2d-expression-command="sendLive2DExpressionCommand" @patch-godot-view-state="handleGodotViewPatch" >