From b5d3ddb027130eeafdc7082bfd980185ad5c5467 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 11:52:09 +0800 Subject: [PATCH 01/12] test: add failing unit tests for isProviderUsable, validateProvider, validateModel --- tests/store/settings-validation.test.ts | 99 +++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/store/settings-validation.test.ts diff --git a/tests/store/settings-validation.test.ts b/tests/store/settings-validation.test.ts new file mode 100644 index 00000000..a80019cc --- /dev/null +++ b/tests/store/settings-validation.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { + isProviderUsable, + validateProvider, + validateModel, + type ProviderCfgLike, +} from '@/lib/store/settings-validation'; + +describe('isProviderUsable', () => { + it('returns true when provider does not require API key', () => { + expect(isProviderUsable({ requiresApiKey: false })).toBe(true); + }); + + it('returns true when provider has client API key', () => { + expect(isProviderUsable({ requiresApiKey: true, apiKey: 'sk-xxx' })).toBe(true); + }); + + it('returns true when provider is server-configured', () => { + expect(isProviderUsable({ requiresApiKey: true, isServerConfigured: true })).toBe(true); + }); + + it('returns false when requires key but has neither client key nor server config', () => { + expect( + isProviderUsable({ requiresApiKey: true, apiKey: '', isServerConfigured: false }), + ).toBe(false); + }); + + it('returns false for undefined config', () => { + expect(isProviderUsable(undefined)).toBe(false); + }); +}); + +describe('validateProvider', () => { + const cfg = (overrides: Partial = {}): ProviderCfgLike => ({ + requiresApiKey: true, + apiKey: '', + isServerConfigured: false, + ...overrides, + }); + + it('keeps current provider when it is usable', () => { + const configMap = { + 'provider-a': cfg({ isServerConfigured: true }), + 'provider-b': cfg(), + }; + expect(validateProvider('provider-a', configMap, ['provider-a', 'provider-b'])).toBe( + 'provider-a', + ); + }); + + it('falls back to first usable provider when current is unusable', () => { + const configMap = { + 'provider-a': cfg(), + 'provider-b': cfg({ isServerConfigured: true }), + }; + expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-b'); + }); + + it('falls back to provider that does not require API key', () => { + const configMap = { + 'provider-a': cfg(), + 'browser-native': cfg({ requiresApiKey: false }), + }; + expect(validateProvider('provider-a', configMap, ['browser-native'])).toBe('browser-native'); + }); + + it('returns empty string when no fallback is usable', () => { + const configMap = { + 'provider-a': cfg(), + 'provider-b': cfg(), + }; + expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe(''); + }); + + it('returns current id unchanged when it is empty', () => { + const configMap = { 'provider-a': cfg({ isServerConfigured: true }) }; + expect(validateProvider('', configMap, ['provider-a'])).toBe(''); + }); +}); + +describe('validateModel', () => { + it('keeps model when still in available list', () => { + expect(validateModel('gpt-4o', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe('gpt-4o'); + }); + + it('falls back to first model when current is not in list', () => { + expect(validateModel('gpt-4-turbo', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe( + 'gpt-4o', + ); + }); + + it('returns empty string when list is empty', () => { + expect(validateModel('gpt-4o', [])).toBe(''); + }); + + it('returns current id unchanged when it is empty', () => { + expect(validateModel('', [{ id: 'gpt-4o' }])).toBe(''); + }); +}); From 80d6b1895ec6a0c8aeda75fa977023964026828f Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 11:54:14 +0800 Subject: [PATCH 02/12] test: add failing integration tests for all provider stale selection Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/store/settings-validation.ts | 33 ++ tests/store/settings-server-sync.test.ts | 377 ++++++++++++++++++++--- 2 files changed, 363 insertions(+), 47 deletions(-) create mode 100644 lib/store/settings-validation.ts diff --git a/lib/store/settings-validation.ts b/lib/store/settings-validation.ts new file mode 100644 index 00000000..7064adba --- /dev/null +++ b/lib/store/settings-validation.ts @@ -0,0 +1,33 @@ +/** + * Provider selection validation utilities. + * + * Pure functions used by fetchServerProviders() to detect and fix + * stale provider/model selections after server config changes. + */ + +export type ProviderCfgLike = { + isServerConfigured?: boolean; + requiresApiKey?: boolean; + apiKey?: string; +}; + +// Stub implementations — tests should fail on assertions, not on import + +export function isProviderUsable(_cfg: ProviderCfgLike | undefined): boolean { + return false; +} + +export function validateProvider( + _currentId: T | '', + _configMap: Partial>, + _fallbackOrder: T[], +): T | '' { + return '' as T; +} + +export function validateModel( + _currentModelId: string, + _availableModels: Array<{ id: string }>, +): string { + return ''; +} diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index 92328d1d..cd1f2c77 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -44,18 +44,55 @@ vi.mock('@/lib/ai/providers', () => ({ })); vi.mock('@/lib/audio/constants', () => ({ - ASR_PROVIDERS: {}, - DEFAULT_TTS_VOICES: {}, + TTS_PROVIDERS: { + 'openai-tts': { id: 'openai-tts', requiresApiKey: true }, + 'azure-tts': { id: 'azure-tts', requiresApiKey: true }, + 'browser-native-tts': { id: 'browser-native-tts', requiresApiKey: false }, + }, + ASR_PROVIDERS: { + 'openai-whisper': { id: 'openai-whisper', requiresApiKey: true }, + 'browser-native': { id: 'browser-native', requiresApiKey: false }, + }, + DEFAULT_TTS_VOICES: { 'openai-tts': 'alloy' }, })); vi.mock('@/lib/audio/types', () => ({})); +vi.mock('@/lib/pdf/constants', () => ({ + PDF_PROVIDERS: { + unpdf: { id: 'unpdf', requiresApiKey: false }, + mineru: { id: 'mineru', requiresApiKey: false }, + }, +})); + vi.mock('@/lib/media/image-providers', () => ({ - IMAGE_PROVIDERS: {}, + IMAGE_PROVIDERS: { + seedream: { + id: 'seedream', + requiresApiKey: true, + models: [{ id: 'doubao-seedream-5-0-260128', name: 'Seedream 5.0' }], + }, + 'qwen-image': { + id: 'qwen-image', + requiresApiKey: true, + models: [{ id: 'qwen-image-max', name: 'Qwen Image Max' }], + }, + }, })); vi.mock('@/lib/media/video-providers', () => ({ - VIDEO_PROVIDERS: {}, + VIDEO_PROVIDERS: { + seedance: { + id: 'seedance', + requiresApiKey: true, + models: [{ id: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro' }], + }, + kling: { + id: 'kling', + requiresApiKey: true, + models: [{ id: 'kling-v2-6', name: 'Kling V2' }], + }, + }, })); vi.mock('@/lib/logger', () => ({ @@ -83,25 +120,30 @@ vi.stubGlobal('localStorage', { // Helpers // --------------------------------------------------------------------------- -/** Build a standard /api/server-providers response */ -function serverResponse(providers: Record = {}) { - return { - providers, - tts: {}, - asr: {}, - pdf: {}, - image: {}, - video: {}, - webSearch: {}, - }; +/** Full server response shape */ +interface MockServerResponse { + providers?: Record; + tts?: Record; + asr?: Record; + pdf?: Record; + image?: Record>; + video?: Record>; + webSearch?: Record; } -function mockServerProviders( - providers: Record = {}, -) { +function mockServerResponse(overrides: MockServerResponse = {}) { mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => serverResponse(providers), + json: async () => ({ + providers: {}, + tts: {}, + asr: {}, + pdf: {}, + image: {}, + video: {}, + webSearch: {}, + ...overrides, + }), }); } @@ -125,9 +167,9 @@ describe('fetchServerProviders — provider availability sync', () => { it('filters models to only those the server allows', async () => { const store = await getStore(); - mockServerProviders({ + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] }, - }); + } }); await store.getState().fetchServerProviders(); @@ -140,9 +182,9 @@ describe('fetchServerProviders — provider availability sync', () => { it('keeps all models when server provides no model restriction', async () => { const store = await getStore(); - mockServerProviders({ + mockServerResponse({ providers: { openai: {}, // no models field = no restriction - }); + } }); await store.getState().fetchServerProviders(); @@ -156,9 +198,9 @@ describe('fetchServerProviders — provider availability sync', () => { const store = await getStore(); // Round 1: server allows two models - mockServerProviders({ + mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, - }); + } }); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.models.map((m) => m.id)).toEqual([ 'gpt-4o', @@ -166,9 +208,9 @@ describe('fetchServerProviders — provider availability sync', () => { ]); // Round 2: server removes gpt-4o-mini - mockServerProviders({ + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] }, - }); + } }); await store.getState().fetchServerProviders(); const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id); expect(modelIds).toEqual(['gpt-4o']); @@ -179,9 +221,9 @@ describe('fetchServerProviders — provider availability sync', () => { it('marks provider as server-configured when present in response', async () => { const store = await getStore(); - mockServerProviders({ + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] }, - }); + } }); await store.getState().fetchServerProviders(); @@ -192,19 +234,19 @@ describe('fetchServerProviders — provider availability sync', () => { const store = await getStore(); // Round 1: openai is server-configured - mockServerProviders({ openai: { models: ['gpt-4o'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); // Round 2: openai is no longer in server response - mockServerProviders({}); + mockServerResponse({}); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.isServerConfigured).toBe(false); }); it('provider without client key and not server-configured has no usable path', async () => { const store = await getStore(); - mockServerProviders({}); // no server providers + mockServerResponse({}); // no server providers await store.getState().fetchServerProviders(); @@ -221,10 +263,10 @@ describe('fetchServerProviders — provider availability sync', () => { it('handles mixed provider state: one configured, one not', async () => { const store = await getStore(); - mockServerProviders({ + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] }, // anthropic not in response - }); + } }); await store.getState().fetchServerProviders(); @@ -236,9 +278,9 @@ describe('fetchServerProviders — provider availability sync', () => { it('stores serverModels metadata for downstream filtering', async () => { const store = await getStore(); - mockServerProviders({ + mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, - }); + } }); await store.getState().fetchServerProviders(); @@ -248,11 +290,11 @@ describe('fetchServerProviders — provider availability sync', () => { it('clears serverModels when provider removed from server', async () => { const store = await getStore(); - mockServerProviders({ openai: { models: ['gpt-4o'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o']); - mockServerProviders({}); + mockServerResponse({}); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.serverModels).toBeUndefined(); }); @@ -271,11 +313,11 @@ describe('fetchServerProviders — provider availability sync', () => { expect(store.getState().modelId).toBe('gpt-4o-mini'); // Server drops gpt-4o-mini - mockServerProviders({ openai: { models: ['gpt-4o'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); await store.getState().fetchServerProviders(); // modelId should be cleared, not silently kept as a stale value - expect(store.getState().modelId).toBe(''); + expect(store.getState().modelId).toBe('gpt-4o'); }); it('clears providerId when entire provider loses server config and has no client key', async () => { @@ -283,12 +325,12 @@ describe('fetchServerProviders — provider availability sync', () => { // User on a server-only provider (no client key) store.getState().setModel('openai', 'gpt-4o'); - mockServerProviders({ openai: { models: ['gpt-4o'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); // Server removes openai entirely — no client key either - mockServerProviders({}); + mockServerResponse({}); await store.getState().fetchServerProviders(); // Provider is unusable → selection should be cleared @@ -300,23 +342,23 @@ describe('fetchServerProviders — provider availability sync', () => { const store = await getStore(); // Round 1: user picks gpt-4-turbo - mockServerProviders({ openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } } }); await store.getState().fetchServerProviders(); store.getState().setModel('openai', 'gpt-4-turbo'); // Round 2: server narrows to gpt-4o only - mockServerProviders({ openai: { models: ['gpt-4o'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); await store.getState().fetchServerProviders(); // Selection should be cleared, not left pointing to unavailable model - expect(store.getState().modelId).toBe(''); + expect(store.getState().modelId).toBe('gpt-4o'); }); it('keeps modelId when selected model is still available after server sync', async () => { const store = await getStore(); store.getState().setModel('openai', 'gpt-4o'); - mockServerProviders({ openai: { models: ['gpt-4o', 'gpt-4o-mini'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini'] } } }); await store.getState().fetchServerProviders(); // gpt-4o is still available — selection should be preserved @@ -330,7 +372,7 @@ describe('fetchServerProviders — provider availability sync', () => { const store = await getStore(); // First, set up a known state - mockServerProviders({ openai: { models: ['gpt-4o'] } }); + mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } }); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true); @@ -351,3 +393,244 @@ describe('fetchServerProviders — provider availability sync', () => { await expect(store.getState().fetchServerProviders()).resolves.not.toThrow(); }); }); + +describe('fetchServerProviders — TTS stale selection', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('falls back to browser-native-tts when selected TTS provider loses server config', async () => { + const store = await getStore(); + + mockServerResponse({ tts: { 'openai-tts': {} } }); + await store.getState().fetchServerProviders(); + store.getState().setTTSProvider('openai-tts'); + expect(store.getState().ttsProviderId).toBe('openai-tts'); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().ttsProviderId).toBe('browser-native-tts'); + }); + + it('falls back to remaining server TTS provider when selected one is removed', async () => { + const store = await getStore(); + + mockServerResponse({ tts: { 'openai-tts': {}, 'azure-tts': {} } }); + await store.getState().fetchServerProviders(); + store.getState().setTTSProvider('openai-tts'); + + mockServerResponse({ tts: { 'azure-tts': {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().ttsProviderId).toBe('azure-tts'); + }); + + it('keeps TTS provider when it is still server-configured', async () => { + const store = await getStore(); + + mockServerResponse({ tts: { 'openai-tts': {} } }); + await store.getState().fetchServerProviders(); + store.getState().setTTSProvider('openai-tts'); + + mockServerResponse({ tts: { 'openai-tts': {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().ttsProviderId).toBe('openai-tts'); + }); +}); + +describe('fetchServerProviders — ASR stale selection', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('falls back to browser-native when selected ASR provider loses server config', async () => { + const store = await getStore(); + + mockServerResponse({ asr: { 'openai-whisper': {} } }); + await store.getState().fetchServerProviders(); + store.getState().setASRProvider('openai-whisper'); + expect(store.getState().asrProviderId).toBe('openai-whisper'); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().asrProviderId).toBe('browser-native'); + }); + + it('keeps ASR provider when it is still server-configured', async () => { + const store = await getStore(); + + mockServerResponse({ asr: { 'openai-whisper': {} } }); + await store.getState().fetchServerProviders(); + store.getState().setASRProvider('openai-whisper'); + + mockServerResponse({ asr: { 'openai-whisper': {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().asrProviderId).toBe('openai-whisper'); + }); +}); + +describe('fetchServerProviders — PDF stale selection', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('falls back to unpdf when mineru loses server config', async () => { + const store = await getStore(); + + mockServerResponse({ pdf: { mineru: {} } }); + await store.getState().fetchServerProviders(); + store.getState().setPDFProvider('mineru'); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().pdfProviderId).toBe('unpdf'); + }); +}); + +describe('fetchServerProviders — Image stale selection', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('clears imageProviderId and imageModelId when provider loses server config', async () => { + const store = await getStore(); + + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + store.getState().setImageProvider('seedream'); + store.getState().setImageModelId('doubao-seedream-5-0-260128'); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().imageProviderId).toBe(''); + expect(store.getState().imageModelId).toBe(''); + }); + + it('falls back to another server-configured image provider', async () => { + const store = await getStore(); + + mockServerResponse({ image: { seedream: {}, 'qwen-image': {} } }); + await store.getState().fetchServerProviders(); + store.getState().setImageProvider('seedream'); + store.getState().setImageModelId('doubao-seedream-5-0-260128'); + + mockServerResponse({ image: { 'qwen-image': {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().imageProviderId).toBe('qwen-image'); + expect(store.getState().imageModelId).toBe('qwen-image-max'); + }); +}); + +describe('fetchServerProviders — Video stale selection', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('clears videoProviderId and videoModelId when provider loses server config', async () => { + const store = await getStore(); + + mockServerResponse({ video: { seedance: {} } }); + await store.getState().fetchServerProviders(); + store.getState().setVideoProvider('seedance'); + store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215'); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().videoProviderId).toBe(''); + expect(store.getState().videoModelId).toBe(''); + }); + + it('falls back to another server-configured video provider', async () => { + const store = await getStore(); + + mockServerResponse({ video: { seedance: {}, kling: {} } }); + await store.getState().fetchServerProviders(); + store.getState().setVideoProvider('seedance'); + store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215'); + + mockServerResponse({ video: { kling: {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().videoProviderId).toBe('kling'); + expect(store.getState().videoModelId).toBe('kling-v2-6'); + }); +}); + +describe('fetchServerProviders — LLM cross-provider fallback', () => { + beforeEach(() => { + vi.resetModules(); + storage.clear(); + mockFetch.mockReset(); + }); + + async function getStore() { + const { useSettingsStore } = await import('@/lib/store/settings'); + return useSettingsStore; + } + + it('falls back to another server-configured LLM provider when current becomes unusable', async () => { + const store = await getStore(); + + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o'] }, + anthropic: { models: ['claude-sonnet-4-6'] }, + }, + }); + await store.getState().fetchServerProviders(); + store.getState().setModel('openai', 'gpt-4o'); + + mockServerResponse({ + providers: { + anthropic: { models: ['claude-sonnet-4-6'] }, + }, + }); + await store.getState().fetchServerProviders(); + + expect(store.getState().providerId).toBe('anthropic'); + expect(store.getState().modelId).toBe('claude-sonnet-4-6'); + }); +}); From 13c8d486a5fae0616eeb926e571d14cac912653f Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 12:00:29 +0800 Subject: [PATCH 03/12] feat: implement isProviderUsable, validateProvider, validateModel --- lib/store/settings-validation.ts | 40 +++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/lib/store/settings-validation.ts b/lib/store/settings-validation.ts index 7064adba..c94ea99b 100644 --- a/lib/store/settings-validation.ts +++ b/lib/store/settings-validation.ts @@ -11,23 +11,41 @@ export type ProviderCfgLike = { apiKey?: string; }; -// Stub implementations — tests should fail on assertions, not on import - -export function isProviderUsable(_cfg: ProviderCfgLike | undefined): boolean { - return false; +/** Check whether a provider has a usable path (server config, client key, or no key needed). */ +export function isProviderUsable(cfg: ProviderCfgLike | undefined): boolean { + if (!cfg) return false; + if (!cfg.requiresApiKey) return true; + return !!cfg.apiKey || !!cfg.isServerConfigured; } +/** + * Validate current provider selection against updated config. + * Returns the current ID if still usable, otherwise the first usable + * provider from fallbackOrder, or '' if nothing is available. + */ export function validateProvider( - _currentId: T | '', - _configMap: Partial>, - _fallbackOrder: T[], + currentId: T | '', + configMap: Partial>, + fallbackOrder: T[], ): T | '' { - return '' as T; + if (!currentId) return currentId; + if (isProviderUsable(configMap[currentId])) return currentId; + + for (const id of fallbackOrder) { + if (isProviderUsable(configMap[id])) return id; + } + return ''; } +/** + * Validate current model selection against available models list. + * Falls back to first available model, or '' if list is empty. + */ export function validateModel( - _currentModelId: string, - _availableModels: Array<{ id: string }>, + currentModelId: string, + availableModels: Array<{ id: string }>, ): string { - return ''; + if (!currentModelId) return currentModelId; + if (availableModels.some((m) => m.id === currentModelId)) return currentModelId; + return availableModels[0]?.id ?? ''; } From 2d1c87d2d66bb3a767decefcbedcbae7e65fdbf5 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 12:13:58 +0800 Subject: [PATCH 04/12] feat: validate all provider selections after server sync (#263) Replace inline LLM-only validation from PR #232 with unified validation for all provider types (LLM, TTS, ASR, PDF, Image, Video). - Extract isProviderUsable, validateProvider, validateModel into lib/store/settings-validation.ts - Usability checks server config and client API key, not requiresApiKey - Auto-select fallback to usable providers instead of clearing to empty - TTS/ASR/PDF fall back to local defaults (browser-native, unpdf) - Image/Video clear to empty when no server provider available Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/store/settings-validation.ts | 11 +- lib/store/settings.ts | 135 ++++++++++++++++++----- tests/store/settings-server-sync.test.ts | 62 +++++++---- tests/store/settings-validation.test.ts | 67 +++++++---- 4 files changed, 198 insertions(+), 77 deletions(-) diff --git a/lib/store/settings-validation.ts b/lib/store/settings-validation.ts index c94ea99b..020664ac 100644 --- a/lib/store/settings-validation.ts +++ b/lib/store/settings-validation.ts @@ -7,26 +7,25 @@ export type ProviderCfgLike = { isServerConfigured?: boolean; - requiresApiKey?: boolean; apiKey?: string; }; -/** Check whether a provider has a usable path (server config, client key, or no key needed). */ +/** Check whether a provider has a usable path (server config or client key). */ export function isProviderUsable(cfg: ProviderCfgLike | undefined): boolean { if (!cfg) return false; - if (!cfg.requiresApiKey) return true; - return !!cfg.apiKey || !!cfg.isServerConfigured; + return !!cfg.isServerConfigured || !!cfg.apiKey; } /** * Validate current provider selection against updated config. * Returns the current ID if still usable, otherwise the first usable - * provider from fallbackOrder, or '' if nothing is available. + * provider from fallbackOrder, or defaultId if provided, or ''. */ export function validateProvider( currentId: T | '', configMap: Partial>, fallbackOrder: T[], + defaultId?: T, ): T | '' { if (!currentId) return currentId; if (isProviderUsable(configMap[currentId])) return currentId; @@ -34,7 +33,7 @@ export function validateProvider( for (const id of fallbackOrder) { if (isProviderUsable(configMap[id])) return id; } - return ''; + return defaultId ?? ''; } /** diff --git a/lib/store/settings.ts b/lib/store/settings.ts index ebcce40f..ae3013b4 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -18,6 +18,7 @@ import { VIDEO_PROVIDERS } from '@/lib/media/video-providers'; import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants'; import type { WebSearchProviderId } from '@/lib/web-search/types'; import { createLogger } from '@/lib/logger'; +import { validateProvider, validateModel } from '@/lib/store/settings-validation'; const log = createLogger('Settings'); @@ -755,27 +756,6 @@ export const useSettingsStore = create()( } } - // Validate current LLM selection against updated provider config - let clearedProviderId: ProviderId | undefined; - let clearedModelId: string | undefined; - if (state.providerId) { - const selectedCfg = newProvidersConfig[state.providerId as ProviderId]; - const isUsable = - selectedCfg && - (!selectedCfg.requiresApiKey || - !!selectedCfg.apiKey || - !!selectedCfg.isServerConfigured); - if (!isUsable) { - clearedProviderId = '' as ProviderId; - clearedModelId = ''; - } else if ( - state.modelId && - !selectedCfg.models.some((m) => m.id === state.modelId) - ) { - clearedModelId = ''; - } - } - // Merge TTS providers const newTTSConfig = { ...state.ttsProvidersConfig }; for (const pid of Object.keys(newTTSConfig)) { @@ -915,6 +895,89 @@ export const useSettingsStore = create()( } } + // === Validate current selections against updated configs === + const llmFallback = [ + ...Object.entries(newProvidersConfig) + .filter(([, c]) => c.isServerConfigured) + .map(([id]) => id as ProviderId), + ...Object.entries(newProvidersConfig) + .filter(([, c]) => !c.isServerConfigured && !!c.apiKey) + .map(([id]) => id as ProviderId), + ]; + const ttsFallback = Object.keys(newTTSConfig).filter( + (id) => newTTSConfig[id as TTSProviderId]?.isServerConfigured, + ) as TTSProviderId[]; + const asrFallback = Object.keys(newASRConfig).filter( + (id) => newASRConfig[id as ASRProviderId]?.isServerConfigured, + ) as ASRProviderId[]; + const pdfFallback = Object.keys(newPDFConfig).filter( + (id) => newPDFConfig[id as PDFProviderId]?.isServerConfigured, + ) as PDFProviderId[]; + const imageFallback = Object.keys(newImageConfig).filter( + (id) => newImageConfig[id as ImageProviderId]?.isServerConfigured, + ) as ImageProviderId[]; + const videoFallback = Object.keys(newVideoConfig).filter( + (id) => newVideoConfig[id as VideoProviderId]?.isServerConfigured, + ) as VideoProviderId[]; + + const validLLMProvider = validateProvider( + state.providerId, + newProvidersConfig, + llmFallback, + ); + const validTTSProvider = validateProvider( + state.ttsProviderId, + newTTSConfig, + ttsFallback, + 'browser-native-tts' as TTSProviderId, + ); + const validASRProvider = validateProvider( + state.asrProviderId, + newASRConfig, + asrFallback, + 'browser-native' as ASRProviderId, + ); + const validPDFProvider = validateProvider( + state.pdfProviderId, + newPDFConfig, + pdfFallback, + 'unpdf' as PDFProviderId, + ); + const validImageProvider = validateProvider( + state.imageProviderId, + newImageConfig, + imageFallback, + ); + const validVideoProvider = validateProvider( + state.videoProviderId, + newVideoConfig, + videoFallback, + ); + + const validLLMModel = validLLMProvider + ? validateModel( + state.modelId, + newProvidersConfig[validLLMProvider as ProviderId]?.models ?? [], + ) + : ''; + const validImageModel = validImageProvider + ? validateModel( + state.imageModelId, + IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? [], + ) + : ''; + const validVideoModel = validVideoProvider + ? validateModel( + state.videoModelId, + VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? [], + ) + : ''; + + const validTTSVoice = + validTTSProvider !== state.ttsProviderId + ? DEFAULT_TTS_VOICES[validTTSProvider as TTSProviderId] || 'default' + : state.ttsVoice; + // === Auto-select / auto-enable (only on first run) === let autoTtsProvider: TTSProviderId | undefined; let autoTtsVoice: string | undefined; @@ -981,10 +1044,10 @@ export const useSettingsStore = create()( } } - // LLM auto-select: when modelId is empty + // LLM auto-select: only on true first load (no provider selected yet) let autoProviderId: ProviderId | undefined; let autoModelId: string | undefined; - if (!state.modelId) { + if (!state.providerId && !state.modelId) { for (const [pid, cfg] of Object.entries(newProvidersConfig)) { if (cfg.isServerConfigured) { // Prefer server-restricted models, fall back to built-in list @@ -1010,6 +1073,30 @@ export const useSettingsStore = create()( videoProvidersConfig: newVideoConfig, webSearchProvidersConfig: newWebSearchConfig, autoConfigApplied: true, + // Validated selections + ...(validLLMProvider !== state.providerId && { + providerId: validLLMProvider as ProviderId, + }), + ...(validLLMModel !== state.modelId && { modelId: validLLMModel }), + ...(validTTSProvider !== state.ttsProviderId && { + ttsProviderId: validTTSProvider as TTSProviderId, + ttsVoice: validTTSVoice, + }), + ...(validASRProvider !== state.asrProviderId && { + asrProviderId: validASRProvider as ASRProviderId, + }), + ...(validPDFProvider !== state.pdfProviderId && { + pdfProviderId: validPDFProvider as PDFProviderId, + }), + ...(validImageProvider !== state.imageProviderId && { + imageProviderId: validImageProvider as ImageProviderId, + imageModelId: validImageModel, + }), + ...(validVideoProvider !== state.videoProviderId && { + videoProviderId: validVideoProvider as VideoProviderId, + videoModelId: validVideoModel, + }), + // First-run auto-select (autoConfigApplied guard) ...(autoPdfProvider && { pdfProviderId: autoPdfProvider }), ...(autoTtsProvider && { ttsProviderId: autoTtsProvider, @@ -1032,8 +1119,6 @@ export const useSettingsStore = create()( }), ...(autoProviderId && { providerId: autoProviderId }), ...(autoModelId && { modelId: autoModelId }), - ...(clearedProviderId !== undefined && { providerId: clearedProviderId }), - ...(clearedModelId !== undefined && { modelId: clearedModelId }), }; }); } catch (e) { diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index cd1f2c77..09b4fece 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -167,9 +167,11 @@ describe('fetchServerProviders — provider availability sync', () => { it('filters models to only those the server allows', async () => { const store = await getStore(); - mockServerResponse({ providers: { - openai: { models: ['gpt-4o'] }, - } }); + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o'] }, + }, + }); await store.getState().fetchServerProviders(); @@ -182,9 +184,11 @@ describe('fetchServerProviders — provider availability sync', () => { it('keeps all models when server provides no model restriction', async () => { const store = await getStore(); - mockServerResponse({ providers: { - openai: {}, // no models field = no restriction - } }); + mockServerResponse({ + providers: { + openai: {}, // no models field = no restriction + }, + }); await store.getState().fetchServerProviders(); @@ -198,9 +202,11 @@ describe('fetchServerProviders — provider availability sync', () => { const store = await getStore(); // Round 1: server allows two models - mockServerResponse({ providers: { - openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, - } }); + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, + }, + }); await store.getState().fetchServerProviders(); expect(store.getState().providersConfig.openai.models.map((m) => m.id)).toEqual([ 'gpt-4o', @@ -208,9 +214,11 @@ describe('fetchServerProviders — provider availability sync', () => { ]); // Round 2: server removes gpt-4o-mini - mockServerResponse({ providers: { - openai: { models: ['gpt-4o'] }, - } }); + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o'] }, + }, + }); await store.getState().fetchServerProviders(); const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id); expect(modelIds).toEqual(['gpt-4o']); @@ -221,9 +229,11 @@ describe('fetchServerProviders — provider availability sync', () => { it('marks provider as server-configured when present in response', async () => { const store = await getStore(); - mockServerResponse({ providers: { - openai: { models: ['gpt-4o'] }, - } }); + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o'] }, + }, + }); await store.getState().fetchServerProviders(); @@ -263,10 +273,12 @@ describe('fetchServerProviders — provider availability sync', () => { it('handles mixed provider state: one configured, one not', async () => { const store = await getStore(); - mockServerResponse({ providers: { - openai: { models: ['gpt-4o'] }, - // anthropic not in response - } }); + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o'] }, + // anthropic not in response + }, + }); await store.getState().fetchServerProviders(); @@ -278,9 +290,11 @@ describe('fetchServerProviders — provider availability sync', () => { it('stores serverModels metadata for downstream filtering', async () => { const store = await getStore(); - mockServerResponse({ providers: { - openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, - } }); + mockServerResponse({ + providers: { + openai: { models: ['gpt-4o', 'gpt-4o-mini'] }, + }, + }); await store.getState().fetchServerProviders(); @@ -342,7 +356,9 @@ describe('fetchServerProviders — provider availability sync', () => { const store = await getStore(); // Round 1: user picks gpt-4-turbo - mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } } }); + mockServerResponse({ + providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } }, + }); await store.getState().fetchServerProviders(); store.getState().setModel('openai', 'gpt-4-turbo'); diff --git a/tests/store/settings-validation.test.ts b/tests/store/settings-validation.test.ts index a80019cc..d4a8caca 100644 --- a/tests/store/settings-validation.test.ts +++ b/tests/store/settings-validation.test.ts @@ -7,45 +7,56 @@ import { } from '@/lib/store/settings-validation'; describe('isProviderUsable', () => { - it('returns true when provider does not require API key', () => { - expect(isProviderUsable({ requiresApiKey: false })).toBe(true); - }); - it('returns true when provider has client API key', () => { - expect(isProviderUsable({ requiresApiKey: true, apiKey: 'sk-xxx' })).toBe(true); + expect(isProviderUsable({ apiKey: 'sk-xxx' })).toBe(true); }); it('returns true when provider is server-configured', () => { - expect(isProviderUsable({ requiresApiKey: true, isServerConfigured: true })).toBe(true); + expect(isProviderUsable({ isServerConfigured: true })).toBe(true); + }); + + it('returns true when provider has both client key and server config', () => { + expect(isProviderUsable({ apiKey: 'sk-xxx', isServerConfigured: true })).toBe(true); }); - it('returns false when requires key but has neither client key nor server config', () => { - expect( - isProviderUsable({ requiresApiKey: true, apiKey: '', isServerConfigured: false }), - ).toBe(false); + it('returns false when has neither client key nor server config', () => { + expect(isProviderUsable({ apiKey: '', isServerConfigured: false })).toBe(false); + }); + + it('returns false when apiKey is empty and not server-configured', () => { + expect(isProviderUsable({ apiKey: '' })).toBe(false); }); it('returns false for undefined config', () => { expect(isProviderUsable(undefined)).toBe(false); }); + + it('returns false for empty object', () => { + expect(isProviderUsable({})).toBe(false); + }); }); describe('validateProvider', () => { const cfg = (overrides: Partial = {}): ProviderCfgLike => ({ - requiresApiKey: true, apiKey: '', isServerConfigured: false, ...overrides, }); - it('keeps current provider when it is usable', () => { + it('keeps current provider when it is server-configured', () => { const configMap = { 'provider-a': cfg({ isServerConfigured: true }), 'provider-b': cfg(), }; - expect(validateProvider('provider-a', configMap, ['provider-a', 'provider-b'])).toBe( - 'provider-a', - ); + expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-a'); + }); + + it('keeps current provider when it has client API key', () => { + const configMap = { + 'provider-a': cfg({ apiKey: 'sk-xxx' }), + 'provider-b': cfg(), + }; + expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-a'); }); it('falls back to first usable provider when current is unusable', () => { @@ -56,20 +67,32 @@ describe('validateProvider', () => { expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe('provider-b'); }); - it('falls back to provider that does not require API key', () => { + it('returns empty string when no fallback is usable and no default', () => { const configMap = { 'provider-a': cfg(), - 'browser-native': cfg({ requiresApiKey: false }), + 'provider-b': cfg(), }; - expect(validateProvider('provider-a', configMap, ['browser-native'])).toBe('browser-native'); + expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe(''); }); - it('returns empty string when no fallback is usable', () => { + it('falls back to defaultId when no fallback is usable', () => { const configMap = { 'provider-a': cfg(), 'provider-b': cfg(), }; - expect(validateProvider('provider-a', configMap, ['provider-b'])).toBe(''); + expect(validateProvider('provider-a', configMap, ['provider-b'], 'browser-native')).toBe( + 'browser-native', + ); + }); + + it('prefers usable fallback over defaultId', () => { + const configMap = { + 'provider-a': cfg(), + 'provider-b': cfg({ isServerConfigured: true }), + }; + expect(validateProvider('provider-a', configMap, ['provider-b'], 'browser-native')).toBe( + 'provider-b', + ); }); it('returns current id unchanged when it is empty', () => { @@ -84,9 +107,7 @@ describe('validateModel', () => { }); it('falls back to first model when current is not in list', () => { - expect(validateModel('gpt-4-turbo', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe( - 'gpt-4o', - ); + expect(validateModel('gpt-4-turbo', [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }])).toBe('gpt-4o'); }); it('returns empty string when list is empty', () => { From 2b73dfbe5d4a62def5b52084f6c6b333fbb2d348 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 12:40:26 +0800 Subject: [PATCH 05/12] fix: remove setup-needed prompt from header Server-configured providers auto-select on first load, making the manual configuration prompt unnecessary. --- app/page.tsx | 19 +------------------ components/header.tsx | 21 +-------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 80dfbd85..68719c48 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -83,13 +83,11 @@ function HomePage() { // Model setup state const currentModelId = useSettingsStore((s) => s.modelId); - const [storeHydrated, setStoreHydrated] = useState(false); const [recentOpen, setRecentOpen] = useState(true); // Hydrate client-only state after mount (avoids SSR mismatch) /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ useEffect(() => { - setStoreHydrated(true); try { const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY); if (saved !== null) setRecentOpen(saved !== 'false'); @@ -125,7 +123,6 @@ function HomePage() { } } - const needsSetup = storeHydrated && !currentModelId; const [languageOpen, setLanguageOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false); const [error, setError] = useState(null); @@ -441,24 +438,10 @@ function HomePage() {
- {needsSetup && ( - <> - - - - - - {t('settings.setupNeeded')} - - - )}
s.modelId); - const needsSetup = !currentModelId; - // Export const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX(); const [exportMenuOpen, setExportMenuOpen] = useState(false); @@ -216,24 +211,10 @@ export function Header({ currentSceneTitle }: HeaderProps) {
- {needsSetup && ( - <> - - - - - - {t('settings.setupNeeded')} - - - )}
From d3c1b6f38e1c3b87807875eef2d0205668d7c082 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 13:05:24 +0800 Subject: [PATCH 06/12] fix: disable image/video generation when no provider is usable Auto-close imageGenerationEnabled/videoGenerationEnabled when server removes all providers. Prevent re-enabling until a provider becomes available. --- lib/store/settings.ts | 32 ++++++++++++-- tests/store/settings-server-sync.test.ts | 54 ++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lib/store/settings.ts b/lib/store/settings.ts index ae3013b4..b1814ce8 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -500,7 +500,7 @@ const migrateFromOldStorage = () => { export const useSettingsStore = create()( persist( - (set) => { + (set, get) => { // Try to migrate from old storage const migratedData = migrateFromOldStorage(); const defaultAudioConfig = getDefaultAudioConfig(); @@ -689,8 +689,26 @@ export const useSettingsStore = create()( })), // Media generation toggle actions - setImageGenerationEnabled: (enabled) => set({ imageGenerationEnabled: enabled }), - setVideoGenerationEnabled: (enabled) => set({ videoGenerationEnabled: enabled }), + setImageGenerationEnabled: (enabled) => { + if (enabled) { + const cfg = get().imageProvidersConfig; + const hasUsable = Object.values(cfg).some( + (c) => c.isServerConfigured || c.apiKey, + ); + if (!hasUsable) return; + } + set({ imageGenerationEnabled: enabled }); + }, + setVideoGenerationEnabled: (enabled) => { + if (enabled) { + const cfg = get().videoProvidersConfig; + const hasUsable = Object.values(cfg).some( + (c) => c.isServerConfigured || c.apiKey, + ); + if (!hasUsable) return; + } + set({ videoGenerationEnabled: enabled }); + }, setTTSEnabled: (enabled) => set({ ttsEnabled: enabled }), setASREnabled: (enabled) => set({ asrEnabled: enabled }), @@ -978,6 +996,12 @@ export const useSettingsStore = create()( ? DEFAULT_TTS_VOICES[validTTSProvider as TTSProviderId] || 'default' : state.ttsVoice; + // Disable image/video generation when no provider is usable + const shouldDisableImage = + !validImageProvider && state.imageGenerationEnabled; + const shouldDisableVideo = + !validVideoProvider && state.videoGenerationEnabled; + // === Auto-select / auto-enable (only on first run) === let autoTtsProvider: TTSProviderId | undefined; let autoTtsVoice: string | undefined; @@ -1096,6 +1120,8 @@ export const useSettingsStore = create()( videoProviderId: validVideoProvider as VideoProviderId, videoModelId: validVideoModel, }), + ...(shouldDisableImage && { imageGenerationEnabled: false }), + ...(shouldDisableVideo && { videoGenerationEnabled: false }), // First-run auto-select (autoConfigApplied guard) ...(autoPdfProvider && { pdfProviderId: autoPdfProvider }), ...(autoTtsProvider && { diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index 09b4fece..4cfe90ac 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -556,6 +556,35 @@ describe('fetchServerProviders — Image stale selection', () => { expect(store.getState().imageModelId).toBe(''); }); + it('disables imageGenerationEnabled when no image provider is usable', async () => { + const store = await getStore(); + + // Server configures seedream, user enables image generation + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + store.getState().setImageProvider('seedream'); + store.getState().setImageGenerationEnabled(true); + expect(store.getState().imageGenerationEnabled).toBe(true); + + // Server removes all image providers + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().imageGenerationEnabled).toBe(false); + }); + + it('prevents enabling image generation when no image provider is usable', async () => { + const store = await getStore(); + + // No server image providers + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + // User tries to enable image generation + store.getState().setImageGenerationEnabled(true); + expect(store.getState().imageGenerationEnabled).toBe(false); + }); + it('falls back to another server-configured image provider', async () => { const store = await getStore(); @@ -599,6 +628,31 @@ describe('fetchServerProviders — Video stale selection', () => { expect(store.getState().videoModelId).toBe(''); }); + it('disables videoGenerationEnabled when no video provider is usable', async () => { + const store = await getStore(); + + mockServerResponse({ video: { seedance: {} } }); + await store.getState().fetchServerProviders(); + store.getState().setVideoProvider('seedance'); + store.getState().setVideoGenerationEnabled(true); + expect(store.getState().videoGenerationEnabled).toBe(true); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + expect(store.getState().videoGenerationEnabled).toBe(false); + }); + + it('prevents enabling video generation when no video provider is usable', async () => { + const store = await getStore(); + + mockServerResponse({}); + await store.getState().fetchServerProviders(); + + store.getState().setVideoGenerationEnabled(true); + expect(store.getState().videoGenerationEnabled).toBe(false); + }); + it('falls back to another server-configured video provider', async () => { const store = await getStore(); From cf865bb1f47aaeaf819f5c190f1093aa704bf7fd Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 13:14:19 +0800 Subject: [PATCH 07/12] fix: auto-recover image/video provider and enable on server restore When server adds image/video providers after empty state, auto-select the first available provider+model and enable generation. When server removes all providers, disable generation and prevent re-enabling. --- lib/store/settings.ts | 38 ++++++++++++++++++++---- tests/store/settings-server-sync.test.ts | 38 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/lib/store/settings.ts b/lib/store/settings.ts index b1814ce8..098a6953 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -961,17 +961,33 @@ export const useSettingsStore = create()( pdfFallback, 'unpdf' as PDFProviderId, ); - const validImageProvider = validateProvider( + let validImageProvider = validateProvider( state.imageProviderId, newImageConfig, imageFallback, ); - const validVideoProvider = validateProvider( + let validVideoProvider = validateProvider( state.videoProviderId, newVideoConfig, videoFallback, ); + // Auto-recover: when provider is empty but server has available ones + let recoveredImageModel = ''; + if (!validImageProvider && imageFallback.length > 0) { + validImageProvider = imageFallback[0]; + const models = + IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models; + if (models?.length) recoveredImageModel = models[0].id; + } + let recoveredVideoModel = ''; + if (!validVideoProvider && videoFallback.length > 0) { + validVideoProvider = videoFallback[0]; + const models = + VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models; + if (models?.length) recoveredVideoModel = models[0].id; + } + const validLLMModel = validLLMProvider ? validateModel( state.modelId, @@ -979,13 +995,15 @@ export const useSettingsStore = create()( ) : ''; const validImageModel = validImageProvider - ? validateModel( + ? recoveredImageModel || + validateModel( state.imageModelId, IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? [], ) : ''; const validVideoModel = validVideoProvider - ? validateModel( + ? recoveredVideoModel || + validateModel( state.videoModelId, VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? [], ) @@ -996,11 +1014,19 @@ export const useSettingsStore = create()( ? DEFAULT_TTS_VOICES[validTTSProvider as TTSProviderId] || 'default' : state.ttsVoice; - // Disable image/video generation when no provider is usable + // Auto-disable/enable image/video generation based on provider availability const shouldDisableImage = !validImageProvider && state.imageGenerationEnabled; + const shouldEnableImage = + !!validImageProvider && + !state.imageGenerationEnabled && + validImageProvider !== state.imageProviderId; const shouldDisableVideo = !validVideoProvider && state.videoGenerationEnabled; + const shouldEnableVideo = + !!validVideoProvider && + !state.videoGenerationEnabled && + validVideoProvider !== state.videoProviderId; // === Auto-select / auto-enable (only on first run) === let autoTtsProvider: TTSProviderId | undefined; @@ -1121,7 +1147,9 @@ export const useSettingsStore = create()( videoModelId: validVideoModel, }), ...(shouldDisableImage && { imageGenerationEnabled: false }), + ...(shouldEnableImage && { imageGenerationEnabled: true }), ...(shouldDisableVideo && { videoGenerationEnabled: false }), + ...(shouldEnableVideo && { videoGenerationEnabled: true }), // First-run auto-select (autoConfigApplied guard) ...(autoPdfProvider && { pdfProviderId: autoPdfProvider }), ...(autoTtsProvider && { diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index 4cfe90ac..d6b2fa71 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -599,6 +599,25 @@ describe('fetchServerProviders — Image stale selection', () => { expect(store.getState().imageProviderId).toBe('qwen-image'); expect(store.getState().imageModelId).toBe('qwen-image-max'); }); + + it('auto-selects provider and model when server adds image provider after empty state', async () => { + const store = await getStore(); + + // Start with no image providers — selection is empty, generation disabled + mockServerResponse({}); + await store.getState().fetchServerProviders(); + expect(store.getState().imageProviderId).toBe(''); + expect(store.getState().imageModelId).toBe(''); + expect(store.getState().imageGenerationEnabled).toBe(false); + + // Server adds seedream + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().imageProviderId).toBe('seedream'); + expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); + expect(store.getState().imageGenerationEnabled).toBe(true); + }); }); describe('fetchServerProviders — Video stale selection', () => { @@ -667,6 +686,25 @@ describe('fetchServerProviders — Video stale selection', () => { expect(store.getState().videoProviderId).toBe('kling'); expect(store.getState().videoModelId).toBe('kling-v2-6'); }); + + it('auto-selects provider and model when server adds video provider after empty state', async () => { + const store = await getStore(); + + // Start with no video providers — generation disabled + mockServerResponse({}); + await store.getState().fetchServerProviders(); + expect(store.getState().videoProviderId).toBe(''); + expect(store.getState().videoModelId).toBe(''); + expect(store.getState().videoGenerationEnabled).toBe(false); + + // Server adds seedance + mockServerResponse({ video: { seedance: {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().videoProviderId).toBe('seedance'); + expect(store.getState().videoModelId).toBe('doubao-seedance-1-5-pro-251215'); + expect(store.getState().videoGenerationEnabled).toBe(true); + }); }); describe('fetchServerProviders — LLM cross-provider fallback', () => { From 00736357c9d9bb63bc66cf89b088465fda2a9c4e Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 13:24:19 +0800 Subject: [PATCH 08/12] fix: auto-enable image/video and select model when server has provider Fix cases where provider matches default but generation stays disabled, and where model is empty after provider recovery. Split provider and model spreads so model updates independently of provider changes. --- lib/store/settings.ts | 18 +++++++------ tests/store/settings-server-sync.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 098a6953..0448f1b1 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -999,14 +999,16 @@ export const useSettingsStore = create()( validateModel( state.imageModelId, IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? [], - ) + ) || + (IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models?.[0]?.id ?? '') : ''; const validVideoModel = validVideoProvider ? recoveredVideoModel || validateModel( state.videoModelId, VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? [], - ) + ) || + (VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models?.[0]?.id ?? '') : ''; const validTTSVoice = @@ -1018,15 +1020,11 @@ export const useSettingsStore = create()( const shouldDisableImage = !validImageProvider && state.imageGenerationEnabled; const shouldEnableImage = - !!validImageProvider && - !state.imageGenerationEnabled && - validImageProvider !== state.imageProviderId; + imageFallback.length > 0 && !state.imageGenerationEnabled; const shouldDisableVideo = !validVideoProvider && state.videoGenerationEnabled; const shouldEnableVideo = - !!validVideoProvider && - !state.videoGenerationEnabled && - validVideoProvider !== state.videoProviderId; + videoFallback.length > 0 && !state.videoGenerationEnabled; // === Auto-select / auto-enable (only on first run) === let autoTtsProvider: TTSProviderId | undefined; @@ -1140,10 +1138,14 @@ export const useSettingsStore = create()( }), ...(validImageProvider !== state.imageProviderId && { imageProviderId: validImageProvider as ImageProviderId, + }), + ...(validImageModel !== state.imageModelId && { imageModelId: validImageModel, }), ...(validVideoProvider !== state.videoProviderId && { videoProviderId: validVideoProvider as VideoProviderId, + }), + ...(validVideoModel !== state.videoModelId && { videoModelId: validVideoModel, }), ...(shouldDisableImage && { imageGenerationEnabled: false }), diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index d6b2fa71..854f0fba 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -618,6 +618,39 @@ describe('fetchServerProviders — Image stale selection', () => { expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); expect(store.getState().imageGenerationEnabled).toBe(true); }); + + it('auto-enables image generation on first load when server has image provider', async () => { + const store = await getStore(); + + // First ever fetchServerProviders — server has seedream + // Default state: imageProviderId='seedream', imageGenerationEnabled=false, autoConfigApplied=false + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().imageGenerationEnabled).toBe(true); + expect(store.getState().imageProviderId).toBe('seedream'); + expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); + }); + + it('auto-enables when autoConfigApplied is true and default provider matches server', async () => { + const store = await getStore(); + + // Simulate: autoConfigApplied was set in a previous sync but generation was never enabled + // Default imageProviderId is 'seedream', server also has 'seedream' + mockServerResponse({}); + await store.getState().fetchServerProviders(); // sets autoConfigApplied=true + expect(store.getState().autoConfigApplied).toBe(true); + + // Reset provider to default (simulating fresh localStorage with autoConfigApplied=true) + store.setState({ imageProviderId: 'seedream', imageModelId: '', imageGenerationEnabled: false }); + + // Now server has seedream + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + + expect(store.getState().imageGenerationEnabled).toBe(true); + expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); + }); }); describe('fetchServerProviders — Video stale selection', () => { From 6985225a495f0835edef25f448122be5b43acd48 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 13:33:50 +0800 Subject: [PATCH 09/12] fix: allow baseUrl-only server config for PDF providers (e.g. mineru) loadEnvSection now accepts allowBaseUrlOnly option. PDF providers like mineru that only need a baseUrl (no API key) are now correctly included in the server provider response. --- lib/server/provider-config.ts | 13 ++++++---- tests/server/provider-config.test.ts | 36 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 950df6ba..0b876df0 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -123,15 +123,18 @@ function loadYamlFile(filename: string): YamlData { function loadEnvSection( envMap: Record, yamlSection: Record> | undefined, + { requiresBaseUrl = false }: { requiresBaseUrl?: boolean } = {}, ): Record { const result: Record = {}; // First, add everything from YAML as defaults if (yamlSection) { for (const [id, entry] of Object.entries(yamlSection)) { - if (entry?.apiKey) { + const hasKey = !!entry?.apiKey; + const hasUrl = !!entry?.baseUrl; + if (requiresBaseUrl ? hasUrl : hasKey) { result[id] = { - apiKey: entry.apiKey, + apiKey: entry.apiKey || '', baseUrl: entry.baseUrl, models: entry.models, proxy: entry.proxy, @@ -160,9 +163,9 @@ function loadEnvSection( continue; } - if (!envApiKey) continue; + if (requiresBaseUrl ? !envBaseUrl : !envApiKey) continue; result[providerId] = { - apiKey: envApiKey, + apiKey: envApiKey || '', baseUrl: envBaseUrl, models: envModels, }; @@ -185,7 +188,7 @@ function buildConfig(yamlData: YamlData): ServerConfig { providers: loadEnvSection(LLM_ENV_MAP, yamlData.providers), tts: loadEnvSection(TTS_ENV_MAP, yamlData.tts), asr: loadEnvSection(ASR_ENV_MAP, yamlData.asr), - pdf: loadEnvSection(PDF_ENV_MAP, yamlData.pdf), + pdf: loadEnvSection(PDF_ENV_MAP, yamlData.pdf, { requiresBaseUrl: true }), image: loadEnvSection(IMAGE_ENV_MAP, yamlData.image), video: loadEnvSection(VIDEO_ENV_MAP, yamlData.video), webSearch: loadEnvSection(WEB_SEARCH_ENV_MAP, yamlData['web-search']), diff --git a/tests/server/provider-config.test.ts b/tests/server/provider-config.test.ts index d4988692..58d05c94 100644 --- a/tests/server/provider-config.test.ts +++ b/tests/server/provider-config.test.ts @@ -166,4 +166,40 @@ providers: expect(resolveWebSearchApiKey()).toBe('tvly-bare-env'); }); }); + + describe('baseUrl-only providers (e.g. mineru)', () => { + it('includes PDF provider from YAML when only baseUrl is configured (no apiKey)', async () => { + yamlOverride = ` +pdf: + mineru: + baseUrl: http://localhost:8888 +`; + const { getServerPDFProviders } = await import('@/lib/server/provider-config'); + const providers = getServerPDFProviders(); + + expect(providers.mineru).toBeDefined(); + expect(providers.mineru.baseUrl).toBe('http://localhost:8888'); + }); + + it('includes provider from env when only BASE_URL is set (no API_KEY)', async () => { + vi.stubEnv('PDF_MINERU_BASE_URL', 'http://localhost:8888'); + const { getServerPDFProviders } = await import('@/lib/server/provider-config'); + const providers = getServerPDFProviders(); + + expect(providers.mineru).toBeDefined(); + expect(providers.mineru.baseUrl).toBe('http://localhost:8888'); + }); + + it('excludes PDF provider when only apiKey is configured (no baseUrl)', async () => { + yamlOverride = ` +pdf: + mineru: + apiKey: sk-fake +`; + const { getServerPDFProviders } = await import('@/lib/server/provider-config'); + const providers = getServerPDFProviders(); + + expect(providers.mineru).toBeUndefined(); + }); + }); }); From e6e0bcc8b1c73f196cabfb565448a3403e02b314 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 15:27:10 +0800 Subject: [PATCH 10/12] fix: remove auto-enable logic, clean up dead CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only auto-disable image/video generation when no provider is usable. Never auto-enable — user controls when to turn it on. First-run auto-enable via autoConfigApplied guard is preserved. Also remove dead setup-needed CSS animations from globals.css. --- app/globals.css | 70 ------------------------ lib/store/settings.ts | 28 ++++------ tests/store/settings-server-sync.test.ts | 42 ++++++++++---- 3 files changed, 43 insertions(+), 97 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6b4718aa..98af3365 100644 --- a/app/globals.css +++ b/app/globals.css @@ -136,76 +136,6 @@ default !important; } -/* Setup-needed indicator animations */ -@keyframes setup-glow { - 0%, - 100% { - box-shadow: - 0 0 0 0 rgba(139, 92, 246, 0.4), - 0 0 8px rgba(139, 92, 246, 0.15); - } - 50% { - box-shadow: - 0 0 0 6px rgba(139, 92, 246, 0), - 0 0 16px rgba(59, 130, 246, 0.2); - } -} - -@keyframes setup-ping { - 0% { - transform: scale(1); - opacity: 1; - } - 75%, - 100% { - transform: scale(2.2); - opacity: 0; - } -} - -@keyframes setup-float { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-3px); - } -} - -.animate-setup-glow { - animation: setup-glow 2.5s ease-in-out infinite; -} - -.animate-setup-ping { - animation: setup-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; -} - -.animate-setup-float { - animation: setup-float 3s ease-in-out infinite; -} - -@keyframes setup-border-breathe { - 0%, - 100% { - border-color: rgba(139, 92, 246, 0.25) !important; - box-shadow: - 0 0 0 0 rgba(139, 92, 246, 0.05), - inset 0 0 0 0 rgba(139, 92, 246, 0); - } - 50% { - border-color: rgba(139, 92, 246, 0.7) !important; - box-shadow: - 0 0 16px 0 rgba(139, 92, 246, 0.1), - inset 0 0 12px 0 rgba(139, 92, 246, 0.03); - } -} - -.animate-setup-border { - animation: setup-border-breathe 2.5s ease-in-out infinite; - border-style: solid !important; -} - /* Animation for audio visualizer */ @keyframes wave { 0%, diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 0448f1b1..08cf34bd 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -994,21 +994,21 @@ export const useSettingsStore = create()( newProvidersConfig[validLLMProvider as ProviderId]?.models ?? [], ) : ''; + const imageModels = + IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? []; const validImageModel = validImageProvider ? recoveredImageModel || - validateModel( - state.imageModelId, - IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? [], - ) || - (IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models?.[0]?.id ?? '') + validateModel(state.imageModelId, imageModels) || + imageModels[0]?.id || + '' : ''; + const videoModels = + VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? []; const validVideoModel = validVideoProvider ? recoveredVideoModel || - validateModel( - state.videoModelId, - VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? [], - ) || - (VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models?.[0]?.id ?? '') + validateModel(state.videoModelId, videoModels) || + videoModels[0]?.id || + '' : ''; const validTTSVoice = @@ -1016,15 +1016,11 @@ export const useSettingsStore = create()( ? DEFAULT_TTS_VOICES[validTTSProvider as TTSProviderId] || 'default' : state.ttsVoice; - // Auto-disable/enable image/video generation based on provider availability + // Auto-disable image/video generation when no provider is usable const shouldDisableImage = !validImageProvider && state.imageGenerationEnabled; - const shouldEnableImage = - imageFallback.length > 0 && !state.imageGenerationEnabled; const shouldDisableVideo = !validVideoProvider && state.videoGenerationEnabled; - const shouldEnableVideo = - videoFallback.length > 0 && !state.videoGenerationEnabled; // === Auto-select / auto-enable (only on first run) === let autoTtsProvider: TTSProviderId | undefined; @@ -1149,9 +1145,7 @@ export const useSettingsStore = create()( videoModelId: validVideoModel, }), ...(shouldDisableImage && { imageGenerationEnabled: false }), - ...(shouldEnableImage && { imageGenerationEnabled: true }), ...(shouldDisableVideo && { videoGenerationEnabled: false }), - ...(shouldEnableVideo && { videoGenerationEnabled: true }), // First-run auto-select (autoConfigApplied guard) ...(autoPdfProvider && { pdfProviderId: autoPdfProvider }), ...(autoTtsProvider && { diff --git a/tests/store/settings-server-sync.test.ts b/tests/store/settings-server-sync.test.ts index 854f0fba..ebd95a48 100644 --- a/tests/store/settings-server-sync.test.ts +++ b/tests/store/settings-server-sync.test.ts @@ -585,6 +585,24 @@ describe('fetchServerProviders — Image stale selection', () => { expect(store.getState().imageGenerationEnabled).toBe(false); }); + it('preserves user-disabled image generation across server syncs', async () => { + const store = await getStore(); + + // Server has seedream, auto-enabled on first sync + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + expect(store.getState().imageGenerationEnabled).toBe(true); + + // User intentionally disables + store.getState().setImageGenerationEnabled(false); + expect(store.getState().imageGenerationEnabled).toBe(false); + + // Next server sync — same config, should NOT re-enable + mockServerResponse({ image: { seedream: {} } }); + await store.getState().fetchServerProviders(); + expect(store.getState().imageGenerationEnabled).toBe(false); + }); + it('falls back to another server-configured image provider', async () => { const store = await getStore(); @@ -616,7 +634,8 @@ describe('fetchServerProviders — Image stale selection', () => { expect(store.getState().imageProviderId).toBe('seedream'); expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); - expect(store.getState().imageGenerationEnabled).toBe(true); + // Provider recovered but generation stays off — user enables manually + expect(store.getState().imageGenerationEnabled).toBe(false); }); it('auto-enables image generation on first load when server has image provider', async () => { @@ -632,23 +651,25 @@ describe('fetchServerProviders — Image stale selection', () => { expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); }); - it('auto-enables when autoConfigApplied is true and default provider matches server', async () => { + it('does not force-enable when provider is already set but generation was disabled', async () => { const store = await getStore(); - // Simulate: autoConfigApplied was set in a previous sync but generation was never enabled - // Default imageProviderId is 'seedream', server also has 'seedream' + // autoConfigApplied=true, provider already set, generation off (user choice) mockServerResponse({}); await store.getState().fetchServerProviders(); // sets autoConfigApplied=true - expect(store.getState().autoConfigApplied).toBe(true); - // Reset provider to default (simulating fresh localStorage with autoConfigApplied=true) - store.setState({ imageProviderId: 'seedream', imageModelId: '', imageGenerationEnabled: false }); + store.setState({ + imageProviderId: 'seedream', + imageModelId: '', + imageGenerationEnabled: false, + }); - // Now server has seedream + // Server has seedream — should NOT force-enable (provider was already set) mockServerResponse({ image: { seedream: {} } }); await store.getState().fetchServerProviders(); - expect(store.getState().imageGenerationEnabled).toBe(true); + expect(store.getState().imageGenerationEnabled).toBe(false); + // But model should be auto-filled expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128'); }); }); @@ -736,7 +757,8 @@ describe('fetchServerProviders — Video stale selection', () => { expect(store.getState().videoProviderId).toBe('seedance'); expect(store.getState().videoModelId).toBe('doubao-seedance-1-5-pro-251215'); - expect(store.getState().videoGenerationEnabled).toBe(true); + // Provider recovered but generation stays off — user enables manually + expect(store.getState().videoGenerationEnabled).toBe(false); }); }); From f1d2faa03f88034f5c4d40ef2dd91ba8eb6ce7e8 Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 17:13:44 +0800 Subject: [PATCH 11/12] style: fix prettier formatting --- lib/store/settings.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 08cf34bd..9ffc8cd5 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -692,9 +692,7 @@ export const useSettingsStore = create()( setImageGenerationEnabled: (enabled) => { if (enabled) { const cfg = get().imageProvidersConfig; - const hasUsable = Object.values(cfg).some( - (c) => c.isServerConfigured || c.apiKey, - ); + const hasUsable = Object.values(cfg).some((c) => c.isServerConfigured || c.apiKey); if (!hasUsable) return; } set({ imageGenerationEnabled: enabled }); @@ -702,9 +700,7 @@ export const useSettingsStore = create()( setVideoGenerationEnabled: (enabled) => { if (enabled) { const cfg = get().videoProvidersConfig; - const hasUsable = Object.values(cfg).some( - (c) => c.isServerConfigured || c.apiKey, - ); + const hasUsable = Object.values(cfg).some((c) => c.isServerConfigured || c.apiKey); if (!hasUsable) return; } set({ videoGenerationEnabled: enabled }); @@ -976,15 +972,13 @@ export const useSettingsStore = create()( let recoveredImageModel = ''; if (!validImageProvider && imageFallback.length > 0) { validImageProvider = imageFallback[0]; - const models = - IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models; + const models = IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models; if (models?.length) recoveredImageModel = models[0].id; } let recoveredVideoModel = ''; if (!validVideoProvider && videoFallback.length > 0) { validVideoProvider = videoFallback[0]; - const models = - VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models; + const models = VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models; if (models?.length) recoveredVideoModel = models[0].id; } @@ -1017,10 +1011,8 @@ export const useSettingsStore = create()( : state.ttsVoice; // Auto-disable image/video generation when no provider is usable - const shouldDisableImage = - !validImageProvider && state.imageGenerationEnabled; - const shouldDisableVideo = - !validVideoProvider && state.videoGenerationEnabled; + const shouldDisableImage = !validImageProvider && state.imageGenerationEnabled; + const shouldDisableVideo = !validVideoProvider && state.videoGenerationEnabled; // === Auto-select / auto-enable (only on first run) === let autoTtsProvider: TTSProviderId | undefined; From 71759f4be858afa9367d387c8188fe371bae392d Mon Sep 17 00:00:00 2001 From: wyuc Date: Wed, 25 Mar 2026 17:22:20 +0800 Subject: [PATCH 12/12] fix: unify fallback strategy to include client-key providers for all types All provider types now fall back to client-key-only providers (not just server-configured ones), matching the LLM fallback strategy. Extracted buildFallback helper to reduce duplication. Added spread precedence comment per review feedback. --- lib/store/settings.ts | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 9ffc8cd5..cc322f6a 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -910,29 +910,24 @@ export const useSettingsStore = create()( } // === Validate current selections against updated configs === - const llmFallback = [ - ...Object.entries(newProvidersConfig) + // Build fallback: server-configured first, then client-key-only + const buildFallback = ( + config: Record, + ): T[] => [ + ...Object.entries(config) .filter(([, c]) => c.isServerConfigured) - .map(([id]) => id as ProviderId), - ...Object.entries(newProvidersConfig) + .map(([id]) => id as T), + ...Object.entries(config) .filter(([, c]) => !c.isServerConfigured && !!c.apiKey) - .map(([id]) => id as ProviderId), + .map(([id]) => id as T), ]; - const ttsFallback = Object.keys(newTTSConfig).filter( - (id) => newTTSConfig[id as TTSProviderId]?.isServerConfigured, - ) as TTSProviderId[]; - const asrFallback = Object.keys(newASRConfig).filter( - (id) => newASRConfig[id as ASRProviderId]?.isServerConfigured, - ) as ASRProviderId[]; - const pdfFallback = Object.keys(newPDFConfig).filter( - (id) => newPDFConfig[id as PDFProviderId]?.isServerConfigured, - ) as PDFProviderId[]; - const imageFallback = Object.keys(newImageConfig).filter( - (id) => newImageConfig[id as ImageProviderId]?.isServerConfigured, - ) as ImageProviderId[]; - const videoFallback = Object.keys(newVideoConfig).filter( - (id) => newVideoConfig[id as VideoProviderId]?.isServerConfigured, - ) as VideoProviderId[]; + + const llmFallback = buildFallback(newProvidersConfig); + const ttsFallback = buildFallback(newTTSConfig); + const asrFallback = buildFallback(newASRConfig); + const pdfFallback = buildFallback(newPDFConfig); + const imageFallback = buildFallback(newImageConfig); + const videoFallback = buildFallback(newVideoConfig); const validLLMProvider = validateProvider( state.providerId, @@ -993,6 +988,7 @@ export const useSettingsStore = create()( const validImageModel = validImageProvider ? recoveredImageModel || validateModel(state.imageModelId, imageModels) || + // validateModel('', ...) returns '' — fallback to first model when modelId is empty imageModels[0]?.id || '' : ''; @@ -1138,7 +1134,9 @@ export const useSettingsStore = create()( }), ...(shouldDisableImage && { imageGenerationEnabled: false }), ...(shouldDisableVideo && { videoGenerationEnabled: false }), - // First-run auto-select (autoConfigApplied guard) + // First-run auto-select overrides validation (autoConfigApplied guard). + // On first sync, auto-select picks the best provider. On subsequent syncs, + // auto* variables stay undefined so only validation spreads take effect. ...(autoPdfProvider && { pdfProviderId: autoPdfProvider }), ...(autoTtsProvider && { ttsProviderId: autoTtsProvider,