/** * Settings Store * * Global settings, persisted through the `@openmaic/storage` KVStore in the * `account` scope. The bulk of this store is the user's provider/model * configuration — the canonical `account`-scoped value in the storage contract, * and the thing a second device should not have to be told again. */ 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 { findModelById, getCanonicalModelId } from '@/lib/ai/model-aliases'; 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 type { AgentVoiceOverride } from '@/lib/audio/voice-resolver'; import { isCustomTTSProvider, isCustomASRProvider } from '@/lib/audio/types'; import { ASR_PROVIDERS, DEFAULT_TTS_VOICES, isQwenCatalogVoice, isQwenVoiceCloneModel, 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, buildWebSearchFallbackOrder } from '@/lib/web-search/constants'; import type { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types'; import { createLogger } from '@/lib/logger'; import { validateProvider, resolveSelectedModel, isLLMProviderConfigured, } from '@/lib/store/settings-validation'; import { createKVPersistStorage, purgeLegacyPersistKey } from '@/lib/store/kv-persist'; const log = createLogger('Settings'); /** Persisted-blob version for zustand's `persist` `migrate` ladder. */ const SETTINGS_PERSIST_VERSION = 4; /** * Bound after the store exists; see `onWriteRefused` for why it is not inlined. * The explicit annotation is what breaks the type cycle — inferring this from * the store would put the store back in its own definition. */ const recovery: { rehydrate?: () => void | Promise } = {}; 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; /** Admin/server-level force-off (server-providers.yml / env). Overrides `enabled`. */ serverDisabled?: boolean; // 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; /** Admin/server-level force-off (server-providers.yml / env). Overrides `enabled`. */ serverDisabled?: boolean; // Custom provider fields customName?: string; customDefaultBaseUrl?: string; isBuiltIn?: boolean; requiresApiKey?: boolean; } >; // PDF settings pdfProviderId: PDFProviderId; pdfProvidersConfig: Record< PDFProviderId, { apiKey: string; baseUrl: string; enabled: boolean; requiresApiKey?: boolean; isServerConfigured?: boolean; /** Aliyun AccessKey ID (AliDocMind). */ accessKeyId?: string; /** Aliyun AccessKey Secret (AliDocMind). */ accessKeySecret?: string; } >; baiduSubSources: BaiduSubSources; // Image Generation settings imageProviderId: ImageProviderId; imageModelId: string; imageProvidersConfig: Record< ImageProviderId, { apiKey: string; baseUrl: string; enabled: boolean; isServerConfigured?: boolean; /** Admin/server-level force-off (server-providers.yml / env). Overrides `enabled`. */ serverDisabled?: boolean; customModels?: Array<{ id: string; name: string }>; replaceBuiltInModels?: boolean; } >; // Video Generation settings videoProviderId: VideoProviderId; videoModelId: string; videoProvidersConfig: Record< VideoProviderId, { apiKey: string; baseUrl: string; enabled: boolean; isServerConfigured?: boolean; /** Admin/server-level force-off (server-providers.yml / env). Overrides `enabled`. */ serverDisabled?: boolean; customModels?: Array<{ id: string; name: string }>; replaceBuiltInModels?: boolean; } >; // Media generation toggles imageGenerationEnabled: boolean; videoGenerationEnabled: boolean; reviewOutlineEnabled: boolean; // Web Search settings webSearchProviderId: WebSearchProviderId; webSearchProvidersConfig: Record< WebSearchProviderId, { apiKey: string; baseUrl: string; enabled: boolean; requiresApiKey?: boolean; isServerConfigured?: boolean; /** Admin/server-level force-off (server-providers.yml / env). Overrides `enabled`. */ serverDisabled?: boolean; /** Model-based providers only (Claude): the model that runs the search. */ modelId?: string; } >; // Global TTS/ASR toggles ttsEnabled: boolean; asrEnabled: boolean; // Server-configured opt-in parallel scene-content concurrency (#572). // 0 = off (serial generation); populated by fetchServerProviders. parallelSceneConcurrency: number; // 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[]; agentMode: 'preset' | 'auto'; autoAgentCount: number; /** * Per-agent voice picks made in the AgentBar, keyed by agent id. Lives here * (persisted) rather than on registry AgentConfig records because default * agents are reset from code and generated agents are rebuilt from IndexedDB * on every load. Highest-priority input to resolveAgentVoice. */ agentVoiceOverrides: Record; /** * Whether agentMode/selectedAgentIds were explicitly set by the user (in the * AgentBar), as opposed to stage-derived defaults written by a classroom * load. Only a user-set selection carries across classrooms on restore. */ agentSelectionIsUserSet: boolean; // Layout preferences (persisted via localStorage) sidebarCollapsed: boolean; chatAreaCollapsed: boolean; chatAreaWidth: number; editRailCollapsed: boolean; editRailWidth: 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; setAgentMode: (mode: 'preset' | 'auto') => void; setAutoAgentCount: (count: number) => void; /** Set (or clear, with `undefined`) the persisted voice pick for one agent. */ setAgentVoiceOverride: (agentId: string, voice: AgentVoiceOverride | undefined) => void; setAgentSelectionIsUserSet: (isUserSet: boolean) => void; // Layout actions setSidebarCollapsed: (collapsed: boolean) => void; setChatAreaCollapsed: (collapsed: boolean) => void; setChatAreaWidth: (width: number) => void; setEditRailCollapsed: (collapsed: boolean) => void; setEditRailWidth: (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; accessKeyId: string; accessKeySecret: string; }>, ) => 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 }>; replaceBuiltInModels: boolean; }>, ) => 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 }>; replaceBuiltInModels: boolean; }>, ) => void; // Media generation toggle actions setImageGenerationEnabled: (enabled: boolean) => void; setVideoGenerationEnabled: (enabled: boolean) => void; setReviewOutlineEnabled: (enabled: boolean) => void; // Web Search actions setWebSearchProvider: (providerId: WebSearchProviderId) => void; setWebSearchProviderConfig: ( providerId: WebSearchProviderId, config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean; modelId: string }>, ) => void; setBaiduSubSources: (sources: Partial) => 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; }; /** * Single shared LLM selection resolver (#580). Given a providers config and * the current (providerId, modelId), return the resolved selection that * upholds the invariant for ANY config mutation — clearing/adding a key, * editing models, deleting a provider, import, reset: * * - keep the current provider if it is still usable; * - else fall back to the first usable provider; * - else State A (empty selection). * * Then resolve a concrete model for the chosen provider. Used by both * setProviderConfig (single edit) and setProvidersConfig (bulk) so the two * paths can never diverge — the per-call-site asymmetry #580 set out to kill. */ function resolveLLMSelection( config: ProvidersConfig, currentProviderId: ProviderId, currentModelId: string, ): { providerId: ProviderId; modelId: string } { const isUsable = (id: ProviderId) => !!config[id] && isLLMProviderConfigured(config[id]); const providerId = isUsable(currentProviderId) ? currentProviderId : ((Object.keys(config) as ProviderId[]).find(isUsable) ?? ('' as ProviderId)); const modelId = providerId ? resolveSelectedLLMModel(providerId, currentModelId, config[providerId]?.models ?? []) : ''; return { providerId, modelId }; } /** Keep the caller's wire model ID when it resolves to a known catalog alias. */ function resolveSelectedLLMModel( providerId: ProviderId, currentModelId: string, availableModels: Array<{ id: string }>, ): string { if (availableModels.some((model) => model.id === currentModelId)) return currentModelId; const canonicalModelId = getCanonicalModelId(providerId, currentModelId); if ( canonicalModelId !== currentModelId && availableModels.some((model) => model.id === canonicalModelId) ) { return currentModelId; } return availableModels[0]?.id ?? ''; } function resolveMediaModels( builtInModels: T[], config?: { customModels?: T[]; replaceBuiltInModels?: boolean }, ): T[] { const customModels = config?.customModels ?? []; return config?.replaceBuiltInModels && customModels.length > 0 ? customModels : [...builtInModels, ...customModels]; } function isUsableMediaProvider( provider: { requiresApiKey: boolean } | undefined, config: { apiKey?: string; enabled?: boolean; isServerConfigured?: boolean } | undefined, ): boolean { if (!provider || config?.enabled === false) return false; return !provider.requiresApiKey || !!config?.apiKey || !!config?.isServerConfigured; } // 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: { // Built-in providers default enabled:true — they only ever surface once // configured (API key or server-managed), so "enabled" is a user opt-OUT, // not the visibility gate. A server-configured provider must not be hidden // by a stale default (#665). 'openai-tts': { apiKey: '', baseUrl: '', enabled: true }, 'azure-tts': { apiKey: '', baseUrl: '', enabled: true }, 'glm-tts': { apiKey: '', baseUrl: '', enabled: true }, 'qwen-tts': { apiKey: '', baseUrl: '', enabled: true }, 'voxcpm-tts': { apiKey: '', baseUrl: '', modelId: VOXCPM_VLLM_MODEL_ID, enabled: true, providerOptions: { backend: DEFAULT_VOXCPM_BACKEND }, }, 'doubao-tts': { apiKey: '', baseUrl: '', enabled: true }, 'elevenlabs-tts': { apiKey: '', baseUrl: '', enabled: true }, 'minimax-tts': { apiKey: '', baseUrl: '', modelId: 'speech-2.8-hd', enabled: true }, 'lemonade-tts': { apiKey: '', baseUrl: '', modelId: 'kokoro-v1', enabled: true, }, // Browser-native is OFF by default — fully opt-in. Native voice quality is // poor; it must never be a silent default (#665). 'browser-native-tts': { apiKey: '', baseUrl: '', enabled: false }, } 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 }, 'azure-asr': { apiKey: '', baseUrl: '', enabled: false }, 'funasr-asr': { apiKey: '', baseUrl: '', enabled: false }, 'lemonade-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 }, alidocmind: { apiKey: '', baseUrl: '', enabled: false, accessKeyId: '', accessKeySecret: '' }, } as Record< PDFProviderId, { apiKey: string; baseUrl: string; enabled: boolean; accessKeyId?: string; accessKeySecret?: string; } >, }); // 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 }, 'comfyui-image': { apiKey: '', baseUrl: '', enabled: false }, lemonade: { apiKey: '', baseUrl: '', enabled: false }, } as Record, }); // Initialize default Video config const getDefaultVideoConfig = () => ({ videoProviderId: 'seedance' as VideoProviderId, videoModelId: 'doubao-seedance-2-0-260128', videoProvidersConfig: { seedance: { apiKey: '', baseUrl: '', enabled: false }, kling: { apiKey: '', baseUrl: '', enabled: false }, veo: { apiKey: '', baseUrl: '', enabled: false }, 'minimax-video': { apiKey: '', baseUrl: '', enabled: false }, 'grok-video': { apiKey: '', baseUrl: '', enabled: false }, happyhorse: { apiKey: '', baseUrl: '', enabled: false }, } as Record, }); // Initialize default Web Search config const getDefaultWebSearchConfig = () => ({ webSearchProviderId: 'tavily' as WebSearchProviderId, webSearchProvidersConfig: { tavily: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: true }, bocha: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: true }, brave: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.brave.defaultBaseUrl || '', enabled: true, requiresApiKey: false, }, baidu: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: true }, claude: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: true, modelId: '', }, minimax: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.minimax.defaultBaseUrl || '', enabled: true, requiresApiKey: true, }, doubao: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.doubao.defaultBaseUrl || '', enabled: true, requiresApiKey: true, }, searxng: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: false, }, } as Record< WebSearchProviderId, { apiKey: string; baseUrl: string; enabled: boolean; requiresApiKey?: boolean; modelId?: string; } >, baiduSubSources: { webSearch: true, baike: true, scholar: true, } as BaiduSubSources, }); /** * 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; } ensureBaiduSubSources(state); 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]; } }); } /** * Backfill built-in PDF/document providers into persisted state. Without this, * a provider added after a user's persisted state was written (e.g. AliDocMind) * never appears in `pdfProvidersConfig`, so it can't be selected and never * picks up its server-configured flag on rehydrate. */ function ensureBuiltInPDFProviders(state: Partial): void { if (!state.pdfProvidersConfig) return; const defaultConfig = getDefaultPDFConfig().pdfProvidersConfig; Object.keys(PDF_PROVIDERS).forEach((pid) => { const providerId = pid as PDFProviderId; if (!state.pdfProvidersConfig![providerId]) { state.pdfProvidersConfig![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]; } }); } /** * Ensure webSearchProvidersConfig includes all built-in web search providers. * Called on every rehydrate so newly added providers appear automatically. */ function ensureBuiltInWebSearchProviders(state: Partial): void { if (!state.webSearchProvidersConfig) return; const defaultConfig = getDefaultWebSearchConfig().webSearchProvidersConfig; Object.keys(WEB_SEARCH_PROVIDERS).forEach((pid) => { const providerId = pid as WebSearchProviderId; if (!state.webSearchProvidersConfig![providerId]) { state.webSearchProvidersConfig![providerId] = defaultConfig[providerId]; } else { state.webSearchProvidersConfig![providerId] = { ...state.webSearchProvidersConfig![providerId], requiresApiKey: WEB_SEARCH_PROVIDERS[providerId].requiresApiKey, }; } }); } function ensureBaiduSubSources(state: Partial): void { const defaults = getDefaultWebSearchConfig().baiduSubSources; const current = state.baiduSubSources; state.baiduSubSources = { webSearch: current?.webSearch ?? defaults.webSearch, baike: current?.baike ?? defaults.baike, scholar: current?.scholar ?? defaults.scholar, }; } /** * Strip the removed `serverBaseUrl` field from any persisted provider config. * * Managed providers no longer expose their base URL to the client (#620). Old * localStorage may still carry a `serverBaseUrl` on provider entries; this * clears it on every rehydrate so a stale server URL can't linger in client * state. Called from both migrate and merge to cover all rehydration paths. */ function stripLegacyServerBaseUrl(state: Partial): void { const maps = [ state.providersConfig, state.ttsProvidersConfig, state.asrProvidersConfig, state.pdfProvidersConfig, state.imageProvidersConfig, state.videoProvidersConfig, state.webSearchProvidersConfig, ]; for (const map of maps) { if (!map) continue; for (const cfg of Object.values(map as Record>)) { if (cfg && 'serverBaseUrl' in cfg) delete cfg.serverBaseUrl; } } } export const useSettingsStore = create()( persist( (set, get) => { const defaultAudioConfig = getDefaultAudioConfig(); const defaultPDFConfig = getDefaultPDFConfig(); const defaultImageConfig = getDefaultImageConfig(); const defaultVideoConfig = getDefaultVideoConfig(); const defaultWebSearchConfig = getDefaultWebSearchConfig(); return { // Initial state is plain defaults. This store does not migrate any // pre-cutover localStorage data — everything persisted arrives through // the KVStore on rehydration; an upgrading user reconfigures once. providerId: 'openai' as ProviderId, modelId: '', thinkingConfigs: {}, providersConfig: getDefaultProvidersConfig(), ttsModel: 'openai-tts', selectedAgentIds: ['default-1', 'default-2', 'default-3'], agentMode: 'auto' as const, autoAgentCount: 3, agentVoiceOverrides: {}, agentSelectionIsUserSet: false, // Playback controls ttsMuted: false, ttsVolume: 1, autoPlayLecture: false, playbackSpeed: 1, // Layout preferences sidebarCollapsed: true, chatAreaCollapsed: true, chatAreaWidth: 320, editRailCollapsed: false, editRailWidth: 220, // 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, reviewOutlineEnabled: false, // TTS is OFF by default; auto-enabled on first server-sync when a TTS // provider is configured (mirrors image/video). Fresh installs with no // provider stay off and show an "enable browser-native" CTA (#665). ttsEnabled: false, asrEnabled: true, // Off until the server reports a concurrency via fetchServerProviders. parallelSceneConcurrency: 0, 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, }, }; // Re-resolve through the shared resolver (#580): a single config // edit can make the active provider usable (adopt + pick a model), // make it INVALID — e.g. the user clears its API key — (fall back // to another usable provider or State A), or change its model list // (re-pick the model). All handled atomically here, never leaving // an invalid/stale (provider, model) selected. const { providerId: nextProvider, modelId: nextModel } = resolveLLMSelection( providersConfig, state.providerId, state.modelId, ); return { providersConfig, thinkingConfigs: pruneThinkingConfigs(state.thinkingConfigs, providersConfig), ...(nextProvider !== state.providerId && { providerId: nextProvider }), ...(nextModel !== state.modelId && { modelId: nextModel }), }; }), setProvidersConfig: (config) => set((state) => { // Bulk config replace (delete provider/model, import, reset): same // shared resolver as setProviderConfig so the two paths can never // diverge — never leave the deleted/invalid provider selected. const { providerId: nextProvider, modelId: nextModel } = resolveLLMSelection( config, state.providerId, state.modelId, ); return { providersConfig: config, thinkingConfigs: pruneThinkingConfigs(state.thinkingConfigs, config), ...(nextProvider !== state.providerId && { providerId: nextProvider }), ...(nextModel !== state.modelId && { modelId: nextModel }), }; }), 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 }), setAgentMode: (mode) => set({ agentMode: mode }), setAutoAgentCount: (count) => set({ autoAgentCount: count }), setAgentVoiceOverride: (agentId, voice) => set((state) => { const next = { ...state.agentVoiceOverrides }; if (voice) { next[agentId] = voice; } else { delete next[agentId]; } return { agentVoiceOverrides: next }; }), setAgentSelectionIsUserSet: (isUserSet) => set({ agentSelectionIsUserSet: isUserSet }), // Layout actions setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), setChatAreaCollapsed: (collapsed) => set({ chatAreaCollapsed: collapsed }), setEditRailCollapsed: (collapsed) => set({ editRailCollapsed: collapsed }), setEditRailWidth: (width) => set({ editRailWidth: width }), 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 }), ...(providerId === 'qwen-tts' && isQwenCatalogVoice(defaultVoice) && isQwenVoiceCloneModel(state.ttsProvidersConfig['qwen-tts']?.modelId) ? { ttsProvidersConfig: { ...state.ttsProvidersConfig, 'qwen-tts': { ...state.ttsProvidersConfig['qwen-tts'], modelId: TTS_PROVIDERS['qwen-tts'].defaultModelId, }, }, } : {}), }; }), setTTSVoice: (voice) => set((state) => ({ ttsVoice: voice, ...(state.ttsProviderId === 'qwen-tts' && isQwenCatalogVoice(voice) && isQwenVoiceCloneModel(state.ttsProvidersConfig['qwen-tts']?.modelId) ? { ttsProvidersConfig: { ...state.ttsProvidersConfig, 'qwen-tts': { ...state.ttsProvidersConfig['qwen-tts'], modelId: TTS_PROVIDERS['qwen-tts'].defaultModelId, }, }, } : {}), })), 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) => { const ttsProvidersConfig = { ...state.ttsProvidersConfig, [providerId]: { ...state.ttsProvidersConfig[providerId], ...config, }, }; // Disabling the active provider (e.g. removing a token plan) switches // the selection back to the always-available browser TTS so playback // doesn't keep pointing at a disabled provider with an empty key. if (state.ttsProviderId === providerId && config.enabled === false) { return { ttsProvidersConfig, ttsProviderId: getDefaultAudioConfig().ttsProviderId, ttsVoice: 'default', }; } return { ttsProvidersConfig }; }), 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((state) => ({ imageProviderId: providerId, imageModelId: resolveSelectedModel( state.imageModelId, resolveMediaModels( IMAGE_PROVIDERS[providerId]?.models ?? [], state.imageProvidersConfig[providerId], ), ), })), setImageModelId: (modelId) => set({ imageModelId: modelId }), setImageProviderConfig: (providerId, config) => set((state) => { const mergedProvider = { ...state.imageProvidersConfig[providerId], ...config, }; const imageProvidersConfig = { ...state.imageProvidersConfig, [providerId]: mergedProvider, }; const base = { imageProvidersConfig }; if (state.imageProviderId === providerId) { // Disabling the active provider (e.g. removing a token plan) must // switch the selection away to the default, or generation paths // keep pointing at a disabled provider with an empty key. if (config.enabled === false) { const providerIds = Object.keys(IMAGE_PROVIDERS) as ImageProviderId[]; const usableFallback = providerIds.find( (id) => id !== providerId && isUsableMediaProvider(IMAGE_PROVIDERS[id], imageProvidersConfig[id]), ); const fallback = usableFallback ?? providerIds.find((id) => id !== providerId) ?? providerId; const fallbackModels = resolveMediaModels( IMAGE_PROVIDERS[fallback]?.models ?? [], imageProvidersConfig[fallback], ); return { ...base, imageProviderId: fallback, imageModelId: resolveSelectedModel(state.imageModelId, fallbackModels), ...(!usableFallback ? { imageGenerationEnabled: false } : {}), }; } // Atomic invariant (#580): a config edit on the active image // provider (e.g. deleting the selected custom model) must not // leave imageModelId pointing at a model that no longer exists. const models = resolveMediaModels( IMAGE_PROVIDERS[providerId]?.models ?? [], mergedProvider, ); const imageModelId = resolveSelectedModel(state.imageModelId, models); if (imageModelId) { return { ...base, imageModelId }; } } return base; }), // Video Generation actions setVideoProvider: (providerId) => set((state) => ({ videoProviderId: providerId, videoModelId: resolveSelectedModel( state.videoModelId, resolveMediaModels( VIDEO_PROVIDERS[providerId]?.models ?? [], state.videoProvidersConfig[providerId], ), ), })), setVideoModelId: (modelId) => set({ videoModelId: modelId }), setVideoProviderConfig: (providerId, config) => set((state) => { const mergedProvider = { ...state.videoProvidersConfig[providerId], ...config, }; const videoProvidersConfig = { ...state.videoProvidersConfig, [providerId]: mergedProvider, }; const base = { videoProvidersConfig }; if (state.videoProviderId === providerId) { // Symmetric with image: disabling the active provider switches the // selection back to the default so nothing keeps pointing at a // disabled provider with an empty key. if (config.enabled === false) { const providerIds = Object.keys(VIDEO_PROVIDERS) as VideoProviderId[]; const usableFallback = providerIds.find( (id) => id !== providerId && isUsableMediaProvider(VIDEO_PROVIDERS[id], videoProvidersConfig[id]), ); const fallback = usableFallback ?? providerIds.find((id) => id !== providerId) ?? providerId; const fallbackModels = resolveMediaModels( VIDEO_PROVIDERS[fallback]?.models ?? [], videoProvidersConfig[fallback], ); return { ...base, videoProviderId: fallback, videoModelId: resolveSelectedModel(state.videoModelId, fallbackModels), ...(!usableFallback ? { videoGenerationEnabled: false } : {}), }; } // Atomic invariant (#580): symmetric with image — a config edit on // the active video provider must not leave videoModelId stale. const models = resolveMediaModels( VIDEO_PROVIDERS[providerId]?.models ?? [], mergedProvider, ); const videoModelId = resolveSelectedModel(state.videoModelId, models); if (videoModelId) { return { ...base, videoModelId }; } } return base; }), // 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 }); }, setReviewOutlineEnabled: (enabled) => set({ reviewOutlineEnabled: 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) => { const webSearchProvidersConfig = { ...state.webSearchProvidersConfig, [providerId]: { ...state.webSearchProvidersConfig[providerId], ...config, }, }; // Disabling the active provider switches the selection back to the // default so web search doesn't keep pointing at a disabled provider. if (state.webSearchProviderId === providerId && config.enabled === false) { return { webSearchProvidersConfig, webSearchProviderId: getDefaultWebSearchConfig().webSearchProviderId, }; } return { webSearchProvidersConfig }; }), setBaiduSubSources: (sources) => set((state) => { const next = { ...state.baiduSubSources, ...sources, }; if (!next.webSearch && !next.baike && !next.scholar) { return state; } return { baiduSubSources: next }; }), // Fetch server-configured providers and merge into local state fetchServerProviders: async () => { try { const res = await fetch('/api/server-providers'); if (!res.ok) return; // Managed providers expose only their allowed model list (LLM/image) // and presence (the "managed" flag) — never a base URL. Every // capability section carries an optional `disabled` flag for // admin/server-level force-off (#665). const data = (await res.json()) as { providers: Record; tts: Record; asr: Record; pdf: Record>; image: Record; video: Record; webSearch: Record; generation?: { parallelSceneConcurrency?: number }; }; 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, }; } } // 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 filteredModels = info.models?.length ? info.models.map((id) => { const currentModel = findModelById(key, currentModels, id); const builtInModel = findModelById(key, PROVIDERS[key]?.models, id); const model = currentModel && builtInModel ? { ...builtInModel, ...currentModel, name: currentModel.name === currentModel.id ? builtInModel.name : currentModel.name, capabilities: { ...builtInModel.capabilities, ...currentModel.capabilities, }, } : (currentModel ?? builtInModel); return model ? { ...model, id, name: model.name || id } : { id, name: id }; }) : currentModels; newProvidersConfig[key] = { ...newProvidersConfig[key], isServerConfigured: true, serverModels: info.models, models: filteredModels, }; } } // Merge TTS providers. Reset both server flags first, then apply: // an entry with `disabled` is force-off (server precedence) and is // NOT treated as managed/configured; any other entry is managed. 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, serverDisabled: false, }; } } for (const [pid, info] of Object.entries(data.tts)) { const key = pid as TTSProviderId; if (newTTSConfig[key]) { newTTSConfig[key] = { ...newTTSConfig[key], isServerConfigured: !info.disabled, serverDisabled: info.disabled === true, }; } } // Merge ASR providers. Reset both server flags first, then apply: // an entry with `disabled` is force-off (server precedence) and is // NOT treated as managed/configured, mirroring the TTS merge. 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, serverDisabled: false, }; } } for (const [pid, info] of Object.entries(data.asr)) { const key = pid as ASRProviderId; if (newASRConfig[key]) { newASRConfig[key] = { ...newASRConfig[key], isServerConfigured: !info.disabled, serverDisabled: info.disabled === true, }; } } // 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, }; } } for (const pid of Object.keys(data.pdf)) { const key = pid as PDFProviderId; if (newPDFConfig[key]) { newPDFConfig[key] = { ...newPDFConfig[key], isServerConfigured: true, }; } } // Merge Image providers. Reset both server flags first, then // apply: `disabled` is force-off (server precedence), mirroring // the TTS merge. 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, serverDisabled: false, }; } } for (const [pid, info] of Object.entries(data.image)) { const key = pid as ImageProviderId; if (newImageConfig[key]) { newImageConfig[key] = { ...newImageConfig[key], isServerConfigured: !info.disabled, serverDisabled: info.disabled === true, }; } } // Merge Video providers. Reset both server flags first, then // apply: `disabled` is force-off (server precedence), mirroring // the TTS merge. 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, serverDisabled: false, }; } } 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: !info.disabled, serverDisabled: info.disabled === true, }; } } } // Merge Web Search config — reset all first, then mark // server-configured; `disabled` is force-off (server precedence), // mirroring the TTS merge. const newWebSearchConfig = { ...state.webSearchProvidersConfig }; for (const key of Object.keys(newWebSearchConfig) as WebSearchProviderId[]) { newWebSearchConfig[key] = { ...newWebSearchConfig[key], isServerConfigured: false, serverDisabled: false, }; } 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: !info.disabled, serverDisabled: info.disabled === true, }; } } } // === Validate current selections against updated configs === // Build fallback: server-configured first, then client-key-only const buildFallback = ( config: Record< string, { isServerConfigured?: boolean; apiKey?: string; serverDisabled?: boolean } >, ): T[] => [ // Server-disabled providers are never fallback targets. ...Object.entries(config) .filter(([, c]) => c.isServerConfigured && !c.serverDisabled) .map(([id]) => id as T), ...Object.entries(config) .filter(([, c]) => !c.isServerConfigured && !c.serverDisabled && !!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 webSearchFallback = buildWebSearchFallbackOrder(newWebSearchConfig); let 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, ); const validWebSearchProvider = validateProvider( state.webSearchProviderId, newWebSearchConfig, webSearchFallback, 'tavily' as WebSearchProviderId, ); // Auto-recover: when the selected provider is empty/unusable but // a usable one exists, adopt the first usable fallback. Applied // symmetrically to LLM/image/video so that "usable provider ⇒ a // concrete model is selected" holds for every modality (#580). if (!validLLMProvider && llmFallback.length > 0) { validLLMProvider = llmFallback[0]; } if (!validImageProvider && imageFallback.length > 0) { validImageProvider = imageFallback[0]; } if (!validVideoProvider && videoFallback.length > 0) { validVideoProvider = videoFallback[0]; } // Resolve the model in the same place the provider is resolved. // resolveSelectedModel never yields '' when the provider has ≥1 // model, so a usable provider can never settle with an empty model. const llmModels = validLLMProvider ? (newProvidersConfig[validLLMProvider as ProviderId]?.models ?? []) : []; const validLLMModel = validLLMProvider ? resolveSelectedLLMModel(validLLMProvider as ProviderId, state.modelId, llmModels) : ''; const imageModels = validImageProvider ? resolveMediaModels( IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? [], newImageConfig[validImageProvider as ImageProviderId], ) : []; const validImageModel = validImageProvider ? resolveSelectedModel(state.imageModelId, imageModels) : ''; const videoModels = validVideoProvider ? resolveMediaModels( VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? [], newVideoConfig[validVideoProvider as VideoProviderId], ) : []; const validVideoModel = validVideoProvider ? resolveSelectedModel(state.videoModelId, videoModels) : ''; 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; let autoTtsEnabled: 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. // Skip server-disabled entries — they are force-off, not selectable. const serverTtsIds = Object.entries(data.tts) .filter(([, info]) => !info.disabled) .map(([id]) => id) as TTSProviderId[]; if ( serverTtsIds.length > 0 && !newTTSConfig[state.ttsProviderId]?.isServerConfigured ) { autoTtsProvider = serverTtsIds[0]; autoTtsVoice = DEFAULT_TTS_VOICES[autoTtsProvider as BuiltInTTSProviderId] || 'default'; } // Auto-enable TTS on first run when a server provider exists // (mirrors image/video). No provider ⇒ stays off + CTA. if (serverTtsIds.length > 0 && !state.ttsEnabled) { autoTtsEnabled = true; } // ASR: select first server provider if current is not // server-configured. Skip server-disabled entries — they are // force-off, not selectable. const serverAsrIds = Object.entries(data.asr) .filter(([, info]) => !info.disabled) .map(([id]) => id) as ASRProviderId[]; if ( serverAsrIds.length > 0 && !newASRConfig[state.asrProviderId]?.isServerConfigured ) { autoAsrProvider = serverAsrIds[0]; } // Image: first server provider. Skip server-disabled entries — // they are force-off, not selectable. const serverImageIds = Object.entries(data.image) .filter(([, info]) => !info.disabled) .map(([id]) => id) 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. Skip server-disabled entries — // they are force-off, not selectable. const serverVideoIds = Object.entries(data.video || {}) .filter(([, info]) => !info.disabled) .map(([id]) => id) 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 first-load auto-select removed: the symmetric provider // recovery + resolveSelectedModel above now resolve LLM provider // and model atomically at the source, covering server-configured // AND client-API-key providers — see #580.) return { providersConfig: newProvidersConfig, ttsProvidersConfig: newTTSConfig, asrProvidersConfig: newASRConfig, pdfProvidersConfig: newPDFConfig, imageProvidersConfig: newImageConfig, videoProvidersConfig: newVideoConfig, webSearchProvidersConfig: newWebSearchConfig, // Already clamped server-side (getParallelSceneConcurrency); this // re-clamp is intentional belt-and-suspenders against a malformed // response. The consumer (use-scene-generator) clamps once more. parallelSceneConcurrency: Math.max( 0, Math.floor(data.generation?.parallelSceneConcurrency ?? 0), ), 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, }), ...(validWebSearchProvider !== state.webSearchProviderId && { webSearchProviderId: validWebSearchProvider as WebSearchProviderId, }), ...(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, }), ...(autoTtsEnabled !== undefined && { ttsEnabled: autoTtsEnabled }), }; }); } catch (e) { // Silently fail — server providers are optional log.warn('Failed to fetch server providers:', e); } }, }; }, { name: 'settings-storage', // `Partial` because `migrate` below returns a partial — // that is what zustand infers as the persisted shape here. storage: createKVPersistStorage>('account', { // One recovery attempt when a write is refused because hydration never // succeeded — the backend may have come back since. Routed through a // variable assigned below rather than naming the store directly: a // self-reference here would make the store's own type circular, and // every `useSettingsStore(s => ...)` selector would silently widen to // `any`. onWriteRefused: () => recovery.rehydrate?.(), }), version: SETTINGS_PERSIST_VERSION, // 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); ensureBuiltInPDFProviders(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); ensureBuiltInWebSearchProviders(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; } if (state.reviewOutlineEnabled === undefined) { state.reviewOutlineEnabled = false; } // Add default audio toggles if missing. TTS defaults OFF (opt-in / CTA); // first server-sync auto-enables it when a provider is configured (#665). if ((state as Record).ttsEnabled === undefined) { (state as Record).ttsEnabled = false; } 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, requiresApiKey: true, isServerConfigured: oldIsServerConfigured, }, bocha: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: true, }, brave: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.brave.defaultBaseUrl || '', enabled: true, requiresApiKey: false, }, baidu: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: true, }, minimax: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.minimax.defaultBaseUrl || '', enabled: true, requiresApiKey: true, }, doubao: { apiKey: '', baseUrl: WEB_SEARCH_PROVIDERS.doubao.defaultBaseUrl || '', enabled: true, requiresApiKey: true, }, searxng: { apiKey: '', baseUrl: '', enabled: true, requiresApiKey: false, }, } as SettingsState['webSearchProvidersConfig']; delete stateRecord.webSearchApiKey; delete stateRecord.webSearchIsServerConfigured; } // v2 → v3: managed providers no longer expose a base URL to the client; // drop any persisted serverBaseUrl left over from older versions (#620). stripLegacyServerBaseUrl(state); // v3 → v4: the per-provider `enabled` flag becomes live under the // unified enablement model (#665). Before v4 it was never user-editable, // so any persisted value is just a stale default — normalize it: // browser-native OFF (opt-in), every other built-in ON (it only surfaces // once configured, so a server-managed provider must not stay hidden). if (version < 4 && state.ttsProvidersConfig) { for (const pid of Object.keys(TTS_PROVIDERS) as BuiltInTTSProviderId[]) { const cfg = state.ttsProvidersConfig[pid]; if (cfg) cfg.enabled = pid !== 'browser-native-tts'; } } ensureValidProviderSelections(state); ensureBuiltInAudioProviders(state); ensureBuiltInWebSearchProviders(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) => { // The insert toolbar is draggable and no longer collapses. Sanitize // this retired property on every rehydrate instead of bumping the // storage version and replaying unrelated legacy migrations. const persisted = { ...(persistedState as object) } as Record; delete persisted.editInsertToolbarCollapsed; const merged = { ...currentState, ...persisted }; ensureBuiltInProviders(merged as Partial); promoteLegacyCustomProviderBaseUrls(merged as Partial); ensureBuiltInAudioProviders(merged as Partial); ensureBuiltInImageProviders(merged as Partial); ensureBuiltInVideoProviders(merged as Partial); ensureBuiltInPDFProviders(merged as Partial); ensureBuiltInWebSearchProviders(merged as Partial); ensureValidProviderSelections(merged as Partial); stripLegacyServerBaseUrl(merged as Partial); const typedMerged = merged as Partial; typedMerged.thinkingConfigs = pruneThinkingConfigs( typedMerged.thinkingConfigs, typedMerged.providersConfig, ); return merged as SettingsState; }, }, ), ); // Bound after the store exists so the `onWriteRefused` hook above stays free of // a self-reference (see the comment there). recovery.rehydrate = () => useSettingsStore.persist.rehydrate(); // Best-effort, fire-and-forget: drop the pre-cutover raw `localStorage` blob. // It is never read (this store does not migrate legacy data), and the old blob // holds plaintext provider API keys, so clearing it is a small security win. No // correctness depends on it. purgeLegacyPersistKey('settings-storage');