From ef9f122d501a1964dc7965df1fbe7cee41aed0dd Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 8 Sep 2026 17:48:39 +0800 Subject: [PATCH] fix(stage-ui): inherit global settings for default card (#2332) --- apps/stage-pocket/src/App.vue | 7 +- apps/stage-tamagotchi/src/renderer/App.vue | 7 +- apps/stage-web/src/App.vue | 7 +- packages/i18n/src/locales/en/settings.yaml | 5 +- .../i18n/src/locales/zh-Hans/settings.yaml | 4 +- .../components/CardCreationDialog.vue | 264 ++++++++------ .../airi-card/components/CardDetailDialog.vue | 33 +- .../pages/settings/modules/consciousness.vue | 21 +- .../src/pages/settings/modules/speech.vue | 61 ++-- .../src/pages/settings/modules/vision.vue | 32 +- packages/stage-ui/README.md | 25 ++ .../providers/providers/official/constants.ts | 6 + .../providers/providers/official/index.ts | 7 +- .../src/services/airi-card-editor.test.ts | 52 ++- .../stage-ui/src/services/airi-card-editor.ts | 43 +++ .../src/services/airi-card-modules.ts | 18 + .../airi-card-inheritance.browser.test.ts | 112 ++++++ .../modules/airi-card-inheritance.test.ts | 298 +++++++++++++++ .../src/stores/modules/airi-card.test.ts | 131 ++++--- .../stage-ui/src/stores/modules/airi-card.ts | 338 ++++++++++++------ .../stage-ui/src/stores/modules/default.ts | 2 +- 21 files changed, 1097 insertions(+), 376 deletions(-) create mode 100644 packages/stage-ui/src/libs/providers/providers/official/constants.ts create mode 100644 packages/stage-ui/src/services/airi-card-modules.ts create mode 100644 packages/stage-ui/src/stores/modules/airi-card-inheritance.browser.test.ts create mode 100644 packages/stage-ui/src/stores/modules/airi-card-inheritance.test.ts diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index 5079c0db7..ec4916488 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -10,7 +10,6 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' @@ -57,8 +56,7 @@ async function removeAuthenticationProviderConfiguration() { if (!syncedPinia.isLeader()) return - if (await unconfigureAuthenticationProviders()) - await cardStore.persistActiveCardModuleSelections() + await cardStore.configureForAuthentication(false) } function registerAuthenticatedSetup() { @@ -66,8 +64,7 @@ function registerAuthenticatedSetup() { if (!syncedPinia.isLeader()) return - if (await configureAsDefaultsIfEmpty()) - await cardStore.persistActiveCardModuleSelections() + await cardStore.configureForAuthentication(true) await onboardingStore.closeAfterAuthentication() }) stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration) diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 2c470499c..67ca8170f 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -19,7 +19,6 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' @@ -142,8 +141,7 @@ function createFullStageRuntime() { if (!syncedPinia.isLeader()) return - if (await unconfigureAuthenticationProviders()) - await cardStore.persistActiveCardModuleSelections() + await cardStore.configureForAuthentication(false) } function registerAuthenticatedSetup() { @@ -151,8 +149,7 @@ function createFullStageRuntime() { if (!syncedPinia.isLeader()) return - if (await configureAsDefaultsIfEmpty()) - await cardStore.persistActiveCardModuleSelections() + await cardStore.configureForAuthentication(true) await onboardingStore.closeAfterAuthentication() }) stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration) diff --git a/apps/stage-web/src/App.vue b/apps/stage-web/src/App.vue index 6a05eefe8..768bc5308 100644 --- a/apps/stage-web/src/App.vue +++ b/apps/stage-web/src/App.vue @@ -12,7 +12,6 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card' import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry' import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness' -import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from '@proj-airi/stage-ui/stores/modules/default' import { useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' @@ -62,8 +61,7 @@ async function removeAuthenticationProviderConfiguration() { if (!syncedPinia.isLeader()) return - if (await unconfigureAuthenticationProviders()) - await cardStore.persistActiveCardModuleSelections() + await cardStore.configureForAuthentication(false) } function registerAuthenticatedSetup() { @@ -71,8 +69,7 @@ function registerAuthenticatedSetup() { if (!syncedPinia.isLeader()) return - if (await configureAsDefaultsIfEmpty()) - await cardStore.persistActiveCardModuleSelections() + await cardStore.configureForAuthentication(true) await onboardingStore.closeAfterAuthentication() }) stopLoggedOutSetup ??= authStore.onLogout(removeAuthenticationProviderConfiguration) diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 4112e3f69..3cbf70663 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -414,10 +414,10 @@ pages: systemprompt: You will receive messages, answer to them like a real human. posthistoryinstructions: Remember to imitate an human. modules_info: >- - Configure modules for this character card. If not set, default module - configuration will be used. + Configure modules for this character card. Empty fields inherit global settings. use_default: Use default use_default_not_configured: Use default (not configured) + inherit_global_settings: Inherit global settings fields_info: subtitle: >- You can put here some details about the character you are creating, @@ -441,6 +441,7 @@ pages: consciousness_model: Select the consciousness model (AI model) for this character. speech_model: Select the speech synthesis model and voice for this character. errors: + model_required: Select a model for each explicit provider. Models cannot be inherited from a different provider. name: Name should be valid or non-empty. version: 'Error: Invalid version number !' description: 'Error: You must provide a description for this card.' diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 3966436cd..45730f0d7 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -399,9 +399,10 @@ pages: systemprompt: 你将收到消息,请像真实人类一样回复。 posthistoryinstructions: 记得模仿人类的行为。 modules_info: >- - 为此角色卡配置模块。如未设置,将使用默认模块配置。 + 配置此角色卡的模块。空字段将继承全局设置。 use_default: 使用默认 use_default_not_configured: 使用默认(未配置) + inherit_global_settings: 继承全局设置 fields_info: subtitle: >- 您可以在这里填写有关您正在创建的角色的一些详细信息,解释他的背景和情境,以及应该如何回应您的互动。 @@ -421,6 +422,7 @@ pages: consciousness_model: 为这个角色选择 AI 模型。 speech_model: 为此角色指定语音合成模型与音色。 errors: + model_required: 请为指定的提供商选择模型,不能继承其他提供商的模型。 name: 错误:你必须提供一个有效的名称! version: '错误:版本号无效!' description: '错误:你必须为此卡片提供描述。' 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 048fdabdf..308d6314f 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 @@ -1,19 +1,19 @@ @@ -574,9 +628,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.chat.provider') }} @@ -588,10 +642,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.consciousness.model') }} @@ -603,9 +656,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.vision.provider') }} @@ -617,10 +670,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.vision.model') }} @@ -632,9 +684,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.speech.provider') }} @@ -646,10 +698,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.speech.model') }} @@ -661,10 +712,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.speech.voice') }} @@ -676,9 +726,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string { {{ t('settings.pages.card.body-model') }} @@ -695,7 +745,7 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
diff --git a/packages/stage-pages/src/pages/settings/airi-card/components/CardDetailDialog.vue b/packages/stage-pages/src/pages/settings/airi-card/components/CardDetailDialog.vue index d1141cc22..7661efce7 100644 --- a/packages/stage-pages/src/pages/settings/airi-card/components/CardDetailDialog.vue +++ b/packages/stage-pages/src/pages/settings/airi-card/components/CardDetailDialog.vue @@ -9,9 +9,6 @@ import { exportAiriCardPackage } from '@proj-airi/stage-ui/services/airi-card-im import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background' 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 { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision' import { Button, Select } from '@proj-airi/ui' import { storeToRefs } from 'pinia' import { @@ -41,17 +38,11 @@ const emit = defineEmits<{ const { t } = useI18n() const { trackSceneBackgroundSet } = useAnalytics() const cardStore = useAiriCardStore() -const consciousnessStore = useConsciousnessStore() -const speechStore = useSpeechStore() -const visionStore = useVisionStore() const backgroundStore = useBackgroundStore() const displayModelsStore = useDisplayModelsStore() const { removeCard } = cardStore const { activeCardId } = storeToRefs(cardStore) -const { activeProvider: consciousnessProvider, activeModel: defaultConsciousnessModel } = storeToRefs(consciousnessStore) -const { activeSpeechProvider: speechProvider, activeSpeechModel: defaultSpeechModel, activeSpeechVoiceId: defaultVoiceId } = storeToRefs(speechStore) -const { activeProvider: visionProvider, activeModel: defaultVisionModel } = storeToRefs(visionStore) const isRefreshingGallery = ref(false) const isExportingCard = shallowRef(false) @@ -317,15 +308,13 @@ watch(() => props.modelValue, (isOpen) => { }) // Helper function to generate placeholder text for default values -function getDefaultPlaceholder(defaultValue: string | undefined): string { - return defaultValue - ? `${t('settings.pages.card.creation.use_default')} (${defaultValue})` - : t('settings.pages.card.creation.use_default_not_configured') +function getDefaultPlaceholder(): string { + return t('settings.pages.card.creation.inherit_global_settings') } // Helper function to get display value for module settings -function getModuleDisplayValue(value: string | undefined, defaultValue: string | undefined): string { - return value || getDefaultPlaceholder(defaultValue) +function getModuleDisplayValue(value: string | undefined): string { + return value || getDefaultPlaceholder() } @@ -466,7 +455,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.chat.provider') }}
- {{ getModuleDisplayValue(moduleSettings.consciousnessProvider, consciousnessProvider) }} + {{ getModuleDisplayValue(moduleSettings.consciousnessProvider) }}
@@ -483,7 +472,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.consciousness.model') }}
- {{ getModuleDisplayValue(moduleSettings.consciousness, defaultConsciousnessModel) }} + {{ getModuleDisplayValue(moduleSettings.consciousness) }}
@@ -500,7 +489,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.vision.provider') }}
- {{ getModuleDisplayValue(moduleSettings.visionProvider, visionProvider) }} + {{ getModuleDisplayValue(moduleSettings.visionProvider) }}
@@ -517,7 +506,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.vision.model') }}
- {{ getModuleDisplayValue(moduleSettings.vision, defaultVisionModel) }} + {{ getModuleDisplayValue(moduleSettings.vision) }}
@@ -534,7 +523,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.speech.provider') }}
- {{ getModuleDisplayValue(moduleSettings.speechProvider, speechProvider) }} + {{ getModuleDisplayValue(moduleSettings.speechProvider) }}
@@ -551,7 +540,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.speech.model') }}
- {{ getModuleDisplayValue(moduleSettings.speech, defaultSpeechModel) }} + {{ getModuleDisplayValue(moduleSettings.speech) }}
@@ -568,7 +557,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string | {{ t('settings.pages.card.speech.voice') }}
- {{ getModuleDisplayValue(moduleSettings.voice, defaultVoiceId) }} + {{ getModuleDisplayValue(moduleSettings.voice) }}
diff --git a/packages/stage-pages/src/pages/settings/modules/consciousness.vue b/packages/stage-pages/src/pages/settings/modules/consciousness.vue index 96562e1a8..b168b62a0 100644 --- a/packages/stage-pages/src/pages/settings/modules/consciousness.vue +++ b/packages/stage-pages/src/pages/settings/modules/consciousness.vue @@ -42,28 +42,26 @@ watch(activeProvider, async (provider) => { await consciousnessStore.loadModelsForProvider(provider) }, { immediate: true }) -watch([activeProvider, activeModel], ([provider, model]) => { - void airiCardStore.updateActiveCardConsciousness({ provider, model }) -}) +async function persistSelection() { + await airiCardStore.updateActiveCardConsciousness({ provider: activeProvider.value, model: activeModel.value }) +} function updateCustomModelName(value: string) { customModelName.value = value } -function selectModel(modelId: string) { +async function selectModel(modelId: string) { const previousModelId = activeModel.value activeModel.value = modelId + await persistSelection() if (previousModelId !== modelId) trackModelSwitched(previousModelId || 'none', modelId) } -function handleDeleteProvider(providerId: string) { - if (activeProvider.value === providerId) { - activeProvider.value = '' - activeModel.value = '' - } - providersStore.deleteProvider(providerId) +async function handleDeleteProvider(providerId: string) { + await airiCardStore.clearProviderSelections(providerId) + await providersStore.deleteProvider(providerId) } async function updateReasoning(value: boolean) { @@ -104,6 +102,7 @@ async function updateReasoning(value: boolean) { :value="metadata.id" :title="metadata.localizedName || 'Unknown'" :description="metadata.localizedDescription" + @update:model-value="persistSelection" @click="trackProviderClick(metadata.id, 'consciousness')" > @@ -285,6 +285,7 @@ async function updateReasoning(value: boolean) { v-model="activeModel" type="text" class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" :placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')" + @input="persistSelection" > diff --git a/packages/stage-pages/src/pages/settings/modules/speech.vue b/packages/stage-pages/src/pages/settings/modules/speech.vue index f62d6beb7..1f78ef11f 100644 --- a/packages/stage-pages/src/pages/settings/modules/speech.vue +++ b/packages/stage-pages/src/pages/settings/modules/speech.vue @@ -279,6 +279,7 @@ function selectSpeechProvider(providerId: string) { * Tracks explicit voice selection from catalog or custom input controls. */ async function selectSpeechVoice(voiceId: string | undefined) { + await persistSelection() if (!voiceId) return @@ -290,11 +291,15 @@ async function selectSpeechVoice(voiceId: string | undefined) { }) } -function selectSpeechSource(sourceId: string) { +async function selectSpeechSource(sourceId: string) { activeSpeechProvider.value = sourceId + activeSpeechModel.value = '' + activeSpeechVoiceId.value = '' + activeSpeechVoice.value = undefined + await persistSelection() } -function selectSpeechModel(modelOptionId: string) { +async function selectSpeechModel(modelOptionId: string) { const streamingModelId = modelIdFromStreamingOptionId(modelOptionId) const nextProvider = streamingModelId == null ? activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID @@ -310,6 +315,9 @@ function selectSpeechModel(modelOptionId: string) { } activeSpeechModel.value = nextModel + activeSpeechVoiceId.value = '' + activeSpeechVoice.value = undefined + await persistSelection() } /** @@ -366,26 +374,11 @@ onMounted(async () => { trackOfficialTtsExposure() }) -watch(activeSpeechProvider, async (newProvider, oldProvider) => { +watch(activeSpeechProvider, async (newProvider) => { await providersStore.loadModelsForConfiguredProviders() + if (newProvider !== activeSpeechProvider.value) + return - // Reset model and voice when switching providers (but not on initial load) - const isMergedOfficialSwitch = ( - oldProvider === OFFICIAL_SPEECH_PROVIDER_ID - || oldProvider === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID - ) && ( - newProvider === OFFICIAL_SPEECH_PROVIDER_ID - || newProvider === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID - ) - if (oldProvider !== undefined && oldProvider !== newProvider && !isMergedOfficialSwitch) { - activeSpeechModel.value = '' - activeSpeechVoiceId.value = '' - activeSpeechVoice.value = undefined - } - - // Re-seed the streaming default model after the reset above so its voices - // load model-scoped (the server only returns recommended voices for an - // explicit ?model=). No-op for other providers / when a model is selected. speechStore.ensureActiveSpeechModel() await speechStore.loadVoicesForProvider(newProvider, activeSpeechModel.value || undefined) trackOfficialTtsExposure(newProvider, currentTtsModelId()) @@ -397,16 +390,17 @@ watch(activeSpeechModel, async (model) => { if (!activeSpeechProvider.value) return - activeSpeechVoiceId.value = '' - activeSpeechVoice.value = undefined - await speechStore.loadVoicesForProvider(activeSpeechProvider.value, model || undefined) trackOfficialTtsExposure(activeSpeechProvider.value, currentTtsModelId()) }) -watch([activeSpeechProvider, activeSpeechModel, activeSpeechVoiceId], ([provider, model, voiceId]) => { - void airiCardStore.updateActiveCardSpeech({ provider, model, voice_id: voiceId }) -}) +async function persistSelection() { + await airiCardStore.updateActiveCardSpeech({ + provider: activeSpeechProvider.value, + model: activeSpeechModel.value, + voice_id: activeSpeechVoiceId.value, + }) +} // Function to generate speech async function generateTestSpeech() { @@ -589,21 +583,17 @@ function commitCustomVoiceSelection() { function updateCustomModelName(value: string | undefined) { activeSpeechModel.value = value || '' + activeSpeechVoiceId.value = '' + void persistSelection() } -function handleDeleteProvider(providerId: string) { +async function handleDeleteProvider(providerId: string) { if (providerId === 'speech-noop') { return } - if (activeSpeechProvider.value === providerId) { - activeSpeechProvider.value = 'speech-noop' - activeSpeechModel.value = '' - activeSpeechVoiceId.value = '' - activeSpeechVoice.value = undefined - } - - providersStore.deleteProvider(providerId) + await airiCardStore.clearProviderSelections(providerId) + await providersStore.deleteProvider(providerId) } @@ -899,6 +889,7 @@ function handleDeleteProvider(providerId: string) {