feat(stage-tamagotchi): add profile switcher to controls island and web header (#1328)
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { ProfileSwitcherPopover } from '@proj-airi/stage-ui/components'
|
||||
|
||||
import { electronOpenSettings } from '../../../../shared/eventa'
|
||||
|
||||
const openSettings = useElectronEventaInvoke(electronOpenSettings)
|
||||
|
||||
function handleNavigation() {
|
||||
openSettings({ route: '/settings/airi-card' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileSwitcherPopover placement="up" @create="handleNavigation" @manage="handleNavigation">
|
||||
<template #default="{ open, toggle, activeCard }">
|
||||
<slot :open="open" :toggle="toggle" :active-card="activeCard" />
|
||||
</template>
|
||||
</ProfileSwitcherPopover>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import ControlButtonTooltip from './control-button-tooltip.vue'
|
||||
import ControlButton from './control-button.vue'
|
||||
import ControlsIslandFadeOnHover from './controls-island-fade-on-hover.vue'
|
||||
import ControlsIslandHearingConfig from './controls-island-hearing-config.vue'
|
||||
import ControlsIslandProfilePicker from './controls-island-profile-picker.vue'
|
||||
import IndicatorMicVolume from './indicator-mic-volume.vue'
|
||||
|
||||
import {
|
||||
@@ -128,6 +129,19 @@ function refreshWindow() {
|
||||
</template>
|
||||
</ControlButtonTooltip>
|
||||
|
||||
<ControlButtonTooltip disable-hoverable-content>
|
||||
<ControlsIslandProfilePicker>
|
||||
<template #default="{ toggle }">
|
||||
<ControlButton :button-style="adjustStyleClasses.button" @click="toggle">
|
||||
<div i-solar:emoji-funny-square-broken :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
|
||||
</ControlButton>
|
||||
</template>
|
||||
</ControlsIslandProfilePicker>
|
||||
<template #tooltip>
|
||||
{{ t('tamagotchi.stage.controls-island.switch-profile') }}
|
||||
</template>
|
||||
</ControlButtonTooltip>
|
||||
|
||||
<ControlButtonTooltip disable-hoverable-content>
|
||||
<ControlButton :button-style="adjustStyleClasses.button" @click="openChat">
|
||||
<div i-solar:chat-line-line-duotone :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
|
||||
|
||||
@@ -180,6 +180,7 @@ pages:
|
||||
scenario: Scenario
|
||||
search: Search cards...
|
||||
sort_by: Sort by
|
||||
body-model: Body / Display Model
|
||||
speech:
|
||||
provider: Speech / Provider
|
||||
model: Speech / Model
|
||||
|
||||
@@ -30,4 +30,9 @@ viewers:
|
||||
camera: Camera
|
||||
model:
|
||||
title: Model
|
||||
profile-switcher:
|
||||
no-profile: No Profile
|
||||
no-profiles: No profiles yet
|
||||
save-as-new: Save as New Profile
|
||||
manage: Manage Profiles
|
||||
waiting: Waiting
|
||||
|
||||
@@ -21,6 +21,7 @@ docs:
|
||||
'switch-to-dark-mode': Switch to dark mode
|
||||
'pin-on-top': Pin on top
|
||||
'unpin-from-top': Unpin from top
|
||||
'switch-profile': Switch Profile
|
||||
'close': Close
|
||||
'expand': Expand
|
||||
'collapse': Collapse
|
||||
|
||||
@@ -21,7 +21,7 @@ menu:
|
||||
close_caption: Close Caption...
|
||||
caption_overlay: Lớp phủ phụ đề
|
||||
follow_window: Theo cửa sổ
|
||||
reset_position: Đặt lại vị trí
|
||||
reset_position: Đặt lại vị trí
|
||||
devtools: Công cụ phát triển
|
||||
troubleshoot_beatsync: Sửa lỗi BeatSync...
|
||||
quit: Thoát ra
|
||||
|
||||
@@ -172,6 +172,7 @@ pages:
|
||||
scenario: 场景
|
||||
search: 搜索角色卡...
|
||||
sort_by: 排序方式
|
||||
body-model: 形体 / 显示模型
|
||||
speech:
|
||||
provider: 语音 / 提供商
|
||||
model: 声音 / 模型
|
||||
|
||||
@@ -30,4 +30,9 @@ viewers:
|
||||
camera: 摄像机
|
||||
model:
|
||||
title: 模型
|
||||
profile-switcher:
|
||||
no-profile: 无配置
|
||||
no-profiles: 暂无配置,点击上方按钮创建
|
||||
save-as-new: 保存为新配置
|
||||
manage: 管理配置
|
||||
waiting: 等待中
|
||||
|
||||
@@ -21,6 +21,7 @@ docs:
|
||||
'switch-to-dark-mode': 切换到暗色模式
|
||||
'pin-on-top': 置顶显示
|
||||
'unpin-from-top': 取消置顶
|
||||
'switch-profile': 切换配置
|
||||
'close': 关闭
|
||||
'expand': 展开
|
||||
'collapse': 折叠
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ProfileSwitcherPopover } from '@proj-airi/stage-ui/components'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import HeaderAvatar from './HeaderAvatar.vue'
|
||||
import HeaderLink from './HeaderLink.vue'
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function handleNavigation() {
|
||||
router.push('/settings/airi-card')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header mb-1 w-full flex items-center justify-between gap-2>
|
||||
<HeaderLink />
|
||||
<div flex items-center gap-2>
|
||||
<ProfileSwitcherPopover @create="handleNavigation" @manage="handleNavigation" />
|
||||
<ActionAbout />
|
||||
<HeaderAvatar />
|
||||
</div>
|
||||
|
||||
+37
-6
@@ -4,10 +4,12 @@ import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card
|
||||
|
||||
import kebabcase from '@stdlib/string-base-kebabcase'
|
||||
|
||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { Button, FieldInput, FieldValues } from '@proj-airi/ui'
|
||||
import { Select } from '@proj-airi/ui/components/form'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -38,9 +40,13 @@ const cardStore = useAiriCardStore()
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
const stageModelStore = useSettingsStageModel()
|
||||
|
||||
const { activeProvider: consciousnessProvider, activeModel: defaultConsciousnessModel } = storeToRefs(consciousnessStore)
|
||||
const { activeSpeechProvider: speechProvider, activeSpeechModel: defaultSpeechModel, activeSpeechVoiceId: defaultSpeechVoiceId } = storeToRefs(speechStore)
|
||||
const { displayModels } = storeToRefs(displayModelsStore)
|
||||
const { stageModelSelected: defaultDisplayModelId } = storeToRefs(stageModelStore)
|
||||
|
||||
// Determine if we're in edit mode
|
||||
const isEditMode = computed(() => !!props.cardId)
|
||||
@@ -51,6 +57,15 @@ const selectedConsciousnessModel = ref<string>('')
|
||||
const selectedSpeechProvider = ref<string>('')
|
||||
const selectedSpeechModel = ref<string>('')
|
||||
const selectedSpeechVoiceId = ref<string>('')
|
||||
const selectedDisplayModelId = ref<string>('')
|
||||
|
||||
// Computed: available display model options
|
||||
const displayModelOptions = computed(() =>
|
||||
displayModels.value.map(model => ({
|
||||
value: model.id,
|
||||
label: model.name,
|
||||
})),
|
||||
)
|
||||
|
||||
// Computed: available consciousness provider options
|
||||
const consciousnessProviderOptions = computed(() => {
|
||||
@@ -194,7 +209,7 @@ function saveCard(card: Card): boolean {
|
||||
// Before saving, let's validate what the user entered :
|
||||
const rawCard: Card = toRaw(card)
|
||||
|
||||
if (!(rawCard.name!.length > 0)) { // ! is used, since a default value is provided, and computed values passed to v-model should never be undefined
|
||||
if (!((rawCard.name?.length ?? 0) > 0)) {
|
||||
// No name
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.name')
|
||||
@@ -206,31 +221,31 @@ function saveCard(card: Card): boolean {
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.version')
|
||||
return false
|
||||
}
|
||||
else if (!(rawCard.description!.length > 0)) {
|
||||
else if (!((rawCard.description?.length ?? 0) > 0)) {
|
||||
// No description
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.description')
|
||||
return false
|
||||
}
|
||||
else if (!(rawCard.personality!.length > 0)) {
|
||||
else if (!((rawCard.personality?.length ?? 0) > 0)) {
|
||||
// No personality
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.personality')
|
||||
return false
|
||||
}
|
||||
else if (!(rawCard.scenario!.length > 0)) {
|
||||
else if (!((rawCard.scenario?.length ?? 0) > 0)) {
|
||||
// No Scenario
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.scenario')
|
||||
return false
|
||||
}
|
||||
else if (!(rawCard.systemPrompt!.length > 0)) {
|
||||
else if (!((rawCard.systemPrompt?.length ?? 0) > 0)) {
|
||||
// No sys prompt
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.systemprompt')
|
||||
return false
|
||||
}
|
||||
else if (!(rawCard.postHistoryInstructions!.length > 0)) {
|
||||
else if (!((rawCard.postHistoryInstructions?.length ?? 0) > 0)) {
|
||||
// No post history prompt
|
||||
showError.value = true
|
||||
errorMessage.value = t('settings.pages.card.creation.errors.posthistoryinstructions')
|
||||
@@ -254,6 +269,7 @@ function saveCard(card: Card): boolean {
|
||||
model: selectedSpeechModel.value || defaultSpeechModel.value,
|
||||
voice_id: selectedSpeechVoiceId.value || defaultSpeechVoiceId.value,
|
||||
},
|
||||
displayModelId: selectedDisplayModelId.value || defaultDisplayModelId.value,
|
||||
},
|
||||
agents: {},
|
||||
} as AiriExtension,
|
||||
@@ -287,6 +303,7 @@ function initializeCard(): Card {
|
||||
selectedSpeechProvider.value = airiExt?.modules?.speech?.provider || speechProvider.value
|
||||
selectedSpeechModel.value = airiExt?.modules?.speech?.model || defaultSpeechModel.value
|
||||
selectedSpeechVoiceId.value = airiExt?.modules?.speech?.voice_id || defaultSpeechVoiceId.value
|
||||
selectedDisplayModelId.value = airiExt?.modules?.displayModelId || defaultDisplayModelId.value
|
||||
|
||||
// Return existing card data or defaults
|
||||
if (existingCard) {
|
||||
@@ -505,6 +522,20 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Display Model (Body) -->
|
||||
<div :class="['flex', 'flex-col', 'gap-2', 'sm:col-span-2']">
|
||||
<label :class="['flex', 'flex-row', 'items-center', 'gap-2', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
|
||||
<div i-solar:ghost-bold-duotone />
|
||||
{{ t('settings.pages.card.body-model') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="selectedDisplayModelId"
|
||||
:options="displayModelOptions"
|
||||
:placeholder="getDefaultPlaceholder(defaultDisplayModelId)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Alert } from './alert.vue'
|
||||
export { default as ErrorContainer } from './error-container.vue'
|
||||
export { default as ProfileSwitcherPopover } from './profile-switcher-popover.vue'
|
||||
export * from './steppers'
|
||||
export { default as WIP } from './wip.vue'
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAiriCardStore } from '../../stores/modules/airi-card'
|
||||
|
||||
interface Props {
|
||||
// 'down': opens below the trigger (for elements near the top, e.g. header)
|
||||
// 'up': opens above the trigger (for elements near the bottom, e.g. floating island)
|
||||
placement?: 'down' | 'up'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { placement: 'down' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void
|
||||
(e: 'manage'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const cardStore = useAiriCardStore()
|
||||
const { cards, activeCardId, activeCard } = storeToRefs(cardStore)
|
||||
|
||||
const open = ref(false)
|
||||
const popoverRef = ref<HTMLElement>()
|
||||
|
||||
onClickOutside(popoverRef, () => {
|
||||
open.value = false
|
||||
})
|
||||
|
||||
const cardsList = computed(() =>
|
||||
Array.from(cards.value.entries()).map(([id, card]) => ({ id, name: card.name })),
|
||||
)
|
||||
|
||||
function activateCard(id: string) {
|
||||
activeCardId.value = id
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
open.value = false
|
||||
emit('create')
|
||||
}
|
||||
|
||||
function handleManage() {
|
||||
open.value = false
|
||||
emit('manage')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="popoverRef" class="relative">
|
||||
<!-- Trigger button -->
|
||||
<slot :open="open" :toggle="() => open = !open" :active-card="activeCard">
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-sm transition-all',
|
||||
'border border-neutral-200/60 dark:border-neutral-800/60',
|
||||
'bg-neutral-50/70 dark:bg-neutral-800/70 backdrop-blur-md',
|
||||
'hover:bg-neutral-100 dark:hover:bg-neutral-700',
|
||||
open ? 'ring-2 ring-primary-500/20' : '',
|
||||
]"
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
>
|
||||
<div class="i-solar:emoji-funny-square-broken size-4 text-neutral-500 dark:text-neutral-400" />
|
||||
<span class="max-w-28 truncate text-neutral-700 dark:text-neutral-200">
|
||||
{{ activeCard?.name ?? t('stage.profile-switcher.no-profile') }}
|
||||
</span>
|
||||
<div
|
||||
class="i-solar:alt-arrow-down-linear size-3 text-neutral-400 transition-transform duration-200"
|
||||
:class="open === (placement === 'down') ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
</slot>
|
||||
|
||||
<!-- Popover panel -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
:enter-from-class="placement === 'up' ? 'translate-y-1 opacity-0 scale-95' : '-translate-y-1 opacity-0 scale-95'"
|
||||
enter-to-class="translate-y-0 opacity-100 scale-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100 scale-100"
|
||||
:leave-to-class="placement === 'up' ? 'translate-y-1 opacity-0 scale-95' : '-translate-y-1 opacity-0 scale-95'"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
:class="[
|
||||
'absolute right-0 z-50 min-w-48',
|
||||
placement === 'up'
|
||||
? 'bottom-full mb-2 origin-bottom-right'
|
||||
: 'top-full mt-2 origin-top-right',
|
||||
'border border-neutral-200/60 dark:border-neutral-800/60 rounded-xl',
|
||||
'bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl shadow-xl',
|
||||
'divide-y divide-neutral-100 dark:divide-neutral-800',
|
||||
]"
|
||||
>
|
||||
<!-- Profile list -->
|
||||
<div class="max-h-60 overflow-y-auto p-1">
|
||||
<button
|
||||
v-for="card in cardsList"
|
||||
:key="card.id"
|
||||
:class="[
|
||||
'group w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition',
|
||||
card.id === activeCardId
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
|
||||
]"
|
||||
type="button"
|
||||
@click="activateCard(card.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'size-4 shrink-0',
|
||||
card.id === activeCardId
|
||||
? 'i-solar:check-circle-bold-duotone text-primary-500'
|
||||
: 'i-solar:emoji-funny-square-broken text-neutral-400',
|
||||
]"
|
||||
/>
|
||||
<span class="truncate">{{ card.name }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="cardsList.length === 0"
|
||||
class="px-3 py-2 text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
{{ t('stage.profile-switcher.no-profiles') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-1">
|
||||
<button
|
||||
:class="[
|
||||
'group w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition',
|
||||
'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
|
||||
]"
|
||||
type="button"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<div class="i-solar:add-circle-bold-duotone size-4 text-neutral-400 transition group-hover:text-primary-500" />
|
||||
{{ t('stage.profile-switcher.save-as-new') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="[
|
||||
'group w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition',
|
||||
'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
|
||||
]"
|
||||
type="button"
|
||||
@click="handleManage"
|
||||
>
|
||||
<div class="i-solar:settings-minimalistic-bold-duotone size-4 text-neutral-400 transition group-hover:text-primary-500" />
|
||||
{{ t('stage.profile-switcher.manage') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SystemPromptV2 from '../../constants/prompts/system-v2'
|
||||
|
||||
import { useSettingsStageModel } from '../settings/stage-model'
|
||||
import { useConsciousnessStore } from './consciousness'
|
||||
import { useSpeechStore } from './speech'
|
||||
|
||||
@@ -40,6 +41,9 @@ export interface AiriExtension {
|
||||
file?: string // Example: "live2d/model.json"
|
||||
url?: string // Example: "https://example.com/live2d/model.json"
|
||||
}
|
||||
|
||||
// ID from display-models store (e.g. 'preset-live2d-1', 'display-model-<nanoid>')
|
||||
displayModelId?: string
|
||||
}
|
||||
|
||||
agents: {
|
||||
@@ -66,6 +70,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
|
||||
|
||||
const consciousnessStore = useConsciousnessStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const stageModelStore = useSettingsStageModel()
|
||||
|
||||
const {
|
||||
activeProvider: activeConsciousnessProvider,
|
||||
@@ -123,6 +128,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
|
||||
model: activeSpeechModel.value,
|
||||
voice_id: activeSpeechVoiceId.value,
|
||||
},
|
||||
displayModelId: stageModelStore.stageModelSelected,
|
||||
}
|
||||
|
||||
// Return default if no extension exists
|
||||
@@ -151,6 +157,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
|
||||
},
|
||||
vrm: existingExtension.modules?.vrm,
|
||||
live2d: existingExtension.modules?.live2d,
|
||||
displayModelId: existingExtension.modules?.displayModelId ?? defaultModules.displayModelId,
|
||||
},
|
||||
agents: existingExtension.agents ?? {},
|
||||
}
|
||||
@@ -223,7 +230,6 @@ export const useAiriCardStore = defineStore('airi-card', () => {
|
||||
if (!newCard)
|
||||
return
|
||||
|
||||
// TODO: live2d, vrm
|
||||
// TODO: Minecraft Agent, etc
|
||||
const extension = resolveAiriExtension(newCard)
|
||||
if (!extension)
|
||||
@@ -235,6 +241,13 @@ export const useAiriCardStore = defineStore('airi-card', () => {
|
||||
activeSpeechProvider.value = extension?.modules?.speech?.provider
|
||||
activeSpeechModel.value = extension?.modules?.speech?.model
|
||||
activeSpeechVoiceId.value = extension?.modules?.speech?.voice_id
|
||||
|
||||
// Apply body model if the card has a display model configured.
|
||||
// NOTICE: must set via store property directly (not storeToRefs .value) so Pinia's
|
||||
// proxy correctly calls the writable computed setter → stageModelSelectedState → updateStageModel().
|
||||
if (extension.modules?.displayModelId) {
|
||||
stageModelStore.stageModelSelected = extension.modules.displayModelId
|
||||
}
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
@@ -264,6 +277,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
|
||||
model: activeSpeechModel.value,
|
||||
voice_id: activeSpeechVoiceId.value,
|
||||
},
|
||||
displayModelId: stageModelStore.stageModelSelected,
|
||||
} satisfies AiriExtension['modules']
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user