/** * Settings Store * Global settings state synchronized with localStorage */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { ProviderId } from '@/lib/ai/providers'; import type { ProvidersConfig } from '@/lib/types/settings'; import { PROVIDERS } from '@/lib/ai/providers'; import type { ThinkingConfig } from '@/lib/types/provider'; import { getThinkingConfigKey, supportsConfigurableThinking } from '@/lib/ai/thinking-config'; import type { TTSProviderId, ASRProviderId, BuiltInTTSProviderId } from '@/lib/audio/types'; import { isCustomTTSProvider, isCustomASRProvider } from '@/lib/audio/types'; import { ASR_PROVIDERS, DEFAULT_TTS_VOICES, TTS_PROVIDERS } from '@/lib/audio/constants'; import { DEFAULT_VOXCPM_BACKEND, VOXCPM_MODEL_ID, VOXCPM_VLLM_MODEL_ID } from '@/lib/audio/voxcpm'; import { PDF_PROVIDERS } from '@/lib/pdf/constants'; import type { PDFProviderId } from '@/lib/pdf/types'; import type { ImageProviderId, VideoProviderId } from '@/lib/media/types'; import { IMAGE_PROVIDERS } from '@/lib/media/image-providers'; 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'); function pruneThinkingConfigs( thinkingConfigs: Record | undefined, providersConfig: ProvidersConfig | undefined, ): Record { if (!thinkingConfigs || !providersConfig) return {}; const validKeys = new Set(); for (const [providerId, providerConfig] of Object.entries(providersConfig)) { for (const model of providerConfig.models) { if (supportsConfigurableThinking(model.capabilities?.thinking)) { validKeys.add(getThinkingConfigKey(providerId, model.id)); } } } return Object.fromEntries( Object.entries(thinkingConfigs).filter(([key]) => validKeys.has(key)), ) as Record; } /** Available playback speed tiers */ export const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2] as const; export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number]; export interface SettingsState { // Model selection providerId: ProviderId; modelId: string; thinkingConfigs: Record; // Provider configurations (unified JSON storage) providersConfig: ProvidersConfig; // TTS settings (legacy, kept for backward compatibility) ttsModel: string; // Audio settings (new unified audio configuration) ttsProviderId: TTSProviderId; ttsVoice: string; ttsSpeed: number; asrProviderId: ASRProviderId; asrLanguage: string; // Audio provider configurations ttsProvidersConfig: Record< TTSProviderId, { apiKey: string; baseUrl: string; enabled: boolean; modelId?: string; customModels?: Array<{ id: string; name: string }>; providerOptions?: Record; isServerConfigured?: boolean; serverBaseUrl?: string; // Custom provider fields customName?: string; customDefaultBaseUrl?: string; customVoices?: Array<{ id: string; name: string }>; isBuiltIn?: boolean; requiresApiKey?: boolean; } >; asrProvidersConfig: Record< ASRProviderId, { apiKey: string; baseUrl: string; enabled: boolean; modelId?: string; customModels?: Array<{ id: string; name: string }>; providerOptions?: Record; isServerConfigured?: boolean; serverBaseUrl?: string; // Custom provider fields customName?: string; customDefaultBaseUrl?: string; isBuiltIn?: boolean; requiresApiKey?: boolean; } >; // PDF settings pdfProviderId: PDFProviderId; pdfProvidersConfig: Record< PDFProviderId, { apiKey: string; baseUrl: string; enabled: boolean; isServerConfigured?: boolean; serverBaseUrl?: string; } >; // Image Generation settings imageProviderId: ImageProviderId; imageModelId: string; imageProvidersConfig: Record< ImageProviderId, { apiKey: string; baseUrl: string; enabled: boolean; isServerConfigured?: boolean; serverBaseUrl?: string; customModels?: Array<{ id: string; name: string }>; } >; // Video Generation settings videoProviderId: VideoProviderId; videoModelId: string; videoProvidersConfig: Record< VideoProviderId, { apiKey: string; baseUrl: string; enabled: boolean; isServerConfigured?: boolean; serverBaseUrl?: string; customModels?: Array<{ id: string; name: string }>; } >; // Media generation toggles imageGenerationEnabled: boolean; videoGenerationEnabled: boolean; // Web Search settings webSearchProviderId: WebSearchProviderId; webSearchProvidersConfig: Record< WebSearchProviderId, { apiKey: string; baseUrl: string; enabled: boolean; isServerConfigured?: boolean; serverBaseUrl?: string; } >; // Global TTS/ASR toggles ttsEnabled: boolean; asrEnabled: boolean; // Auto-config lifecycle flag (persisted) autoConfigApplied: boolean; // Playback controls ttsMuted: boolean; ttsVolume: number; // 0-1, actual volume level autoPlayLecture: boolean; playbackSpeed: PlaybackSpeed; // Agent settings selectedAgentIds: string[]; maxTurns: string; agentMode: 'preset' | 'auto'; autoAgentCount: number; // Layout preferences (persisted via localStorage) sidebarCollapsed: boolean; chatAreaCollapsed: boolean; chatAreaWidth: number; // Actions setModel: (providerId: ProviderId, modelId: string) => void; setThinkingConfig: ( providerId: ProviderId, modelId: string, config: ThinkingConfig | undefined, ) => void; setProviderConfig: (providerId: ProviderId, config: Partial) => void; setProvidersConfig: (config: ProvidersConfig) => void; setTtsModel: (model: string) => void; setTTSMuted: (muted: boolean) => void; setTTSVolume: (volume: number) => void; setAutoPlayLecture: (autoPlay: boolean) => void; setPlaybackSpeed: (speed: PlaybackSpeed) => void; setSelectedAgentIds: (ids: string[]) => void; setMaxTurns: (turns: string) => void; setAgentMode: (mode: 'preset' | 'auto') => void; setAutoAgentCount: (count: number) => void; // Layout actions setSidebarCollapsed: (collapsed: boolean) => void; setChatAreaCollapsed: (collapsed: boolean) => void; setChatAreaWidth: (width: number) => void; // Audio actions setTTSProvider: (providerId: TTSProviderId) => void; setTTSVoice: (voice: string) => void; setTTSSpeed: (speed: number) => void; setASRProvider: (providerId: ASRProviderId) => void; setASRLanguage: (language: string) => void; setTTSProviderConfig: ( providerId: TTSProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean; modelId: string; customModels: Array<{ id: string; name: string }>; customVoices: Array<{ id: string; name: string }>; providerOptions: Record; }>, ) => void; setASRProviderConfig: ( providerId: ASRProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean; modelId: string; customModels: Array<{ id: string; name: string }>; providerOptions: Record; }>, ) => void; setTTSEnabled: (enabled: boolean) => void; setASREnabled: (enabled: boolean) => void; // Custom audio provider actions addCustomTTSProvider: ( id: TTSProviderId, name: string, baseUrl: string, requiresApiKey: boolean, defaultModel?: string, ) => void; removeCustomTTSProvider: (id: TTSProviderId) => void; addCustomASRProvider: ( id: ASRProviderId, name: string, baseUrl: string, requiresApiKey: boolean, ) => void; removeCustomASRProvider: (id: ASRProviderId) => void; // PDF actions setPDFProvider: (providerId: PDFProviderId) => void; setPDFProviderConfig: ( providerId: PDFProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>, ) => void; // Image Generation actions setImageProvider: (providerId: ImageProviderId) => void; setImageModelId: (modelId: string) => void; setImageProviderConfig: ( providerId: ImageProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean; customModels: Array<{ id: string; name: string }>; }>, ) => void; // Video Generation actions setVideoProvider: (providerId: VideoProviderId) => void; setVideoModelId: (modelId: string) => void; setVideoProviderConfig: ( providerId: VideoProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean; customModels: Array<{ id: string; name: string }>; }>, ) => void; // Media generation toggle actions setImageGenerationEnabled: (enabled: boolean) => void; setVideoGenerationEnabled: (enabled: boolean) => void; // Web Search actions setWebSearchProvider: (providerId: WebSearchProviderId) => void; setWebSearchProviderConfig: ( providerId: WebSearchProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>, ) => void; // Server provider actions fetchServerProviders: () => Promise; } // Initialize default providers config const getDefaultProvidersConfig = (): ProvidersConfig => { const config: ProvidersConfig = {} as ProvidersConfig; Object.keys(PROVIDERS).forEach((pid) => { const provider = PROVIDERS[pid as ProviderId]; config[pid as ProviderId] = { apiKey: '', baseUrl: '', models: provider.models, name: provider.name, type: provider.type, defaultBaseUrl: provider.defaultBaseUrl, icon: provider.icon, requiresApiKey: provider.requiresApiKey, isBuiltIn: true, }; }); return config; }; // Initialize default audio config const getDefaultAudioConfig = () => ({ ttsProviderId: 'browser-native-tts' as TTSProviderId, ttsVoice: 'default', ttsSpeed: 1.0, asrProviderId: 'browser-native' as ASRProviderId, asrLanguage: 'zh', ttsProvidersConfig: { 'openai-tts': { apiKey: '', baseUrl: '', enabled: true }, 'azure-tts': { apiKey: '', baseUrl: '', enabled: false }, 'glm-tts': { apiKey: '', baseUrl: '', enabled: false }, 'qwen-tts': { apiKey: '', baseUrl: '', enabled: false }, 'voxcpm-tts': { apiKey: '', baseUrl: '', modelId: VOXCPM_VLLM_MODEL_ID, enabled: false, providerOptions: { backend: DEFAULT_VOXCPM_BACKEND }, }, 'doubao-tts': { apiKey: '', baseUrl: '', enabled: false }, 'elevenlabs-tts': { apiKey: '', baseUrl: '', enabled: false }, 'minimax-tts': { apiKey: '', baseUrl: '', modelId: 'speech-2.8-hd', enabled: false }, 'browser-native-tts': { apiKey: '', baseUrl: '', enabled: true }, } as Record< TTSProviderId, { apiKey: string; baseUrl: string; modelId?: string; enabled: boolean } >, asrProvidersConfig: { 'openai-whisper': { apiKey: '', baseUrl: '', enabled: true }, 'browser-native': { apiKey: '', baseUrl: '', enabled: true }, 'qwen-asr': { apiKey: '', baseUrl: '', enabled: false }, } as Record, }); // Initialize default PDF config const getDefaultPDFConfig = () => ({ pdfProviderId: 'unpdf' as PDFProviderId, pdfProvidersConfig: { unpdf: { apiKey: '', baseUrl: '', enabled: true }, mineru: { apiKey: '', baseUrl: '', enabled: false }, 'mineru-cloud': { apiKey: '', baseUrl: '', enabled: false }, } as Record, }); // Initialize default Image config const getDefaultImageConfig = () => ({ imageProviderId: 'seedream' as ImageProviderId, imageModelId: 'doubao-seedream-5-0-260128', imageProvidersConfig: { seedream: { apiKey: '', baseUrl: '', enabled: false }, 'openai-image': { apiKey: '', baseUrl: '', enabled: false }, 'qwen-image': { apiKey: '', baseUrl: '', enabled: false }, 'nano-banana': { apiKey: '', baseUrl: '', enabled: false }, 'minimax-image': { apiKey: '', baseUrl: '', enabled: false }, 'grok-image': { apiKey: '', baseUrl: '', enabled: false }, } as Record, }); // Initialize default Video config const getDefaultVideoConfig = () => ({ videoProviderId: 'seedance' as VideoProviderId, videoModelId: 'doubao-seedance-1-5-pro-251215', videoProvidersConfig: { seedance: { apiKey: '', baseUrl: '', enabled: false }, kling: { apiKey: '', baseUrl: '', enabled: false }, veo: { apiKey: '', baseUrl: '', enabled: false }, sora: { apiKey: '', baseUrl: '', enabled: false }, 'minimax-video': { apiKey: '', baseUrl: '', enabled: false }, 'grok-video': { apiKey: '', baseUrl: '', enabled: false }, } as Record, }); // Initialize default Web Search config const getDefaultWebSearchConfig = () => ({ webSearchProviderId: 'tavily' as WebSearchProviderId, webSearchProvidersConfig: { tavily: { apiKey: '', baseUrl: '', enabled: true }, } as Record, }); /** * Check whether a provider ID exists in the given provider registry. */ function hasProviderId(providerMap: Record, providerId?: string): boolean { return typeof providerId === 'string' && providerId in providerMap; } /** * Validate all persisted provider IDs against their registries. * Reset any stale / removed ID back to its default value. * Called during both migrate and merge to cover all rehydration paths. */ function ensureValidProviderSelections(state: Partial): void { const defaultAudioConfig = getDefaultAudioConfig(); const defaultPdfConfig = getDefaultPDFConfig(); const defaultImageConfig = getDefaultImageConfig(); const defaultVideoConfig = getDefaultVideoConfig(); const defaultWebSearchConfig = getDefaultWebSearchConfig(); if (!hasProviderId(PDF_PROVIDERS, state.pdfProviderId)) { state.pdfProviderId = defaultPdfConfig.pdfProviderId; } if (!hasProviderId(WEB_SEARCH_PROVIDERS, state.webSearchProviderId)) { state.webSearchProviderId = defaultWebSearchConfig.webSearchProviderId; } if (!hasProviderId(IMAGE_PROVIDERS, state.imageProviderId)) { state.imageProviderId = defaultImageConfig.imageProviderId; } if (!hasProviderId(VIDEO_PROVIDERS, state.videoProviderId)) { state.videoProviderId = defaultVideoConfig.videoProviderId; } if ( !hasProviderId(TTS_PROVIDERS, state.ttsProviderId) && !( state.ttsProviderId && isCustomTTSProvider(state.ttsProviderId) && state.ttsProvidersConfig && state.ttsProviderId in state.ttsProvidersConfig ) ) { state.ttsProviderId = defaultAudioConfig.ttsProviderId; } if ( !hasProviderId(ASR_PROVIDERS, state.asrProviderId) && !( state.asrProviderId && isCustomASRProvider(state.asrProviderId) && state.asrProvidersConfig && state.asrProviderId in state.asrProvidersConfig ) ) { state.asrProviderId = defaultAudioConfig.asrProviderId; } } function ensureBuiltInAudioProviders(state: Partial): void { const defaultAudioConfig = getDefaultAudioConfig(); if (state.ttsProvidersConfig) { for (const providerId of Object.keys(TTS_PROVIDERS) as BuiltInTTSProviderId[]) { if (!state.ttsProvidersConfig[providerId]) { state.ttsProvidersConfig[providerId] = defaultAudioConfig.ttsProvidersConfig[providerId]; } } const voxcpmConfig = state.ttsProvidersConfig['voxcpm-tts']; if (voxcpmConfig) { if (!voxcpmConfig.modelId || voxcpmConfig.modelId === VOXCPM_MODEL_ID) { voxcpmConfig.modelId = VOXCPM_VLLM_MODEL_ID; } voxcpmConfig.providerOptions = { backend: DEFAULT_VOXCPM_BACKEND, ...(voxcpmConfig.providerOptions || {}), }; } } if (state.asrProvidersConfig) { for (const providerId of Object.keys(ASR_PROVIDERS) as ASRProviderId[]) { if (!state.asrProvidersConfig[providerId]) { state.asrProvidersConfig[providerId] = defaultAudioConfig.asrProvidersConfig[providerId]; } } } } /** * Ensure providersConfig includes all built-in providers and their latest models. * Called on every rehydrate (not just version migrations) so new providers * added in code are always picked up without clearing cache. */ function ensureBuiltInProviders(state: Partial): void { if (!state.providersConfig) return; const defaultConfig = getDefaultProvidersConfig(); Object.keys(PROVIDERS).forEach((pid) => { const providerId = pid as ProviderId; if (!state.providersConfig![providerId]) { // New provider: add with defaults state.providersConfig![providerId] = defaultConfig[providerId]; } else { // Existing provider: refresh built-in models from the registry and // keep user-added models after the built-in list. const provider = PROVIDERS[providerId]; const existing = state.providersConfig![providerId]; const builtInModelIds = new Set(provider.models.map((m) => m.id)); const customModels = (existing.models || []).filter((m) => !builtInModelIds.has(m.id)); const mergedModels = [...provider.models, ...customModels]; state.providersConfig![providerId] = { ...existing, models: mergedModels, name: existing.name || provider.name, type: existing.type || provider.type, defaultBaseUrl: existing.defaultBaseUrl || provider.defaultBaseUrl, icon: provider.icon || existing.icon, requiresApiKey: existing.requiresApiKey ?? provider.requiresApiKey, isBuiltIn: existing.isBuiltIn ?? true, }; } }); } /** * Custom providers created before #414 stored their actual endpoint in * defaultBaseUrl while leaving baseUrl empty. Promote that persisted value * during rehydrate so downstream request builders keep using baseUrl only. */ export function promoteLegacyCustomProviderBaseUrls(state: Partial): void { if (!state.providersConfig) return; Object.values(state.providersConfig).forEach((config) => { if (!config.isBuiltIn && !config.baseUrl && config.defaultBaseUrl) { config.baseUrl = config.defaultBaseUrl; } }); } /** * Ensure imageProvidersConfig includes all built-in image providers. * Called on every rehydrate so newly added image providers appear automatically. */ function ensureBuiltInImageProviders(state: Partial): void { if (!state.imageProvidersConfig) return; const defaultConfig = getDefaultImageConfig().imageProvidersConfig; Object.keys(IMAGE_PROVIDERS).forEach((pid) => { const providerId = pid as ImageProviderId; if (!state.imageProvidersConfig![providerId]) { state.imageProvidersConfig![providerId] = defaultConfig[providerId]; } }); } /** * Ensure videoProvidersConfig includes all built-in video providers. * Called on every rehydrate so newly added video providers appear automatically. */ function ensureBuiltInVideoProviders(state: Partial): void { if (!state.videoProvidersConfig) return; const defaultConfig = getDefaultVideoConfig().videoProvidersConfig; Object.keys(VIDEO_PROVIDERS).forEach((pid) => { const providerId = pid as VideoProviderId; if (!state.videoProvidersConfig![providerId]) { state.videoProvidersConfig![providerId] = defaultConfig[providerId]; } }); } // Migrate from old localStorage format const migrateFromOldStorage = () => { if (typeof window === 'undefined') return null; // Check if new storage already exists const newStorage = localStorage.getItem('settings-storage'); if (newStorage) return null; // Already migrated or new install // Read old localStorage keys const oldLlmModel = localStorage.getItem('llmModel'); const oldProvidersConfig = localStorage.getItem('providersConfig'); const oldTtsModel = localStorage.getItem('ttsModel'); const oldSelectedAgents = localStorage.getItem('selectedAgentIds'); const oldMaxTurns = localStorage.getItem('maxTurns'); if (!oldLlmModel && !oldProvidersConfig) return null; // No old data // Parse model selection let providerId: ProviderId = 'openai'; let modelId = 'gpt-5.4-mini'; if (oldLlmModel) { const [pid, mid] = oldLlmModel.split(':'); if (pid && mid) { providerId = pid as ProviderId; modelId = mid; } } // Parse providers config let providersConfig = getDefaultProvidersConfig(); if (oldProvidersConfig) { try { const parsed = JSON.parse(oldProvidersConfig); providersConfig = { ...providersConfig, ...parsed }; } catch (e) { log.error('Failed to parse old providersConfig:', e); } } // Parse other settings let ttsModel = 'openai-tts'; if (oldTtsModel) ttsModel = oldTtsModel; let selectedAgentIds = ['default-1', 'default-2', 'default-3']; if (oldSelectedAgents) { try { const parsed = JSON.parse(oldSelectedAgents); if (Array.isArray(parsed) && parsed.length > 0) { selectedAgentIds = parsed; } } catch (e) { log.error('Failed to parse old selectedAgentIds:', e); } } let maxTurns = '10'; if (oldMaxTurns) maxTurns = oldMaxTurns; return { providerId, modelId, thinkingConfigs: {}, providersConfig, ttsModel, selectedAgentIds, maxTurns, }; }; export const useSettingsStore = create()( persist( (set, get) => { // Try to migrate from old storage const migratedData = migrateFromOldStorage(); const defaultAudioConfig = getDefaultAudioConfig(); const defaultPDFConfig = getDefaultPDFConfig(); const defaultImageConfig = getDefaultImageConfig(); const defaultVideoConfig = getDefaultVideoConfig(); const defaultWebSearchConfig = getDefaultWebSearchConfig(); const initialProvidersConfig = migratedData?.providersConfig || getDefaultProvidersConfig(); return { // Initial state (use migrated data if available) providerId: migratedData?.providerId || 'openai', modelId: migratedData?.modelId || '', thinkingConfigs: pruneThinkingConfigs( migratedData?.thinkingConfigs || {}, initialProvidersConfig, ), providersConfig: initialProvidersConfig, ttsModel: migratedData?.ttsModel || 'openai-tts', selectedAgentIds: migratedData?.selectedAgentIds || ['default-1', 'default-2', 'default-3'], maxTurns: migratedData?.maxTurns?.toString() || '10', agentMode: 'auto' as const, autoAgentCount: 3, // Playback controls ttsMuted: false, ttsVolume: 1, autoPlayLecture: false, playbackSpeed: 1, // Layout preferences sidebarCollapsed: true, chatAreaCollapsed: true, chatAreaWidth: 320, // Audio settings (use defaults) ...defaultAudioConfig, // PDF settings (use defaults) ...defaultPDFConfig, // Image settings (use defaults) ...defaultImageConfig, // Video settings (use defaults) ...defaultVideoConfig, // Media generation toggles (off by default) imageGenerationEnabled: false, videoGenerationEnabled: false, // Audio feature toggles (on by default) ttsEnabled: true, asrEnabled: true, autoConfigApplied: false, // Web Search settings (use defaults) ...defaultWebSearchConfig, // Actions setModel: (providerId, modelId) => set({ providerId, modelId }), setThinkingConfig: (providerId, modelId, config) => set((state) => { const key = getThinkingConfigKey(providerId, modelId); const next = { ...state.thinkingConfigs }; if (config) { next[key] = config; } else { delete next[key]; } return { thinkingConfigs: next }; }), setProviderConfig: (providerId, config) => set((state) => { const providersConfig = { ...state.providersConfig, [providerId]: { ...state.providersConfig[providerId], ...config, }, }; return { providersConfig, thinkingConfigs: pruneThinkingConfigs(state.thinkingConfigs, providersConfig), }; }), setProvidersConfig: (config) => set((state) => ({ providersConfig: config, thinkingConfigs: pruneThinkingConfigs(state.thinkingConfigs, config), })), setTtsModel: (model) => set({ ttsModel: model }), setTTSMuted: (muted) => set({ ttsMuted: muted }), setTTSVolume: (volume) => set({ ttsVolume: Math.max(0, Math.min(1, volume)) }), setAutoPlayLecture: (autoPlay) => set({ autoPlayLecture: autoPlay }), setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), setSelectedAgentIds: (ids) => set({ selectedAgentIds: ids }), setMaxTurns: (turns) => set({ maxTurns: turns }), setAgentMode: (mode) => set({ agentMode: mode }), setAutoAgentCount: (count) => set({ autoAgentCount: count }), // Layout actions setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), setChatAreaCollapsed: (collapsed) => set({ chatAreaCollapsed: collapsed }), setChatAreaWidth: (width) => set({ chatAreaWidth: width }), // Audio actions setTTSProvider: (providerId) => set((state) => { // If switching provider, set default voice for that provider const shouldUpdateVoice = state.ttsProviderId !== providerId; const defaultVoice = isCustomTTSProvider(providerId) ? state.ttsProvidersConfig[providerId]?.customVoices?.[0]?.id || 'default' : DEFAULT_TTS_VOICES[providerId as BuiltInTTSProviderId] || 'default'; return { ttsProviderId: providerId, ...(shouldUpdateVoice && { ttsVoice: defaultVoice }), }; }), setTTSVoice: (voice) => set({ ttsVoice: voice }), setTTSSpeed: (speed) => set({ ttsSpeed: speed }), // Reset language when switching providers, since language code formats differ // (e.g. browser-native uses BCP-47 "en-US", OpenAI Whisper uses ISO 639-1 "en") setASRProvider: (providerId) => set((state) => { let supportedLanguages: string[]; if (isCustomASRProvider(providerId)) { supportedLanguages = ['auto']; } else { supportedLanguages = ASR_PROVIDERS[providerId as keyof typeof ASR_PROVIDERS]?.supportedLanguages || []; } const isLanguageValid = supportedLanguages.includes(state.asrLanguage); return { asrProviderId: providerId, ...(isLanguageValid ? {} : { asrLanguage: supportedLanguages[0] || 'auto' }), }; }), setASRLanguage: (language) => set({ asrLanguage: language }), setTTSProviderConfig: (providerId, config) => set((state) => ({ ttsProvidersConfig: { ...state.ttsProvidersConfig, [providerId]: { ...state.ttsProvidersConfig[providerId], ...config, }, }, })), setASRProviderConfig: (providerId, config) => set((state) => ({ asrProvidersConfig: { ...state.asrProvidersConfig, [providerId]: { ...state.asrProvidersConfig[providerId], ...config, }, }, })), // PDF actions setPDFProvider: (providerId) => set({ pdfProviderId: providerId }), setPDFProviderConfig: (providerId, config) => set((state) => ({ pdfProvidersConfig: { ...state.pdfProvidersConfig, [providerId]: { ...state.pdfProvidersConfig[providerId], ...config, }, }, })), // Image Generation actions setImageProvider: (providerId) => set({ imageProviderId: providerId }), setImageModelId: (modelId) => set({ imageModelId: modelId }), setImageProviderConfig: (providerId, config) => set((state) => ({ imageProvidersConfig: { ...state.imageProvidersConfig, [providerId]: { ...state.imageProvidersConfig[providerId], ...config, }, }, })), // Video Generation actions setVideoProvider: (providerId) => set({ videoProviderId: providerId }), setVideoModelId: (modelId) => set({ videoModelId: modelId }), setVideoProviderConfig: (providerId, config) => set((state) => ({ videoProvidersConfig: { ...state.videoProvidersConfig, [providerId]: { ...state.videoProvidersConfig[providerId], ...config, }, }, })), // Media generation toggle actions 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 }), // Custom audio provider actions addCustomTTSProvider: (id, name, baseUrl, requiresApiKey, defaultModel) => set((state) => ({ ttsProvidersConfig: { ...state.ttsProvidersConfig, [id]: { apiKey: '', baseUrl: '', enabled: true, modelId: defaultModel || '', customName: name, customDefaultBaseUrl: baseUrl, customVoices: [], isBuiltIn: false, requiresApiKey, }, }, ttsProviderId: id, })), removeCustomTTSProvider: (id) => set((state) => { if (!isCustomTTSProvider(id)) return state; const { [id]: _, ...rest } = state.ttsProvidersConfig; return { ttsProvidersConfig: rest as typeof state.ttsProvidersConfig, ...(state.ttsProviderId === id && { ttsProviderId: 'browser-native-tts' as TTSProviderId, ttsVoice: 'default', }), }; }), addCustomASRProvider: (id, name, baseUrl, requiresApiKey) => set((state) => ({ asrProvidersConfig: { ...state.asrProvidersConfig, [id]: { apiKey: '', baseUrl: '', enabled: true, modelId: '', customModels: [], customName: name, customDefaultBaseUrl: baseUrl, isBuiltIn: false, requiresApiKey, }, }, asrProviderId: id, })), removeCustomASRProvider: (id) => set((state) => { if (!isCustomASRProvider(id)) return state; const { [id]: _, ...rest } = state.asrProvidersConfig; return { asrProvidersConfig: rest as typeof state.asrProvidersConfig, ...(state.asrProviderId === id && { asrProviderId: 'browser-native' as ASRProviderId, asrLanguage: 'zh', }), }; }), // Web Search actions setWebSearchProvider: (providerId) => set({ webSearchProviderId: providerId }), setWebSearchProviderConfig: (providerId, config) => set((state) => ({ webSearchProvidersConfig: { ...state.webSearchProvidersConfig, [providerId]: { ...state.webSearchProvidersConfig[providerId], ...config, }, }, })), // Fetch server-configured providers and merge into local state fetchServerProviders: async () => { try { const res = await fetch('/api/server-providers'); if (!res.ok) return; const data = (await res.json()) as { providers: Record; tts: Record; asr: Record; pdf: Record; image: Record; video: Record; webSearch: Record; }; set((state) => { // Merge LLM providers const newProvidersConfig = { ...state.providersConfig }; // First reset all server flags for (const pid of Object.keys(newProvidersConfig)) { const key = pid as ProviderId; if (newProvidersConfig[key]) { newProvidersConfig[key] = { ...newProvidersConfig[key], isServerConfigured: false, serverModels: undefined, serverBaseUrl: undefined, }; } } // Set flags for server-configured providers for (const [pid, info] of Object.entries(data.providers)) { const key = pid as ProviderId; if (newProvidersConfig[key]) { const currentModels = newProvidersConfig[key].models; // When server specifies allowed models, filter the models list // while preserving custom IDs from env/YAML in server order. const currentModelMap = new Map(currentModels.map((m) => [m.id, m])); const filteredModels = info.models?.length ? info.models.map((id) => currentModelMap.get(id) ?? { id, name: id }) : currentModels; newProvidersConfig[key] = { ...newProvidersConfig[key], isServerConfigured: true, serverModels: info.models, serverBaseUrl: info.baseUrl, models: filteredModels, }; } } // Merge TTS providers const newTTSConfig = { ...state.ttsProvidersConfig }; for (const pid of Object.keys(newTTSConfig)) { const key = pid as TTSProviderId; if (newTTSConfig[key]) { newTTSConfig[key] = { ...newTTSConfig[key], isServerConfigured: false, serverBaseUrl: undefined, }; } } for (const [pid, info] of Object.entries(data.tts)) { const key = pid as TTSProviderId; if (newTTSConfig[key]) { newTTSConfig[key] = { ...newTTSConfig[key], isServerConfigured: true, serverBaseUrl: info.baseUrl, }; } } // Merge ASR providers const newASRConfig = { ...state.asrProvidersConfig }; for (const pid of Object.keys(newASRConfig)) { const key = pid as ASRProviderId; if (newASRConfig[key]) { newASRConfig[key] = { ...newASRConfig[key], isServerConfigured: false, serverBaseUrl: undefined, }; } } for (const [pid, info] of Object.entries(data.asr)) { const key = pid as ASRProviderId; if (newASRConfig[key]) { newASRConfig[key] = { ...newASRConfig[key], isServerConfigured: true, serverBaseUrl: info.baseUrl, }; } } // Merge PDF providers const newPDFConfig = { ...state.pdfProvidersConfig }; for (const pid of Object.keys(newPDFConfig)) { const key = pid as PDFProviderId; if (newPDFConfig[key]) { newPDFConfig[key] = { ...newPDFConfig[key], isServerConfigured: false, serverBaseUrl: undefined, }; } } for (const [pid, info] of Object.entries(data.pdf)) { const key = pid as PDFProviderId; if (newPDFConfig[key]) { newPDFConfig[key] = { ...newPDFConfig[key], isServerConfigured: true, serverBaseUrl: info.baseUrl, }; } } // Merge Image providers const newImageConfig = { ...state.imageProvidersConfig }; for (const pid of Object.keys(newImageConfig)) { const key = pid as ImageProviderId; if (newImageConfig[key]) { newImageConfig[key] = { ...newImageConfig[key], isServerConfigured: false, serverBaseUrl: undefined, }; } } for (const [pid, info] of Object.entries(data.image)) { const key = pid as ImageProviderId; if (newImageConfig[key]) { newImageConfig[key] = { ...newImageConfig[key], isServerConfigured: true, serverBaseUrl: info.baseUrl, }; } } // Merge Video providers const newVideoConfig = { ...state.videoProvidersConfig }; for (const pid of Object.keys(newVideoConfig)) { const key = pid as VideoProviderId; if (newVideoConfig[key]) { newVideoConfig[key] = { ...newVideoConfig[key], isServerConfigured: false, serverBaseUrl: undefined, }; } } if (data.video) { for (const [pid, info] of Object.entries(data.video)) { const key = pid as VideoProviderId; if (newVideoConfig[key]) { newVideoConfig[key] = { ...newVideoConfig[key], isServerConfigured: true, serverBaseUrl: info.baseUrl, }; } } } // Merge Web Search config — reset all first, then mark server-configured const newWebSearchConfig = { ...state.webSearchProvidersConfig }; for (const key of Object.keys(newWebSearchConfig) as WebSearchProviderId[]) { newWebSearchConfig[key] = { ...newWebSearchConfig[key], isServerConfigured: false, serverBaseUrl: undefined, }; } if (data.webSearch) { for (const [pid, info] of Object.entries(data.webSearch)) { const key = pid as WebSearchProviderId; if (newWebSearchConfig[key]) { newWebSearchConfig[key] = { ...newWebSearchConfig[key], isServerConfigured: true, serverBaseUrl: info.baseUrl, }; } } } // === Validate current selections against updated configs === // 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 T), ...Object.entries(config) .filter(([, c]) => !c.isServerConfigured && !!c.apiKey) .map(([id]) => id as T), ]; 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, 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, ); let validImageProvider = validateProvider( state.imageProviderId, newImageConfig, imageFallback, ); 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, newProvidersConfig[validLLMProvider as ProviderId]?.models ?? [], ) : ''; const imageModels = IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? []; const validImageModel = validImageProvider ? recoveredImageModel || validateModel(state.imageModelId, imageModels) || // validateModel('', ...) returns '' — fallback to first model when modelId is empty imageModels[0]?.id || '' : ''; const videoModels = VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? []; const validVideoModel = validVideoProvider ? recoveredVideoModel || validateModel(state.videoModelId, videoModels) || videoModels[0]?.id || '' : ''; const validTTSVoice = validTTSProvider !== state.ttsProviderId ? DEFAULT_TTS_VOICES[validTTSProvider as BuiltInTTSProviderId] || 'default' : state.ttsVoice; // Auto-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; let autoAsrProvider: ASRProviderId | undefined; let autoPdfProvider: PDFProviderId | undefined; let autoImageProvider: ImageProviderId | undefined; let autoImageModel: string | undefined; let autoVideoProvider: VideoProviderId | undefined; let autoVideoModel: string | undefined; let autoImageEnabled: boolean | undefined; let autoVideoEnabled: boolean | undefined; if (!state.autoConfigApplied) { // PDF: unpdf → mineru-cloud or mineru if server has it if (state.pdfProviderId === 'unpdf') { if (newPDFConfig['mineru-cloud']?.isServerConfigured) { autoPdfProvider = 'mineru-cloud' as PDFProviderId; } else if (newPDFConfig.mineru?.isServerConfigured) { autoPdfProvider = 'mineru' as PDFProviderId; } } // TTS: select first server provider if current is not server-configured const serverTtsIds = Object.keys(data.tts) as TTSProviderId[]; if ( serverTtsIds.length > 0 && !newTTSConfig[state.ttsProviderId]?.isServerConfigured ) { autoTtsProvider = serverTtsIds[0]; autoTtsVoice = DEFAULT_TTS_VOICES[autoTtsProvider as BuiltInTTSProviderId] || 'default'; } // ASR: select first server provider if current is not server-configured const serverAsrIds = Object.keys(data.asr) as ASRProviderId[]; if ( serverAsrIds.length > 0 && !newASRConfig[state.asrProviderId]?.isServerConfigured ) { autoAsrProvider = serverAsrIds[0]; } // Image: first server provider const serverImageIds = Object.keys(data.image) as ImageProviderId[]; if ( serverImageIds.length > 0 && !newImageConfig[state.imageProviderId]?.isServerConfigured ) { autoImageProvider = serverImageIds[0]; const models = IMAGE_PROVIDERS[autoImageProvider]?.models; if (models?.length) autoImageModel = models[0].id; } if (serverImageIds.length > 0 && !state.imageGenerationEnabled) { autoImageEnabled = true; } // Video: first server provider const serverVideoIds = Object.keys(data.video || {}) as VideoProviderId[]; if ( serverVideoIds.length > 0 && !newVideoConfig[state.videoProviderId]?.isServerConfigured ) { autoVideoProvider = serverVideoIds[0]; const models = VIDEO_PROVIDERS[autoVideoProvider]?.models; if (models?.length) autoVideoModel = models[0].id; } if (serverVideoIds.length > 0 && !state.videoGenerationEnabled) { autoVideoEnabled = true; } } // LLM auto-select: only on true first load (no provider selected yet) let autoProviderId: ProviderId | undefined; let autoModelId: string | undefined; 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 const serverModels = cfg.serverModels; const modelId = serverModels?.length ? serverModels[0] : PROVIDERS[pid as ProviderId]?.models[0]?.id; if (modelId) { autoProviderId = pid as ProviderId; autoModelId = modelId; break; } } } } return { providersConfig: newProvidersConfig, ttsProvidersConfig: newTTSConfig, asrProvidersConfig: newASRConfig, pdfProvidersConfig: newPDFConfig, imageProvidersConfig: newImageConfig, 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, }), ...(validImageModel !== state.imageModelId && { imageModelId: validImageModel, }), ...(validVideoProvider !== state.videoProviderId && { videoProviderId: validVideoProvider as VideoProviderId, }), ...(validVideoModel !== state.videoModelId && { videoModelId: validVideoModel, }), ...(shouldDisableImage && { imageGenerationEnabled: false }), ...(shouldDisableVideo && { videoGenerationEnabled: false }), // 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, ttsVoice: autoTtsVoice, }), ...(autoAsrProvider && { asrProviderId: autoAsrProvider }), ...(autoImageProvider && { imageProviderId: autoImageProvider, }), ...(autoImageModel && { imageModelId: autoImageModel }), ...(autoVideoProvider && { videoProviderId: autoVideoProvider, }), ...(autoVideoModel && { videoModelId: autoVideoModel }), ...(autoImageEnabled !== undefined && { imageGenerationEnabled: autoImageEnabled, }), ...(autoVideoEnabled !== undefined && { videoGenerationEnabled: autoVideoEnabled, }), ...(autoProviderId && { providerId: autoProviderId }), ...(autoModelId && { modelId: autoModelId }), }; }); } catch (e) { // Silently fail — server providers are optional log.warn('Failed to fetch server providers:', e); } }, }; }, { name: 'settings-storage', version: 2, // Migrate persisted state migrate: (persistedState: unknown, version: number) => { const state = persistedState as Partial; // v0 → v1: clear hardcoded default model so user must actively select if (version === 0) { if (state.providerId === 'openai' && state.modelId === 'gpt-4o-mini') { state.modelId = ''; } } // Ensure providersConfig has all built-in providers (also in merge below) ensureBuiltInProviders(state); promoteLegacyCustomProviderBaseUrls(state); // Ensure image/video configs have all built-in providers ensureBuiltInImageProviders(state); ensureBuiltInVideoProviders(state); // Migrate from old ttsModel to new ttsProviderId if (state.ttsModel && !state.ttsProviderId) { // Map old ttsModel values to new ttsProviderId if (state.ttsModel === 'openai-tts') { state.ttsProviderId = 'openai-tts'; } else if (state.ttsModel === 'azure-tts') { state.ttsProviderId = 'azure-tts'; } else { // Default to OpenAI state.ttsProviderId = 'openai-tts'; } } // Add default audio config if missing if (!state.ttsProvidersConfig || !state.asrProvidersConfig) { const defaultAudioConfig = getDefaultAudioConfig(); Object.assign(state, defaultAudioConfig); } ensureBuiltInAudioProviders(state); // Migrate global ttsModelId to per-provider if ((state as Record).ttsModelId) { const pid = state.ttsProviderId; if (pid && state.ttsProvidersConfig?.[pid]) { state.ttsProvidersConfig[pid].modelId = (state as Record) .ttsModelId as string; } delete (state as Record).ttsModelId; } // Same for asrModelId if ((state as Record).asrModelId) { const pid = state.asrProviderId; if (pid && state.asrProvidersConfig?.[pid]) { state.asrProvidersConfig[pid].modelId = (state as Record) .asrModelId as string; } delete (state as Record).asrModelId; } // Migrate MiniMax's model field to modelId for (const [, cfg] of Object.entries( (state.ttsProvidersConfig as Record>) || {}, )) { if (cfg.model && !cfg.modelId) { cfg.modelId = cfg.model; delete cfg.model; } } // Add default PDF config if missing if (!state.pdfProvidersConfig) { const defaultPDFConfig = getDefaultPDFConfig(); Object.assign(state, defaultPDFConfig); } // Add default Image config if missing if (!state.imageProvidersConfig) { const defaultImageConfig = getDefaultImageConfig(); Object.assign(state, defaultImageConfig); } // Add default Video config if missing if (!state.videoProvidersConfig) { const defaultVideoConfig = getDefaultVideoConfig(); Object.assign(state, defaultVideoConfig); } // v1 → v2: Replace deep research with web search if (version < 2) { delete (state as Record).deepResearchProviderId; delete (state as Record).deepResearchProvidersConfig; } // Add default media generation toggles if missing if (state.imageGenerationEnabled === undefined) { state.imageGenerationEnabled = false; } if (state.videoGenerationEnabled === undefined) { state.videoGenerationEnabled = false; } // Add default audio toggles if missing if ((state as Record).ttsEnabled === undefined) { (state as Record).ttsEnabled = true; } if ((state as Record).asrEnabled === undefined) { (state as Record).asrEnabled = true; } // Existing users already have their config set up — mark auto-config as done if ((state as Record).autoConfigApplied === undefined) { (state as Record).autoConfigApplied = true; } if ((state as Record).agentMode === undefined) { (state as Record).agentMode = 'preset'; } if ((state as Record).autoAgentCount === undefined) { (state as Record).autoAgentCount = 3; } if ((state as Record).thinkingConfigs === undefined) { (state as Record).thinkingConfigs = {}; } // Migrate Web Search: old flat fields → new provider-based config if (!state.webSearchProvidersConfig) { const stateRecord = state as Record; const oldApiKey = (stateRecord.webSearchApiKey as string) || ''; const oldIsServerConfigured = (stateRecord.webSearchIsServerConfigured as boolean) || false; state.webSearchProviderId = 'tavily' as WebSearchProviderId; state.webSearchProvidersConfig = { tavily: { apiKey: oldApiKey, baseUrl: '', enabled: true, isServerConfigured: oldIsServerConfigured, }, } as SettingsState['webSearchProvidersConfig']; delete stateRecord.webSearchApiKey; delete stateRecord.webSearchIsServerConfigured; } ensureValidProviderSelections(state); ensureBuiltInAudioProviders(state); state.thinkingConfigs = pruneThinkingConfigs(state.thinkingConfigs, state.providersConfig); return state; }, // Custom merge: always sync built-in providers on every rehydrate, // so newly added providers/models appear without clearing cache. merge: (persistedState, currentState) => { const merged = { ...currentState, ...(persistedState as object) }; ensureBuiltInProviders(merged as Partial); promoteLegacyCustomProviderBaseUrls(merged as Partial); ensureBuiltInAudioProviders(merged as Partial); ensureBuiltInImageProviders(merged as Partial); ensureBuiltInVideoProviders(merged as Partial); ensureValidProviderSelections(merged as Partial); const typedMerged = merged as Partial; typedMerged.thinkingConfigs = pruneThinkingConfigs( typedMerged.thinkingConfigs, typedMerged.providersConfig, ); return merged as SettingsState; }, }, ), );