From d7f38783f796f3d2c851407e9613eda849f5a67b Mon Sep 17 00:00:00 2001 From: leafyy Date: Thu, 10 Sep 2026 00:21:06 +0800 Subject: [PATCH] fix(stage-ui): persist speech provider settings (#2497) --- package.json | 3 +- packages/stage-pages/package.json | 7 + ...rovider-config-persistence.browser.test.ts | 122 ++++++++++++++++++ packages/stage-pages/vitest.config.ts | 47 +++++++ .../providers/speech-provider-settings.vue | 5 +- .../voicevox-family-settings.browser.test.ts | 40 +++++- .../src/stores/providers/config.test.ts | 21 +++ .../stage-ui/src/stores/providers/config.ts | 19 +++ .../src/stores/providers/provider.test.ts | 25 ++++ .../stage-ui/src/stores/providers/provider.ts | 2 +- pnpm-lock.yaml | 18 +++ 11 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 packages/stage-pages/src/pages/settings/providers/speech/provider-config-persistence.browser.test.ts create mode 100644 packages/stage-pages/vitest.config.ts diff --git a/package.json b/package.json index 03082dde8..958f3a7f5 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "build:packages": "turbo run build -F=\"./packages/*\"", "build:engines": "turbo run build -F=\"./engines/*\"", "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-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", diff --git a/packages/stage-pages/package.json b/packages/stage-pages/package.json index 79736f6f8..b6436f6ab 100644 --- a/packages/stage-pages/package.json +++ b/packages/stage-pages/package.json @@ -19,6 +19,7 @@ "./*": "./src/*.ts" }, "scripts": { + "test:run": "vitest run", "typecheck": "vue-tsc --noEmit" }, "dependencies": { @@ -59,11 +60,17 @@ }, "devDependencies": { "@nekopaw/tempora": "catalog:", + "@pinia/colada": "catalog:", "@proj-airi/stage-shared": "workspace:^", "@types/audioworklet": "catalog:", "@types/splitpanes": "catalog:", "@types/three": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vitest/browser-playwright": "catalog:vitest", + "unocss": "catalog:", "unplugin-info": "catalog:", + "vitest": "catalog:vitest", + "vitest-browser-vue": "catalog:", "vue-tsc": "catalog:" } } diff --git a/packages/stage-pages/src/pages/settings/providers/speech/provider-config-persistence.browser.test.ts b/packages/stage-pages/src/pages/settings/providers/speech/provider-config-persistence.browser.test.ts new file mode 100644 index 000000000..f04154e51 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/speech/provider-config-persistence.browser.test.ts @@ -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 + + beforeEach(() => { + localStorage.clear() + pinia = createPinia() + // The form must accept credentials before a TTS server is reachable. + vi.stubGlobal('fetch', vi.fn(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: '
' } }], + }) + 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') + }) +}) diff --git a/packages/stage-pages/vitest.config.ts b/packages/stage-pages/vitest.config.ts new file mode 100644 index 000000000..15627728e --- /dev/null +++ b/packages/stage-pages/vitest.config.ts @@ -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' }, + ], + }, + }, +}) diff --git a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue index 612e2cbb9..6ed63a801 100644 --- a/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue +++ b/packages/stage-ui/src/components/scenarios/providers/speech-provider-settings.vue @@ -127,14 +127,13 @@ onMounted(async () => { }) const debouncedUpdate = useDebounceFn(() => { - providers.value[props.providerId] = { - ...providers.value[props.providerId], + providerStore.patchProviderConfig(props.providerId, { // A provider without a credential field keeps no `apiKey` key. The guard in // `onMounted` stops the same key arriving by the other path. ...(props.hideApiKey ? {} : { apiKey: apiKey.value }), baseUrl: baseUrl.value || providerMetadata.value?.defaultConfig.baseUrl || '', voiceSettings: { ...voiceSettings.value }, - } + }) }, 1000) // Watch all settings and update the provider configuration diff --git a/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts b/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts index 5d6d68e3e..b7c8aa186 100644 --- a/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts +++ b/packages/stage-ui/src/components/scenarios/providers/voicevox-family-settings.browser.test.ts @@ -37,7 +37,7 @@ function stubUnreachableEngine() { }) } -async function mountSettings(pinia: Pinia) { +async function mountSettings(pinia: Pinia, providerId = 'voicevox') { const i18n = createI18n({ // Every label resolves to its own key. These cases assert on engine data and // on store state, so they need no message catalogue. @@ -57,7 +57,7 @@ async function mountSettings(pinia: Pinia) { return await render(VoicevoxFamilySettings, { props: { - providerId: 'voicevox', + providerId, intonationLabelKey: 'intonation.label', intonationDescriptionKey: 'intonation.description', 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', () => { afterEach(() => { vi.unstubAllGlobals() @@ -114,6 +133,23 @@ describe('voicevox family settings', () => { 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: // // A follower renderer reported "Failed to execute 'postMessage' on diff --git a/packages/stage-ui/src/stores/providers/config.test.ts b/packages/stage-ui/src/stores/providers/config.test.ts index 821d90450..5d521a3b5 100644 --- a/packages/stage-ui/src/stores/providers/config.test.ts +++ b/packages/stage-ui/src/stores/providers/config.test.ts @@ -118,4 +118,25 @@ describe('provider config store', () => { ) 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() + }) }) diff --git a/packages/stage-ui/src/stores/providers/config.ts b/packages/stage-ui/src/stores/providers/config.ts index 6fa9f8a41..9096409c6 100644 --- a/packages/stage-ui/src/stores/providers/config.ts +++ b/packages/stage-ui/src/stores/providers/config.ts @@ -153,6 +153,24 @@ export const useProviderConfigStore = defineStore('provider-config', () => { 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) { + 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. * @@ -288,6 +306,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => { markProviderAdded, unmarkProviderAdded, setProviderStatus, + patchProviderConfig, setProviderModel, setProviderModelIfUnset, fetchProviders, diff --git a/packages/stage-ui/src/stores/providers/provider.test.ts b/packages/stage-ui/src/stores/providers/provider.test.ts index fc57ebe77..c1a2d0942 100644 --- a/packages/stage-ui/src/stores/providers/provider.test.ts +++ b/packages/stage-ui/src/stores/providers/provider.test.ts @@ -128,6 +128,31 @@ describe('provider store synchronization boundary', () => { 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: // // Module pages treated every credential-free provider as available before diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index 88c754bff..9dc8348cd 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -425,7 +425,7 @@ export const useProviderStore = defineStore('provider', () => { // Initialize provider configurations async function initializeProvider(providerId: string) { await waitForProviderMetadata() - if (!providerCredentials.value[providerId]) { + if (!providerConfigStore.getProvider(providerId)) { const definitionId = getProviderDefinitionId(providerId) providerConfigStore.ensureProvider(providerId, definitionId, getDefaultProviderConfig(providerId)) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d46f147c4..1b6cbd1f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4280,6 +4280,9 @@ importers: '@nekopaw/tempora': specifier: 'catalog:' 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': specifier: workspace:^ version: link:../stage-shared @@ -4292,9 +4295,24 @@ importers: '@types/three': specifier: 'catalog:' 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: 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)) + 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: specifier: 'catalog:' version: 3.3.11(typescript@6.0.3)