refactor(config): centralize provider default resolution and simplify user preference inheritance
Move provider normalization logic into a dedicated module to ensure consistent handling of provider defaults and user preference inheritance across client and server. Update layout components to mount ConfigProvider only at the shared layout level, preventing unnecessary remounts and hydration issues during navigation. Adjust app config defaults so user provider settings are empty by default, always inheriting the admin-configured provider unless explicitly set. Add tests for normalization and sync logic to ensure robust provider resolution.
This commit is contained in:
parent
3d1ef1fd41
commit
925c995274
15 changed files with 523 additions and 279 deletions
|
|
@ -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 (
|
||||
<ConfigProvider>
|
||||
<DocumentProvider>
|
||||
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
||||
</DocumentProvider>
|
||||
</ConfigProvider>
|
||||
<DocumentProvider>
|
||||
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
||||
</DocumentProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ConfigProvider>
|
||||
<TTSProvider>
|
||||
{children}
|
||||
</TTSProvider>
|
||||
</ConfigProvider>
|
||||
<TTSProvider>
|
||||
{children}
|
||||
</TTSProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ConfigProvider>
|
||||
<TTSProvider>{children}</TTSProvider>
|
||||
</ConfigProvider>
|
||||
<TTSProvider>{children}</TTSProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<AppShell>
|
||||
<AppMain>{children}</AppMain>
|
||||
</AppShell>
|
||||
{/* 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. */}
|
||||
<ConfigProvider>
|
||||
<AppShell>
|
||||
<AppMain>{children}</AppMain>
|
||||
</AppShell>
|
||||
</ConfigProvider>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
|
|
|
|||
|
|
@ -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 PdfReaderLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<TTSProvider>{children}</TTSProvider>
|
||||
</ConfigProvider>
|
||||
<TTSProvider>{children}</TTSProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): Record<str
|
|||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<PreferenceNormalizationContext> {
|
||||
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<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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<string, unknown>;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className={fieldLabelClass}>TTS Provider</label>
|
||||
{ttsProviders.length === 0 ? (
|
||||
{sharedProvidersLoading ? (
|
||||
<p className="text-xs text-soft">Loading providers…</p>
|
||||
) : ttsProviders.length === 0 ? (
|
||||
<p className="text-xs text-accent">
|
||||
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
||||
</p>
|
||||
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(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<AppConfigValues>) => {
|
||||
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<AppConfigRow> = {};
|
||||
|
||||
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<AppConfigRow> = {};
|
||||
|
||||
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<AppConfigRow> = { 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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>
|
|||
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',
|
||||
|
|
|
|||
161
src/lib/server/user/preferences-normalize.ts
Normal file
161
src/lib/server/user/preferences-normalize.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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<AppConfigValues> = {
|
||||
get(_target, prop) {
|
||||
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
99
tests/unit/user-preferences-normalize.vitest.spec.ts
Normal file
99
tests/unit/user-preferences-normalize.vitest.spec.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
81
tests/unit/user-preferences-sync.vitest.spec.ts
Normal file
81
tests/unit/user-preferences-sync.vitest.spec.ts
Normal file
|
|
@ -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<typeof vi.fn> & { 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue