fix(stage-ui): persist speech provider settings (#2497)

This commit is contained in:
leafyy
2026-09-10 00:21:06 +08:00
committed by GitHub
parent 352a9e389b
commit d7f38783f7
11 changed files with 302 additions and 7 deletions
+2 -1
View File
@@ -34,7 +34,8 @@
"build:packages": "turbo run build -F=\"./packages/*\"", "build:packages": "turbo run build -F=\"./packages/*\"",
"build:engines": "turbo run build -F=\"./engines/*\"", "build:engines": "turbo run build -F=\"./engines/*\"",
"test": "vitest --coverage", "test": "vitest --coverage",
"test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-stage-web:run && pnpm run test-ui:run", "test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-stage-web:run && pnpm run test-stage-pages:run && pnpm run test-ui:run",
"test-stage-pages:run": "pnpm -F @proj-airi/stage-pages test:run",
"test-stage-web:run": "pnpm -F @proj-airi/stage-web exec vitest run --project browser src/pages/devtools/vrm-model-position.browser.test.ts", "test-stage-web:run": "pnpm -F @proj-airi/stage-web exec vitest run --project browser src/pages/devtools/vrm-model-position.browser.test.ts",
"test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser", "test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser",
"test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts", "test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts",
+7
View File
@@ -19,6 +19,7 @@
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"scripts": { "scripts": {
"test:run": "vitest run",
"typecheck": "vue-tsc --noEmit" "typecheck": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -59,11 +60,17 @@
}, },
"devDependencies": { "devDependencies": {
"@nekopaw/tempora": "catalog:", "@nekopaw/tempora": "catalog:",
"@pinia/colada": "catalog:",
"@proj-airi/stage-shared": "workspace:^", "@proj-airi/stage-shared": "workspace:^",
"@types/audioworklet": "catalog:", "@types/audioworklet": "catalog:",
"@types/splitpanes": "catalog:", "@types/splitpanes": "catalog:",
"@types/three": "catalog:", "@types/three": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"@vitest/browser-playwright": "catalog:vitest",
"unocss": "catalog:",
"unplugin-info": "catalog:", "unplugin-info": "catalog:",
"vitest": "catalog:vitest",
"vitest-browser-vue": "catalog:",
"vue-tsc": "catalog:" "vue-tsc": "catalog:"
} }
} }
@@ -0,0 +1,122 @@
import en from '@proj-airi/i18n/locales/en'
import { PiniaColada } from '@pinia/colada'
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
import { createPinia, disposePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { page } from 'vitest/browser'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import CometApiSpeechPage from './comet-api-speech.vue'
import OpenAICompatibleSpeechPage from './openai-compatible-audio-speech.vue'
import 'virtual:uno.css'
const providerId = 'openai-compatible-audio-speech'
describe('speech provider configuration persistence', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
localStorage.clear()
pinia = createPinia()
// The form must accept credentials before a TTS server is reachable.
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => {
throw new TypeError('TTS server unavailable')
}))
})
async function renderPage(component = OpenAICompatibleSpeechPage) {
await page.viewport(1100, 1100)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
await router.push('/')
return await render(component, {
global: {
plugins: [pinia, PiniaColada, router, createI18n({ legacy: false, locale: 'en', messages: { en } })],
directives: { motion: {} },
},
})
}
afterEach(() => {
disposePinia(pinia)
vi.unstubAllGlobals()
localStorage.clear()
})
// ROOT CAUSE:
//
// The immediate model watcher creates an entry in the computed configs map.
// initializeProvider sees that entry and skips creation of the persisted provider.
// Input changes then mutate a plain object, so the playground keeps its cached
// missing-key state and localStorage stays empty. These tests fail if the page
// and initialization flow stop writing through the provider store.
// https://github.com/moeru-ai/airi/issues/2449
it('enables Test Voice after the user enters an API key (Issue #2449)', async () => {
const screen = await renderPage()
const testVoice = screen.getByRole('button', { name: 'Test Voice', exact: true })
await expect.element(testVoice).toBeDisabled()
await page.getByPlaceholder('sk-...').fill('issue-2449-test-key')
await expect.element(testVoice, { timeout: 1500 }).toBeEnabled()
await expect.element(screen.getByText('Please enter an API key to test the voice.')).not.toBeInTheDocument()
expect(useProviderConfigStore(pinia).getProviderConfig(providerId)?.apiKey).toBe('issue-2449-test-key')
})
// https://github.com/moeru-ai/airi/issues/2449
it('persists a key entered on a fresh settings page (Issue #2449)', async () => {
await renderPage()
await page.getByPlaceholder('sk-...').fill('issue-2449-test-key')
await expect.poll(() => {
const stored = localStorage.getItem('settings/providers/configured')
if (!stored)
return undefined
return JSON.parse(stored)[providerId]?.config?.apiKey
}, { timeout: 2000 }).toBe('issue-2449-test-key')
})
// https://github.com/moeru-ai/airi/issues/2449#issuecomment-5586964144
it('accepts the reported workaround with a persisted provider (Issue #2449)', async () => {
localStorage.setItem('settings/providers/configured', JSON.stringify({
[providerId]: {
id: providerId,
definitionId: providerId,
config: { apiKey: 'issue-2449-test-key', baseUrl: 'http://127.0.0.1:8080/v1/', model: 'qwen', voice: 'assistant' },
status: 'unconfigured',
configuredBy: 'user',
},
}))
const screen = await renderPage()
await expect.element(screen.getByRole('button', { name: 'Test Voice', exact: true })).toBeEnabled()
expect(useProviderConfigStore(pinia).getProviderConfig(providerId)?.apiKey).toBe('issue-2449-test-key')
})
// https://github.com/moeru-ai/airi/issues/2449
it('enables Comet API Test Voice after the user enters an API key (Issue #2449)', async () => {
const screen = await renderPage(CometApiSpeechPage)
const testVoice = screen.getByRole('button', { name: 'Test Voice', exact: true })
await expect.element(testVoice).toBeDisabled()
await page.getByPlaceholder('API Key').fill('issue-2449-comet-key')
await expect.element(testVoice, { timeout: 1500 }).toBeEnabled()
})
// https://github.com/moeru-ai/airi/issues/2449
it('persists a Comet API key entered on a fresh settings page (Issue #2449)', async () => {
await renderPage(CometApiSpeechPage)
await page.getByPlaceholder('API Key').fill('issue-2449-comet-key')
await expect.poll(() => {
const stored = localStorage.getItem('settings/providers/configured')
if (!stored)
return undefined
return JSON.parse(stored)['comet-api-speech']?.config?.apiKey
}, { timeout: 2000 }).toBe('issue-2449-comet-key')
})
})
+47
View File
@@ -0,0 +1,47 @@
import { resolve } from 'node:path'
import Vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
import Info from 'unplugin-info/vite'
import VueRouter from 'vue-router/vite'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
import { sharedUnoConfig } from '../../uno.config'
export default defineConfig({
root: import.meta.dirname,
plugins: [
Info(),
Vue(),
VueRouter({
extensions: ['.vue'],
dts: false,
routesFolder: resolve(import.meta.dirname, 'src', 'pages'),
exclude: ['**/components/**', '**/*.test.ts'],
}),
UnoCSS({
...sharedUnoConfig(),
configFile: false,
content: {
filesystem: [
`${import.meta.dirname}/src/**/*.vue`,
`${import.meta.dirname}/../stage-ui/src/**/*.vue`,
`${import.meta.dirname}/../ui/src/**/*.vue`,
],
},
}),
],
test: {
include: ['src/**/*.browser.test.ts'],
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
})
@@ -127,14 +127,13 @@ onMounted(async () => {
}) })
const debouncedUpdate = useDebounceFn(() => { const debouncedUpdate = useDebounceFn(() => {
providers.value[props.providerId] = { providerStore.patchProviderConfig(props.providerId, {
...providers.value[props.providerId],
// A provider without a credential field keeps no `apiKey` key. The guard in // A provider without a credential field keeps no `apiKey` key. The guard in
// `onMounted` stops the same key arriving by the other path. // `onMounted` stops the same key arriving by the other path.
...(props.hideApiKey ? {} : { apiKey: apiKey.value }), ...(props.hideApiKey ? {} : { apiKey: apiKey.value }),
baseUrl: baseUrl.value || providerMetadata.value?.defaultConfig.baseUrl || '', baseUrl: baseUrl.value || providerMetadata.value?.defaultConfig.baseUrl || '',
voiceSettings: { ...voiceSettings.value }, voiceSettings: { ...voiceSettings.value },
} })
}, 1000) }, 1000)
// Watch all settings and update the provider configuration // Watch all settings and update the provider configuration
@@ -37,7 +37,7 @@ function stubUnreachableEngine() {
}) })
} }
async function mountSettings(pinia: Pinia) { async function mountSettings(pinia: Pinia, providerId = 'voicevox') {
const i18n = createI18n({ const i18n = createI18n({
// Every label resolves to its own key. These cases assert on engine data and // Every label resolves to its own key. These cases assert on engine data and
// on store state, so they need no message catalogue. // on store state, so they need no message catalogue.
@@ -57,7 +57,7 @@ async function mountSettings(pinia: Pinia) {
return await render(VoicevoxFamilySettings, { return await render(VoicevoxFamilySettings, {
props: { props: {
providerId: 'voicevox', providerId,
intonationLabelKey: 'intonation.label', intonationLabelKey: 'intonation.label',
intonationDescriptionKey: 'intonation.description', intonationDescriptionKey: 'intonation.description',
defaultText: 'こんにちは', defaultText: 'こんにちは',
@@ -71,6 +71,25 @@ async function mountSettings(pinia: Pinia) {
}) })
} }
async function expectVoiceSettingsPersistence(providerId: 'voicevox' | 'aivis-speech') {
stubReachableEngine()
const pinia = createPinia()
const screen = await mountSettings(pinia, providerId)
const providerConfig = useProviderConfigStore(pinia)
const sliders = screen.getByRole('slider').all()
await sliders[0].fill('15000')
await expect.poll(() => providerConfig.getProviderConfig(providerId)?.voiceSettings).toMatchObject({ speed: 1.5 })
await expect.poll(() => {
const stored = localStorage.getItem('settings/providers/configured')
if (!stored)
return undefined
return JSON.parse(stored)[providerId]?.config?.voiceSettings?.speed
}).toBe(1.5)
}
describe('voicevox family settings', () => { describe('voicevox family settings', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
@@ -114,6 +133,23 @@ describe('voicevox family settings', () => {
expect(speech.availableVoices.voicevox).toBeUndefined() expect(speech.availableVoices.voicevox).toBeUndefined()
}) })
// ROOT CAUSE:
//
// `SpeechProviderSettings` replaced an entry on the computed `configs` map.
// The replacement did not update the full provider record, so local storage
// kept the old voice settings.
//
// The shared component now updates configuration through the owning store.
// https://github.com/moeru-ai/airi/issues/2449
it('persists voice settings through the provider record (Issue #2449)', async () => {
await expectVoiceSettingsPersistence('voicevox')
})
// https://github.com/moeru-ai/airi/issues/2449
it('persists AivisSpeech voice settings through the provider record (Issue #2449)', async () => {
await expectVoiceSettingsPersistence('aivis-speech')
})
// ROOT CAUSE: // ROOT CAUSE:
// //
// A follower renderer reported "Failed to execute 'postMessage' on // A follower renderer reported "Failed to execute 'postMessage' on
@@ -118,4 +118,25 @@ describe('provider config store', () => {
) )
expect(mocks.service.deleteRemote).toHaveBeenCalledWith(mocks.client, remoteProvider.id) expect(mocks.service.deleteRemote).toHaveBeenCalledWith(mocks.client, remoteProvider.id)
}) })
it('patches configuration through the owning provider record', () => {
const store = installStore()
store.providers[localProvider.id] = {
...localProvider,
config: { baseUrl: 'https://example.com/v1/' },
}
expect(store.patchProviderConfig(localProvider.id, { apiKey: 'sk-test' })).toBe(true)
expect(store.getProviderConfig(localProvider.id)).toEqual({
apiKey: 'sk-test',
baseUrl: 'https://example.com/v1/',
})
})
it('does not create an incomplete provider while patching configuration', () => {
const store = installStore()
expect(store.patchProviderConfig('missing-provider', { apiKey: 'sk-test' })).toBe(false)
expect(store.getProvider('missing-provider')).toBeUndefined()
})
}) })
@@ -153,6 +153,24 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
provider.status = status provider.status = status
} }
/**
* Applies configuration fields to an existing provider record.
*
* The caller must initialize the provider before this action runs. A new
* configuration object makes persistence and state replication observable.
*/
function patchProviderConfig(providerId: string, config: Record<string, unknown>) {
const provider = providers.value[providerId]
if (!provider)
return false
providers.value[providerId] = {
...provider,
config: { ...provider.config, ...config },
}
return true
}
/** /**
* Updates the selected model in the leader-owned provider snapshot. * Updates the selected model in the leader-owned provider snapshot.
* *
@@ -288,6 +306,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
markProviderAdded, markProviderAdded,
unmarkProviderAdded, unmarkProviderAdded,
setProviderStatus, setProviderStatus,
patchProviderConfig,
setProviderModel, setProviderModel,
setProviderModelIfUnset, setProviderModelIfUnset,
fetchProviders, fetchProviders,
@@ -128,6 +128,31 @@ describe('provider store synchronization boundary', () => {
expect(getProviderCalls).toBe(0) expect(getProviderCalls).toBe(0)
}) })
// ROOT CAUSE:
//
// Speech settings pages wrote defaults into the computed `configs` map before
// provider initialization. The derived entry made `initializeProvider` skip
// the source provider record, so later input was not persisted.
//
// The provider record is now the only existence check. A derived entry cannot
// prevent initialization of the persisted source state.
// https://github.com/moeru-ai/airi/issues/2449
it('creates the provider when only a derived configuration entry exists (Issue #2449)', async () => {
const store = useProviderStore()
const configStore = useProviderConfigStore()
configStore.configs['openai-compatible-audio-speech'] = { model: 'qwen' }
expect(configStore.getProvider('openai-compatible-audio-speech')).toBeUndefined()
await store.initializeProvider('openai-compatible-audio-speech')
expect(configStore.getProvider('openai-compatible-audio-speech')).toMatchObject({
id: 'openai-compatible-audio-speech',
definitionId: 'openai-compatible-audio-speech',
})
})
// ROOT CAUSE: // ROOT CAUSE:
// //
// Module pages treated every credential-free provider as available before // Module pages treated every credential-free provider as available before
@@ -425,7 +425,7 @@ export const useProviderStore = defineStore('provider', () => {
// Initialize provider configurations // Initialize provider configurations
async function initializeProvider(providerId: string) { async function initializeProvider(providerId: string) {
await waitForProviderMetadata() await waitForProviderMetadata()
if (!providerCredentials.value[providerId]) { if (!providerConfigStore.getProvider(providerId)) {
const definitionId = getProviderDefinitionId(providerId) const definitionId = getProviderDefinitionId(providerId)
providerConfigStore.ensureProvider(providerId, definitionId, getDefaultProviderConfig(providerId)) providerConfigStore.ensureProvider(providerId, definitionId, getDefaultProviderConfig(providerId))
} }
+18
View File
@@ -4280,6 +4280,9 @@ importers:
'@nekopaw/tempora': '@nekopaw/tempora':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.4.0-alpha.1 version: 0.4.0-alpha.1
'@pinia/colada':
specifier: 'catalog:'
version: 1.4.2(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3))
'@proj-airi/stage-shared': '@proj-airi/stage-shared':
specifier: workspace:^ specifier: workspace:^
version: link:../stage-shared version: link:../stage-shared
@@ -4292,9 +4295,24 @@ importers:
'@types/three': '@types/three':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.185.4 version: 0.185.4
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.8(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
'@vitest/browser-playwright':
specifier: catalog:vitest
version: 4.1.11(bufferutil@4.1.0)(playwright@1.62.1)(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11)
unocss:
specifier: 'catalog:'
version: 66.7.5(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))
unplugin-info: unplugin-info:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.3.2(@nuxt/kit@3.21.11(magicast@0.5.4))(esbuild@0.28.2)(rollup@4.62.5)(supports-color@10.2.2)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) version: 1.3.2(@nuxt/kit@3.21.11(magicast@0.5.4))(esbuild@0.28.2)(rollup@4.62.5)(supports-color@10.2.2)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))
vitest:
specifier: catalog:vitest
version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0)(canvas@3.2.3))(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0(supports-color@10.2.2))(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))
vitest-browser-vue:
specifier: 'catalog:'
version: 2.1.0(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vitest@4.1.11)(vue@3.5.41(typescript@6.0.3))
vue-tsc: vue-tsc:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.3.11(typescript@6.0.3) version: 3.3.11(typescript@6.0.3)