Merge pull request #103 from richardr1126/fix/provider-resolution
fix(tts): make user/shared provider selection reliable and default to inheriting the admin provider
This commit is contained in:
commit
5185974eb3
32 changed files with 817 additions and 367 deletions
|
|
@ -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}]}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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=<generate-with-openssl-rand-hex-32>
|
||||
# Optional when you need multiple local origins:
|
||||
|
|
@ -260,10 +259,9 @@ AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
|||
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
|
||||
|
||||
```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=<generate-with-openssl-rand-hex-32>
|
||||
# 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=<generate-with-openssl-rand-hex-32>
|
||||
|
|
@ -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=<generate-with-openssl-rand-hex-32>
|
||||
COMPUTE_WORKER_URL=http://localhost:8081
|
||||
|
|
|
|||
|
|
@ -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://<YOUR_LAN_IP>: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.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,81 +209,66 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
}, [appConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return;
|
||||
if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) {
|
||||
const resetPatch: Partial<AppConfigRow> = {};
|
||||
|
||||
const resetPatch: Partial<AppConfigRow> = {};
|
||||
// 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<AppConfigRow> = {};
|
||||
|
||||
const resetPatch: Partial<AppConfigRow> = {};
|
||||
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<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 },
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<unknown>;
|
||||
try {
|
||||
|
|
@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
|
|||
}
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const baseUrl = process.env.API_BASE?.trim() || null;
|
||||
const now = Date.now();
|
||||
let enc: ReturnType<typeof encryptSecret>;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> {
|
||||
export async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<Record<string, unknown>> {
|
||||
const model = request.model as string;
|
||||
|
||||
if (model === 'google/gemini-3.1-flash-tts') {
|
||||
|
|
@ -517,6 +521,16 @@ async function addReplicateLanguageInput(
|
|||
input: Record<string, unknown>,
|
||||
request: ResolvedServerTTSRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
|
|||
model,
|
||||
apiKey = '',
|
||||
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> {
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
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 && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -28,6 +28,28 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
|
|||
zm: 'zh-CN',
|
||||
};
|
||||
|
||||
const REPLICATE_KOKORO_LANGUAGE_CODE_BY_TAG: Readonly<Record<string, string>> = {
|
||||
'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<Record<string, string>> = {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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('日本語');
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
108
tests/unit/user-preferences-normalize.vitest.spec.ts
Normal file
108
tests/unit/user-preferences-normalize.vitest.spec.ts
Normal file
|
|
@ -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> = {}): 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({});
|
||||
});
|
||||
});
|
||||
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