Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

126 lines
4.8 KiB
TypeScript

/**
* TTS provider enablement model (#665)
*
* A single, framework-agnostic source of truth for "which TTS providers may be
* used", honored everywhere (voice picker, auto-assignment, fallback, toolbar
* CTA). It collapses the previously ad-hoc gates in voice-resolver, the
* hardcoded browser-native branch in the agent UI, and the implicit fallbacks
* into two layered predicates:
*
* configured — the provider has a real credential path (server-managed, a
* client API key, or an explicit base URL). browser-native is
* intrinsically configured (it runs in the browser).
* enabled — configured AND not server-disabled AND the user's per-provider
* `enabled` flag is not explicitly false.
*
* Server precedence: `serverDisabled` (admin/server-level disable via
* server-providers.yml / env) forces a provider off regardless of the user
* toggle.
*
* No React, no IO — pure functions over the persisted ttsProvidersConfig slice,
* so they are trivially unit-testable and safe to import in both client and
* server code.
*/
import { TTS_PROVIDERS } from '@/lib/audio/constants';
import { isCustomTTSProvider, type TTSProviderId } from '@/lib/audio/types';
export const BROWSER_NATIVE_TTS_PROVIDER_ID = 'browser-native-tts' as const;
/** The slice of a persisted ttsProvidersConfig entry the predicates rely on. */
export interface TTSEnablementConfig {
apiKey?: string;
baseUrl?: string;
/** User-level per-provider toggle. Absent / true ⇒ allowed; false ⇒ hidden. */
enabled?: boolean;
isServerConfigured?: boolean;
serverBaseUrl?: string;
/** Server/admin force-off (server-providers.yml / env). Overrides `enabled`. */
serverDisabled?: boolean;
requiresApiKey?: boolean;
customVoices?: Array<{ id: string; name: string }>;
}
type ConfigMap = Partial<Record<string, TTSEnablementConfig>>;
function hasText(value: string | undefined): boolean {
return !!value && value.trim().length > 0;
}
/**
* Whether a provider has a usable credential path (is "available").
*
* Deliberately does NOT fall back to the registry `defaultBaseUrl`: a keyless
* local provider (e.g. Lemonade) must be shown only once the operator
* server-configures it or the user supplies an explicit base URL — otherwise
* its voices are selectable while nothing is actually running there (#665
* symptom 1).
*/
export function isTTSProviderConfigured(
providerId: TTSProviderId,
config: TTSEnablementConfig | undefined,
): boolean {
// Browser-native runs in-browser; it never needs a credential path.
if (providerId === BROWSER_NATIVE_TTS_PROVIDER_ID) return true;
if (!config) return false;
if (config.isServerConfigured) return true;
if (isCustomTTSProvider(providerId)) {
// A custom provider is usable once it has a credential path or any voices
// the user defined for it (its existing visibility rule).
return (
hasText(config.apiKey) || hasText(config.baseUrl) || (config.customVoices?.length ?? 0) > 0
);
}
const def = TTS_PROVIDERS[providerId as keyof typeof TTS_PROVIDERS];
if (!def) return false;
if (def.requiresApiKey) return hasText(config.apiKey);
// Keyless built-in (VoxCPM, Lemonade): require an EXPLICIT base URL.
return hasText(config.serverBaseUrl) || hasText(config.baseUrl);
}
/**
* Whether a provider may be used: configured, not server-disabled, and the
* user's per-provider `enabled` flag is not explicitly false.
*/
export function isTTSProviderEnabled(
providerId: TTSProviderId,
config: TTSEnablementConfig | undefined,
): boolean {
if (config?.serverDisabled) return false; // server precedence
if (!isTTSProviderConfigured(providerId, config)) return false;
return config?.enabled !== false;
}
/**
* All enabled provider IDs in canonical (deterministic) registry order,
* built-ins first then custom providers. Includes browser-native only when it
* is explicitly enabled. Used for the toolbar empty-state CTA decision.
*/
export function listEnabledTTSProviderIds(config: ConfigMap): TTSProviderId[] {
const ids: TTSProviderId[] = [];
for (const id of Object.keys(TTS_PROVIDERS) as TTSProviderId[]) {
if (isTTSProviderEnabled(id, config[id])) ids.push(id);
}
for (const id of Object.keys(config)) {
if (isCustomTTSProvider(id) && isTTSProviderEnabled(id as TTSProviderId, config[id])) {
ids.push(id as TTSProviderId);
}
}
return ids;
}
/** Whether at least one TTS provider (incl. browser-native) is enabled. */
export function hasAnyEnabledTTSProvider(config: ConfigMap): boolean {
for (const id of Object.keys(TTS_PROVIDERS) as TTSProviderId[]) {
if (isTTSProviderEnabled(id, config[id])) return true;
}
for (const id of Object.keys(config)) {
if (isCustomTTSProvider(id) && isTTSProviderEnabled(id as TTSProviderId, config[id])) {
return true;
}
}
return false;
}