diff --git a/.env.example b/.env.example index 02c3e57..173fc65 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ # remove them once the seed has run. Auth must be configured for this seed path. # See Settings → Admin → Shared providers. API_BASE=http://localhost:8880/v1 -API_KEY=api_key_optional +API_KEY= # Auth configuration (required in v4+) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -88,4 +88,4 @@ S3_BUCKET= # (Optional) v4 JSON seed for first-boot runtime config + shared providers. # If both are set, RUNTIME_SEED_JSON_PATH is used. # RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json -# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]} +# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","defaultModel":"kokoro","enabled":true}]} diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 181071a..82a70c4 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -50,10 +50,10 @@ Whether users can supply their own personal built-in provider keys is controlled ### Auto-seeded "default-openai" -On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with: +On first boot, if `admin_providers` is empty and `API_BASE` or `API_KEY` is set, OpenReader creates a single shared provider with: - slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai` -- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted) +- baseUrl from `API_BASE`, apiKey from `API_KEY` when provided (blank keys are supported) - defaultModel set to `kokoro` (you can edit it in Admin → Shared providers) After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient. @@ -124,7 +124,7 @@ In v4, runtime site features are managed by admin settings and optional JSON see 1. Deploy this version with your existing env values in place. 2. Boot the app once. Open Settings → Admin and verify: - Seeded settings appear as **from seed** (if you supplied a runtime JSON seed). - - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set). + - A `default-openai` row exists in **Shared providers** (if you had `API_BASE` or `API_KEY` set). 3. Remove any bootstrap env vars you no longer need from `.env`. 4. Redeploy. Behavior is unchanged — the DB is now the source of truth. diff --git a/docs-site/docs/configure/tts-provider-guides/other.md b/docs-site/docs/configure/tts-provider-guides/other.md index bcb3a0a..4e3aafc 100644 --- a/docs-site/docs/configure/tts-provider-guides/other.md +++ b/docs-site/docs/configure/tts-provider-guides/other.md @@ -26,7 +26,7 @@ Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS ```env API_BASE=http://your-tts-server/v1 -API_KEY=optional-key-if-required +# API_KEY=optional-key-if-required ``` **Or in-app via Settings → TTS Provider:** diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7454b7b..3f5f0b1 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -249,7 +249,6 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -260,10 +259,9 @@ AUTH_SECRET= ```env -# API_BASE / API_KEY are seeded into the admin "default-openai" shared provider +# API_BASE and optional API_KEY are seeded into the admin "default-openai" shared provider # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -275,7 +273,6 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none USE_EMBEDDED_WEED_MINI=false BASE_URL=http://localhost:3003 AUTH_SECRET= @@ -293,7 +290,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= COMPUTE_WORKER_URL=http://localhost:8081 diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 06cf6fa..e9e1cc0 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -42,7 +42,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://localhost:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e ADMIN_EMAILS=you@example.com \ @@ -54,7 +53,7 @@ What this command enables: - `-p 3003:3003`: exposes the OpenReader web app/API. - `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download. - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. -- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). +- `-e API_BASE=...` / optional `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest when provided). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). - `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings. @@ -70,7 +69,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \ @@ -88,7 +86,7 @@ What this command enables: - `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin. - `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled. - `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that. -- `API_KEY` seeds the default provider's key (encrypted at rest). After first boot, manage keys from the admin panel or set per-user keys if `restrictUserApiKeys=false`. Always needed for seeding the default provider. +- `API_KEY` optionally seeds the default provider's key (encrypted at rest). Omit it for an upstream that does not require authentication. - `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI. - `openreader_docstore` volume keeps data persistent across restarts. @@ -112,7 +110,7 @@ What this command enables: - Fast startup with only the required auth env vars. - No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount. - The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode. -- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. +- No TTS provider preset by default. Configure `API_BASE` and, when required, `API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index d80bd8e..dfe985c 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -85,7 +85,7 @@ App server log level. Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider. - Example: `http://host.docker.internal:8880/v1` -- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set. +- Read only for provider bootstrap when shared providers are empty. Setting `API_BASE` is sufficient; `API_KEY` may be blank. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. ### API_KEY @@ -96,27 +96,6 @@ Optional first-boot bootstrap API key for the auto-created `default-openai` shar - Stored encrypted at rest after bootstrap. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. -### TTS Daily Rate Limiting (Runtime Settings) - -Managed as runtime config in **Settings → Admin → Site features**. - -- `disableTtsRateLimit` default: `true` (daily TTS limits disabled) -- `ttsDailyLimitAnonymous` default: `50000` -- `ttsDailyLimitAuthenticated` default: `500000` -- `ttsIpDailyLimitAnonymous` default: `100000` -- `ttsIpDailyLimitAuthenticated` default: `1000000` - -### TTS Upstream Settings (Runtime Settings) - -Managed as runtime config in **Settings → Admin → Site features → TTS upstream**. - -- `ttsUpstreamMaxRetries` default: `2` -- `ttsUpstreamTimeoutMs` default: `285000` -- `ttsCacheMaxSizeBytes` default: `268435456` (256 MB) -- `ttsCacheTtlMs` default: `1800000` (30 minutes) - -There are no dedicated env vars for these runtime settings. - ## Auth and Identity ### BASE_URL @@ -339,19 +318,6 @@ External compute worker URL. Shared token for app-to-external-worker requests. -## Compute PDF Parsing Rate Limiting (Runtime Settings) - -Managed as runtime config in **Settings → Admin → Site features**. - -- `disableComputeRateLimit` default: `true` -- `computeParseBurstMax` default: `8` -- `computeParseBurstWindowSec` default: `60` -- `computeParseSustainedMax` default: `24` -- `computeParseSustainedWindowSec` default: `600` -- `maxUploadMb` default: `200` - -There are no dedicated env vars for these runtime settings. - ## Audio Runtime ### FFMPEG_BIN @@ -446,7 +412,6 @@ Example: "displayName": "Default (seeded)", "providerType": "custom-openai", "baseUrl": "http://localhost:8880/v1", - "apiKey": "api_key_optional", "defaultModel": "kokoro", "enabled": true } @@ -457,7 +422,7 @@ Example: Provider fallback behavior: - If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped. -- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. +- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. `API_BASE` alone is sufficient for an upstream that does not require authentication. Precedence summary: 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/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index e63d045..7aa881a 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -501,7 +501,7 @@ export async function POST(request: NextRequest) { normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, }); } - const openApiKey = credResolved.apiKey || 'none'; + const openApiKey = credResolved.apiKey; const openApiBaseUrl = credResolved.baseUrl; const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider; const model = resolveTtsModelForProvider({ diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index f089231..4dfb6d9 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -644,7 +644,7 @@ export async function POST(request: NextRequest) { instructions: effectiveSettings.ttsInstructions, language: effectiveSettings.language, provider: requestCreds.provider, - apiKey: requestCreds.apiKey || 'none', + apiKey: requestCreds.apiKey, baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, }, request.signal, { diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 0e1b5f5..1c31b39 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -62,7 +62,7 @@ export async function GET(req: NextRequest) { const voices = await resolveVoices({ provider: resolved.provider, model: requestedModel, - apiKey: resolved.apiKey || 'none', + apiKey: resolved.apiKey, baseUrl: resolved.baseUrl, }); return NextResponse.json({ voices }); 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..a397c0f 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,81 +209,66 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return; + if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + // 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. - 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; - } - // 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. + if (Object.keys(resetPatch).length === 0) return; - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to clear hidden TTS provider settings:', error); - }); - queueSyncedPreferencePatch(resetPatch); - }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to clear hidden TTS provider settings:', error); + }); + queueSyncedPreferencePatch(resetPatch); + } + }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig) return; + if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + 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)) { + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; + } - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; + if (Object.keys(resetPatch).length === 0) return; + + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to enforce restricted user API key mode:', error); + }); + queueSyncedPreferencePatch(resetPatch); } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } - 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; - } - } - - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - 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/seed.ts b/src/lib/server/admin/seed.ts index c06cc48..0409299 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -32,7 +32,7 @@ type SeedProviderInput = { slug: string; displayName: string; providerType: TtsProviderId; - apiKey: string; + apiKey?: string; baseUrl?: string | null; defaultModel?: string | null; defaultInstructions?: string | null; @@ -187,8 +187,8 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI if (typeof rec.providerType !== 'string') { throw new Error(`Seed JSON providers[${index}].providerType must be a string`); } - if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) { - throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty string`); + if (rec.apiKey !== undefined && typeof rec.apiKey !== 'string') { + throw new Error(`Seed JSON providers[${index}].apiKey must be a string when provided`); } if (rec.baseUrl !== undefined && rec.baseUrl !== null && typeof rec.baseUrl !== 'string') { @@ -208,7 +208,7 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI slug: validateSlug(rec.slug), displayName: rec.displayName.trim(), providerType: validateProviderType(rec.providerType), - apiKey: rec.apiKey.trim(), + ...(rec.apiKey !== undefined ? { apiKey: rec.apiKey.trim() } : {}), ...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}), ...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}), ...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}), @@ -246,7 +246,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi if (existing.length > 0) continue; try { - const encrypted = encryptSecret(provider.apiKey); + const apiKey = provider.apiKey ?? ''; + const encrypted = encryptSecret(apiKey); await db.insert(adminProviders).values({ id: randomUUID(), slug: provider.slug, @@ -255,7 +256,7 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi baseUrl: provider.baseUrl ?? null, apiKeyCiphertext: encrypted.ciphertext, apiKeyIv: encrypted.iv, - apiKeyLast4: apiKeyLast4(provider.apiKey), + apiKeyLast4: apiKeyLast4(apiKey), defaultModel: provider.defaultModel ?? null, defaultInstructions: provider.defaultInstructions ?? null, enabled: provider.enabled === false ? 0 : 1, @@ -278,8 +279,9 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi } async function seedDefaultAdminProviderFromEnvFallback(): Promise { - const apiKey = process.env.API_KEY; - if (!apiKey || !apiKey.trim()) return; + const apiKey = process.env.API_KEY?.trim() ?? ''; + const baseUrl = process.env.API_BASE?.trim() || null; + if (!apiKey && !baseUrl) return; let existing: Array; try { @@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise { } if (existing.length > 0) return; - const baseUrl = process.env.API_BASE?.trim() || null; const now = Date.now(); let enc: ReturnType; try { 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/tts/generate.ts b/src/lib/server/tts/generate.ts index cfca3d3..0b2f719 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -15,7 +15,11 @@ import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import { access, readFile } from 'fs/promises'; import { resolve } from 'path'; -import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language'; +import { + getLanguageDisplayName, + resolveReplicateKokoroLanguageCode, + toBaseLanguageCode, +} from '@/lib/shared/language'; export interface ServerTTSRequest { text: string; @@ -434,7 +438,7 @@ async function fetchTTSBufferWithRetry( } } -async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise> { +export async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise> { const model = request.model as string; if (model === 'google/gemini-3.1-flash-tts') { @@ -517,6 +521,16 @@ async function addReplicateLanguageInput( input: Record, request: ResolvedServerTTSRequest, ): Promise> { + if (request.model === REPLICATE_KOKORO_82M_VERSIONED_MODEL) { + const languageCode = resolveReplicateKokoroLanguageCode({ + language: request.language, + voice: request.voice, + }); + if (languageCode) { + input.language_code = languageCode; + } + return input; + } if (!request.language) return input; const languageInput = await resolveReplicateLanguageInput({ provider: 'replicate', @@ -627,6 +641,7 @@ async function runProviderRequest( const openai = new OpenAI({ apiKey: request.apiKey, baseURL: request.baseUrl, + defaultHeaders: request.apiKey ? undefined : { Authorization: null }, maxRetries: 0, timeout: upstreamSettings.ttsUpstreamTimeoutMs, }); diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 7d80a13..4a517c4 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({ model, apiKey = '', }: ResolveVoicesOptions): Promise { - if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null; + if (provider !== 'replicate' || !apiKey) return null; const cached = replicateLanguageInputCache.get(model); if (cached) return cached; @@ -377,7 +377,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { signal: controller.signal, headers: { - Authorization: `Bearer ${apiKey}`, + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 'Content-Type': 'application/json', }, }); diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts new file mode 100644 index 0000000..51d0a4e --- /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 && !Array.isArray(value); +} + +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/lib/shared/language.ts b/src/lib/shared/language.ts index b278bd0..4ce259e 100644 --- a/src/lib/shared/language.ts +++ b/src/lib/shared/language.ts @@ -28,6 +28,28 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly> = { zm: 'zh-CN', }; +const REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG: Readonly> = { + 'en-US': 'a', + 'en-GB': 'b', + es: 'e', + fr: 'f', + hi: 'h', + it: 'i', + ja: 'j', + 'pt-BR': 'p', + 'zh-CN': 'z', +}; + +const REPLICATE_KOKORO_LANGUAGE_CODE_BY_BASE_TAG: Readonly> = { + es: 'e', + fr: 'f', + hi: 'h', + it: 'i', + ja: 'j', + pt: 'p', + zh: 'z', +}; + export const KOKORO_SUPPORTED_LANGUAGES = [ 'en', 'es', @@ -79,6 +101,26 @@ export function inferKokoroLanguageFromVoice(voice: string | null | undefined): return languages.size === 1 ? [...languages][0] : null; } +export function resolveReplicateKokoroLanguageCode(input: { + language?: string | null; + voice?: string | null; +}): string | null { + const normalizedLanguage = input.language ? normalizeLanguageTag(input.language) : null; + const voiceLanguage = inferKokoroLanguageFromVoice(input.voice); + + for (const candidate of [normalizedLanguage, voiceLanguage]) { + if (!candidate) continue; + + const exactCode = REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG[candidate]; + if (exactCode) return exactCode; + + const baseCode = REPLICATE_KOKORO_LANGUAGE_CODE_BY_BASE_TAG[toBaseLanguageCode(candidate)]; + if (baseCode) return baseCode; + } + + return null; +} + export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] { if (!voice?.trim()) return []; return Array.from(new Set( 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/admin-providers-validation.vitest.spec.ts b/tests/unit/admin-providers-validation.vitest.spec.ts index e977cc7..6511e0d 100644 --- a/tests/unit/admin-providers-validation.vitest.spec.ts +++ b/tests/unit/admin-providers-validation.vitest.spec.ts @@ -5,6 +5,8 @@ import { adminProviders } from '../../src/db/schema'; import { AdminProviderError, + createAdminProvider, + decryptedKeyFor, listAdminProviders, toMasked, validateProviderType, @@ -53,6 +55,23 @@ describe('admin provider validation', () => { expect(toMasked(record).apiKeyMask).toBe('••••abcd'); }); + test('creates providers without an API key', async () => { + const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + const created = await createAdminProvider({ + slug: `keyless-${suffix}`, + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + + try { + expect(toMasked(created).apiKeyMask).toBe('(not set)'); + await expect(decryptedKeyFor(created)).resolves.toBe(''); + } finally { + await db.delete(adminProviders).where(inArray(adminProviders.id, [created.id])); + } + }); + test('lists providers in deterministic updated/created/slug order', async () => { const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; const inserted = [ diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts index b50552c..4fd9e40 100644 --- a/tests/unit/language.vitest.spec.ts +++ b/tests/unit/language.vitest.spec.ts @@ -8,6 +8,7 @@ import { keepKokoroVoicesInOneLanguage, normalizeOptionalLanguageTag, normalizeUnicodeToken, + resolveReplicateKokoroLanguageCode, resolveTtsLanguage, segmentSentences, segmentWords, @@ -95,6 +96,13 @@ describe('multilingual language utilities', () => { expect(toBaseLanguageCode('zh-CN')).toBe('zh'); }); + test('maps Kokoro voices and normalized language tags to Replicate language codes', () => { + expect(resolveReplicateKokoroLanguageCode({ language: 'en', voice: 'af_sarah' })).toBe('a'); + expect(resolveReplicateKokoroLanguageCode({ language: 'en', voice: 'bf_emma' })).toBe('b'); + expect(resolveReplicateKokoroLanguageCode({ language: 'ja-JP', voice: 'jf_alpha' })).toBe('j'); + expect(resolveReplicateKokoroLanguageCode({ language: 'zh-TW', voice: 'zf_xiaobei' })).toBe('z'); + }); + test('normalizes Unicode tokens without dropping non-Latin scripts', () => { expect(normalizeUnicodeToken('École!')).toBe('école'); expect(normalizeUnicodeToken('日本語。')).toBe('日本語'); diff --git a/tests/unit/runtime-seed-json.vitest.spec.ts b/tests/unit/runtime-seed-json.vitest.spec.ts index e62cefc..fd710bf 100644 --- a/tests/unit/runtime-seed-json.vitest.spec.ts +++ b/tests/unit/runtime-seed-json.vitest.spec.ts @@ -160,6 +160,26 @@ describe('runtime seed JSON parsing', () => { expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); }); + + test('accepts seeded providers without an API key', () => { + const parsed = __seedInternals.parseRuntimeSeedDocument(JSON.stringify({ + version: 1, + providers: [{ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }], + })); + + expect(parsed.seed.providers?.[0]).toMatchObject({ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + expect(parsed.seed.providers?.[0]).not.toHaveProperty('apiKey'); + }); }); describe('runtime config JSON seeding', () => { @@ -380,6 +400,44 @@ describe('provider seeding and fallback precedence', () => { } }); + test('falls back to API_BASE with a blank API_KEY', async () => { + const providerSnapshot = await snapshotProvidersBySlug(testSlugs); + const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } }); + + try { + await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); + const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); + if (blockedByOtherRows) return; + + await withEnv({ + RUNTIME_SEED_JSON: runtimeSeed, + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: 'http://localhost:8880/v1', + API_KEY: '', + AUTH_SECRET: 'seed-test-auth-secret-keyless', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ + slug: adminProviders.slug, + baseUrl: adminProviders.baseUrl, + apiKeyLast4: adminProviders.apiKeyLast4, + }) + .from(adminProviders); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + slug: 'default-openai', + baseUrl: 'http://localhost:8880/v1', + apiKeyLast4: '', + }); + } finally { + await restoreProvidersBySlug(testSlugs, providerSnapshot); + } + }); + test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => { const providerSnapshot = await snapshotProvidersBySlug(testSlugs); const runtimeSeed = JSON.stringify({ version: 1, providers: [] }); diff --git a/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts index 4b35d02..e32f6d3 100644 --- a/tests/unit/tts-generate.vitest.spec.ts +++ b/tests/unit/tts-generate.vitest.spec.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'vitest'; import { + buildReplicateInput, buildTTSCacheKey, extractReplicateAudioUrl, resolveReplicateLanguageValue, } from '../../src/lib/server/tts/generate'; +import { REPLICATE_KOKORO_82M_VERSIONED_MODEL } from '../../src/lib/shared/tts-provider-catalog'; describe('replicate output URL extraction', () => { test('returns direct URL string output', () => { @@ -70,4 +72,53 @@ describe('Replicate language schema values', () => { expect(resolveReplicateLanguageValue('ja-JP', ['English', 'Japanese'])).toBe('Japanese'); expect(resolveReplicateLanguageValue('ja-JP', ['English', 'French'])).toBeNull(); }); + + test('includes language_code for the built-in Replicate Kokoro model', async () => { + await expect(buildReplicateInput({ + text: 'Hello world', + voice: 'af_sarah', + speed: 1, + format: 'mp3', + model: REPLICATE_KOKORO_82M_VERSIONED_MODEL, + language: 'en', + provider: 'replicate', + apiKey: 'r8_token', + testNamespace: null, + })).resolves.toEqual({ + text: 'Hello world', + voice: 'af_sarah', + language_code: 'a', + }); + + await expect(buildReplicateInput({ + text: 'Hello world', + voice: 'bf_emma', + speed: 1, + format: 'mp3', + model: REPLICATE_KOKORO_82M_VERSIONED_MODEL, + language: 'en', + provider: 'replicate', + apiKey: 'r8_token', + testNamespace: null, + })).resolves.toEqual({ + text: 'Hello world', + voice: 'bf_emma', + language_code: 'b', + }); + + await expect(buildReplicateInput({ + text: 'Hello world', + voice: 'af_sarah', + speed: 1, + format: 'mp3', + model: REPLICATE_KOKORO_82M_VERSIONED_MODEL, + provider: 'replicate', + apiKey: 'r8_token', + testNamespace: null, + })).resolves.toEqual({ + text: 'Hello world', + voice: 'af_sarah', + language_code: 'a', + }); + }); }); diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index 8996617..02e72af 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -435,6 +435,50 @@ describe('tts provider catalog', () => { globalThis.fetch = originalFetch; } }); + + test('discovers Replicate language input key for built-in models too', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return { + ok: true, + json: async () => ({ + latest_version: { + openapi_schema: { + components: { + schemas: { + Input: { + type: 'object', + properties: { + text: { type: 'string' }, + language: { type: 'string', enum: ['auto', 'en', 'ja'] }, + }, + }, + }, + }, + }, + }, + }), + } as Response; + }; + + try { + await expect(resolveReplicateLanguageInputKey({ + provider: 'replicate', + model: 'minimax/speech-2.8-turbo', + apiKey: 'r8_token', + })).resolves.toBe('language'); + await expect(resolveReplicateLanguageInputKey({ + provider: 'replicate', + model: 'minimax/speech-2.8-turbo', + apiKey: 'r8_token', + })).resolves.toBe('language'); + expect(calls).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe('config helpers', () => { @@ -476,9 +520,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..13f5478 --- /dev/null +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'vitest'; + +import { + sanitizePreferencesPatch, + sanitizeSavedVoices, + 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); + }); + + test('rejects arrays where preference records are expected', () => { + expect(sanitizePreferencesPatch(['voice'], makeContext(), { fillMissingProvider: false })).toEqual({ + patch: {}, + migrated: false, + }); + expect(sanitizeSavedVoices(['af_sarah'])).toEqual({}); + }); +}); 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(); + }); +});