fix(stage-ui): persist speech provider settings (#2497)
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
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
|
||||
|
||||
+38
-2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<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.
|
||||
*
|
||||
@@ -288,6 +306,7 @@ export const useProviderConfigStore = defineStore('provider-config', () => {
|
||||
markProviderAdded,
|
||||
unmarkProviderAdded,
|
||||
setProviderStatus,
|
||||
patchProviderConfig,
|
||||
setProviderModel,
|
||||
setProviderModelIfUnset,
|
||||
fetchProviders,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Generated
+18
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user