Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

2025 lines
78 KiB
TypeScript

/**
* 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 type { AgentVoiceOverride } from '@/lib/audio/voice-resolver';
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 { BaiduSubSources, WebSearchProviderId } from '@/lib/web-search/types';
import { createLogger } from '@/lib/logger';
import {
validateProvider,
resolveSelectedModel,
isLLMProviderConfigured,
} from '@/lib/store/settings-validation';
const log = createLogger('Settings');
function pruneThinkingConfigs(
thinkingConfigs: Record<string, ThinkingConfig> | undefined,
providersConfig: ProvidersConfig | undefined,
): Record<string, ThinkingConfig> {
if (!thinkingConfigs || !providersConfig) return {};
const validKeys = new Set<string>();
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<string, ThinkingConfig>;
}
/** 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<string, ThinkingConfig>;
// 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<string, unknown>;
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<string, unknown>;
isServerConfigured?: 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;
}
>;
baiduSubSources: BaiduSubSources;
// Image Generation settings
imageProviderId: ImageProviderId;
imageModelId: string;
imageProvidersConfig: Record<
ImageProviderId,
{
apiKey: string;
baseUrl: string;
enabled: boolean;
isServerConfigured?: 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;
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;
}
>;
// 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<string, AgentVoiceOverride>;
/**
* 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;
editInsertToolbarCollapsed: boolean;
// Actions
setModel: (providerId: ProviderId, modelId: string) => void;
setThinkingConfig: (
providerId: ProviderId,
modelId: string,
config: ThinkingConfig | undefined,
) => void;
setProviderConfig: (providerId: ProviderId, config: Partial<ProvidersConfig[ProviderId]>) => 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;
setEditInsertToolbarCollapsed: (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<string, unknown>;
}>,
) => void;
setASRProviderConfig: (
providerId: ASRProviderId,
config: Partial<{
apiKey: string;
baseUrl: string;
enabled: boolean;
modelId: string;
customModels: Array<{ id: string; name: string }>;
providerOptions: Record<string, unknown>;
}>,
) => 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 }>;
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 }>,
) => void;
setBaiduSubSources: (sources: Partial<BaiduSubSources>) => void;
// Server provider actions
fetchServerProviders: () => Promise<void>;
}
// 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
? resolveSelectedModel(currentModelId, config[providerId]?.models ?? [])
: '';
return { providerId, modelId };
}
function resolveMediaModels<T extends { id: string; name: string }>(
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 },
'lemonade-asr': { apiKey: '', baseUrl: '', enabled: false },
} as Record<ASRProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
});
// 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<PDFProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
});
// 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<ImageProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
});
// 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 },
sora: { apiKey: '', baseUrl: '', enabled: false },
'minimax-video': { apiKey: '', baseUrl: '', enabled: false },
'grok-video': { apiKey: '', baseUrl: '', enabled: false },
happyhorse: { apiKey: '', baseUrl: '', enabled: false },
} as Record<VideoProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
});
// 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 },
minimax: {
apiKey: '',
baseUrl: WEB_SEARCH_PROVIDERS.minimax.defaultBaseUrl || '',
enabled: true,
requiresApiKey: true,
},
doubao: {
apiKey: '',
baseUrl: WEB_SEARCH_PROVIDERS.doubao.defaultBaseUrl || '',
enabled: true,
requiresApiKey: true,
},
} as Record<
WebSearchProviderId,
{ apiKey: string; baseUrl: string; enabled: boolean; requiresApiKey?: boolean }
>,
baiduSubSources: {
webSearch: true,
baike: true,
scholar: true,
} as BaiduSubSources,
});
/**
* Check whether a provider ID exists in the given provider registry.
*/
function hasProviderId(providerMap: Record<string, unknown>, 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<SettingsState>): 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<string, Record<string, unknown>>)) {
if (cfg && 'serverBaseUrl' in cfg) delete cfg.serverBaseUrl;
}
}
}
// 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');
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);
}
}
return {
providerId,
modelId,
thinkingConfigs: {},
providersConfig,
ttsModel,
selectedAgentIds,
};
};
export const useSettingsStore = create<SettingsState>()(
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'],
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,
editInsertToolbarCollapsed: false,
// 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 }),
setEditInsertToolbarCollapsed: (collapsed) =>
set({ editInsertToolbarCollapsed: 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) => {
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.
const data = (await res.json()) as {
providers: Record<string, { models?: string[] }>;
// TTS additionally carries an optional `disabled` flag for
// admin/server-level force-off (#665).
tts: Record<string, { disabled?: boolean }>;
asr: Record<string, Record<string, never>>;
pdf: Record<string, Record<string, never>>;
image: Record<string, { models?: string[] }>;
video: Record<string, Record<string, never>>;
webSearch: Record<string, Record<string, never>>;
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 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,
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
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,
};
}
}
for (const pid of Object.keys(data.asr)) {
const key = pid as ASRProviderId;
if (newASRConfig[key]) {
newASRConfig[key] = {
...newASRConfig[key],
isServerConfigured: 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
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,
};
}
}
for (const pid of Object.keys(data.image)) {
const key = pid as ImageProviderId;
if (newImageConfig[key]) {
newImageConfig[key] = {
...newImageConfig[key],
isServerConfigured: true,
};
}
}
// 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,
};
}
}
if (data.video) {
for (const pid of Object.keys(data.video)) {
const key = pid as VideoProviderId;
if (newVideoConfig[key]) {
newVideoConfig[key] = {
...newVideoConfig[key],
isServerConfigured: true,
};
}
}
}
// 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,
};
}
if (data.webSearch) {
for (const pid of Object.keys(data.webSearch)) {
const key = pid as WebSearchProviderId;
if (newWebSearchConfig[key]) {
newWebSearchConfig[key] = {
...newWebSearchConfig[key],
isServerConfigured: true,
};
}
}
}
// === Validate current selections against updated configs ===
// Build fallback: server-configured first, then client-key-only
const buildFallback = <T extends string>(
config: Record<
string,
{ isServerConfigured?: boolean; apiKey?: string; serverDisabled?: boolean }
>,
): T[] => [
// Server-disabled providers (TTS only) 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<ProviderId>(newProvidersConfig);
const ttsFallback = buildFallback<TTSProviderId>(newTTSConfig);
const asrFallback = buildFallback<ASRProviderId>(newASRConfig);
const pdfFallback = buildFallback<PDFProviderId>(newPDFConfig);
const imageFallback = buildFallback<ImageProviderId>(newImageConfig);
const videoFallback = buildFallback<VideoProviderId>(newVideoConfig);
const webSearchFallback = buildFallback<WebSearchProviderId>(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
? resolveSelectedModel(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
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 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',
version: 4,
// Migrate persisted state
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Partial<SettingsState>;
// 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);
ensureBuiltInWebSearchProviders(state);
// Migrate global ttsModelId to per-provider
if ((state as Record<string, unknown>).ttsModelId) {
const pid = state.ttsProviderId;
if (pid && state.ttsProvidersConfig?.[pid]) {
state.ttsProvidersConfig[pid].modelId = (state as Record<string, unknown>)
.ttsModelId as string;
}
delete (state as Record<string, unknown>).ttsModelId;
}
// Same for asrModelId
if ((state as Record<string, unknown>).asrModelId) {
const pid = state.asrProviderId;
if (pid && state.asrProvidersConfig?.[pid]) {
state.asrProvidersConfig[pid].modelId = (state as Record<string, unknown>)
.asrModelId as string;
}
delete (state as Record<string, unknown>).asrModelId;
}
// Migrate MiniMax's model field to modelId
for (const [, cfg] of Object.entries(
(state.ttsProvidersConfig as Record<string, Record<string, unknown>>) || {},
)) {
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<string, unknown>).deepResearchProviderId;
delete (state as Record<string, unknown>).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<string, unknown>).ttsEnabled === undefined) {
(state as Record<string, unknown>).ttsEnabled = false;
}
if ((state as Record<string, unknown>).asrEnabled === undefined) {
(state as Record<string, unknown>).asrEnabled = true;
}
// Existing users already have their config set up — mark auto-config as done
if ((state as Record<string, unknown>).autoConfigApplied === undefined) {
(state as Record<string, unknown>).autoConfigApplied = true;
}
if ((state as Record<string, unknown>).agentMode === undefined) {
(state as Record<string, unknown>).agentMode = 'preset';
}
if ((state as Record<string, unknown>).autoAgentCount === undefined) {
(state as Record<string, unknown>).autoAgentCount = 3;
}
if ((state as Record<string, unknown>).thinkingConfigs === undefined) {
(state as Record<string, unknown>).thinkingConfigs = {};
}
// Migrate Web Search: old flat fields → new provider-based config
if (!state.webSearchProvidersConfig) {
const stateRecord = state as Record<string, unknown>;
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,
},
} 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) => {
const merged = { ...currentState, ...(persistedState as object) };
ensureBuiltInProviders(merged as Partial<SettingsState>);
promoteLegacyCustomProviderBaseUrls(merged as Partial<SettingsState>);
ensureBuiltInAudioProviders(merged as Partial<SettingsState>);
ensureBuiltInImageProviders(merged as Partial<SettingsState>);
ensureBuiltInVideoProviders(merged as Partial<SettingsState>);
ensureBuiltInWebSearchProviders(merged as Partial<SettingsState>);
ensureValidProviderSelections(merged as Partial<SettingsState>);
stripLegacyServerBaseUrl(merged as Partial<SettingsState>);
const typedMerged = merged as Partial<SettingsState>;
typedMerged.thinkingConfigs = pruneThinkingConfigs(
typedMerged.thinkingConfigs,
typedMerged.providersConfig,
);
return merged as SettingsState;
},
},
),
);