settings.ts 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353
  1. /**
  2. * Settings Store
  3. * Global settings state synchronized with localStorage
  4. */
  5. import { create } from 'zustand';
  6. import { persist } from 'zustand/middleware';
  7. import type { ProviderId } from '@/lib/ai/providers';
  8. import type { ProvidersConfig } from '@/lib/types/settings';
  9. import { PROVIDERS } from '@/lib/ai/providers';
  10. import type { TTSProviderId, ASRProviderId } from '@/lib/audio/types';
  11. import { ASR_PROVIDERS, DEFAULT_TTS_VOICES, TTS_PROVIDERS } from '@/lib/audio/constants';
  12. import { PDF_PROVIDERS } from '@/lib/pdf/constants';
  13. import type { PDFProviderId } from '@/lib/pdf/types';
  14. import type { ImageProviderId, VideoProviderId } from '@/lib/media/types';
  15. import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
  16. import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
  17. import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
  18. import type { WebSearchProviderId } from '@/lib/web-search/types';
  19. import { createLogger } from '@/lib/logger';
  20. import { validateProvider, validateModel } from '@/lib/store/settings-validation';
  21. const log = createLogger('Settings');
  22. /** Available playback speed tiers */
  23. export const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2] as const;
  24. export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number];
  25. export interface SettingsState {
  26. // Model selection
  27. providerId: ProviderId;
  28. modelId: string;
  29. // Provider configurations (unified JSON storage)
  30. providersConfig: ProvidersConfig;
  31. // TTS settings (legacy, kept for backward compatibility)
  32. ttsModel: string;
  33. // Audio settings (new unified audio configuration)
  34. ttsProviderId: TTSProviderId;
  35. ttsVoice: string;
  36. ttsSpeed: number;
  37. asrProviderId: ASRProviderId;
  38. asrLanguage: string;
  39. // Audio provider configurations
  40. ttsProvidersConfig: Record<
  41. TTSProviderId,
  42. {
  43. apiKey: string;
  44. baseUrl: string;
  45. enabled: boolean;
  46. modelId?: string;
  47. customModels?: Array<{ id: string; name: string }>;
  48. providerOptions?: Record<string, unknown>;
  49. isServerConfigured?: boolean;
  50. serverBaseUrl?: string;
  51. }
  52. >;
  53. asrProvidersConfig: Record<
  54. ASRProviderId,
  55. {
  56. apiKey: string;
  57. baseUrl: string;
  58. enabled: boolean;
  59. modelId?: string;
  60. customModels?: Array<{ id: string; name: string }>;
  61. providerOptions?: Record<string, unknown>;
  62. isServerConfigured?: boolean;
  63. serverBaseUrl?: string;
  64. }
  65. >;
  66. // PDF settings
  67. pdfProviderId: PDFProviderId;
  68. pdfProvidersConfig: Record<
  69. PDFProviderId,
  70. {
  71. apiKey: string;
  72. baseUrl: string;
  73. enabled: boolean;
  74. isServerConfigured?: boolean;
  75. serverBaseUrl?: string;
  76. }
  77. >;
  78. // Image Generation settings
  79. imageProviderId: ImageProviderId;
  80. imageModelId: string;
  81. imageProvidersConfig: Record<
  82. ImageProviderId,
  83. {
  84. apiKey: string;
  85. baseUrl: string;
  86. enabled: boolean;
  87. isServerConfigured?: boolean;
  88. serverBaseUrl?: string;
  89. customModels?: Array<{ id: string; name: string }>;
  90. }
  91. >;
  92. // Video Generation settings
  93. videoProviderId: VideoProviderId;
  94. videoModelId: string;
  95. videoProvidersConfig: Record<
  96. VideoProviderId,
  97. {
  98. apiKey: string;
  99. baseUrl: string;
  100. enabled: boolean;
  101. isServerConfigured?: boolean;
  102. serverBaseUrl?: string;
  103. customModels?: Array<{ id: string; name: string }>;
  104. }
  105. >;
  106. // Media generation toggles
  107. imageGenerationEnabled: boolean;
  108. videoGenerationEnabled: boolean;
  109. // Web Search settings
  110. webSearchProviderId: WebSearchProviderId;
  111. webSearchProvidersConfig: Record<
  112. WebSearchProviderId,
  113. {
  114. apiKey: string;
  115. baseUrl: string;
  116. enabled: boolean;
  117. isServerConfigured?: boolean;
  118. serverBaseUrl?: string;
  119. }
  120. >;
  121. // Global TTS/ASR toggles
  122. ttsEnabled: boolean;
  123. asrEnabled: boolean;
  124. // Auto-config lifecycle flag (persisted)
  125. autoConfigApplied: boolean;
  126. // Playback controls
  127. ttsMuted: boolean;
  128. ttsVolume: number; // 0-1, actual volume level
  129. autoPlayLecture: boolean;
  130. playbackSpeed: PlaybackSpeed;
  131. // Agent settings
  132. selectedAgentIds: string[];
  133. maxTurns: string;
  134. agentMode: 'preset' | 'auto';
  135. autoAgentCount: number;
  136. // Layout preferences (persisted via localStorage)
  137. sidebarCollapsed: boolean;
  138. chatAreaCollapsed: boolean;
  139. chatAreaWidth: number;
  140. // Actions
  141. setModel: (providerId: ProviderId, modelId: string) => void;
  142. setProviderConfig: (providerId: ProviderId, config: Partial<ProvidersConfig[ProviderId]>) => void;
  143. setProvidersConfig: (config: ProvidersConfig) => void;
  144. setTtsModel: (model: string) => void;
  145. setTTSMuted: (muted: boolean) => void;
  146. setTTSVolume: (volume: number) => void;
  147. setAutoPlayLecture: (autoPlay: boolean) => void;
  148. setPlaybackSpeed: (speed: PlaybackSpeed) => void;
  149. setSelectedAgentIds: (ids: string[]) => void;
  150. setMaxTurns: (turns: string) => void;
  151. setAgentMode: (mode: 'preset' | 'auto') => void;
  152. setAutoAgentCount: (count: number) => void;
  153. // Layout actions
  154. setSidebarCollapsed: (collapsed: boolean) => void;
  155. setChatAreaCollapsed: (collapsed: boolean) => void;
  156. setChatAreaWidth: (width: number) => void;
  157. // Audio actions
  158. setTTSProvider: (providerId: TTSProviderId) => void;
  159. setTTSVoice: (voice: string) => void;
  160. setTTSSpeed: (speed: number) => void;
  161. setASRProvider: (providerId: ASRProviderId) => void;
  162. setASRLanguage: (language: string) => void;
  163. setTTSProviderConfig: (
  164. providerId: TTSProviderId,
  165. config: Partial<{
  166. apiKey: string;
  167. baseUrl: string;
  168. enabled: boolean;
  169. modelId: string;
  170. customModels: Array<{ id: string; name: string }>;
  171. providerOptions: Record<string, unknown>;
  172. }>,
  173. ) => void;
  174. setASRProviderConfig: (
  175. providerId: ASRProviderId,
  176. config: Partial<{
  177. apiKey: string;
  178. baseUrl: string;
  179. enabled: boolean;
  180. modelId: string;
  181. customModels: Array<{ id: string; name: string }>;
  182. providerOptions: Record<string, unknown>;
  183. }>,
  184. ) => void;
  185. setTTSEnabled: (enabled: boolean) => void;
  186. setASREnabled: (enabled: boolean) => void;
  187. // PDF actions
  188. setPDFProvider: (providerId: PDFProviderId) => void;
  189. setPDFProviderConfig: (
  190. providerId: PDFProviderId,
  191. config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>,
  192. ) => void;
  193. // Image Generation actions
  194. setImageProvider: (providerId: ImageProviderId) => void;
  195. setImageModelId: (modelId: string) => void;
  196. setImageProviderConfig: (
  197. providerId: ImageProviderId,
  198. config: Partial<{
  199. apiKey: string;
  200. baseUrl: string;
  201. enabled: boolean;
  202. customModels: Array<{ id: string; name: string }>;
  203. }>,
  204. ) => void;
  205. // Video Generation actions
  206. setVideoProvider: (providerId: VideoProviderId) => void;
  207. setVideoModelId: (modelId: string) => void;
  208. setVideoProviderConfig: (
  209. providerId: VideoProviderId,
  210. config: Partial<{
  211. apiKey: string;
  212. baseUrl: string;
  213. enabled: boolean;
  214. customModels: Array<{ id: string; name: string }>;
  215. }>,
  216. ) => void;
  217. // Media generation toggle actions
  218. setImageGenerationEnabled: (enabled: boolean) => void;
  219. setVideoGenerationEnabled: (enabled: boolean) => void;
  220. // Web Search actions
  221. setWebSearchProvider: (providerId: WebSearchProviderId) => void;
  222. setWebSearchProviderConfig: (
  223. providerId: WebSearchProviderId,
  224. config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>,
  225. ) => void;
  226. // Server provider actions
  227. fetchServerProviders: () => Promise<void>;
  228. }
  229. // Initialize default providers config
  230. const getDefaultProvidersConfig = (): ProvidersConfig => {
  231. const config: ProvidersConfig = {} as ProvidersConfig;
  232. Object.keys(PROVIDERS).forEach((pid) => {
  233. const provider = PROVIDERS[pid as ProviderId];
  234. config[pid as ProviderId] = {
  235. apiKey: '',
  236. baseUrl: '',
  237. models: provider.models,
  238. name: provider.name,
  239. type: provider.type,
  240. defaultBaseUrl: provider.defaultBaseUrl,
  241. icon: provider.icon,
  242. requiresApiKey: provider.requiresApiKey,
  243. isBuiltIn: true,
  244. };
  245. });
  246. return config;
  247. };
  248. // Initialize default audio config
  249. const getDefaultAudioConfig = () => ({
  250. ttsProviderId: 'browser-native-tts' as TTSProviderId,
  251. ttsVoice: 'default',
  252. ttsSpeed: 1.0,
  253. asrProviderId: 'browser-native' as ASRProviderId,
  254. asrLanguage: 'zh',
  255. ttsProvidersConfig: {
  256. 'openai-tts': { apiKey: '', baseUrl: '', enabled: true },
  257. 'azure-tts': { apiKey: '', baseUrl: '', enabled: false },
  258. 'glm-tts': { apiKey: '', baseUrl: '', enabled: false },
  259. 'qwen-tts': { apiKey: '', baseUrl: '', enabled: false },
  260. 'doubao-tts': { apiKey: '', baseUrl: '', enabled: false },
  261. 'elevenlabs-tts': { apiKey: '', baseUrl: '', enabled: false },
  262. 'minimax-tts': { apiKey: '', baseUrl: '', modelId: 'speech-2.8-hd', enabled: false },
  263. 'browser-native-tts': { apiKey: '', baseUrl: '', enabled: true },
  264. } as Record<
  265. TTSProviderId,
  266. { apiKey: string; baseUrl: string; modelId?: string; enabled: boolean }
  267. >,
  268. asrProvidersConfig: {
  269. 'openai-whisper': { apiKey: '', baseUrl: '', enabled: true },
  270. 'browser-native': { apiKey: '', baseUrl: '', enabled: true },
  271. 'qwen-asr': { apiKey: '', baseUrl: '', enabled: false },
  272. } as Record<ASRProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
  273. });
  274. // Initialize default PDF config
  275. const getDefaultPDFConfig = () => ({
  276. pdfProviderId: 'unpdf' as PDFProviderId,
  277. pdfProvidersConfig: {
  278. unpdf: { apiKey: '', baseUrl: '', enabled: true },
  279. mineru: { apiKey: '', baseUrl: '', enabled: false },
  280. } as Record<PDFProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
  281. });
  282. // Initialize default Image config
  283. const getDefaultImageConfig = () => ({
  284. imageProviderId: 'seedream' as ImageProviderId,
  285. imageModelId: 'doubao-seedream-5-0-260128',
  286. imageProvidersConfig: {
  287. seedream: { apiKey: '', baseUrl: '', enabled: false },
  288. 'qwen-image': { apiKey: '', baseUrl: '', enabled: false },
  289. 'nano-banana': { apiKey: '', baseUrl: '', enabled: false },
  290. 'minimax-image': { apiKey: '', baseUrl: '', enabled: false },
  291. 'grok-image': { apiKey: '', baseUrl: '', enabled: false },
  292. } as Record<ImageProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
  293. });
  294. // Initialize default Video config
  295. const getDefaultVideoConfig = () => ({
  296. videoProviderId: 'seedance' as VideoProviderId,
  297. videoModelId: 'doubao-seedance-1-5-pro-251215',
  298. videoProvidersConfig: {
  299. seedance: { apiKey: '', baseUrl: '', enabled: false },
  300. kling: { apiKey: '', baseUrl: '', enabled: false },
  301. veo: { apiKey: '', baseUrl: '', enabled: false },
  302. sora: { apiKey: '', baseUrl: '', enabled: false },
  303. 'minimax-video': { apiKey: '', baseUrl: '', enabled: false },
  304. 'grok-video': { apiKey: '', baseUrl: '', enabled: false },
  305. } as Record<VideoProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
  306. });
  307. // Initialize default Web Search config
  308. const getDefaultWebSearchConfig = () => ({
  309. webSearchProviderId: 'tavily' as WebSearchProviderId,
  310. webSearchProvidersConfig: {
  311. tavily: { apiKey: '', baseUrl: '', enabled: true },
  312. } as Record<WebSearchProviderId, { apiKey: string; baseUrl: string; enabled: boolean }>,
  313. });
  314. /**
  315. * Check whether a provider ID exists in the given provider registry.
  316. */
  317. function hasProviderId(providerMap: Record<string, unknown>, providerId?: string): boolean {
  318. return typeof providerId === 'string' && providerId in providerMap;
  319. }
  320. /**
  321. * Validate all persisted provider IDs against their registries.
  322. * Reset any stale / removed ID back to its default value.
  323. * Called during both migrate and merge to cover all rehydration paths.
  324. */
  325. function ensureValidProviderSelections(state: Partial<SettingsState>): void {
  326. const defaultAudioConfig = getDefaultAudioConfig();
  327. const defaultPdfConfig = getDefaultPDFConfig();
  328. const defaultImageConfig = getDefaultImageConfig();
  329. const defaultVideoConfig = getDefaultVideoConfig();
  330. const defaultWebSearchConfig = getDefaultWebSearchConfig();
  331. if (!hasProviderId(PDF_PROVIDERS, state.pdfProviderId)) {
  332. state.pdfProviderId = defaultPdfConfig.pdfProviderId;
  333. }
  334. if (!hasProviderId(WEB_SEARCH_PROVIDERS, state.webSearchProviderId)) {
  335. state.webSearchProviderId = defaultWebSearchConfig.webSearchProviderId;
  336. }
  337. if (!hasProviderId(IMAGE_PROVIDERS, state.imageProviderId)) {
  338. state.imageProviderId = defaultImageConfig.imageProviderId;
  339. }
  340. if (!hasProviderId(VIDEO_PROVIDERS, state.videoProviderId)) {
  341. state.videoProviderId = defaultVideoConfig.videoProviderId;
  342. }
  343. if (!hasProviderId(TTS_PROVIDERS, state.ttsProviderId)) {
  344. state.ttsProviderId = defaultAudioConfig.ttsProviderId;
  345. }
  346. if (!hasProviderId(ASR_PROVIDERS, state.asrProviderId)) {
  347. state.asrProviderId = defaultAudioConfig.asrProviderId;
  348. }
  349. }
  350. /**
  351. * Ensure providersConfig includes all built-in providers and their latest models.
  352. * Called on every rehydrate (not just version migrations) so new providers
  353. * added in code are always picked up without clearing cache.
  354. */
  355. function ensureBuiltInProviders(state: Partial<SettingsState>): void {
  356. if (!state.providersConfig) return;
  357. const defaultConfig = getDefaultProvidersConfig();
  358. Object.keys(PROVIDERS).forEach((pid) => {
  359. const providerId = pid as ProviderId;
  360. if (!state.providersConfig![providerId]) {
  361. // New provider: add with defaults
  362. state.providersConfig![providerId] = defaultConfig[providerId];
  363. } else {
  364. // Existing provider: merge new models & metadata
  365. const provider = PROVIDERS[providerId];
  366. const existing = state.providersConfig![providerId];
  367. const existingModelIds = new Set(existing.models?.map((m) => m.id) || []);
  368. const newModels = provider.models.filter((m) => !existingModelIds.has(m.id));
  369. const mergedModels =
  370. newModels.length > 0 ? [...newModels, ...(existing.models || [])] : existing.models;
  371. state.providersConfig![providerId] = {
  372. ...existing,
  373. models: mergedModels,
  374. name: existing.name || provider.name,
  375. type: existing.type || provider.type,
  376. defaultBaseUrl: existing.defaultBaseUrl || provider.defaultBaseUrl,
  377. icon: provider.icon || existing.icon,
  378. requiresApiKey: existing.requiresApiKey ?? provider.requiresApiKey,
  379. isBuiltIn: existing.isBuiltIn ?? true,
  380. };
  381. }
  382. });
  383. }
  384. /**
  385. * Ensure imageProvidersConfig includes all built-in image providers.
  386. * Called on every rehydrate so newly added image providers appear automatically.
  387. */
  388. function ensureBuiltInImageProviders(state: Partial<SettingsState>): void {
  389. if (!state.imageProvidersConfig) return;
  390. const defaultConfig = getDefaultImageConfig().imageProvidersConfig;
  391. Object.keys(IMAGE_PROVIDERS).forEach((pid) => {
  392. const providerId = pid as ImageProviderId;
  393. if (!state.imageProvidersConfig![providerId]) {
  394. state.imageProvidersConfig![providerId] = defaultConfig[providerId];
  395. }
  396. });
  397. }
  398. /**
  399. * Ensure videoProvidersConfig includes all built-in video providers.
  400. * Called on every rehydrate so newly added video providers appear automatically.
  401. */
  402. function ensureBuiltInVideoProviders(state: Partial<SettingsState>): void {
  403. if (!state.videoProvidersConfig) return;
  404. const defaultConfig = getDefaultVideoConfig().videoProvidersConfig;
  405. Object.keys(VIDEO_PROVIDERS).forEach((pid) => {
  406. const providerId = pid as VideoProviderId;
  407. if (!state.videoProvidersConfig![providerId]) {
  408. state.videoProvidersConfig![providerId] = defaultConfig[providerId];
  409. }
  410. });
  411. }
  412. // Migrate from old localStorage format
  413. const migrateFromOldStorage = () => {
  414. if (typeof window === 'undefined') return null;
  415. // Check if new storage already exists
  416. const newStorage = localStorage.getItem('settings-storage');
  417. if (newStorage) return null; // Already migrated or new install
  418. // Read old localStorage keys
  419. const oldLlmModel = localStorage.getItem('llmModel');
  420. const oldProvidersConfig = localStorage.getItem('providersConfig');
  421. const oldTtsModel = localStorage.getItem('ttsModel');
  422. const oldSelectedAgents = localStorage.getItem('selectedAgentIds');
  423. const oldMaxTurns = localStorage.getItem('maxTurns');
  424. if (!oldLlmModel && !oldProvidersConfig) return null; // No old data
  425. // Parse model selection
  426. let providerId: ProviderId = 'openai';
  427. let modelId = 'gpt-4o-mini';
  428. if (oldLlmModel) {
  429. const [pid, mid] = oldLlmModel.split(':');
  430. if (pid && mid) {
  431. providerId = pid as ProviderId;
  432. modelId = mid;
  433. }
  434. }
  435. // Parse providers config
  436. let providersConfig = getDefaultProvidersConfig();
  437. if (oldProvidersConfig) {
  438. try {
  439. const parsed = JSON.parse(oldProvidersConfig);
  440. providersConfig = { ...providersConfig, ...parsed };
  441. } catch (e) {
  442. log.error('Failed to parse old providersConfig:', e);
  443. }
  444. }
  445. // Parse other settings
  446. let ttsModel = 'openai-tts';
  447. if (oldTtsModel) ttsModel = oldTtsModel;
  448. let selectedAgentIds = ['default-1', 'default-2', 'default-3'];
  449. if (oldSelectedAgents) {
  450. try {
  451. const parsed = JSON.parse(oldSelectedAgents);
  452. if (Array.isArray(parsed) && parsed.length > 0) {
  453. selectedAgentIds = parsed;
  454. }
  455. } catch (e) {
  456. log.error('Failed to parse old selectedAgentIds:', e);
  457. }
  458. }
  459. let maxTurns = '10';
  460. if (oldMaxTurns) maxTurns = oldMaxTurns;
  461. return {
  462. providerId,
  463. modelId,
  464. providersConfig,
  465. ttsModel,
  466. selectedAgentIds,
  467. maxTurns,
  468. };
  469. };
  470. export const useSettingsStore = create<SettingsState>()(
  471. persist(
  472. (set, get) => {
  473. // Try to migrate from old storage
  474. const migratedData = migrateFromOldStorage();
  475. const defaultAudioConfig = getDefaultAudioConfig();
  476. const defaultPDFConfig = getDefaultPDFConfig();
  477. const defaultImageConfig = getDefaultImageConfig();
  478. const defaultVideoConfig = getDefaultVideoConfig();
  479. const defaultWebSearchConfig = getDefaultWebSearchConfig();
  480. return {
  481. // Initial state (use migrated data if available)
  482. providerId: migratedData?.providerId || 'openai',
  483. modelId: migratedData?.modelId || '',
  484. providersConfig: migratedData?.providersConfig || getDefaultProvidersConfig(),
  485. ttsModel: migratedData?.ttsModel || 'openai-tts',
  486. selectedAgentIds: migratedData?.selectedAgentIds || ['default-1', 'default-2', 'default-3'],
  487. maxTurns: migratedData?.maxTurns?.toString() || '10',
  488. agentMode: 'auto' as const,
  489. autoAgentCount: 3,
  490. // Playback controls
  491. ttsMuted: false,
  492. ttsVolume: 1,
  493. autoPlayLecture: false,
  494. playbackSpeed: 1,
  495. // Layout preferences
  496. sidebarCollapsed: true,
  497. chatAreaCollapsed: true,
  498. chatAreaWidth: 320,
  499. // Audio settings (use defaults)
  500. ...defaultAudioConfig,
  501. // PDF settings (use defaults)
  502. ...defaultPDFConfig,
  503. // Image settings (use defaults)
  504. ...defaultImageConfig,
  505. // Video settings (use defaults)
  506. ...defaultVideoConfig,
  507. // Media generation toggles (off by default)
  508. imageGenerationEnabled: false,
  509. videoGenerationEnabled: false,
  510. // Audio feature toggles (on by default)
  511. ttsEnabled: true,
  512. asrEnabled: true,
  513. autoConfigApplied: false,
  514. // Web Search settings (use defaults)
  515. ...defaultWebSearchConfig,
  516. // Actions
  517. setModel: (providerId, modelId) => set({ providerId, modelId }),
  518. setProviderConfig: (providerId, config) =>
  519. set((state) => ({
  520. providersConfig: {
  521. ...state.providersConfig,
  522. [providerId]: {
  523. ...state.providersConfig[providerId],
  524. ...config,
  525. },
  526. },
  527. })),
  528. setProvidersConfig: (config) => set({ providersConfig: config }),
  529. setTtsModel: (model) => set({ ttsModel: model }),
  530. setTTSMuted: (muted) => set({ ttsMuted: muted }),
  531. setTTSVolume: (volume) => set({ ttsVolume: Math.max(0, Math.min(1, volume)) }),
  532. setAutoPlayLecture: (autoPlay) => set({ autoPlayLecture: autoPlay }),
  533. setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
  534. setSelectedAgentIds: (ids) => set({ selectedAgentIds: ids }),
  535. setMaxTurns: (turns) => set({ maxTurns: turns }),
  536. setAgentMode: (mode) => set({ agentMode: mode }),
  537. setAutoAgentCount: (count) => set({ autoAgentCount: count }),
  538. // Layout actions
  539. setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
  540. setChatAreaCollapsed: (collapsed) => set({ chatAreaCollapsed: collapsed }),
  541. setChatAreaWidth: (width) => set({ chatAreaWidth: width }),
  542. // Audio actions
  543. setTTSProvider: (providerId) =>
  544. set((state) => {
  545. // If switching provider, set default voice for that provider
  546. const shouldUpdateVoice = state.ttsProviderId !== providerId;
  547. return {
  548. ttsProviderId: providerId,
  549. ...(shouldUpdateVoice && { ttsVoice: DEFAULT_TTS_VOICES[providerId] }),
  550. };
  551. }),
  552. setTTSVoice: (voice) => set({ ttsVoice: voice }),
  553. setTTSSpeed: (speed) => set({ ttsSpeed: speed }),
  554. // Reset language when switching providers, since language code formats differ
  555. // (e.g. browser-native uses BCP-47 "en-US", OpenAI Whisper uses ISO 639-1 "en")
  556. setASRProvider: (providerId) =>
  557. set((state) => {
  558. const supportedLanguages = ASR_PROVIDERS[providerId]?.supportedLanguages || [];
  559. const isLanguageValid = supportedLanguages.includes(state.asrLanguage);
  560. return {
  561. asrProviderId: providerId,
  562. ...(isLanguageValid ? {} : { asrLanguage: supportedLanguages[0] || 'auto' }),
  563. };
  564. }),
  565. setASRLanguage: (language) => set({ asrLanguage: language }),
  566. setTTSProviderConfig: (providerId, config) =>
  567. set((state) => ({
  568. ttsProvidersConfig: {
  569. ...state.ttsProvidersConfig,
  570. [providerId]: {
  571. ...state.ttsProvidersConfig[providerId],
  572. ...config,
  573. },
  574. },
  575. })),
  576. setASRProviderConfig: (providerId, config) =>
  577. set((state) => ({
  578. asrProvidersConfig: {
  579. ...state.asrProvidersConfig,
  580. [providerId]: {
  581. ...state.asrProvidersConfig[providerId],
  582. ...config,
  583. },
  584. },
  585. })),
  586. // PDF actions
  587. setPDFProvider: (providerId) => set({ pdfProviderId: providerId }),
  588. setPDFProviderConfig: (providerId, config) =>
  589. set((state) => ({
  590. pdfProvidersConfig: {
  591. ...state.pdfProvidersConfig,
  592. [providerId]: {
  593. ...state.pdfProvidersConfig[providerId],
  594. ...config,
  595. },
  596. },
  597. })),
  598. // Image Generation actions
  599. setImageProvider: (providerId) => set({ imageProviderId: providerId }),
  600. setImageModelId: (modelId) => set({ imageModelId: modelId }),
  601. setImageProviderConfig: (providerId, config) =>
  602. set((state) => ({
  603. imageProvidersConfig: {
  604. ...state.imageProvidersConfig,
  605. [providerId]: {
  606. ...state.imageProvidersConfig[providerId],
  607. ...config,
  608. },
  609. },
  610. })),
  611. // Video Generation actions
  612. setVideoProvider: (providerId) => set({ videoProviderId: providerId }),
  613. setVideoModelId: (modelId) => set({ videoModelId: modelId }),
  614. setVideoProviderConfig: (providerId, config) =>
  615. set((state) => ({
  616. videoProvidersConfig: {
  617. ...state.videoProvidersConfig,
  618. [providerId]: {
  619. ...state.videoProvidersConfig[providerId],
  620. ...config,
  621. },
  622. },
  623. })),
  624. // Media generation toggle actions
  625. setImageGenerationEnabled: (enabled) => {
  626. if (enabled) {
  627. const cfg = get().imageProvidersConfig;
  628. const hasUsable = Object.values(cfg).some((c) => c.isServerConfigured || c.apiKey);
  629. if (!hasUsable) return;
  630. }
  631. set({ imageGenerationEnabled: enabled });
  632. },
  633. setVideoGenerationEnabled: (enabled) => {
  634. if (enabled) {
  635. const cfg = get().videoProvidersConfig;
  636. const hasUsable = Object.values(cfg).some((c) => c.isServerConfigured || c.apiKey);
  637. if (!hasUsable) return;
  638. }
  639. set({ videoGenerationEnabled: enabled });
  640. },
  641. setTTSEnabled: (enabled) => set({ ttsEnabled: enabled }),
  642. setASREnabled: (enabled) => set({ asrEnabled: enabled }),
  643. // Web Search actions
  644. setWebSearchProvider: (providerId) => set({ webSearchProviderId: providerId }),
  645. setWebSearchProviderConfig: (providerId, config) =>
  646. set((state) => ({
  647. webSearchProvidersConfig: {
  648. ...state.webSearchProvidersConfig,
  649. [providerId]: {
  650. ...state.webSearchProvidersConfig[providerId],
  651. ...config,
  652. },
  653. },
  654. })),
  655. // Fetch server-configured providers and merge into local state
  656. fetchServerProviders: async () => {
  657. try {
  658. const res = await fetch('/api/server-providers');
  659. if (!res.ok) return;
  660. const data = (await res.json()) as {
  661. providers: Record<string, { models?: string[]; baseUrl?: string }>;
  662. tts: Record<string, { baseUrl?: string }>;
  663. asr: Record<string, { baseUrl?: string }>;
  664. pdf: Record<string, { baseUrl?: string }>;
  665. image: Record<string, { baseUrl?: string }>;
  666. video: Record<string, { baseUrl?: string }>;
  667. webSearch: Record<string, { baseUrl?: string }>;
  668. };
  669. set((state) => {
  670. // Merge LLM providers
  671. const newProvidersConfig = { ...state.providersConfig };
  672. // First reset all server flags
  673. for (const pid of Object.keys(newProvidersConfig)) {
  674. const key = pid as ProviderId;
  675. if (newProvidersConfig[key]) {
  676. newProvidersConfig[key] = {
  677. ...newProvidersConfig[key],
  678. isServerConfigured: false,
  679. serverModels: undefined,
  680. serverBaseUrl: undefined,
  681. };
  682. }
  683. }
  684. // Set flags for server-configured providers
  685. for (const [pid, info] of Object.entries(data.providers)) {
  686. const key = pid as ProviderId;
  687. if (newProvidersConfig[key]) {
  688. const currentModels = newProvidersConfig[key].models;
  689. // When server specifies allowed models, filter the models list
  690. const filteredModels = info.models?.length
  691. ? currentModels.filter((m) => info.models!.includes(m.id))
  692. : currentModels;
  693. newProvidersConfig[key] = {
  694. ...newProvidersConfig[key],
  695. isServerConfigured: true,
  696. serverModels: info.models,
  697. serverBaseUrl: info.baseUrl,
  698. models: filteredModels,
  699. };
  700. }
  701. }
  702. // Merge TTS providers
  703. const newTTSConfig = { ...state.ttsProvidersConfig };
  704. for (const pid of Object.keys(newTTSConfig)) {
  705. const key = pid as TTSProviderId;
  706. if (newTTSConfig[key]) {
  707. newTTSConfig[key] = {
  708. ...newTTSConfig[key],
  709. isServerConfigured: false,
  710. serverBaseUrl: undefined,
  711. };
  712. }
  713. }
  714. for (const [pid, info] of Object.entries(data.tts)) {
  715. const key = pid as TTSProviderId;
  716. if (newTTSConfig[key]) {
  717. newTTSConfig[key] = {
  718. ...newTTSConfig[key],
  719. isServerConfigured: true,
  720. serverBaseUrl: info.baseUrl,
  721. };
  722. }
  723. }
  724. // Merge ASR providers
  725. const newASRConfig = { ...state.asrProvidersConfig };
  726. for (const pid of Object.keys(newASRConfig)) {
  727. const key = pid as ASRProviderId;
  728. if (newASRConfig[key]) {
  729. newASRConfig[key] = {
  730. ...newASRConfig[key],
  731. isServerConfigured: false,
  732. serverBaseUrl: undefined,
  733. };
  734. }
  735. }
  736. for (const [pid, info] of Object.entries(data.asr)) {
  737. const key = pid as ASRProviderId;
  738. if (newASRConfig[key]) {
  739. newASRConfig[key] = {
  740. ...newASRConfig[key],
  741. isServerConfigured: true,
  742. serverBaseUrl: info.baseUrl,
  743. };
  744. }
  745. }
  746. // Merge PDF providers
  747. const newPDFConfig = { ...state.pdfProvidersConfig };
  748. for (const pid of Object.keys(newPDFConfig)) {
  749. const key = pid as PDFProviderId;
  750. if (newPDFConfig[key]) {
  751. newPDFConfig[key] = {
  752. ...newPDFConfig[key],
  753. isServerConfigured: false,
  754. serverBaseUrl: undefined,
  755. };
  756. }
  757. }
  758. for (const [pid, info] of Object.entries(data.pdf)) {
  759. const key = pid as PDFProviderId;
  760. if (newPDFConfig[key]) {
  761. newPDFConfig[key] = {
  762. ...newPDFConfig[key],
  763. isServerConfigured: true,
  764. serverBaseUrl: info.baseUrl,
  765. };
  766. }
  767. }
  768. // Merge Image providers
  769. const newImageConfig = { ...state.imageProvidersConfig };
  770. for (const pid of Object.keys(newImageConfig)) {
  771. const key = pid as ImageProviderId;
  772. if (newImageConfig[key]) {
  773. newImageConfig[key] = {
  774. ...newImageConfig[key],
  775. isServerConfigured: false,
  776. serverBaseUrl: undefined,
  777. };
  778. }
  779. }
  780. for (const [pid, info] of Object.entries(data.image)) {
  781. const key = pid as ImageProviderId;
  782. if (newImageConfig[key]) {
  783. newImageConfig[key] = {
  784. ...newImageConfig[key],
  785. isServerConfigured: true,
  786. serverBaseUrl: info.baseUrl,
  787. };
  788. }
  789. }
  790. // Merge Video providers
  791. const newVideoConfig = { ...state.videoProvidersConfig };
  792. for (const pid of Object.keys(newVideoConfig)) {
  793. const key = pid as VideoProviderId;
  794. if (newVideoConfig[key]) {
  795. newVideoConfig[key] = {
  796. ...newVideoConfig[key],
  797. isServerConfigured: false,
  798. serverBaseUrl: undefined,
  799. };
  800. }
  801. }
  802. if (data.video) {
  803. for (const [pid, info] of Object.entries(data.video)) {
  804. const key = pid as VideoProviderId;
  805. if (newVideoConfig[key]) {
  806. newVideoConfig[key] = {
  807. ...newVideoConfig[key],
  808. isServerConfigured: true,
  809. serverBaseUrl: info.baseUrl,
  810. };
  811. }
  812. }
  813. }
  814. // Merge Web Search config — reset all first, then mark server-configured
  815. const newWebSearchConfig = { ...state.webSearchProvidersConfig };
  816. for (const key of Object.keys(newWebSearchConfig) as WebSearchProviderId[]) {
  817. newWebSearchConfig[key] = {
  818. ...newWebSearchConfig[key],
  819. isServerConfigured: false,
  820. serverBaseUrl: undefined,
  821. };
  822. }
  823. if (data.webSearch) {
  824. for (const [pid, info] of Object.entries(data.webSearch)) {
  825. const key = pid as WebSearchProviderId;
  826. if (newWebSearchConfig[key]) {
  827. newWebSearchConfig[key] = {
  828. ...newWebSearchConfig[key],
  829. isServerConfigured: true,
  830. serverBaseUrl: info.baseUrl,
  831. };
  832. }
  833. }
  834. }
  835. // === Validate current selections against updated configs ===
  836. // Build fallback: server-configured first, then client-key-only
  837. const buildFallback = <T extends string>(
  838. config: Record<string, { isServerConfigured?: boolean; apiKey?: string }>,
  839. ): T[] => [
  840. ...Object.entries(config)
  841. .filter(([, c]) => c.isServerConfigured)
  842. .map(([id]) => id as T),
  843. ...Object.entries(config)
  844. .filter(([, c]) => !c.isServerConfigured && !!c.apiKey)
  845. .map(([id]) => id as T),
  846. ];
  847. const llmFallback = buildFallback<ProviderId>(newProvidersConfig);
  848. const ttsFallback = buildFallback<TTSProviderId>(newTTSConfig);
  849. const asrFallback = buildFallback<ASRProviderId>(newASRConfig);
  850. const pdfFallback = buildFallback<PDFProviderId>(newPDFConfig);
  851. const imageFallback = buildFallback<ImageProviderId>(newImageConfig);
  852. const videoFallback = buildFallback<VideoProviderId>(newVideoConfig);
  853. const validLLMProvider = validateProvider(
  854. state.providerId,
  855. newProvidersConfig,
  856. llmFallback,
  857. );
  858. const validTTSProvider = validateProvider(
  859. state.ttsProviderId,
  860. newTTSConfig,
  861. ttsFallback,
  862. 'browser-native-tts' as TTSProviderId,
  863. );
  864. const validASRProvider = validateProvider(
  865. state.asrProviderId,
  866. newASRConfig,
  867. asrFallback,
  868. 'browser-native' as ASRProviderId,
  869. );
  870. const validPDFProvider = validateProvider(
  871. state.pdfProviderId,
  872. newPDFConfig,
  873. pdfFallback,
  874. 'unpdf' as PDFProviderId,
  875. );
  876. let validImageProvider = validateProvider(
  877. state.imageProviderId,
  878. newImageConfig,
  879. imageFallback,
  880. );
  881. let validVideoProvider = validateProvider(
  882. state.videoProviderId,
  883. newVideoConfig,
  884. videoFallback,
  885. );
  886. // Auto-recover: when provider is empty but server has available ones
  887. let recoveredImageModel = '';
  888. if (!validImageProvider && imageFallback.length > 0) {
  889. validImageProvider = imageFallback[0];
  890. const models = IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models;
  891. if (models?.length) recoveredImageModel = models[0].id;
  892. }
  893. let recoveredVideoModel = '';
  894. if (!validVideoProvider && videoFallback.length > 0) {
  895. validVideoProvider = videoFallback[0];
  896. const models = VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models;
  897. if (models?.length) recoveredVideoModel = models[0].id;
  898. }
  899. const validLLMModel = validLLMProvider
  900. ? validateModel(
  901. state.modelId,
  902. newProvidersConfig[validLLMProvider as ProviderId]?.models ?? [],
  903. )
  904. : '';
  905. const imageModels =
  906. IMAGE_PROVIDERS[validImageProvider as ImageProviderId]?.models ?? [];
  907. const validImageModel = validImageProvider
  908. ? recoveredImageModel ||
  909. validateModel(state.imageModelId, imageModels) ||
  910. // validateModel('', ...) returns '' — fallback to first model when modelId is empty
  911. imageModels[0]?.id ||
  912. ''
  913. : '';
  914. const videoModels =
  915. VIDEO_PROVIDERS[validVideoProvider as VideoProviderId]?.models ?? [];
  916. const validVideoModel = validVideoProvider
  917. ? recoveredVideoModel ||
  918. validateModel(state.videoModelId, videoModels) ||
  919. videoModels[0]?.id ||
  920. ''
  921. : '';
  922. const validTTSVoice =
  923. validTTSProvider !== state.ttsProviderId
  924. ? DEFAULT_TTS_VOICES[validTTSProvider as TTSProviderId] || 'default'
  925. : state.ttsVoice;
  926. // Auto-disable image/video generation when no provider is usable
  927. const shouldDisableImage = !validImageProvider && state.imageGenerationEnabled;
  928. const shouldDisableVideo = !validVideoProvider && state.videoGenerationEnabled;
  929. // === Auto-select / auto-enable (only on first run) ===
  930. let autoTtsProvider: TTSProviderId | undefined;
  931. let autoTtsVoice: string | undefined;
  932. let autoAsrProvider: ASRProviderId | undefined;
  933. let autoPdfProvider: PDFProviderId | undefined;
  934. let autoImageProvider: ImageProviderId | undefined;
  935. let autoImageModel: string | undefined;
  936. let autoVideoProvider: VideoProviderId | undefined;
  937. let autoVideoModel: string | undefined;
  938. let autoImageEnabled: boolean | undefined;
  939. let autoVideoEnabled: boolean | undefined;
  940. if (!state.autoConfigApplied) {
  941. // PDF: unpdf → mineru if server has it
  942. if (newPDFConfig.mineru?.isServerConfigured && state.pdfProviderId === 'unpdf') {
  943. autoPdfProvider = 'mineru' as PDFProviderId;
  944. }
  945. // TTS: select first server provider if current is not server-configured
  946. const serverTtsIds = Object.keys(data.tts) as TTSProviderId[];
  947. if (
  948. serverTtsIds.length > 0 &&
  949. !newTTSConfig[state.ttsProviderId]?.isServerConfigured
  950. ) {
  951. autoTtsProvider = serverTtsIds[0];
  952. autoTtsVoice = DEFAULT_TTS_VOICES[autoTtsProvider] || 'default';
  953. }
  954. // ASR: select first server provider if current is not server-configured
  955. const serverAsrIds = Object.keys(data.asr) as ASRProviderId[];
  956. if (
  957. serverAsrIds.length > 0 &&
  958. !newASRConfig[state.asrProviderId]?.isServerConfigured
  959. ) {
  960. autoAsrProvider = serverAsrIds[0];
  961. }
  962. // Image: first server provider
  963. const serverImageIds = Object.keys(data.image) as ImageProviderId[];
  964. if (
  965. serverImageIds.length > 0 &&
  966. !newImageConfig[state.imageProviderId]?.isServerConfigured
  967. ) {
  968. autoImageProvider = serverImageIds[0];
  969. const models = IMAGE_PROVIDERS[autoImageProvider]?.models;
  970. if (models?.length) autoImageModel = models[0].id;
  971. }
  972. if (serverImageIds.length > 0 && !state.imageGenerationEnabled) {
  973. autoImageEnabled = true;
  974. }
  975. // Video: first server provider
  976. const serverVideoIds = Object.keys(data.video || {}) as VideoProviderId[];
  977. if (
  978. serverVideoIds.length > 0 &&
  979. !newVideoConfig[state.videoProviderId]?.isServerConfigured
  980. ) {
  981. autoVideoProvider = serverVideoIds[0];
  982. const models = VIDEO_PROVIDERS[autoVideoProvider]?.models;
  983. if (models?.length) autoVideoModel = models[0].id;
  984. }
  985. if (serverVideoIds.length > 0 && !state.videoGenerationEnabled) {
  986. autoVideoEnabled = true;
  987. }
  988. }
  989. // LLM auto-select: only on true first load (no provider selected yet)
  990. let autoProviderId: ProviderId | undefined;
  991. let autoModelId: string | undefined;
  992. if (!state.providerId && !state.modelId) {
  993. for (const [pid, cfg] of Object.entries(newProvidersConfig)) {
  994. if (cfg.isServerConfigured) {
  995. // Prefer server-restricted models, fall back to built-in list
  996. const serverModels = cfg.serverModels;
  997. const modelId = serverModels?.length
  998. ? serverModels[0]
  999. : PROVIDERS[pid as ProviderId]?.models[0]?.id;
  1000. if (modelId) {
  1001. autoProviderId = pid as ProviderId;
  1002. autoModelId = modelId;
  1003. break;
  1004. }
  1005. }
  1006. }
  1007. }
  1008. return {
  1009. providersConfig: newProvidersConfig,
  1010. ttsProvidersConfig: newTTSConfig,
  1011. asrProvidersConfig: newASRConfig,
  1012. pdfProvidersConfig: newPDFConfig,
  1013. imageProvidersConfig: newImageConfig,
  1014. videoProvidersConfig: newVideoConfig,
  1015. webSearchProvidersConfig: newWebSearchConfig,
  1016. autoConfigApplied: true,
  1017. // Validated selections
  1018. ...(validLLMProvider !== state.providerId && {
  1019. providerId: validLLMProvider as ProviderId,
  1020. }),
  1021. ...(validLLMModel !== state.modelId && { modelId: validLLMModel }),
  1022. ...(validTTSProvider !== state.ttsProviderId && {
  1023. ttsProviderId: validTTSProvider as TTSProviderId,
  1024. ttsVoice: validTTSVoice,
  1025. }),
  1026. ...(validASRProvider !== state.asrProviderId && {
  1027. asrProviderId: validASRProvider as ASRProviderId,
  1028. }),
  1029. ...(validPDFProvider !== state.pdfProviderId && {
  1030. pdfProviderId: validPDFProvider as PDFProviderId,
  1031. }),
  1032. ...(validImageProvider !== state.imageProviderId && {
  1033. imageProviderId: validImageProvider as ImageProviderId,
  1034. }),
  1035. ...(validImageModel !== state.imageModelId && {
  1036. imageModelId: validImageModel,
  1037. }),
  1038. ...(validVideoProvider !== state.videoProviderId && {
  1039. videoProviderId: validVideoProvider as VideoProviderId,
  1040. }),
  1041. ...(validVideoModel !== state.videoModelId && {
  1042. videoModelId: validVideoModel,
  1043. }),
  1044. ...(shouldDisableImage && { imageGenerationEnabled: false }),
  1045. ...(shouldDisableVideo && { videoGenerationEnabled: false }),
  1046. // First-run auto-select overrides validation (autoConfigApplied guard).
  1047. // On first sync, auto-select picks the best provider. On subsequent syncs,
  1048. // auto* variables stay undefined so only validation spreads take effect.
  1049. ...(autoPdfProvider && { pdfProviderId: autoPdfProvider }),
  1050. ...(autoTtsProvider && {
  1051. ttsProviderId: autoTtsProvider,
  1052. ttsVoice: autoTtsVoice,
  1053. }),
  1054. ...(autoAsrProvider && { asrProviderId: autoAsrProvider }),
  1055. ...(autoImageProvider && {
  1056. imageProviderId: autoImageProvider,
  1057. }),
  1058. ...(autoImageModel && { imageModelId: autoImageModel }),
  1059. ...(autoVideoProvider && {
  1060. videoProviderId: autoVideoProvider,
  1061. }),
  1062. ...(autoVideoModel && { videoModelId: autoVideoModel }),
  1063. ...(autoImageEnabled !== undefined && {
  1064. imageGenerationEnabled: autoImageEnabled,
  1065. }),
  1066. ...(autoVideoEnabled !== undefined && {
  1067. videoGenerationEnabled: autoVideoEnabled,
  1068. }),
  1069. ...(autoProviderId && { providerId: autoProviderId }),
  1070. ...(autoModelId && { modelId: autoModelId }),
  1071. };
  1072. });
  1073. } catch (e) {
  1074. // Silently fail — server providers are optional
  1075. log.warn('Failed to fetch server providers:', e);
  1076. }
  1077. },
  1078. };
  1079. },
  1080. {
  1081. name: 'settings-storage',
  1082. version: 2,
  1083. // Migrate persisted state
  1084. migrate: (persistedState: unknown, version: number) => {
  1085. const state = persistedState as Partial<SettingsState>;
  1086. // v0 → v1: clear hardcoded default model so user must actively select
  1087. if (version === 0) {
  1088. if (state.providerId === 'openai' && state.modelId === 'gpt-4o-mini') {
  1089. state.modelId = '';
  1090. }
  1091. }
  1092. // Ensure providersConfig has all built-in providers (also in merge below)
  1093. ensureBuiltInProviders(state);
  1094. // Ensure image/video configs have all built-in providers
  1095. ensureBuiltInImageProviders(state);
  1096. ensureBuiltInVideoProviders(state);
  1097. // Migrate from old ttsModel to new ttsProviderId
  1098. if (state.ttsModel && !state.ttsProviderId) {
  1099. // Map old ttsModel values to new ttsProviderId
  1100. if (state.ttsModel === 'openai-tts') {
  1101. state.ttsProviderId = 'openai-tts';
  1102. } else if (state.ttsModel === 'azure-tts') {
  1103. state.ttsProviderId = 'azure-tts';
  1104. } else {
  1105. // Default to OpenAI
  1106. state.ttsProviderId = 'openai-tts';
  1107. }
  1108. }
  1109. // Add default audio config if missing
  1110. if (!state.ttsProvidersConfig || !state.asrProvidersConfig) {
  1111. const defaultAudioConfig = getDefaultAudioConfig();
  1112. Object.assign(state, defaultAudioConfig);
  1113. }
  1114. // Migrate global ttsModelId to per-provider
  1115. if ((state as Record<string, unknown>).ttsModelId) {
  1116. const pid = state.ttsProviderId;
  1117. if (pid && state.ttsProvidersConfig?.[pid]) {
  1118. state.ttsProvidersConfig[pid].modelId = (state as Record<string, unknown>)
  1119. .ttsModelId as string;
  1120. }
  1121. delete (state as Record<string, unknown>).ttsModelId;
  1122. }
  1123. // Same for asrModelId
  1124. if ((state as Record<string, unknown>).asrModelId) {
  1125. const pid = state.asrProviderId;
  1126. if (pid && state.asrProvidersConfig?.[pid]) {
  1127. state.asrProvidersConfig[pid].modelId = (state as Record<string, unknown>)
  1128. .asrModelId as string;
  1129. }
  1130. delete (state as Record<string, unknown>).asrModelId;
  1131. }
  1132. // Migrate MiniMax's model field to modelId
  1133. for (const [, cfg] of Object.entries(
  1134. (state.ttsProvidersConfig as Record<string, Record<string, unknown>>) || {},
  1135. )) {
  1136. if (cfg.model && !cfg.modelId) {
  1137. cfg.modelId = cfg.model;
  1138. delete cfg.model;
  1139. }
  1140. }
  1141. // Add default PDF config if missing
  1142. if (!state.pdfProvidersConfig) {
  1143. const defaultPDFConfig = getDefaultPDFConfig();
  1144. Object.assign(state, defaultPDFConfig);
  1145. }
  1146. // Add default Image config if missing
  1147. if (!state.imageProvidersConfig) {
  1148. const defaultImageConfig = getDefaultImageConfig();
  1149. Object.assign(state, defaultImageConfig);
  1150. }
  1151. // Add default Video config if missing
  1152. if (!state.videoProvidersConfig) {
  1153. const defaultVideoConfig = getDefaultVideoConfig();
  1154. Object.assign(state, defaultVideoConfig);
  1155. }
  1156. // v1 → v2: Replace deep research with web search
  1157. if (version < 2) {
  1158. delete (state as Record<string, unknown>).deepResearchProviderId;
  1159. delete (state as Record<string, unknown>).deepResearchProvidersConfig;
  1160. }
  1161. // Add default media generation toggles if missing
  1162. if (state.imageGenerationEnabled === undefined) {
  1163. state.imageGenerationEnabled = false;
  1164. }
  1165. if (state.videoGenerationEnabled === undefined) {
  1166. state.videoGenerationEnabled = false;
  1167. }
  1168. // Add default audio toggles if missing
  1169. if ((state as Record<string, unknown>).ttsEnabled === undefined) {
  1170. (state as Record<string, unknown>).ttsEnabled = true;
  1171. }
  1172. if ((state as Record<string, unknown>).asrEnabled === undefined) {
  1173. (state as Record<string, unknown>).asrEnabled = true;
  1174. }
  1175. // Existing users already have their config set up — mark auto-config as done
  1176. if ((state as Record<string, unknown>).autoConfigApplied === undefined) {
  1177. (state as Record<string, unknown>).autoConfigApplied = true;
  1178. }
  1179. if ((state as Record<string, unknown>).agentMode === undefined) {
  1180. (state as Record<string, unknown>).agentMode = 'preset';
  1181. }
  1182. if ((state as Record<string, unknown>).autoAgentCount === undefined) {
  1183. (state as Record<string, unknown>).autoAgentCount = 3;
  1184. }
  1185. // Migrate Web Search: old flat fields → new provider-based config
  1186. if (!state.webSearchProvidersConfig) {
  1187. const stateRecord = state as Record<string, unknown>;
  1188. const oldApiKey = (stateRecord.webSearchApiKey as string) || '';
  1189. const oldIsServerConfigured =
  1190. (stateRecord.webSearchIsServerConfigured as boolean) || false;
  1191. state.webSearchProviderId = 'tavily' as WebSearchProviderId;
  1192. state.webSearchProvidersConfig = {
  1193. tavily: {
  1194. apiKey: oldApiKey,
  1195. baseUrl: '',
  1196. enabled: true,
  1197. isServerConfigured: oldIsServerConfigured,
  1198. },
  1199. } as SettingsState['webSearchProvidersConfig'];
  1200. delete stateRecord.webSearchApiKey;
  1201. delete stateRecord.webSearchIsServerConfigured;
  1202. }
  1203. ensureValidProviderSelections(state);
  1204. return state;
  1205. },
  1206. // Custom merge: always sync built-in providers on every rehydrate,
  1207. // so newly added providers/models appear without clearing cache.
  1208. merge: (persistedState, currentState) => {
  1209. const merged = { ...currentState, ...(persistedState as object) };
  1210. ensureBuiltInProviders(merged as Partial<SettingsState>);
  1211. ensureBuiltInImageProviders(merged as Partial<SettingsState>);
  1212. ensureBuiltInVideoProviders(merged as Partial<SettingsState>);
  1213. ensureValidProviderSelections(merged as Partial<SettingsState>);
  1214. return merged as SettingsState;
  1215. },
  1216. },
  1217. ),
  1218. );