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`
|
||||
- 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.
|
||||
|
||||
|
|
@ -65,10 +65,10 @@ Runtime-editable settings, one row per key:
|
|||
| Key | What it controls |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
|
||||
| `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. |
|
||||
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
|
||||
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ Use Replicate's hosted TTS models as your provider.
|
|||
|
||||
```env
|
||||
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`):**
|
||||
|
||||
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.
|
||||
- `restrictUserApiKeys=true` to block user BYOK through the hosted server.
|
||||
- `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.
|
||||
- `enableAudiobookExport=true`.
|
||||
- `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`)
|
||||
- Runtime key: `defaultTtsProvider`
|
||||
|
||||
### NEXT_PUBLIC_DEFAULT_TTS_MODEL
|
||||
|
||||
Sets the default TTS model for new users.
|
||||
|
||||
- Default: `kokoro`
|
||||
- Example values: `hexgrad/Kokoro-82M`, `tts-1`
|
||||
- Runtime key: `defaultTtsModel`
|
||||
`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**.
|
||||
|
||||
### 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 { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
||||
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 type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||
|
|
@ -99,7 +100,11 @@ function s3NotConfiguredResponse(): NextResponse {
|
|||
}
|
||||
|
||||
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, nativeSpeed: 1 };
|
||||
}
|
||||
|
|
@ -118,7 +123,8 @@ function isAudiobookGenerationSettings(value: unknown): value is AudiobookGenera
|
|||
}
|
||||
|
||||
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.voice === 'string'
|
||||
&& isFiniteNumber(record.nativeSpeed)
|
||||
|
|
@ -364,7 +370,8 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
if (normalizedExistingSettings && hasChapters && incomingSettings) {
|
||||
const mismatch =
|
||||
normalizedExistingSettings.ttsProvider !== incomingSettings.ttsProvider ||
|
||||
normalizedExistingSettings.providerRef !== incomingSettings.providerRef ||
|
||||
normalizedExistingSettings.providerType !== incomingSettings.providerType ||
|
||||
normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel ||
|
||||
normalizedExistingSettings.voice !== incomingSettings.voice ||
|
||||
normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
|
||||
|
|
@ -409,7 +416,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
const requestedProvider = request.headers.get('x-tts-provider')
|
||||
|| mergedSettings?.ttsProvider
|
||||
|| mergedSettings?.providerRef
|
||||
|| 'openai';
|
||||
providerForError = requestedProvider;
|
||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
|
|
@ -433,15 +440,21 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
const provider = credResolved.provider;
|
||||
if (!isBuiltInTtsProviderId(provider)) {
|
||||
return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 });
|
||||
}
|
||||
const openApiKey = credResolved.apiKey || 'none';
|
||||
const openApiBaseUrl = credResolved.baseUrl;
|
||||
const model = mergedSettings?.ttsModel;
|
||||
const voice = mergedSettings?.voice
|
||||
|| (provider === 'openai'
|
||||
? 'alloy'
|
||||
: provider === 'deepinfra'
|
||||
? 'af_bella'
|
||||
: 'af_sarah');
|
||||
const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider;
|
||||
const model = resolveTtsModelForProvider({
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType: provider,
|
||||
model: mergedSettings?.ttsModel,
|
||||
sharedProviders: credResolved.adminRecord ? [credResolved.adminRecord] : [],
|
||||
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||
});
|
||||
const voice = mergedSettings?.voice || defaultVoiceForProviderType(provider);
|
||||
const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1;
|
||||
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
|
||||
const instructions = resolveEffectiveTtsInstructions({
|
||||
|
|
@ -586,7 +599,14 @@ export async function POST(request: NextRequest) {
|
|||
if (!normalizedExistingSettings && incomingSettings) {
|
||||
const settingsToPersist: AudiobookGenerationSettings = {
|
||||
...incomingSettings,
|
||||
...(supportsTtsInstructions(incomingSettings.ttsModel)
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType: provider,
|
||||
ttsModel: model,
|
||||
...(resolveTtsProviderModelPolicy({
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType: provider,
|
||||
model,
|
||||
}).supportsInstructions
|
||||
? { ttsInstructions: instructions ?? '' }
|
||||
: { ttsInstructions: '' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
projectSegmentLocator,
|
||||
probeAudioDurationMsFromBuffer,
|
||||
} from '@/lib/server/tts/segments';
|
||||
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||
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 { alignAudioWithText } from '@/lib/server/whisper/alignment';
|
||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
|
||||
import type {
|
||||
TTSSegmentInput,
|
||||
TTSSegmentManifestItem,
|
||||
|
|
@ -50,14 +52,16 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d
|
|||
function parseSettings(value: unknown): TTSSegmentSettings | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
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.voice !== 'string') return null;
|
||||
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
|
||||
if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null;
|
||||
|
||||
return {
|
||||
ttsProvider: rec.ttsProvider,
|
||||
providerRef: rec.providerRef,
|
||||
providerType: rec.providerType,
|
||||
ttsModel: rec.ttsModel,
|
||||
voice: rec.voice,
|
||||
nativeSpeed: Number(rec.nativeSpeed),
|
||||
|
|
@ -169,7 +173,7 @@ export async function POST(request: NextRequest) {
|
|||
if (scope instanceof Response) return scope;
|
||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
const requestCreds = await resolveTtsCredentials({
|
||||
providerHeader: parsed.settings.ttsProvider,
|
||||
providerHeader: parsed.settings.providerRef,
|
||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
||||
baseUrlHeader: request.headers.get('x-openai-base-url'),
|
||||
fallbackProvider: runtimeConfig.defaultTtsProvider,
|
||||
|
|
@ -189,13 +193,28 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
// Normalize request settings to the effective generation settings so cache
|
||||
// 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,
|
||||
sharedProviders: requestCreds.adminRecord ? [requestCreds.adminRecord] : [],
|
||||
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||
});
|
||||
const effectiveInstructions = resolveEffectiveTtsInstructions({
|
||||
model: effectiveModel,
|
||||
requestInstructions: parsed.settings.ttsInstructions,
|
||||
sharedDefaultInstructions: requestCreds.adminRecord?.defaultInstructions,
|
||||
}) ?? '';
|
||||
const resolvedProviderType = isBuiltInTtsProviderId(requestCreds.provider)
|
||||
? requestCreds.provider
|
||||
: 'unknown';
|
||||
const effectiveSettings: TTSSegmentSettings = {
|
||||
...parsed.settings,
|
||||
providerRef: effectiveProviderRef,
|
||||
providerType: resolvedProviderType,
|
||||
ttsModel: effectiveModel,
|
||||
ttsInstructions: effectiveInstructions,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import type {
|
|||
TTSSegmentVariant,
|
||||
TTSSegmentsManifestResponse,
|
||||
} 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 dynamic = 'force-dynamic';
|
||||
|
|
@ -44,20 +46,31 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
|
|||
if (!raw || typeof raw !== 'object') return null;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
|
||||
const ttsProvider = typeof rec.ttsProvider === 'string'
|
||||
? rec.ttsProvider
|
||||
: typeof rec.provider === 'string' ? rec.provider : null;
|
||||
// Accept both current settings shape and canonicalized legacy keys from
|
||||
// persisted rows (e.g., SQLite stringified settings with `model/speed`).
|
||||
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'
|
||||
? 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 speedSource = rec.nativeSpeed ?? rec.speed;
|
||||
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
|
||||
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
|
||||
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
|
||||
|
||||
if (!ttsProvider || !ttsModel || !voice) return null;
|
||||
return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions };
|
||||
if (!providerRef || !providerType || !ttsModel || !voice) return null;
|
||||
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
|
||||
}
|
||||
|
||||
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||
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')
|
||||
|| resolved.adminRecord?.defaultModel
|
||||
|| 'tts-1';
|
||||
if (!isBuiltInTtsProviderId(resolved.provider)) {
|
||||
return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 });
|
||||
}
|
||||
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({
|
||||
provider: resolved.provider,
|
||||
model: requestedModel,
|
||||
|
|
@ -50,10 +62,18 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ voices });
|
||||
} catch (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 provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai';
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { userPreferences } from '@/db/schema';
|
|||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||
import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { listAdminProviders } from '@/lib/server/admin/providers';
|
||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -17,17 +21,54 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function parseStoredPreferences(value: unknown): SyncedPreferencesPatch {
|
||||
if (!value) return {};
|
||||
interface PreferenceNormalizationContext {
|
||||
defaultProviderRef: string;
|
||||
showAllProviderModels: boolean;
|
||||
sharedProviders: Array<{
|
||||
slug: string;
|
||||
providerType: TtsProviderId;
|
||||
defaultModel: string | null;
|
||||
defaultInstructions: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
||||
const [runtimeConfig, providers] = await Promise.all([
|
||||
getResolvedRuntimeConfig(),
|
||||
listAdminProviders(),
|
||||
]);
|
||||
return {
|
||||
defaultProviderRef: runtimeConfig.defaultTtsProvider,
|
||||
showAllProviderModels: runtimeConfig.showAllProviderModels,
|
||||
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') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {};
|
||||
return isRecord(parsed)
|
||||
? sanitizePreferencesPatch(parsed, context, { fillMissingProvider: true })
|
||||
: { patch: {}, migrated: false };
|
||||
} 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> {
|
||||
|
|
@ -41,25 +82,52 @@ function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
|||
return out;
|
||||
}
|
||||
|
||||
function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
||||
if (!isRecord(input)) return {};
|
||||
function sanitizePreferencesPatch(
|
||||
input: unknown,
|
||||
context: PreferenceNormalizationContext,
|
||||
options: { fillMissingProvider: boolean },
|
||||
): { patch: SyncedPreferencesPatch; migrated: boolean } {
|
||||
if (!isRecord(input)) return { patch: {}, migrated: false };
|
||||
|
||||
const rec = input as Record<string, unknown>;
|
||||
const out: SyncedPreferencesPatch = {};
|
||||
let migrated = false;
|
||||
|
||||
const legacyProviderRef = typeof rec.ttsProvider === 'string'
|
||||
? rec.ttsProvider
|
||||
: typeof rec.provider === 'string'
|
||||
? rec.provider
|
||||
: '';
|
||||
const rawProviderRef = typeof rec.providerRef === 'string' ? rec.providerRef : legacyProviderRef;
|
||||
const normalizedProviderRef = normalizeLegacyProviderRef(rawProviderRef, context.defaultProviderRef);
|
||||
const providerDefaults = resolveProviderDefaults({
|
||||
providerRef: normalizedProviderRef || context.defaultProviderRef,
|
||||
providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown',
|
||||
sharedProviders: context.sharedProviders,
|
||||
fallbackProviderRef: context.defaultProviderRef,
|
||||
});
|
||||
const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string';
|
||||
if (hasLegacyProviderKey || rawProviderRef !== providerDefaults.providerRef) migrated = true;
|
||||
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in input)) continue;
|
||||
const value = input[key];
|
||||
if (!(key in rec)) continue;
|
||||
const value = rec[key];
|
||||
|
||||
switch (key) {
|
||||
case 'viewType':
|
||||
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
|
||||
break;
|
||||
case 'voice':
|
||||
case 'ttsProvider':
|
||||
case 'ttsModel':
|
||||
case 'ttsInstructions':
|
||||
if (typeof value === 'string') out[key] = value;
|
||||
break;
|
||||
case 'providerRef':
|
||||
out[key] = providerDefaults.providerRef;
|
||||
break;
|
||||
case 'providerType':
|
||||
out[key] = providerDefaults.providerType;
|
||||
break;
|
||||
case 'voiceSpeed':
|
||||
case 'audioPlayerSpeed':
|
||||
case 'segmentPreloadDepthPages':
|
||||
|
|
@ -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 {
|
||||
|
|
@ -99,6 +200,7 @@ function normalizeClientUpdatedAtMs(value: unknown): number {
|
|||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const normalizationContext = await loadPreferenceNormalizationContext();
|
||||
const scope = await resolveUserStateScope(req);
|
||||
if (scope instanceof Response) return scope;
|
||||
|
||||
|
|
@ -112,9 +214,29 @@ export async function GET(req: NextRequest) {
|
|||
.limit(1);
|
||||
|
||||
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);
|
||||
|
||||
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({
|
||||
preferences: storedPatch,
|
||||
clientUpdatedAtMs,
|
||||
|
|
@ -128,13 +250,18 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const normalizationContext = await loadPreferenceNormalizationContext();
|
||||
const scope = await resolveUserStateScope(req);
|
||||
if (scope instanceof Response) return scope;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as
|
||||
| { patch?: unknown; clientUpdatedAtMs?: unknown }
|
||||
| null;
|
||||
const patch = sanitizePreferencesPatch(body?.patch);
|
||||
const patch = sanitizePreferencesPatch(
|
||||
body?.patch,
|
||||
normalizationContext,
|
||||
{ fillMissingProvider: false },
|
||||
).patch;
|
||||
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
|
|
@ -151,7 +278,7 @@ export async function PUT(req: NextRequest) {
|
|||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
||||
const existingPatch = parseStoredPreferences(existing?.dataJson);
|
||||
const existingPatch = parseStoredPreferences(existing?.dataJson, normalizationContext).patch;
|
||||
|
||||
if (existing && clientUpdatedAtMs < existingUpdated) {
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { useConfig } from '@/contexts/ConfigContext';
|
|||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||
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 {
|
||||
getAudiobookStatus,
|
||||
|
|
@ -49,7 +49,7 @@ export function AudiobookExportModal({
|
|||
onGenerateAudiobook,
|
||||
onRegenerateChapter
|
||||
}: 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 { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
|
@ -74,7 +74,11 @@ export function AudiobookExportModal({
|
|||
const formatSpeed = useCallback((speed: number) => {
|
||||
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 hasExistingAudiobook = Boolean(bookId) || chapters.length > 0;
|
||||
|
|
@ -113,15 +117,16 @@ export function AudiobookExportModal({
|
|||
const nextVoice = audiobookVoice || configVoice || availableVoices[0] || '';
|
||||
if (!nextVoice) return null;
|
||||
return {
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
providerType,
|
||||
ttsModel,
|
||||
voice: nextVoice,
|
||||
nativeSpeed: effectiveNativeSpeed,
|
||||
postSpeed,
|
||||
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) => {
|
||||
if (soft) {
|
||||
|
|
@ -514,7 +519,11 @@ export function AudiobookExportModal({
|
|||
<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-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`
|
||||
: 'Not supported'}
|
||||
</div>
|
||||
|
|
@ -538,7 +547,7 @@ export function AudiobookExportModal({
|
|||
availableVoices={availableVoices}
|
||||
voice={audiobookVoice}
|
||||
onChangeVoice={setAudiobookVoice}
|
||||
ttsProvider={ttsProvider}
|
||||
providerType={providerType}
|
||||
ttsModel={ttsModel}
|
||||
dropdownDirection="down"
|
||||
variant="field"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,17 @@ import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/a
|
|||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||
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 { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
|
||||
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
||||
|
|
@ -105,6 +115,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
const runtimeConfig = useRuntimeConfig();
|
||||
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
|
||||
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
|
||||
const showAllProviderModels = runtimeConfig.showAllProviderModels;
|
||||
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
|
||||
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -113,11 +124,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
const { theme, setTheme, applyCustomColors } = useTheme();
|
||||
const [customColors, setCustomColors] = useState<CustomThemeColors>(getCustomThemeColors);
|
||||
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 [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||
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 [customModelInput, setCustomModelInput] = useState('');
|
||||
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
||||
|
|
@ -154,17 +166,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
selectedModelId,
|
||||
canSubmit,
|
||||
selectedSharedProvider,
|
||||
selectedProviderRef,
|
||||
selectedProviderType,
|
||||
} = useMemo(() => resolveTtsSettingsViewModel({
|
||||
provider: localTTSProvider,
|
||||
providerRef: localProviderRef,
|
||||
providerType: localProviderType,
|
||||
apiKey: localApiKey,
|
||||
modelValue,
|
||||
customModelInput,
|
||||
showAllDeepInfra,
|
||||
showAllProviderModels,
|
||||
sharedProviders,
|
||||
allowBuiltInProviders: !restrictUserApiKeys,
|
||||
}), [localTTSProvider, localApiKey, modelValue, customModelInput, showAllDeepInfra, sharedProviders, restrictUserApiKeys]);
|
||||
}), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllDeepInfra, showAllProviderModels, sharedProviders, restrictUserApiKeys]);
|
||||
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 appConfig = await getAppConfig();
|
||||
|
|
@ -187,10 +203,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
useEffect(() => {
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalTTSProvider(ttsProvider);
|
||||
setLocalProviderRef(providerRef);
|
||||
setLocalProviderType(providerType);
|
||||
setModelValue(ttsModel);
|
||||
setLocalTTSInstructions(ttsInstructions);
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions]);
|
||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authEnabled) {
|
||||
|
|
@ -216,12 +233,31 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
}, [modelValue, ttsModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restrictUserApiKeys) return;
|
||||
if (selectedProviderOption) return;
|
||||
if (ttsProviders.length > 0) {
|
||||
setLocalTTSProvider(ttsProviders[0].id);
|
||||
if (ttsProviders.length === 0) return;
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
|
|
@ -364,7 +400,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
setIsOpen(false);
|
||||
setLocalApiKey(apiKey);
|
||||
setLocalBaseUrl(baseUrl);
|
||||
setLocalTTSProvider(ttsProvider);
|
||||
setLocalProviderRef(providerRef);
|
||||
setLocalProviderType(providerType);
|
||||
setModelValue(ttsModel);
|
||||
setLocalTTSInstructions(ttsInstructions);
|
||||
if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') {
|
||||
|
|
@ -372,7 +409,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
} else {
|
||||
setCustomModelInput('');
|
||||
}
|
||||
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]);
|
||||
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels]);
|
||||
|
||||
const [systemIsDark, setSystemIsDark] = useState(
|
||||
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 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 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
|
||||
&& !isSharedSelected
|
||||
&& effectiveProviderType !== 'replicate'
|
||||
&& (effectiveProviderType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
|
||||
&& providerModelPolicy.isResolvedProviderType
|
||||
&& providerModelPolicy.providerType !== 'replicate'
|
||||
&& (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
|
||||
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
|
||||
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
|
||||
const selectedModelVersion = selectedModel?.id?.includes(':')
|
||||
|
|
@ -551,26 +599,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
<Listbox
|
||||
value={selectedProviderOption!}
|
||||
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) {
|
||||
// Shared admin provider — credentials live on the server.
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
const shared = sharedProviders.find((p) => p.slug === provider.id);
|
||||
if (shared?.defaultModel) setModelValue(shared.defaultModel);
|
||||
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');
|
||||
} else if (isBuiltInTtsProviderId(provider.providerType)) {
|
||||
setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType));
|
||||
}
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
|
|
@ -652,7 +695,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
type="password"
|
||||
value={localApiKey}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -665,6 +708,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
|
||||
<div className="space-y-1.5">
|
||||
<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">
|
||||
<Listbox
|
||||
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
||||
|
|
@ -752,7 +800,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{supportsTtsInstructions(modelValue) && (
|
||||
{providerModelPolicy.supportsInstructions && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||
<textarea
|
||||
|
|
@ -769,15 +817,18 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
type="button"
|
||||
className={`${btnSecondary} px-4 py-2`}
|
||||
onClick={async () => {
|
||||
const sharedDefault = sharedProviders.find(
|
||||
(p) => p.slug === runtimeConfig.defaultTtsProvider,
|
||||
);
|
||||
const defaults = resolveProviderDefaults({
|
||||
providerRef: runtimeConfig.defaultTtsProvider,
|
||||
sharedProviders,
|
||||
fallbackProviderRef: 'custom-openai',
|
||||
});
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
setLocalTTSProvider(runtimeConfig.defaultTtsProvider);
|
||||
setModelValue(runtimeConfig.defaultTtsModel);
|
||||
setLocalProviderRef(defaults.providerRef);
|
||||
setLocalProviderType(defaults.providerType);
|
||||
setModelValue(defaults.defaultModel);
|
||||
setCustomModelInput('');
|
||||
setLocalTTSInstructions(sharedDefault?.defaultInstructions ?? '');
|
||||
setLocalTTSInstructions(defaults.defaultInstructions);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
|
|
@ -788,12 +839,20 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
className={`${btnPrimary} px-4 py-2`}
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
const defaults = resolveProviderDefaults({
|
||||
providerRef: selectedProviderRef,
|
||||
providerType: selectedProviderType,
|
||||
sharedProviders,
|
||||
});
|
||||
await updateConfig({
|
||||
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
|
||||
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
|
||||
});
|
||||
await updateConfigKey('ttsProvider', localTTSProvider);
|
||||
const finalModel = selectedModelId === 'custom' ? customModelInput.trim() : modelValue;
|
||||
await updateConfigKey('providerRef', selectedProviderRef);
|
||||
await updateConfigKey('providerType', selectedProviderType);
|
||||
const finalModel = showAllProviderModels
|
||||
? (selectedModelId === 'custom' ? customModelInput.trim() : modelValue)
|
||||
: defaults.defaultModel;
|
||||
await updateConfigKey('ttsModel', finalModel);
|
||||
await updateConfigKey('ttsInstructions', localTTSInstructions);
|
||||
setIsOpen(false);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
|
|
@ -19,13 +18,8 @@ import {
|
|||
ToggleRow,
|
||||
btnPrimary,
|
||||
btnSecondary,
|
||||
inputClass,
|
||||
} from '@/components/admin/ui';
|
||||
import {
|
||||
providerSupportsCustomModel,
|
||||
resolveProviderModels,
|
||||
type TtsProviderId,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||
|
||||
type RuntimeConfigSource = 'env-seed' | 'admin' | 'default';
|
||||
|
|
@ -47,7 +41,6 @@ export function AdminFeaturesPanel() {
|
|||
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||
const [dirty, setDirty] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [customModelInput, setCustomModelInput] = useState('');
|
||||
const { providers: sharedProviders } = useSharedProviders();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
|
@ -87,7 +80,6 @@ export function AdminFeaturesPanel() {
|
|||
});
|
||||
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
|
||||
toast.success('Reset to env default');
|
||||
setCustomModelInput('');
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
@ -123,10 +115,9 @@ export function AdminFeaturesPanel() {
|
|||
if (!data) return;
|
||||
setDraft({ ...data.values });
|
||||
setDirty(new Set());
|
||||
setCustomModelInput('');
|
||||
};
|
||||
|
||||
// --- Provider + model option resolution (mirrors the user-facing TTS tab) ---
|
||||
// --- Provider option resolution ---
|
||||
|
||||
const providerOptions = useMemo<ProviderOption[]>(() => {
|
||||
return sharedProviders.map((entry) => ({
|
||||
|
|
@ -153,71 +144,10 @@ export function AdminFeaturesPanel() {
|
|||
shared: true,
|
||||
} as ProviderOption
|
||||
: 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 selectedModelDefinition = modelDefinitions.find(
|
||||
(m) => m.id === selectedModelDropdownId,
|
||||
);
|
||||
|
||||
const handleProviderChange = (opt: ProviderOption) => {
|
||||
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 ---
|
||||
|
|
@ -248,7 +178,7 @@ export function AdminFeaturesPanel() {
|
|||
<div className="space-y-4">
|
||||
<Section
|
||||
title="TTS defaults"
|
||||
subtitle="What new users start with, and how they can override it."
|
||||
subtitle="What new users start with."
|
||||
>
|
||||
{/* Provider picker */}
|
||||
<Card className="space-y-1.5">
|
||||
|
|
@ -256,7 +186,7 @@ export function AdminFeaturesPanel() {
|
|||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
||||
|
|
@ -311,80 +241,6 @@ export function AdminFeaturesPanel() {
|
|||
)}
|
||||
</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 */}
|
||||
<ToggleRow
|
||||
label="Restrict user API keys (recommended)"
|
||||
|
|
@ -415,6 +271,13 @@ export function AdminFeaturesPanel() {
|
|||
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons
|
|||
import {
|
||||
providerSupportsCustomModel,
|
||||
resolveProviderModels,
|
||||
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||
supportsTtsInstructions,
|
||||
type TtsModelDefinition,
|
||||
type TtsProviderId,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
import {
|
||||
defaultBaseUrlForProviderType,
|
||||
defaultModelForProviderType,
|
||||
resolveTtsProviderModelPolicy,
|
||||
} from '@/lib/shared/tts-provider-policy';
|
||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||
import {
|
||||
Badge,
|
||||
|
|
@ -61,12 +64,7 @@ interface FormState {
|
|||
enabled: boolean;
|
||||
}
|
||||
|
||||
function providerDefaultModel(providerType: ProviderType): 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';
|
||||
}
|
||||
const providerDefaultModel = defaultModelForProviderType;
|
||||
|
||||
function createEmptyForm(): FormState {
|
||||
return {
|
||||
|
|
@ -215,13 +213,16 @@ export function AdminProvidersPanel() {
|
|||
? 'custom'
|
||||
: modelDefinitions[0]?.id ?? '';
|
||||
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
||||
const baseUrlPlaceholder = form.providerType === 'openai'
|
||||
? 'https://api.openai.com/v1'
|
||||
: form.providerType === 'deepinfra'
|
||||
? 'https://api.deepinfra.com/v1/openai'
|
||||
: 'https://your-tts-host/v1';
|
||||
const modelSupportsInstructions = (model: string) => resolveTtsProviderModelPolicy({
|
||||
providerRef: form.slug,
|
||||
providerType: form.providerType,
|
||||
model,
|
||||
}).supportsInstructions;
|
||||
const baseUrlPlaceholder = form.providerType === 'custom-openai'
|
||||
? 'https://your-tts-host/v1'
|
||||
: defaultBaseUrlForProviderType(form.providerType);
|
||||
const shouldShowBaseUrl = form.providerType === 'custom-openai';
|
||||
const shouldShowInstructions = supportsTtsInstructions(form.defaultModel);
|
||||
const shouldShowInstructions = modelSupportsInstructions(form.defaultModel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsCustomModel) {
|
||||
|
|
@ -237,10 +238,10 @@ export function AdminProvidersPanel() {
|
|||
}, [supportsCustomModel, modelIsPreset, form.defaultModel, customModelInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (supportsTtsInstructions(form.defaultModel)) return;
|
||||
if (modelSupportsInstructions(form.defaultModel)) return;
|
||||
if (!form.defaultInstructions) return;
|
||||
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
|
||||
}, [form.defaultModel, form.defaultInstructions]);
|
||||
}, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug]);
|
||||
|
||||
return (
|
||||
<Section
|
||||
|
|
@ -299,7 +300,7 @@ export function AdminProvidersPanel() {
|
|||
providerType: opt.value,
|
||||
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
|
||||
defaultModel: nextModel,
|
||||
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||
});
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
|
|
@ -356,14 +357,14 @@ export function AdminProvidersPanel() {
|
|||
setForm({
|
||||
...form,
|
||||
defaultModel: nextModel,
|
||||
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm({
|
||||
...form,
|
||||
defaultModel: modelId,
|
||||
defaultInstructions: supportsTtsInstructions(modelId) ? form.defaultInstructions : '',
|
||||
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
|
||||
});
|
||||
setCustomModelInput('');
|
||||
}}
|
||||
|
|
@ -426,7 +427,7 @@ export function AdminProvidersPanel() {
|
|||
setForm({
|
||||
...form,
|
||||
defaultModel: nextModel,
|
||||
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
||||
});
|
||||
}}
|
||||
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 { useConfig } from '@/contexts/ConfigContext';
|
||||
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 = ({
|
||||
setSpeedAndRestart,
|
||||
|
|
@ -13,8 +13,12 @@ export const SpeedControl = ({
|
|||
setSpeedAndRestart: (speed: number) => void;
|
||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||
}) => {
|
||||
const { voiceSpeed, audioPlayerSpeed, ttsProvider, ttsModel } = useConfig();
|
||||
const nativeSpeedSupported = supportsNativeModelSpeed(ttsProvider, ttsModel);
|
||||
const { voiceSpeed, audioPlayerSpeed, providerType, ttsModel } = useConfig();
|
||||
const nativeSpeedSupported = resolveTtsProviderModelPolicy({
|
||||
providerRef: '',
|
||||
providerType,
|
||||
model: ttsModel,
|
||||
}).supportsNativeModelSpeed;
|
||||
|
||||
const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed);
|
||||
const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
|||
availableVoices: string[];
|
||||
setVoiceAndRestart: (voice: string) => void;
|
||||
}) => {
|
||||
const { ttsModel, ttsProvider } = useConfig();
|
||||
const { ttsModel, providerType } = useConfig();
|
||||
const { voice } = useTTS();
|
||||
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
|||
availableVoices={availableVoices}
|
||||
voice={voice || ''}
|
||||
onChangeVoice={onChangeVoice}
|
||||
ttsProvider={ttsProvider}
|
||||
providerType={providerType}
|
||||
ttsModel={ttsModel}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ import {
|
|||
} from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
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({
|
||||
availableVoices,
|
||||
voice,
|
||||
onChangeVoice,
|
||||
ttsProvider,
|
||||
providerType,
|
||||
ttsModel,
|
||||
dropdownDirection = 'up',
|
||||
variant = 'compact',
|
||||
|
|
@ -22,7 +24,7 @@ export function VoicesControlBase({
|
|||
availableVoices: string[];
|
||||
voice: string;
|
||||
onChangeVoice: (voice: string) => void;
|
||||
ttsProvider: string;
|
||||
providerType: TtsProviderType;
|
||||
ttsModel: string;
|
||||
dropdownDirection?: 'up' | 'down';
|
||||
variant?: 'compact' | 'field';
|
||||
|
|
@ -43,11 +45,16 @@ export function VoicesControlBase({
|
|||
? 'h-4 w-4 text-muted'
|
||||
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
|
||||
|
||||
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||
providerRef: '',
|
||||
providerType,
|
||||
model: ttsModel,
|
||||
});
|
||||
const dropdownWidth = variant === 'field'
|
||||
? 'w-full'
|
||||
: isKokoroModel(ttsModel) && getMaxVoicesForProvider(ttsProvider, ttsModel) > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
|
||||
const isKokoro = isKokoroModel(ttsModel);
|
||||
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
|
||||
: providerModelPolicy.isKokoroModel && providerModelPolicy.maxVoices > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
|
||||
const isKokoro = providerModelPolicy.isKokoroModel;
|
||||
const maxVoices = providerModelPolicy.maxVoices;
|
||||
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ function formatVoiceLabel(settings: TTSSegmentSettings | null): string {
|
|||
function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
return (
|
||||
a.ttsProvider === b.ttsProvider
|
||||
a.providerRef === b.providerRef
|
||||
&& a.providerType === b.providerType
|
||||
&& a.ttsModel === b.ttsModel
|
||||
&& a.voice === b.voice
|
||||
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
||||
|
|
@ -182,7 +183,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
activeReaderType,
|
||||
} = useTTS();
|
||||
const {
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
providerType,
|
||||
ttsModel,
|
||||
voice,
|
||||
voiceSpeed,
|
||||
|
|
@ -241,12 +243,13 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
const didAutoScrollOnOpenRef = useRef(false);
|
||||
|
||||
const activeSettings = useMemo<TTSSegmentSettings>(() => ({
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
providerType,
|
||||
ttsModel,
|
||||
voice,
|
||||
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
||||
ttsInstructions: ttsInstructions || '',
|
||||
}), [ttsProvider, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
||||
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
||||
|
||||
const loadManifest = useCallback(async (
|
||||
mode: 'reset' | 'append' = 'reset',
|
||||
|
|
@ -381,7 +384,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
|
||||
if (!settings) return;
|
||||
await Promise.all([
|
||||
updateConfigKey('ttsProvider', settings.ttsProvider),
|
||||
updateConfigKey('providerRef', settings.providerRef),
|
||||
updateConfigKey('providerType', settings.providerType),
|
||||
updateConfigKey('ttsModel', settings.ttsModel),
|
||||
updateConfigKey('voice', settings.voice),
|
||||
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)}
|
||||
title={
|
||||
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'
|
||||
}
|
||||
className={[
|
||||
|
|
@ -858,7 +862,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
<Row label="settings">
|
||||
<span className="font-mono text-[10px] text-foreground">
|
||||
{v.settings
|
||||
? `${v.settings.ttsProvider} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
|
||||
? `${v.settings.providerRef} · ${v.settings.ttsModel} · ${formatVoiceLabel(v.settings)}`
|
||||
: 'unknown'}
|
||||
</span>
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
|
||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
|
||||
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
|
|
@ -11,6 +13,7 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
||||
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||
import toast from 'react-hot-toast';
|
||||
export type { ViewType } from '@/types/config';
|
||||
|
||||
|
|
@ -34,7 +37,8 @@ interface ConfigContextType {
|
|||
footerMargin: number;
|
||||
leftMargin: number;
|
||||
rightMargin: number;
|
||||
ttsProvider: string;
|
||||
providerRef: string;
|
||||
providerType: AppConfigValues['providerType'];
|
||||
ttsModel: string;
|
||||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
|
|
@ -61,12 +65,22 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [isDBReady, setIsDBReady] = useState(false);
|
||||
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
|
||||
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
|
||||
const showAllProviderModels = useFeatureFlag('showAllProviderModels');
|
||||
const didRunStartupMigrations = useRef(false);
|
||||
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { providers: sharedProviders } = useSharedProviders();
|
||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||
const providerResetDefaults = useMemo(() => {
|
||||
return resolveProviderDefaults({
|
||||
providerRef: APP_CONFIG_DEFAULTS.providerRef,
|
||||
providerType: APP_CONFIG_DEFAULTS.providerType,
|
||||
sharedProviders,
|
||||
fallbackProviderRef: 'custom-openai',
|
||||
});
|
||||
}, [sharedProviders]);
|
||||
|
||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||
if (!authEnabled || sessionKey === 'no-session') return;
|
||||
|
|
@ -211,14 +225,17 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
|
||||
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
|
||||
}
|
||||
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
|
||||
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
|
||||
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
|
||||
resetPatch.providerRef = providerResetDefaults.providerRef;
|
||||
}
|
||||
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
|
||||
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
|
||||
if (appConfig.providerType !== providerResetDefaults.providerType) {
|
||||
resetPatch.providerType = providerResetDefaults.providerType;
|
||||
}
|
||||
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
|
||||
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
|
||||
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
|
||||
resetPatch.ttsModel = providerResetDefaults.defaultModel;
|
||||
}
|
||||
if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
|
||||
resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
|
||||
}
|
||||
// Keep voice selection state intact so player/Audiobook voice pickers still
|
||||
// work when the TTS providers tab is hidden. This reset is only for provider
|
||||
|
|
@ -230,13 +247,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
console.warn('Failed to clear hidden TTS provider settings:', error);
|
||||
});
|
||||
queueSyncedPreferencePatch(resetPatch);
|
||||
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch]);
|
||||
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
|
||||
|
||||
const resetPatch: Partial<AppConfigRow> = {};
|
||||
const builtInProviderIds = new Set(['custom-openai', 'replicate', 'deepinfra', 'openai']);
|
||||
|
||||
if (appConfig.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) {
|
||||
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
|
||||
}
|
||||
if (builtInProviderIds.has(appConfig.ttsProvider)) {
|
||||
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
|
||||
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
|
||||
if (isBuiltInTtsProviderId(appConfig.providerRef)) {
|
||||
if (appConfig.providerRef !== providerResetDefaults.providerRef) {
|
||||
resetPatch.providerRef = providerResetDefaults.providerRef;
|
||||
}
|
||||
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
|
||||
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
|
||||
if (appConfig.providerType !== providerResetDefaults.providerType) {
|
||||
resetPatch.providerType = providerResetDefaults.providerType;
|
||||
}
|
||||
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
|
||||
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
|
||||
if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
|
||||
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);
|
||||
});
|
||||
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(() => {
|
||||
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
|
||||
|
|
@ -310,7 +346,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
providerType: _persistedProviderType,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
savedVoices,
|
||||
|
|
@ -323,6 +360,46 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
epubHighlightEnabled,
|
||||
epubWordHighlightEnabled,
|
||||
} = 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
|
||||
|
|
@ -360,7 +437,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
try {
|
||||
setIsLoading(true);
|
||||
const { storagePatch, syncPatch } = applyConfigUpdate({
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
providerType,
|
||||
ttsModel,
|
||||
savedVoices,
|
||||
}, key, value);
|
||||
|
|
@ -368,7 +446,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
await updateAppConfig(storagePatch);
|
||||
if (
|
||||
key === 'voice' ||
|
||||
key === 'ttsProvider' ||
|
||||
key === 'providerRef' ||
|
||||
key === 'providerType' ||
|
||||
key === 'ttsModel' ||
|
||||
key === 'savedVoices' ||
|
||||
syncedPreferenceKeys.has(String(key))
|
||||
|
|
@ -401,7 +480,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
providerType,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
savedVoices,
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
ttsSegmentMaxBlockLength,
|
||||
smartSentenceSplitting,
|
||||
epubTheme,
|
||||
|
|
@ -878,7 +878,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
defaultProvider: providerRef,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
|
|
@ -890,7 +890,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter of the audiobook
|
||||
|
|
@ -911,7 +911,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
defaultProvider: providerRef,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -921,7 +921,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating chapter:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
const setRendition = useCallback((rendition: Rendition) => {
|
||||
bookRef.current = rendition.book;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
rightMargin,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
ttsProvider,
|
||||
providerRef,
|
||||
smartSentenceSplitting,
|
||||
segmentPreloadDepthPages,
|
||||
ttsSegmentMaxBlockLength,
|
||||
|
|
@ -442,7 +442,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
adapter: audiobookAdapter,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
defaultProvider: providerRef,
|
||||
onProgress,
|
||||
signal,
|
||||
onChapterComplete,
|
||||
|
|
@ -454,7 +454,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
/**
|
||||
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||
|
|
@ -475,7 +475,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
signal,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
defaultProvider: ttsProvider,
|
||||
defaultProvider: providerRef,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -485,7 +485,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error regenerating page:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [audiobookAdapter, apiKey, baseUrl, ttsProvider]);
|
||||
}, [audiobookAdapter, apiKey, baseUrl, providerRef]);
|
||||
|
||||
/**
|
||||
* Effect hook to initialize TTS as non-EPUB mode
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|||
*/
|
||||
export interface RuntimeConfig {
|
||||
defaultTtsProvider: string;
|
||||
defaultTtsModel: string;
|
||||
restrictUserApiKeys: boolean;
|
||||
enableTtsProvidersTab: boolean;
|
||||
enableWordHighlight: boolean;
|
||||
|
|
@ -21,11 +20,11 @@ export interface RuntimeConfig {
|
|||
enableDocxConversion: boolean;
|
||||
enableDestructiveDeleteActions: boolean;
|
||||
showAllDeepInfraModels: boolean;
|
||||
showAllProviderModels: boolean;
|
||||
}
|
||||
|
||||
const RUNTIME_DEFAULTS: RuntimeConfig = {
|
||||
defaultTtsProvider: 'custom-openai',
|
||||
defaultTtsModel: 'kokoro',
|
||||
restrictUserApiKeys: true,
|
||||
enableTtsProvidersTab: true,
|
||||
enableWordHighlight: true,
|
||||
|
|
@ -33,6 +32,7 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
|
|||
enableDocxConversion: true,
|
||||
enableDestructiveDeleteActions: true,
|
||||
showAllDeepInfraModels: true,
|
||||
showAllProviderModels: true,
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ import {
|
|||
type CompletedEpubBoundarySegment,
|
||||
} from '@/lib/client/epub/tts-epub-handoff';
|
||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import type {
|
||||
EpubRenderedLocationWalker,
|
||||
|
|
@ -355,7 +354,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice: configVoice,
|
||||
ttsProvider: configTTSProvider,
|
||||
providerRef: configProviderRef,
|
||||
providerType: configProviderType,
|
||||
ttsModel: configTTSModel,
|
||||
ttsInstructions: configTTSInstructions,
|
||||
updateConfigKey,
|
||||
|
|
@ -372,7 +372,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
// Audio and voice management hooks
|
||||
const audioContext = useAudioContext();
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(
|
||||
openApiKey,
|
||||
openApiBaseUrl,
|
||||
configProviderRef,
|
||||
configProviderType,
|
||||
configTTSModel,
|
||||
);
|
||||
const {
|
||||
authEnabled,
|
||||
onTTSStart,
|
||||
|
|
@ -536,9 +542,25 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const [voice, setVoice] = useState(configVoice);
|
||||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||||
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(
|
||||
() => (supportsNativeModelSpeed(configTTSProvider, ttsModel) ? speed : 1),
|
||||
[configTTSProvider, ttsModel, speed],
|
||||
() => (providerModelPolicy.supportsNativeModelSpeed ? speed : 1),
|
||||
[providerModelPolicy.supportsNativeModelSpeed, speed],
|
||||
);
|
||||
|
||||
// Track pending preload requests
|
||||
|
|
@ -1328,11 +1350,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
useEffect(() => {
|
||||
const signature = [
|
||||
documentId,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
supportsTtsInstructions(ttsModel) ? ttsInstructions : '',
|
||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||
ttsSegmentMaxBlockLength,
|
||||
].join('|');
|
||||
|
||||
|
|
@ -1347,7 +1369,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
bumpEpubPreloadGeneration();
|
||||
}, [
|
||||
documentId,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
|
|
@ -1364,8 +1386,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
useEffect(() => {
|
||||
if (availableVoices.length > 0) {
|
||||
// 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 providerUnresolved = !configModelPolicy.isResolvedProviderType;
|
||||
|
||||
if (isKokoro && providerUnresolved && voice.includes('+')) {
|
||||
const firstVoice = voice.split('+')[0]?.replace(/\([^)]*\)/g, '').trim();
|
||||
if (firstVoice) {
|
||||
setVoice(firstVoice);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKokoro) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}, [availableVoices, voice, configVoice, configTTSModel]);
|
||||
}, [availableVoices, voice, configVoice, configModelPolicy]);
|
||||
|
||||
const getSegmentPlaybackSource = useCallback(async (
|
||||
sentence: string,
|
||||
|
|
@ -1412,7 +1443,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
sentence,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
segmentKey,
|
||||
);
|
||||
|
|
@ -1445,7 +1476,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-tts-provider': configTTSProvider,
|
||||
'x-tts-provider': configProviderRef,
|
||||
};
|
||||
if (openApiBaseUrl) {
|
||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||
|
|
@ -1473,14 +1504,15 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
return undefined;
|
||||
}
|
||||
const ensured = await withRetry(
|
||||
async () => ensureTtsSegments({
|
||||
async () => ensureTtsSegments({
|
||||
documentId,
|
||||
settings: {
|
||||
ttsProvider: configTTSProvider,
|
||||
providerRef: configProviderRef,
|
||||
providerType: configProviderType,
|
||||
ttsModel,
|
||||
voice,
|
||||
nativeSpeed: effectiveNativeSpeed,
|
||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
||||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||
},
|
||||
segments: persistSegments,
|
||||
}, reqHeaders, controller.signal),
|
||||
|
|
@ -1559,7 +1591,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
ttsInstructions,
|
||||
openApiKey,
|
||||
openApiBaseUrl,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
isEPUB,
|
||||
pdfHighlightEnabled,
|
||||
pdfWordHighlightEnabled,
|
||||
|
|
@ -2027,7 +2059,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
sentence,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
playbackSegment?.key,
|
||||
);
|
||||
|
|
@ -2061,7 +2093,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
playSentenceWithHowl,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
isEPUB,
|
||||
currDocPage,
|
||||
|
|
@ -2133,11 +2165,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
if (isEPUB) {
|
||||
const generationAtStart = epubPreloadGenerationRef.current;
|
||||
const settingsHash = [
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
supportsTtsInstructions(ttsModel) ? ttsInstructions : '',
|
||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||
].join('|');
|
||||
const walkStartKey = `${currDocPage}|${settingsHash}|${ttsSegmentMaxBlockLength}|${maxDepth}`;
|
||||
|
||||
|
|
@ -2157,7 +2189,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
nextSentence,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
nextSegment?.key,
|
||||
);
|
||||
|
|
@ -2204,7 +2236,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-tts-provider': configTTSProvider,
|
||||
'x-tts-provider': configProviderRef,
|
||||
};
|
||||
if (openApiBaseUrl) {
|
||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||
|
|
@ -2283,7 +2315,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
segment.text,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
segment.key,
|
||||
);
|
||||
|
|
@ -2321,11 +2353,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
async () => ensureTtsSegments({
|
||||
documentId,
|
||||
settings: {
|
||||
ttsProvider: configTTSProvider,
|
||||
providerRef: configProviderRef,
|
||||
providerType: configProviderType,
|
||||
ttsModel,
|
||||
voice,
|
||||
nativeSpeed: effectiveNativeSpeed,
|
||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
||||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||
},
|
||||
segments: persistPayload,
|
||||
}, reqHeaders, controller.signal),
|
||||
|
|
@ -2417,7 +2450,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
sentence,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
plannedSegment?.key,
|
||||
);
|
||||
|
|
@ -2446,7 +2479,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
sentence,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
segment.key,
|
||||
);
|
||||
|
|
@ -2471,7 +2504,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const reqHeaders: TTSRequestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-openai-key': openApiKey || '',
|
||||
'x-tts-provider': configTTSProvider,
|
||||
'x-tts-provider': configProviderRef,
|
||||
};
|
||||
if (openApiBaseUrl) {
|
||||
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||
|
|
@ -2504,11 +2537,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
async () => ensureTtsSegments({
|
||||
documentId,
|
||||
settings: {
|
||||
ttsProvider: configTTSProvider,
|
||||
providerRef: configProviderRef,
|
||||
providerType: configProviderType,
|
||||
ttsModel,
|
||||
voice,
|
||||
nativeSpeed: effectiveNativeSpeed,
|
||||
...(supportsTtsInstructions(ttsModel) && ttsInstructions ? { ttsInstructions } : {}),
|
||||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||
},
|
||||
segments: persistPayload,
|
||||
}, reqHeaders, controller.signal),
|
||||
|
|
@ -2587,7 +2621,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
playbackSegments,
|
||||
voice,
|
||||
effectiveNativeSpeed,
|
||||
configTTSProvider,
|
||||
configProviderRef,
|
||||
ttsModel,
|
||||
openApiKey,
|
||||
openApiBaseUrl,
|
||||
|
|
|
|||
|
|
@ -2,20 +2,23 @@
|
|||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
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
|
||||
* @param apiKey OpenAI API key
|
||||
* @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
|
||||
* @returns Object containing available voices and fetch function
|
||||
*/
|
||||
export function useVoiceManagement(
|
||||
apiKey: string | undefined,
|
||||
baseUrl: string | undefined,
|
||||
ttsProvider: string | undefined,
|
||||
providerRef: string | undefined,
|
||||
providerType: TtsProviderType | undefined,
|
||||
ttsModel: string | undefined
|
||||
) {
|
||||
const [availableVoices, setAvailableVoices] = useState<string[]>([]);
|
||||
|
|
@ -28,20 +31,32 @@ export function useVoiceManagement(
|
|||
const data = await getVoices({
|
||||
'x-openai-key': apiKey || '',
|
||||
'x-openai-base-url': baseUrl || '',
|
||||
'x-tts-provider': ttsProvider || 'openai',
|
||||
'x-tts-provider': providerRef || 'openai',
|
||||
'x-tts-model': ttsModel || 'tts-1',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
|
||||
// Ignore stale responses from older provider/model fetches.
|
||||
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) {
|
||||
console.error('Error fetching voices:', error);
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ interface RegenerateAudiobookChapterOptions {
|
|||
}
|
||||
|
||||
interface ResolvedAudiobookRequestSettings {
|
||||
effectiveProvider: string;
|
||||
effectiveProviderRef: string;
|
||||
effectiveFormat: TTSAudiobookFormat;
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ function resolveAudiobookRequestSettings(
|
|||
format: TTSAudiobookFormat,
|
||||
): ResolvedAudiobookRequestSettings {
|
||||
return {
|
||||
effectiveProvider: settings?.ttsProvider ?? defaultProvider,
|
||||
effectiveProviderRef: settings?.providerRef ?? defaultProvider,
|
||||
effectiveFormat: settings?.format ?? format,
|
||||
};
|
||||
}
|
||||
|
|
@ -115,8 +115,8 @@ export async function runAudiobookGeneration({
|
|||
throw new Error(adapter.noContentMessage);
|
||||
}
|
||||
|
||||
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
|
||||
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
||||
let processedLength = 0;
|
||||
let bookId = providedBookId;
|
||||
|
||||
|
|
@ -233,8 +233,8 @@ export async function regenerateAudiobookChapter({
|
|||
throw new Error(adapter.noContentMessage);
|
||||
}
|
||||
|
||||
const { effectiveProvider, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProvider);
|
||||
const { effectiveProviderRef, effectiveFormat } = resolveAudiobookRequestSettings(settings, defaultProvider, format);
|
||||
const reqHeaders = buildAudiobookRequestHeaders(apiKey, baseUrl, effectiveProviderRef);
|
||||
|
||||
return withRetry(
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { AppConfigRow, AppConfigValues, SavedVoices } from '@/types/config';
|
||||
|
||||
export function getVoicePreferenceKey(provider: string, model: string): string {
|
||||
return `${provider}:${model}`;
|
||||
export function getVoicePreferenceKey(providerRef: string, model: string): string {
|
||||
return `${providerRef}:${model}`;
|
||||
}
|
||||
|
||||
export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
||||
currentConfig: Pick<AppConfigValues, 'ttsProvider' | 'ttsModel' | 'savedVoices'>,
|
||||
currentConfig: Pick<AppConfigValues, 'providerRef' | 'providerType' | 'ttsModel' | 'savedVoices'>,
|
||||
key: K,
|
||||
value: AppConfigValues[K],
|
||||
): {
|
||||
|
|
@ -13,7 +13,7 @@ export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
|||
syncPatch: Partial<AppConfigValues>;
|
||||
} {
|
||||
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 };
|
||||
return {
|
||||
storagePatch: {
|
||||
|
|
@ -27,10 +27,10 @@ export function applyConfigUpdate<K extends keyof AppConfigValues>(
|
|||
};
|
||||
}
|
||||
|
||||
if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
const newProvider = key === 'ttsProvider' ? (value as string) : currentConfig.ttsProvider;
|
||||
if (key === 'providerRef' || key === 'ttsModel' || key === 'providerType') {
|
||||
const newProviderRef = key === 'providerRef' ? (value as string) : currentConfig.providerRef;
|
||||
const newModel = key === 'ttsModel' ? (value as string) : currentConfig.ttsModel;
|
||||
const voiceKey = getVoicePreferenceKey(newProvider, newModel);
|
||||
const voiceKey = getVoicePreferenceKey(newProviderRef, newModel);
|
||||
const restoredVoice = currentConfig.savedVoices[voiceKey] || '';
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import Dexie, { type EntityTable } from 'dexie';
|
||||
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 {
|
||||
PDFDocument,
|
||||
EPUBDocument,
|
||||
|
|
@ -14,7 +19,7 @@ import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
|||
|
||||
const DB_NAME = 'openreader-db';
|
||||
// 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 EPUB_TABLE = 'epub-documents' as const;
|
||||
|
|
@ -108,50 +113,56 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|||
|
||||
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 cachedBaseUrl = raw.baseUrl;
|
||||
let inferredProvider = raw.ttsProvider || '';
|
||||
let inferredProviderRef = raw.providerRef || raw.ttsProvider || raw.provider || '';
|
||||
inferredProviderRef = normalizeLegacyProviderRef(inferredProviderRef, getAppConfigDefaults().providerRef);
|
||||
|
||||
if (!raw.ttsProvider) {
|
||||
inferredProvider = getAppConfigDefaults().ttsProvider;
|
||||
} else if (!inferredProvider) {
|
||||
if (!raw.providerRef && !raw.ttsProvider && !raw.provider) {
|
||||
inferredProviderRef = getAppConfigDefaults().providerRef;
|
||||
} else if (!inferredProviderRef) {
|
||||
if (cachedBaseUrl) {
|
||||
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
||||
if (baseUrlLower.includes('deepinfra.com')) {
|
||||
inferredProvider = 'deepinfra';
|
||||
inferredProviderRef = 'deepinfra';
|
||||
} else if (baseUrlLower.includes('openai.com')) {
|
||||
inferredProvider = 'openai';
|
||||
inferredProviderRef = 'openai';
|
||||
} else if (
|
||||
baseUrlLower.includes('localhost') ||
|
||||
baseUrlLower.includes('127.0.0.1') ||
|
||||
baseUrlLower.includes('internal')
|
||||
) {
|
||||
inferredProvider = 'custom-openai';
|
||||
inferredProviderRef = 'custom-openai';
|
||||
} else {
|
||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
||||
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
|
||||
}
|
||||
} else {
|
||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
||||
inferredProviderRef = cachedApiKey ? 'openai' : 'custom-openai';
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = cachedBaseUrl || '';
|
||||
if (!baseUrl) {
|
||||
if (inferredProvider === 'openai') {
|
||||
if (inferredProviderRef === 'openai') {
|
||||
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
|
||||
} else if (inferredProvider === 'deepinfra') {
|
||||
} else if (inferredProviderRef === 'deepinfra') {
|
||||
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
|
||||
} else {
|
||||
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
|
||||
}
|
||||
}
|
||||
|
||||
return { provider: inferredProvider, baseUrl };
|
||||
return { providerRef: inferredProviderRef, baseUrl };
|
||||
}
|
||||
|
||||
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 = {};
|
||||
if (raw.savedVoices) {
|
||||
|
|
@ -188,14 +199,9 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
|||
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
|
||||
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
|
||||
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
|
||||
ttsProvider: provider || APP_CONFIG_DEFAULTS.ttsProvider,
|
||||
ttsModel:
|
||||
raw.ttsModel ||
|
||||
(provider === 'openai'
|
||||
? 'tts-1'
|
||||
: provider === 'deepinfra'
|
||||
? 'hexgrad/Kokoro-82M'
|
||||
: APP_CONFIG_DEFAULTS.ttsModel),
|
||||
providerRef: providerRef || APP_CONFIG_DEFAULTS.providerRef,
|
||||
providerType,
|
||||
ttsModel: raw.ttsModel || (providerType === 'unknown' ? APP_CONFIG_DEFAULTS.ttsModel : defaultModelForProviderType(providerType)),
|
||||
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
|
||||
savedVoices,
|
||||
pdfHighlightEnabled:
|
||||
|
|
@ -210,13 +216,13 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
|||
documentListState,
|
||||
};
|
||||
|
||||
const voiceKey = `${config.ttsProvider}:${config.ttsModel}`;
|
||||
const voiceKey = `${config.providerRef}:${config.ttsModel}`;
|
||||
config.voice = config.savedVoices[voiceKey] || '';
|
||||
|
||||
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({
|
||||
[PDF_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.
|
||||
[CONFIG_TABLE]: null,
|
||||
}).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) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +271,7 @@ db.version(DB_VERSION).stores({
|
|||
}
|
||||
|
||||
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.
|
||||
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
import {
|
||||
isBuiltInTtsProviderId,
|
||||
TTS_PROVIDER_DEFINITIONS,
|
||||
providerSupportsCustomModel,
|
||||
resolveProviderModels,
|
||||
type TtsModelDefinition,
|
||||
type TtsProviderDefinition,
|
||||
type TtsProviderId,
|
||||
type TtsProviderType,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
import {
|
||||
normalizeLegacyProviderRef,
|
||||
resolveEffectiveProviderType,
|
||||
resolveProviderDefaults,
|
||||
resolveTtsModelForProvider,
|
||||
resolveTtsProviderModelPolicy,
|
||||
} from '@/lib/shared/tts-provider-policy';
|
||||
import type { SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||
|
||||
export interface ResolveTtsSettingsViewModelOptions {
|
||||
provider: string;
|
||||
providerRef: string;
|
||||
providerType: TtsProviderType;
|
||||
apiKey?: string;
|
||||
modelValue: string;
|
||||
customModelInput: string;
|
||||
showAllDeepInfra: boolean;
|
||||
showAllProviderModels: boolean;
|
||||
sharedProviders?: SharedProviderEntry[];
|
||||
allowBuiltInProviders?: boolean;
|
||||
}
|
||||
|
|
@ -35,6 +45,8 @@ export interface TtsSettingsViewModel {
|
|||
canSubmit: boolean;
|
||||
/** The matched shared provider entry, if the current selection is a shared slug. */
|
||||
selectedSharedProvider: SharedProviderEntry | null;
|
||||
selectedProviderRef: string;
|
||||
selectedProviderType: TtsProviderType;
|
||||
}
|
||||
|
||||
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({
|
||||
provider,
|
||||
providerRef,
|
||||
providerType,
|
||||
apiKey,
|
||||
modelValue,
|
||||
customModelInput,
|
||||
showAllDeepInfra,
|
||||
showAllProviderModels,
|
||||
sharedProviders = [],
|
||||
allowBuiltInProviders = true,
|
||||
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
|
||||
|
|
@ -65,24 +79,69 @@ export function resolveTtsSettingsViewModel({
|
|||
shared: true,
|
||||
}));
|
||||
const providers = [...sharedOptions, ...builtInOptions];
|
||||
const selectedProviderId = providers.some((opt) => opt.id === provider)
|
||||
? provider
|
||||
const normalizedInputProviderRef = normalizeLegacyProviderRef(providerRef);
|
||||
const selectedProviderRef = providers.some((opt) => opt.id === normalizedInputProviderRef)
|
||||
? normalizedInputProviderRef
|
||||
: 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 === selectedProviderId) ?? null;
|
||||
const effectiveProvider = selectedShared ? selectedShared.providerType : selectedProviderId;
|
||||
const selectedShared = sharedProviders.find((p) => p.slug === selectedProviderRef) ?? null;
|
||||
const selectedProviderType = resolveEffectiveProviderType({
|
||||
providerRef: selectedProviderRef,
|
||||
providerType,
|
||||
sharedProviders,
|
||||
});
|
||||
const effectiveProviderType = selectedProviderType;
|
||||
const knownProviderType = isBuiltInTtsProviderId(effectiveProviderType)
|
||||
? effectiveProviderType
|
||||
: null;
|
||||
|
||||
const models = resolveProviderModels(effectiveProvider, {
|
||||
const catalogModels = resolveProviderModels(knownProviderType ?? 'custom-openai', {
|
||||
apiKey,
|
||||
showAllDeepInfra,
|
||||
});
|
||||
const supportsCustomModel =
|
||||
BUILT_IN_DEFINITION_BY_ID.has(effectiveProvider) &&
|
||||
providerSupportsCustomModel(effectiveProvider);
|
||||
const isPreset = models.some((model) => model.id === modelValue);
|
||||
const providerDefaults = resolveProviderDefaults({
|
||||
providerRef: selectedProviderRef,
|
||||
providerType: selectedProviderType,
|
||||
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
|
||||
? modelValue
|
||||
? normalizedModelValue
|
||||
: supportsCustomModel
|
||||
? 'custom'
|
||||
: models[0]?.id ?? '';
|
||||
|
|
@ -98,5 +157,7 @@ export function resolveTtsSettingsViewModel({
|
|||
selectedModelId,
|
||||
canSubmit,
|
||||
selectedSharedProvider: selectedShared,
|
||||
selectedProviderRef,
|
||||
selectedProviderType,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { and, eq } from 'drizzle-orm';
|
|||
import { db } from '@/db';
|
||||
import { adminProviders } from '@/db/schema';
|
||||
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[] = [
|
||||
'custom-openai',
|
||||
|
|
@ -170,7 +171,11 @@ function normalizeOptionalText(value: string | null | undefined): string | null
|
|||
|
||||
function assertInstructionsCompatibility(model: string | null, instructions: string | null): void {
|
||||
if (!instructions) return;
|
||||
if (!supportsTtsInstructions(model)) {
|
||||
if (!resolveTtsProviderModelPolicy({
|
||||
providerRef: '',
|
||||
providerType: 'custom-openai',
|
||||
model,
|
||||
}).supportsInstructions) {
|
||||
throw new AdminProviderError(
|
||||
'defaultInstructions is only supported for models that support TTS instructions.',
|
||||
400,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ async function runSeed(): Promise<void> {
|
|||
await seedRuntimeConfigFromEnv();
|
||||
await seedDefaultAdminProvider();
|
||||
await cleanupLegacyDefaultTtsProviderSeedRow();
|
||||
await cleanupLegacyDefaultTtsModelRows();
|
||||
}
|
||||
|
||||
async function seedDefaultAdminProvider(): Promise<void> {
|
||||
|
|
@ -64,17 +65,15 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
|||
|
||||
const baseUrl = process.env.API_BASE?.trim() || null;
|
||||
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>;
|
||||
try {
|
||||
enc = encryptSecret(apiKey);
|
||||
} catch (error) {
|
||||
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) {
|
||||
try {
|
||||
await db
|
||||
|
|
@ -110,7 +109,7 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
|||
apiKeyCiphertext: enc.ciphertext,
|
||||
apiKeyIv: enc.iv,
|
||||
apiKeyLast4: apiKeyLast4(apiKey),
|
||||
defaultModel,
|
||||
defaultModel: 'kokoro',
|
||||
enabled: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -123,7 +122,9 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
|||
|
||||
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
||||
// 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;
|
||||
|
||||
const key = 'defaultTtsProvider';
|
||||
|
|
@ -142,3 +143,13 @@ async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
|||
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> {
|
||||
/** TS-level default. Used when neither DB nor env have a value. */
|
||||
default: T;
|
||||
/** Env var name to seed from on first run. */
|
||||
envVar: string;
|
||||
/** Env var name to seed from on first run. Omit for DB/admin-only keys. */
|
||||
envVar?: string;
|
||||
/** Parse a string env value to T. Returns undefined to skip seeding. */
|
||||
parseEnv(raw: string): T | undefined;
|
||||
/** 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> {
|
||||
return {
|
||||
default: defaultValue,
|
||||
|
|
@ -63,7 +76,6 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
|
|||
|
||||
export const RUNTIME_CONFIG_SCHEMA = {
|
||||
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'),
|
||||
// Historically the env semantics were "true unless explicitly 'false'",
|
||||
// i.e. the feature defaults to ON.
|
||||
|
|
@ -73,6 +85,7 @@ export const RUNTIME_CONFIG_SCHEMA = {
|
|||
enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'),
|
||||
enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
|
||||
showAllDeepInfraModels: booleanFlag(true, 'NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS'),
|
||||
showAllProviderModels: runtimeBoolean(true),
|
||||
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
|
||||
|
||||
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) {
|
||||
if (existing.has(key)) continue;
|
||||
const def = RUNTIME_CONFIG_SCHEMA[key];
|
||||
if (!def.envVar) continue;
|
||||
const raw = process.env[def.envVar];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
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 {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
|
|
@ -21,11 +21,14 @@ export function resolveEffectiveTtsInstructions(opts: {
|
|||
requestInstructions?: string | null;
|
||||
sharedDefaultInstructions?: string | null;
|
||||
}): string | undefined {
|
||||
if (!supportsTtsInstructions(opts.model)) {
|
||||
if (!resolveTtsProviderModelPolicy({
|
||||
providerRef: '',
|
||||
providerType: 'custom-openai',
|
||||
model: opts.model,
|
||||
}).supportsInstructions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizeInstructionCandidate(opts.requestInstructions)
|
||||
?? normalizeInstructionCandidate(opts.sharedDefaultInstructions);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import OpenAI from 'openai';
|
||||
import Replicate from 'replicate';
|
||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
import {
|
||||
isBuiltInTtsProviderId,
|
||||
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||
supportsNativeModelSpeed,
|
||||
supportsTtsInstructions,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution';
|
||||
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
|
@ -224,21 +223,27 @@ export function extractReplicateAudioUrl(output: unknown): string | null {
|
|||
|
||||
function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
||||
const provider = input.provider || 'openai';
|
||||
const providerType = isBuiltInTtsProviderId(provider) ? provider : 'openai';
|
||||
const rawModel = provider === 'deepinfra' && !input.model ? 'hexgrad/Kokoro-82M'
|
||||
: provider === 'replicate' && !input.model ? REPLICATE_KOKORO_82M_VERSIONED_MODEL
|
||||
: input.model;
|
||||
const model = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
|
||||
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||
providerRef: provider,
|
||||
providerType,
|
||||
model: model as string,
|
||||
});
|
||||
|
||||
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
|
||||
) as string;
|
||||
|
||||
const format = input.format || 'mp3';
|
||||
const requestedSpeed = Number.isFinite(Number(input.speed)) ? Number(input.speed) : 1;
|
||||
const speed = supportsNativeModelSpeed(provider, model as string) ? requestedSpeed : 1;
|
||||
const instructions = supportsTtsInstructions(model as string) && input.instructions
|
||||
const speed = providerModelPolicy.supportsNativeModelSpeed ? requestedSpeed : 1;
|
||||
const instructions = providerModelPolicy.supportsInstructions && input.instructions
|
||||
? input.instructions
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ function stableStringify(value: unknown): string {
|
|||
|
||||
function settingsCanonical(settings: TTSSegmentSettings): string {
|
||||
return stableStringify({
|
||||
provider: settings.ttsProvider,
|
||||
providerRef: settings.providerRef,
|
||||
providerType: settings.providerType,
|
||||
model: settings.ttsModel,
|
||||
voice: settings.voice,
|
||||
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 {
|
||||
const canonical: TTSSegmentSettings = {
|
||||
ttsProvider: settings.ttsProvider,
|
||||
providerRef: settings.providerRef,
|
||||
providerType: settings.providerType,
|
||||
ttsModel: settings.ttsModel,
|
||||
voice: settings.voice,
|
||||
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { LRUCache } from 'lru-cache';
|
||||
import {
|
||||
getDefaultVoices,
|
||||
resolveProviderModels,
|
||||
resolveVoiceSource,
|
||||
type ReplicateVoiceInputKey,
|
||||
type ResolveVoicesOptions,
|
||||
} from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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[]> {
|
||||
const defaultVoices = getDefaultVoices(provider, model);
|
||||
const voiceSource = resolveVoiceSource(provider, model);
|
||||
const providerModelPolicy = resolveTtsProviderModelPolicy({
|
||||
providerRef: provider,
|
||||
providerType: provider,
|
||||
model,
|
||||
});
|
||||
const defaultVoices = providerModelPolicy.defaultVoices;
|
||||
const voiceSource = providerModelPolicy.voiceSource;
|
||||
|
||||
if (voiceSource === 'deepinfra-api') {
|
||||
const apiVoices = await fetchDeepinfraVoices(apiKey);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const isKokoroModel = (modelName: string | undefined): boolean => {
|
|||
* @param model - TTS model name
|
||||
* @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;
|
||||
|
||||
// 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
|
||||
return Infinity;
|
||||
};
|
||||
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||
|
||||
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 ReplicateVoiceInputKey = 'voice' | 'voice_id' | 'speaker';
|
||||
|
||||
export interface SharedProviderTypeResolverEntry {
|
||||
slug: string;
|
||||
providerType: TtsProviderId;
|
||||
defaultModel?: string | null;
|
||||
defaultInstructions?: string | null;
|
||||
}
|
||||
|
||||
export interface TtsModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -22,7 +30,7 @@ export interface ResolveProviderModelsContext {
|
|||
}
|
||||
|
||||
export interface ResolveVoicesOptions {
|
||||
provider: string;
|
||||
provider: TtsProviderId;
|
||||
model: string;
|
||||
apiKey?: 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([
|
||||
'gpt-4o-mini-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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -183,18 +223,20 @@ export function supportsNativeModelSpeed(provider: string | null | undefined, mo
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function providerSupportsCustomModel(provider: string | null | undefined): boolean {
|
||||
export function providerSupportsCustomModel(provider: TtsProviderId): boolean {
|
||||
return getProviderDefinition(provider)?.supportsCustomModel ?? false;
|
||||
}
|
||||
|
||||
export function getDefaultVoices(provider: string, model: string): string[] {
|
||||
export function getDefaultVoices(provider: TtsProviderId, model: string): string[] {
|
||||
if (provider === 'openai') {
|
||||
return supportsTtsInstructions(model) ? [...GPT4O_MINI_DEFAULT_VOICES] : [...OPENAI_DEFAULT_VOICES];
|
||||
}
|
||||
|
|
@ -237,7 +279,7 @@ function parseReplicateModelIdentifier(model: string): {
|
|||
: 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)) {
|
||||
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,
|
||||
TTSAudiobookFormat,
|
||||
} from '@/types/tts';
|
||||
import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
// --- TTS Client Request Types ---
|
||||
|
||||
|
|
@ -37,7 +38,8 @@ export interface AudiobookStatusResponse {
|
|||
}
|
||||
|
||||
export interface AudiobookGenerationSettings {
|
||||
ttsProvider: string;
|
||||
providerRef: string;
|
||||
providerType: TtsProviderType;
|
||||
ttsModel: string;
|
||||
voice: string;
|
||||
nativeSpeed: number;
|
||||
|
|
@ -74,7 +76,8 @@ export interface AlignmentResponse {
|
|||
}
|
||||
|
||||
export interface TTSSegmentSettings {
|
||||
ttsProvider: string;
|
||||
providerRef: string;
|
||||
providerType: TtsProviderType;
|
||||
ttsModel: string;
|
||||
voice: string;
|
||||
nativeSpeed: number;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
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
|
||||
// below. We resolve it lazily so this module stays importable from non-React
|
||||
|
|
@ -64,7 +66,8 @@ export interface AppConfigValues {
|
|||
footerMargin: number;
|
||||
leftMargin: number;
|
||||
rightMargin: number;
|
||||
ttsProvider: string;
|
||||
providerRef: string;
|
||||
providerType: TtsProviderType;
|
||||
ttsModel: string;
|
||||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
|
|
@ -92,6 +95,12 @@ export interface AppConfigValues {
|
|||
*/
|
||||
export function getAppConfigDefaults(): AppConfigValues {
|
||||
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 {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
|
|
@ -105,8 +114,9 @@ export function getAppConfigDefaults(): AppConfigValues {
|
|||
footerMargin: 0,
|
||||
leftMargin: 0,
|
||||
rightMargin: 0,
|
||||
ttsProvider: readRuntimeString('defaultTtsProvider', 'custom-openai'),
|
||||
ttsModel: readRuntimeString('defaultTtsModel', 'kokoro'),
|
||||
providerRef: defaultProviderRef,
|
||||
providerType: defaultProviderType,
|
||||
ttsModel: defaultModel,
|
||||
ttsInstructions: '',
|
||||
savedVoices: {},
|
||||
smartSentenceSplitting: true,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export const SYNCED_PREFERENCE_KEYS = [
|
|||
'footerMargin',
|
||||
'leftMargin',
|
||||
'rightMargin',
|
||||
'ttsProvider',
|
||||
'providerRef',
|
||||
'providerType',
|
||||
'ttsModel',
|
||||
'ttsInstructions',
|
||||
'savedVoices',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
supportsNativeModelSpeed,
|
||||
supportsTtsInstructions,
|
||||
} 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 { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({
|
||||
|
|
@ -319,7 +391,8 @@ test.describe('config helpers', () => {
|
|||
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
|
||||
|
||||
const voiceUpdate = applyConfigUpdate({
|
||||
ttsProvider: 'openai',
|
||||
providerRef: 'openai',
|
||||
providerType: 'openai',
|
||||
ttsModel: 'tts-1',
|
||||
savedVoices: {},
|
||||
}, 'voice', 'alloy');
|
||||
|
|
@ -329,14 +402,15 @@ test.describe('config helpers', () => {
|
|||
});
|
||||
|
||||
const providerUpdate = applyConfigUpdate({
|
||||
ttsProvider: 'openai',
|
||||
providerRef: 'openai',
|
||||
providerType: 'openai',
|
||||
ttsModel: 'tts-1',
|
||||
savedVoices: {
|
||||
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
|
||||
},
|
||||
}, 'ttsProvider', 'deepinfra');
|
||||
}, 'providerRef', 'deepinfra');
|
||||
expect(providerUpdate.storagePatch).toEqual({
|
||||
ttsProvider: 'deepinfra',
|
||||
providerRef: 'deepinfra',
|
||||
voice: '',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,14 +14,16 @@ import {
|
|||
test.describe('tts segment helpers', () => {
|
||||
test('builds stable settings hash', () => {
|
||||
const a = buildTtsSegmentSettingsHash({
|
||||
ttsProvider: 'openai',
|
||||
providerRef: 'openai',
|
||||
providerType: 'openai',
|
||||
ttsModel: 'gpt-4o-mini-tts',
|
||||
voice: 'alloy',
|
||||
nativeSpeed: 1,
|
||||
ttsInstructions: 'calm',
|
||||
});
|
||||
const b = buildTtsSegmentSettingsHash({
|
||||
ttsProvider: 'openai',
|
||||
providerRef: 'openai',
|
||||
providerType: 'openai',
|
||||
ttsModel: 'gpt-4o-mini-tts',
|
||||
voice: 'alloy',
|
||||
nativeSpeed: 1,
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ const SHARED: SharedProviderEntry[] = [
|
|||
test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
||||
test('restrict mode exposes only shared providers', () => {
|
||||
const vm = resolveTtsSettingsViewModel({
|
||||
provider: 'shared-openai',
|
||||
providerRef: 'shared-openai',
|
||||
providerType: 'unknown',
|
||||
modelValue: 'gpt-4o-mini-tts',
|
||||
customModelInput: '',
|
||||
showAllDeepInfra: false,
|
||||
showAllProviderModels: true,
|
||||
sharedProviders: SHARED,
|
||||
allowBuiltInProviders: false,
|
||||
});
|
||||
|
|
@ -37,23 +39,28 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
|||
|
||||
test('invalid provider falls back to first available option', () => {
|
||||
const vm = resolveTtsSettingsViewModel({
|
||||
provider: 'missing-provider',
|
||||
modelValue: 'gpt-4o-mini-tts',
|
||||
providerRef: 'missing-provider',
|
||||
providerType: 'unknown',
|
||||
modelValue: 'kokoro',
|
||||
customModelInput: '',
|
||||
showAllDeepInfra: false,
|
||||
showAllProviderModels: true,
|
||||
sharedProviders: SHARED,
|
||||
allowBuiltInProviders: false,
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const vm = resolveTtsSettingsViewModel({
|
||||
provider: 'shared-replicate',
|
||||
providerRef: 'shared-replicate',
|
||||
providerType: 'unknown',
|
||||
modelValue: 'my-custom-model',
|
||||
customModelInput: '',
|
||||
showAllDeepInfra: false,
|
||||
showAllProviderModels: true,
|
||||
sharedProviders: SHARED,
|
||||
allowBuiltInProviders: false,
|
||||
});
|
||||
|
|
@ -65,10 +72,12 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
|||
|
||||
test('non-custom providers fall back to preset model selection', () => {
|
||||
const vm = resolveTtsSettingsViewModel({
|
||||
provider: 'shared-openai',
|
||||
providerRef: 'shared-openai',
|
||||
providerType: 'unknown',
|
||||
modelValue: 'not-in-presets',
|
||||
customModelInput: '',
|
||||
showAllDeepInfra: false,
|
||||
showAllProviderModels: true,
|
||||
sharedProviders: SHARED,
|
||||
allowBuiltInProviders: false,
|
||||
});
|
||||
|
|
@ -77,4 +86,38 @@ test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
|
|||
expect(vm.selectedModelId).not.toBe('custom');
|
||||
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