From 71bd789da9c91269ee779fac1eae70a667ff825d Mon Sep 17 00:00:00 2001 From: xuan pan Date: Mon, 30 Mar 2026 14:38:42 -0400 Subject: [PATCH 1/7] feat: add Fish Audio as native TTS provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fish-audio provider entry with a custom fetch override that translates xsai generateSpeech requests to Fish Audio's POST /v1/tts format (text + reference_id in body) - Add Vite dev proxy (/fish-audio-api → https://api.fish.audio) to bypass browser CORS — Fish Audio's API is server-to-server only and does not send Access-Control-Allow-Origin for browser origins - Add listFishAudioVoices helper that fetches own models and top public models in parallel, merges and deduplicates them with own models first - Add fish-audio settings page with SpeechPlayground integration - Add i18n strings for all 9 supported locales - Improve TTS error logging in Stage.vue (was silently swallowing errors) Made-with: Cursor --- apps/stage-web/vite.config.ts | 13 ++ packages/i18n/src/locales/en/settings.yaml | 3 + packages/i18n/src/locales/es/settings.yaml | 3 + packages/i18n/src/locales/fr/settings.yaml | 3 + packages/i18n/src/locales/ja/settings.yaml | 3 + packages/i18n/src/locales/ko/settings.yaml | 3 + packages/i18n/src/locales/ru/settings.yaml | 3 + packages/i18n/src/locales/vi/settings.yaml | 3 + .../i18n/src/locales/zh-Hans/settings.yaml | 3 + .../i18n/src/locales/zh-Hant/settings.yaml | 3 + .../settings/providers/speech/fish-audio.vue | 79 +++++++++++ .../providers/speech-provider-settings.vue | 1 + .../stage-ui/src/components/scenes/Stage.vue | 8 +- packages/stage-ui/src/stores/providers.ts | 126 ++++++++++++++++++ .../providers/fish-audio/list-voices.ts | 66 +++++++++ 15 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue create mode 100644 packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index 925b00af96..e1207c1485 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -21,6 +21,7 @@ import { VitePWA } from 'vite-plugin-pwa' const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) +const FISH_AUDIO_PROXY_RE = /^\/fish-audio-api/ export default defineConfig({ optimizeDeps: { @@ -68,6 +69,18 @@ export default defineConfig({ // See: https://vite.dev/config/server-options#server-fs-strict strict: false, }, + // NOTICE: Fish Audio's API is server-to-server only and doesn't send + // Access-Control-Allow-Origin headers for browser origins, so direct + // fetch() calls from the browser are blocked by CORS. We proxy them + // through the local Vite dev server so they appear same-origin. + // See packages/stage-ui/src/stores/providers.ts for the matching client-side usage. + proxy: { + '/fish-audio-api': { + target: 'https://api.fish.audio', + changeOrigin: true, + rewrite: (path: string) => path.replace(FISH_AUDIO_PROXY_RE, ''), + }, + }, warmup: { clientFiles: [ `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 1e48dbf047..78d689457b 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -780,6 +780,9 @@ pages: default-text: Hello! This is a test of the Kokoro text-to-speech system. title: Voice Playground title: Kokoro TTS (Local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/es/settings.yaml b/packages/i18n/src/locales/es/settings.yaml index af69b6e632..22fa956b7f 100644 --- a/packages/i18n/src/locales/es/settings.yaml +++ b/packages/i18n/src/locales/es/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: '¡Hola! Esta es una prueba del sistema de texto a voz de Kokoro.' title: Campo de voz title: TTS de Kokoro (local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/fr/settings.yaml b/packages/i18n/src/locales/fr/settings.yaml index f9acbe0398..0a7c990f14 100644 --- a/packages/i18n/src/locales/fr/settings.yaml +++ b/packages/i18n/src/locales/fr/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: Bonjour ! Ceci est un test du système de synthèse vocale Kokoro. title: Zone d'essai vocale title: Kokoro TTS (Local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/ja/settings.yaml b/packages/i18n/src/locales/ja/settings.yaml index 41ceacf1af..eefa073531 100644 --- a/packages/i18n/src/locales/ja/settings.yaml +++ b/packages/i18n/src/locales/ja/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: こんにちは!これは音声合成システム Kokoro のテストです。 title: 音声合成実験場 title: Kokoro TTS (ローカル) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/ko/settings.yaml b/packages/i18n/src/locales/ko/settings.yaml index 83164fbd11..8516200e0b 100644 --- a/packages/i18n/src/locales/ko/settings.yaml +++ b/packages/i18n/src/locales/ko/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: 안녕하세요! 이것은 Kokoro 문자 음성 변환 시스템의 테스트입니다. title: 음성 플레이그라운드 title: Kokoro TTS (로컬) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/ru/settings.yaml b/packages/i18n/src/locales/ru/settings.yaml index b753b11551..a4b60fac52 100644 --- a/packages/i18n/src/locales/ru/settings.yaml +++ b/packages/i18n/src/locales/ru/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: Привет! Это тест-системы синтеза речи. title: Голосовая платформа title: Kokoro TTS (Local) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: Fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/vi/settings.yaml b/packages/i18n/src/locales/vi/settings.yaml index 549aefe658..94c5ec690e 100644 --- a/packages/i18n/src/locales/vi/settings.yaml +++ b/packages/i18n/src/locales/vi/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: Xin chào! Đây là bản thử nghiệm hệ thống giọng nói Kokoro. title: Thử nghiệm giọng nói title: Kokoro TTS (Cục bộ) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 57ebc40dbd..7ea215c1f1 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: 您好!这是 Kokoro 文本转语音(TTS)系统的测试。 title: 实验平台 title: Kokoro TTS (本地) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: Fireworks.ai title: Fireworks.ai diff --git a/packages/i18n/src/locales/zh-Hant/settings.yaml b/packages/i18n/src/locales/zh-Hant/settings.yaml index 96b03dd3d3..33c35b98a1 100644 --- a/packages/i18n/src/locales/zh-Hant/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/settings.yaml @@ -745,6 +745,9 @@ pages: default-text: 您好!這是 Kokoro 文字轉語音系統的測試。 title: 語音測試場 title: Kokoro TTS (本地) + fish-audio: + description: fish.audio + title: Fish Audio fireworks: description: Fireworks.ai title: Fireworks.ai diff --git a/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue new file mode 100644 index 0000000000..fbbfdb8243 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue @@ -0,0 +1,79 @@ + + + + + + meta: + layout: settings + stageTransition: + name: slide + 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 d679c4dbd7..2eb8f9f6c1 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 @@ -33,6 +33,7 @@ defineSlots<{ 'advanced-settings': (props: any) => any 'playground': (props: any) => any }>() + const { t } = useI18n() const router = useRouter() const providersStore = useProvidersStore() diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index d6b53c812e..08041d6b74 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -312,7 +312,13 @@ const speechPipeline = createSpeechPipeline({ const audioBuffer = await audioContext.decodeAudioData(res) return audioBuffer } - catch { + catch (error) { + console.error('[Speech Pipeline] TTS failed:', { + provider: activeSpeechProvider.value, + model: activeSpeechModel.value, + voice: activeSpeechVoice.value?.id, + error, + }) return null } }, diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 52012fcb62..a2a5e236da 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -58,6 +58,8 @@ import { buildOpenAICompatibleProvider } from './providers/openai-compatible-bui import { buildOpenRouterAudioSpeechProvider } from './providers/openrouter/audio-speech' import { createWebSpeechAPIProvider } from './providers/web-speech-api' +const TRAILING_SLASH_RE = /\/$/ + const ALIYUN_NLS_REGIONS = [ 'cn-shanghai', 'cn-shanghai-internal', @@ -1540,6 +1542,130 @@ export const useProvidersStore = defineStore('providers', () => { }, }, }, + 'fish-audio': { + id: 'fish-audio', + category: 'speech', + tasks: ['text-to-speech'], + nameKey: 'settings.pages.providers.provider.fish-audio.title', + name: 'Fish Audio', + descriptionKey: 'settings.pages.providers.provider.fish-audio.description', + description: 'fish.audio', + icon: 'i-lobe-icons:fishaudio', + defaultOptions: () => ({ + baseUrl: 'https://api.fish.audio', + }), + createProvider: async (config) => { + const apiKey = (config.apiKey as string ?? '').trim() + const baseUrl = ((config.baseUrl as string) || 'https://api.fish.audio').replace(TRAILING_SLASH_RE, '') + // NOTICE: Fish Audio's API is server-to-server only and does not send CORS + // headers for browser origins. In Vite dev mode we route through the local + // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request + // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own + // proxy) bypass this logic and are used as-is. + // See apps/stage-web/vite.config.ts for the matching server.proxy entry. + const effectiveBase = (import.meta.env.DEV && baseUrl === 'https://api.fish.audio') + ? '/fish-audio-api' + : baseUrl + const provider: SpeechProvider = { + speech: (model: string) => ({ + // NOTICE: baseURL must be an absolute URL — @xsai/generate-speech calls + // `new URL('audio/speech', baseURL)` internally. Our custom fetch below + // ignores the URL argument entirely and builds its own, so the value here + // only needs to be valid; we always keep the original absolute baseUrl. + baseURL: `${baseUrl}/`, + model, + // NOTICE: Fish Audio does not use the OpenAI /audio/speech endpoint format. + // We intercept the xsai generateSpeech request and translate it to + // Fish Audio's POST /v1/tts format (text/reference_id in body). + // + // NOTICE: The Fish Audio API accepts the model via a `model` request header, + // but sending custom headers from the browser triggers a CORS preflight that + // Fish Audio's CDN does not allow (Access-Control-Allow-Headers does not + // include `model`). The Fish Audio JS/Python SDKs also never send `model` as + // a header — they rely on the server default (`s2-pro`). We do the same here. + fetch: async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse((init?.body as string) ?? '{}') as { + input?: string + voice?: string + } + return fetch(`${effectiveBase}/v1/tts`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: body.input ?? '', + reference_id: body.voice || null, + format: 'mp3', + }), + }) + }, + }), + } + return provider + }, + capabilities: { + // NOTICE: The Fish Audio API selects the model via a `model` HTTP header. + // Sending that custom header from a browser causes CORS preflight failures, + // so we cannot forward model selection to the API. The server defaults to + // s2-pro when no header is present. Listing models here is informational only. + listModels: async () => [ + { + id: 's2-pro', + name: 'S2 Pro', + provider: 'fish-audio', + description: 'Latest generation model (server default)', + contextLength: 0, + deprecated: false, + }, + ], + listVoices: async (config) => { + const { listFishAudioVoices } = await import('./providers/fish-audio/list-voices') + const rawBase = ((config.baseUrl as string) || 'https://api.fish.audio').replace(TRAILING_SLASH_RE, '') + const effectiveVoiceBase = (import.meta.env.DEV && rawBase === 'https://api.fish.audio') + ? '/fish-audio-api' + : rawBase + return listFishAudioVoices( + ((config.apiKey as string) ?? '').trim(), + effectiveVoiceBase, + ) + }, + }, + validators: { + chatPingCheckAvailable: false, + validateProviderConfig: (config) => { + const errors: Error[] = [] + + if (!config.apiKey) { + errors.push(new Error('API key is required.')) + } + + // NOTICE: We intentionally skip the shared baseUrlValidator here because + // it requires a trailing slash for standard URL composition. Fish Audio's + // custom fetch override ignores the passed URL and constructs its own, so + // trailing-slash strictness is irrelevant. We only verify it's a valid + // absolute URL when one is explicitly provided. + if (config.baseUrl) { + try { + const parsed = new URL(config.baseUrl as string) + if (!parsed.host) { + errors.push(new Error('Base URL must have a valid host.')) + } + } + catch { + errors.push(new Error('Base URL is not a valid absolute URL.')) + } + } + + return { + errors, + reason: errors.map(e => e.message).join(', '), + valid: errors.length === 0, + } + }, + }, + }, 'kokoro-local': { id: 'kokoro-local', category: 'speech', diff --git a/packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts b/packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts new file mode 100644 index 0000000000..85a76aa438 --- /dev/null +++ b/packages/stage-ui/src/stores/providers/fish-audio/list-voices.ts @@ -0,0 +1,66 @@ +import type { VoiceInfo } from '../../providers' + +export interface FishAudioModelEntity { + _id: string + type: 'svc' | 'tts' + title: string + description: string + state: 'created' | 'training' | 'trained' | 'failed' + samples: Array<{ audio: string }> + languages: string[] +} + +interface FishAudioModelListResponse { + items: FishAudioModelEntity[] +} + +const TRAILING_SLASH_RE = /\/$/ + +function mapModelToVoiceInfo(model: FishAudioModelEntity): VoiceInfo { + return { + id: model._id, + name: model.title, + provider: 'fish-audio', + description: model.description || undefined, + previewURL: model.samples?.[0]?.audio || undefined, + languages: model.languages.length > 0 + ? model.languages.map(lang => ({ code: lang, title: lang })) + : [{ code: 'en', title: 'English' }], + } +} + +async function fetchModels(url: string, headers: HeadersInit): Promise { + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error(`Failed to fetch Fish Audio voices: ${response.status} ${response.statusText}`) + } + const data = await response.json() as FishAudioModelListResponse + return data.items.filter(model => model.state === 'trained' && model.type === 'tts') +} + +/** + * Fetches voice models from Fish Audio and maps them to VoiceInfo. + * + * Fetches both the user's own models (GET /model?self=true) and the top + * community/public models (GET /model?sort_by=task_count), merges them + * deduped by ID, with own models listed first. + */ +export async function listFishAudioVoices(apiKey: string, baseUrl: string): Promise { + const base = baseUrl.replace(TRAILING_SLASH_RE, '') + const headers: HeadersInit = { Authorization: `Bearer ${apiKey}` } + + // Fetch own models and popular public models in parallel + const [ownModels, publicModels] = await Promise.all([ + fetchModels(`${base}/model?self=true&page_size=100`, headers), + fetchModels(`${base}/model?sort_by=task_count&page_size=100`, headers), + ]) + + // Merge: own models first, then public models not already in the own list + const ownIds = new Set(ownModels.map(m => m._id)) + const merged = [ + ...ownModels, + ...publicModels.filter(m => !ownIds.has(m._id)), + ] + + return merged.map(mapModelToVoiceInfo) +} From 76daef5ab2e99038ad6b6ebeab9ce8bb21f96237 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Mon, 30 Mar 2026 15:08:08 -0400 Subject: [PATCH 2/7] fix(fish-audio): forward AbortSignal, guard Electron proxy, debounce voice reload - Forward init.signal to the custom Fish Audio fetch so the HTTP request is cancelled when the TTS pipeline is aborted (e.g. user interrupts playback) - Guard the Vite dev-server proxy rewrite with a userAgent Electron check; Electron's renderer has no matching proxy route so the rewrite caused 404s - Debounce the apiKey/baseUrl watcher in fish-audio.vue (800 ms) to avoid firing repeated requests with partial credentials on every keystroke Made-with: Cursor --- .../pages/settings/providers/speech/fish-audio.vue | 7 ++++++- packages/stage-ui/src/stores/providers.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue index fbbfdb8243..9151358163 100644 --- a/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue +++ b/packages/stage-pages/src/pages/settings/providers/speech/fish-audio.vue @@ -7,6 +7,7 @@ import { } from '@proj-airi/stage-ui/components' import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' +import { useDebounceFn } from '@vueuse/core' import { storeToRefs } from 'pinia' import { computed, onMounted, watch } from 'vue' @@ -45,12 +46,16 @@ async function tryLoadVoices() { } } +// Debounced so rapid keystrokes while editing API key / base URL don't fire +// repeated requests with partial/invalid credentials. +const debouncedLoadVoices = useDebounceFn(tryLoadVoices, 800) + onMounted(tryLoadVoices) // Reload voices whenever the API key or base URL changes watch( () => [providers.value[providerId]?.apiKey, providers.value[providerId]?.baseUrl], - tryLoadVoices, + debouncedLoadVoices, ) diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index a2a5e236da..def26d7028 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1563,7 +1563,12 @@ export const useProvidersStore = defineStore('providers', () => { // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own // proxy) bypass this logic and are used as-is. // See apps/stage-web/vite.config.ts for the matching server.proxy entry. - const effectiveBase = (import.meta.env.DEV && baseUrl === 'https://api.fish.audio') + // + // NOTICE: The proxy only exists in the stage-web Vite dev server. + // Electron's renderer dev server has no matching proxy route, so we must + // skip the rewrite when running inside Electron to avoid 404s. + const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') + const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' : baseUrl const provider: SpeechProvider = { @@ -1590,6 +1595,9 @@ export const useProvidersStore = defineStore('providers', () => { } return fetch(`${effectiveBase}/v1/tts`, { method: 'POST', + // Forward the AbortSignal so the HTTP request is cancelled when + // the TTS pipeline is aborted (e.g. user interrupts playback). + signal: init?.signal, headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', @@ -1623,7 +1631,8 @@ export const useProvidersStore = defineStore('providers', () => { listVoices: async (config) => { const { listFishAudioVoices } = await import('./providers/fish-audio/list-voices') const rawBase = ((config.baseUrl as string) || 'https://api.fish.audio').replace(TRAILING_SLASH_RE, '') - const effectiveVoiceBase = (import.meta.env.DEV && rawBase === 'https://api.fish.audio') + const isElectronRenderer = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') + const effectiveVoiceBase = (import.meta.env.DEV && !isElectronRenderer && rawBase === 'https://api.fish.audio') ? '/fish-audio-api' : rawBase return listFishAudioVoices( From 8b2d2fe15e3cd093e7ef44f467e0c23d09ce7520 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 20:20:29 -0400 Subject: [PATCH 3/7] fix: gate Fish Audio dev proxy to all browser-based Vite runtimes Add /fish-audio-api proxy to apps/stage-pocket/vite.config.ts so that stage-pocket DEV builds don't 404 when providers.ts rewrites the base URL. Previously the rewrite was only guarded against Electron, causing 404s in Capacitor dev mode where no matching proxy existed. Update the NOTICE comment in providers.ts to reference both vite configs. Made-with: Cursor --- apps/stage-pocket/vite.config.ts | 13 +++++++++++++ packages/stage-ui/src/stores/providers.ts | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 05f25ea36a..f62ef96ffb 100644 --- a/apps/stage-pocket/vite.config.ts +++ b/apps/stage-pocket/vite.config.ts @@ -34,6 +34,7 @@ function isEnvTruthy(value: string | undefined | null): boolean { const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) +const FISH_AUDIO_PROXY_RE = /^\/fish-audio-api/ export default defineConfig({ optimizeDeps: { @@ -83,6 +84,18 @@ export default defineConfig({ // See: https://vite.dev/config/server-options#server-fs-strict strict: false, }, + // NOTICE: Fish Audio's API is server-to-server only and doesn't send + // Access-Control-Allow-Origin headers for browser origins, so direct + // fetch() calls from the browser are blocked by CORS. We proxy them + // through the local Vite dev server so they appear same-origin. + // See packages/stage-ui/src/stores/providers.ts for the matching client-side usage. + proxy: { + '/fish-audio-api': { + target: 'https://api.fish.audio', + changeOrigin: true, + rewrite: (path: string) => path.replace(FISH_AUDIO_PROXY_RE, ''), + }, + }, warmup: { clientFiles: [ `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index def26d7028..7edd6610ed 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1562,11 +1562,12 @@ export const useProvidersStore = defineStore('providers', () => { // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own // proxy) bypass this logic and are used as-is. - // See apps/stage-web/vite.config.ts for the matching server.proxy entry. + // See apps/stage-web/vite.config.ts and apps/stage-pocket/vite.config.ts for the + // matching server.proxy entries. // - // NOTICE: The proxy only exists in the stage-web Vite dev server. - // Electron's renderer dev server has no matching proxy route, so we must - // skip the rewrite when running inside Electron to avoid 404s. + // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, + // stage-pocket). Electron's renderer dev server has no matching proxy route, so + // we must skip the rewrite when running inside Electron to avoid 404s. const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' From 6b6f78e4a6f8581ef79c958f862c06d11f3171e0 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 20:20:29 -0400 Subject: [PATCH 4/7] fix: gate Fish Audio dev proxy to all browser-based Vite runtimes Add /fish-audio-api proxy to apps/stage-pocket/vite.config.ts so that stage-pocket DEV builds don't 404 when providers.ts rewrites the base URL. Previously the rewrite was only guarded against Electron, causing 404s in Capacitor dev mode where no matching proxy existed. Update the NOTICE comment in providers.ts to reference both vite configs. --- apps/stage-pocket/vite.config.ts | 13 +++++++++++++ packages/stage-ui/src/stores/providers.ts | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 05f25ea36a..f62ef96ffb 100644 --- a/apps/stage-pocket/vite.config.ts +++ b/apps/stage-pocket/vite.config.ts @@ -34,6 +34,7 @@ function isEnvTruthy(value: string | undefined | null): boolean { const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) +const FISH_AUDIO_PROXY_RE = /^\/fish-audio-api/ export default defineConfig({ optimizeDeps: { @@ -83,6 +84,18 @@ export default defineConfig({ // See: https://vite.dev/config/server-options#server-fs-strict strict: false, }, + // NOTICE: Fish Audio's API is server-to-server only and doesn't send + // Access-Control-Allow-Origin headers for browser origins, so direct + // fetch() calls from the browser are blocked by CORS. We proxy them + // through the local Vite dev server so they appear same-origin. + // See packages/stage-ui/src/stores/providers.ts for the matching client-side usage. + proxy: { + '/fish-audio-api': { + target: 'https://api.fish.audio', + changeOrigin: true, + rewrite: (path: string) => path.replace(FISH_AUDIO_PROXY_RE, ''), + }, + }, warmup: { clientFiles: [ `${resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src'))}/*.vue`, diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index def26d7028..7edd6610ed 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1562,11 +1562,12 @@ export const useProvidersStore = defineStore('providers', () => { // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own // proxy) bypass this logic and are used as-is. - // See apps/stage-web/vite.config.ts for the matching server.proxy entry. + // See apps/stage-web/vite.config.ts and apps/stage-pocket/vite.config.ts for the + // matching server.proxy entries. // - // NOTICE: The proxy only exists in the stage-web Vite dev server. - // Electron's renderer dev server has no matching proxy route, so we must - // skip the rewrite when running inside Electron to avoid 404s. + // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, + // stage-pocket). Electron's renderer dev server has no matching proxy route, so + // we must skip the rewrite when running inside Electron to avoid 404s. const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' From 540df09ba773f63f573fdb8da53f6335c9aecc06 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 22:07:11 -0400 Subject: [PATCH 5/7] fix: gate Fish Audio default URL in non-DEV and Electron contexts validateProviderConfig now surfaces a clear error when the default api.fish.audio URL is used in contexts where browser CORS will block the request and no proxy/IPC path exists: - Electron (all modes): renderer enforces CORS; IPC path not yet built - Web/Capacitor production: Vite dev-server proxy is absent Capacitor native builds (iOS/Android) are exempt because the OS HTTP stack is not subject to browser CORS. Users with a custom base URL (self-hosted CORS proxy) are unaffected. Adds a TODO comment marking the Electron IPC path as follow-up work. Made-with: Cursor --- packages/stage-ui/src/stores/providers.ts | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 7edd6610ed..713dc9b503 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -20,7 +20,7 @@ import type { import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription' -import { isStageTamagotchi, isUrl } from '@proj-airi/stage-shared' +import { isStageCapacitor, isStageTamagotchi, isStageWeb, isUrl } from '@proj-airi/stage-shared' import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core' import { createOpenAI, @@ -1568,6 +1568,11 @@ export const useProvidersStore = defineStore('providers', () => { // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, // stage-pocket). Electron's renderer dev server has no matching proxy route, so // we must skip the rewrite when running inside Electron to avoid 404s. + // + // TODO: Add an Eventa IPC handler (electronFetchFishAudio) so the Electron main + // process can perform the request via net.fetch (no CORS restrictions in Node). + // Until that path exists, Fish Audio with the default URL is gated out of + // Electron via validateProviderConfig below. const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' @@ -1668,6 +1673,31 @@ export const useProvidersStore = defineStore('providers', () => { } } + // Gate the default Fish Audio URL in contexts where browser CORS will block + // the request and no local proxy or IPC path exists yet. + // + // - Electron (all modes): renderer enforces CORS; main-process IPC path not yet + // implemented. TODO: remove this gate once electronFetchFishAudio IPC exists. + // - Web/Capacitor production: Vite dev-server proxy is absent. Users who need + // production access must point baseUrl at a same-origin CORS proxy they control. + // - Capacitor native (iOS/Android): exempt — Capacitor uses the OS HTTP stack + // which is not subject to browser CORS restrictions. + const usingDefaultUrl = !config.baseUrl || config.baseUrl === 'https://api.fish.audio' + if (usingDefaultUrl) { + if (isStageTamagotchi()) { + errors.push(new Error( + 'Fish Audio requires a custom base URL (a CORS proxy) when running in the desktop app. ' + + 'Native IPC support is not yet available.', + )) + } + else if ((isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { + errors.push(new Error( + 'Fish Audio requires a custom base URL (a CORS proxy) in production builds. ' + + 'The dev-server proxy is only available during local development.', + )) + } + } + return { errors, reason: errors.map(e => e.message).join(', '), From 2ec9e3c065cc310ac6a08fcc38e3417d18ae6d65 Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 22:20:13 -0400 Subject: [PATCH 6/7] feat: route Fish Audio TTS through Electron main process via IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Eventa IPC path so the Electron renderer never makes a direct cross-origin request to api.fish.audio (which would be blocked by CORS): - packages/stage-ui/src/stores/providers/fish-audio/ipc.ts New file. Defines the electronFishAudioTTS Eventa contract and its payload/result types. Lives in the package so both the renderer and the main-process service can import it without an app→package inversion. - apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts New file. Main-process handler that uses net.fetch (Node, no CORS) to forward the TTS request to Fish Audio and returns the raw audio bytes as a structured-clone-safe Uint8Array. - apps/stage-tamagotchi/src/main/windows/shared/window.ts Registers createFishAudioService in setupBaseWindowElectronInvokes so the handler is available to all renderer windows. - apps/stage-tamagotchi/src/shared/eventa.ts Re-exports the contract from packages/stage-ui to keep the eventa contract centrally discoverable. - packages/stage-ui/src/stores/providers.ts In Electron, the custom fetch override now dynamically imports the Eventa adapter and delegates to the main process. The previous validateProviderConfig gate for Electron is removed since the IPC path is now in place. The DEV Vite proxy path is preserved for web/pocket browser development. Made-with: Cursor --- .../src/main/services/electron/fish-audio.ts | 43 ++++++++++ .../src/main/services/electron/index.ts | 1 + .../src/main/windows/shared/window.ts | 3 +- apps/stage-tamagotchi/src/shared/eventa.ts | 9 +- packages/stage-ui/src/stores/providers.ts | 83 ++++++++++--------- .../src/stores/providers/fish-audio/ipc.ts | 32 +++++++ 6 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts create mode 100644 packages/stage-ui/src/stores/providers/fish-audio/ipc.ts diff --git a/apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts b/apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts new file mode 100644 index 0000000000..1cb72d1013 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts @@ -0,0 +1,43 @@ +import type { createContext } from '@moeru/eventa/adapters/electron/main' + +import { defineInvokeHandler } from '@moeru/eventa' +import { electronFishAudioTTS } from '@proj-airi/stage-ui/stores/providers/fish-audio/ipc' +import { net } from 'electron' + +/** + * Register the Fish Audio TTS IPC handler. + * + * The renderer cannot call https://api.fish.audio directly because the CDN + * does not send CORS headers for browser origins. By routing the request + * through the main process with net.fetch, we bypass the renderer's CORS + * enforcement entirely. + */ +export function createFishAudioService(params: { + context: ReturnType['context'] +}): void { + defineInvokeHandler(params.context, electronFishAudioTTS, async (payload) => { + const response = await net.fetch(`${payload.baseUrl}/v1/tts`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${payload.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: payload.text, + reference_id: payload.referenceId ?? null, + format: 'mp3', + }), + }) + + if (!response.ok) { + throw new Error(`Fish Audio TTS failed: ${response.status} ${response.statusText}`) + } + + const buffer = await response.arrayBuffer() + return { + data: new Uint8Array(buffer), + contentType: response.headers.get('content-type') ?? 'audio/mpeg', + status: response.status, + } + }) +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 6b58098a74..b3a244e342 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -1,5 +1,6 @@ export * from './app' export * from './auto-updater' +export * from './fish-audio' export * from './powerMonitor' export * from './screen' export * from './window' diff --git a/apps/stage-tamagotchi/src/main/windows/shared/window.ts b/apps/stage-tamagotchi/src/main/windows/shared/window.ts index e8b92058a8..aa20485497 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/window.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/window.ts @@ -10,7 +10,7 @@ import { isMacOS } from 'std-env' import { createServerChannelService } from '../../services/airi/channel-server' import { createI18nService } from '../../services/airi/i18n' -import { createAppService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron' +import { createAppService, createFishAudioService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron' export function toggleWindowShow(window?: BrowserWindow | null): void { if (!window) { @@ -100,6 +100,7 @@ export async function setupBaseWindowElectronInvokes(params: { createWindowService({ context: params.context, window: params.window }) createAppService({ context: params.context, window: params.window }) createPowerMonitorService({ context: params.context, window: params.window }) + createFishAudioService({ context: params.context }) await createI18nService({ context: params.context, window: params.window, i18n: params.i18n }) diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 97a4668dd2..f47ac127e5 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -283,6 +283,9 @@ export interface ElectronAuthTokens { expiresIn: number } export const electronAuthStartLogin = defineInvokeEventa('eventa:invoke:electron:auth:start-login') + +export { electron } from '@proj-airi/electron-eventa' +export * from '@proj-airi/electron-eventa/electron-updater' export const electronAuthCallback = defineEventa('eventa:event:electron:auth:callback') export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa:event:electron:auth:callback-error') export const electronAuthLogout = defineInvokeEventa('eventa:invoke:electron:auth:logout') @@ -290,5 +293,7 @@ export const electronAuthLogout = defineInvokeEventa('eventa:invoke:electr export const i18nSetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:set-locale') export const i18nGetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:get-locale') -export { electron } from '@proj-airi/electron-eventa' -export * from '@proj-airi/electron-eventa/electron-updater' +// Fish Audio TTS IPC contract lives in packages/stage-ui so it can be imported +// by the renderer without creating an app → package dependency inversion. +export { electronFishAudioTTS } from '@proj-airi/stage-ui/stores/providers/fish-audio/ipc' +export type { ElectronFishAudioTTSPayload, ElectronFishAudioTTSResult } from '@proj-airi/stage-ui/stores/providers/fish-audio/ipc' diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 713dc9b503..28892e259d 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -20,7 +20,7 @@ import type { import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription' -import { isStageCapacitor, isStageTamagotchi, isStageWeb, isUrl } from '@proj-airi/stage-shared' +import { isElectronWindow, isStageCapacitor, isStageTamagotchi, isStageWeb, isUrl } from '@proj-airi/stage-shared' import { computedAsync, useIntervalFn, useLocalStorage } from '@vueuse/core' import { createOpenAI, @@ -1558,23 +1558,25 @@ export const useProvidersStore = defineStore('providers', () => { const apiKey = (config.apiKey as string ?? '').trim() const baseUrl = ((config.baseUrl as string) || 'https://api.fish.audio').replace(TRAILING_SLASH_RE, '') // NOTICE: Fish Audio's API is server-to-server only and does not send CORS - // headers for browser origins. In Vite dev mode we route through the local - // dev-server proxy (/fish-audio-api → https://api.fish.audio) so the request - // appears same-origin and is never blocked. Custom base URLs (e.g. a user's own - // proxy) bypass this logic and are used as-is. - // See apps/stage-web/vite.config.ts and apps/stage-pocket/vite.config.ts for the - // matching server.proxy entries. + // headers for browser origins. Depending on the runtime, we choose one of + // three strategies to avoid CORS failures: // - // NOTICE: The proxy only exists in browser-based Vite dev servers (stage-web, - // stage-pocket). Electron's renderer dev server has no matching proxy route, so - // we must skip the rewrite when running inside Electron to avoid 404s. + // 1. Electron — delegate to the main process via Eventa IPC. The main + // process uses net.fetch which runs in Node and is never subject to + // browser CORS enforcement. The IPC contract is defined in + // apps/stage-tamagotchi/src/shared/eventa.ts (electronFishAudioTTS). // - // TODO: Add an Eventa IPC handler (electronFetchFishAudio) so the Electron main - // process can perform the request via net.fetch (no CORS restrictions in Node). - // Until that path exists, Fish Audio with the default URL is gated out of - // Electron via validateProviderConfig below. - const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron') - const effectiveBase = (import.meta.env.DEV && !isElectron && baseUrl === 'https://api.fish.audio') + // 2. Browser DEV (stage-web / stage-pocket) — route through the local + // Vite dev-server proxy (/fish-audio-api → https://api.fish.audio) so + // the request appears same-origin. See vite.config.ts in each app for + // the matching server.proxy entry. + // + // 3. Everything else (custom base URL, Capacitor native) — use the URL + // as-is. Capacitor native builds use the OS HTTP stack and are not + // subject to browser CORS. A custom base URL means the user has their + // own same-origin proxy and needs no rewrite. + const electronWindow = isStageTamagotchi() ? isElectronWindow(window) && window : false + const effectiveBase = (!electronWindow && import.meta.env.DEV && baseUrl === 'https://api.fish.audio') ? '/fish-audio-api' : baseUrl const provider: SpeechProvider = { @@ -1599,6 +1601,23 @@ export const useProvidersStore = defineStore('providers', () => { input?: string voice?: string } + if (electronWindow) { + // Delegate to main process to bypass renderer CORS. + // defineInvoke returns a typed invoker function; call it with the payload. + const { defineInvoke } = await import('@moeru/eventa') + const { createContext } = await import('@moeru/eventa/adapters/electron/renderer') + const { electronFishAudioTTS } = await import('./providers/fish-audio/ipc') + const { context } = createContext(electronWindow.electron.ipcRenderer) + // NOTICE: AbortSignal is not serializable over IPC and cannot be + // forwarded to the main process. Cancellation of in-flight IPC + // requests is not yet supported for this path. + const invokeTTS = defineInvoke(context, electronFishAudioTTS) + const result = await invokeTTS({ apiKey, baseUrl, text: body.input ?? '', referenceId: body.voice || null }) + return new Response(result.data.buffer as ArrayBuffer, { + status: result.status, + headers: { 'Content-Type': result.contentType }, + }) + } return fetch(`${effectiveBase}/v1/tts`, { method: 'POST', // Forward the AbortSignal so the HTTP request is cancelled when @@ -1673,29 +1692,19 @@ export const useProvidersStore = defineStore('providers', () => { } } - // Gate the default Fish Audio URL in contexts where browser CORS will block - // the request and no local proxy or IPC path exists yet. + // Gate the default Fish Audio URL in contexts where browser CORS will + // block the request and no proxy/IPC path exists. // - // - Electron (all modes): renderer enforces CORS; main-process IPC path not yet - // implemented. TODO: remove this gate once electronFetchFishAudio IPC exists. - // - Web/Capacitor production: Vite dev-server proxy is absent. Users who need - // production access must point baseUrl at a same-origin CORS proxy they control. - // - Capacitor native (iOS/Android): exempt — Capacitor uses the OS HTTP stack - // which is not subject to browser CORS restrictions. + // - Electron: handled via IPC (electronFishAudioTTS) — no gate needed. + // - Capacitor native (iOS/Android): OS HTTP stack bypasses CORS — no gate. + // - Web/Capacitor production: Vite dev-server proxy is absent; users must + // point baseUrl at a same-origin CORS proxy they control. const usingDefaultUrl = !config.baseUrl || config.baseUrl === 'https://api.fish.audio' - if (usingDefaultUrl) { - if (isStageTamagotchi()) { - errors.push(new Error( - 'Fish Audio requires a custom base URL (a CORS proxy) when running in the desktop app. ' - + 'Native IPC support is not yet available.', - )) - } - else if ((isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { - errors.push(new Error( - 'Fish Audio requires a custom base URL (a CORS proxy) in production builds. ' - + 'The dev-server proxy is only available during local development.', - )) - } + if (usingDefaultUrl && (isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { + errors.push(new Error( + 'Fish Audio requires a custom base URL (a CORS proxy) in production builds. ' + + 'The dev-server proxy is only available during local development.', + )) } return { diff --git a/packages/stage-ui/src/stores/providers/fish-audio/ipc.ts b/packages/stage-ui/src/stores/providers/fish-audio/ipc.ts new file mode 100644 index 0000000000..8a94de43a1 --- /dev/null +++ b/packages/stage-ui/src/stores/providers/fish-audio/ipc.ts @@ -0,0 +1,32 @@ +import { defineInvokeEventa } from '@moeru/eventa' + +/** + * Payload sent from the Electron renderer to the main process for a Fish Audio + * TTS request. AbortSignal is intentionally omitted — it is not serializable + * across IPC and cancellation must be handled at a higher level if needed. + */ +export interface ElectronFishAudioTTSPayload { + apiKey: string + baseUrl: string + text: string + referenceId?: string | null +} + +/** Response returned by the main process after proxying the Fish Audio request. */ +export interface ElectronFishAudioTTSResult { + /** Raw audio bytes as a structured-clone-safe Uint8Array. */ + data: Uint8Array + contentType: string + status: number +} + +/** + * Eventa IPC contract for proxying Fish Audio TTS through the Electron main + * process. The main process uses net.fetch which runs in Node and is therefore + * not subject to browser CORS enforcement. + * + * Handler: apps/stage-tamagotchi/src/main/services/electron/fish-audio.ts + */ +export const electronFishAudioTTS = defineInvokeEventa( + 'eventa:invoke:electron:providers:fish-audio:tts', +) From f5748d6b0307fbaad80feb5dcbf8fe4c4c4c8edc Mon Sep 17 00:00:00 2001 From: xuan pan Date: Tue, 7 Apr 2026 22:29:01 -0400 Subject: [PATCH 7/7] fix: normalize baseUrl before Fish Audio default-URL gate comparison Made-with: Cursor --- packages/stage-ui/src/stores/providers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 28892e259d..795ef04d75 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1699,7 +1699,10 @@ export const useProvidersStore = defineStore('providers', () => { // - Capacitor native (iOS/Android): OS HTTP stack bypasses CORS — no gate. // - Web/Capacitor production: Vite dev-server proxy is absent; users must // point baseUrl at a same-origin CORS proxy they control. - const usingDefaultUrl = !config.baseUrl || config.baseUrl === 'https://api.fish.audio' + // Normalize before comparison: strip trailing slashes the same way + // createProvider does, so 'https://api.fish.audio/' is caught too. + const normalizedConfigUrl = ((config.baseUrl as string) || '').replace(TRAILING_SLASH_RE, '') + const usingDefaultUrl = !config.baseUrl || normalizedConfigUrl === 'https://api.fish.audio' if (usingDefaultUrl && (isStageWeb() || isStageCapacitor()) && !import.meta.env.DEV) { errors.push(new Error( 'Fish Audio requires a custom base URL (a CORS proxy) in production builds. '