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.
|
# remove them once the seed has run. Auth must be configured for this seed path.
|
||||||
# See Settings → Admin → Shared providers.
|
# See Settings → Admin → Shared providers.
|
||||||
API_BASE=http://localhost:8880/v1
|
API_BASE=http://localhost:8880/v1
|
||||||
API_KEY=api_key_optional
|
API_KEY=
|
||||||
|
|
||||||
# Auth configuration (required in v4+)
|
# 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)
|
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.
|
# (Optional) v4 JSON seed for first-boot runtime config + shared providers.
|
||||||
# If both are set, RUNTIME_SEED_JSON_PATH is used.
|
# If both are set, RUNTIME_SEED_JSON_PATH is used.
|
||||||
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json
|
# 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"
|
### 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`
|
- 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)
|
- 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.
|
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.
|
1. Deploy this version with your existing env values in place.
|
||||||
2. Boot the app once. Open Settings → Admin and verify:
|
2. Boot the app once. Open Settings → Admin and verify:
|
||||||
- Seeded settings appear as **from seed** (if you supplied a runtime JSON seed).
|
- 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`.
|
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.
|
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
|
```env
|
||||||
API_BASE=http://your-tts-server/v1
|
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:**
|
**Or in-app via Settings → TTS Provider:**
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,6 @@ Use one of these `.env` mode templates:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
API_KEY=none
|
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
# Optional when you need multiple local origins:
|
# 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">
|
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
|
||||||
|
|
||||||
```env
|
```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.
|
# on first boot, then no longer read. Manage them in Settings → Admin afterwards.
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
API_KEY=none
|
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
# Comma-separated emails to auto-promote to admin on signin.
|
# Comma-separated emails to auto-promote to admin on signin.
|
||||||
|
|
@ -275,7 +273,6 @@ ADMIN_EMAILS=you@example.com
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
API_KEY=none
|
|
||||||
USE_EMBEDDED_WEED_MINI=false
|
USE_EMBEDDED_WEED_MINI=false
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
|
|
@ -293,7 +290,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
API_KEY=none
|
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
COMPUTE_WORKER_URL=http://localhost:8081
|
COMPUTE_WORKER_URL=http://localhost:8081
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ docker run --name openreader \
|
||||||
-p 8333:8333 \
|
-p 8333:8333 \
|
||||||
-v openreader_docstore:/app/docstore \
|
-v openreader_docstore:/app/docstore \
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||||
-e API_KEY=none \
|
|
||||||
-e BASE_URL=http://localhost:3003 \
|
-e BASE_URL=http://localhost:3003 \
|
||||||
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
||||||
-e ADMIN_EMAILS=you@example.com \
|
-e ADMIN_EMAILS=you@example.com \
|
||||||
|
|
@ -54,7 +53,7 @@ What this command enables:
|
||||||
- `-p 3003:3003`: exposes the OpenReader web app/API.
|
- `-p 3003:3003`: exposes the OpenReader web app/API.
|
||||||
- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download.
|
- `-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.
|
- `-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 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.
|
- `-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 \
|
-p 8333:8333 \
|
||||||
-v openreader_docstore:/app/docstore \
|
-v openreader_docstore:/app/docstore \
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||||
-e API_KEY=none \
|
|
||||||
-e BASE_URL=http://<YOUR_LAN_IP>:3003 \
|
-e BASE_URL=http://<YOUR_LAN_IP>:3003 \
|
||||||
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
||||||
-e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \
|
-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.
|
- `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.
|
- `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_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.
|
- `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.
|
- `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.
|
- 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.
|
- 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.
|
- 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>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ App server log level.
|
||||||
Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
|
Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
|
||||||
|
|
||||||
- Example: `http://host.docker.internal:8880/v1`
|
- 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**.
|
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
||||||
|
|
||||||
### API_KEY
|
### 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.
|
- Stored encrypted at rest after bootstrap.
|
||||||
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
- 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
|
## Auth and Identity
|
||||||
|
|
||||||
### BASE_URL
|
### BASE_URL
|
||||||
|
|
@ -339,19 +318,6 @@ External compute worker URL.
|
||||||
|
|
||||||
Shared token for app-to-external-worker requests.
|
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
|
## Audio Runtime
|
||||||
|
|
||||||
### FFMPEG_BIN
|
### FFMPEG_BIN
|
||||||
|
|
@ -446,7 +412,6 @@ Example:
|
||||||
"displayName": "Default (seeded)",
|
"displayName": "Default (seeded)",
|
||||||
"providerType": "custom-openai",
|
"providerType": "custom-openai",
|
||||||
"baseUrl": "http://localhost:8880/v1",
|
"baseUrl": "http://localhost:8880/v1",
|
||||||
"apiKey": "api_key_optional",
|
|
||||||
"defaultModel": "kokoro",
|
"defaultModel": "kokoro",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
|
@ -457,7 +422,7 @@ Example:
|
||||||
Provider fallback behavior:
|
Provider fallback behavior:
|
||||||
|
|
||||||
- If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped.
|
- 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:
|
Precedence summary:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
|
||||||
import { DocumentProvider } from '@/contexts/DocumentContext';
|
import { DocumentProvider } from '@/contexts/DocumentContext';
|
||||||
import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext';
|
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 }) {
|
export default function AppHomeLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<DocumentProvider>
|
||||||
<DocumentProvider>
|
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
||||||
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
</DocumentProvider>
|
||||||
</DocumentProvider>
|
|
||||||
</ConfigProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
|
||||||
import { TTSProvider } from '@/contexts/TTSContext';
|
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 }) {
|
export default function EpubReaderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<TTSProvider>
|
||||||
<TTSProvider>
|
{children}
|
||||||
{children}
|
</TTSProvider>
|
||||||
</TTSProvider>
|
|
||||||
</ConfigProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
|
||||||
import { TTSProvider } from '@/contexts/TTSContext';
|
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 }) {
|
export default function HtmlReaderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<TTSProvider>{children}</TTSProvider>
|
||||||
<TTSProvider>{children}</TTSProvider>
|
|
||||||
</ConfigProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
import { Providers } from '@/app/providers';
|
import { Providers } from '@/app/providers';
|
||||||
|
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||||
import { AppMain, AppShell } from '@/components/layout';
|
import { AppMain, AppShell } from '@/components/layout';
|
||||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||||
|
|
||||||
|
|
@ -33,9 +34,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<AppShell>
|
{/* ConfigProvider lives here, in the shared (app) layout, so it stays
|
||||||
<AppMain>{children}</AppMain>
|
mounted across library <-> reader navigation. Mounting it per-route
|
||||||
</AppShell>
|
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
|
<Toaster
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
|
||||||
import { TTSProvider } from '@/contexts/TTSContext';
|
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 }) {
|
export default function PdfReaderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<TTSProvider>{children}</TTSProvider>
|
||||||
<TTSProvider>{children}</TTSProvider>
|
|
||||||
</ConfigProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@ export async function POST(request: NextRequest) {
|
||||||
normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
|
normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const openApiKey = credResolved.apiKey || 'none';
|
const openApiKey = credResolved.apiKey;
|
||||||
const openApiBaseUrl = credResolved.baseUrl;
|
const openApiBaseUrl = credResolved.baseUrl;
|
||||||
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
|
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
|
||||||
const model = resolveTtsModelForProvider({
|
const model = resolveTtsModelForProvider({
|
||||||
|
|
|
||||||
|
|
@ -644,7 +644,7 @@ export async function POST(request: NextRequest) {
|
||||||
instructions: effectiveSettings.ttsInstructions,
|
instructions: effectiveSettings.ttsInstructions,
|
||||||
language: effectiveSettings.language,
|
language: effectiveSettings.language,
|
||||||
provider: requestCreds.provider,
|
provider: requestCreds.provider,
|
||||||
apiKey: requestCreds.apiKey || 'none',
|
apiKey: requestCreds.apiKey,
|
||||||
baseUrl: requestCreds.baseUrl,
|
baseUrl: requestCreds.baseUrl,
|
||||||
testNamespace: scope.testNamespace,
|
testNamespace: scope.testNamespace,
|
||||||
}, request.signal, {
|
}, request.signal, {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export async function GET(req: NextRequest) {
|
||||||
const voices = await resolveVoices({
|
const voices = await resolveVoices({
|
||||||
provider: resolved.provider,
|
provider: resolved.provider,
|
||||||
model: requestedModel,
|
model: requestedModel,
|
||||||
apiKey: resolved.apiKey || 'none',
|
apiKey: resolved.apiKey,
|
||||||
baseUrl: resolved.baseUrl,
|
baseUrl: resolved.baseUrl,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ voices });
|
return NextResponse.json({ voices });
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { userPreferences } from '@/db/schema';
|
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 { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
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 { listAdminProviders } from '@/lib/server/admin/providers';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
import {
|
||||||
|
sanitizePreferencesPatch,
|
||||||
|
type PreferenceNormalizationContext,
|
||||||
|
} from '@/lib/server/user/preferences-normalize';
|
||||||
import {
|
import {
|
||||||
deserializeUserPreferencesPayload,
|
deserializeUserPreferencesPayload,
|
||||||
extractUserPreferencesMeta,
|
extractUserPreferencesMeta,
|
||||||
|
|
@ -25,29 +27,14 @@ function serializePreferencesForDb(payload: Record<string, unknown>): Record<str
|
||||||
return JSON.stringify(payload);
|
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> {
|
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
||||||
const [runtimeConfig, providers] = await Promise.all([
|
const [runtimeConfig, providers] = await Promise.all([
|
||||||
getResolvedRuntimeConfig(),
|
getResolvedRuntimeConfig(),
|
||||||
listAdminProviders(),
|
listAdminProviders(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
defaultProviderRef: runtimeConfig.defaultTtsProvider,
|
|
||||||
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||||
|
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
|
||||||
sharedProviders: providers
|
sharedProviders: providers
|
||||||
.filter((entry) => entry.enabled)
|
.filter((entry) => entry.enabled)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
|
|
@ -69,128 +56,6 @@ function parseStoredPreferences(
|
||||||
return { ...sanitized, meta };
|
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 {
|
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||||
const normalized = coerceTimestampMs(value, nowTimestampMs());
|
const normalized = coerceTimestampMs(value, nowTimestampMs());
|
||||||
if (normalized <= 0) return nowTimestampMs();
|
if (normalized <= 0) return nowTimestampMs();
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
|
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
|
||||||
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
||||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
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 { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||||
import {
|
import {
|
||||||
SidebarNav,
|
SidebarNav,
|
||||||
|
|
@ -265,7 +267,7 @@ export function SettingsModal({
|
||||||
prefetch: prefetchLibraryDocuments,
|
prefetch: prefetchLibraryDocuments,
|
||||||
} = useLibraryDocumentsQuery(isSelectionModalOpen);
|
} = useLibraryDocumentsQuery(isSelectionModalOpen);
|
||||||
|
|
||||||
const { providers: sharedProviders } = useSharedProviders();
|
const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
|
||||||
const {
|
const {
|
||||||
providers: ttsProviders,
|
providers: ttsProviders,
|
||||||
models: ttsModels,
|
models: ttsModels,
|
||||||
|
|
@ -295,13 +297,18 @@ export function SettingsModal({
|
||||||
}, [changelogOpenSignal, setIsOpen]);
|
}, [changelogOpenSignal, setIsOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setLocalApiKey(apiKey);
|
||||||
setLocalBaseUrl(baseUrl);
|
setLocalBaseUrl(baseUrl);
|
||||||
setLocalProviderRef(providerRef);
|
setLocalProviderRef(providerRef);
|
||||||
setLocalProviderType(providerType);
|
setLocalProviderType(providerType);
|
||||||
setModelValue(ttsModel);
|
setModelValue(ttsModel);
|
||||||
setLocalTTSInstructions(ttsInstructions);
|
setLocalTTSInstructions(ttsInstructions);
|
||||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
|
}, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
|
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
|
||||||
|
|
@ -643,7 +650,9 @@ export function SettingsModal({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className={fieldLabelClass}>TTS Provider</label>
|
<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">
|
<p className="text-xs text-accent">
|
||||||
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -815,7 +824,7 @@ export function SettingsModal({
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit || sharedProvidersLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const defaults = resolveProviderDefaults({
|
const defaults = resolveProviderDefaults({
|
||||||
providerRef: selectedProviderRef,
|
providerRef: selectedProviderRef,
|
||||||
|
|
@ -833,6 +842,16 @@ export function SettingsModal({
|
||||||
: defaults.defaultModel;
|
: defaults.defaultModel;
|
||||||
await updateConfigKey('ttsModel', finalModel);
|
await updateConfigKey('ttsModel', finalModel);
|
||||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
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);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
|
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 { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||||
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
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 { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
|
||||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
||||||
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
||||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||||
|
|
@ -69,16 +69,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const didRunStartupMigrations = useRef(false);
|
const didRunStartupMigrations = useRef(false);
|
||||||
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
||||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
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 { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||||
const providerResetDefaults = useMemo(() => {
|
// The instance/admin default provider. An empty user providerRef "inherits"
|
||||||
return resolveProviderDefaults({
|
// this, resolved (admin slug -> concrete provider) where the value is used.
|
||||||
providerRef: APP_CONFIG_DEFAULTS.providerRef,
|
const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
|
||||||
providerType: APP_CONFIG_DEFAULTS.providerType,
|
|
||||||
sharedProviders,
|
|
||||||
});
|
|
||||||
}, [sharedProviders]);
|
|
||||||
|
|
||||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||||
if (sessionKey === 'no-session') return;
|
if (sessionKey === 'no-session') return;
|
||||||
|
|
@ -213,81 +209,66 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
}, [appConfig]);
|
}, [appConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (Object.keys(resetPatch).length === 0) return;
|
||||||
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;
|
updateAppConfig(resetPatch).catch((error) => {
|
||||||
|
console.warn('Failed to clear hidden TTS provider settings:', error);
|
||||||
updateAppConfig(resetPatch).catch((error) => {
|
});
|
||||||
console.warn('Failed to clear hidden TTS provider settings:', error);
|
queueSyncedPreferencePatch(resetPatch);
|
||||||
});
|
}
|
||||||
queueSyncedPreferencePatch(resetPatch);
|
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||||
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (Object.keys(resetPatch).length === 0) return;
|
||||||
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
|
|
||||||
|
updateAppConfig(resetPatch).catch((error) => {
|
||||||
|
console.warn('Failed to enforce restricted user API key mode:', error);
|
||||||
|
});
|
||||||
|
queueSyncedPreferencePatch(resetPatch);
|
||||||
}
|
}
|
||||||
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
|
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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({
|
const providerDefaults = resolveProviderDefaults({
|
||||||
providerRef: appConfig.providerRef,
|
providerRef: appConfig.providerRef,
|
||||||
providerType: appConfig.providerType,
|
providerType: appConfig.providerType,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
fallbackProviderRef: providerResetDefaults.providerRef,
|
fallbackProviderRef: adminDefaultProviderRef,
|
||||||
});
|
});
|
||||||
if (!providerDefaults.defaultModel) return;
|
if (!providerDefaults.defaultModel) return;
|
||||||
if (appConfig.ttsModel === 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);
|
console.warn('Failed to enforce provider default model restriction:', error);
|
||||||
});
|
});
|
||||||
queueSyncedPreferencePatch(patch);
|
queueSyncedPreferencePatch(patch);
|
||||||
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
|
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDBReady || !appConfig || isSessionPending) return;
|
if (!isDBReady || !appConfig || isSessionPending) return;
|
||||||
|
|
@ -359,33 +340,48 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
htmlHighlightEnabled,
|
htmlHighlightEnabled,
|
||||||
htmlWordHighlightEnabled,
|
htmlWordHighlightEnabled,
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
const providerType = useMemo(
|
// Resolve the effective provider for consumers. An empty stored providerRef
|
||||||
() => resolveEffectiveProviderType({
|
// 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,
|
providerRef,
|
||||||
providerType: _persistedProviderType,
|
providerType: _persistedProviderType,
|
||||||
sharedProviders,
|
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(() => {
|
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;
|
if (appConfig.providerType === providerType) return;
|
||||||
const patch: Partial<AppConfigRow> = { providerType };
|
const patch: Partial<AppConfigRow> = { providerType };
|
||||||
updateAppConfig(patch).catch((error) => {
|
updateAppConfig(patch).catch((error) => {
|
||||||
console.warn('Failed to persist resolved providerType:', error);
|
console.warn('Failed to persist resolved providerType:', error);
|
||||||
});
|
});
|
||||||
queueSyncedPreferencePatch(patch);
|
queueSyncedPreferencePatch(patch);
|
||||||
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]);
|
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
|
||||||
void _persistedProviderType;
|
void _persistedProviderType;
|
||||||
|
|
||||||
useEffect(() => {
|
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({
|
const providerDefaults = resolveProviderDefaults({
|
||||||
providerRef: appConfig.providerRef,
|
providerRef: appConfig.providerRef,
|
||||||
providerType: appConfig.providerType,
|
providerType: appConfig.providerType,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
fallbackProviderRef: providerResetDefaults.providerRef,
|
fallbackProviderRef: adminDefaultProviderRef,
|
||||||
});
|
});
|
||||||
if (!providerDefaults.defaultModel) return;
|
if (!providerDefaults.defaultModel) return;
|
||||||
if (appConfig.ttsModel === 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);
|
console.warn('Failed to normalize shared-provider default model:', error);
|
||||||
});
|
});
|
||||||
queueSyncedPreferencePatch(patch);
|
queueSyncedPreferencePatch(patch);
|
||||||
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]);
|
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates multiple configuration values simultaneously
|
* Updates multiple configuration values simultaneously
|
||||||
|
|
@ -436,9 +432,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const { storagePatch, syncPatch } = applyConfigUpdate({
|
const { storagePatch, syncPatch } = applyConfigUpdate({
|
||||||
providerRef,
|
providerRef: effectiveProviderRef,
|
||||||
providerType,
|
providerType,
|
||||||
ttsModel,
|
ttsModel: effectiveTtsModel,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
}, key, value);
|
}, key, value);
|
||||||
|
|
||||||
|
|
@ -478,10 +474,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
rightMargin,
|
rightMargin,
|
||||||
providerRef,
|
providerRef: effectiveProviderRef,
|
||||||
providerType,
|
providerType,
|
||||||
ttsModel,
|
ttsModel: effectiveTtsModel,
|
||||||
ttsInstructions,
|
ttsInstructions: effectiveTtsInstructions,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,32 @@ export function scheduleUserPreferencesSync(
|
||||||
}, debounceMs);
|
}, 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(
|
export async function getDocumentProgress(
|
||||||
documentId: string,
|
documentId: string,
|
||||||
options?: { signal?: AbortSignal },
|
options?: { signal?: AbortSignal },
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ type SeedProviderInput = {
|
||||||
slug: string;
|
slug: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
providerType: TtsProviderId;
|
providerType: TtsProviderId;
|
||||||
apiKey: string;
|
apiKey?: string;
|
||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
defaultModel?: string | null;
|
defaultModel?: string | null;
|
||||||
defaultInstructions?: string | null;
|
defaultInstructions?: string | null;
|
||||||
|
|
@ -187,8 +187,8 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI
|
||||||
if (typeof rec.providerType !== 'string') {
|
if (typeof rec.providerType !== 'string') {
|
||||||
throw new Error(`Seed JSON providers[${index}].providerType must be a string`);
|
throw new Error(`Seed JSON providers[${index}].providerType must be a string`);
|
||||||
}
|
}
|
||||||
if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) {
|
if (rec.apiKey !== undefined && typeof rec.apiKey !== 'string') {
|
||||||
throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty 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') {
|
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),
|
slug: validateSlug(rec.slug),
|
||||||
displayName: rec.displayName.trim(),
|
displayName: rec.displayName.trim(),
|
||||||
providerType: validateProviderType(rec.providerType),
|
providerType: validateProviderType(rec.providerType),
|
||||||
apiKey: rec.apiKey.trim(),
|
...(rec.apiKey !== undefined ? { apiKey: rec.apiKey.trim() } : {}),
|
||||||
...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}),
|
...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}),
|
||||||
...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}),
|
...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}),
|
||||||
...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}),
|
...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}),
|
||||||
|
|
@ -246,7 +246,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi
|
||||||
if (existing.length > 0) continue;
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = encryptSecret(provider.apiKey);
|
const apiKey = provider.apiKey ?? '';
|
||||||
|
const encrypted = encryptSecret(apiKey);
|
||||||
await db.insert(adminProviders).values({
|
await db.insert(adminProviders).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
slug: provider.slug,
|
slug: provider.slug,
|
||||||
|
|
@ -255,7 +256,7 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi
|
||||||
baseUrl: provider.baseUrl ?? null,
|
baseUrl: provider.baseUrl ?? null,
|
||||||
apiKeyCiphertext: encrypted.ciphertext,
|
apiKeyCiphertext: encrypted.ciphertext,
|
||||||
apiKeyIv: encrypted.iv,
|
apiKeyIv: encrypted.iv,
|
||||||
apiKeyLast4: apiKeyLast4(provider.apiKey),
|
apiKeyLast4: apiKeyLast4(apiKey),
|
||||||
defaultModel: provider.defaultModel ?? null,
|
defaultModel: provider.defaultModel ?? null,
|
||||||
defaultInstructions: provider.defaultInstructions ?? null,
|
defaultInstructions: provider.defaultInstructions ?? null,
|
||||||
enabled: provider.enabled === false ? 0 : 1,
|
enabled: provider.enabled === false ? 0 : 1,
|
||||||
|
|
@ -278,8 +279,9 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
|
async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
|
||||||
const apiKey = process.env.API_KEY;
|
const apiKey = process.env.API_KEY?.trim() ?? '';
|
||||||
if (!apiKey || !apiKey.trim()) return;
|
const baseUrl = process.env.API_BASE?.trim() || null;
|
||||||
|
if (!apiKey && !baseUrl) return;
|
||||||
|
|
||||||
let existing: Array<unknown>;
|
let existing: Array<unknown>;
|
||||||
try {
|
try {
|
||||||
|
|
@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
|
||||||
}
|
}
|
||||||
if (existing.length > 0) return;
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
const baseUrl = process.env.API_BASE?.trim() || null;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let enc: ReturnType<typeof encryptSecret>;
|
let enc: ReturnType<typeof encryptSecret>;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { asc, desc, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { adminProviders, adminSettings } from '@/db/schema';
|
import { adminProviders, adminSettings } from '@/db/schema';
|
||||||
import { serverLogger } from '@/lib/server/logger';
|
import { serverLogger } from '@/lib/server/logger';
|
||||||
|
|
@ -116,9 +116,19 @@ async function resolveImplicitDefaultTtsProvider(): Promise<string | undefined>
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ slug: adminProviders.slug })
|
.select({ slug: adminProviders.slug })
|
||||||
.from(adminProviders)
|
.from(adminProviders)
|
||||||
.where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1)))
|
.where(eq(adminProviders.enabled, 1))
|
||||||
.limit(1);
|
.orderBy(
|
||||||
return rows[0]?.slug;
|
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) {
|
} catch (error) {
|
||||||
logDegraded(serverLogger, {
|
logDegraded(serverLogger, {
|
||||||
event: 'admin.runtime_config.default_provider_lookup.failed',
|
event: 'admin.runtime_config.default_provider_lookup.failed',
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ import { LRUCache } from 'lru-cache';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { access, readFile } from 'fs/promises';
|
import { access, readFile } from 'fs/promises';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language';
|
import {
|
||||||
|
getLanguageDisplayName,
|
||||||
|
resolveReplicateKokoroLanguageCode,
|
||||||
|
toBaseLanguageCode,
|
||||||
|
} from '@/lib/shared/language';
|
||||||
|
|
||||||
export interface ServerTTSRequest {
|
export interface ServerTTSRequest {
|
||||||
text: string;
|
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;
|
const model = request.model as string;
|
||||||
|
|
||||||
if (model === 'google/gemini-3.1-flash-tts') {
|
if (model === 'google/gemini-3.1-flash-tts') {
|
||||||
|
|
@ -517,6 +521,16 @@ async function addReplicateLanguageInput(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
request: ResolvedServerTTSRequest,
|
request: ResolvedServerTTSRequest,
|
||||||
): Promise<Record<string, unknown>> {
|
): 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;
|
if (!request.language) return input;
|
||||||
const languageInput = await resolveReplicateLanguageInput({
|
const languageInput = await resolveReplicateLanguageInput({
|
||||||
provider: 'replicate',
|
provider: 'replicate',
|
||||||
|
|
@ -627,6 +641,7 @@ async function runProviderRequest(
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: request.apiKey,
|
apiKey: request.apiKey,
|
||||||
baseURL: request.baseUrl,
|
baseURL: request.baseUrl,
|
||||||
|
defaultHeaders: request.apiKey ? undefined : { Authorization: null },
|
||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
timeout: upstreamSettings.ttsUpstreamTimeoutMs,
|
timeout: upstreamSettings.ttsUpstreamTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
|
||||||
model,
|
model,
|
||||||
apiKey = '',
|
apiKey = '',
|
||||||
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> {
|
}: 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);
|
const cached = replicateLanguageInputCache.get(model);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
@ -377,7 +377,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
|
||||||
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
|
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||||
'Content-Type': 'application/json',
|
'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',
|
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 = [
|
export const KOKORO_SUPPORTED_LANGUAGES = [
|
||||||
'en',
|
'en',
|
||||||
'es',
|
'es',
|
||||||
|
|
@ -79,6 +101,26 @@ export function inferKokoroLanguageFromVoice(voice: string | null | undefined):
|
||||||
return languages.size === 1 ? [...languages][0] : null;
|
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[] {
|
export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] {
|
||||||
if (!voice?.trim()) return [];
|
if (!voice?.trim()) return [];
|
||||||
return Array.from(new Set(
|
return Array.from(new Set(
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,5 @@
|
||||||
import type { DocumentListState } from '@/types/documents';
|
import type { DocumentListState } from '@/types/documents';
|
||||||
import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
import 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ViewType = 'single' | 'dual' | 'scroll';
|
export type ViewType = 'single' | 'dual' | 'scroll';
|
||||||
|
|
||||||
|
|
@ -78,20 +62,18 @@ export interface AppConfigValues {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build defaults lazily so we can read SSR-injected admin overrides
|
* Build the static app-config defaults. These no longer read any SSR/admin
|
||||||
* (`window.__RUNTIME_CONFIG__`). Modules that need the defaults
|
* runtime config: the user's TTS provider defaults to empty ("inherit the
|
||||||
* statically should call `getAppConfigDefaults()` at use time. The exported
|
* admin default"), which is resolved to a concrete provider where it is used
|
||||||
* `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so
|
* (ConfigContext on the client, credential resolution on the server).
|
||||||
* mutations to the runtime config (admin edits) are picked up by anything
|
|
||||||
* that reads through it.
|
|
||||||
*/
|
*/
|
||||||
export function getAppConfigDefaults(): AppConfigValues {
|
export function getAppConfigDefaults(): AppConfigValues {
|
||||||
const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai');
|
// The user's TTS provider is intentionally left empty by default. An empty
|
||||||
const defaultProviderRef = runtimeProviderRef.trim();
|
// providerRef means "inherit the instance/admin default" and is resolved to a
|
||||||
const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown';
|
// concrete provider at read time (see ConfigContext) and at generation time
|
||||||
const defaultModel = isBuiltInTtsProviderId(defaultProviderType)
|
// (server-side credential resolution). We no longer bake a placeholder
|
||||||
? defaultModelForProviderType(defaultProviderType)
|
// provider id (the old 'custom-openai') into every user's config, since that
|
||||||
: 'kokoro';
|
// value isn't actually selectable in shared-provider mode.
|
||||||
return {
|
return {
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
|
|
@ -105,9 +87,9 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
footerMargin: 0,
|
footerMargin: 0,
|
||||||
leftMargin: 0,
|
leftMargin: 0,
|
||||||
rightMargin: 0,
|
rightMargin: 0,
|
||||||
providerRef: defaultProviderRef,
|
providerRef: '',
|
||||||
providerType: defaultProviderType,
|
providerType: 'unknown',
|
||||||
ttsModel: defaultModel,
|
ttsModel: '',
|
||||||
ttsInstructions: '',
|
ttsInstructions: '',
|
||||||
savedVoices: {},
|
savedVoices: {},
|
||||||
segmentPreloadDepthPages: 1,
|
segmentPreloadDepthPages: 1,
|
||||||
|
|
@ -134,17 +116,13 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static defaults snapshot resolved at first access. For callers that need
|
* Static defaults snapshot, resolved once at first access. The values are
|
||||||
* fresh values after admin edits, prefer `getAppConfigDefaults()` directly.
|
* constant (no SSR/admin overrides are read here anymore), so the lazy Proxy
|
||||||
* Most consumers just need a stable defaults object for spreads, so this is
|
* just avoids building the object until something first reads it.
|
||||||
* resolved once per process — admin overrides take effect on next page load
|
|
||||||
* (which is the SSR-injected behavior we want anyway).
|
|
||||||
*/
|
*/
|
||||||
let cachedDefaults: AppConfigValues | null = null;
|
let cachedDefaults: AppConfigValues | null = null;
|
||||||
export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => {
|
export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => {
|
||||||
// Return a getter-backed object that resolves on first access. On the
|
// Getter-backed object that builds the defaults lazily on first access.
|
||||||
// server, this resolves to built-in defaults; on the client, to the
|
|
||||||
// SSR-injected admin values.
|
|
||||||
const handler: ProxyHandler<AppConfigValues> = {
|
const handler: ProxyHandler<AppConfigValues> = {
|
||||||
get(_target, prop) {
|
get(_target, prop) {
|
||||||
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { adminProviders } from '../../src/db/schema';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AdminProviderError,
|
AdminProviderError,
|
||||||
|
createAdminProvider,
|
||||||
|
decryptedKeyFor,
|
||||||
listAdminProviders,
|
listAdminProviders,
|
||||||
toMasked,
|
toMasked,
|
||||||
validateProviderType,
|
validateProviderType,
|
||||||
|
|
@ -53,6 +55,23 @@ describe('admin provider validation', () => {
|
||||||
expect(toMasked(record).apiKeyMask).toBe('••••abcd');
|
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 () => {
|
test('lists providers in deterministic updated/created/slug order', async () => {
|
||||||
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const inserted = [
|
const inserted = [
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
keepKokoroVoicesInOneLanguage,
|
keepKokoroVoicesInOneLanguage,
|
||||||
normalizeOptionalLanguageTag,
|
normalizeOptionalLanguageTag,
|
||||||
normalizeUnicodeToken,
|
normalizeUnicodeToken,
|
||||||
|
resolveReplicateKokoroLanguageCode,
|
||||||
resolveTtsLanguage,
|
resolveTtsLanguage,
|
||||||
segmentSentences,
|
segmentSentences,
|
||||||
segmentWords,
|
segmentWords,
|
||||||
|
|
@ -95,6 +96,13 @@ describe('multilingual language utilities', () => {
|
||||||
expect(toBaseLanguageCode('zh-CN')).toBe('zh');
|
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', () => {
|
test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
|
||||||
expect(normalizeUnicodeToken('École!')).toBe('école');
|
expect(normalizeUnicodeToken('École!')).toBe('école');
|
||||||
expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
|
expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,26 @@ describe('runtime seed JSON parsing', () => {
|
||||||
expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true);
|
expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true);
|
||||||
expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false);
|
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', () => {
|
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 () => {
|
test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => {
|
||||||
const providerSnapshot = await snapshotProvidersBySlug(testSlugs);
|
const providerSnapshot = await snapshotProvidersBySlug(testSlugs);
|
||||||
const runtimeSeed = JSON.stringify({ version: 1, providers: [] });
|
const runtimeSeed = JSON.stringify({ version: 1, providers: [] });
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildReplicateInput,
|
||||||
buildTTSCacheKey,
|
buildTTSCacheKey,
|
||||||
extractReplicateAudioUrl,
|
extractReplicateAudioUrl,
|
||||||
resolveReplicateLanguageValue,
|
resolveReplicateLanguageValue,
|
||||||
} from '../../src/lib/server/tts/generate';
|
} from '../../src/lib/server/tts/generate';
|
||||||
|
import { REPLICATE_KOKORO_82M_VERSIONED_MODEL } from '../../src/lib/shared/tts-provider-catalog';
|
||||||
|
|
||||||
describe('replicate output URL extraction', () => {
|
describe('replicate output URL extraction', () => {
|
||||||
test('returns direct URL string output', () => {
|
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', 'Japanese'])).toBe('Japanese');
|
||||||
expect(resolveReplicateLanguageValue('ja-JP', ['English', 'French'])).toBeNull();
|
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;
|
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', () => {
|
describe('config helpers', () => {
|
||||||
|
|
@ -476,9 +520,13 @@ describe('config helpers', () => {
|
||||||
ttsModel: 'kokoro',
|
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({
|
expect(buildSyncedPreferencePatch({
|
||||||
voiceSpeed: 1,
|
voiceSpeed: 1,
|
||||||
ttsModel: 'kokoro',
|
providerRef: '',
|
||||||
|
providerType: 'unknown',
|
||||||
|
ttsModel: '',
|
||||||
}, { nonDefaultOnly: true })).toEqual({});
|
}, { 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