From 5d8a0c88fe2a55f10e32d5b252821f9162fd5109 Mon Sep 17 00:00:00 2001 From: Liet Blue <127093491+lietblue@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:32:07 +0800 Subject: [PATCH] feat(stage-tamagotchi): add profile switcher to controls island and web header (#1328) --- .../controls-island-profile-picker.vue | 20 +++ .../stage-islands/controls-island/index.vue | 14 ++ packages/i18n/src/locales/en/settings.yaml | 1 + packages/i18n/src/locales/en/stage.yaml | 5 + .../i18n/src/locales/en/tamagotchi/stage.yaml | 1 + .../locales/vi/tamagotchi/electron/tray.yaml | 2 +- .../i18n/src/locales/zh-Hans/settings.yaml | 1 + packages/i18n/src/locales/zh-Hans/stage.yaml | 5 + .../src/locales/zh-Hans/tamagotchi/stage.yaml | 1 + .../src/components/Layouts/Header.vue | 10 ++ .../components/CardCreationDialog.vue | 43 ++++- .../stage-ui/src/components/misc/index.ts | 1 + .../misc/profile-switcher-popover.vue | 162 ++++++++++++++++++ .../stage-ui/src/stores/modules/airi-card.ts | 16 +- 14 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue create mode 100644 packages/stage-ui/src/components/misc/profile-switcher-popover.vue diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue new file mode 100644 index 000000000..2a1f30c94 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-profile-picker.vue @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue index 3005d1250..7d9391f06 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue @@ -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() { + + + + + + + + + + {{ t('tamagotchi.stage.controls-island.switch-profile') }} + + + diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 189563441..9cea061ff 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -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 diff --git a/packages/i18n/src/locales/en/stage.yaml b/packages/i18n/src/locales/en/stage.yaml index b847a87a5..4f3b03744 100644 --- a/packages/i18n/src/locales/en/stage.yaml +++ b/packages/i18n/src/locales/en/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/en/tamagotchi/stage.yaml b/packages/i18n/src/locales/en/tamagotchi/stage.yaml index a193abe8e..5171efb6d 100644 --- a/packages/i18n/src/locales/en/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/vi/tamagotchi/electron/tray.yaml b/packages/i18n/src/locales/vi/tamagotchi/electron/tray.yaml index 76c451d58..29ed05942 100644 --- a/packages/i18n/src/locales/vi/tamagotchi/electron/tray.yaml +++ b/packages/i18n/src/locales/vi/tamagotchi/electron/tray.yaml @@ -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 diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 7b3e61290..679fcc696 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -172,6 +172,7 @@ pages: scenario: 场景 search: 搜索角色卡... sort_by: 排序方式 + body-model: 形体 / 显示模型 speech: provider: 语音 / 提供商 model: 声音 / 模型 diff --git a/packages/i18n/src/locales/zh-Hans/stage.yaml b/packages/i18n/src/locales/zh-Hans/stage.yaml index cafb448c5..8d1239749 100644 --- a/packages/i18n/src/locales/zh-Hans/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/stage.yaml @@ -30,4 +30,9 @@ viewers: camera: 摄像机 model: title: 模型 +profile-switcher: + no-profile: 无配置 + no-profiles: 暂无配置,点击上方按钮创建 + save-as-new: 保存为新配置 + manage: 管理配置 waiting: 等待中 diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml index 3088d7b38..c5282b25c 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml @@ -21,6 +21,7 @@ docs: 'switch-to-dark-mode': 切换到暗色模式 'pin-on-top': 置顶显示 'unpin-from-top': 取消置顶 + 'switch-profile': 切换配置 'close': 关闭 'expand': 展开 'collapse': 折叠 diff --git a/packages/stage-layouts/src/components/Layouts/Header.vue b/packages/stage-layouts/src/components/Layouts/Header.vue index d90fe4036..489601c20 100644 --- a/packages/stage-layouts/src/components/Layouts/Header.vue +++ b/packages/stage-layouts/src/components/Layouts/Header.vue @@ -1,13 +1,23 @@ + diff --git a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue index eab34a7de..7d4907c7e 100644 --- a/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue +++ b/packages/stage-pages/src/pages/settings/airi-card/components/CardCreationDialog.vue @@ -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('') const selectedSpeechProvider = ref('') const selectedSpeechModel = ref('') const selectedSpeechVoiceId = ref('') +const selectedDisplayModelId = ref('') + +// 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" /> + + + + + + {{ t('settings.pages.card.body-model') }} + + + diff --git a/packages/stage-ui/src/components/misc/index.ts b/packages/stage-ui/src/components/misc/index.ts index 6c2982f33..78a9b1204 100644 --- a/packages/stage-ui/src/components/misc/index.ts +++ b/packages/stage-ui/src/components/misc/index.ts @@ -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' diff --git a/packages/stage-ui/src/components/misc/profile-switcher-popover.vue b/packages/stage-ui/src/components/misc/profile-switcher-popover.vue new file mode 100644 index 000000000..8621dcb7c --- /dev/null +++ b/packages/stage-ui/src/components/misc/profile-switcher-popover.vue @@ -0,0 +1,162 @@ + + + + + + + + + + {{ activeCard?.name ?? t('stage.profile-switcher.no-profile') }} + + + + + + + + + + + + + {{ card.name }} + + + + {{ t('stage.profile-switcher.no-profiles') }} + + + + + + + + {{ t('stage.profile-switcher.save-as-new') }} + + + + + {{ t('stage.profile-switcher.manage') }} + + + + + + diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index 8ad99f302..1d4d96871 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -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-') + 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'] }),