diff --git a/src/app/(app)/app/layout.tsx b/src/app/(app)/app/layout.tsx index 947ab4f..3691ee2 100644 --- a/src/app/(app)/app/layout.tsx +++ b/src/app/(app)/app/layout.tsx @@ -2,16 +2,15 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { DocumentProvider } from '@/contexts/DocumentContext'; import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function AppHomeLayout({ children }: { children: ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/src/app/(app)/epub/[id]/layout.tsx b/src/app/(app)/epub/[id]/layout.tsx index 2f41494..f0b39fa 100644 --- a/src/app/(app)/epub/[id]/layout.tsx +++ b/src/app/(app)/epub/[id]/layout.tsx @@ -2,15 +2,14 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { TTSProvider } from '@/contexts/TTSContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function EpubReaderLayout({ children }: { children: ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/src/app/(app)/html/[id]/layout.tsx b/src/app/(app)/html/[id]/layout.tsx index 3cadc04..9401b15 100644 --- a/src/app/(app)/html/[id]/layout.tsx +++ b/src/app/(app)/html/[id]/layout.tsx @@ -2,13 +2,12 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { TTSProvider } from '@/contexts/TTSContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function HtmlReaderLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c048fbf..ebb1972 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; +import { ConfigProvider } from '@/contexts/ConfigContext'; import { AppMain, AppShell } from '@/components/layout'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; @@ -33,9 +34,17 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > - - {children} - + {/* ConfigProvider lives here, in the shared (app) layout, so it stays + mounted across library <-> reader navigation. Mounting it per-route + re-ran the Dexie/server hydration race on every navigation, causing + the reader to briefly use the admin-default provider until a full + page refresh. A single shared instance keeps the user's saved + provider hydrated the whole time. */} + + + {children} + + reader navigation; do not re-wrap it here. export default function PdfReaderLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 78f38ee..9eeae34 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { userPreferences } from '@/db/schema'; -import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { type SyncedPreferencesPatch } from '@/types/user-state'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; -import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { listAdminProviders } from '@/lib/server/admin/providers'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; -import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; +import { + sanitizePreferencesPatch, + type PreferenceNormalizationContext, +} from '@/lib/server/user/preferences-normalize'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -25,29 +27,14 @@ function serializePreferencesForDb(payload: Record): Record { - return typeof value === 'object' && value !== null; -} - -interface PreferenceNormalizationContext { - defaultProviderRef: string; - showAllProviderModels: boolean; - sharedProviders: Array<{ - slug: string; - providerType: TtsProviderId; - defaultModel: string | null; - defaultInstructions: string | null; - }>; -} - async function loadPreferenceNormalizationContext(): Promise { const [runtimeConfig, providers] = await Promise.all([ getResolvedRuntimeConfig(), listAdminProviders(), ]); return { - defaultProviderRef: runtimeConfig.defaultTtsProvider, showAllProviderModels: runtimeConfig.showAllProviderModels, + restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, sharedProviders: providers .filter((entry) => entry.enabled) .map((entry) => ({ @@ -69,128 +56,6 @@ function parseStoredPreferences( return { ...sanitized, meta }; } -function sanitizeSavedVoices(value: unknown): Record { - if (!isRecord(value)) return {}; - const out: Record = {}; - for (const [key, val] of Object.entries(value)) { - if (typeof key !== 'string' || key.length === 0) continue; - if (typeof val !== 'string') continue; - out[key] = val; - } - return out; -} - -function sanitizePreferencesPatch( - input: unknown, - context: PreferenceNormalizationContext, - options: { fillMissingProvider: boolean }, -): { patch: SyncedPreferencesPatch; migrated: boolean } { - if (!isRecord(input)) return { patch: {}, migrated: false }; - - const rec = input as Record; - const out: SyncedPreferencesPatch = {}; - let migrated = false; - - const legacyProviderRef = typeof rec.ttsProvider === 'string' - ? rec.ttsProvider - : typeof rec.provider === 'string' - ? rec.provider - : ''; - const rawProviderRef = typeof rec.providerRef === 'string' ? rec.providerRef : legacyProviderRef; - const normalizedProviderRef = normalizeLegacyProviderRef(rawProviderRef, context.defaultProviderRef); - const providerDefaults = resolveProviderDefaults({ - providerRef: normalizedProviderRef || context.defaultProviderRef, - providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', - sharedProviders: context.sharedProviders, - fallbackProviderRef: context.defaultProviderRef, - }); - const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; - if (hasLegacyProviderKey || rawProviderRef !== providerDefaults.providerRef) migrated = true; - - for (const key of SYNCED_PREFERENCE_KEYS) { - if (!(key in rec)) continue; - const value = rec[key]; - - switch (key) { - case 'viewType': - if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; - break; - case 'voice': - case 'ttsModel': - case 'ttsInstructions': - if (typeof value === 'string') out[key] = value; - break; - case 'providerRef': - out[key] = providerDefaults.providerRef; - break; - case 'providerType': - out[key] = providerDefaults.providerType; - break; - case 'voiceSpeed': - case 'audioPlayerSpeed': - case 'segmentPreloadDepthPages': - case 'segmentPreloadSentenceLookahead': - case 'ttsSegmentMaxBlockLength': - case 'headerMargin': - case 'footerMargin': - case 'leftMargin': - case 'rightMargin': - if (Number.isFinite(value)) out[key] = Number(value); - break; - case 'skipBlank': - case 'epubTheme': - case 'pdfHighlightEnabled': - case 'pdfWordHighlightEnabled': - case 'epubHighlightEnabled': - case 'epubWordHighlightEnabled': - case 'htmlHighlightEnabled': - case 'htmlWordHighlightEnabled': - if (typeof value === 'boolean') out[key] = value; - break; - case 'savedVoices': - out[key] = sanitizeSavedVoices(value); - break; - default: - break; - } - } - - if ('providerRef' in out && !('providerType' in out)) { - out.providerType = providerDefaults.providerType; - migrated = true; - } - - if (options.fillMissingProvider && !('providerRef' in out)) { - out.providerRef = providerDefaults.providerRef; - migrated = true; - } - if (options.fillMissingProvider && !('providerType' in out)) { - out.providerType = providerDefaults.providerType; - migrated = true; - } - - const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; - const shouldNormalizeSharedDefaultModel = - !!providerDefaults.defaultModel - && context.sharedProviders.some((entry) => entry.slug === providerDefaults.providerRef) - && (rawModel.length === 0 || rawModel === 'kokoro') - && (hasLegacyProviderKey || rawProviderRef === 'default-openai'); - - if (options.fillMissingProvider && !('ttsModel' in out) && providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } else if (shouldNormalizeSharedDefaultModel && out.ttsModel !== providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } - if (!context.showAllProviderModels && providerDefaults.defaultModel && out.ttsModel !== providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } - - return { patch: out, migrated }; -} - function normalizeClientUpdatedAtMs(value: unknown): number { const normalized = coerceTimestampMs(value, nowTimestampMs()); if (normalized <= 0) return nowTimestampMs(); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b9465ea..d730dc8 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -37,6 +37,8 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { useSharedProviders } from '@/hooks/useSharedProviders'; +import { flushUserPreferencesSync } from '@/lib/client/api/user-state'; +import toast from 'react-hot-toast'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { SidebarNav, @@ -265,7 +267,7 @@ export function SettingsModal({ prefetch: prefetchLibraryDocuments, } = useLibraryDocumentsQuery(isSelectionModalOpen); - const { providers: sharedProviders } = useSharedProviders(); + const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); const { providers: ttsProviders, models: ttsModels, @@ -295,13 +297,18 @@ export function SettingsModal({ }, [changelogOpenSignal, setIsOpen]); useEffect(() => { + // Only mirror persisted config into the local form while the modal is closed. + // While it's open the user may be mid-edit, and a background refresh (e.g. the + // focus refetch in ConfigContext, or async shared-provider loading) must not + // stomp their in-progress selection. On close, resetToCurrent re-syncs. + if (isOpen) return; setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalProviderRef(providerRef); setLocalProviderType(providerType); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); - }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); + }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { @@ -643,7 +650,9 @@ export function SettingsModal({
- {ttsProviders.length === 0 ? ( + {sharedProvidersLoading ? ( +

Loading providers…

+ ) : ttsProviders.length === 0 ? (

User API keys are restricted and no shared provider is configured. Ask an admin to add one.

@@ -815,7 +824,7 @@ export function SettingsModal({ type="button" variant="primary" size="md" - disabled={!canSubmit} + disabled={!canSubmit || sharedProvidersLoading} onClick={async () => { const defaults = resolveProviderDefaults({ providerRef: selectedProviderRef, @@ -833,6 +842,16 @@ export function SettingsModal({ : defaults.defaultModel; await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsInstructions', localTTSInstructions); + // Push the change to the server immediately rather than waiting on + // the debounce, so a quick reload can't lose the save. Surface + // failures instead of silently dropping them. + try { + await flushUserPreferencesSync(); + } catch (error) { + console.error('Failed to save TTS provider settings:', error); + toast.error('Failed to save provider settings. Please try again.'); + return; + } setIsOpen(false); }} > diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index b6a6c74..cf8ce0a 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -5,11 +5,11 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; -import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; +import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { applyConfigUpdate } from '@/lib/client/config/updates'; import { useSharedProviders } from '@/hooks/useSharedProviders'; @@ -69,16 +69,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const didRunStartupMigrations = useRef(false); const didAttemptInitialPreferenceSeedForSession = useRef(null); const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); - const { providers: sharedProviders } = useSharedProviders(); + const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; - const providerResetDefaults = useMemo(() => { - return resolveProviderDefaults({ - providerRef: APP_CONFIG_DEFAULTS.providerRef, - providerType: APP_CONFIG_DEFAULTS.providerType, - sharedProviders, - }); - }, [sharedProviders]); + // The instance/admin default provider. An empty user providerRef "inherits" + // this, resolved (admin slug -> concrete provider) where the value is used. + const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider; const queueSyncedPreferencePatch = useCallback((patch: Partial) => { if (sessionKey === 'no-session') return; @@ -213,28 +209,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return; + if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return; const resetPatch: Partial = {}; - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; - } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } - if (appConfig.providerRef !== providerResetDefaults.providerRef) { - resetPatch.providerRef = providerResetDefaults.providerRef; - } - if (appConfig.providerType !== providerResetDefaults.providerType) { - resetPatch.providerType = providerResetDefaults.providerType; - } - if (appConfig.ttsModel !== providerResetDefaults.defaultModel) { - resetPatch.ttsModel = providerResetDefaults.defaultModel; - } - if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) { - resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions; - } + // When the provider tab is hidden, clear any user-set provider config back to + // "inherit the admin default" (empty) rather than baking in a concrete value. + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + if (appConfig.providerRef !== '') resetPatch.providerRef = ''; + if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; + if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; + if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; // Keep voice selection state intact so player/Audiobook voice pickers still // work when the TTS providers tab is hidden. This reset is only for provider // configuration fields. @@ -245,32 +231,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to clear hidden TTS provider settings:', error); }); queueSyncedPreferencePatch(resetPatch); - }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig) return; + if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return; const resetPatch: Partial = {}; - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; - } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + // Built-in providers aren't selectable in restricted mode. Clear any stale + // built-in selection (including the old 'custom-openai' default that used to + // be baked into every config) back to "inherit the admin default" so the + // user follows whatever shared provider the admin has configured. if (isBuiltInTtsProviderId(appConfig.providerRef)) { - if (appConfig.providerRef !== providerResetDefaults.providerRef) { - resetPatch.providerRef = providerResetDefaults.providerRef; - } - if (appConfig.providerType !== providerResetDefaults.providerType) { - resetPatch.providerType = providerResetDefaults.providerType; - } - if (appConfig.ttsModel !== providerResetDefaults.defaultModel) { - resetPatch.ttsModel = providerResetDefaults.defaultModel; - } - if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) { - resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions; - } + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; } if (Object.keys(resetPatch).length === 0) return; @@ -279,15 +257,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to enforce restricted user API key mode:', error); }); queueSyncedPreferencePatch(resetPatch); - }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (showAllProviderModels || !isDBReady || !appConfig) return; + if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return; + // Inheriting (empty providerRef): the effective model is resolved at read + // time, so there is nothing to persist/enforce here. + if (!appConfig.providerRef) return; const providerDefaults = resolveProviderDefaults({ providerRef: appConfig.providerRef, providerType: appConfig.providerType, sharedProviders, - fallbackProviderRef: providerResetDefaults.providerRef, + fallbackProviderRef: adminDefaultProviderRef, }); if (!providerDefaults.defaultModel) return; if (appConfig.ttsModel === providerDefaults.defaultModel) return; @@ -296,7 +277,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to enforce provider default model restriction:', error); }); queueSyncedPreferencePatch(patch); - }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); + }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { if (!isDBReady || !appConfig || isSessionPending) return; @@ -359,33 +340,48 @@ export function ConfigProvider({ children }: { children: ReactNode }) { htmlHighlightEnabled, htmlWordHighlightEnabled, } = config || APP_CONFIG_DEFAULTS; - const providerType = useMemo( - () => resolveEffectiveProviderType({ + // Resolve the effective provider for consumers. An empty stored providerRef + // means "inherit the admin default", which we resolve here so the reader, + // voice pickers, and settings UI all see a concrete, usable provider without + // mutating the stored ("inherit") value. + const effectiveProvider = useMemo( + () => resolveProviderDefaults({ providerRef, providerType: _persistedProviderType, sharedProviders, + fallbackProviderRef: adminDefaultProviderRef, }), - [providerRef, _persistedProviderType, sharedProviders], + [providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef], ); + const effectiveProviderRef = effectiveProvider.providerRef; + const providerType = effectiveProvider.providerType; + const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel; + const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions; useEffect(() => { - if (!isDBReady || !appConfig) return; + if (!isDBReady || !appConfig || sharedProvidersLoading) return; + // Only persist a resolved providerType for an explicitly chosen provider. + // While inheriting (empty providerRef) the type stays unset in storage. + if (!appConfig.providerRef) return; if (appConfig.providerType === providerType) return; const patch: Partial = { providerType }; updateAppConfig(patch).catch((error) => { console.warn('Failed to persist resolved providerType:', error); }); queueSyncedPreferencePatch(patch); - }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]); + }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]); void _persistedProviderType; useEffect(() => { - if (!isDBReady || !appConfig) return; + if (!isDBReady || !appConfig || sharedProvidersLoading) return; + // Inheriting (empty providerRef): the effective model is resolved at read + // time; don't write a concrete model into the "inherit" state. + if (!appConfig.providerRef) return; const providerDefaults = resolveProviderDefaults({ providerRef: appConfig.providerRef, providerType: appConfig.providerType, sharedProviders, - fallbackProviderRef: providerResetDefaults.providerRef, + fallbackProviderRef: adminDefaultProviderRef, }); if (!providerDefaults.defaultModel) return; if (appConfig.ttsModel === providerDefaults.defaultModel) return; @@ -398,7 +394,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to normalize shared-provider default model:', error); }); queueSyncedPreferencePatch(patch); - }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]); + }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]); /** * Updates multiple configuration values simultaneously @@ -436,9 +432,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { try { setIsLoading(true); const { storagePatch, syncPatch } = applyConfigUpdate({ - providerRef, + providerRef: effectiveProviderRef, providerType, - ttsModel, + ttsModel: effectiveTtsModel, savedVoices, }, key, value); @@ -478,10 +474,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { footerMargin, leftMargin, rightMargin, - providerRef, + providerRef: effectiveProviderRef, providerType, - ttsModel, - ttsInstructions, + ttsModel: effectiveTtsModel, + ttsInstructions: effectiveTtsInstructions, savedVoices, updateConfig, updateConfigKey, diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts index a3d0948..d9e8cf1 100644 --- a/src/lib/client/api/user-state.ts +++ b/src/lib/client/api/user-state.ts @@ -148,6 +148,32 @@ export function scheduleUserPreferencesSync( }, debounceMs); } +/** + * Immediately send any pending debounced preference patch and await the result. + * Use this when the user explicitly saves: we want the server write to complete + * (and surface failures) instead of silently relying on the debounce timer, which + * can be lost if the page is reloaded inside the debounce window. + */ +export async function flushUserPreferencesSync(): Promise { + if (pendingPreferenceSync.timer) { + clearTimeout(pendingPreferenceSync.timer); + pendingPreferenceSync.timer = null; + } + + const payload = { ...pendingPreferenceSync.patch }; + pendingPreferenceSync.patch = {}; + if (Object.keys(payload).length === 0) return; + + if (activeSyncController) activeSyncController.abort(); + activeSyncController = new AbortController(); + + try { + await putUserPreferences(payload, { signal: activeSyncController.signal }); + } finally { + activeSyncController = null; + } +} + export async function getDocumentProgress( documentId: string, options?: { signal?: AbortSignal }, diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 5a87363..9f65e4a 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { asc, desc, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; import { serverLogger } from '@/lib/server/logger'; @@ -116,9 +116,19 @@ async function resolveImplicitDefaultTtsProvider(): Promise const rows = await db .select({ slug: adminProviders.slug }) .from(adminProviders) - .where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1))) - .limit(1); - return rows[0]?.slug; + .where(eq(adminProviders.enabled, 1)) + .orderBy( + desc(adminProviders.updatedAt), + desc(adminProviders.createdAt), + asc(adminProviders.slug), + ); + const slugs = (rows as Array<{ slug: string }>).map((row) => row.slug); + // Prefer the conventional 'default-openai' slug when present, otherwise fall + // back to the first enabled shared provider so a fresh instance with any + // configured provider resolves to a real, usable provider rather than the + // built-in 'custom-openai' placeholder. + if (slugs.includes('default-openai')) return 'default-openai'; + return slugs[0]; } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.default_provider_lookup.failed', diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts new file mode 100644 index 0000000..4555330 --- /dev/null +++ b/src/lib/server/user/preferences-normalize.ts @@ -0,0 +1,161 @@ +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; + +export interface PreferenceNormalizationContext { + showAllProviderModels: boolean; + restrictUserApiKeys: boolean; + sharedProviders: Array<{ + slug: string; + providerType: TtsProviderId; + defaultModel: string | null; + defaultInstructions: string | null; + }>; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function sanitizeSavedVoices(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof key !== 'string' || key.length === 0) continue; + if (typeof val !== 'string') continue; + out[key] = val; + } + return out; +} + +/** + * Normalize a stored/incoming preferences blob into a clean synced-preferences + * patch. + * + * Provider handling follows an "inherit the admin default" model: an empty + * `providerRef` means the user has made no explicit choice and should follow the + * instance default. We deliberately preserve empty rather than collapsing it to + * a concrete provider, so inheriting users track whatever the admin configures. + * Built-in provider ids under restricted mode (and the legacy `default-openai` + * sentinel that isn't a real shared provider) are mapped back to inherit. + */ +export function sanitizePreferencesPatch( + input: unknown, + context: PreferenceNormalizationContext, + options: { fillMissingProvider: boolean }, +): { patch: SyncedPreferencesPatch; migrated: boolean } { + if (!isRecord(input)) return { patch: {}, migrated: false }; + + const rec = input as Record; + const out: SyncedPreferencesPatch = {}; + let migrated = false; + + const legacyProviderRef = typeof rec.ttsProvider === 'string' + ? rec.ttsProvider + : typeof rec.provider === 'string' + ? rec.provider + : ''; + const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; + const hasProviderRefKey = typeof rec.providerRef === 'string'; + const rawProviderRef = hasProviderRefKey ? (rec.providerRef as string) : legacyProviderRef; + + const sharedSlugs = new Set(context.sharedProviders.map((entry) => entry.slug)); + let providerRefIntent = typeof rawProviderRef === 'string' ? rawProviderRef.trim() : ''; + // Legacy 'default-openai' sentinel: only honored when it's a real configured + // shared provider; otherwise it historically meant "use the default" → inherit. + if (providerRefIntent === 'default-openai' && !sharedSlugs.has('default-openai')) { + providerRefIntent = ''; + } + // Built-in providers aren't selectable under restricted mode → inherit. This + // also migrates the old baked-in 'custom-openai' default off existing rows. + if (context.restrictUserApiKeys && isBuiltInTtsProviderId(providerRefIntent)) { + providerRefIntent = ''; + } + const providerRefIsExplicit = providerRefIntent.length > 0; + const providerDefaults = providerRefIsExplicit + ? resolveProviderDefaults({ + providerRef: providerRefIntent, + providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', + sharedProviders: context.sharedProviders, + }) + : null; + + if (hasLegacyProviderKey || (hasProviderRefKey && (rec.providerRef as string) !== providerRefIntent)) { + migrated = true; + } + + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in rec)) continue; + const value = rec[key]; + + switch (key) { + case 'viewType': + if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; + break; + case 'voice': + case 'ttsInstructions': + if (typeof value === 'string') out[key] = value; + break; + // providerRef / providerType / ttsModel are resolved together below. + case 'providerRef': + case 'providerType': + case 'ttsModel': + break; + case 'voiceSpeed': + case 'audioPlayerSpeed': + case 'segmentPreloadDepthPages': + case 'segmentPreloadSentenceLookahead': + case 'ttsSegmentMaxBlockLength': + case 'headerMargin': + case 'footerMargin': + case 'leftMargin': + case 'rightMargin': + if (Number.isFinite(value)) out[key] = Number(value); + break; + case 'skipBlank': + case 'epubTheme': + case 'pdfHighlightEnabled': + case 'pdfWordHighlightEnabled': + case 'epubHighlightEnabled': + case 'epubWordHighlightEnabled': + case 'htmlHighlightEnabled': + case 'htmlWordHighlightEnabled': + if (typeof value === 'boolean') out[key] = value; + break; + case 'savedVoices': + out[key] = sanitizeSavedVoices(value); + break; + default: + break; + } + } + + // Persist concrete provider/type/model only for an explicit selection. An + // inheriting user keeps these empty so resolution happens against the live + // admin default at read/generation time. + const shouldWriteProvider = hasProviderRefKey || hasLegacyProviderKey || options.fillMissingProvider; + if (shouldWriteProvider) { + out.providerRef = providerRefIntent; + out.providerType = providerRefIsExplicit ? providerDefaults!.providerType : 'unknown'; + } + + if (providerRefIsExplicit) { + const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; + const lockedToDefault = !context.showAllProviderModels && !!providerDefaults!.defaultModel; + const model = lockedToDefault + ? providerDefaults!.defaultModel + : (rawModel || providerDefaults!.defaultModel); + if (model) { + out.ttsModel = model; + if (model !== rawModel) migrated = true; + } else if (typeof rec.ttsModel === 'string') { + out.ttsModel = rec.ttsModel; + } + } else if (shouldWriteProvider || 'ttsModel' in rec) { + // Inheriting: the model inherits too. + if (typeof rec.ttsModel === 'string' && rec.ttsModel !== '') migrated = true; + out.ttsModel = ''; + } + + return { patch: out, migrated }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 53db98c..711b634 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,21 +1,5 @@ import type { DocumentListState } from '@/types/documents'; -import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; -import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; - -// Runtime config (admin-controlled) is layered on top of the static defaults -// below. We resolve it lazily so this module stays importable from non-React -// contexts (Dexie, server routes). The actual values come from -// `window.__RUNTIME_CONFIG__` (SSR-injected) on the client, and -// from the built-in defaults during SSR. - -function readRuntimeString(key: string, defaultValue: string): string { - if (typeof window === 'undefined') return defaultValue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__RUNTIME_CONFIG__; - if (!injected || typeof injected !== 'object') return defaultValue; - const value = injected[key]; - return typeof value === 'string' && value ? value : defaultValue; -} +import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog'; export type ViewType = 'single' | 'dual' | 'scroll'; @@ -78,20 +62,18 @@ export interface AppConfigValues { } /** - * Build defaults lazily so we can read SSR-injected admin overrides - * (`window.__RUNTIME_CONFIG__`). Modules that need the defaults - * statically should call `getAppConfigDefaults()` at use time. The exported - * `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so - * mutations to the runtime config (admin edits) are picked up by anything - * that reads through it. + * Build the static app-config defaults. These no longer read any SSR/admin + * runtime config: the user's TTS provider defaults to empty ("inherit the + * admin default"), which is resolved to a concrete provider where it is used + * (ConfigContext on the client, credential resolution on the server). */ export function getAppConfigDefaults(): AppConfigValues { - const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); - const defaultProviderRef = runtimeProviderRef.trim(); - const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; - const defaultModel = isBuiltInTtsProviderId(defaultProviderType) - ? defaultModelForProviderType(defaultProviderType) - : 'kokoro'; + // The user's TTS provider is intentionally left empty by default. An empty + // providerRef means "inherit the instance/admin default" and is resolved to a + // concrete provider at read time (see ConfigContext) and at generation time + // (server-side credential resolution). We no longer bake a placeholder + // provider id (the old 'custom-openai') into every user's config, since that + // value isn't actually selectable in shared-provider mode. return { apiKey: '', baseUrl: '', @@ -105,9 +87,9 @@ export function getAppConfigDefaults(): AppConfigValues { footerMargin: 0, leftMargin: 0, rightMargin: 0, - providerRef: defaultProviderRef, - providerType: defaultProviderType, - ttsModel: defaultModel, + providerRef: '', + providerType: 'unknown', + ttsModel: '', ttsInstructions: '', savedVoices: {}, segmentPreloadDepthPages: 1, @@ -134,17 +116,13 @@ export function getAppConfigDefaults(): AppConfigValues { } /** - * Static defaults snapshot resolved at first access. For callers that need - * fresh values after admin edits, prefer `getAppConfigDefaults()` directly. - * Most consumers just need a stable defaults object for spreads, so this is - * resolved once per process — admin overrides take effect on next page load - * (which is the SSR-injected behavior we want anyway). + * Static defaults snapshot, resolved once at first access. The values are + * constant (no SSR/admin overrides are read here anymore), so the lazy Proxy + * just avoids building the object until something first reads it. */ let cachedDefaults: AppConfigValues | null = null; export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => { - // Return a getter-backed object that resolves on first access. On the - // server, this resolves to built-in defaults; on the client, to the - // SSR-injected admin values. + // Getter-backed object that builds the defaults lazily on first access. const handler: ProxyHandler = { get(_target, prop) { if (!cachedDefaults) cachedDefaults = getAppConfigDefaults(); diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index 8996617..acd5b94 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -476,9 +476,13 @@ describe('config helpers', () => { ttsModel: 'kokoro', }); + // The user's provider/model now default to empty ("inherit the admin + // default"), so empty values are treated as defaults and filtered out. expect(buildSyncedPreferencePatch({ voiceSpeed: 1, - ttsModel: 'kokoro', + providerRef: '', + providerType: 'unknown', + ttsModel: '', }, { nonDefaultOnly: true })).toEqual({}); }); }); diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts new file mode 100644 index 0000000..16975ea --- /dev/null +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'vitest'; + +import { + sanitizePreferencesPatch, + type PreferenceNormalizationContext, +} from '../../src/lib/server/user/preferences-normalize'; + +function makeContext(overrides: Partial = {}): PreferenceNormalizationContext { + return { + showAllProviderModels: true, + restrictUserApiKeys: true, + sharedProviders: [ + { slug: 'shared-a', providerType: 'openai', defaultModel: 'gpt-4o-mini-tts', defaultInstructions: 'hi' }, + { slug: 'shared-b', providerType: 'custom-openai', defaultModel: 'kokoro', defaultInstructions: null }, + ], + ...overrides, + }; +} + +describe('sanitizePreferencesPatch — inherit-by-default provider model', () => { + test('preserves an empty providerRef as "inherit" rather than collapsing to a concrete provider', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: '', voiceSpeed: 1.5 }, + makeContext(), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(patch.providerType).toBe('unknown'); + expect(patch.ttsModel).toBe(''); + expect(patch.voiceSpeed).toBe(1.5); + expect(migrated).toBe(false); + }); + + test('migrates a stale built-in (custom-openai) selection to inherit under restricted mode', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: 'custom-openai', providerType: 'custom-openai', ttsModel: 'kokoro' }, + makeContext({ restrictUserApiKeys: true }), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(patch.providerType).toBe('unknown'); + expect(patch.ttsModel).toBe(''); + expect(migrated).toBe(true); + }); + + test('keeps an explicit built-in selection when API keys are NOT restricted', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'openai', providerType: 'openai', ttsModel: 'tts-1' }, + makeContext({ restrictUserApiKeys: false }), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('openai'); + expect(patch.providerType).toBe('openai'); + expect(patch.ttsModel).toBe('tts-1'); + }); + + test('preserves an explicit shared-provider selection and resolves its type', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'shared-b', ttsModel: 'my-model' }, + makeContext(), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('shared-b'); + expect(patch.providerType).toBe('custom-openai'); + expect(patch.ttsModel).toBe('my-model'); + }); + + test('locks the model to the shared provider default when showAllProviderModels is false', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'shared-a', ttsModel: 'something-else' }, + makeContext({ showAllProviderModels: false }), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('shared-a'); + expect(patch.ttsModel).toBe('gpt-4o-mini-tts'); + }); + + test('treats the legacy "default-openai" sentinel as inherit when it is not a real shared provider', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: 'default-openai' }, + makeContext(), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(migrated).toBe(true); + }); + + test('does not force provider fields on a partial PUT patch that omits them', () => { + const { patch } = sanitizePreferencesPatch( + { voice: 'af_sarah' }, + makeContext(), + { fillMissingProvider: false }, + ); + expect(patch.voice).toBe('af_sarah'); + expect('providerRef' in patch).toBe(false); + expect('providerType' in patch).toBe(false); + expect('ttsModel' in patch).toBe(false); + }); +}); diff --git a/tests/unit/user-preferences-sync.vitest.spec.ts b/tests/unit/user-preferences-sync.vitest.spec.ts new file mode 100644 index 0000000..aae3c76 --- /dev/null +++ b/tests/unit/user-preferences-sync.vitest.spec.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + cancelPendingPreferenceSync, + flushUserPreferencesSync, + scheduleUserPreferencesSync, +} from '../../src/lib/client/api/user-state'; + +type FetchMock = ReturnType & { calls: Array<{ url: string; init?: RequestInit }> }; + +function installFetchMock(responder: () => Response): FetchMock { + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + mock.calls.push({ url: String(input), init }); + return responder(); + }) as unknown as FetchMock; + mock.calls = []; + global.fetch = mock as unknown as typeof fetch; + return mock; +} + +function okResponse(): Response { + return new Response( + JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('flushUserPreferencesSync', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.useFakeTimers(); + cancelPendingPreferenceSync(); + }); + + afterEach(() => { + cancelPendingPreferenceSync(); + vi.useRealTimers(); + global.fetch = originalFetch; + }); + + test('sends a pending debounced patch immediately instead of waiting for the timer', async () => { + const fetchMock = installFetchMock(okResponse); + + scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1'); + + // Nothing should have fired yet — it's still debounced. + expect(fetchMock.calls).toHaveLength(0); + + await flushUserPreferencesSync(); + + expect(fetchMock.calls).toHaveLength(1); + const { url, init } = fetchMock.calls[0]; + expect(url).toContain('/api/user/state/preferences'); + expect(init?.method).toBe('PUT'); + const body = JSON.parse(String(init?.body)); + expect(body.patch.providerRef).toBe('shared-b'); + expect(body.patch.providerType).toBe('openai'); + + // The debounce timer must not also fire a duplicate request afterwards. + await vi.runAllTimersAsync(); + expect(fetchMock.calls).toHaveLength(1); + }); + + test('is a no-op when there is no pending patch', async () => { + const fetchMock = installFetchMock(okResponse); + await flushUserPreferencesSync(); + expect(fetchMock.calls).toHaveLength(0); + }); + + test('rejects when the server write fails so callers can surface the error', async () => { + installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + })); + + scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1'); + + await expect(flushUserPreferencesSync()).rejects.toThrow(); + }); +});