feat(tts,config,types): migrate to providerRef/providerType model and add centralized TTS provider policy
Transition all TTS-related logic, types, and UI to use the new providerRef/providerType model in place of legacy ttsProvider fields. Introduce a centralized tts-provider-policy module to encapsulate provider/model capability checks, default value resolution, and compatibility logic. Update all API routes, contexts, hooks, components, and tests to use providerRef and providerType, ensuring consistent handling of built-in and shared TTS providers. Remove legacy defaultTtsModel config in favor of per-provider defaults and shared provider admin control. Add the showAllProviderModels runtime flag to restrict users to provider default models when desired. BREAKING CHANGE: ttsProvider fields are replaced by providerRef/providerType throughout the codebase; defaultTtsModel config is removed in favor of per-provider defaults.
This commit is contained in:
parent
dae97b2afc
commit
b4f4d43d6a
43 changed files with 1225 additions and 470 deletions
|
|
@ -54,7 +54,7 @@ On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is
|
||||||
|
|
||||||
- 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` (encrypted)
|
||||||
- defaultModel from `NEXT_PUBLIC_DEFAULT_TTS_MODEL` if set
|
- 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.
|
||||||
|
|
||||||
|
|
@ -65,10 +65,10 @@ Runtime-editable settings, one row per key:
|
||||||
| Key | What it controls |
|
| Key | What it controls |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
|
| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
|
||||||
| `defaultTtsModel` | Default model id new users start with. |
|
|
||||||
| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
|
| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
|
||||||
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
|
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
|
||||||
| `showAllDeepInfraModels` | Show the full DeepInfra catalog vs. the Kokoro-only subset. |
|
| `showAllDeepInfraModels` | Show the full DeepInfra catalog vs. the Kokoro-only subset. |
|
||||||
|
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
|
||||||
| `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. |
|
| `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. |
|
||||||
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
|
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
|
||||||
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
|
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@ Use Replicate's hosted TTS models as your provider.
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_KEY=r8_...
|
API_KEY=r8_...
|
||||||
NEXT_PUBLIC_DEFAULT_TTS_MODEL=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then update the shared provider's **Default model** in **Settings → Admin → Shared providers**.
|
||||||
|
|
||||||
**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
|
**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
|
||||||
|
|
||||||
1. Set provider to `Replicate`.
|
1. Set provider to `Replicate`.
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ After the first successful deploy and admin login, open **Settings → Admin** a
|
||||||
- `enableTtsProvidersTab=false` if you want shared-provider-only UX.
|
- `enableTtsProvidersTab=false` if you want shared-provider-only UX.
|
||||||
- `restrictUserApiKeys=true` to block user BYOK through the hosted server.
|
- `restrictUserApiKeys=true` to block user BYOK through the hosted server.
|
||||||
- `defaultTtsProvider=replicate` (or your preferred shared slug).
|
- `defaultTtsProvider=replicate` (or your preferred shared slug).
|
||||||
- `defaultTtsModel=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5`.
|
- `showAllProviderModels=false` if you want users locked to each provider's default model.
|
||||||
- `showAllDeepInfraModels=false` if you want a narrower default catalog.
|
- `showAllDeepInfraModels=false` if you want a narrower default catalog.
|
||||||
- `enableAudiobookExport=true`.
|
- `enableAudiobookExport=true`.
|
||||||
- `enableWordHighlight=false` unless your timestamp stack is configured.
|
- `enableWordHighlight=false` unless your timestamp stack is configured.
|
||||||
|
|
|
||||||
|
|
@ -403,13 +403,7 @@ Sets the default TTS provider for new users.
|
||||||
- Example values: `replicate`, `deepinfra`, `openai`, `custom-openai`, or an admin-defined shared provider slug (e.g. `kokoro-prod`)
|
- Example values: `replicate`, `deepinfra`, `openai`, `custom-openai`, or an admin-defined shared provider slug (e.g. `kokoro-prod`)
|
||||||
- Runtime key: `defaultTtsProvider`
|
- Runtime key: `defaultTtsProvider`
|
||||||
|
|
||||||
### NEXT_PUBLIC_DEFAULT_TTS_MODEL
|
`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**.
|
||||||
|
|
||||||
Sets the default TTS model for new users.
|
|
||||||
|
|
||||||
- Default: `kokoro`
|
|
||||||
- Example values: `hexgrad/Kokoro-82M`, `tts-1`
|
|
||||||
- Runtime key: `defaultTtsModel`
|
|
||||||
|
|
||||||
### NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS
|
### NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
||||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||||
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
||||||
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
||||||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
import { defaultVoiceForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
@ -99,7 +100,11 @@ function s3NotConfiguredResponse(): NextResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings {
|
function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings {
|
||||||
return supportsNativeModelSpeed(settings.ttsProvider, settings.ttsModel)
|
return resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: settings.providerRef,
|
||||||
|
providerType: settings.providerType,
|
||||||
|
model: settings.ttsModel,
|
||||||
|
}).supportsNativeModelSpeed
|
||||||
? settings
|
? settings
|
||||||
: { ...settings, nativeSpeed: 1 };
|
: { ...settings, nativeSpeed: 1 };
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +123,8 @@ function isAudiobookGenerationSettings(value: unknown): value is AudiobookGenera
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = value as Record<string, unknown>;
|
const record = value as Record<string, unknown>;
|
||||||
return typeof record.ttsProvider === 'string'
|
return typeof record.providerRef === 'string'
|
||||||
|
&& isTtsProviderType(record.providerType)
|
||||||
&& typeof record.ttsModel === 'string'
|
&& typeof record.ttsModel === 'string'
|
||||||
&& typeof record.voice === 'string'
|
&& typeof record.voice === 'string'
|
||||||
&& isFiniteNumber(record.nativeSpeed)
|
&& isFiniteNumber(record.nativeSpeed)
|
||||||
|
|
@ -364,7 +370,8 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
if (normalizedExistingSettings && hasChapters && incomingSettings) {
|
if (normalizedExistingSettings && hasChapters && incomingSettings) {
|
||||||
const mismatch =
|
const mismatch =
|
||||||
normalizedExistingSettings.ttsProvider !== incomingSettings.ttsProvider ||
|
normalizedExistingSettings.providerRef !== incomingSettings.providerRef ||
|
||||||
|
normalizedExistingSettings.providerType !== incomingSettings.providerType ||
|
||||||
normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel ||
|
normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel ||
|
||||||
normalizedExistingSettings.voice !== incomingSettings.voice ||
|
normalizedExistingSettings.voice !== incomingSettings.voice ||
|
||||||
normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
|
normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
|
||||||
|
|
@ -409,7 +416,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedProvider = request.headers.get('x-tts-provider')
|
const requestedProvider = request.headers.get('x-tts-provider')
|
||||||
|| mergedSettings?.ttsProvider
|
|| mergedSettings?.providerRef
|
||||||
|| 'openai';
|
|| 'openai';
|
||||||
providerForError = requestedProvider;
|
providerForError = requestedProvider;
|
||||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||||
|
|
@ -433,15 +440,21 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const provider = credResolved.provider;
|
const provider = credResolved.provider;
|
||||||
|
if (!isBuiltInTtsProviderId(provider)) {
|
||||||
|
return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 });
|
||||||
|
}
|
||||||
const openApiKey = credResolved.apiKey || 'none';
|
const openApiKey = credResolved.apiKey || 'none';
|
||||||
const openApiBaseUrl = credResolved.baseUrl;
|
const openApiBaseUrl = credResolved.baseUrl;
|
||||||
const model = mergedSettings?.ttsModel;
|
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
|
||||||
const voice = mergedSettings?.voice
|
const model = resolveTtsModelForProvider({
|
||||||
|| (provider === 'openai'
|
providerRef: effectiveProviderRef,
|
||||||
? 'alloy'
|
providerType: provider,
|
||||||
: provider === 'deepinfra'
|
model: mergedSettings?.ttsModel,
|
||||||
? 'af_bella'
|
sharedProviders: credResolved.adminRecord ? [credResolved.adminRecord] : [],
|
||||||
: 'af_sarah');
|
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||||
|
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||||
|
});
|
||||||
|
const voice = mergedSettings?.voice || defaultVoiceForProviderType(provider);
|
||||||
const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1;
|
const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1;
|
||||||
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
|
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
|
||||||
const instructions = resolveEffectiveTtsInstructions({
|
const instructions = resolveEffectiveTtsInstructions({
|
||||||
|
|
@ -586,7 +599,14 @@ export async function POST(request: NextRequest) {
|
||||||
if (!normalizedExistingSettings && incomingSettings) {
|
if (!normalizedExistingSettings && incomingSettings) {
|
||||||
const settingsToPersist: AudiobookGenerationSettings = {
|
const settingsToPersist: AudiobookGenerationSettings = {
|
||||||
...incomingSettings,
|
...incomingSettings,
|
||||||
...(supportsTtsInstructions(incomingSettings.ttsModel)
|
providerRef: effectiveProviderRef,
|
||||||
|
providerType: provider,
|
||||||
|
ttsModel: model,
|
||||||
|
...(resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: effectiveProviderRef,
|
||||||
|
providerType: provider,
|
||||||
|
model,
|
||||||
|
}).supportsInstructions
|
||||||
? { ttsInstructions: instructions ?? '' }
|
? { ttsInstructions: instructions ?? '' }
|
||||||
: { ttsInstructions: '' }),
|
: { ttsInstructions: '' }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
projectSegmentLocator,
|
projectSegmentLocator,
|
||||||
probeAudioDurationMsFromBuffer,
|
probeAudioDurationMsFromBuffer,
|
||||||
} from '@/lib/server/tts/segments';
|
} from '@/lib/server/tts/segments';
|
||||||
|
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||||
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||||
|
|
@ -31,6 +32,7 @@ import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem
|
||||||
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
||||||
import { alignAudioWithText } from '@/lib/server/whisper/alignment';
|
import { alignAudioWithText } from '@/lib/server/whisper/alignment';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
|
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
|
||||||
import type {
|
import type {
|
||||||
TTSSegmentInput,
|
TTSSegmentInput,
|
||||||
TTSSegmentManifestItem,
|
TTSSegmentManifestItem,
|
||||||
|
|
@ -50,14 +52,16 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d
|
||||||
function parseSettings(value: unknown): TTSSegmentSettings | null {
|
function parseSettings(value: unknown): TTSSegmentSettings | null {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const rec = value as Record<string, unknown>;
|
const rec = value as Record<string, unknown>;
|
||||||
if (typeof rec.ttsProvider !== 'string') return null;
|
if (typeof rec.providerRef !== 'string') return null;
|
||||||
|
if (!isTtsProviderType(rec.providerType)) return null;
|
||||||
if (typeof rec.ttsModel !== 'string') return null;
|
if (typeof rec.ttsModel !== 'string') return null;
|
||||||
if (typeof rec.voice !== 'string') return null;
|
if (typeof rec.voice !== 'string') return null;
|
||||||
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
|
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
|
||||||
if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null;
|
if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ttsProvider: rec.ttsProvider,
|
providerRef: rec.providerRef,
|
||||||
|
providerType: rec.providerType,
|
||||||
ttsModel: rec.ttsModel,
|
ttsModel: rec.ttsModel,
|
||||||
voice: rec.voice,
|
voice: rec.voice,
|
||||||
nativeSpeed: Number(rec.nativeSpeed),
|
nativeSpeed: Number(rec.nativeSpeed),
|
||||||
|
|
@ -169,7 +173,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (scope instanceof Response) return scope;
|
if (scope instanceof Response) return scope;
|
||||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||||
const requestCreds = await resolveTtsCredentials({
|
const requestCreds = await resolveTtsCredentials({
|
||||||
providerHeader: parsed.settings.ttsProvider,
|
providerHeader: parsed.settings.providerRef,
|
||||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
apiKeyHeader: request.headers.get('x-openai-key'),
|
||||||
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
||||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||||
|
|
@ -189,13 +193,28 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// Normalize request settings to the effective generation settings so cache
|
// Normalize request settings to the effective generation settings so cache
|
||||||
// keys and persisted metadata match what we actually synthesize.
|
// keys and persisted metadata match what we actually synthesize.
|
||||||
const effectiveInstructions = resolveEffectiveTtsInstructions({
|
const effectiveProviderRef = requestCreds.adminRecord?.slug || parsed.settings.providerRef;
|
||||||
|
const effectiveModel = resolveTtsModelForProvider({
|
||||||
|
providerRef: effectiveProviderRef,
|
||||||
|
providerType: isBuiltInTtsProviderId(requestCreds.provider) ? requestCreds.provider : 'unknown',
|
||||||
model: parsed.settings.ttsModel,
|
model: parsed.settings.ttsModel,
|
||||||
|
sharedProviders: requestCreds.adminRecord ? [requestCreds.adminRecord] : [],
|
||||||
|
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||||
|
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||||
|
});
|
||||||
|
const effectiveInstructions = resolveEffectiveTtsInstructions({
|
||||||
|
model: effectiveModel,
|
||||||
requestInstructions: parsed.settings.ttsInstructions,
|
requestInstructions: parsed.settings.ttsInstructions,
|
||||||
sharedDefaultInstructions: requestCreds.adminRecord?.defaultInstructions,
|
sharedDefaultInstructions: requestCreds.adminRecord?.defaultInstructions,
|
||||||
}) ?? '';
|
}) ?? '';
|
||||||
|
const resolvedProviderType = isBuiltInTtsProviderId(requestCreds.provider)
|
||||||
|
? requestCreds.provider
|
||||||
|
: 'unknown';
|
||||||
const effectiveSettings: TTSSegmentSettings = {
|
const effectiveSettings: TTSSegmentSettings = {
|
||||||
...parsed.settings,
|
...parsed.settings,
|
||||||
|
providerRef: effectiveProviderRef,
|
||||||
|
providerType: resolvedProviderType,
|
||||||
|
ttsModel: effectiveModel,
|
||||||
ttsInstructions: effectiveInstructions,
|
ttsInstructions: effectiveInstructions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import type {
|
||||||
TTSSegmentVariant,
|
TTSSegmentVariant,
|
||||||
TTSSegmentsManifestResponse,
|
TTSSegmentsManifestResponse,
|
||||||
} from '@/types/client';
|
} from '@/types/client';
|
||||||
|
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -44,20 +46,31 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
const rec = raw as Record<string, unknown>;
|
const rec = raw as Record<string, unknown>;
|
||||||
|
|
||||||
const ttsProvider = typeof rec.ttsProvider === 'string'
|
// Accept both current settings shape and canonicalized legacy keys from
|
||||||
? rec.ttsProvider
|
// persisted rows (e.g., SQLite stringified settings with `model/speed`).
|
||||||
: typeof rec.provider === 'string' ? rec.provider : null;
|
const providerRef = typeof rec.providerRef === 'string'
|
||||||
|
? rec.providerRef
|
||||||
|
: typeof rec.ttsProvider === 'string'
|
||||||
|
? rec.ttsProvider
|
||||||
|
: typeof rec.provider === 'string'
|
||||||
|
? rec.provider
|
||||||
|
: null;
|
||||||
|
const providerType = isTtsProviderType(rec.providerType)
|
||||||
|
? rec.providerType
|
||||||
|
: resolveEffectiveProviderType({ providerRef });
|
||||||
const ttsModel = typeof rec.ttsModel === 'string'
|
const ttsModel = typeof rec.ttsModel === 'string'
|
||||||
? rec.ttsModel
|
? rec.ttsModel
|
||||||
: typeof rec.model === 'string' ? rec.model : null;
|
: typeof rec.model === 'string'
|
||||||
|
? rec.model
|
||||||
|
: null;
|
||||||
const voice = typeof rec.voice === 'string' ? rec.voice : null;
|
const voice = typeof rec.voice === 'string' ? rec.voice : null;
|
||||||
const speedSource = rec.nativeSpeed ?? rec.speed;
|
const speedSource = rec.nativeSpeed ?? rec.speed;
|
||||||
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
|
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
|
||||||
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
|
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
|
||||||
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
|
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
|
||||||
|
|
||||||
if (!ttsProvider || !ttsModel || !voice) return null;
|
if (!providerRef || !providerType || !ttsModel || !voice) return null;
|
||||||
return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions };
|
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
|
||||||
}
|
}
|
||||||
|
|
||||||
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
|
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
|
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { defaultModelForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import { resolveVoices } from '@/lib/server/tts/voice-resolution';
|
import { resolveVoices } from '@/lib/server/tts/voice-resolution';
|
||||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
|
|
@ -38,9 +39,20 @@ export async function GET(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedModel = req.headers.get('x-tts-model')
|
if (!isBuiltInTtsProviderId(resolved.provider)) {
|
||||||
|| resolved.adminRecord?.defaultModel
|
return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 });
|
||||||
|| 'tts-1';
|
}
|
||||||
|
const effectiveProviderRef = resolved.adminRecord?.slug
|
||||||
|
?? req.headers.get('x-tts-provider')
|
||||||
|
?? runtimeConfig.defaultTtsProvider;
|
||||||
|
const requestedModel = resolveTtsModelForProvider({
|
||||||
|
providerRef: effectiveProviderRef,
|
||||||
|
providerType: resolved.provider,
|
||||||
|
model: req.headers.get('x-tts-model'),
|
||||||
|
sharedProviders: resolved.adminRecord ? [resolved.adminRecord] : [],
|
||||||
|
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||||
|
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||||
|
}) || defaultModelForProviderType(resolved.provider);
|
||||||
const voices = await resolveVoices({
|
const voices = await resolveVoices({
|
||||||
provider: resolved.provider,
|
provider: resolved.provider,
|
||||||
model: requestedModel,
|
model: requestedModel,
|
||||||
|
|
@ -50,10 +62,18 @@ export async function GET(req: NextRequest) {
|
||||||
return NextResponse.json({ voices });
|
return NextResponse.json({ voices });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in voices endpoint:', error);
|
console.error('Error in voices endpoint:', error);
|
||||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
const providerRef = req.headers.get('x-tts-provider') || 'openai';
|
||||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||||
|
const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai';
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to resolve voices', fallbackVoices: getDefaultVoices(provider, model) },
|
{
|
||||||
|
error: 'Failed to resolve voices',
|
||||||
|
fallbackVoices: resolveTtsProviderModelPolicy({
|
||||||
|
providerRef,
|
||||||
|
providerType: provider,
|
||||||
|
model,
|
||||||
|
}).defaultVoices,
|
||||||
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import { userPreferences } from '@/db/schema';
|
||||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
|
import { SYNCED_PREFERENCE_KEYS, 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 { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
|
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -17,17 +21,54 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStoredPreferences(value: unknown): SyncedPreferencesPatch {
|
interface PreferenceNormalizationContext {
|
||||||
if (!value) return {};
|
defaultProviderRef: string;
|
||||||
|
showAllProviderModels: boolean;
|
||||||
|
sharedProviders: Array<{
|
||||||
|
slug: string;
|
||||||
|
providerType: TtsProviderId;
|
||||||
|
defaultModel: string | null;
|
||||||
|
defaultInstructions: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
||||||
|
const [runtimeConfig, providers] = await Promise.all([
|
||||||
|
getResolvedRuntimeConfig(),
|
||||||
|
listAdminProviders(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
defaultProviderRef: runtimeConfig.defaultTtsProvider,
|
||||||
|
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||||
|
sharedProviders: providers
|
||||||
|
.filter((entry) => entry.enabled)
|
||||||
|
.map((entry) => ({
|
||||||
|
slug: entry.slug,
|
||||||
|
providerType: entry.providerType,
|
||||||
|
defaultModel: entry.defaultModel,
|
||||||
|
defaultInstructions: entry.defaultInstructions,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredPreferences(
|
||||||
|
value: unknown,
|
||||||
|
context: PreferenceNormalizationContext,
|
||||||
|
): { patch: SyncedPreferencesPatch; migrated: boolean } {
|
||||||
|
if (!value) return { patch: {}, migrated: false };
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {};
|
return isRecord(parsed)
|
||||||
|
? sanitizePreferencesPatch(parsed, context, { fillMissingProvider: true })
|
||||||
|
: { patch: {}, migrated: false };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return { patch: {}, migrated: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isRecord(value) ? sanitizePreferencesPatch(value) : {};
|
return isRecord(value)
|
||||||
|
? sanitizePreferencesPatch(value, context, { fillMissingProvider: true })
|
||||||
|
: { patch: {}, migrated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
||||||
|
|
@ -41,25 +82,52 @@ function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
function sanitizePreferencesPatch(
|
||||||
if (!isRecord(input)) return {};
|
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 = {};
|
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) {
|
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||||
if (!(key in input)) continue;
|
if (!(key in rec)) continue;
|
||||||
const value = input[key];
|
const value = rec[key];
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'viewType':
|
case 'viewType':
|
||||||
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
|
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
|
||||||
break;
|
break;
|
||||||
case 'voice':
|
case 'voice':
|
||||||
case 'ttsProvider':
|
|
||||||
case 'ttsModel':
|
case 'ttsModel':
|
||||||
case 'ttsInstructions':
|
case 'ttsInstructions':
|
||||||
if (typeof value === 'string') out[key] = value;
|
if (typeof value === 'string') out[key] = value;
|
||||||
break;
|
break;
|
||||||
|
case 'providerRef':
|
||||||
|
out[key] = providerDefaults.providerRef;
|
||||||
|
break;
|
||||||
|
case 'providerType':
|
||||||
|
out[key] = providerDefaults.providerType;
|
||||||
|
break;
|
||||||
case 'voiceSpeed':
|
case 'voiceSpeed':
|
||||||
case 'audioPlayerSpeed':
|
case 'audioPlayerSpeed':
|
||||||
case 'segmentPreloadDepthPages':
|
case 'segmentPreloadDepthPages':
|
||||||
|
|
@ -88,7 +156,40 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
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 {
|
||||||
|
|
@ -99,6 +200,7 @@ function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const normalizationContext = await loadPreferenceNormalizationContext();
|
||||||
const scope = await resolveUserStateScope(req);
|
const scope = await resolveUserStateScope(req);
|
||||||
if (scope instanceof Response) return scope;
|
if (scope instanceof Response) return scope;
|
||||||
|
|
||||||
|
|
@ -112,9 +214,29 @@ export async function GET(req: NextRequest) {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const storedPatch = parseStoredPreferences(row?.dataJson);
|
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
|
||||||
|
const storedPatch = stored.patch;
|
||||||
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
||||||
|
|
||||||
|
if (row && stored.migrated) {
|
||||||
|
const updatedAt = nowTimestampMs();
|
||||||
|
await db
|
||||||
|
.insert(userPreferences)
|
||||||
|
.values({
|
||||||
|
userId: scope.ownerUserId,
|
||||||
|
dataJson: serializePreferencesForDb(storedPatch),
|
||||||
|
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userPreferences.userId],
|
||||||
|
set: {
|
||||||
|
dataJson: serializePreferencesForDb(storedPatch),
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
preferences: storedPatch,
|
preferences: storedPatch,
|
||||||
clientUpdatedAtMs,
|
clientUpdatedAtMs,
|
||||||
|
|
@ -128,13 +250,18 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const normalizationContext = await loadPreferenceNormalizationContext();
|
||||||
const scope = await resolveUserStateScope(req);
|
const scope = await resolveUserStateScope(req);
|
||||||
if (scope instanceof Response) return scope;
|
if (scope instanceof Response) return scope;
|
||||||
|
|
||||||
const body = (await req.json().catch(() => null)) as
|
const body = (await req.json().catch(() => null)) as
|
||||||
| { patch?: unknown; clientUpdatedAtMs?: unknown }
|
| { patch?: unknown; clientUpdatedAtMs?: unknown }
|
||||||
| null;
|
| null;
|
||||||
const patch = sanitizePreferencesPatch(body?.patch);
|
const patch = sanitizePreferencesPatch(
|
||||||
|
body?.patch,
|
||||||
|
normalizationContext,
|
||||||
|
{ fillMissingProvider: false },
|
||||||
|
).patch;
|
||||||
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
|
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
|
|
@ -151,7 +278,7 @@ export async function PUT(req: NextRequest) {
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const existing = existingRows[0];
|
const existing = existingRows[0];
|
||||||
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
||||||
const existingPatch = parseStoredPreferences(existing?.dataJson);
|
const existingPatch = parseStoredPreferences(existing?.dataJson, normalizationContext).patch;
|
||||||
|
|
||||||
if (existing && clientUpdatedAtMs < existingUpdated) {
|
if (existing && clientUpdatedAtMs < existingUpdated) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import {
|
import {
|
||||||
getAudiobookStatus,
|
getAudiobookStatus,
|
||||||
|
|
@ -49,7 +49,7 @@ export function AudiobookExportModal({
|
||||||
onGenerateAudiobook,
|
onGenerateAudiobook,
|
||||||
onRegenerateChapter
|
onRegenerateChapter
|
||||||
}: AudiobookExportModalProps) {
|
}: AudiobookExportModalProps) {
|
||||||
const { isLoading, isDBReady, ttsProvider, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
|
const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
|
||||||
const { availableVoices } = useTTS();
|
const { availableVoices } = useTTS();
|
||||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
@ -74,7 +74,11 @@ export function AudiobookExportModal({
|
||||||
const formatSpeed = useCallback((speed: number) => {
|
const formatSpeed = useCallback((speed: number) => {
|
||||||
return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1);
|
return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1);
|
||||||
}, []);
|
}, []);
|
||||||
const nativeSpeedSupported = useMemo(() => supportsNativeModelSpeed(ttsProvider, ttsModel), [ttsProvider, ttsModel]);
|
const providerModelPolicy = useMemo(
|
||||||
|
() => resolveTtsProviderModelPolicy({ providerRef, providerType, model: ttsModel }),
|
||||||
|
[providerRef, providerType, ttsModel],
|
||||||
|
);
|
||||||
|
const nativeSpeedSupported = providerModelPolicy.supportsNativeModelSpeed;
|
||||||
const effectiveNativeSpeed = nativeSpeedSupported ? nativeSpeed : 1;
|
const effectiveNativeSpeed = nativeSpeedSupported ? nativeSpeed : 1;
|
||||||
|
|
||||||
const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0;
|
const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0;
|
||||||
|
|
@ -113,15 +117,16 @@ export function AudiobookExportModal({
|
||||||
const nextVoice = audiobookVoice || configVoice || availableVoices[0] || '';
|
const nextVoice = audiobookVoice || configVoice || availableVoices[0] || '';
|
||||||
if (!nextVoice) return null;
|
if (!nextVoice) return null;
|
||||||
return {
|
return {
|
||||||
ttsProvider,
|
providerRef,
|
||||||
|
providerType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice: nextVoice,
|
voice: nextVoice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
postSpeed,
|
postSpeed,
|
||||||
format,
|
format,
|
||||||
ttsInstructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined,
|
ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined,
|
||||||
};
|
};
|
||||||
}, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format]);
|
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]);
|
||||||
|
|
||||||
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||||
if (soft) {
|
if (soft) {
|
||||||
|
|
@ -514,7 +519,11 @@ export function AudiobookExportModal({
|
||||||
<div className="rounded-lg bg-base p-3">
|
<div className="rounded-lg bg-base p-3">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
|
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
{supportsNativeModelSpeed(savedSettings.ttsProvider, savedSettings.ttsModel)
|
{resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: savedSettings.providerRef,
|
||||||
|
providerType: savedSettings.providerType,
|
||||||
|
model: savedSettings.ttsModel,
|
||||||
|
}).supportsNativeModelSpeed
|
||||||
? `${formatSpeed(savedSettings.nativeSpeed)}x`
|
? `${formatSpeed(savedSettings.nativeSpeed)}x`
|
||||||
: 'Not supported'}
|
: 'Not supported'}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -538,7 +547,7 @@ export function AudiobookExportModal({
|
||||||
availableVoices={availableVoices}
|
availableVoices={availableVoices}
|
||||||
voice={audiobookVoice}
|
voice={audiobookVoice}
|
||||||
onChangeVoice={setAudiobookVoice}
|
onChangeVoice={setAudiobookVoice}
|
||||||
ttsProvider={ttsProvider}
|
providerType={providerType}
|
||||||
ttsModel={ttsModel}
|
ttsModel={ttsModel}
|
||||||
dropdownDirection="down"
|
dropdownDirection="down"
|
||||||
variant="field"
|
variant="field"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,17 @@ import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/a
|
||||||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||||
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
||||||
import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
import {
|
||||||
|
isBuiltInTtsProviderId,
|
||||||
|
type TtsProviderType,
|
||||||
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import {
|
||||||
|
defaultBaseUrlForProviderType,
|
||||||
|
defaultModelForProviderType,
|
||||||
|
resolveProviderDefaults,
|
||||||
|
resolveEffectiveProviderType,
|
||||||
|
resolveTtsProviderModelPolicy,
|
||||||
|
} from '@/lib/shared/tts-provider-policy';
|
||||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
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';
|
||||||
|
|
@ -105,6 +115,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
|
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
|
||||||
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
|
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
|
||||||
|
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
||||||
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
||||||
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
|
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
@ -113,11 +124,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
const { theme, setTheme, applyCustomColors } = useTheme();
|
const { theme, setTheme, applyCustomColors } = useTheme();
|
||||||
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
|
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
|
||||||
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
|
const [isCustomExpanded, setIsCustomExpanded] = useState(false);
|
||||||
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
||||||
const { refreshDocuments } = useDocuments();
|
const { refreshDocuments } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||||
const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider);
|
const [localProviderRef, setLocalProviderRef] = useState(providerRef);
|
||||||
|
const [localProviderType, setLocalProviderType] = useState<TtsProviderType>(providerType);
|
||||||
const [modelValue, setModelValue] = useState(ttsModel);
|
const [modelValue, setModelValue] = useState(ttsModel);
|
||||||
const [customModelInput, setCustomModelInput] = useState('');
|
const [customModelInput, setCustomModelInput] = useState('');
|
||||||
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
||||||
|
|
@ -154,17 +166,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
selectedSharedProvider,
|
selectedSharedProvider,
|
||||||
|
selectedProviderRef,
|
||||||
|
selectedProviderType,
|
||||||
} = useMemo(() => resolveTtsSettingsViewModel({
|
} = useMemo(() => resolveTtsSettingsViewModel({
|
||||||
provider: localTTSProvider,
|
providerRef: localProviderRef,
|
||||||
|
providerType: localProviderType,
|
||||||
apiKey: localApiKey,
|
apiKey: localApiKey,
|
||||||
modelValue,
|
modelValue,
|
||||||
customModelInput,
|
customModelInput,
|
||||||
showAllDeepInfra,
|
showAllDeepInfra,
|
||||||
|
showAllProviderModels,
|
||||||
sharedProviders,
|
sharedProviders,
|
||||||
allowBuiltInProviders: !restrictUserApiKeys,
|
allowBuiltInProviders: !restrictUserApiKeys,
|
||||||
}), [localTTSProvider, localApiKey, modelValue, customModelInput, showAllDeepInfra, sharedProviders, restrictUserApiKeys]);
|
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllDeepInfra, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
|
||||||
const isSharedSelected = Boolean(selectedSharedProvider);
|
const isSharedSelected = Boolean(selectedSharedProvider);
|
||||||
const selectedProviderOption = ttsProviders.find((p) => p.id === localTTSProvider) ?? ttsProviders[0];
|
const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0];
|
||||||
|
|
||||||
const checkFirstVist = useCallback(async () => {
|
const checkFirstVist = useCallback(async () => {
|
||||||
const appConfig = await getAppConfig();
|
const appConfig = await getAppConfig();
|
||||||
|
|
@ -187,10 +203,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalApiKey(apiKey);
|
setLocalApiKey(apiKey);
|
||||||
setLocalBaseUrl(baseUrl);
|
setLocalBaseUrl(baseUrl);
|
||||||
setLocalTTSProvider(ttsProvider);
|
setLocalProviderRef(providerRef);
|
||||||
|
setLocalProviderType(providerType);
|
||||||
setModelValue(ttsModel);
|
setModelValue(ttsModel);
|
||||||
setLocalTTSInstructions(ttsInstructions);
|
setLocalTTSInstructions(ttsInstructions);
|
||||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions]);
|
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
|
|
@ -216,12 +233,31 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
}, [modelValue, ttsModels]);
|
}, [modelValue, ttsModels]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!restrictUserApiKeys) return;
|
|
||||||
if (selectedProviderOption) return;
|
if (selectedProviderOption) return;
|
||||||
if (ttsProviders.length > 0) {
|
if (ttsProviders.length === 0) return;
|
||||||
setLocalTTSProvider(ttsProviders[0].id);
|
|
||||||
|
const fallback = ttsProviders[0];
|
||||||
|
setLocalProviderRef(fallback.id);
|
||||||
|
setLocalProviderType(fallback.providerType);
|
||||||
|
|
||||||
|
if (fallback.shared) {
|
||||||
|
const shared = sharedProviders.find((p) => p.slug === fallback.id);
|
||||||
|
if (shared?.defaultModel) {
|
||||||
|
setModelValue(shared.defaultModel);
|
||||||
|
}
|
||||||
|
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
|
||||||
|
setLocalApiKey('');
|
||||||
|
setLocalBaseUrl('');
|
||||||
|
setCustomModelInput('');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [restrictUserApiKeys, selectedProviderOption, ttsProviders]);
|
|
||||||
|
if (isBuiltInTtsProviderId(fallback.providerType)) {
|
||||||
|
setModelValue(defaultModelForProviderType(fallback.providerType));
|
||||||
|
setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType));
|
||||||
|
setCustomModelInput('');
|
||||||
|
}
|
||||||
|
}, [selectedProviderOption, ttsProviders, sharedProviders]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -364,7 +400,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setLocalApiKey(apiKey);
|
setLocalApiKey(apiKey);
|
||||||
setLocalBaseUrl(baseUrl);
|
setLocalBaseUrl(baseUrl);
|
||||||
setLocalTTSProvider(ttsProvider);
|
setLocalProviderRef(providerRef);
|
||||||
|
setLocalProviderType(providerType);
|
||||||
setModelValue(ttsModel);
|
setModelValue(ttsModel);
|
||||||
setLocalTTSInstructions(ttsInstructions);
|
setLocalTTSInstructions(ttsInstructions);
|
||||||
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
|
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
|
||||||
|
|
@ -372,7 +409,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
} else {
|
} else {
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}
|
}
|
||||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]);
|
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels]);
|
||||||
|
|
||||||
const [systemIsDark, setSystemIsDark] = useState(
|
const [systemIsDark, setSystemIsDark] = useState(
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|
@ -428,11 +465,22 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`;
|
const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`;
|
||||||
const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
|
const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
|
||||||
const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`;
|
const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`;
|
||||||
const effectiveProviderType = selectedSharedProvider?.providerType ?? localTTSProvider;
|
const effectiveProviderType = resolveEffectiveProviderType({
|
||||||
|
providerRef: selectedProviderRef,
|
||||||
|
providerType: localProviderType,
|
||||||
|
sharedProviders,
|
||||||
|
});
|
||||||
|
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: selectedProviderRef,
|
||||||
|
providerType: effectiveProviderType,
|
||||||
|
model: modelValue,
|
||||||
|
sharedProviders,
|
||||||
|
});
|
||||||
const shouldShowBaseUrl = !restrictUserApiKeys
|
const shouldShowBaseUrl = !restrictUserApiKeys
|
||||||
&& !isSharedSelected
|
&& !isSharedSelected
|
||||||
&& effectiveProviderType !== 'replicate'
|
&& providerModelPolicy.isResolvedProviderType
|
||||||
&& (effectiveProviderType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
|
&& providerModelPolicy.providerType !== 'replicate'
|
||||||
|
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
|
||||||
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
|
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
|
||||||
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
|
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
|
||||||
const selectedModelVersion = selectedModel?.id?.includes(':')
|
const selectedModelVersion = selectedModel?.id?.includes(':')
|
||||||
|
|
@ -551,26 +599,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
<Listbox
|
<Listbox
|
||||||
value={selectedProviderOption!}
|
value={selectedProviderOption!}
|
||||||
onChange={(provider) => {
|
onChange={(provider) => {
|
||||||
setLocalTTSProvider(provider.id);
|
const defaults = resolveProviderDefaults({
|
||||||
|
providerRef: provider.id,
|
||||||
|
providerType: provider.providerType,
|
||||||
|
sharedProviders,
|
||||||
|
});
|
||||||
|
setLocalProviderRef(provider.id);
|
||||||
|
setLocalProviderType(defaults.providerType);
|
||||||
|
setModelValue(defaults.defaultModel);
|
||||||
|
setLocalTTSInstructions(defaults.defaultInstructions);
|
||||||
if (provider.shared) {
|
if (provider.shared) {
|
||||||
// Shared admin provider — credentials live on the server.
|
// Shared admin provider — credentials live on the server.
|
||||||
setLocalApiKey('');
|
setLocalApiKey('');
|
||||||
setLocalBaseUrl('');
|
setLocalBaseUrl('');
|
||||||
const shared = sharedProviders.find((p) => p.slug === provider.id);
|
} else if (isBuiltInTtsProviderId(provider.providerType)) {
|
||||||
if (shared?.defaultModel) setModelValue(shared.defaultModel);
|
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
|
||||||
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
|
|
||||||
} else if (provider.id === 'openai') {
|
|
||||||
setModelValue('tts-1');
|
|
||||||
setLocalBaseUrl('https://api.openai.com/v1');
|
|
||||||
} else if (provider.id === 'custom-openai') {
|
|
||||||
setModelValue('kokoro');
|
|
||||||
setLocalBaseUrl('');
|
|
||||||
} else if (provider.id === 'replicate') {
|
|
||||||
setModelValue(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
|
|
||||||
setLocalBaseUrl('');
|
|
||||||
} else if (provider.id === 'deepinfra') {
|
|
||||||
setModelValue('hexgrad/Kokoro-82M');
|
|
||||||
setLocalBaseUrl('https://api.deepinfra.com/v1/openai');
|
|
||||||
}
|
}
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
|
|
@ -652,7 +695,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
type="password"
|
type="password"
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||||
placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
|
placeholder={!showAllDeepInfra && providerModelPolicy.providerType === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -665,6 +708,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||||
|
{!showAllProviderModels && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
This instance restricts model selection to each provider's default model.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Listbox
|
<Listbox
|
||||||
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
||||||
|
|
@ -752,7 +800,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{supportsTtsInstructions(modelValue) && (
|
{providerModelPolicy.supportsInstructions && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -769,15 +817,18 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
type="button"
|
type="button"
|
||||||
className={`${btnSecondary} px-4 py-2`}
|
className={`${btnSecondary} px-4 py-2`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const sharedDefault = sharedProviders.find(
|
const defaults = resolveProviderDefaults({
|
||||||
(p) => p.slug === runtimeConfig.defaultTtsProvider,
|
providerRef: runtimeConfig.defaultTtsProvider,
|
||||||
);
|
sharedProviders,
|
||||||
|
fallbackProviderRef: 'custom-openai',
|
||||||
|
});
|
||||||
setLocalApiKey('');
|
setLocalApiKey('');
|
||||||
setLocalBaseUrl('');
|
setLocalBaseUrl('');
|
||||||
setLocalTTSProvider(runtimeConfig.defaultTtsProvider);
|
setLocalProviderRef(defaults.providerRef);
|
||||||
setModelValue(runtimeConfig.defaultTtsModel);
|
setLocalProviderType(defaults.providerType);
|
||||||
|
setModelValue(defaults.defaultModel);
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
setLocalTTSInstructions(sharedDefault?.defaultInstructions ?? '');
|
setLocalTTSInstructions(defaults.defaultInstructions);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
|
|
@ -788,12 +839,20 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
className={`${btnPrimary} px-4 py-2`}
|
className={`${btnPrimary} px-4 py-2`}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const defaults = resolveProviderDefaults({
|
||||||
|
providerRef: selectedProviderRef,
|
||||||
|
providerType: selectedProviderType,
|
||||||
|
sharedProviders,
|
||||||
|
});
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
|
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
|
||||||
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
|
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
|
||||||
});
|
});
|
||||||
await updateConfigKey('ttsProvider', localTTSProvider);
|
await updateConfigKey('providerRef', selectedProviderRef);
|
||||||
const finalModel = selectedModelId === 'custom' ? customModelInput.trim() : modelValue;
|
await updateConfigKey('providerType', selectedProviderType);
|
||||||
|
const finalModel = showAllProviderModels
|
||||||
|
? (selectedModelId === 'custom' ? customModelInput.trim() : modelValue)
|
||||||
|
: defaults.defaultModel;
|
||||||
await updateConfigKey('ttsModel', finalModel);
|
await updateConfigKey('ttsModel', finalModel);
|
||||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
|
||||||
Listbox,
|
Listbox,
|
||||||
ListboxButton,
|
ListboxButton,
|
||||||
ListboxOption,
|
ListboxOption,
|
||||||
|
|
@ -19,13 +18,8 @@ import {
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
btnPrimary,
|
btnPrimary,
|
||||||
btnSecondary,
|
btnSecondary,
|
||||||
inputClass,
|
|
||||||
} from '@/components/admin/ui';
|
} from '@/components/admin/ui';
|
||||||
import {
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
providerSupportsCustomModel,
|
|
||||||
resolveProviderModels,
|
|
||||||
type TtsProviderId,
|
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
|
||||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||||
|
|
||||||
type RuntimeConfigSource = 'env-seed' | 'admin' | 'default';
|
type RuntimeConfigSource = 'env-seed' | 'admin' | 'default';
|
||||||
|
|
@ -47,7 +41,6 @@ export function AdminFeaturesPanel() {
|
||||||
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||||
const [dirty, setDirty] = useState<Set<string>>(new Set());
|
const [dirty, setDirty] = useState<Set<string>>(new Set());
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [customModelInput, setCustomModelInput] = useState('');
|
|
||||||
const { providers: sharedProviders } = useSharedProviders();
|
const { providers: sharedProviders } = useSharedProviders();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
|
@ -87,7 +80,6 @@ export function AdminFeaturesPanel() {
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
|
||||||
toast.success('Reset to env default');
|
toast.success('Reset to env default');
|
||||||
setCustomModelInput('');
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
@ -123,10 +115,9 @@ export function AdminFeaturesPanel() {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
setDraft({ ...data.values });
|
setDraft({ ...data.values });
|
||||||
setDirty(new Set());
|
setDirty(new Set());
|
||||||
setCustomModelInput('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Provider + model option resolution (mirrors the user-facing TTS tab) ---
|
// --- Provider option resolution ---
|
||||||
|
|
||||||
const providerOptions = useMemo<ProviderOption[]>(() => {
|
const providerOptions = useMemo<ProviderOption[]>(() => {
|
||||||
return sharedProviders.map((entry) => ({
|
return sharedProviders.map((entry) => ({
|
||||||
|
|
@ -153,71 +144,10 @@ export function AdminFeaturesPanel() {
|
||||||
shared: true,
|
shared: true,
|
||||||
} as ProviderOption
|
} as ProviderOption
|
||||||
: fallbackShared;
|
: fallbackShared;
|
||||||
const effectiveProviderType: TtsProviderId =
|
|
||||||
effectiveSelectedProvider?.providerType ?? 'custom-openai';
|
|
||||||
|
|
||||||
const showAllDeepInfra = Boolean(draft.showAllDeepInfraModels);
|
|
||||||
const modelDefinitions = useMemo(
|
|
||||||
() => resolveProviderModels(effectiveProviderType, { showAllDeepInfra }),
|
|
||||||
[effectiveProviderType, showAllDeepInfra],
|
|
||||||
);
|
|
||||||
const supportsCustomModel = providerSupportsCustomModel(effectiveProviderType);
|
|
||||||
|
|
||||||
const currentModelId =
|
|
||||||
typeof draft.defaultTtsModel === 'string' ? draft.defaultTtsModel : '';
|
|
||||||
const modelIsPreset = modelDefinitions.some((m) => m.id === currentModelId);
|
|
||||||
const selectedModelDropdownId = modelIsPreset
|
|
||||||
? currentModelId
|
|
||||||
: supportsCustomModel && currentModelId
|
|
||||||
? 'custom'
|
|
||||||
: modelDefinitions[0]?.id ?? '';
|
|
||||||
|
|
||||||
// Seed the custom input from the stored value the first time we land on a
|
|
||||||
// provider whose stored model isn't in the preset list.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!modelIsPreset && supportsCustomModel && currentModelId && !customModelInput) {
|
|
||||||
setCustomModelInput(currentModelId);
|
|
||||||
}
|
|
||||||
}, [modelIsPreset, supportsCustomModel, currentModelId, customModelInput]);
|
|
||||||
|
|
||||||
const selectedProviderOption = effectiveSelectedProvider;
|
const selectedProviderOption = effectiveSelectedProvider;
|
||||||
|
|
||||||
const selectedModelDefinition = modelDefinitions.find(
|
|
||||||
(m) => m.id === selectedModelDropdownId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleProviderChange = (opt: ProviderOption) => {
|
const handleProviderChange = (opt: ProviderOption) => {
|
||||||
updateDraft('defaultTtsProvider', opt.id);
|
updateDraft('defaultTtsProvider', opt.id);
|
||||||
|
|
||||||
// Reset the model selection when the provider changes — try to keep the
|
|
||||||
// current id if it happens to exist in the new provider's catalog,
|
|
||||||
// otherwise fall back to the new provider's first preset.
|
|
||||||
const nextModels = resolveProviderModels(opt.providerType, { showAllDeepInfra });
|
|
||||||
const keepCurrent = nextModels.some((m) => m.id === currentModelId);
|
|
||||||
if (!keepCurrent) {
|
|
||||||
const sharedDefault = opt.shared ? sharedProviders.find((p) => p.slug === opt.id)?.defaultModel : null;
|
|
||||||
const fallback =
|
|
||||||
(sharedDefault && nextModels.some((m) => m.id === sharedDefault)
|
|
||||||
? sharedDefault
|
|
||||||
: nextModels[0]?.id) ?? '';
|
|
||||||
updateDraft('defaultTtsModel', fallback);
|
|
||||||
setCustomModelInput('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelChange = (modelId: string) => {
|
|
||||||
if (modelId === 'custom') {
|
|
||||||
// Switching to custom keeps whatever's already in the input
|
|
||||||
updateDraft('defaultTtsModel', customModelInput.trim());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateDraft('defaultTtsModel', modelId);
|
|
||||||
setCustomModelInput('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomModelInput = (value: string) => {
|
|
||||||
setCustomModelInput(value);
|
|
||||||
updateDraft('defaultTtsModel', value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Renderers ---
|
// --- Renderers ---
|
||||||
|
|
@ -248,7 +178,7 @@ export function AdminFeaturesPanel() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Section
|
<Section
|
||||||
title="TTS defaults"
|
title="TTS defaults"
|
||||||
subtitle="What new users start with, and how they can override it."
|
subtitle="What new users start with."
|
||||||
>
|
>
|
||||||
{/* Provider picker */}
|
{/* Provider picker */}
|
||||||
<Card className="space-y-1.5">
|
<Card className="space-y-1.5">
|
||||||
|
|
@ -256,7 +186,7 @@ export function AdminFeaturesPanel() {
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
|
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
|
||||||
<p className="text-xs text-muted mt-0.5">
|
<p className="text-xs text-muted mt-0.5">
|
||||||
Initial selection for new users. Only admin shared providers are selectable.
|
Initial selection for new users. Model and instructions come from that shared provider configuration.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
||||||
|
|
@ -311,80 +241,6 @@ export function AdminFeaturesPanel() {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Model picker */}
|
|
||||||
<Card className="space-y-1.5">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">Default TTS model</p>
|
|
||||||
<p className="text-xs text-muted mt-0.5">
|
|
||||||
Model selected when a new user lands on the chosen provider.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">{renderSource('defaultTtsModel')}</div>
|
|
||||||
</div>
|
|
||||||
<Listbox
|
|
||||||
value={selectedModelDropdownId}
|
|
||||||
onChange={handleModelChange}
|
|
||||||
>
|
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors">
|
|
||||||
<span className="block truncate">
|
|
||||||
{selectedModelDefinition?.name ?? 'Select model'}
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
|
||||||
</span>
|
|
||||||
</ListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<ListboxOptions
|
|
||||||
anchor="bottom start"
|
|
||||||
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
|
|
||||||
>
|
|
||||||
{modelDefinitions.map((model) => (
|
|
||||||
<ListboxOption
|
|
||||||
key={model.id}
|
|
||||||
value={model.id}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
<span className="block truncate">{model.name}</span>
|
|
||||||
{model.id.includes(':') && (
|
|
||||||
<span className="block truncate text-xs text-muted">
|
|
||||||
{model.id.slice(model.id.indexOf(':'))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ListboxOption>
|
|
||||||
))}
|
|
||||||
</ListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</Listbox>
|
|
||||||
{supportsCustomModel && selectedModelDropdownId === 'custom' && (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={customModelInput}
|
|
||||||
onChange={(e) => handleCustomModelInput(e.target.value)}
|
|
||||||
placeholder="Enter custom model id"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Boolean TTS toggles */}
|
{/* Boolean TTS toggles */}
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Restrict user API keys (recommended)"
|
label="Restrict user API keys (recommended)"
|
||||||
|
|
@ -415,6 +271,13 @@ export function AdminFeaturesPanel() {
|
||||||
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
|
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
|
||||||
right={renderSource('showAllDeepInfraModels')}
|
right={renderSource('showAllDeepInfraModels')}
|
||||||
/>
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Show all provider models"
|
||||||
|
description="When off, users are restricted to each provider's default model."
|
||||||
|
checked={Boolean(draft.showAllProviderModels)}
|
||||||
|
onChange={(checked) => updateDraft('showAllProviderModels', checked)}
|
||||||
|
right={renderSource('showAllProviderModels')}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons
|
||||||
import {
|
import {
|
||||||
providerSupportsCustomModel,
|
providerSupportsCustomModel,
|
||||||
resolveProviderModels,
|
resolveProviderModels,
|
||||||
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
|
||||||
supportsTtsInstructions,
|
|
||||||
type TtsModelDefinition,
|
type TtsModelDefinition,
|
||||||
type TtsProviderId,
|
type TtsProviderId,
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import {
|
||||||
|
defaultBaseUrlForProviderType,
|
||||||
|
defaultModelForProviderType,
|
||||||
|
resolveTtsProviderModelPolicy,
|
||||||
|
} from '@/lib/shared/tts-provider-policy';
|
||||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
|
@ -61,12 +64,7 @@ interface FormState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerDefaultModel(providerType: ProviderType): string {
|
const providerDefaultModel = defaultModelForProviderType;
|
||||||
if (providerType === 'openai') return 'tts-1';
|
|
||||||
if (providerType === 'deepinfra') return 'hexgrad/Kokoro-82M';
|
|
||||||
if (providerType === 'replicate') return REPLICATE_KOKORO_82M_VERSIONED_MODEL;
|
|
||||||
return 'kokoro';
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyForm(): FormState {
|
function createEmptyForm(): FormState {
|
||||||
return {
|
return {
|
||||||
|
|
@ -215,13 +213,16 @@ export function AdminProvidersPanel() {
|
||||||
? 'custom'
|
? 'custom'
|
||||||
: modelDefinitions[0]?.id ?? '';
|
: modelDefinitions[0]?.id ?? '';
|
||||||
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
||||||
const baseUrlPlaceholder = form.providerType === 'openai'
|
const modelSupportsInstructions = (model: string) => resolveTtsProviderModelPolicy({
|
||||||
? 'https://api.openai.com/v1'
|
providerRef: form.slug,
|
||||||
: form.providerType === 'deepinfra'
|
providerType: form.providerType,
|
||||||
? 'https://api.deepinfra.com/v1/openai'
|
model,
|
||||||
: 'https://your-tts-host/v1';
|
}).supportsInstructions;
|
||||||
|
const baseUrlPlaceholder = form.providerType === 'custom-openai'
|
||||||
|
? 'https://your-tts-host/v1'
|
||||||
|
: defaultBaseUrlForProviderType(form.providerType);
|
||||||
const shouldShowBaseUrl = form.providerType === 'custom-openai';
|
const shouldShowBaseUrl = form.providerType === 'custom-openai';
|
||||||
const shouldShowInstructions = supportsTtsInstructions(form.defaultModel);
|
const shouldShowInstructions = modelSupportsInstructions(form.defaultModel);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supportsCustomModel) {
|
if (!supportsCustomModel) {
|
||||||
|
|
@ -237,10 +238,10 @@ export function AdminProvidersPanel() {
|
||||||
}, [supportsCustomModel, modelIsPreset, form.defaultModel, customModelInput]);
|
}, [supportsCustomModel, modelIsPreset, form.defaultModel, customModelInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (supportsTtsInstructions(form.defaultModel)) return;
|
if (modelSupportsInstructions(form.defaultModel)) return;
|
||||||
if (!form.defaultInstructions) return;
|
if (!form.defaultInstructions) return;
|
||||||
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
|
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
|
||||||
}, [form.defaultModel, form.defaultInstructions]);
|
}, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
|
@ -299,7 +300,7 @@ export function AdminProvidersPanel() {
|
||||||
providerType: opt.value,
|
providerType: opt.value,
|
||||||
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
|
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
|
||||||
defaultModel: nextModel,
|
defaultModel: nextModel,
|
||||||
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
|
|
@ -356,14 +357,14 @@ export function AdminProvidersPanel() {
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
defaultModel: nextModel,
|
defaultModel: nextModel,
|
||||||
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
defaultModel: modelId,
|
defaultModel: modelId,
|
||||||
defaultInstructions: supportsTtsInstructions(modelId) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
|
|
@ -426,7 +427,7 @@ export function AdminProvidersPanel() {
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
defaultModel: nextModel,
|
defaultModel: nextModel,
|
||||||
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model id"
|
placeholder="Enter custom model id"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { supportsNativeModelSpeed } from '@/lib/shared/tts-provider-catalog';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
export const SpeedControl = ({
|
export const SpeedControl = ({
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
|
|
@ -13,8 +13,12 @@ export const SpeedControl = ({
|
||||||
setSpeedAndRestart: (speed: number) => void;
|
setSpeedAndRestart: (speed: number) => void;
|
||||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { voiceSpeed, audioPlayerSpeed, ttsProvider, ttsModel } = useConfig();
|
const { voiceSpeed, audioPlayerSpeed, providerType, ttsModel } = useConfig();
|
||||||
const nativeSpeedSupported = supportsNativeModelSpeed(ttsProvider, ttsModel);
|
const nativeSpeedSupported = resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: '',
|
||||||
|
providerType,
|
||||||
|
model: ttsModel,
|
||||||
|
}).supportsNativeModelSpeed;
|
||||||
|
|
||||||
const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed);
|
const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed);
|
||||||
const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed);
|
const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { ttsModel, ttsProvider } = useConfig();
|
const { ttsModel, providerType } = useConfig();
|
||||||
const { voice } = useTTS();
|
const { voice } = useTTS();
|
||||||
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
availableVoices={availableVoices}
|
availableVoices={availableVoices}
|
||||||
voice={voice || ''}
|
voice={voice || ''}
|
||||||
onChangeVoice={onChangeVoice}
|
onChangeVoice={onChangeVoice}
|
||||||
ttsProvider={ttsProvider}
|
providerType={providerType}
|
||||||
ttsModel={ttsModel}
|
ttsModel={ttsModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,15 @@ import {
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
||||||
|
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
export function VoicesControlBase({
|
export function VoicesControlBase({
|
||||||
availableVoices,
|
availableVoices,
|
||||||
voice,
|
voice,
|
||||||
onChangeVoice,
|
onChangeVoice,
|
||||||
ttsProvider,
|
providerType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
dropdownDirection = 'up',
|
dropdownDirection = 'up',
|
||||||
variant = 'compact',
|
variant = 'compact',
|
||||||
|
|
@ -22,7 +24,7 @@ export function VoicesControlBase({
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
voice: string;
|
voice: string;
|
||||||
onChangeVoice: (voice: string) => void;
|
onChangeVoice: (voice: string) => void;
|
||||||
ttsProvider: string;
|
providerType: TtsProviderType;
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
dropdownDirection?: 'up' | 'down';
|
dropdownDirection?: 'up' | 'down';
|
||||||
variant?: 'compact' | 'field';
|
variant?: 'compact' | 'field';
|
||||||
|
|
@ -43,11 +45,16 @@ export function VoicesControlBase({
|
||||||
? 'h-4 w-4 text-muted'
|
? 'h-4 w-4 text-muted'
|
||||||
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
|
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
|
||||||
|
|
||||||
|
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: '',
|
||||||
|
providerType,
|
||||||
|
model: ttsModel,
|
||||||
|
});
|
||||||
const dropdownWidth = variant === 'field'
|
const dropdownWidth = variant === 'field'
|
||||||
? 'w-full'
|
? 'w-full'
|
||||||
: isKokoroModel(ttsModel) && getMaxVoicesForProvider(ttsProvider, ttsModel) > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
|
: providerModelPolicy.isKokoroModel && providerModelPolicy.maxVoices > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
|
||||||
const isKokoro = isKokoroModel(ttsModel);
|
const isKokoro = providerModelPolicy.isKokoroModel;
|
||||||
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
|
const maxVoices = providerModelPolicy.maxVoices;
|
||||||
|
|
||||||
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,8 @@ function formatVoiceLabel(settings: TTSSegmentSettings | null): string {
|
||||||
function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings | null): boolean {
|
function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings | null): boolean {
|
||||||
if (!a || !b) return false;
|
if (!a || !b) return false;
|
||||||
return (
|
return (
|
||||||
a.ttsProvider === b.ttsProvider
|
a.providerRef === b.providerRef
|
||||||
|
&& a.providerType === b.providerType
|
||||||
&& a.ttsModel === b.ttsModel
|
&& a.ttsModel === b.ttsModel
|
||||||
&& a.voice === b.voice
|
&& a.voice === b.voice
|
||||||
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
||||||
|
|
@ -182,7 +183,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
activeReaderType,
|
activeReaderType,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
ttsProvider,
|
providerRef,
|
||||||
|
providerType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
|
|
@ -241,12 +243,13 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
const didAutoScrollOnOpenRef = useRef(false);
|
const didAutoScrollOnOpenRef = useRef(false);
|
||||||
|
|
||||||
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
||||||
ttsProvider,
|
providerRef,
|
||||||
|
providerType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
||||||
ttsInstructions: ttsInstructions || '',
|
ttsInstructions: ttsInstructions || '',
|
||||||
}), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
||||||
|
|
||||||
const loadManifest = useCallback(async (
|
const loadManifest = useCallback(async (
|
||||||
mode: 'reset' | 'append' = 'reset',
|
mode: 'reset' | 'append' = 'reset',
|
||||||
|
|
@ -381,7 +384,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
|
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
updateConfigKey('ttsProvider', settings.ttsProvider),
|
updateConfigKey('providerRef', settings.providerRef),
|
||||||
|
updateConfigKey('providerType', settings.providerType),
|
||||||
updateConfigKey('ttsModel', settings.ttsModel),
|
updateConfigKey('ttsModel', settings.ttsModel),
|
||||||
updateConfigKey('voice', settings.voice),
|
updateConfigKey('voice', settings.voice),
|
||||||
updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
|
updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
|
||||||
|
|
@ -768,7 +772,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
||||||
onClick={() => void handleSelectVariant(variant.settings)}
|
onClick={() => void handleSelectVariant(variant.settings)}
|
||||||
title={
|
title={
|
||||||
variant.settings
|
variant.settings
|
||||||
? `${variant.settings.ttsProvider} · ${variant.settings.ttsModel} · ${variant.settings.voice}${variant.settings.nativeSpeed && variant.settings.nativeSpeed !== 1 ? ` · ${variant.settings.nativeSpeed}×` : ''}`
|
? `${variant.settings.providerRef} · ${variant.settings.ttsModel} · ${variant.settings.voice}${variant.settings.nativeSpeed && variant.settings.nativeSpeed !== 1 ? ` · ${variant.settings.nativeSpeed}×` : ''}`
|
||||||
: 'Unknown variant'
|
: 'Unknown variant'
|
||||||
}
|
}
|
||||||
className={[
|
className={[
|
||||||
|
|
@ -858,7 +862,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
<Row label="settings">
|
<Row label="settings">
|
||||||
<span className="font-mono text-[10px] text-foreground">
|
<span className="font-mono text-[10px] text-foreground">
|
||||||
{v.settings
|
{v.settings
|
||||||
? `${v.settings.ttsProvider} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
|
? `${v.settings.providerRef} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
|
||||||
: 'unknown'}
|
: 'unknown'}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
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 { resolveEffectiveProviderType, 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';
|
||||||
|
|
@ -11,6 +13,7 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } 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 toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
export type { ViewType } from '@/types/config';
|
export type { ViewType } from '@/types/config';
|
||||||
|
|
||||||
|
|
@ -34,7 +37,8 @@ interface ConfigContextType {
|
||||||
footerMargin: number;
|
footerMargin: number;
|
||||||
leftMargin: number;
|
leftMargin: number;
|
||||||
rightMargin: number;
|
rightMargin: number;
|
||||||
ttsProvider: string;
|
providerRef: string;
|
||||||
|
providerType: AppConfigValues['providerType'];
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
ttsInstructions: string;
|
ttsInstructions: string;
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
|
|
@ -61,12 +65,22 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const [isDBReady, setIsDBReady] = useState(false);
|
const [isDBReady, setIsDBReady] = useState(false);
|
||||||
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
|
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
|
||||||
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
|
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
|
||||||
|
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
|
||||||
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 { authEnabled } = useAuthConfig();
|
const { authEnabled } = useAuthConfig();
|
||||||
|
const { providers: sharedProviders } = 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(() => {
|
||||||
|
return resolveProviderDefaults({
|
||||||
|
providerRef: APP_CONFIG_DEFAULTS.providerRef,
|
||||||
|
providerType: APP_CONFIG_DEFAULTS.providerType,
|
||||||
|
sharedProviders,
|
||||||
|
fallbackProviderRef: 'custom-openai',
|
||||||
|
});
|
||||||
|
}, [sharedProviders]);
|
||||||
|
|
||||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||||
if (!authEnabled || sessionKey === 'no-session') return;
|
if (!authEnabled || sessionKey === 'no-session') return;
|
||||||
|
|
@ -211,14 +225,17 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
|
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
|
||||||
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
|
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
|
||||||
}
|
}
|
||||||
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
|
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
|
||||||
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
|
resetPatch.providerRef = providerResetDefaults.providerRef;
|
||||||
}
|
}
|
||||||
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
|
if (appConfig.providerType !== providerResetDefaults.providerType) {
|
||||||
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
|
resetPatch.providerType = providerResetDefaults.providerType;
|
||||||
}
|
}
|
||||||
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
|
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
|
||||||
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
|
resetPatch.ttsModel = providerResetDefaults.defaultModel;
|
||||||
|
}
|
||||||
|
if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
|
||||||
|
resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
|
||||||
}
|
}
|
||||||
// Keep voice selection state intact so player/Audiobook voice pickers still
|
// 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
|
// work when the TTS providers tab is hidden. This reset is only for provider
|
||||||
|
|
@ -230,13 +247,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
console.warn('Failed to clear hidden TTS provider settings:', error);
|
console.warn('Failed to clear hidden TTS provider settings:', error);
|
||||||
});
|
});
|
||||||
queueSyncedPreferencePatch(resetPatch);
|
queueSyncedPreferencePatch(resetPatch);
|
||||||
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch]);
|
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
|
if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
|
||||||
|
|
||||||
const resetPatch: Partial<AppConfigRow> = {};
|
const resetPatch: Partial<AppConfigRow> = {};
|
||||||
const builtInProviderIds = new Set(['custom-openai', 'replicate', 'deepinfra', 'openai']);
|
|
||||||
|
|
||||||
if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
|
if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
|
||||||
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
|
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
|
||||||
|
|
@ -244,15 +260,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
|
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
|
||||||
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
|
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
|
||||||
}
|
}
|
||||||
if (builtInProviderIds.has(appConfig.ttsProvider)) {
|
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
|
||||||
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
|
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
|
||||||
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
|
resetPatch.providerRef = providerResetDefaults.providerRef;
|
||||||
}
|
}
|
||||||
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
|
if (appConfig.providerType !== providerResetDefaults.providerType) {
|
||||||
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
|
resetPatch.providerType = providerResetDefaults.providerType;
|
||||||
}
|
}
|
||||||
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
|
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
|
||||||
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
|
resetPatch.ttsModel = providerResetDefaults.defaultModel;
|
||||||
|
}
|
||||||
|
if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
|
||||||
|
resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,7 +281,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
console.warn('Failed to enforce restricted user API key mode:', error);
|
console.warn('Failed to enforce restricted user API key mode:', error);
|
||||||
});
|
});
|
||||||
queueSyncedPreferencePatch(resetPatch);
|
queueSyncedPreferencePatch(resetPatch);
|
||||||
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch]);
|
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAllProviderModels || !isDBReady || !appConfig) return;
|
||||||
|
const providerDefaults = resolveProviderDefaults({
|
||||||
|
providerRef: appConfig.providerRef,
|
||||||
|
providerType: appConfig.providerType,
|
||||||
|
sharedProviders,
|
||||||
|
fallbackProviderRef: providerResetDefaults.providerRef,
|
||||||
|
});
|
||||||
|
if (!providerDefaults.defaultModel) return;
|
||||||
|
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
|
||||||
|
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
|
||||||
|
updateAppConfig(patch).catch((error) => {
|
||||||
|
console.warn('Failed to enforce provider default model restriction:', error);
|
||||||
|
});
|
||||||
|
queueSyncedPreferencePatch(patch);
|
||||||
|
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
|
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
|
||||||
|
|
@ -310,7 +346,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
rightMargin,
|
rightMargin,
|
||||||
ttsProvider,
|
providerRef,
|
||||||
|
providerType: _persistedProviderType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
|
|
@ -323,6 +360,46 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
|
const providerType = useMemo(
|
||||||
|
() => resolveEffectiveProviderType({
|
||||||
|
providerRef,
|
||||||
|
providerType: _persistedProviderType,
|
||||||
|
sharedProviders,
|
||||||
|
}),
|
||||||
|
[providerRef, _persistedProviderType, sharedProviders],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDBReady || !appConfig) return;
|
||||||
|
if (appConfig.providerType === providerType) return;
|
||||||
|
const patch: Partial<AppConfigRow> = { providerType };
|
||||||
|
updateAppConfig(patch).catch((error) => {
|
||||||
|
console.warn('Failed to persist resolved providerType:', error);
|
||||||
|
});
|
||||||
|
queueSyncedPreferencePatch(patch);
|
||||||
|
}, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]);
|
||||||
|
void _persistedProviderType;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDBReady || !appConfig) return;
|
||||||
|
const providerDefaults = resolveProviderDefaults({
|
||||||
|
providerRef: appConfig.providerRef,
|
||||||
|
providerType: appConfig.providerType,
|
||||||
|
sharedProviders,
|
||||||
|
fallbackProviderRef: providerResetDefaults.providerRef,
|
||||||
|
});
|
||||||
|
if (!providerDefaults.defaultModel) return;
|
||||||
|
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
|
||||||
|
// Heal stale fallback model values that were written while the provider UI
|
||||||
|
// was disabled and shared provider context was unavailable.
|
||||||
|
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) return;
|
||||||
|
|
||||||
|
const patch: Partial<AppConfigRow> = { ttsModel: providerDefaults.defaultModel };
|
||||||
|
updateAppConfig(patch).catch((error) => {
|
||||||
|
console.warn('Failed to normalize shared-provider default model:', error);
|
||||||
|
});
|
||||||
|
queueSyncedPreferencePatch(patch);
|
||||||
|
}, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates multiple configuration values simultaneously
|
* Updates multiple configuration values simultaneously
|
||||||
|
|
@ -360,7 +437,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const { storagePatch, syncPatch } = applyConfigUpdate({
|
const { storagePatch, syncPatch } = applyConfigUpdate({
|
||||||
ttsProvider,
|
providerRef,
|
||||||
|
providerType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
}, key, value);
|
}, key, value);
|
||||||
|
|
@ -368,7 +446,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
await updateAppConfig(storagePatch);
|
await updateAppConfig(storagePatch);
|
||||||
if (
|
if (
|
||||||
key === 'voice' ||
|
key === 'voice' ||
|
||||||
key === 'ttsProvider' ||
|
key === 'providerRef' ||
|
||||||
|
key === 'providerType' ||
|
||||||
key === 'ttsModel' ||
|
key === 'ttsModel' ||
|
||||||
key === 'savedVoices' ||
|
key === 'savedVoices' ||
|
||||||
syncedPreferenceKeys.has(String(key))
|
syncedPreferenceKeys.has(String(key))
|
||||||
|
|
@ -401,7 +480,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
rightMargin,
|
rightMargin,
|
||||||
ttsProvider,
|
providerRef,
|
||||||
|
providerType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
ttsProvider,
|
providerRef,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
|
|
@ -878,7 +878,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
adapter: audiobookAdapter,
|
adapter: audiobookAdapter,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
defaultProvider: ttsProvider,
|
defaultProvider: providerRef,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
onChapterComplete,
|
onChapterComplete,
|
||||||
|
|
@ -890,7 +890,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regenerates a specific chapter of the audiobook
|
* Regenerates a specific chapter of the audiobook
|
||||||
|
|
@ -911,7 +911,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
signal,
|
signal,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
defaultProvider: ttsProvider,
|
defaultProvider: providerRef,
|
||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -921,7 +921,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error regenerating chapter:', error);
|
console.error('Error regenerating chapter:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||||
|
|
||||||
const setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
bookRef.current = rendition.book;
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
rightMargin,
|
rightMargin,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
ttsProvider,
|
providerRef,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
segmentPreloadDepthPages,
|
segmentPreloadDepthPages,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
|
|
@ -442,7 +442,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
adapter: audiobookAdapter,
|
adapter: audiobookAdapter,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
defaultProvider: ttsProvider,
|
defaultProvider: providerRef,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
onChapterComplete,
|
onChapterComplete,
|
||||||
|
|
@ -454,7 +454,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||||
|
|
@ -475,7 +475,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
signal,
|
signal,
|
||||||
apiKey,
|
apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
defaultProvider: ttsProvider,
|
defaultProvider: providerRef,
|
||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -485,7 +485,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error regenerating page:', error);
|
console.error('Error regenerating page:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook to initialize TTS as non-EPUB mode
|
* Effect hook to initialize TTS as non-EPUB mode
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||||
*/
|
*/
|
||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
defaultTtsProvider: string;
|
defaultTtsProvider: string;
|
||||||
defaultTtsModel: string;
|
|
||||||
restrictUserApiKeys: boolean;
|
restrictUserApiKeys: boolean;
|
||||||
enableTtsProvidersTab: boolean;
|
enableTtsProvidersTab: boolean;
|
||||||
enableWordHighlight: boolean;
|
enableWordHighlight: boolean;
|
||||||
|
|
@ -21,11 +20,11 @@ export interface RuntimeConfig {
|
||||||
enableDocxConversion: boolean;
|
enableDocxConversion: boolean;
|
||||||
enableDestructiveDeleteActions: boolean;
|
enableDestructiveDeleteActions: boolean;
|
||||||
showAllDeepInfraModels: boolean;
|
showAllDeepInfraModels: boolean;
|
||||||
|
showAllProviderModels: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUNTIME_DEFAULTS: RuntimeConfig = {
|
const RUNTIME_DEFAULTS: RuntimeConfig = {
|
||||||
defaultTtsProvider: 'custom-openai',
|
defaultTtsProvider: 'custom-openai',
|
||||||
defaultTtsModel: 'kokoro',
|
|
||||||
restrictUserApiKeys: true,
|
restrictUserApiKeys: true,
|
||||||
enableTtsProvidersTab: true,
|
enableTtsProvidersTab: true,
|
||||||
enableWordHighlight: true,
|
enableWordHighlight: true,
|
||||||
|
|
@ -33,6 +32,7 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
|
||||||
enableDocxConversion: true,
|
enableDocxConversion: true,
|
||||||
enableDestructiveDeleteActions: true,
|
enableDestructiveDeleteActions: true,
|
||||||
showAllDeepInfraModels: true,
|
showAllDeepInfraModels: true,
|
||||||
|
showAllProviderModels: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,7 @@ import {
|
||||||
type CompletedEpubBoundarySegment,
|
type CompletedEpubBoundarySegment,
|
||||||
} from '@/lib/client/epub/tts-epub-handoff';
|
} from '@/lib/client/epub/tts-epub-handoff';
|
||||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import type {
|
import type {
|
||||||
EpubRenderedLocationWalker,
|
EpubRenderedLocationWalker,
|
||||||
|
|
@ -355,7 +354,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
audioPlayerSpeed,
|
audioPlayerSpeed,
|
||||||
voice: configVoice,
|
voice: configVoice,
|
||||||
ttsProvider: configTTSProvider,
|
providerRef: configProviderRef,
|
||||||
|
providerType: configProviderType,
|
||||||
ttsModel: configTTSModel,
|
ttsModel: configTTSModel,
|
||||||
ttsInstructions: configTTSInstructions,
|
ttsInstructions: configTTSInstructions,
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
|
|
@ -372,7 +372,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
// Audio and voice management hooks
|
// Audio and voice management hooks
|
||||||
const audioContext = useAudioContext();
|
const audioContext = useAudioContext();
|
||||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
const { availableVoices, fetchVoices } = useVoiceManagement(
|
||||||
|
openApiKey,
|
||||||
|
openApiBaseUrl,
|
||||||
|
configProviderRef,
|
||||||
|
configProviderType,
|
||||||
|
configTTSModel,
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
authEnabled,
|
authEnabled,
|
||||||
onTTSStart,
|
onTTSStart,
|
||||||
|
|
@ -536,9 +542,25 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const [voice, setVoice] = useState(configVoice);
|
const [voice, setVoice] = useState(configVoice);
|
||||||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||||||
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
||||||
|
const providerModelPolicy = useMemo(
|
||||||
|
() => resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: configProviderRef,
|
||||||
|
providerType: configProviderType,
|
||||||
|
model: ttsModel,
|
||||||
|
}),
|
||||||
|
[configProviderRef, configProviderType, ttsModel],
|
||||||
|
);
|
||||||
|
const configModelPolicy = useMemo(
|
||||||
|
() => resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: configProviderRef,
|
||||||
|
providerType: configProviderType,
|
||||||
|
model: configTTSModel,
|
||||||
|
}),
|
||||||
|
[configProviderRef, configProviderType, configTTSModel],
|
||||||
|
);
|
||||||
const effectiveNativeSpeed = useMemo(
|
const effectiveNativeSpeed = useMemo(
|
||||||
() => (supportsNativeModelSpeed(configTTSProvider, ttsModel) ? speed : 1),
|
() => (providerModelPolicy.supportsNativeModelSpeed ? speed : 1),
|
||||||
[configTTSProvider, ttsModel, speed],
|
[providerModelPolicy.supportsNativeModelSpeed, speed],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track pending preload requests
|
// Track pending preload requests
|
||||||
|
|
@ -1328,11 +1350,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const signature = [
|
const signature = [
|
||||||
documentId,
|
documentId,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
supportsTtsInstructions(ttsModel) ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|
||||||
|
|
@ -1347,7 +1369,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
bumpEpubPreloadGeneration();
|
bumpEpubPreloadGeneration();
|
||||||
}, [
|
}, [
|
||||||
documentId,
|
documentId,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
|
|
@ -1364,8 +1386,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (availableVoices.length > 0) {
|
if (availableVoices.length > 0) {
|
||||||
// Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider
|
// Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider
|
||||||
const isKokoro = isKokoroModel(configTTSModel);
|
const isKokoro = configModelPolicy.isKokoroModel;
|
||||||
const fallbackVoice = configVoice || availableVoices[0];
|
const fallbackVoice = configVoice || availableVoices[0];
|
||||||
|
const providerUnresolved = !configModelPolicy.isResolvedProviderType;
|
||||||
|
|
||||||
|
if (isKokoro && providerUnresolved && voice.includes('+')) {
|
||||||
|
const firstVoice = voice.split('+')[0]?.replace(/\([^)]*\)/g, '').trim();
|
||||||
|
if (firstVoice) {
|
||||||
|
setVoice(firstVoice);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isKokoro) {
|
if (isKokoro) {
|
||||||
// If Kokoro and we have any voice string (including plus/weights), don't override it.
|
// If Kokoro and we have any voice string (including plus/weights), don't override it.
|
||||||
|
|
@ -1389,7 +1420,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Don't save to config - just use it temporarily until user explicitly selects one
|
// Don't save to config - just use it temporarily until user explicitly selects one
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [availableVoices, voice, configVoice, configTTSModel]);
|
}, [availableVoices, voice, configVoice, configModelPolicy]);
|
||||||
|
|
||||||
const getSegmentPlaybackSource = useCallback(async (
|
const getSegmentPlaybackSource = useCallback(async (
|
||||||
sentence: string,
|
sentence: string,
|
||||||
|
|
@ -1412,7 +1443,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
sentence,
|
sentence,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
segmentKey,
|
segmentKey,
|
||||||
);
|
);
|
||||||
|
|
@ -1445,7 +1476,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const reqHeaders: TTSRequestHeaders = {
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': openApiKey || '',
|
'x-openai-key': openApiKey || '',
|
||||||
'x-tts-provider': configTTSProvider,
|
'x-tts-provider': configProviderRef,
|
||||||
};
|
};
|
||||||
if (openApiBaseUrl) {
|
if (openApiBaseUrl) {
|
||||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||||
|
|
@ -1473,14 +1504,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const ensured = await withRetry(
|
const ensured = await withRetry(
|
||||||
async () => ensureTtsSegments({
|
async () => ensureTtsSegments({
|
||||||
documentId,
|
documentId,
|
||||||
settings: {
|
settings: {
|
||||||
ttsProvider: configTTSProvider,
|
providerRef: configProviderRef,
|
||||||
|
providerType: configProviderType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||||
},
|
},
|
||||||
segments: persistSegments,
|
segments: persistSegments,
|
||||||
}, reqHeaders, controller.signal),
|
}, reqHeaders, controller.signal),
|
||||||
|
|
@ -1559,7 +1591,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
openApiKey,
|
openApiKey,
|
||||||
openApiBaseUrl,
|
openApiBaseUrl,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
isEPUB,
|
isEPUB,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
|
|
@ -2027,7 +2059,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
sentence,
|
sentence,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
playbackSegment?.key,
|
playbackSegment?.key,
|
||||||
);
|
);
|
||||||
|
|
@ -2061,7 +2093,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
playSentenceWithHowl,
|
playSentenceWithHowl,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
isEPUB,
|
isEPUB,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
|
|
@ -2133,11 +2165,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
if (isEPUB) {
|
if (isEPUB) {
|
||||||
const generationAtStart = epubPreloadGenerationRef.current;
|
const generationAtStart = epubPreloadGenerationRef.current;
|
||||||
const settingsHash = [
|
const settingsHash = [
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
supportsTtsInstructions(ttsModel) ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
].join('|');
|
].join('|');
|
||||||
const walkStartKey = `${currDocPage}|${settingsHash}|${ttsSegmentMaxBlockLength}|${maxDepth}`;
|
const walkStartKey = `${currDocPage}|${settingsHash}|${ttsSegmentMaxBlockLength}|${maxDepth}`;
|
||||||
|
|
||||||
|
|
@ -2157,7 +2189,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
nextSentence,
|
nextSentence,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
nextSegment?.key,
|
nextSegment?.key,
|
||||||
);
|
);
|
||||||
|
|
@ -2204,7 +2236,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const reqHeaders: TTSRequestHeaders = {
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': openApiKey || '',
|
'x-openai-key': openApiKey || '',
|
||||||
'x-tts-provider': configTTSProvider,
|
'x-tts-provider': configProviderRef,
|
||||||
};
|
};
|
||||||
if (openApiBaseUrl) {
|
if (openApiBaseUrl) {
|
||||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||||
|
|
@ -2283,7 +2315,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
segment.text,
|
segment.text,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
segment.key,
|
segment.key,
|
||||||
);
|
);
|
||||||
|
|
@ -2321,11 +2353,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
async () => ensureTtsSegments({
|
async () => ensureTtsSegments({
|
||||||
documentId,
|
documentId,
|
||||||
settings: {
|
settings: {
|
||||||
ttsProvider: configTTSProvider,
|
providerRef: configProviderRef,
|
||||||
|
providerType: configProviderType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||||
},
|
},
|
||||||
segments: persistPayload,
|
segments: persistPayload,
|
||||||
}, reqHeaders, controller.signal),
|
}, reqHeaders, controller.signal),
|
||||||
|
|
@ -2417,7 +2450,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
sentence,
|
sentence,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
plannedSegment?.key,
|
plannedSegment?.key,
|
||||||
);
|
);
|
||||||
|
|
@ -2446,7 +2479,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
sentence,
|
sentence,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
segment.key,
|
segment.key,
|
||||||
);
|
);
|
||||||
|
|
@ -2471,7 +2504,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const reqHeaders: TTSRequestHeaders = {
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': openApiKey || '',
|
'x-openai-key': openApiKey || '',
|
||||||
'x-tts-provider': configTTSProvider,
|
'x-tts-provider': configProviderRef,
|
||||||
};
|
};
|
||||||
if (openApiBaseUrl) {
|
if (openApiBaseUrl) {
|
||||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||||
|
|
@ -2504,11 +2537,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
async () => ensureTtsSegments({
|
async () => ensureTtsSegments({
|
||||||
documentId,
|
documentId,
|
||||||
settings: {
|
settings: {
|
||||||
ttsProvider: configTTSProvider,
|
providerRef: configProviderRef,
|
||||||
|
providerType: configProviderType,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||||
},
|
},
|
||||||
segments: persistPayload,
|
segments: persistPayload,
|
||||||
}, reqHeaders, controller.signal),
|
}, reqHeaders, controller.signal),
|
||||||
|
|
@ -2587,7 +2621,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
playbackSegments,
|
playbackSegments,
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
configTTSProvider,
|
configProviderRef,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
openApiKey,
|
openApiKey,
|
||||||
openApiBaseUrl,
|
openApiBaseUrl,
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,23 @@
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { getVoices } from '@/lib/client/api/audiobooks';
|
import { getVoices } from '@/lib/client/api/audiobooks';
|
||||||
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing TTS voices
|
* Custom hook for managing TTS voices
|
||||||
* @param apiKey OpenAI API key
|
* @param apiKey OpenAI API key
|
||||||
* @param baseUrl OpenAI API base URL
|
* @param baseUrl OpenAI API base URL
|
||||||
* @param ttsProvider TTS provider (openai, custom-openai, deepinfra)
|
* @param providerRef TTS provider routing reference (built-in id or shared slug)
|
||||||
|
* @param providerType Resolved provider type for capability/default logic
|
||||||
* @param ttsModel TTS model name
|
* @param ttsModel TTS model name
|
||||||
* @returns Object containing available voices and fetch function
|
* @returns Object containing available voices and fetch function
|
||||||
*/
|
*/
|
||||||
export function useVoiceManagement(
|
export function useVoiceManagement(
|
||||||
apiKey: string | undefined,
|
apiKey: string | undefined,
|
||||||
baseUrl: string | undefined,
|
baseUrl: string | undefined,
|
||||||
ttsProvider: string | undefined,
|
providerRef: string | undefined,
|
||||||
|
providerType: TtsProviderType | undefined,
|
||||||
ttsModel: string | undefined
|
ttsModel: string | undefined
|
||||||
) {
|
) {
|
||||||
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
||||||
|
|
@ -28,20 +31,32 @@ export function useVoiceManagement(
|
||||||
const data = await getVoices({
|
const data = await getVoices({
|
||||||
'x-openai-key': apiKey || '',
|
'x-openai-key': apiKey || '',
|
||||||
'x-openai-base-url': baseUrl || '',
|
'x-openai-base-url': baseUrl || '',
|
||||||
'x-tts-provider': ttsProvider || 'openai',
|
'x-tts-provider': providerRef || 'openai',
|
||||||
'x-tts-model': ttsModel || 'tts-1',
|
'x-tts-model': ttsModel || 'tts-1',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ignore stale responses from older provider/model fetches.
|
// Ignore stale responses from older provider/model fetches.
|
||||||
if (fetchSeq !== fetchSeqRef.current) return;
|
if (fetchSeq !== fetchSeqRef.current) return;
|
||||||
setAvailableVoices(data.voices || getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
if (data.voices && data.voices.length > 0) {
|
||||||
|
setAvailableVoices(data.voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvailableVoices(resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: providerRef || '',
|
||||||
|
providerType,
|
||||||
|
model: ttsModel || 'tts-1',
|
||||||
|
}).defaultVoices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching voices:', error);
|
console.error('Error fetching voices:', error);
|
||||||
if (fetchSeq !== fetchSeqRef.current) return;
|
if (fetchSeq !== fetchSeqRef.current) return;
|
||||||
setAvailableVoices(getDefaultVoices(ttsProvider || 'openai', ttsModel || 'tts-1'));
|
setAvailableVoices(resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: providerRef || '',
|
||||||
|
providerType,
|
||||||
|
model: ttsModel || 'tts-1',
|
||||||
|
}).defaultVoices);
|
||||||
}
|
}
|
||||||
}, [apiKey, baseUrl, ttsProvider, ttsModel]);
|
}, [apiKey, baseUrl, providerRef, providerType, ttsModel]);
|
||||||
|
|
||||||
return { availableVoices, fetchVoices };
|
return { availableVoices, fetchVoices };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ interface RegenerateAudiobookChapterOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedAudiobookRequestSettings {
|
interface ResolvedAudiobookRequestSettings {
|
||||||
effectiveProvider: string;
|
effectiveProviderRef: string;
|
||||||
effectiveFormat: TTSAudiobookFormat;
|
effectiveFormat: TTSAudiobookFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +64,7 @@ function resolveAudiobookRequestSettings(
|
||||||
format: TTSAudiobookFormat,
|
format: TTSAudiobookFormat,
|
||||||
): ResolvedAudiobookRequestSettings {
|
): ResolvedAudiobookRequestSettings {
|
||||||
return {
|
return {
|
||||||
effectiveProvider: settings?.ttsProvider ?? defaultProvider,
|
effectiveProviderRef: settings?.providerRef ?? defaultProvider,
|
||||||
effectiveFormat: settings?.format ?? format,
|
effectiveFormat: settings?.format ?? format,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -115,8 +115,8 @@ export async function runAudiobookGeneration({
|
||||||
throw new Error(adapter.noContentMessage);
|
throw new Error(adapter.noContentMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
|
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
||||||
let processedLength = 0;
|
let processedLength = 0;
|
||||||
let bookId = providedBookId;
|
let bookId = providedBookId;
|
||||||
|
|
||||||
|
|
@ -233,8 +233,8 @@ export async function regenerateAudiobookChapter({
|
||||||
throw new Error(adapter.noContentMessage);
|
throw new Error(adapter.noContentMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
|
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
||||||
|
|
||||||
return withRetry(
|
return withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { AppConfigRow, AppConfigValues, SavedVoices } from '@/types/config';
|
import type { AppConfigRow, AppConfigValues, SavedVoices } from '@/types/config';
|
||||||
|
|
||||||
export function getVoicePreferenceKey(provider: string, model: string): string {
|
export function getVoicePreferenceKey(providerRef: string, model: string): string {
|
||||||
return `${provider}:${model}`;
|
return `${providerRef}:${model}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
||||||
currentConfig: Pick<AppConfigValues, 'ttsProvider' | 'ttsModel' | 'savedVoices'>,
|
currentConfig: Pick<AppConfigValues, 'providerRef' | 'providerType' | 'ttsModel' | 'savedVoices'>,
|
||||||
key: K,
|
key: K,
|
||||||
value: AppConfigValues[K],
|
value: AppConfigValues[K],
|
||||||
): {
|
): {
|
||||||
|
|
@ -13,7 +13,7 @@ export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
||||||
syncPatch: Partial<AppConfigValues>;
|
syncPatch: Partial<AppConfigValues>;
|
||||||
} {
|
} {
|
||||||
if (key === 'voice') {
|
if (key === 'voice') {
|
||||||
const voiceKey = getVoicePreferenceKey(currentConfig.ttsProvider, currentConfig.ttsModel);
|
const voiceKey = getVoicePreferenceKey(currentConfig.providerRef, currentConfig.ttsModel);
|
||||||
const updatedSavedVoices = { ...currentConfig.savedVoices, [voiceKey]: value as string };
|
const updatedSavedVoices = { ...currentConfig.savedVoices, [voiceKey]: value as string };
|
||||||
return {
|
return {
|
||||||
storagePatch: {
|
storagePatch: {
|
||||||
|
|
@ -27,10 +27,10 @@ export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ttsProvider' || key === 'ttsModel') {
|
if (key === 'providerRef' || key === 'ttsModel' || key === 'providerType') {
|
||||||
const newProvider = key === 'ttsProvider' ? (value as string) : currentConfig.ttsProvider;
|
const newProviderRef = key === 'providerRef' ? (value as string) : currentConfig.providerRef;
|
||||||
const newModel = key === 'ttsModel' ? (value as string) : currentConfig.ttsModel;
|
const newModel = key === 'ttsModel' ? (value as string) : currentConfig.ttsModel;
|
||||||
const voiceKey = getVoicePreferenceKey(newProvider, newModel);
|
const voiceKey = getVoicePreferenceKey(newProviderRef, newModel);
|
||||||
const restoredVoice = currentConfig.savedVoices[voiceKey] || '';
|
const restoredVoice = currentConfig.savedVoices[voiceKey] || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import Dexie, { type EntityTable } from 'dexie';
|
import Dexie, { type EntityTable } from 'dexie';
|
||||||
import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
|
import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
|
||||||
|
import {
|
||||||
|
isTtsProviderType,
|
||||||
|
type TtsProviderType,
|
||||||
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { defaultModelForProviderType, normalizeLegacyProviderRef, resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
||||||
import {
|
import {
|
||||||
PDFDocument,
|
PDFDocument,
|
||||||
EPUBDocument,
|
EPUBDocument,
|
||||||
|
|
@ -14,7 +19,7 @@ import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
||||||
|
|
||||||
const DB_NAME = 'openreader-db';
|
const DB_NAME = 'openreader-db';
|
||||||
// Managed via Dexie (version bumped from the original manual IndexedDB)
|
// Managed via Dexie (version bumped from the original manual IndexedDB)
|
||||||
const DB_VERSION = 8;
|
const DB_VERSION = 9;
|
||||||
|
|
||||||
const PDF_TABLE = 'pdf-documents' as const;
|
const PDF_TABLE = 'pdf-documents' as const;
|
||||||
const EPUB_TABLE = 'epub-documents' as const;
|
const EPUB_TABLE = 'epub-documents' as const;
|
||||||
|
|
@ -108,50 +113,56 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||||
|
|
||||||
type RawConfigMap = Record<string, string | undefined>;
|
type RawConfigMap = Record<string, string | undefined>;
|
||||||
|
|
||||||
function inferProviderAndBaseUrl(raw: RawConfigMap): { provider: string; baseUrl: string } {
|
function inferProviderRefAndBaseUrl(raw: RawConfigMap): { providerRef: string; baseUrl: string } {
|
||||||
const cachedApiKey = raw.apiKey;
|
const cachedApiKey = raw.apiKey;
|
||||||
const cachedBaseUrl = raw.baseUrl;
|
const cachedBaseUrl = raw.baseUrl;
|
||||||
let inferredProvider = raw.ttsProvider || '';
|
let inferredProviderRef = raw.providerRef || raw.ttsProvider || raw.provider || '';
|
||||||
|
inferredProviderRef = normalizeLegacyProviderRef(inferredProviderRef, getAppConfigDefaults().providerRef);
|
||||||
|
|
||||||
if (!raw.ttsProvider) {
|
if (!raw.providerRef && !raw.ttsProvider && !raw.provider) {
|
||||||
inferredProvider = getAppConfigDefaults().ttsProvider;
|
inferredProviderRef = getAppConfigDefaults().providerRef;
|
||||||
} else if (!inferredProvider) {
|
} else if (!inferredProviderRef) {
|
||||||
if (cachedBaseUrl) {
|
if (cachedBaseUrl) {
|
||||||
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
||||||
if (baseUrlLower.includes('deepinfra.com')) {
|
if (baseUrlLower.includes('deepinfra.com')) {
|
||||||
inferredProvider = 'deepinfra';
|
inferredProviderRef = 'deepinfra';
|
||||||
} else if (baseUrlLower.includes('openai.com')) {
|
} else if (baseUrlLower.includes('openai.com')) {
|
||||||
inferredProvider = 'openai';
|
inferredProviderRef = 'openai';
|
||||||
} else if (
|
} else if (
|
||||||
baseUrlLower.includes('localhost') ||
|
baseUrlLower.includes('localhost') ||
|
||||||
baseUrlLower.includes('127.0.0.1') ||
|
baseUrlLower.includes('127.0.0.1') ||
|
||||||
baseUrlLower.includes('internal')
|
baseUrlLower.includes('internal')
|
||||||
) {
|
) {
|
||||||
inferredProvider = 'custom-openai';
|
inferredProviderRef = 'custom-openai';
|
||||||
} else {
|
} else {
|
||||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseUrl = cachedBaseUrl || '';
|
let baseUrl = cachedBaseUrl || '';
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
if (inferredProvider === 'openai') {
|
if (inferredProviderRef === 'openai') {
|
||||||
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
|
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
|
||||||
} else if (inferredProvider === 'deepinfra') {
|
} else if (inferredProviderRef === 'deepinfra') {
|
||||||
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
|
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
|
||||||
} else {
|
} else {
|
||||||
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
|
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { provider: inferredProvider, baseUrl };
|
return { providerRef: inferredProviderRef, baseUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
const { provider, baseUrl } = inferProviderAndBaseUrl(raw);
|
const { providerRef, baseUrl } = inferProviderRefAndBaseUrl(raw);
|
||||||
|
const providerTypeRaw = raw.providerType;
|
||||||
|
const providerType: TtsProviderType =
|
||||||
|
isTtsProviderType(providerTypeRaw)
|
||||||
|
? providerTypeRaw
|
||||||
|
: resolveEffectiveProviderType({ providerRef });
|
||||||
|
|
||||||
let savedVoices: SavedVoices = {};
|
let savedVoices: SavedVoices = {};
|
||||||
if (raw.savedVoices) {
|
if (raw.savedVoices) {
|
||||||
|
|
@ -188,14 +199,9 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
|
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
|
||||||
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
|
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
|
||||||
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
|
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
|
||||||
ttsProvider: provider || APP_CONFIG_DEFAULTS.ttsProvider,
|
providerRef: providerRef || APP_CONFIG_DEFAULTS.providerRef,
|
||||||
ttsModel:
|
providerType,
|
||||||
raw.ttsModel ||
|
ttsModel: raw.ttsModel || (providerType === 'unknown' ? APP_CONFIG_DEFAULTS.ttsModel : defaultModelForProviderType(providerType)),
|
||||||
(provider === 'openai'
|
|
||||||
? 'tts-1'
|
|
||||||
: provider === 'deepinfra'
|
|
||||||
? 'hexgrad/Kokoro-82M'
|
|
||||||
: APP_CONFIG_DEFAULTS.ttsModel),
|
|
||||||
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
|
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
|
||||||
savedVoices,
|
savedVoices,
|
||||||
pdfHighlightEnabled:
|
pdfHighlightEnabled:
|
||||||
|
|
@ -210,13 +216,13 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
documentListState,
|
documentListState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const voiceKey = `${config.ttsProvider}:${config.ttsModel}`;
|
const voiceKey = `${config.providerRef}:${config.ttsModel}`;
|
||||||
config.voice = config.savedVoices[voiceKey] || '';
|
config.voice = config.savedVoices[voiceKey] || '';
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version 8: add local cache metadata/indexes so document + preview caches can be bounded via LRU pruning.
|
// Version 9: normalize app-config provider fields to providerRef/providerType.
|
||||||
db.version(DB_VERSION).stores({
|
db.version(DB_VERSION).stores({
|
||||||
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
||||||
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt',
|
||||||
|
|
@ -229,8 +235,31 @@ db.version(DB_VERSION).stores({
|
||||||
// but Dexie still lets us read it inside the upgrade transaction.
|
// but Dexie still lets us read it inside the upgrade transaction.
|
||||||
[CONFIG_TABLE]: null,
|
[CONFIG_TABLE]: null,
|
||||||
}).upgrade(async (trans) => {
|
}).upgrade(async (trans) => {
|
||||||
const appConfig = await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).get('singleton');
|
const appConfigTable = trans.table<AppConfigRow, string>(APP_CONFIG_TABLE);
|
||||||
|
const appConfig = await appConfigTable.get('singleton');
|
||||||
if (appConfig) {
|
if (appConfig) {
|
||||||
|
const legacyProviderRef = (() => {
|
||||||
|
const providerRefRaw = (appConfig as unknown as { providerRef?: unknown }).providerRef;
|
||||||
|
if (typeof providerRefRaw === 'string' && providerRefRaw.trim()) return providerRefRaw;
|
||||||
|
const ttsProviderRaw = (appConfig as unknown as { ttsProvider?: unknown }).ttsProvider;
|
||||||
|
if (typeof ttsProviderRaw === 'string' && ttsProviderRaw.trim()) return ttsProviderRaw;
|
||||||
|
const providerRaw = (appConfig as unknown as { provider?: unknown }).provider;
|
||||||
|
if (typeof providerRaw === 'string' && providerRaw.trim()) return providerRaw;
|
||||||
|
return '';
|
||||||
|
})();
|
||||||
|
const providerRef = normalizeLegacyProviderRef(legacyProviderRef, APP_CONFIG_DEFAULTS.providerRef) || APP_CONFIG_DEFAULTS.providerRef;
|
||||||
|
const providerType = resolveEffectiveProviderType({ providerRef });
|
||||||
|
const savedVoices = appConfig.savedVoices && typeof appConfig.savedVoices === 'object'
|
||||||
|
? appConfig.savedVoices
|
||||||
|
: {};
|
||||||
|
const resolvedVoiceKey = `${providerRef}:${appConfig.ttsModel || APP_CONFIG_DEFAULTS.ttsModel}`;
|
||||||
|
const resolvedVoice = savedVoices[resolvedVoiceKey] || '';
|
||||||
|
await appConfigTable.put({
|
||||||
|
...appConfig,
|
||||||
|
providerRef,
|
||||||
|
providerType,
|
||||||
|
voice: appConfig.voice || resolvedVoice,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,7 +271,7 @@ db.version(DB_VERSION).stores({
|
||||||
}
|
}
|
||||||
|
|
||||||
const built = buildAppConfigFromRaw(raw);
|
const built = buildAppConfigFromRaw(raw);
|
||||||
await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).put(built);
|
await appConfigTable.put(built);
|
||||||
|
|
||||||
// Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
|
// Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
|
||||||
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);
|
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,29 @@
|
||||||
import {
|
import {
|
||||||
|
isBuiltInTtsProviderId,
|
||||||
TTS_PROVIDER_DEFINITIONS,
|
TTS_PROVIDER_DEFINITIONS,
|
||||||
providerSupportsCustomModel,
|
|
||||||
resolveProviderModels,
|
resolveProviderModels,
|
||||||
type TtsModelDefinition,
|
type TtsModelDefinition,
|
||||||
type TtsProviderDefinition,
|
type TtsProviderDefinition,
|
||||||
type TtsProviderId,
|
type TtsProviderId,
|
||||||
|
type TtsProviderType,
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import {
|
||||||
|
normalizeLegacyProviderRef,
|
||||||
|
resolveEffectiveProviderType,
|
||||||
|
resolveProviderDefaults,
|
||||||
|
resolveTtsModelForProvider,
|
||||||
|
resolveTtsProviderModelPolicy,
|
||||||
|
} from '@/lib/shared/tts-provider-policy';
|
||||||
import type { SharedProviderEntry } from '@/hooks/useSharedProviders';
|
import type { SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||||
|
|
||||||
export interface ResolveTtsSettingsViewModelOptions {
|
export interface ResolveTtsSettingsViewModelOptions {
|
||||||
provider: string;
|
providerRef: string;
|
||||||
|
providerType: TtsProviderType;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
customModelInput: string;
|
customModelInput: string;
|
||||||
showAllDeepInfra: boolean;
|
showAllDeepInfra: boolean;
|
||||||
|
showAllProviderModels: boolean;
|
||||||
sharedProviders?: SharedProviderEntry[];
|
sharedProviders?: SharedProviderEntry[];
|
||||||
allowBuiltInProviders?: boolean;
|
allowBuiltInProviders?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +45,8 @@ export interface TtsSettingsViewModel {
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
/** The matched shared provider entry, if the current selection is a shared slug. */
|
/** The matched shared provider entry, if the current selection is a shared slug. */
|
||||||
selectedSharedProvider: SharedProviderEntry | null;
|
selectedSharedProvider: SharedProviderEntry | null;
|
||||||
|
selectedProviderRef: string;
|
||||||
|
selectedProviderType: TtsProviderType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUILT_IN_DEFINITION_BY_ID: Map<string, TtsProviderDefinition> = new Map(
|
const BUILT_IN_DEFINITION_BY_ID: Map<string, TtsProviderDefinition> = new Map(
|
||||||
|
|
@ -42,11 +54,13 @@ const BUILT_IN_DEFINITION_BY_ID: Map<string, TtsProviderDefinition> = new Map(
|
||||||
);
|
);
|
||||||
|
|
||||||
export function resolveTtsSettingsViewModel({
|
export function resolveTtsSettingsViewModel({
|
||||||
provider,
|
providerRef,
|
||||||
|
providerType,
|
||||||
apiKey,
|
apiKey,
|
||||||
modelValue,
|
modelValue,
|
||||||
customModelInput,
|
customModelInput,
|
||||||
showAllDeepInfra,
|
showAllDeepInfra,
|
||||||
|
showAllProviderModels,
|
||||||
sharedProviders = [],
|
sharedProviders = [],
|
||||||
allowBuiltInProviders = true,
|
allowBuiltInProviders = true,
|
||||||
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
|
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
|
||||||
|
|
@ -65,24 +79,69 @@ export function resolveTtsSettingsViewModel({
|
||||||
shared: true,
|
shared: true,
|
||||||
}));
|
}));
|
||||||
const providers = [...sharedOptions, ...builtInOptions];
|
const providers = [...sharedOptions, ...builtInOptions];
|
||||||
const selectedProviderId = providers.some((opt) => opt.id === provider)
|
const normalizedInputProviderRef = normalizeLegacyProviderRef(providerRef);
|
||||||
? provider
|
const selectedProviderRef = providers.some((opt) => opt.id === normalizedInputProviderRef)
|
||||||
|
? normalizedInputProviderRef
|
||||||
: providers[0]?.id ?? '';
|
: providers[0]?.id ?? '';
|
||||||
|
const providerSelectionChanged = selectedProviderRef !== normalizedInputProviderRef;
|
||||||
|
|
||||||
// Determine the *effective* built-in provider type used for model resolution.
|
const selectedShared = sharedProviders.find((p) => p.slug === selectedProviderRef) ?? null;
|
||||||
const selectedShared = sharedProviders.find((p) => p.slug === selectedProviderId) ?? null;
|
const selectedProviderType = resolveEffectiveProviderType({
|
||||||
const effectiveProvider = selectedShared ? selectedShared.providerType : selectedProviderId;
|
providerRef: selectedProviderRef,
|
||||||
|
providerType,
|
||||||
|
sharedProviders,
|
||||||
|
});
|
||||||
|
const effectiveProviderType = selectedProviderType;
|
||||||
|
const knownProviderType = isBuiltInTtsProviderId(effectiveProviderType)
|
||||||
|
? effectiveProviderType
|
||||||
|
: null;
|
||||||
|
|
||||||
const models = resolveProviderModels(effectiveProvider, {
|
const catalogModels = resolveProviderModels(knownProviderType ?? 'custom-openai', {
|
||||||
apiKey,
|
apiKey,
|
||||||
showAllDeepInfra,
|
showAllDeepInfra,
|
||||||
});
|
});
|
||||||
const supportsCustomModel =
|
const providerDefaults = resolveProviderDefaults({
|
||||||
BUILT_IN_DEFINITION_BY_ID.has(effectiveProvider) &&
|
providerRef: selectedProviderRef,
|
||||||
providerSupportsCustomModel(effectiveProvider);
|
providerType: selectedProviderType,
|
||||||
const isPreset = models.some((model) => model.id === modelValue);
|
sharedProviders,
|
||||||
|
});
|
||||||
|
const defaultModel = resolveTtsModelForProvider({
|
||||||
|
providerRef: selectedProviderRef,
|
||||||
|
providerType: selectedProviderType,
|
||||||
|
model: providerDefaults.defaultModel,
|
||||||
|
sharedProviders,
|
||||||
|
showAllProviderModels: false,
|
||||||
|
});
|
||||||
|
const models = (!showAllProviderModels && defaultModel)
|
||||||
|
? [{
|
||||||
|
id: defaultModel,
|
||||||
|
name: catalogModels.find((model) => model.id === defaultModel)?.name ?? defaultModel,
|
||||||
|
}]
|
||||||
|
: catalogModels;
|
||||||
|
const supportsCustomModel = showAllProviderModels && knownProviderType
|
||||||
|
? BUILT_IN_DEFINITION_BY_ID.has(knownProviderType) && resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: selectedProviderRef,
|
||||||
|
providerType: knownProviderType,
|
||||||
|
model: modelValue,
|
||||||
|
sharedProviders,
|
||||||
|
}).supportsCustomModel
|
||||||
|
: false;
|
||||||
|
const normalizedModelValue = (() => {
|
||||||
|
if (!showAllProviderModels) {
|
||||||
|
return defaultModel;
|
||||||
|
}
|
||||||
|
const trimmedModel = modelValue.trim();
|
||||||
|
const sharedDefault = selectedShared?.defaultModel?.trim() || providerDefaults.defaultModel;
|
||||||
|
if (!sharedDefault) return trimmedModel;
|
||||||
|
if (!trimmedModel) return sharedDefault;
|
||||||
|
if ((providerSelectionChanged || normalizedInputProviderRef === 'default-openai') && trimmedModel === 'kokoro') {
|
||||||
|
return sharedDefault;
|
||||||
|
}
|
||||||
|
return trimmedModel;
|
||||||
|
})();
|
||||||
|
const isPreset = models.some((model) => model.id === normalizedModelValue);
|
||||||
const selectedModelId = isPreset
|
const selectedModelId = isPreset
|
||||||
? modelValue
|
? normalizedModelValue
|
||||||
: supportsCustomModel
|
: supportsCustomModel
|
||||||
? 'custom'
|
? 'custom'
|
||||||
: models[0]?.id ?? '';
|
: models[0]?.id ?? '';
|
||||||
|
|
@ -98,5 +157,7 @@ export function resolveTtsSettingsViewModel({
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
selectedSharedProvider: selectedShared,
|
selectedSharedProvider: selectedShared,
|
||||||
|
selectedProviderRef,
|
||||||
|
selectedProviderType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { adminProviders } from '@/db/schema';
|
import { adminProviders } from '@/db/schema';
|
||||||
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
|
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
|
||||||
import { supportsTtsInstructions, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
|
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
|
||||||
'custom-openai',
|
'custom-openai',
|
||||||
|
|
@ -170,7 +171,11 @@ function normalizeOptionalText(value: string | null | undefined): string | null
|
||||||
|
|
||||||
function assertInstructionsCompatibility(model: string | null, instructions: string | null): void {
|
function assertInstructionsCompatibility(model: string | null, instructions: string | null): void {
|
||||||
if (!instructions) return;
|
if (!instructions) return;
|
||||||
if (!supportsTtsInstructions(model)) {
|
if (!resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: '',
|
||||||
|
providerType: 'custom-openai',
|
||||||
|
model,
|
||||||
|
}).supportsInstructions) {
|
||||||
throw new AdminProviderError(
|
throw new AdminProviderError(
|
||||||
'defaultInstructions is only supported for models that support TTS instructions.',
|
'defaultInstructions is only supported for models that support TTS instructions.',
|
||||||
400,
|
400,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ async function runSeed(): Promise<void> {
|
||||||
await seedRuntimeConfigFromEnv();
|
await seedRuntimeConfigFromEnv();
|
||||||
await seedDefaultAdminProvider();
|
await seedDefaultAdminProvider();
|
||||||
await cleanupLegacyDefaultTtsProviderSeedRow();
|
await cleanupLegacyDefaultTtsProviderSeedRow();
|
||||||
|
await cleanupLegacyDefaultTtsModelRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedDefaultAdminProvider(): Promise<void> {
|
async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
|
|
@ -64,17 +65,15 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
|
|
||||||
const baseUrl = process.env.API_BASE?.trim() || null;
|
const baseUrl = process.env.API_BASE?.trim() || null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const defaultModel = (() => {
|
|
||||||
const raw = process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsModel.envVar];
|
|
||||||
return raw && raw.trim() ? raw.trim() : null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
let enc: ReturnType<typeof encryptSecret>;
|
let enc: ReturnType<typeof encryptSecret>;
|
||||||
try {
|
try {
|
||||||
enc = encryptSecret(apiKey);
|
enc = encryptSecret(apiKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const hasExplicitRestriction =
|
const hasExplicitRestriction =
|
||||||
Boolean(process.env[RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar]?.trim());
|
Boolean(
|
||||||
|
RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar
|
||||||
|
&& process.env[RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar]?.trim(),
|
||||||
|
);
|
||||||
if (!hasExplicitRestriction) {
|
if (!hasExplicitRestriction) {
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
|
|
@ -110,7 +109,7 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
apiKeyCiphertext: enc.ciphertext,
|
apiKeyCiphertext: enc.ciphertext,
|
||||||
apiKeyIv: enc.iv,
|
apiKeyIv: enc.iv,
|
||||||
apiKeyLast4: apiKeyLast4(apiKey),
|
apiKeyLast4: apiKeyLast4(apiKey),
|
||||||
defaultModel,
|
defaultModel: 'kokoro',
|
||||||
enabled: 1,
|
enabled: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
@ -123,7 +122,9 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
|
|
||||||
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
||||||
// If an explicit env default exists, keep env-seeded behavior.
|
// If an explicit env default exists, keep env-seeded behavior.
|
||||||
const explicit = process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar];
|
const explicit = RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar
|
||||||
|
? process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar]
|
||||||
|
: undefined;
|
||||||
if (explicit && explicit.trim()) return;
|
if (explicit && explicit.trim()) return;
|
||||||
|
|
||||||
const key = 'defaultTtsProvider';
|
const key = 'defaultTtsProvider';
|
||||||
|
|
@ -142,3 +143,13 @@ async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
||||||
console.warn('[admin-seed] failed to cleanup legacy defaultTtsProvider seed row', error);
|
console.warn('[admin-seed] failed to cleanup legacy defaultTtsProvider seed row', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanupLegacyDefaultTtsModelRows(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(adminSettings)
|
||||||
|
.where(eq(adminSettings.key, 'defaultTtsModel'));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[admin-seed] failed to cleanup legacy defaultTtsModel rows', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ export type RuntimeConfigSource = 'env-seed' | 'admin';
|
||||||
export interface RuntimeConfigKeyDef<T> {
|
export interface RuntimeConfigKeyDef<T> {
|
||||||
/** TS-level default. Used when neither DB nor env have a value. */
|
/** TS-level default. Used when neither DB nor env have a value. */
|
||||||
default: T;
|
default: T;
|
||||||
/** Env var name to seed from on first run. */
|
/** Env var name to seed from on first run. Omit for DB/admin-only keys. */
|
||||||
envVar: string;
|
envVar?: string;
|
||||||
/** Parse a string env value to T. Returns undefined to skip seeding. */
|
/** Parse a string env value to T. Returns undefined to skip seeding. */
|
||||||
parseEnv(raw: string): T | undefined;
|
parseEnv(raw: string): T | undefined;
|
||||||
/** Validate an incoming admin-supplied value. */
|
/** Validate an incoming admin-supplied value. */
|
||||||
|
|
@ -46,6 +46,19 @@ function booleanFlag(defaultValue: boolean, envVar: string): RuntimeConfigKeyDef
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
|
||||||
|
return {
|
||||||
|
default: defaultValue,
|
||||||
|
parseEnv() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
validate(value) {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<string> {
|
function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<string> {
|
||||||
return {
|
return {
|
||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
|
|
@ -63,7 +76,6 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
|
||||||
|
|
||||||
export const RUNTIME_CONFIG_SCHEMA = {
|
export const RUNTIME_CONFIG_SCHEMA = {
|
||||||
defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'),
|
defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'),
|
||||||
defaultTtsModel: stringValue('kokoro', 'NEXT_PUBLIC_DEFAULT_TTS_MODEL'),
|
|
||||||
restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'),
|
restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'),
|
||||||
// Historically the env semantics were "true unless explicitly 'false'",
|
// Historically the env semantics were "true unless explicitly 'false'",
|
||||||
// i.e. the feature defaults to ON.
|
// i.e. the feature defaults to ON.
|
||||||
|
|
@ -73,6 +85,7 @@ export const RUNTIME_CONFIG_SCHEMA = {
|
||||||
enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'),
|
enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'),
|
||||||
enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
|
enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
|
||||||
showAllDeepInfraModels: booleanFlag(true, 'NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS'),
|
showAllDeepInfraModels: booleanFlag(true, 'NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS'),
|
||||||
|
showAllProviderModels: runtimeBoolean(true),
|
||||||
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
|
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
|
||||||
|
|
||||||
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;
|
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;
|
||||||
|
|
@ -267,6 +280,7 @@ export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfi
|
||||||
for (const key of RUNTIME_KEYS) {
|
for (const key of RUNTIME_KEYS) {
|
||||||
if (existing.has(key)) continue;
|
if (existing.has(key)) continue;
|
||||||
const def = RUNTIME_CONFIG_SCHEMA[key];
|
const def = RUNTIME_CONFIG_SCHEMA[key];
|
||||||
|
if (!def.envVar) continue;
|
||||||
const raw = process.env[def.envVar];
|
const raw = process.env[def.envVar];
|
||||||
if (raw === undefined || raw === null || raw === '') continue;
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
const parsed = def.parseEnv(raw);
|
const parsed = def.parseEnv(raw);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
function normalizeInstructionCandidate(value: string | null | undefined): string | undefined {
|
function normalizeInstructionCandidate(value: string | null | undefined): string | undefined {
|
||||||
if (value === null || value === undefined) return undefined;
|
if (value === null || value === undefined) return undefined;
|
||||||
|
|
@ -21,11 +21,14 @@ export function resolveEffectiveTtsInstructions(opts: {
|
||||||
requestInstructions?: string | null;
|
requestInstructions?: string | null;
|
||||||
sharedDefaultInstructions?: string | null;
|
sharedDefaultInstructions?: string | null;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
if (!supportsTtsInstructions(opts.model)) {
|
if (!resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: '',
|
||||||
|
providerType: 'custom-openai',
|
||||||
|
model: opts.model,
|
||||||
|
}).supportsInstructions) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeInstructionCandidate(opts.requestInstructions)
|
return normalizeInstructionCandidate(opts.requestInstructions)
|
||||||
?? normalizeInstructionCandidate(opts.sharedDefaultInstructions);
|
?? normalizeInstructionCandidate(opts.sharedDefaultInstructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import Replicate from 'replicate';
|
import Replicate from 'replicate';
|
||||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
|
||||||
import {
|
import {
|
||||||
|
isBuiltInTtsProviderId,
|
||||||
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||||
supportsNativeModelSpeed,
|
|
||||||
supportsTtsInstructions,
|
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution';
|
import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution';
|
||||||
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
@ -224,21 +223,27 @@ export function extractReplicateAudioUrl(output: unknown): string | null {
|
||||||
|
|
||||||
function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
||||||
const provider = input.provider || 'openai';
|
const provider = input.provider || 'openai';
|
||||||
|
const providerType = isBuiltInTtsProviderId(provider) ? provider : 'openai';
|
||||||
const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M'
|
const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M'
|
||||||
: provider === 'replicate' && !input.model ? REPLICATE_KOKORO_82M_VERSIONED_MODEL
|
: provider === 'replicate' && !input.model ? REPLICATE_KOKORO_82M_VERSIONED_MODEL
|
||||||
: input.model;
|
: input.model;
|
||||||
const model = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
|
const model = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
|
||||||
|
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||||
|
providerRef: provider,
|
||||||
|
providerType,
|
||||||
|
model: model as string,
|
||||||
|
});
|
||||||
|
|
||||||
const normalizedVoice = (
|
const normalizedVoice = (
|
||||||
(provider === 'replicate' || !isKokoroModel(model as string)) && input.voice.includes('+')
|
(providerType === 'replicate' || !providerModelPolicy.isKokoroModel) && input.voice.includes('+')
|
||||||
? input.voice.split('+')[0].trim()
|
? input.voice.split('+')[0].trim()
|
||||||
: input.voice
|
: input.voice
|
||||||
) as string;
|
) as string;
|
||||||
|
|
||||||
const format = input.format || 'mp3';
|
const format = input.format || 'mp3';
|
||||||
const requestedSpeed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
const requestedSpeed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
||||||
const speed = supportsNativeModelSpeed(provider, model as string) ? requestedSpeed : 1;
|
const speed = providerModelPolicy.supportsNativeModelSpeed ? requestedSpeed : 1;
|
||||||
const instructions = supportsTtsInstructions(model as string) && input.instructions
|
const instructions = providerModelPolicy.supportsInstructions && input.instructions
|
||||||
? input.instructions
|
? input.instructions
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ function stableStringify(value: unknown): string {
|
||||||
|
|
||||||
function settingsCanonical(settings: TTSSegmentSettings): string {
|
function settingsCanonical(settings: TTSSegmentSettings): string {
|
||||||
return stableStringify({
|
return stableStringify({
|
||||||
provider: settings.ttsProvider,
|
providerRef: settings.providerRef,
|
||||||
|
providerType: settings.providerType,
|
||||||
model: settings.ttsModel,
|
model: settings.ttsModel,
|
||||||
voice: settings.voice,
|
voice: settings.voice,
|
||||||
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
||||||
|
|
@ -41,7 +42,8 @@ export function buildTtsSegmentSettingsHash(settings: TTSSegmentSettings): strin
|
||||||
|
|
||||||
export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSegmentSettings | string {
|
export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSegmentSettings | string {
|
||||||
const canonical: TTSSegmentSettings = {
|
const canonical: TTSSegmentSettings = {
|
||||||
ttsProvider: settings.ttsProvider,
|
providerRef: settings.providerRef,
|
||||||
|
providerType: settings.providerType,
|
||||||
ttsModel: settings.ttsModel,
|
ttsModel: settings.ttsModel,
|
||||||
voice: settings.voice,
|
voice: settings.voice,
|
||||||
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import {
|
import {
|
||||||
getDefaultVoices,
|
|
||||||
resolveProviderModels,
|
resolveProviderModels,
|
||||||
resolveVoiceSource,
|
|
||||||
type ReplicateVoiceInputKey,
|
type ReplicateVoiceInputKey,
|
||||||
type ResolveVoicesOptions,
|
type ResolveVoicesOptions,
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
|
|
@ -324,8 +323,13 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise<string[]> {
|
export async function resolveVoices({ provider, model, apiKey = '', baseUrl = '' }: ResolveVoicesOptions): Promise<string[]> {
|
||||||
const defaultVoices = getDefaultVoices(provider, model);
|
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||||
const voiceSource = resolveVoiceSource(provider, model);
|
providerRef: provider,
|
||||||
|
providerType: provider,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
const defaultVoices = providerModelPolicy.defaultVoices;
|
||||||
|
const voiceSource = providerModelPolicy.voiceSource;
|
||||||
|
|
||||||
if (voiceSource === 'deepinfra-api') {
|
if (voiceSource === 'deepinfra-api') {
|
||||||
const apiVoices = await fetchDeepinfraVoices(apiKey);
|
const apiVoices = await fetchDeepinfraVoices(apiKey);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export const isKokoroModel = (modelName: string | undefined): boolean => {
|
||||||
* @param model - TTS model name
|
* @param model - TTS model name
|
||||||
* @returns Maximum number of voices (Infinity for unlimited)
|
* @returns Maximum number of voices (Infinity for unlimited)
|
||||||
*/
|
*/
|
||||||
export const getMaxVoicesForProvider = (provider: string, model: string): number => {
|
export const getMaxVoicesForProvider = (provider: TtsProviderId, model: string): number => {
|
||||||
if (!isKokoroModel(model)) return 1;
|
if (!isKokoroModel(model)) return 1;
|
||||||
|
|
||||||
// Deepinfra and Replicate Kokoro do not support multiple voices.
|
// Deepinfra and Replicate Kokoro do not support multiple voices.
|
||||||
|
|
@ -63,3 +63,4 @@ export const getMaxVoicesForProvider = (provider: string, model: string): number
|
||||||
// Other providers with Kokoro support unlimited voices
|
// Other providers with Kokoro support unlimited voices
|
||||||
return Infinity;
|
return Infinity;
|
||||||
};
|
};
|
||||||
|
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||||
|
|
||||||
export type TtsProviderId = 'custom-openai' | 'replicate' | 'deepinfra' | 'openai';
|
export type TtsProviderId = 'custom-openai' | 'replicate' | 'deepinfra' | 'openai';
|
||||||
|
export type TtsProviderType = TtsProviderId | 'unknown';
|
||||||
export type TtsVoiceSource = 'static' | 'deepinfra-api' | 'custom-openai-api' | 'replicate-api';
|
export type TtsVoiceSource = 'static' | 'deepinfra-api' | 'custom-openai-api' | 'replicate-api';
|
||||||
export type ReplicateVoiceInputKey = 'voice' | 'voice_id' | 'speaker';
|
export type ReplicateVoiceInputKey = 'voice' | 'voice_id' | 'speaker';
|
||||||
|
|
||||||
|
export interface SharedProviderTypeResolverEntry {
|
||||||
|
slug: string;
|
||||||
|
providerType: TtsProviderId;
|
||||||
|
defaultModel?: string | null;
|
||||||
|
defaultInstructions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TtsModelDefinition {
|
export interface TtsModelDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -22,7 +30,7 @@ export interface ResolveProviderModelsContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveVoicesOptions {
|
export interface ResolveVoicesOptions {
|
||||||
provider: string;
|
provider: TtsProviderId;
|
||||||
model: string;
|
model: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
@ -155,6 +163,14 @@ export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BUILT_IN_PROVIDER_ID_SET: ReadonlySet<TtsProviderId> = new Set(
|
||||||
|
TTS_PROVIDER_DEFINITIONS.map((definition) => definition.id),
|
||||||
|
);
|
||||||
|
const TTS_PROVIDER_TYPE_SET: ReadonlySet<TtsProviderType> = new Set([
|
||||||
|
...TTS_PROVIDER_DEFINITIONS.map((definition) => definition.id),
|
||||||
|
'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
const MODELS_WITH_INSTRUCTIONS = new Set([
|
const MODELS_WITH_INSTRUCTIONS = new Set([
|
||||||
'gpt-4o-mini-tts',
|
'gpt-4o-mini-tts',
|
||||||
'google/gemini-3.1-flash-tts',
|
'google/gemini-3.1-flash-tts',
|
||||||
|
|
@ -170,7 +186,31 @@ export function supportsTtsInstructions(model: string | null | undefined): boole
|
||||||
return !!model && MODELS_WITH_INSTRUCTIONS.has(model);
|
return !!model && MODELS_WITH_INSTRUCTIONS.has(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsNativeModelSpeed(provider: string | null | undefined, model: string | null | undefined): boolean {
|
export function isBuiltInTtsProviderId(value: string | null | undefined): value is TtsProviderId {
|
||||||
|
return typeof value === 'string' && BUILT_IN_PROVIDER_ID_SET.has(value as TtsProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTtsProviderType(value: unknown): value is TtsProviderType {
|
||||||
|
return typeof value === 'string' && TTS_PROVIDER_TYPE_SET.has(value as TtsProviderType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderType(
|
||||||
|
providerRef: string | null | undefined,
|
||||||
|
sharedProviders: readonly SharedProviderTypeResolverEntry[] = [],
|
||||||
|
): TtsProviderType {
|
||||||
|
if (isBuiltInTtsProviderId(providerRef)) {
|
||||||
|
return providerRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providerRef) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = sharedProviders.find((entry) => entry.slug === providerRef);
|
||||||
|
return shared ? shared.providerType : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNativeModelSpeed(provider: TtsProviderId, model: string | null | undefined): boolean {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -183,18 +223,20 @@ export function supportsNativeModelSpeed(provider: string | null | undefined, mo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderDefinition(provider: string | null | undefined): TtsProviderDefinition | undefined {
|
export function getProviderDefinition(provider: string | null | undefined): TtsProviderDefinition | undefined {
|
||||||
return TTS_PROVIDER_DEFINITIONS.find((definition) => definition.id === provider);
|
return isBuiltInTtsProviderId(provider)
|
||||||
|
? TTS_PROVIDER_DEFINITIONS.find((definition) => definition.id === provider)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProviderModels(provider: string | null | undefined, context?: ResolveProviderModelsContext): TtsModelDefinition[] {
|
export function resolveProviderModels(provider: TtsProviderId, context?: ResolveProviderModelsContext): TtsModelDefinition[] {
|
||||||
return getProviderDefinition(provider)?.models(context) ?? DEFAULT_MODELS;
|
return getProviderDefinition(provider)?.models(context) ?? DEFAULT_MODELS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function providerSupportsCustomModel(provider: string | null | undefined): boolean {
|
export function providerSupportsCustomModel(provider: TtsProviderId): boolean {
|
||||||
return getProviderDefinition(provider)?.supportsCustomModel ?? false;
|
return getProviderDefinition(provider)?.supportsCustomModel ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultVoices(provider: string, model: string): string[] {
|
export function getDefaultVoices(provider: TtsProviderId, model: string): string[] {
|
||||||
if (provider === 'openai') {
|
if (provider === 'openai') {
|
||||||
return supportsTtsInstructions(model) ? [...GPT4O_MINI_DEFAULT_VOICES] : [...OPENAI_DEFAULT_VOICES];
|
return supportsTtsInstructions(model) ? [...GPT4O_MINI_DEFAULT_VOICES] : [...OPENAI_DEFAULT_VOICES];
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +279,7 @@ function parseReplicateModelIdentifier(model: string): {
|
||||||
: parsed;
|
: parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveVoiceSource(provider: string, model: string): TtsVoiceSource {
|
export function resolveVoiceSource(provider: TtsProviderId, model: string): TtsVoiceSource {
|
||||||
if (provider === 'deepinfra' && DEEPINFRA_API_VOICE_MODELS.has(model)) {
|
if (provider === 'deepinfra' && DEEPINFRA_API_VOICE_MODELS.has(model)) {
|
||||||
return 'deepinfra-api';
|
return 'deepinfra-api';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
src/lib/shared/tts-provider-policy.ts
Normal file
175
src/lib/shared/tts-provider-policy.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { getMaxVoicesForProvider, isKokoroModel } from '@/lib/shared/kokoro';
|
||||||
|
import {
|
||||||
|
getDefaultVoices,
|
||||||
|
isBuiltInTtsProviderId,
|
||||||
|
providerSupportsCustomModel,
|
||||||
|
resolveProviderType,
|
||||||
|
resolveVoiceSource,
|
||||||
|
supportsNativeModelSpeed,
|
||||||
|
supportsTtsInstructions,
|
||||||
|
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||||
|
type SharedProviderTypeResolverEntry,
|
||||||
|
type TtsProviderId,
|
||||||
|
type TtsProviderType,
|
||||||
|
type TtsVoiceSource,
|
||||||
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
|
|
||||||
|
export interface ResolveTtsPolicyInput {
|
||||||
|
providerRef: string | null | undefined;
|
||||||
|
providerType?: TtsProviderType | null | undefined;
|
||||||
|
model: string | null | undefined;
|
||||||
|
sharedProviders?: readonly SharedProviderTypeResolverEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderDefaultResolverEntry extends SharedProviderTypeResolverEntry {
|
||||||
|
defaultModel?: string | null;
|
||||||
|
defaultInstructions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsProviderModelPolicy {
|
||||||
|
providerRef: string;
|
||||||
|
providerType: TtsProviderType;
|
||||||
|
isResolvedProviderType: boolean;
|
||||||
|
model: string;
|
||||||
|
isKokoroModel: boolean;
|
||||||
|
maxVoices: number;
|
||||||
|
supportsNativeModelSpeed: boolean;
|
||||||
|
supportsInstructions: boolean;
|
||||||
|
supportsCustomModel: boolean;
|
||||||
|
defaultVoices: string[];
|
||||||
|
voiceSource: TtsVoiceSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedProviderDefaults {
|
||||||
|
providerRef: string;
|
||||||
|
providerType: TtsProviderType;
|
||||||
|
defaultModel: string;
|
||||||
|
defaultVoice: string;
|
||||||
|
defaultInstructions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveTtsModelForProviderInput {
|
||||||
|
providerRef: string | null | undefined;
|
||||||
|
providerType?: TtsProviderType | null | undefined;
|
||||||
|
model: string | null | undefined;
|
||||||
|
sharedProviders?: readonly ProviderDefaultResolverEntry[];
|
||||||
|
fallbackProviderRef?: string | null | undefined;
|
||||||
|
showAllProviderModels?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultModelForProviderType(providerType: TtsProviderId): string {
|
||||||
|
if (providerType === 'openai') return 'tts-1';
|
||||||
|
if (providerType === 'deepinfra') return 'hexgrad/Kokoro-82M';
|
||||||
|
if (providerType === 'replicate') return REPLICATE_KOKORO_82M_VERSIONED_MODEL;
|
||||||
|
return 'kokoro';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultBaseUrlForProviderType(providerType: TtsProviderId): string {
|
||||||
|
if (providerType === 'openai') return 'https://api.openai.com/v1';
|
||||||
|
if (providerType === 'deepinfra') return 'https://api.deepinfra.com/v1/openai';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultVoiceForProviderType(providerType: TtsProviderId): string {
|
||||||
|
if (providerType === 'openai') return 'alloy';
|
||||||
|
if (providerType === 'deepinfra') return 'af_bella';
|
||||||
|
return 'af_sarah';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLegacyProviderRef(
|
||||||
|
providerRef: string | null | undefined,
|
||||||
|
fallbackProviderRef?: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const raw = typeof providerRef === 'string' ? providerRef.trim() : '';
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw !== 'default-openai') return raw;
|
||||||
|
const fallback = typeof fallbackProviderRef === 'string' ? fallbackProviderRef.trim() : '';
|
||||||
|
return fallback || 'custom-openai';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderDefaults(input: {
|
||||||
|
providerRef: string | null | undefined;
|
||||||
|
providerType?: TtsProviderType | null | undefined;
|
||||||
|
sharedProviders?: readonly ProviderDefaultResolverEntry[];
|
||||||
|
fallbackProviderRef?: string | null | undefined;
|
||||||
|
}): ResolvedProviderDefaults {
|
||||||
|
const normalizedProviderRef = normalizeLegacyProviderRef(input.providerRef, input.fallbackProviderRef);
|
||||||
|
const fallbackRef = typeof input.fallbackProviderRef === 'string' ? input.fallbackProviderRef.trim() : '';
|
||||||
|
const providerRef = normalizedProviderRef || fallbackRef;
|
||||||
|
const sharedProviders = input.sharedProviders ?? [];
|
||||||
|
const providerType = resolveEffectiveProviderType({
|
||||||
|
providerRef,
|
||||||
|
providerType: input.providerType,
|
||||||
|
sharedProviders,
|
||||||
|
});
|
||||||
|
const shared = sharedProviders.find((entry) => entry.slug === providerRef);
|
||||||
|
const sharedDefaultModel = typeof shared?.defaultModel === 'string'
|
||||||
|
? shared.defaultModel.trim()
|
||||||
|
: '';
|
||||||
|
const sharedDefaultInstructions = typeof shared?.defaultInstructions === 'string'
|
||||||
|
? shared.defaultInstructions
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerRef,
|
||||||
|
providerType,
|
||||||
|
defaultModel: sharedDefaultModel || (isBuiltInTtsProviderId(providerType) ? defaultModelForProviderType(providerType) : ''),
|
||||||
|
defaultVoice: isBuiltInTtsProviderId(providerType) ? defaultVoiceForProviderType(providerType) : '',
|
||||||
|
defaultInstructions: sharedDefaultInstructions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveProviderType(input: {
|
||||||
|
providerRef: string | null | undefined;
|
||||||
|
providerType?: TtsProviderType | null | undefined;
|
||||||
|
sharedProviders?: readonly SharedProviderTypeResolverEntry[];
|
||||||
|
}): TtsProviderType {
|
||||||
|
const fromRef = resolveProviderType(input.providerRef, input.sharedProviders ?? []);
|
||||||
|
if (fromRef !== 'unknown') return fromRef;
|
||||||
|
return isBuiltInTtsProviderId(input.providerType) ? input.providerType : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTtsModelForProvider(input: ResolveTtsModelForProviderInput): string {
|
||||||
|
const providerDefaults = resolveProviderDefaults({
|
||||||
|
providerRef: input.providerRef,
|
||||||
|
providerType: input.providerType,
|
||||||
|
sharedProviders: input.sharedProviders,
|
||||||
|
fallbackProviderRef: input.fallbackProviderRef,
|
||||||
|
});
|
||||||
|
const requested = typeof input.model === 'string' ? input.model.trim() : '';
|
||||||
|
if (input.showAllProviderModels === false) {
|
||||||
|
return providerDefaults.defaultModel || requested;
|
||||||
|
}
|
||||||
|
return requested || providerDefaults.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTtsProviderModelPolicy(input: ResolveTtsPolicyInput): TtsProviderModelPolicy {
|
||||||
|
const providerRef = input.providerRef || '';
|
||||||
|
const providerType = resolveEffectiveProviderType({
|
||||||
|
providerRef,
|
||||||
|
providerType: input.providerType,
|
||||||
|
sharedProviders: input.sharedProviders,
|
||||||
|
});
|
||||||
|
const model = input.model || '';
|
||||||
|
const isResolvedProviderType = isBuiltInTtsProviderId(providerType);
|
||||||
|
const isKokoro = isKokoroModel(model);
|
||||||
|
const maxVoices = isResolvedProviderType ? getMaxVoicesForProvider(providerType, model) : 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerRef,
|
||||||
|
providerType,
|
||||||
|
isResolvedProviderType,
|
||||||
|
model,
|
||||||
|
isKokoroModel: isKokoro,
|
||||||
|
maxVoices,
|
||||||
|
supportsNativeModelSpeed: isResolvedProviderType
|
||||||
|
? supportsNativeModelSpeed(providerType, model)
|
||||||
|
: false,
|
||||||
|
supportsInstructions: supportsTtsInstructions(model),
|
||||||
|
supportsCustomModel: isResolvedProviderType
|
||||||
|
? providerSupportsCustomModel(providerType)
|
||||||
|
: false,
|
||||||
|
defaultVoices: isResolvedProviderType ? getDefaultVoices(providerType, model) : [],
|
||||||
|
voiceSource: isResolvedProviderType ? resolveVoiceSource(providerType, model) : 'static',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
TTSAudioBytes,
|
TTSAudioBytes,
|
||||||
TTSAudiobookFormat,
|
TTSAudiobookFormat,
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
|
import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
|
||||||
// --- TTS Client Request Types ---
|
// --- TTS Client Request Types ---
|
||||||
|
|
||||||
|
|
@ -37,7 +38,8 @@ export interface AudiobookStatusResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudiobookGenerationSettings {
|
export interface AudiobookGenerationSettings {
|
||||||
ttsProvider: string;
|
providerRef: string;
|
||||||
|
providerType: TtsProviderType;
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
voice: string;
|
voice: string;
|
||||||
nativeSpeed: number;
|
nativeSpeed: number;
|
||||||
|
|
@ -74,7 +76,8 @@ export interface AlignmentResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTSSegmentSettings {
|
export interface TTSSegmentSettings {
|
||||||
ttsProvider: string;
|
providerRef: string;
|
||||||
|
providerType: TtsProviderType;
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
voice: string;
|
voice: string;
|
||||||
nativeSpeed: number;
|
nativeSpeed: number;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { DocumentListState } from '@/types/documents';
|
import type { DocumentListState } from '@/types/documents';
|
||||||
|
import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { defaultModelForProviderType, normalizeLegacyProviderRef } from '@/lib/shared/tts-provider-policy';
|
||||||
|
|
||||||
// Runtime config (admin-controlled) is layered on top of the static defaults
|
// 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
|
// below. We resolve it lazily so this module stays importable from non-React
|
||||||
|
|
@ -64,7 +66,8 @@ export interface AppConfigValues {
|
||||||
footerMargin: number;
|
footerMargin: number;
|
||||||
leftMargin: number;
|
leftMargin: number;
|
||||||
rightMargin: number;
|
rightMargin: number;
|
||||||
ttsProvider: string;
|
providerRef: string;
|
||||||
|
providerType: TtsProviderType;
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
ttsInstructions: string;
|
ttsInstructions: string;
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
|
|
@ -92,6 +95,12 @@ export interface AppConfigValues {
|
||||||
*/
|
*/
|
||||||
export function getAppConfigDefaults(): AppConfigValues {
|
export function getAppConfigDefaults(): AppConfigValues {
|
||||||
const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true);
|
const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true);
|
||||||
|
const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai');
|
||||||
|
const defaultProviderRef = normalizeLegacyProviderRef(runtimeProviderRef, 'custom-openai');
|
||||||
|
const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown';
|
||||||
|
const defaultModel = isBuiltInTtsProviderId(defaultProviderType)
|
||||||
|
? defaultModelForProviderType(defaultProviderType)
|
||||||
|
: 'kokoro';
|
||||||
return {
|
return {
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
|
|
@ -105,8 +114,9 @@ export function getAppConfigDefaults(): AppConfigValues {
|
||||||
footerMargin: 0,
|
footerMargin: 0,
|
||||||
leftMargin: 0,
|
leftMargin: 0,
|
||||||
rightMargin: 0,
|
rightMargin: 0,
|
||||||
ttsProvider: readRuntimeString('defaultTtsProvider', 'custom-openai'),
|
providerRef: defaultProviderRef,
|
||||||
ttsModel: readRuntimeString('defaultTtsModel', 'kokoro'),
|
providerType: defaultProviderType,
|
||||||
|
ttsModel: defaultModel,
|
||||||
ttsInstructions: '',
|
ttsInstructions: '',
|
||||||
savedVoices: {},
|
savedVoices: {},
|
||||||
smartSentenceSplitting: true,
|
smartSentenceSplitting: true,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ export const SYNCED_PREFERENCE_KEYS = [
|
||||||
'footerMargin',
|
'footerMargin',
|
||||||
'leftMargin',
|
'leftMargin',
|
||||||
'rightMargin',
|
'rightMargin',
|
||||||
'ttsProvider',
|
'providerRef',
|
||||||
|
'providerType',
|
||||||
'ttsModel',
|
'ttsModel',
|
||||||
'ttsInstructions',
|
'ttsInstructions',
|
||||||
'savedVoices',
|
'savedVoices',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
supportsNativeModelSpeed,
|
supportsNativeModelSpeed,
|
||||||
supportsTtsInstructions,
|
supportsTtsInstructions,
|
||||||
} from '../../src/lib/shared/tts-provider-catalog';
|
} from '../../src/lib/shared/tts-provider-catalog';
|
||||||
|
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
|
||||||
import { resolveReplicateVoiceInputKey, resolveVoices } from '../../src/lib/server/tts/voice-resolution';
|
import { resolveReplicateVoiceInputKey, resolveVoices } from '../../src/lib/server/tts/voice-resolution';
|
||||||
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
||||||
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
||||||
|
|
@ -59,6 +60,77 @@ test.describe('tts provider catalog', () => {
|
||||||
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
|
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes legacy default-openai provider ref to fallback', () => {
|
||||||
|
expect(normalizeLegacyProviderRef('default-openai', 'shared-replicate')).toBe('shared-replicate');
|
||||||
|
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('custom-openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves shared-provider default model as authoritative', () => {
|
||||||
|
const defaults = resolveProviderDefaults({
|
||||||
|
providerRef: 'shared-replicate',
|
||||||
|
providerType: 'unknown',
|
||||||
|
fallbackProviderRef: 'shared-replicate',
|
||||||
|
sharedProviders: [
|
||||||
|
{
|
||||||
|
slug: 'shared-replicate',
|
||||||
|
providerType: 'replicate',
|
||||||
|
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||||
|
defaultInstructions: 'Narrate warmly',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(defaults.providerRef).toBe('shared-replicate');
|
||||||
|
expect(defaults.providerType).toBe('replicate');
|
||||||
|
expect(defaults.defaultModel).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
|
||||||
|
expect(defaults.defaultInstructions).toBe('Narrate warmly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTtsModelForProvider enforces shared default model when showAllProviderModels is false', () => {
|
||||||
|
const resolved = resolveTtsModelForProvider({
|
||||||
|
providerRef: 'shared-replicate',
|
||||||
|
providerType: 'unknown',
|
||||||
|
model: 'acme/custom-model',
|
||||||
|
showAllProviderModels: false,
|
||||||
|
sharedProviders: [
|
||||||
|
{
|
||||||
|
slug: 'shared-replicate',
|
||||||
|
providerType: 'replicate',
|
||||||
|
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||||
|
defaultInstructions: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(resolved).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTtsModelForProvider keeps requested model when showAllProviderModels is true', () => {
|
||||||
|
const resolved = resolveTtsModelForProvider({
|
||||||
|
providerRef: 'shared-replicate',
|
||||||
|
providerType: 'unknown',
|
||||||
|
model: 'acme/custom-model',
|
||||||
|
showAllProviderModels: true,
|
||||||
|
sharedProviders: [
|
||||||
|
{
|
||||||
|
slug: 'shared-replicate',
|
||||||
|
providerType: 'replicate',
|
||||||
|
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||||
|
defaultInstructions: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(resolved).toBe('acme/custom-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTtsModelForProvider falls back to built-in provider default model when locked', () => {
|
||||||
|
const resolved = resolveTtsModelForProvider({
|
||||||
|
providerRef: 'openai',
|
||||||
|
providerType: 'openai',
|
||||||
|
model: 'gpt-4o-mini-tts',
|
||||||
|
showAllProviderModels: false,
|
||||||
|
});
|
||||||
|
expect(resolved).toBe('tts-1');
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps explicit empty custom-openai voices response', async () => {
|
test('keeps explicit empty custom-openai voices response', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async () => ({
|
globalThis.fetch = async () => ({
|
||||||
|
|
@ -319,7 +391,8 @@ test.describe('config helpers', () => {
|
||||||
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
|
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
|
||||||
|
|
||||||
const voiceUpdate = applyConfigUpdate({
|
const voiceUpdate = applyConfigUpdate({
|
||||||
ttsProvider: 'openai',
|
providerRef: 'openai',
|
||||||
|
providerType: 'openai',
|
||||||
ttsModel: 'tts-1',
|
ttsModel: 'tts-1',
|
||||||
savedVoices: {},
|
savedVoices: {},
|
||||||
}, 'voice', 'alloy');
|
}, 'voice', 'alloy');
|
||||||
|
|
@ -329,14 +402,15 @@ test.describe('config helpers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const providerUpdate = applyConfigUpdate({
|
const providerUpdate = applyConfigUpdate({
|
||||||
ttsProvider: 'openai',
|
providerRef: 'openai',
|
||||||
|
providerType: 'openai',
|
||||||
ttsModel: 'tts-1',
|
ttsModel: 'tts-1',
|
||||||
savedVoices: {
|
savedVoices: {
|
||||||
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
|
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
|
||||||
},
|
},
|
||||||
}, 'ttsProvider', 'deepinfra');
|
}, 'providerRef', 'deepinfra');
|
||||||
expect(providerUpdate.storagePatch).toEqual({
|
expect(providerUpdate.storagePatch).toEqual({
|
||||||
ttsProvider: 'deepinfra',
|
providerRef: 'deepinfra',
|
||||||
voice: '',
|
voice: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,16 @@ import {
|
||||||
test.describe('tts segment helpers', () => {
|
test.describe('tts segment helpers', () => {
|
||||||
test('builds stable settings hash', () => {
|
test('builds stable settings hash', () => {
|
||||||
const a = buildTtsSegmentSettingsHash({
|
const a = buildTtsSegmentSettingsHash({
|
||||||
ttsProvider: 'openai',
|
providerRef: 'openai',
|
||||||
|
providerType: 'openai',
|
||||||
ttsModel: 'gpt-4o-mini-tts',
|
ttsModel: 'gpt-4o-mini-tts',
|
||||||
voice: 'alloy',
|
voice: 'alloy',
|
||||||
nativeSpeed: 1,
|
nativeSpeed: 1,
|
||||||
ttsInstructions: 'calm',
|
ttsInstructions: 'calm',
|
||||||
});
|
});
|
||||||
const b = buildTtsSegmentSettingsHash({
|
const b = buildTtsSegmentSettingsHash({
|
||||||
ttsProvider: 'openai',
|
providerRef: 'openai',
|
||||||
|
providerType: 'openai',
|
||||||
ttsModel: 'gpt-4o-mini-tts',
|
ttsModel: 'gpt-4o-mini-tts',
|
||||||
voice: 'alloy',
|
voice: 'alloy',
|
||||||
nativeSpeed: 1,
|
nativeSpeed: 1,
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ const SHARED: SharedProviderEntry[] = [
|
||||||
test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
||||||
test('restrict mode exposes only shared providers', () => {
|
test('restrict mode exposes only shared providers', () => {
|
||||||
const vm = resolveTtsSettingsViewModel({
|
const vm = resolveTtsSettingsViewModel({
|
||||||
provider: 'shared-openai',
|
providerRef: 'shared-openai',
|
||||||
|
providerType: 'unknown',
|
||||||
modelValue: 'gpt-4o-mini-tts',
|
modelValue: 'gpt-4o-mini-tts',
|
||||||
customModelInput: '',
|
customModelInput: '',
|
||||||
showAllDeepInfra: false,
|
showAllDeepInfra: false,
|
||||||
|
showAllProviderModels: true,
|
||||||
sharedProviders: SHARED,
|
sharedProviders: SHARED,
|
||||||
allowBuiltInProviders: false,
|
allowBuiltInProviders: false,
|
||||||
});
|
});
|
||||||
|
|
@ -37,23 +39,28 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
||||||
|
|
||||||
test('invalid provider falls back to first available option', () => {
|
test('invalid provider falls back to first available option', () => {
|
||||||
const vm = resolveTtsSettingsViewModel({
|
const vm = resolveTtsSettingsViewModel({
|
||||||
provider: 'missing-provider',
|
providerRef: 'missing-provider',
|
||||||
modelValue: 'gpt-4o-mini-tts',
|
providerType: 'unknown',
|
||||||
|
modelValue: 'kokoro',
|
||||||
customModelInput: '',
|
customModelInput: '',
|
||||||
showAllDeepInfra: false,
|
showAllDeepInfra: false,
|
||||||
|
showAllProviderModels: true,
|
||||||
sharedProviders: SHARED,
|
sharedProviders: SHARED,
|
||||||
allowBuiltInProviders: false,
|
allowBuiltInProviders: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(vm.selectedSharedProvider?.slug).toBe('shared-openai');
|
expect(vm.selectedSharedProvider?.slug).toBe('shared-openai');
|
||||||
|
expect(vm.selectedModelId).toBe('gpt-4o-mini-tts');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('custom-model capable providers use custom mode for unknown model ids', () => {
|
test('custom-model capable providers use custom mode for unknown model ids', () => {
|
||||||
const vm = resolveTtsSettingsViewModel({
|
const vm = resolveTtsSettingsViewModel({
|
||||||
provider: 'shared-replicate',
|
providerRef: 'shared-replicate',
|
||||||
|
providerType: 'unknown',
|
||||||
modelValue: 'my-custom-model',
|
modelValue: 'my-custom-model',
|
||||||
customModelInput: '',
|
customModelInput: '',
|
||||||
showAllDeepInfra: false,
|
showAllDeepInfra: false,
|
||||||
|
showAllProviderModels: true,
|
||||||
sharedProviders: SHARED,
|
sharedProviders: SHARED,
|
||||||
allowBuiltInProviders: false,
|
allowBuiltInProviders: false,
|
||||||
});
|
});
|
||||||
|
|
@ -65,10 +72,12 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
||||||
|
|
||||||
test('non-custom providers fall back to preset model selection', () => {
|
test('non-custom providers fall back to preset model selection', () => {
|
||||||
const vm = resolveTtsSettingsViewModel({
|
const vm = resolveTtsSettingsViewModel({
|
||||||
provider: 'shared-openai',
|
providerRef: 'shared-openai',
|
||||||
|
providerType: 'unknown',
|
||||||
modelValue: 'not-in-presets',
|
modelValue: 'not-in-presets',
|
||||||
customModelInput: '',
|
customModelInput: '',
|
||||||
showAllDeepInfra: false,
|
showAllDeepInfra: false,
|
||||||
|
showAllProviderModels: true,
|
||||||
sharedProviders: SHARED,
|
sharedProviders: SHARED,
|
||||||
allowBuiltInProviders: false,
|
allowBuiltInProviders: false,
|
||||||
});
|
});
|
||||||
|
|
@ -77,4 +86,38 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
||||||
expect(vm.selectedModelId).not.toBe('custom');
|
expect(vm.selectedModelId).not.toBe('custom');
|
||||||
expect(vm.models.length).toBeGreaterThan(0);
|
expect(vm.models.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('legacy default-openai provider ref normalizes to first shared provider', () => {
|
||||||
|
const vm = resolveTtsSettingsViewModel({
|
||||||
|
providerRef: 'default-openai',
|
||||||
|
providerType: 'unknown',
|
||||||
|
modelValue: 'kokoro',
|
||||||
|
customModelInput: '',
|
||||||
|
showAllDeepInfra: false,
|
||||||
|
showAllProviderModels: true,
|
||||||
|
sharedProviders: SHARED,
|
||||||
|
allowBuiltInProviders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vm.selectedProviderRef).toBe('shared-openai');
|
||||||
|
expect(vm.selectedModelId).toBe('gpt-4o-mini-tts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showAllProviderModels=false locks model picker to provider default', () => {
|
||||||
|
const vm = resolveTtsSettingsViewModel({
|
||||||
|
providerRef: 'shared-replicate',
|
||||||
|
providerType: 'unknown',
|
||||||
|
modelValue: 'my-custom-model',
|
||||||
|
customModelInput: 'my-custom-model',
|
||||||
|
showAllDeepInfra: false,
|
||||||
|
showAllProviderModels: false,
|
||||||
|
sharedProviders: SHARED,
|
||||||
|
allowBuiltInProviders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vm.models).toEqual([{ id: 'owner/model:ver', name: 'owner/model:ver' }]);
|
||||||
|
expect(vm.supportsCustomModel).toBe(false);
|
||||||
|
expect(vm.selectedModelId).toBe('owner/model:ver');
|
||||||
|
expect(vm.canSubmit).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue