fix(stage-ui): inherit global settings for default card (#2332)

This commit is contained in:
RainbowBird
2026-09-08 09:48:39 +00:00
committed by GitHub
parent 9a4e1da5a1
commit ef9f122d50
21 changed files with 1097 additions and 376 deletions
+2 -5
View File
@@ -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)
+2 -5
View File
@@ -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)
+2 -5
View File
@@ -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)
+3 -2
View File
@@ -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.'
@@ -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: '错误:你必须为此卡片提供描述。'
@@ -1,19 +1,19 @@
<script setup lang="ts">
import type { Card } from '@proj-airi/ccc'
import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card'
import type { Ref } from 'vue'
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { DEFAULT_ARTISTRY_WIDGET_INSTRUCTION } from '@proj-airi/stage-ui/constants/prompts/artistry-instruction'
import { applyAiriCardEditorModules, safeParseAiriCardDraft } from '@proj-airi/stage-ui/services/airi-card-editor'
import { applyAiriCardEditorModules, getAiriCardEditorModuleSettings, safeParseAiriCardDraft } from '@proj-airi/stage-ui/services/airi-card-editor'
import { resolveModuleSelection } from '@proj-airi/stage-ui/services/airi-card-modules'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
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 { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Button, FieldInput, FieldValues } from '@proj-airi/ui'
import { ComboboxSelect } from '@proj-airi/ui/components/form'
import { storeToRefs } from 'pinia'
@@ -65,15 +65,11 @@ const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const providersStore = useProviderStore()
const displayModelsStore = useDisplayModelsStore()
const stageModelStore = useSettingsStageModel()
const artistryStore = useArtistryStore()
const { activeProvider: consciousnessProvider, activeModel: defaultConsciousnessModel } = storeToRefs(consciousnessStore)
const { activeProvider: visionProvider, activeModel: defaultVisionModel } = storeToRefs(visionStore)
const { activeSpeechProvider: speechProvider, activeSpeechModel: defaultSpeechModel, activeSpeechVoiceId: defaultSpeechVoiceId } = storeToRefs(speechStore)
const consciousnessProvider = computed(() => cardStore.moduleDefaults?.consciousness.provider ?? '')
const visionProvider = computed(() => cardStore.moduleDefaults?.vision.provider ?? '')
const speechProvider = computed(() => cardStore.moduleDefaults?.speech.provider ?? '')
const { displayModels } = storeToRefs(displayModelsStore)
const { stageModelSelected: defaultDisplayModelId } = storeToRefs(stageModelStore)
const { activeProvider: defaultArtistryProvider } = storeToRefs(artistryStore)
// Determine if we're in edit mode
const isEditMode = computed(() => !!props.cardId)
@@ -89,8 +85,34 @@ const selectedSpeechModel = ref<string>('')
const selectedSpeechVoiceId = ref<string>('')
const selectedDisplayModelId = ref<string>('')
// NOTICE:
// The editor needs a non-empty option value for inherited settings.
// Reka ComboboxItem rejects an empty-string item value.
// Source/context: packages/ui/src/components/form/combobox/combobox.vue.
// Removal condition: delete this mapping when Reka accepts empty item values.
const inheritGlobalSettingOptionValue = '__airi-inherit-global-setting__'
function createInheritableSelection(selection: Ref<string>) {
return computed({
get: () => selection.value || inheritGlobalSettingOptionValue,
set: (value: string) => {
selection.value = value === inheritGlobalSettingOptionValue ? '' : value
},
})
}
const consciousnessProviderSelection = createInheritableSelection(selectedConsciousnessProvider)
const consciousnessModelSelection = createInheritableSelection(selectedConsciousnessModel)
const visionProviderSelection = createInheritableSelection(selectedVisionProvider)
const visionModelSelection = createInheritableSelection(selectedVisionModel)
const speechProviderSelection = createInheritableSelection(selectedSpeechProvider)
const speechModelSelection = createInheritableSelection(selectedSpeechModel)
const speechVoiceSelection = createInheritableSelection(selectedSpeechVoiceId)
const displayModelSelection = createInheritableSelection(selectedDisplayModelId)
// Artistry configuration
const selectedArtistryProvider = ref<string>('')
const artistryProviderSelection = createInheritableSelection(selectedArtistryProvider)
const selectedArtistryModel = ref<string>('')
const selectedArtistryPromptPrefix = ref<string>('')
const selectedArtistryWidgetInstruction = ref<string>('')
@@ -101,89 +123,106 @@ const selectedArtistryConfigStr = ref<string>('{\n \n}')
let isInitializingModuleSelections = false
let hasLoadedModuleOptions = false
interface ModuleSelectOption {
value: string
label: string
}
function withInheritGlobalSetting(options: ModuleSelectOption[], selected = ''): ModuleSelectOption[] {
// Imported ids remain visible even when their provider is not configured here.
const missingSelection = selected && !options.some(option => option.value === selected)
? [{ value: selected, label: selected }]
: []
return [
{ value: inheritGlobalSettingOptionValue, label: t('settings.pages.card.creation.inherit_global_settings') },
...options,
...missingSelection,
]
}
// Computed: available display model options
const displayModelOptions = computed(() =>
displayModels.value.map(model => ({
withInheritGlobalSetting(displayModels.value.map(model => ({
value: model.id,
label: model.name,
})),
})), selectedDisplayModelId.value),
)
// Computed: available consciousness provider options
const consciousnessProviderOptions = computed(() => {
return providersStore.configuredChatProvidersMetadata.map(provider => ({
return withInheritGlobalSetting(providersStore.configuredChatProvidersMetadata.map(provider => ({
value: provider.id,
label: provider.localizedName || provider.name,
}))
})), selectedConsciousnessProvider.value)
})
// Computed: available consciousness models options
const consciousnessModelOptions = computed(() => {
const provider = selectedConsciousnessProvider.value || consciousnessProvider.value
if (!provider)
return []
return withInheritGlobalSetting([], selectedConsciousnessModel.value)
const models = providersStore.getModelsForProvider(provider)
return models.map(model => ({
return withInheritGlobalSetting(models.map(model => ({
value: model.id,
label: model.name || model.id,
}))
})), selectedConsciousnessModel.value)
})
// Computed: available vision provider options
const visionProviderOptions = computed(() => {
return providersStore.configuredVisionProvidersMetadata.map(provider => ({
return withInheritGlobalSetting(providersStore.configuredVisionProvidersMetadata.map(provider => ({
value: provider.id,
label: provider.localizedName || provider.name,
}))
})), selectedVisionProvider.value)
})
// Computed: available vision models options
const visionModelOptions = computed(() => {
const provider = selectedVisionProvider.value || visionProvider.value
if (!provider)
return []
return withInheritGlobalSetting([], selectedVisionModel.value)
const models = providersStore.getModelsForProvider(provider)
return models.map(model => ({
return withInheritGlobalSetting(models.map(model => ({
value: model.id,
label: model.name || model.id,
}))
})), selectedVisionModel.value)
})
// Computed: available speech provider options
const speechProviderOptions = computed(() => {
return providersStore.configuredSpeechProvidersMetadata.map(provider => ({
return withInheritGlobalSetting(providersStore.configuredSpeechProvidersMetadata.map(provider => ({
value: provider.id,
label: provider.localizedName || provider.name,
}))
})), selectedSpeechProvider.value)
})
// Computed: available speech models options
const speechModelOptions = computed(() => {
const provider = selectedSpeechProvider.value || speechProvider.value
if (!provider)
return []
return withInheritGlobalSetting([], selectedSpeechModel.value)
const models = providersStore.getModelsForProvider(provider)
return models.map(model => ({
return withInheritGlobalSetting(models.map(model => ({
value: model.id,
label: model.name || model.id,
}))
})), selectedSpeechModel.value)
})
// Computed: available speech voices options
const speechVoiceOptions = computed(() => {
const provider = selectedSpeechProvider.value || speechProvider.value
if (!provider)
return []
return withInheritGlobalSetting([], selectedSpeechVoiceId.value)
const voices = speechStore.getVoicesForProvider(provider)
return voices.map(voice => ({
return withInheritGlobalSetting(voices.map(voice => ({
value: voice.id,
label: voice.name || voice.id,
}))
})), selectedSpeechVoiceId.value)
})
// Computed: available artistry provider options
const artistryProviderOptions = computed(() => {
return [
return withInheritGlobalSetting([
{ value: 'none', label: 'None (Disabled)' },
{ value: 'comfyui', label: 'ComfyUI' },
...(isCustomProvidersDisabled()
@@ -192,7 +231,7 @@ const artistryProviderOptions = computed(() => {
{ value: 'replicate', label: 'Replicate' },
{ value: 'nanobanana', label: 'Nano Banana' },
]),
]
], selectedArtistryProvider.value)
})
async function loadSelectedModuleOptions() {
@@ -201,16 +240,19 @@ async function loadSelectedModuleOptions() {
hasLoadedModuleOptions = true
const loads: Promise<unknown>[] = []
if (selectedConsciousnessProvider.value)
loads.push(consciousnessStore.loadModelsForProvider(selectedConsciousnessProvider.value))
const consciousnessProviderId = selectedConsciousnessProvider.value || consciousnessProvider.value
if (consciousnessProviderId)
loads.push(consciousnessStore.loadModelsForProvider(consciousnessProviderId))
if (selectedVisionProvider.value)
loads.push(visionStore.loadModelsForProvider(selectedVisionProvider.value))
const visionProviderId = selectedVisionProvider.value || visionProvider.value
if (visionProviderId)
loads.push(visionStore.loadModelsForProvider(visionProviderId))
if (selectedSpeechProvider.value) {
loads.push(speechStore.loadVoicesForProvider(selectedSpeechProvider.value, selectedSpeechModel.value || undefined))
if (providersStore.supportsModelListing(selectedSpeechProvider.value))
loads.push(providersStore.fetchModelsForProvider(selectedSpeechProvider.value))
const speechProviderId = selectedSpeechProvider.value || speechProvider.value
if (speechProviderId) {
loads.push(speechStore.loadVoicesForProvider(speechProviderId, selectedSpeechModel.value || undefined))
if (providersStore.supportsModelListing(speechProviderId))
loads.push(providersStore.fetchModelsForProvider(speechProviderId))
}
try {
@@ -222,48 +264,48 @@ async function loadSelectedModuleOptions() {
}
}
watch(selectedArtistryProvider, (provider, previous) => {
if (props.modelValue && !isInitializingModuleSelections && provider !== previous)
selectedArtistryModel.value = ''
}, { flush: 'sync' })
// Watch consciousness provider changes and reload models
watch(selectedConsciousnessProvider, async (newProvider, oldProvider) => {
if (props.modelValue && !isInitializingModuleSelections && oldProvider !== undefined && newProvider !== oldProvider && newProvider) {
await consciousnessStore.loadModelsForProvider(newProvider)
// Reset model selection to default or empty
if (props.modelValue && !isInitializingModuleSelections && newProvider !== oldProvider) {
selectedConsciousnessModel.value = ''
await consciousnessStore.loadModelsForProvider(newProvider || consciousnessProvider.value)
}
})
}, { flush: 'sync' })
// Watch vision provider changes and reload models
watch(selectedVisionProvider, async (newProvider, oldProvider) => {
if (props.modelValue && !isInitializingModuleSelections && oldProvider !== undefined && newProvider !== oldProvider && newProvider) {
await visionStore.loadModelsForProvider(newProvider)
if (props.modelValue && !isInitializingModuleSelections && newProvider !== oldProvider) {
selectedVisionModel.value = ''
await visionStore.loadModelsForProvider(newProvider || visionProvider.value)
}
})
}, { flush: 'sync' })
// Watch speech provider changes and reload models/voices
watch(selectedSpeechProvider, async (newProvider, oldProvider) => {
if (props.modelValue && !isInitializingModuleSelections && oldProvider !== undefined && newProvider !== oldProvider && newProvider) {
await speechStore.loadVoicesForProvider(newProvider)
if (providersStore.supportsModelListing(newProvider)) {
await providersStore.fetchModelsForProvider(newProvider)
}
// Reset model and voice selection
if (props.modelValue && !isInitializingModuleSelections && newProvider !== oldProvider) {
selectedSpeechModel.value = ''
selectedSpeechVoiceId.value = ''
const provider = newProvider || speechProvider.value
await speechStore.loadVoicesForProvider(provider)
if (provider && providersStore.supportsModelListing(provider))
await providersStore.fetchModelsForProvider(provider)
}
})
}, { flush: 'sync' })
// Reset voice when speech model changes (different models may have different voices)
watch(selectedSpeechModel, async (newModel, oldModel) => {
// Only reset if model actually changed and we're not initializing
const provider = selectedSpeechProvider.value || speechProvider.value
if (props.modelValue && !isInitializingModuleSelections && oldModel !== undefined && newModel !== oldModel && provider) {
// Reload voices for the current provider
await speechStore.loadVoicesForProvider(provider)
// Reset voice selection to default
selectedSpeechVoiceId.value = defaultSpeechVoiceId.value || ''
selectedSpeechVoiceId.value = ''
await speechStore.loadVoicesForProvider(provider, newModel || undefined)
}
})
}, { flush: 'sync' })
// Tab type definition
interface Tab {
@@ -322,6 +364,20 @@ const showError = ref<boolean>(false)
const errorMessage = ref<string>('')
async function saveCard(card: Card, activate: boolean): Promise<boolean> {
const defaults = cardStore.moduleDefaults
if (defaults) {
// A card may inherit an unconfigured global module. A different explicit
// provider must have its own model; global model ids are not portable.
const missingModel = [
{ selection: { provider: selectedConsciousnessProvider.value, model: selectedConsciousnessModel.value }, defaults: defaults.consciousness },
{ selection: { provider: selectedVisionProvider.value, model: selectedVisionModel.value }, defaults: defaults.vision },
].some(({ selection, defaults }) => selection.provider && !resolveModuleSelection(selection, defaults).model)
if (missingModel) {
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.model_required')
return false
}
}
const draftResult = safeParseAiriCardDraft(toRaw(card), selectedArtistryConfigStr.value)
if (!draftResult.success) {
showError.value = true
@@ -334,21 +390,21 @@ async function saveCard(card: Card, activate: boolean): Promise<boolean> {
const cardWithModules = applyAiriCardEditorModules(rawCard, {
consciousness: {
provider: selectedConsciousnessProvider.value || consciousnessProvider.value,
model: selectedConsciousnessModel.value || defaultConsciousnessModel.value,
provider: selectedConsciousnessProvider.value,
model: selectedConsciousnessModel.value,
},
vision: {
provider: selectedVisionProvider.value || visionProvider.value,
model: selectedVisionModel.value || defaultVisionModel.value,
provider: selectedVisionProvider.value,
model: selectedVisionModel.value,
},
speech: {
provider: selectedSpeechProvider.value || speechProvider.value,
model: selectedSpeechModel.value || defaultSpeechModel.value,
voice_id: selectedSpeechVoiceId.value || defaultSpeechVoiceId.value,
provider: selectedSpeechProvider.value,
model: selectedSpeechModel.value,
voice_id: selectedSpeechVoiceId.value,
},
displayModelId: selectedDisplayModelId.value || defaultDisplayModelId.value,
displayModelId: selectedDisplayModelId.value,
artistry: {
provider: selectedArtistryProvider.value || defaultArtistryProvider.value,
provider: selectedArtistryProvider.value,
model: selectedArtistryModel.value,
promptPrefix: selectedArtistryPromptPrefix.value,
widgetInstruction: selectedArtistryWidgetInstruction.value,
@@ -404,19 +460,19 @@ function initializeCard(): Card {
const existingCard = (isEditMode.value && props.cardId) ? cardStore.getCard(props.cardId) : undefined
const airiExt = existingCard?.extensions?.airi as AiriExtensionWithLegacyArtistry | undefined
// Initialize module selections with fallback logic (handles all cases: create, edit with/without extension)
selectedConsciousnessProvider.value = airiExt?.modules?.consciousness?.provider || consciousnessProvider.value
selectedConsciousnessModel.value = airiExt?.modules?.consciousness?.model || defaultConsciousnessModel.value
selectedVisionProvider.value = airiExt?.modules?.vision?.provider || visionProvider.value
selectedVisionModel.value = airiExt?.modules?.vision?.model || defaultVisionModel.value
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
const moduleSettings = getAiriCardEditorModuleSettings(existingCard)
selectedConsciousnessProvider.value = moduleSettings.consciousness.provider
selectedConsciousnessModel.value = moduleSettings.consciousness.model
selectedVisionProvider.value = moduleSettings.vision.provider
selectedVisionModel.value = moduleSettings.vision.model
selectedSpeechProvider.value = moduleSettings.speech.provider
selectedSpeechModel.value = moduleSettings.speech.model
selectedSpeechVoiceId.value = moduleSettings.speech.voice_id
selectedDisplayModelId.value = moduleSettings.displayModelId ?? ''
// NOTICE: keep legacy `extensions.airi.artistry` fallback so existing cards continue to load.
const artistrySettings = airiExt?.modules?.artistry || airiExt?.artistry
selectedArtistryProvider.value = artistrySettings?.provider || defaultArtistryProvider.value
selectedArtistryProvider.value = artistrySettings?.provider ?? ''
selectedArtistryModel.value = artistrySettings?.model || ''
selectedArtistryPromptPrefix.value = artistrySettings?.promptPrefix || ''
selectedArtistryWidgetInstruction.value = artistrySettings?.widgetInstruction || DEFAULT_ARTISTRY_WIDGET_INSTRUCTION
@@ -425,10 +481,10 @@ function initializeCard(): Card {
selectedArtistryAutonomousThreshold.value = (artistrySettings as any)?.autonomousThreshold ?? 70
try {
selectedArtistryConfigStr.value = artistrySettings?.options ? JSON.stringify(artistrySettings.options, null, 2) : '{\n \n}'
selectedArtistryConfigStr.value = artistrySettings?.options ? JSON.stringify(artistrySettings.options, null, 2) : ''
}
catch {
selectedArtistryConfigStr.value = '{\n \n}'
selectedArtistryConfigStr.value = ''
}
// Return existing card data or defaults
@@ -490,10 +546,8 @@ const cardSystemPrompt = makeComputed('systemPrompt')
const cardPostHistoryInstructions = makeComputed('postHistoryInstructions')
// 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')
}
</script>
@@ -574,9 +628,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.chat.provider') }}
</label>
<ComboboxSelect
v-model="selectedConsciousnessProvider"
v-model="consciousnessProviderSelection"
:options="consciousnessProviderOptions"
:placeholder="getDefaultPlaceholder(consciousnessProvider)"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -588,10 +642,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.consciousness.model') }}
</label>
<ComboboxSelect
v-model="selectedConsciousnessModel"
v-model="consciousnessModelSelection"
:options="consciousnessModelOptions"
:placeholder="getDefaultPlaceholder(defaultConsciousnessModel)"
:disabled="!selectedConsciousnessProvider && !consciousnessProvider"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -603,9 +656,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.vision.provider') }}
</label>
<ComboboxSelect
v-model="selectedVisionProvider"
v-model="visionProviderSelection"
:options="visionProviderOptions"
:placeholder="getDefaultPlaceholder(visionProvider)"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -617,10 +670,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.vision.model') }}
</label>
<ComboboxSelect
v-model="selectedVisionModel"
v-model="visionModelSelection"
:options="visionModelOptions"
:placeholder="getDefaultPlaceholder(defaultVisionModel)"
:disabled="!selectedVisionProvider && !visionProvider"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -632,9 +684,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.speech.provider') }}
</label>
<ComboboxSelect
v-model="selectedSpeechProvider"
v-model="speechProviderSelection"
:options="speechProviderOptions"
:placeholder="getDefaultPlaceholder(speechProvider)"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -646,10 +698,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.speech.model') }}
</label>
<ComboboxSelect
v-model="selectedSpeechModel"
v-model="speechModelSelection"
:options="speechModelOptions"
:placeholder="getDefaultPlaceholder(defaultSpeechModel)"
:disabled="!selectedSpeechProvider && !speechProvider"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -661,10 +712,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.speech.voice') }}
</label>
<ComboboxSelect
v-model="selectedSpeechVoiceId"
v-model="speechVoiceSelection"
:options="speechVoiceOptions"
:placeholder="getDefaultPlaceholder(defaultSpeechVoiceId)"
:disabled="!selectedSpeechProvider && !speechProvider"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -676,9 +726,9 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
{{ t('settings.pages.card.body-model') }}
</label>
<ComboboxSelect
v-model="selectedDisplayModelId"
v-model="displayModelSelection"
:options="displayModelOptions"
:placeholder="getDefaultPlaceholder(defaultDisplayModelId)"
:placeholder="getDefaultPlaceholder()"
class="w-full"
/>
</div>
@@ -695,7 +745,7 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
<!-- Artistry -->
<CardCreationTabArtistry
v-else-if="activeTab === 'artistry'"
v-model:selected-artistry-provider="selectedArtistryProvider"
v-model:selected-artistry-provider="artistryProviderSelection"
v-model:selected-artistry-model="selectedArtistryModel"
v-model:selected-artistry-prompt-prefix="selectedArtistryPromptPrefix"
v-model:selected-artistry-widget-instruction="selectedArtistryWidgetInstruction"
@@ -704,7 +754,7 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
v-model:selected-artistry-spawn-mode="selectedArtistrySpawnMode"
v-model:selected-artistry-config-str="selectedArtistryConfigStr"
:artistry-provider-options="artistryProviderOptions"
:default-artistry-provider-placeholder="getDefaultPlaceholder(defaultArtistryProvider)"
:default-artistry-provider-placeholder="getDefaultPlaceholder()"
/>
<div class="ml-auto mr-1 flex flex-row gap-2">
@@ -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()
}
</script>
@@ -466,7 +455,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.chat.provider') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.consciousnessProvider, consciousnessProvider) }}
{{ getModuleDisplayValue(moduleSettings.consciousnessProvider) }}
</div>
</div>
@@ -483,7 +472,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.consciousness.model') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.consciousness, defaultConsciousnessModel) }}
{{ getModuleDisplayValue(moduleSettings.consciousness) }}
</div>
</div>
@@ -500,7 +489,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.vision.provider') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.visionProvider, visionProvider) }}
{{ getModuleDisplayValue(moduleSettings.visionProvider) }}
</div>
</div>
@@ -517,7 +506,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.vision.model') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.vision, defaultVisionModel) }}
{{ getModuleDisplayValue(moduleSettings.vision) }}
</div>
</div>
@@ -534,7 +523,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.speech.provider') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.speechProvider, speechProvider) }}
{{ getModuleDisplayValue(moduleSettings.speechProvider) }}
</div>
</div>
@@ -551,7 +540,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.speech.model') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.speech, defaultSpeechModel) }}
{{ getModuleDisplayValue(moduleSettings.speech) }}
</div>
</div>
@@ -568,7 +557,7 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
{{ t('settings.pages.card.speech.voice') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.voice, defaultVoiceId) }}
{{ getModuleDisplayValue(moduleSettings.voice) }}
</div>
</div>
</div>
@@ -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')"
>
<template v-if="!metadata.id.startsWith('official-provider')" #topRight>
@@ -194,6 +193,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"
>
</div>
</template>
@@ -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"
>
</div>
</div>
@@ -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)
}
</script>
@@ -899,6 +889,7 @@ function handleDeleteProvider(providerId: string) {
<select
v-model="activeSpeechModel"
class="w-full border border-neutral-300 rounded bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900"
@change="selectSpeechModel(activeSpeechModel)"
>
<option value="eleven_monolingual_v1">
Monolingual v1
@@ -41,31 +41,30 @@ const {
const { t } = useI18n()
const { trackProviderClick } = useAnalytics()
watch(activeProvider, async (provider, oldProvider) => {
watch(activeProvider, async (provider) => {
if (!provider)
return
if (oldProvider !== undefined && oldProvider !== provider) {
visionStore.resetModelSelection()
}
await visionStore.loadModelsForProvider(provider)
}, { immediate: true })
watch([activeProvider, activeModel], ([provider, model]) => {
void airiCardStore.updateActiveCardVision({ provider, model })
})
async function selectProvider(provider: string) {
activeProvider.value = provider
visionStore.resetModelSelection()
await persistSelection()
}
async function persistSelection() {
await airiCardStore.updateActiveCardVision({ provider: activeProvider.value, model: activeModel.value })
}
function updateCustomModelName(value: string) {
customModelName.value = value
}
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)
}
const formattedLastCapture = computed(() => formatRelativeTime(lastCaptureAt.value))
@@ -114,11 +113,12 @@ function formatRelativeTime(timestamp: number | null) {
v-for="metadata in moduleVisionProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeProvider"
:model-value="activeProvider"
name="provider"
:value="metadata.id"
:title="metadata.localizedName || 'Unknown'"
:description="metadata.localizedDescription"
@update:model-value="selectProvider"
@click="trackProviderClick(metadata.id, 'vision')"
>
<template v-if="canDeleteProvider(metadata.id)" #topRight>
@@ -275,6 +275,7 @@ function formatRelativeTime(timestamp: number | null) {
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
expanded-class="mb-12"
@update:model-value="persistSelection"
@update:custom-value="updateCustomModelName"
/>
</template>
@@ -336,6 +337,7 @@ function formatRelativeTime(timestamp: number | null) {
'dark:bg-neutral-900',
]"
:placeholder="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.manual_model_placeholder')"
@input="persistSelection"
>
</div>
</div>
+25
View File
@@ -2,6 +2,31 @@
Shared core for stage
## Character-card module settings
The card store owns three distinct states:
- `moduleDefaults` stores global provider, model, voice, and display selections.
- Each card stores explicit overrides. An empty string means inherit.
- Module stores expose the resolved runtime selections used by the application.
Use `configureForAuthentication` for login and logout. It updates global
defaults, then reapplies the active card without saving defaults into that card.
Use card commands for activation and explicit edits. Settings pages must not
save cards from watchers: authentication and remote snapshots also trigger them.
The synchronization leader owns these commands; followers receive snapshots.
Models inherit only within the same provider. Voices also require the same
model. A different provider without a model stays unconfigured rather than
receiving an unrelated model id. The editor requires a model for an explicit
chat or vision provider unless that model can be inherited safely.
Defaults are seeded once from the current runtime on upgrade. This cannot
recover historical global values that an older card already overwrote.
Existing `speech-noop` selections are preserved because they may represent
intentional silence. Users can explicitly choose **Inherit global settings**
in the editor; importing or saving an unrelated card field does not change it.
## Button analytics
Register the shared plugin once in each Vue application:
@@ -0,0 +1,6 @@
// Shared with auth setup without importing provider factories or chat stores.
export const OFFICIAL_CHAT_PROVIDER_ID = 'official-provider'
export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech'
export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming'
export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcription'
export const OFFICIAL_VISION_PROVIDER_ID = 'vision-official-provider'
@@ -8,13 +8,10 @@ import { z } from 'zod'
import { getAuthToken } from '../../../../libs/auth'
import { SERVER_URL } from '../../../../libs/server'
import { defineProvider } from '../registry'
import { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID } from './constants'
import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICON, withCredentials } from './shared'
export const OFFICIAL_CHAT_PROVIDER_ID = 'official-provider'
export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech'
export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming'
export const OFFICIAL_TRANSCRIPTION_PROVIDER_ID = 'official-provider-transcription'
export const OFFICIAL_VISION_PROVIDER_ID = 'vision-official-provider'
export { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from './constants'
// Locale → voice id map recommended by the server, keyed by provider id.
// Populated by each speech provider's listVoices() from the response's
@@ -4,7 +4,7 @@ import type { AiriExtension } from '../types/airiCard'
import { describe, expect, it } from 'vitest'
import { applyAiriCardEditorModules, safeParseAiriCardDraft } from './airi-card-editor'
import { applyAiriCardEditorModules, getAiriCardEditorModuleSettings, safeParseAiriCardDraft } from './airi-card-editor'
describe('airi card editor validation', () => {
// https://github.com/moeru-ai/airi/issues/2108
@@ -70,6 +70,56 @@ describe('airi card editor validation', () => {
expect(result.output.artistryOptions).toBeUndefined()
})
it('keeps empty uploaded-card module fields inherited when saved', () => {
// ROOT CAUSE:
//
// The editor replaced empty uploaded-card fields with the current global
// settings during initialization. Saving an unrelated change then stored
// these values as card overrides.
//
// We fixed this by keeping empty module fields in the editor state. The
// saved card keeps these fields empty, so they inherit global settings.
const uploadedCard: Card = {
...createCard(),
description: 'Imported card',
extensions: {
airi: {
modules: {
consciousness: { provider: '', model: '' },
vision: { provider: '', model: '' },
speech: { provider: '', model: '', voice_id: '' },
displayModelId: '',
},
agents: {},
},
},
}
const moduleSettings = getAiriCardEditorModuleSettings(uploadedCard)
const savedCard = applyAiriCardEditorModules({
...uploadedCard,
description: 'Edited imported card',
}, {
...moduleSettings,
artistry: {
provider: '',
model: '',
promptPrefix: '',
widgetInstruction: '',
spawnMode: 'bg_widget',
options: undefined,
autonomousEnabled: false,
autonomousThreshold: 70,
},
})
expect(savedCard.description).toBe('Edited imported card')
expect(savedCard.extensions.airi.modules.consciousness).toEqual({ provider: '', model: '' })
expect(savedCard.extensions.airi.modules.vision).toEqual({ provider: '', model: '' })
expect(savedCard.extensions.airi.modules.speech).toMatchObject({ provider: '', model: '', voice_id: '' })
expect(savedCard.extensions.airi.modules.displayModelId).toBe('')
})
it('preserves AIRI extension fields that are not editable in the form', () => {
// ROOT CAUSE:
//
@@ -37,6 +37,34 @@ interface AiriCardEditorModules {
>
}
/**
* Reads the module fields that the AIRI Card editor owns.
*
* Empty strings mean that the card inherits the corresponding global setting.
*/
export function getAiriCardEditorModuleSettings(
card: Card | undefined,
): Pick<AiriCardEditorModules, 'consciousness' | 'vision' | 'speech' | 'displayModelId'> {
const modules = getAiriCardModules(card?.extensions?.airi)
return {
consciousness: {
provider: getModuleString(modules?.consciousness, 'provider'),
model: getModuleString(modules?.consciousness, 'model'),
},
vision: {
provider: getModuleString(modules?.vision, 'provider'),
model: getModuleString(modules?.vision, 'model'),
},
speech: {
provider: getModuleString(modules?.speech, 'provider'),
model: getModuleString(modules?.speech, 'model'),
voice_id: getModuleString(modules?.speech, 'voice_id'),
},
displayModelId: getModuleString(modules, 'displayModelId'),
}
}
type CardWithAiriExtension = Card & {
extensions: NonNullable<Card['extensions']> & {
airi: AiriExtension
@@ -159,6 +187,21 @@ function isAiriExtension(value: unknown): value is AiriExtension {
&& isRecord(value.agents)
}
function getAiriCardModules(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value) || !isRecord(value.modules))
return undefined
return value.modules
}
function getModuleString(module: unknown, key: string): string {
if (!isRecord(module))
return ''
const value = module[key]
return typeof value === 'string' ? value : ''
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -0,0 +1,18 @@
import type { AiriExtension } from '../types/airiCard'
/** Persisted defaults, separate from the selections applied by the active card. */
export type CardModuleDefaults = Pick<AiriExtension['modules'], 'consciousness' | 'vision' | 'speech' | 'displayModelId'>
/**
* Resolves a card selection without borrowing a model from another provider.
* An empty model remains unconfigured when the card changes provider.
* The caller must ask for a model or use that provider's own default.
*/
export function resolveModuleSelection(
selection: AiriExtension['modules']['consciousness'],
defaults: AiriExtension['modules']['consciousness'],
): AiriExtension['modules']['consciousness'] {
const provider = selection.provider || defaults.provider
const model = selection.model || (provider === defaults.provider ? defaults.model : '')
return { provider, model }
}
@@ -0,0 +1,112 @@
import type { SyncedPiniaRuntime } from 'pinia-plugin-synced'
import { PiniaColada } from '@pinia/colada'
import { createPinia, disposePinia, setActivePinia } from 'pinia'
import { createSyncedPiniaPlugin } from 'pinia-plugin-synced'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import { useAiriCardStore } from './airi-card'
import { useConsciousnessStore } from './consciousness'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ locale: { value: 'en' }, t: (key: string) => key }),
}))
const runtimes: SyncedPiniaRuntime[] = []
const piniaInstances: ReturnType<typeof createPinia>[] = []
function createContext(runtime?: SyncedPiniaRuntime) {
const pinia = createPinia()
if (runtime) {
runtimes.push(runtime)
pinia.use(runtime.plugin)
}
createApp({}).use(pinia).use(PiniaColada)
setActivePinia(pinia)
piniaInstances.push(pinia)
return { pinia, cards: useAiriCardStore(pinia), consciousness: useConsciousnessStore(pinia) }
}
describe('persisted and replicated card defaults', () => {
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ voices: [], recommended: {}, data: [] }))))
})
afterEach(() => {
for (const runtime of runtimes.splice(0))
runtime.dispose()
for (const pinia of piniaInstances.splice(0))
disposePinia(pinia)
vi.restoreAllMocks()
vi.unstubAllGlobals()
localStorage.clear()
})
// https://github.com/moeru-ai/airi/pull/2332
// ROOT CAUSE:
// A null storage default selects VueUse's string serializer. After reload,
// the editor received text instead of module settings. Specify JSON storage.
it('reads saved defaults as settings after a reload', async () => {
localStorage.setItem('airi-card-module-defaults', JSON.stringify({
consciousness: { provider: 'ollama', model: 'global-model' },
vision: { provider: '', model: '' },
speech: { provider: 'speech-noop', model: '', voice_id: '' },
displayModelId: 'preset-live2d-1',
}))
const { cards, consciousness } = createContext()
expect(cards.moduleDefaults?.consciousness.provider).toBe('ollama')
await cards.initialize()
expect(consciousness.activeModel).toBe('global-model')
})
it('does not publish a follower state proposal after a card snapshot', async () => {
const namespace = `card-inheritance-${crypto.randomUUID()}`
const onError = vi.fn()
const leaderRuntime = createSyncedPiniaPlugin({ namespace, leadership: 'leader-only', onError })
const leader = createContext(leaderRuntime)
await expect.poll(() => leaderRuntime.isLeader()).toBe(true)
leader.consciousness.activeProvider = 'ollama'
leader.consciousness.activeModel = 'global-model'
await leader.cards.initialize()
const followerRuntime = createSyncedPiniaPlugin({ namespace, leadership: 'follower-only', onError })
const follower = createContext(followerRuntime)
await expect.poll(() => follower.cards.moduleDefaults?.consciousness.model).toBe('global-model')
await expect.poll(() => follower.consciousness.activeModel).toBe('global-model')
const traffic = vi.spyOn(BroadcastChannel.prototype, 'postMessage')
await leader.cards.updateCard('default', {
...leader.cards.activeCard!,
description: 'Changed in another window',
})
await expect.poll(() => follower.cards.activeCard?.description).toBe('Changed in another window')
await nextTick()
expect(follower.consciousness.activeModel).toBe('global-model')
// replaceState is the plugin's follower-to-leader proposal RPC. A received
// snapshot must not call it, even when Vue watchers observe that snapshot.
const proposals = traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState'))
expect(proposals).toHaveLength(0)
const explicit = await leader.cards.addCard({
...leader.cards.activeCard!,
extensions: {
airi: {
agents: {},
modules: {
consciousness: { provider: 'another-provider', model: 'card-model' },
vision: { provider: '', model: '' },
speech: { provider: '', model: '', voice_id: '' },
},
},
},
}, 'import')
await leader.cards.activateCard(explicit)
await expect.poll(() => follower.consciousness.activeModel).toBe('card-model')
await leader.cards.activateCard('default')
await expect.poll(() => follower.consciousness.activeModel).toBe('global-model')
expect(follower.cards.moduleDefaults?.consciousness.model).toBe('global-model')
expect(traffic.mock.calls.filter(([message]) => JSON.stringify(message).includes('replaceState'))).toHaveLength(0)
expect(onError).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,298 @@
import type { AiriCard } from '../../types/airiCard'
import { PiniaColada } from '@pinia/colada'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, toRaw } from 'vue'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { useAiriCardStore } from './airi-card'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
import { useSpeechStore } from './speech'
import { useVisionStore } from './vision'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ locale: { value: 'en' }, t: (key: string) => key }),
}))
vi.mock('localforage', () => ({
default: {
getItem: vi.fn(async () => undefined),
iterate: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
setItem: vi.fn(async <T>(_: string, value: T) => value),
},
}))
function card(provider = '', model = ''): AiriCard {
return {
name: 'Imported card',
version: '1.0.0',
description: '',
extensions: {
airi: {
modules: {
consciousness: { provider, model },
vision: { provider: '', model: '' },
speech: { provider: '', model: '', voice_id: '' },
},
agents: {},
},
},
}
}
describe('card inheritance with real module stores', () => {
beforeEach(() => {
const pinia = createPinia()
createApp({}).use(pinia).use(PiniaColada)
setActivePinia(pinia)
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ recommended: {}, voices: [], data: [] }))))
})
afterEach(() => vi.unstubAllGlobals())
// https://github.com/moeru-ai/airi/pull/2332
// ROOT CAUSE:
// Empty fields skipped runtime writes, so the previous card became the default.
// Resolve each activation from saved defaults instead of the previous runtime.
it('restores global settings after an explicit card', async () => {
const consciousness = useConsciousnessStore()
consciousness.activeProvider = 'global-provider'
consciousness.activeModel = 'global-model'
const cards = useAiriCardStore()
await cards.initialize()
const explicit = await cards.addCard(card('card-provider', 'card-model'), 'import')
const inherited = await cards.addCard(card(), 'import')
await cards.activateCard(explicit)
expect(consciousness.activeModel).toBe('card-model')
await cards.activateCard(inherited)
expect(consciousness.activeProvider).toBe('global-provider')
expect(consciousness.activeModel).toBe('global-model')
})
// https://github.com/moeru-ai/airi/pull/2332
it('does not inherit a model from a different provider', async () => {
const vision = useVisionStore()
vision.activeProvider = 'global-provider'
vision.activeModel = 'global-model'
const cards = useAiriCardStore()
await cards.initialize()
const uploaded = card()
uploaded.extensions.airi.modules.vision.provider = 'another-provider'
const id = await cards.addCard(uploaded, 'import')
await cards.activateCard(id)
expect(vision.activeProvider).toBe('another-provider')
expect(vision.activeModel).toBe('')
})
// https://github.com/moeru-ai/airi/pull/2332
it('preserves explicit no-speech on the default card', async () => {
const cards = useAiriCardStore()
const muted = card()
muted.extensions.airi.modules.speech.provider = 'speech-noop'
cards.cards.set('default', muted)
await cards.initialize()
expect(cards.activeCard?.extensions.airi.modules.speech.provider).toBe('speech-noop')
expect(useSpeechStore().activeSpeechProvider).toBe('speech-noop')
})
it('keeps the default artistry instruction on a fresh profile', async () => {
const cards = useAiriCardStore()
await cards.initialize()
expect(cards.activeCard?.extensions.airi.modules.artistry?.widgetInstruction).toBe(DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT)
expect(cards.systemPrompt).toContain(DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT)
})
it('restores defaults when an active override is cleared in the editor', async () => {
const consciousness = useConsciousnessStore()
consciousness.activeProvider = 'global-provider'
consciousness.activeModel = 'global-model'
const cards = useAiriCardStore()
await cards.initialize()
const id = await cards.addCard(card('card-provider', 'card-model'), 'import')
await cards.activateCard(id)
await cards.updateCard(id, card())
expect(consciousness.activeProvider).toBe('global-provider')
expect(consciousness.activeModel).toBe('global-model')
})
it('inherits a model only within the same provider', async () => {
const consciousness = useConsciousnessStore()
consciousness.activeProvider = 'global-provider'
consciousness.activeModel = 'global-model'
const cards = useAiriCardStore()
await cards.initialize()
const id = await cards.addCard(card('global-provider'), 'import')
await cards.activateCard(id)
expect(consciousness.activeModel).toBe('global-model')
expect(cards.activeCard?.extensions.airi.modules.consciousness.model).toBe('')
})
it('keeps imported cards without an AIRI extension inherited', async () => {
const consciousness = useConsciousnessStore()
consciousness.activeProvider = 'global-provider'
consciousness.activeModel = 'global-model'
const cards = useAiriCardStore()
await cards.initialize()
const id = await cards.addCard({ name: 'Portable card', version: '1.0.0', description: '' }, 'import')
expect(cards.getCard(id)?.extensions.airi.modules.consciousness).toEqual({ provider: '', model: '' })
})
it('preserves imported extension fields while filling module defaults', async () => {
const cards = useAiriCardStore()
await cards.initialize()
const uploaded = card()
const extensions = {
other: { theme: 'blue' },
airi: {
...uploaded.extensions.airi,
importedMetadata: { revision: 2 },
modules: {
...uploaded.extensions.airi.modules,
customModule: { enabled: true },
speech: { ...uploaded.extensions.airi.modules.speech, customVoiceOptions: { volume: 0.7 }, rate: 1.2 },
},
},
}
const id = await cards.addCard({ ...uploaded, extensions }, 'import')
expect(cards.getCard(id)?.extensions).toMatchObject(extensions)
})
it('does not restore a removed provider from saved defaults', async () => {
const consciousness = useConsciousnessStore()
consciousness.activeProvider = 'removed-provider'
consciousness.activeModel = 'global-model'
const cards = useAiriCardStore()
await cards.initialize()
const id = await cards.addCard(card('removed-provider', 'card-model'), 'import')
await cards.activateCard(id)
await cards.clearProviderSelections('removed-provider')
expect(cards.moduleDefaults?.consciousness).toEqual({ provider: '', model: '' })
expect(cards.getCard(id)?.extensions.airi.modules.consciousness).toEqual({ provider: '', model: '' })
await cards.activateCard('default')
expect(consciousness.activeProvider).toBe('')
expect(consciousness.activeModel).toBe('')
})
it('does not inherit a voice from a different speech model', async () => {
const speech = useSpeechStore()
speech.activeSpeechProvider = 'speech-provider'
speech.activeSpeechModel = 'global-model'
speech.activeSpeechVoiceId = 'global-voice'
const cards = useAiriCardStore()
await cards.initialize()
const uploaded = card()
uploaded.extensions.airi.modules.speech.model = 'card-model'
const id = await cards.addCard(uploaded, 'import')
await cards.activateCard(id)
expect(speech.activeSpeechModel).toBe('card-model')
expect(speech.activeSpeechVoiceId).toBe('')
await cards.activateCard('default')
expect(speech.activeSpeechModel).toBe('global-model')
expect(speech.activeSpeechVoiceId).toBe('global-voice')
})
it('keeps artistry models and options within their provider', async () => {
const artistry = useArtistryStore()
artistry.globalProvider = 'comfyui'
artistry.globalModel = 'global-model'
artistry.globalProviderOptions = { workflow: 'global-workflow' }
const cards = useAiriCardStore()
await cards.initialize()
const uploaded = card()
uploaded.extensions.airi.modules.artistry = { enabled: true, provider: 'replicate', model: '' }
const id = await cards.addCard(uploaded, 'import')
await cards.activateCard(id)
expect(artistry.activeProvider).toBe('replicate')
expect(artistry.activeModel).toBe('')
expect(artistry.providerOptions).toBeUndefined()
await cards.activateCard('default')
expect(artistry.activeModel).toBe('global-model')
expect(artistry.providerOptions).toEqual({ workflow: 'global-workflow' })
})
it('keeps defaults separate across persisted state and follower snapshots', async () => {
const leader = createPinia()
createApp({}).use(leader).use(PiniaColada)
setActivePinia(leader)
const consciousness = useConsciousnessStore(leader)
consciousness.activeProvider = 'global-provider'
consciousness.activeModel = 'global-model'
const cards = useAiriCardStore(leader)
await cards.initialize()
const id = await cards.addCard(card('card-provider', 'card-model'), 'import')
await cards.activateCard(id)
const snapshot = structuredClone({
cards: toRaw(cards.cards),
activeCardId: cards.activeCardId,
moduleDefaults: toRaw(cards.moduleDefaults),
})
const follower = createPinia()
createApp({}).use(follower).use(PiniaColada)
setActivePinia(follower)
const followerConsciousness = useConsciousnessStore(follower)
followerConsciousness.activeProvider = 'card-provider'
followerConsciousness.activeModel = 'card-model'
const followerCards = useAiriCardStore(follower)
const updates = vi.fn()
followerConsciousness.$subscribe(updates, { flush: 'sync' })
followerCards.$patch(snapshot)
await nextTick()
// Applying a card snapshot must not start another runtime transition.
expect(updates).not.toHaveBeenCalled()
await followerCards.initialize()
await followerCards.activateCard('default')
expect(followerConsciousness.activeProvider).toBe('global-provider')
expect(followerConsciousness.activeModel).toBe('global-model')
})
it('keeps card overrides unchanged across login, logout, and another activation', async () => {
const cards = useAiriCardStore()
await cards.initialize()
const id = await cards.addCard(card('official-provider', 'auto'), 'import')
await cards.activateCard(id)
await cards.configureForAuthentication(true)
expect(useConsciousnessStore().activeProvider).toBe('official-provider')
await cards.configureForAuthentication(false)
expect(useConsciousnessStore().activeProvider).toBe('')
await cards.activateCard('default')
await cards.activateCard(id)
expect(useConsciousnessStore().activeProvider).toBe('')
expect(cards.activeCard?.extensions.airi.modules.consciousness).toEqual({ provider: 'official-provider', model: 'auto' })
await cards.configureForAuthentication(true)
expect(useConsciousnessStore().activeModel).toBe('auto')
})
it('does not write login defaults into empty uploaded-card fields', async () => {
const cards = useAiriCardStore()
await cards.initialize()
const id = await cards.addCard(card(), 'import')
await cards.activateCard(id)
await cards.configureForAuthentication(true)
expect(useConsciousnessStore().activeModel).toBe('auto')
expect(cards.activeCard?.extensions.airi.modules.consciousness).toEqual({ provider: '', model: '' })
await cards.configureForAuthentication(false)
expect(cards.activeCard?.extensions.airi.modules.speech.provider).toBe('')
expect(useSpeechStore().activeSpeechProvider).toBe('speech-noop')
})
// https://github.com/moeru-ai/airi/pull/2332#discussion_r3820590225
it('reapplies model and voice overrides after login fills inherited providers', async () => {
const cards = useAiriCardStore()
await cards.initialize()
const uploaded = card('', 'card-model')
uploaded.extensions.airi.modules.speech.voice_id = 'card-voice'
const id = await cards.addCard(uploaded, 'import')
await cards.activateCard(id)
await cards.configureForAuthentication(true)
expect(useConsciousnessStore().activeProvider).toBe('official-provider')
expect(useConsciousnessStore().activeModel).toBe('card-model')
expect(useSpeechStore().activeSpeechVoiceId).toBe('card-voice')
expect(cards.moduleDefaults?.consciousness.model).toBe('auto')
expect(cards.activeCard?.extensions.airi.modules.consciousness.provider).toBe('')
expect(cards.activeCard?.extensions.airi.modules.speech.provider).toBe('')
})
})
@@ -130,6 +130,87 @@ describe('airi-card store', () => {
expect(visionStore.activeModel).toBe('mock-vision-model')
})
// ROOT CAUSE:
//
// The default card took a snapshot before sign-in. Its speech provider was
// `speech-noop`, so a later activation replaced the official speech provider.
// The card then showed missing module settings instead of inherited settings.
//
// We fix this by keeping the default card module fields empty. Empty fields
// inherit the global module settings and never replace them during activation.
it('keeps default card modules inherited after authenticated defaults load', async () => {
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const cardStore = useAiriCardStore()
consciousnessStore.activeProvider = ''
consciousnessStore.activeModel = ''
speechStore.activeSpeechProvider = 'speech-noop'
speechStore.activeSpeechModel = ''
speechStore.activeSpeechVoiceId = ''
visionStore.activeProvider = ''
visionStore.activeModel = ''
await cardStore.initialize()
consciousnessStore.activeProvider = 'official-provider'
consciousnessStore.activeModel = 'auto'
speechStore.activeSpeechProvider = 'official-provider-speech'
speechStore.activeSpeechModel = 'auto'
visionStore.activeProvider = 'vision-official-provider'
visionStore.activeModel = 'auto'
await cardStore.activateCard('default')
expect(cardStore.activeCard?.extensions.airi.modules.consciousness).toEqual({
provider: '',
model: '',
})
expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({
provider: '',
model: '',
voice_id: '',
})
expect(cardStore.activeCard?.extensions.airi.modules.vision).toEqual({
provider: '',
model: '',
})
expect(consciousnessStore.activeProvider).toBe('official-provider')
expect(consciousnessStore.activeModel).toBe('auto')
expect(speechStore.activeSpeechProvider).toBe('official-provider-speech')
expect(speechStore.activeSpeechModel).toBe('auto')
expect(visionStore.activeProvider).toBe('vision-official-provider')
expect(visionStore.activeModel).toBe('auto')
})
it('preserves ambiguous old default speech settings', async () => {
const cardStore = useAiriCardStore()
cardStore.cards.set('default', {
name: 'ReLU',
version: '1.0.0',
description: 'Built-in card from before provider defaults loaded.',
extensions: {
airi: {
modules: {
consciousness: { provider: '', model: '' },
speech: { provider: 'speech-noop', model: '', voice_id: '' },
vision: { provider: '', model: '' },
},
agents: {},
},
},
})
await cardStore.initialize()
expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({
provider: 'speech-noop',
model: '',
voice_id: '',
})
})
// ROOT CAUSE:
//
// Each Electron window called the synchronized initialize action. The leader
@@ -161,50 +242,6 @@ describe('airi-card store', () => {
expect(visionStore.activeModel).toBe('auto')
})
// ROOT CAUSE:
//
// The authentication hook updated the runtime module stores, but the active
// card kept its older empty selections. A later card activation restored
// speech-noop and erased the authenticated defaults.
//
// We fixed this by persisting the resolved runtime selections in one card
// command without applying the card back to the runtime.
it('persists runtime module selections without reapplying the active card', async () => {
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const cardStore = useAiriCardStore()
await cardStore.initialize()
resetArtistryToGlobal.mockClear()
consciousnessStore.activeProvider = 'official-provider'
consciousnessStore.activeModel = 'auto'
speechStore.activeSpeechProvider = 'official-provider-speech'
speechStore.activeSpeechModel = 'auto'
speechStore.activeSpeechVoiceId = ''
visionStore.activeProvider = 'vision-official-provider'
visionStore.activeModel = 'auto'
await expect(cardStore.persistActiveCardModuleSelections()).resolves.toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules.consciousness).toEqual({
provider: 'official-provider',
model: 'auto',
})
expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({
provider: 'official-provider-speech',
model: 'auto',
voice_id: '',
})
expect(cardStore.activeCard?.extensions.airi.modules.vision).toEqual({
provider: 'vision-official-provider',
model: 'auto',
})
expect(resetArtistryToGlobal).not.toHaveBeenCalled()
await expect(cardStore.persistActiveCardModuleSelections()).resolves.toBe(false)
})
// ROOT CAUSE:
//
// A synchronized state snapshot replaced `activeCardId`. The old watcher
@@ -289,17 +326,17 @@ describe('airi-card store', () => {
const cardStore = useAiriCardStore()
await cardStore.initialize()
expect(await cardStore.updateActiveCardDisplayModel('display-model-iru-v2')).toBe(true)
expect(await cardStore.updateActiveCardDisplayModel('preset-vrm-1')).toBe(true)
expect(await cardStore.updateActiveCardConsciousness({ provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' })).toBe(true)
expect(await cardStore.updateActiveCardVision({ provider: 'ollama', model: 'llava' })).toBe(true)
expect(await cardStore.updateActiveCardSpeech({ provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' })).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules).toMatchObject({
displayModelId: 'display-model-iru-v2',
displayModelId: 'preset-vrm-1',
consciousness: { provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' },
vision: { provider: 'ollama', model: 'llava' },
speech: { provider: 'elevenlabs', model: 'eleven_multilingual_v2', voice_id: 'aria' },
})
expect(stageModelStore.stageModelSelected).toBe('display-model-iru-v2')
expect(stageModelStore.stageModelSelected).toBe('preset-vrm-1')
})
// ROOT CAUSE:
+223 -115
View File
@@ -1,18 +1,23 @@
import type { Card, ccv3 } from '@proj-airi/ccc'
import type { CardModuleDefaults } from '../../services/airi-card-modules'
import type { AiriCard, AiriExtension } from '../../types/airiCard'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { StorageSerializers } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { computed, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { captureAnalyticsEvent } from '../../libs/product-signals'
import { resolveModuleSelection } from '../../services/airi-card-modules'
import { useProviderConfigStore } from '../providers/config'
import { useSettingsStageModel } from '../settings/stage-model'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
import { configureAsDefaultsIfEmpty, unconfigureAuthenticationProviders } from './default'
import { useSpeechStore } from './speech'
import { useVisionStore } from './vision'
@@ -45,6 +50,16 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const activeCardId = useLocalStorageManualReset<string>('airi-card-active-id', 'default', { listenToStorageChanges: false })
let initialized = false
// Only leader-owned commands change defaults or apply card overrides. Runtime
// module stores contain the effective selections, not another source of defaults.
// Existing installations seed this snapshot once from their current settings.
const moduleDefaults = useLocalStorageManualReset<CardModuleDefaults | null>('airi-card-module-defaults', null, {
listenToStorageChanges: false,
serializer: StorageSerializers.object,
})
let appliedModules: AiriExtension['modules'] | undefined
let pendingAuthenticationSetup: Promise<void> | undefined
const activeCard = computed(() => cards.value.get(activeCardId.value))
function useRuntimeModuleStores() {
return {
@@ -56,6 +71,102 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}
function readRuntimeModules(): CardModuleDefaults {
const { consciousness, vision, speech, stageModel } = useRuntimeModuleStores()
return {
consciousness: { provider: consciousness.activeProvider, model: consciousness.activeModel },
vision: { provider: vision.activeProvider, model: vision.activeModel },
speech: { provider: speech.activeSpeechProvider, model: speech.activeSpeechModel, voice_id: speech.activeSpeechVoiceId },
displayModelId: stageModel.stageModelSelected,
}
}
function rememberInheritedSettings() {
const runtime = readRuntimeModules()
const defaults = moduleDefaults.value
if (!defaults) {
moduleDefaults.value = runtime
return
}
if (!appliedModules)
return
// A user can change an inherited setting through a module surface. Retain
// those changes, but never promote the previous card's overrides to defaults.
const next = structuredClone(toRaw(defaults))
for (const module of ['consciousness', 'vision', 'speech'] as const) {
const previous = appliedModules[module]
if (previous.provider)
continue
if (next[module].provider !== runtime[module].provider)
next[module].model = ''
next[module].provider = runtime[module].provider
if (!previous.model)
next[module].model = runtime[module].model
}
if (!appliedModules.speech.provider && !appliedModules.speech.model && !appliedModules.speech.voice_id)
next.speech.voice_id = runtime.speech.voice_id
if (!appliedModules.displayModelId)
next.displayModelId = runtime.displayModelId
moduleDefaults.value = next
}
function writeRuntimeModules(modules: CardModuleDefaults) {
const { consciousness, vision, speech, stageModel } = useRuntimeModuleStores()
// Provider changes synchronously clear dependent selections. Assign the
// resolved model and voice afterwards, including empty values.
consciousness.activeProvider = modules.consciousness.provider
consciousness.activeModel = modules.consciousness.model
vision.activeProvider = modules.vision.provider
vision.activeModel = modules.vision.model
speech.activeSpeechProvider = modules.speech.provider
speech.activeSpeechModel = modules.speech.model
speech.activeSpeechVoiceId = modules.speech.voice_id
if (modules.displayModelId !== undefined)
stageModel.stageModelSelected = modules.displayModelId
}
/**
* Updates authenticated defaults without changing any card's stored overrides.
* The synchronization leader owns provider setup, persistence, and reapplication.
*/
async function configureForAuthentication(authenticated: boolean) {
const previous = pendingAuthenticationSetup
const operation = (async () => {
// A failed setup is reported to its caller. The next auth event must
// still run, for example to remove providers after a failed login.
if (previous)
await previous.catch(() => {})
await applyAuthenticationDefaults(authenticated)
})()
pendingAuthenticationSetup = operation
try {
await operation
}
finally {
if (pendingAuthenticationSetup === operation)
pendingAuthenticationSetup = undefined
}
}
async function applyAuthenticationDefaults(authenticated: boolean) {
rememberInheritedSettings()
if (!moduleDefaults.value)
return
writeRuntimeModules(moduleDefaults.value)
try {
if (authenticated)
await configureAsDefaultsIfEmpty()
else
await unconfigureAuthenticationProviders()
moduleDefaults.value = readRuntimeModules()
}
finally {
appliedModules = undefined
applyActiveCardSettings()
}
}
/**
* `source` feeds the `card_created` analytics event: `scratch` = built in
* the creation dialog, `import` = ccv3 JSON upload, `duplicate` = cloned
@@ -70,6 +181,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
const removeCard = async (id: string) => {
await pendingAuthenticationSetup
// The built-in card is the guaranteed fallback for every runtime profile.
if (id === 'default')
return false
@@ -90,6 +202,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
const updateCard = async (id: string, updates: AiriCard | Card | ccv3.CharacterCardV3) => {
await pendingAuthenticationSetup
const existingCard = cards.value.get(id)
if (!existingCard)
return false
@@ -136,6 +249,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
async function updateActiveCardDisplayModel(displayModelId: string | undefined) {
await pendingAuthenticationSetup
const updated = updateActiveCardModules(() => ({ displayModelId }))
if (updated)
applyActiveCardSettings()
@@ -143,6 +257,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
async function updateActiveCardConsciousness(consciousness: AiriExtension['modules']['consciousness']) {
await pendingAuthenticationSetup
const updated = updateActiveCardModules(() => ({ consciousness }))
if (updated)
applyActiveCardSettings()
@@ -150,6 +265,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
async function updateActiveCardVision(vision: AiriExtension['modules']['vision']) {
await pendingAuthenticationSetup
const updated = updateActiveCardModules(() => ({ vision }))
if (updated)
applyActiveCardSettings()
@@ -157,6 +273,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
async function updateActiveCardSpeech(speech: Pick<AiriExtension['modules']['speech'], 'provider' | 'model' | 'voice_id'>) {
await pendingAuthenticationSetup
const updated = updateActiveCardModules(({ modules }) => ({
speech: {
...modules.speech,
@@ -168,63 +285,33 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return updated
}
/**
* Persists the current inference selections in the active card.
*
* This command snapshots runtime state after a higher-level operation, such
* as authenticated default setup. It deliberately does not apply the card
* back to the runtime, so one persistence write cannot start another module
* transition.
*/
async function persistActiveCardModuleSelections() {
const card = cards.value.get(activeCardId.value)
if (!card)
return false
const {
consciousness,
speech,
vision,
} = useRuntimeModuleStores()
const modules = card.extensions?.airi?.modules
const alreadyPersisted = modules?.consciousness?.provider === consciousness.activeProvider
&& modules.consciousness.model === consciousness.activeModel
&& modules?.speech?.provider === speech.activeSpeechProvider
&& modules.speech.model === speech.activeSpeechModel
&& modules.speech.voice_id === speech.activeSpeechVoiceId
&& modules?.vision?.provider === vision.activeProvider
&& modules.vision.model === vision.activeModel
if (alreadyPersisted)
return false
return updateActiveCardModules(({ modules }) => ({
consciousness: {
provider: consciousness.activeProvider,
model: consciousness.activeModel,
},
speech: {
...modules.speech,
provider: speech.activeSpeechProvider,
model: speech.activeSpeechModel,
voice_id: speech.activeSpeechVoiceId,
},
vision: {
provider: vision.activeProvider,
model: vision.activeModel,
},
/** Clears a removed provider from defaults and the active card, not other cards. */
async function clearProviderSelections(providerId: string) {
await pendingAuthenticationSetup
rememberInheritedSettings()
const defaults = moduleDefaults.value
if (!defaults)
return
const next = structuredClone(toRaw(defaults))
for (const module of ['consciousness', 'vision', 'speech'] as const) {
if (next[module].provider === providerId) {
next[module].provider = module === 'speech' ? 'speech-noop' : ''
next[module].model = ''
if (module === 'speech')
next.speech.voice_id = ''
}
}
moduleDefaults.value = next
updateActiveCardModules(({ modules }) => ({
consciousness: modules.consciousness.provider === providerId ? { provider: '', model: '' } : modules.consciousness,
vision: modules.vision.provider === providerId ? { provider: '', model: '' } : modules.vision,
speech: modules.speech.provider === providerId ? { ...modules.speech, provider: '', model: '', voice_id: '' } : modules.speech,
}))
appliedModules = undefined
applyActiveCardSettings()
}
function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension {
const {
artistry,
consciousness,
speech,
stageModel,
vision,
} = useRuntimeModuleStores()
// Get existing extension if available
const existingExtension = ('data' in card
? card.data?.extensions?.airi
@@ -232,28 +319,18 @@ export const useAiriCardStore = defineStore('airi-card', () => {
// Create default modules config
const defaultModules = {
consciousness: {
provider: consciousness.activeProvider,
model: consciousness.activeModel,
},
vision: {
provider: vision.activeProvider,
model: vision.activeModel,
},
speech: {
provider: speech.activeSpeechProvider,
model: speech.activeSpeechModel,
voice_id: speech.activeSpeechVoiceId,
},
displayModelId: stageModel.stageModelSelected,
consciousness: { provider: '', model: '' },
vision: { provider: '', model: '' },
speech: { provider: '', model: '', voice_id: '' },
displayModelId: '',
artistry: {
enabled: false,
provider: artistry.globalProvider,
model: artistry.globalModel,
promptPrefix: artistry.globalPromptPrefix,
provider: '',
model: '',
promptPrefix: '',
widgetInstruction: DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT,
spawnMode: 'bg_widget' as const,
options: artistry.globalProviderOptions,
options: undefined,
autonomousEnabled: false,
autonomousThreshold: 70,
autonomousTarget: 'assistant' as const,
@@ -268,18 +345,23 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
}
// Merge existing extension with defaults
// Fill known fields without discarding settings owned by imported extensions.
return {
...existingExtension,
modules: {
...existingExtension.modules,
consciousness: {
...existingExtension.modules?.consciousness,
provider: existingExtension.modules?.consciousness?.provider ?? defaultModules.consciousness.provider,
model: existingExtension.modules?.consciousness?.model ?? defaultModules.consciousness.model,
},
vision: {
...existingExtension.modules?.vision,
provider: existingExtension.modules?.vision?.provider ?? defaultModules.vision.provider,
model: existingExtension.modules?.vision?.model ?? defaultModules.vision.model,
},
speech: {
...existingExtension.modules?.speech,
provider: existingExtension.modules?.speech?.provider ?? defaultModules.speech.provider,
model: existingExtension.modules?.speech?.model ?? defaultModules.speech.model,
voice_id: existingExtension.modules?.speech?.voice_id ?? defaultModules.speech.voice_id,
@@ -293,6 +375,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
displayModelId: existingExtension.modules?.displayModelId ?? defaultModules.displayModelId,
activeBackgroundId: existingExtension.modules?.activeBackgroundId,
artistry: {
...existingExtension.modules?.artistry,
enabled: existingExtension.modules?.artistry?.enabled ?? (existingExtension as any).artistry?.enabled ?? defaultModules.artistry.enabled,
provider: existingExtension.modules?.artistry?.provider ?? (existingExtension as any).artistry?.provider ?? defaultModules.artistry.provider,
model: existingExtension.modules?.artistry?.model ?? (existingExtension as any).artistry?.model ?? defaultModules.artistry.model,
@@ -343,8 +426,8 @@ export const useAiriCardStore = defineStore('airi-card', () => {
: [],
tags: ccv3Card.data.tags ?? [],
extensions: {
airi: resolveAiriExtension(ccv3Card),
...ccv3Card.data.extensions,
airi: resolveAiriExtension(ccv3Card),
},
}
}
@@ -352,13 +435,14 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return {
...card,
extensions: {
airi: resolveAiriExtension(card),
...card.extensions,
airi: resolveAiriExtension(card),
},
}
}
async function initialize() {
await pendingAuthenticationSetup
// This synchronized action executes in the leader. Each window calls it,
// but only the first call can apply persisted card settings to the runtime.
if (initialized)
@@ -366,12 +450,26 @@ export const useAiriCardStore = defineStore('airi-card', () => {
initialized = true
if (!cards.value.has('default')) {
cards.value.set('default', newAiriCard({
const defaultCard: AiriCard = {
name: 'ReLU',
version: '1.0.0',
description: t('base.prompt.prefix'),
}))
extensions: {
airi: {
modules: {
consciousness: { provider: '', model: '' },
speech: { provider: '', model: '', voice_id: '' },
vision: { provider: '', model: '' },
},
agents: {},
},
},
}
cards.value.set('default', newAiriCard(defaultCard))
}
// Stored speech-noop can mean an intentional mute. Only the editor may
// replace it with inheritance; the old placeholder has no provenance marker.
// The active id and card map are persisted separately. Older versions
// could delete the selected card without repairing its stored id.
if (!cards.value.has(activeCardId.value))
@@ -385,6 +483,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
* leader. Replicated state snapshots never invoke this command.
*/
async function activateCard(id: string) {
await pendingAuthenticationSetup
if (!cards.value.has(id))
return false
@@ -394,13 +493,8 @@ export const useAiriCardStore = defineStore('airi-card', () => {
}
function applyActiveCardSettings(newCard = activeCard.value) {
const {
artistry,
consciousness,
speech,
stageModel,
vision,
} = useRuntimeModuleStores()
rememberInheritedSettings()
const artistry = useArtistryStore()
artistry.resetToGlobal()
@@ -412,38 +506,47 @@ export const useAiriCardStore = defineStore('airi-card', () => {
if (!extension)
return
const consciousnessSettings = extension.modules?.consciousness
if (consciousnessSettings?.provider)
consciousness.activeProvider = consciousnessSettings.provider
if (consciousnessSettings?.model)
consciousness.activeModel = consciousnessSettings.model
const visionSettings = extension.modules?.vision
if (visionSettings?.provider)
vision.activeProvider = visionSettings.provider
if (visionSettings?.model)
vision.activeModel = visionSettings.model
const speechSettings = extension.modules?.speech
if (speechSettings?.provider)
speech.activeSpeechProvider = speechSettings.provider
if (speechSettings?.model)
speech.activeSpeechModel = speechSettings.model
if (speechSettings?.voice_id)
speech.activeSpeechVoiceId = speechSettings.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) {
stageModel.stageModelSelected = extension.modules.displayModelId
const defaults = moduleDefaults.value
if (!defaults)
return
const modules = extension.modules
const speechSelection = resolveModuleSelection(modules.speech, defaults.speech)
const resolved: CardModuleDefaults = {
consciousness: resolveModuleSelection(modules.consciousness, defaults.consciousness),
vision: resolveModuleSelection(modules.vision, defaults.vision),
speech: {
...speechSelection,
voice_id: modules.speech.voice_id || (
speechSelection.provider === defaults.speech.provider && speechSelection.model === defaults.speech.model
? defaults.speech.voice_id
: ''
),
},
displayModelId: modules.displayModelId || defaults.displayModelId,
}
const providers = useProviderConfigStore().providers
for (const module of ['consciousness', 'vision', 'speech'] as const) {
const provider = providers[resolved[module].provider]
// Logout disables authenticated providers without deleting card choices.
if (provider?.configuredBy === 'authentication' && provider.status === 'unconfigured') {
resolved[module].provider = module === 'speech' ? 'speech-noop' : ''
resolved[module].model = ''
if (module === 'speech')
resolved.speech.voice_id = ''
}
}
writeRuntimeModules(resolved)
appliedModules = modules
if (extension.modules?.artistry) {
if (extension.modules.artistry.provider)
artistry.activeProvider = extension.modules.artistry.provider
if (extension.modules.artistry.model)
artistry.activeModel = extension.modules.artistry.model
const selection = resolveModuleSelection({
provider: extension.modules.artistry.provider ?? '',
model: extension.modules.artistry.model ?? '',
}, { provider: artistry.globalProvider, model: artistry.globalModel })
artistry.activeProvider = selection.provider
artistry.activeModel = selection.model
if (selection.provider !== artistry.globalProvider)
artistry.providerOptions = undefined
if (extension.modules.artistry.promptPrefix)
artistry.defaultPromptPrefix = extension.modules.artistry.promptPrefix
if (extension.modules.artistry.options)
@@ -453,12 +556,15 @@ export const useAiriCardStore = defineStore('airi-card', () => {
function resetState() {
initialized = false
appliedModules = undefined
moduleDefaults.reset()
cards.reset()
activeCardId.reset()
}
return {
cards,
moduleDefaults,
activeCard,
activeCardId,
addCard,
@@ -466,13 +572,14 @@ export const useAiriCardStore = defineStore('airi-card', () => {
updateCard,
updateActiveCardConsciousness,
updateActiveCardDisplayModel,
persistActiveCardModuleSelections,
updateActiveCardSpeech,
updateActiveCardVision,
getCard,
resetState,
initialize,
activateCard,
configureForAuthentication,
clearProviderSelections,
currentModels: computed(() => {
const {
@@ -508,8 +615,9 @@ export const useAiriCardStore = defineStore('airi-card', () => {
'activateCard',
'addCard',
'initialize',
'configureForAuthentication',
'clearProviderSelections',
'removeCard',
'persistActiveCardModuleSelections',
'updateActiveCardConsciousness',
'updateActiveCardDisplayModel',
'updateActiveCardSpeech',
@@ -1,4 +1,4 @@
import { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from '../../libs/providers/providers/official'
import { OFFICIAL_CHAT_PROVIDER_ID, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, OFFICIAL_VISION_PROVIDER_ID } from '../../libs/providers/providers/official/constants'
import { useProviderConfigStore } from '../providers/config'
import { useProviderStore } from '../providers/provider'
import { useConsciousnessStore } from './consciousness'