feat(stage-tamagotchi): add profile switcher to controls island and web header (#1328)

This commit is contained in:
Liet Blue
2026-03-13 18:32:07 +08:00
committed by GitHub
parent c95b484bdc
commit 5d8a0c88fe
14 changed files with 274 additions and 8 deletions
@@ -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
+5
View File
@@ -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>
@@ -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']
}),