fix(stage-ui): sync module selections to active character card (#1979)

This commit is contained in:
Doji
2026-06-18 11:21:00 +08:00
committed by GitHub
parent 27569c94f2
commit 5c60cb7661
18 changed files with 253 additions and 14 deletions
@@ -324,6 +324,9 @@ pages:
provider: Chat / Provider
consciousness:
model: Consciousness / Model
vision:
provider: Vision / Provider
model: Vision / Model
created_by: created by
creator_notes: Creator Notes
delete: Delete
@@ -310,6 +310,9 @@ pages:
provider: Chat / Proveedor
consciousness:
model: Consciencia / Modelo
vision:
provider: Visión / Proveedor
model: Visión / Modelo
created_by: creado por
creator_notes: Notas del Creador
delete: Eliminar
@@ -310,6 +310,9 @@ pages:
provider: Chat / Fournisseur
consciousness:
model: Conscience / Modèle
vision:
provider: Vision / Fournisseur
model: Vision / Modèle
created_by: créé par
creator_notes: Notes du créateur
delete: Supprimer
@@ -310,6 +310,9 @@ pages:
provider: チャット / プロバイダー
consciousness:
model: 意識 / モデル
vision:
provider: ビジョン / プロバイダー
model: ビジョン / モデル
created_by: 作成者
creator_notes: 作成者のメモ
delete: 削除
@@ -310,6 +310,9 @@ pages:
provider: 채팅 / 제공자
consciousness:
model: 의식 / 모델
vision:
provider: 비전 / 제공자
model: 비전 / 모델
created_by: 만든이
creator_notes: 제작자 노트
delete: 삭제
@@ -310,6 +310,9 @@ pages:
provider: Чат / провайдеры
consciousness:
model: Сознание / Модель
vision:
provider: Зрение / Провайдер
model: Зрение / Модель
created_by: создано
creator_notes: Заметки автора
delete: Удалить
@@ -310,6 +310,9 @@ pages:
provider: Trò chuyện / Nhà cung cấp
consciousness:
model: Ý thức / Mô hình
vision:
provider: Thị giác / Nhà cung cấp
model: Thị giác / Mô hình
created_by: được tạo bởi
creator_notes: Ghi chú của tác giả
delete: Xóa
@@ -310,6 +310,9 @@ pages:
provider: 聊天 / 提供商
consciousness:
model: 意识 / 模型
vision:
provider: 视觉 / 提供商
model: 视觉 / 模型
created_by: 创建者
creator_notes: 创建者笔记
delete: 删除
@@ -310,6 +310,9 @@ pages:
provider: 聊天 / 供應商
consciousness:
model: 意識 / 模型
vision:
provider: 視覺 / 供應商
model: 視覺 / 模型
created_by: 建立者
creator_notes: 建立者筆記
delete: 刪除
@@ -11,6 +11,7 @@ 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 { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Button, FieldInput, FieldValues } from '@proj-airi/ui'
@@ -59,6 +60,7 @@ const modelValue = defineModel<boolean>()
const { t } = useI18n()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const displayModelsStore = useDisplayModelsStore()
@@ -66,6 +68,7 @@ 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 { displayModels } = storeToRefs(displayModelsStore)
const { stageModelSelected: defaultDisplayModelId } = storeToRefs(stageModelStore)
@@ -77,6 +80,8 @@ const isEditMode = computed(() => !!props.cardId)
// Modules configuration
const selectedConsciousnessProvider = ref<string>('')
const selectedConsciousnessModel = ref<string>('')
const selectedVisionProvider = ref<string>('')
const selectedVisionModel = ref<string>('')
const selectedSpeechProvider = ref<string>('')
const selectedSpeechModel = ref<string>('')
const selectedSpeechVoiceId = ref<string>('')
@@ -120,6 +125,26 @@ const consciousnessModelOptions = computed(() => {
}))
})
// Computed: available vision provider options
const visionProviderOptions = computed(() => {
return providersStore.configuredVisionProvidersMetadata.map(provider => ({
value: provider.id,
label: provider.localizedName || provider.name,
}))
})
// Computed: available vision models options
const visionModelOptions = computed(() => {
const provider = selectedVisionProvider.value || visionProvider.value
if (!provider)
return []
const models = providersStore.getModelsForProvider(provider)
return models.map(model => ({
value: model.id,
label: model.name || model.id,
}))
})
// Computed: available speech provider options
const speechProviderOptions = computed(() => {
return providersStore.configuredSpeechProvidersMetadata.map(provider => ({
@@ -167,10 +192,13 @@ const artistryProviderOptions = computed(() => {
})
// Load models for current providers on init
watch(() => [consciousnessProvider.value, speechProvider.value], async ([consProvider, spProvider]) => {
watch(() => [consciousnessProvider.value, visionProvider.value, speechProvider.value], async ([consProvider, visProvider, spProvider]) => {
if (consProvider) {
await consciousnessStore.loadModelsForProvider(consProvider)
}
if (visProvider) {
await visionStore.loadModelsForProvider(visProvider)
}
if (spProvider) {
await speechStore.loadVoicesForProvider(spProvider)
const metadata = providersStore.getProviderMetadata(spProvider)
@@ -189,6 +217,14 @@ watch(selectedConsciousnessProvider, async (newProvider, oldProvider) => {
}
})
// Watch vision provider changes and reload models
watch(selectedVisionProvider, async (newProvider, oldProvider) => {
if (oldProvider !== undefined && newProvider !== oldProvider && newProvider) {
await visionStore.loadModelsForProvider(newProvider)
selectedVisionModel.value = ''
}
})
// Watch speech provider changes and reload models/voices
watch(selectedSpeechProvider, async (newProvider, oldProvider) => {
if (oldProvider !== undefined && newProvider !== oldProvider && newProvider) {
@@ -353,6 +389,10 @@ function saveCard(card: Card): boolean {
provider: selectedConsciousnessProvider.value || consciousnessProvider.value,
model: selectedConsciousnessModel.value || defaultConsciousnessModel.value,
},
vision: {
provider: selectedVisionProvider.value || visionProvider.value,
model: selectedVisionModel.value || defaultVisionModel.value,
},
speech: {
provider: selectedSpeechProvider.value || speechProvider.value,
model: selectedSpeechModel.value || defaultSpeechModel.value,
@@ -398,6 +438,8 @@ function initializeCard(): Card {
// 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
@@ -594,6 +636,35 @@ function getDefaultPlaceholder(defaultValue: string | undefined): string {
/>
</div>
<!-- Vision Provider -->
<div :class="['flex', 'flex-col', 'gap-2']">
<label :class="['flex', 'flex-row', 'items-center', 'gap-2', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
<div i-lucide:eye />
{{ t('settings.pages.card.vision.provider') }}
</label>
<ComboboxSelect
v-model="selectedVisionProvider"
:options="visionProviderOptions"
:placeholder="getDefaultPlaceholder(visionProvider)"
class="w-full"
/>
</div>
<!-- Vision Model -->
<div :class="['flex', 'flex-col', 'gap-2']">
<label :class="['flex', 'flex-row', 'items-center', 'gap-2', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
<div i-lucide:scan-eye />
{{ t('settings.pages.card.vision.model') }}
</label>
<ComboboxSelect
v-model="selectedVisionModel"
:options="visionModelOptions"
:placeholder="getDefaultPlaceholder(defaultVisionModel)"
:disabled="!selectedVisionProvider && !visionProvider"
class="w-full"
/>
</div>
<!-- Speech Provider -->
<div :class="['flex', 'flex-col', 'gap-2']">
<label :class="['flex', 'flex-row', 'items-center', 'gap-2', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
@@ -7,6 +7,7 @@ import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
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 {
@@ -36,12 +37,14 @@ const { t } = useI18n()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const backgroundStore = useBackgroundStore()
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)
@@ -63,6 +66,8 @@ const moduleSettings = computed(() => {
return {
consciousnessProvider: '',
consciousness: '',
visionProvider: '',
vision: '',
speechProvider: '',
speech: '',
voice: '',
@@ -73,6 +78,8 @@ const moduleSettings = computed(() => {
return {
consciousnessProvider: airiExt.consciousness?.provider || '',
consciousness: airiExt.consciousness?.model || '',
visionProvider: airiExt.vision?.provider || '',
vision: airiExt.vision?.model || '',
speechProvider: airiExt.speech?.provider || '',
speech: airiExt.speech?.model || '',
voice: airiExt.speech?.voice_id || '',
@@ -443,6 +450,40 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-1 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:eye />
{{ t('settings.pages.card.vision.provider') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.visionProvider, visionProvider) }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-1 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:scan-eye />
{{ t('settings.pages.card.vision.model') }}
</span>
<div truncate font-medium>
{{ getModuleDisplayValue(moduleSettings.vision, defaultVisionModel) }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Alert, ErrorContainer, RadioCardManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
@@ -9,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const airiCardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const { persistedChatProvidersMetadata, configuredProviders } = storeToRefs(providersStore)
const {
@@ -37,6 +39,10 @@ watch(activeProvider, async (provider, oldProvider) => {
await consciousnessStore.loadModelsForProvider(provider)
}, { immediate: true })
watch([activeProvider, activeModel], ([provider, model]) => {
airiCardStore.updateActiveCardConsciousness({ provider, model })
})
function updateCustomModelName(value: string) {
customModelName.value = value
}
@@ -162,6 +162,10 @@ watch(activeSpeechModel, async () => {
}
})
watch([activeSpeechProvider, activeSpeechModel, activeSpeechVoiceId], ([provider, model, voiceId]) => {
airiCardStore.updateActiveCardSpeech({ provider, model, voice_id: voiceId })
})
// Function to generate speech
async function generateTestSpeech() {
if (!testText.value.trim() && !useSSML.value)
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Alert, ErrorContainer, RadioCardManySelect, RadioCardSimple } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useVisionProcessingStore, useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { FieldCheckbox, FieldRange } from '@proj-airi/ui'
@@ -10,6 +11,7 @@ import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const providersStore = useProvidersStore()
const airiCardStore = useAiriCardStore()
const visionStore = useVisionStore()
const visionProcessingStore = useVisionProcessingStore()
const { persistedVisionProvidersMetadata, configuredProviders } = storeToRefs(providersStore)
@@ -47,6 +49,10 @@ watch(activeProvider, async (provider, oldProvider) => {
await visionStore.loadModelsForProvider(provider)
}, { immediate: true })
watch([activeProvider, activeModel], ([provider, model]) => {
airiCardStore.updateActiveCardVision({ provider, model })
})
function updateCustomModelName(value: string) {
customModelName.value = value
}
@@ -75,6 +75,10 @@ describe('store character', () => {
provider: 'mock-provider',
model: 'mock-model',
},
vision: {
provider: 'mock-vision-provider',
model: 'mock-vision-model',
},
speech: {
provider: 'mock-speech-provider',
model: 'mock-speech-model',
@@ -129,6 +129,10 @@ describe('store character-orchestrator', () => {
provider: 'mock-provider',
model: 'mock-model',
},
vision: {
provider: 'mock-vision-provider',
model: 'mock-vision-model',
},
speech: {
provider: 'mock-speech-provider',
model: 'mock-speech-model',
@@ -54,6 +54,19 @@ vi.mock('./speech', async () => {
}
})
vi.mock('./vision', async () => {
const { defineStore } = await import('pinia')
return {
useVisionStore: defineStore('vision', {
state: () => ({
activeProvider: 'mock-vision-provider',
activeModel: 'mock-vision-model',
}),
}),
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
@@ -71,19 +84,25 @@ describe('airi-card store', () => {
/**
* @example
* it('persists selected display model on active card', () => {})
* it('persists selected module config on active card', () => {})
*/
it('persists selected display model on active card', () => {
it('persists selected module config on active card', () => {
const stageModelStore = useSettingsStageModel()
stageModelStore.stageModelSelected = 'preset-live2d-1'
const cardStore = useAiriCardStore()
cardStore.initialize()
const updated = cardStore.updateActiveCardDisplayModel('display-model-iru-v2')
expect(updated).toBe(true)
expect(cardStore.activeCard?.extensions.airi.modules.displayModelId).toBe('display-model-iru-v2')
expect(cardStore.updateActiveCardDisplayModel('display-model-iru-v2')).toBe(true)
expect(cardStore.updateActiveCardConsciousness({ provider: 'openrouter-ai', model: 'anthropic/claude-sonnet' })).toBe(true)
expect(cardStore.updateActiveCardVision({ provider: 'ollama', model: 'llava' })).toBe(true)
expect(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',
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('preset-live2d-1')
})
@@ -16,6 +16,7 @@ import { useSettingsStageModel } from '../settings/stage-model'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
import { useSpeechStore } from './speech'
import { useVisionStore } from './vision'
export type VoicePackParams = Record<string, string | number | boolean | null>
@@ -48,6 +49,11 @@ export interface AiriExtension {
model: string // Example: "gpt-4o"
}
vision: {
provider: string // Example: "ollama"
model: string // Example: "llava"
}
speech: {
provider: string // Example: "elevenlabs"
model: string // Example: "eleven_multilingual_v2"
@@ -114,6 +120,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const activeCard = computed(() => cards.value.get(activeCardId.value))
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
const speechStore = useSpeechStore()
const artistryStore = useArtistryStore()
const stageModelStore = useSettingsStageModel()
@@ -123,6 +130,11 @@ export const useAiriCardStore = defineStore('airi-card', () => {
activeModel: activeConsciousnessModel,
} = storeToRefs(consciousnessStore)
const {
activeProvider: activeVisionProvider,
activeModel: activeVisionModel,
} = storeToRefs(visionStore)
const {
activeSpeechProvider,
activeSpeechVoiceId,
@@ -158,25 +170,23 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return cards.value.get(id)
}
function updateActiveCardDisplayModel(displayModelId: string | undefined) {
function updateActiveCardModules(patch: (extension: AiriExtension) => Partial<AiriExtension['modules']>) {
const cardId = activeCardId.value
const card = cards.value.get(cardId)
if (!card)
return false
const extension = resolveAiriExtension(card)
const modules: AiriExtension['modules'] = {
...extension.modules,
displayModelId,
}
cards.value.set(cardId, {
...card,
extensions: {
...card.extensions,
airi: {
...extension,
modules,
modules: {
...extension.modules,
...patch(extension),
},
},
},
})
@@ -184,6 +194,35 @@ export const useAiriCardStore = defineStore('airi-card', () => {
return true
}
function updateActiveCardDisplayModel(displayModelId: string | undefined) {
return updateActiveCardModules(() => ({ displayModelId }))
}
function updateActiveCardConsciousness(consciousness: AiriExtension['modules']['consciousness']) {
return updateActiveCardModules(() => ({ consciousness }))
}
function updateActiveCardVision(vision: AiriExtension['modules']['vision']) {
return updateActiveCardModules(() => ({ vision }))
}
function updateActiveCardSpeech(speech: Pick<AiriExtension['modules']['speech'], 'provider' | 'model' | 'voice_id'>) {
return updateActiveCardModules(({ modules }) => {
const existingVoicePack = modules.speech.voicePack
const shouldKeepVoicePack = speech.provider === OFFICIAL_SPEECH_PROVIDER_ID
&& existingVoicePack?.ttsModelId === speech.model
&& existingVoicePack.voiceId === speech.voice_id
return {
speech: {
...modules.speech,
...speech,
voicePack: shouldKeepVoicePack ? existingVoicePack : undefined,
},
}
})
}
function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension {
// Get existing extension if available
const existingExtension = ('data' in card
@@ -196,6 +235,10 @@ export const useAiriCardStore = defineStore('airi-card', () => {
provider: activeConsciousnessProvider.value,
model: activeConsciousnessModel.value,
},
vision: {
provider: activeVisionProvider.value,
model: activeVisionModel.value,
},
speech: {
provider: activeSpeechProvider.value,
model: activeSpeechModel.value,
@@ -231,6 +274,10 @@ export const useAiriCardStore = defineStore('airi-card', () => {
provider: existingExtension.modules?.consciousness?.provider ?? defaultModules.consciousness.provider,
model: existingExtension.modules?.consciousness?.model ?? defaultModules.consciousness.model,
},
vision: {
provider: existingExtension.modules?.vision?.provider ?? defaultModules.vision.provider,
model: existingExtension.modules?.vision?.model ?? defaultModules.vision.model,
},
speech: {
provider: existingExtension.modules?.speech?.provider ?? defaultModules.speech.provider,
model: existingExtension.modules?.speech?.model ?? defaultModules.speech.model,
@@ -387,6 +434,9 @@ export const useAiriCardStore = defineStore('airi-card', () => {
activeConsciousnessProvider.value = extension?.modules?.consciousness?.provider
activeConsciousnessModel.value = extension?.modules?.consciousness?.model
activeVisionProvider.value = extension?.modules?.vision?.provider
activeVisionModel.value = extension?.modules?.vision?.model
activeSpeechProvider.value = extension?.modules?.speech?.provider
activeSpeechModel.value = extension?.modules?.speech?.model
activeSpeechVoiceId.value = extension?.modules?.speech?.voice_id
@@ -423,7 +473,10 @@ export const useAiriCardStore = defineStore('airi-card', () => {
removeCard,
updateCard,
bindVoicePackToActiveCard,
updateActiveCardConsciousness,
updateActiveCardDisplayModel,
updateActiveCardSpeech,
updateActiveCardVision,
getCard,
resetState,
initialize,
@@ -434,6 +487,10 @@ export const useAiriCardStore = defineStore('airi-card', () => {
provider: activeConsciousnessProvider.value,
model: activeConsciousnessModel.value,
},
vision: {
provider: activeVisionProvider.value,
model: activeVisionModel.value,
},
speech: {
provider: activeSpeechProvider.value,
model: activeSpeechModel.value,