centralized language catalog
This commit is contained in:
parent
d814a76660
commit
13f4574098
9 changed files with 277 additions and 157 deletions
|
|
@ -31,6 +31,7 @@ import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||||
import { FileProvider } from './contexts/FileContext';
|
import { FileProvider } from './contexts/FileContext';
|
||||||
|
import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext';
|
||||||
import { MainLayout } from './layouts/MainLayout';
|
import { MainLayout } from './layouts/MainLayout';
|
||||||
import { FeatureLayout } from './layouts/FeatureLayout';
|
import { FeatureLayout } from './layouts/FeatureLayout';
|
||||||
import { DashboardPage } from './pages/Dashboard';
|
import { DashboardPage } from './pages/Dashboard';
|
||||||
|
|
@ -72,6 +73,7 @@ function App() {
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<VoiceCatalogProvider>
|
||||||
<WorkflowSelectionProvider>
|
<WorkflowSelectionProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -231,6 +233,7 @@ function App() {
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</WorkflowSelectionProvider>
|
</WorkflowSelectionProvider>
|
||||||
|
</VoiceCatalogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|
|
||||||
|
|
@ -102,18 +102,10 @@ export interface ConfigUpdateRequest {
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice/Language Types (from Google TTS API)
|
// Voice option type re-exported from the central voice catalog API.
|
||||||
export interface VoiceLanguage {
|
// The legacy teamsbot-specific {code,name} language type is gone — consumers
|
||||||
code: string;
|
// should use VoiceLanguage from voiceCatalogApi (catalog SSOT).
|
||||||
name: string;
|
export type { VoiceOption } from './voiceCatalogApi';
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceOption {
|
|
||||||
name: string;
|
|
||||||
languageCodes: string[];
|
|
||||||
ssmlGender: string;
|
|
||||||
naturalSampleRateHertz: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth Detection Test Types
|
// Auth Detection Test Types
|
||||||
export interface StepScreenshot {
|
export interface StepScreenshot {
|
||||||
|
|
@ -313,25 +305,19 @@ export async function testVoice(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available TTS languages from Google Cloud.
|
* Fetch the curated voice/language catalog (single source of truth).
|
||||||
* Returns array of language codes (e.g. ["de-DE", "en-US", ...])
|
* Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy
|
||||||
|
* teamsbot consumers stay on one import surface.
|
||||||
*/
|
*/
|
||||||
export async function fetchLanguages(): Promise<string[]> {
|
export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi';
|
||||||
try {
|
|
||||||
const response = await api.get('/voice-google/languages');
|
|
||||||
return response.data?.languages || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available TTS voices for a language from Google Cloud.
|
* Fetch available TTS voices for a language from Google Cloud.
|
||||||
*/
|
*/
|
||||||
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
|
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/voice-google/voices', {
|
const response = await api.get('/api/voice/voices', {
|
||||||
params: { languageCode },
|
params: { language: languageCode },
|
||||||
});
|
});
|
||||||
return response.data?.voices || [];
|
return response.data?.voices || [];
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
47
src/api/voiceCatalogApi.ts
Normal file
47
src/api/voiceCatalogApi.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Voice / Language Catalog API.
|
||||||
|
*
|
||||||
|
* Single source of truth for every voice-language picker, default-voice
|
||||||
|
* lookup, and ISO ⇄ BCP-47 mapping in the frontend. Mirrors
|
||||||
|
* gateway/modules/shared/voiceCatalog.py 1:1.
|
||||||
|
*
|
||||||
|
* Hard-coded language lists or ad-hoc maps in components are forbidden —
|
||||||
|
* consume `useVoiceCatalog()` instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export interface VoiceLanguage {
|
||||||
|
bcp47: string;
|
||||||
|
iso: string;
|
||||||
|
label: string;
|
||||||
|
flag: string;
|
||||||
|
defaultVoice: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceOption {
|
||||||
|
name: string;
|
||||||
|
languageCodes: string[];
|
||||||
|
ssmlGender: string;
|
||||||
|
naturalSampleRateHertz: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogResponse {
|
||||||
|
languages: VoiceLanguage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoicesResponse {
|
||||||
|
voices: VoiceOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVoiceCatalog(): Promise<VoiceLanguage[]> {
|
||||||
|
const response = await api.get<CatalogResponse>('/api/voice/languages');
|
||||||
|
return response.data?.languages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVoicesForLanguage(bcp47: string): Promise<VoiceOption[]> {
|
||||||
|
const response = await api.get<VoicesResponse>('/api/voice/voices', {
|
||||||
|
params: { language: bcp47 },
|
||||||
|
});
|
||||||
|
return response.data?.voices ?? [];
|
||||||
|
}
|
||||||
|
|
@ -1,67 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* VoiceLanguageSelect
|
* VoiceLanguageSelect
|
||||||
*
|
*
|
||||||
* Reusable component for selecting voice/speech recognition language.
|
* Reusable picker for voice/speech-recognition language. Reads the language
|
||||||
* Defaults to user's profile language.
|
* list from the central VoiceCatalog (single source of truth) — never
|
||||||
* Can be used for speech-to-text, text-to-speech, and translation features.
|
* hard-coded here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { useVoiceCatalog, useDefaultVoiceLocale } from '../../../contexts/VoiceCatalogContext';
|
||||||
|
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||||
import styles from './VoiceLanguageSelect.module.css';
|
import styles from './VoiceLanguageSelect.module.css';
|
||||||
|
|
||||||
// Voice language options with full locale codes for Google Cloud Speech
|
export type VoiceLanguageOption = VoiceLanguage;
|
||||||
export interface VoiceLanguageOption {
|
|
||||||
code: string; // Full locale code (e.g., 'de-DE')
|
|
||||||
label: string; // Display label
|
|
||||||
shortCode: string; // Short code for mapping (e.g., 'de')
|
|
||||||
flag?: string; // Optional flag emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supported languages for speech recognition
|
|
||||||
export const voiceLanguages: VoiceLanguageOption[] = [
|
|
||||||
{ code: 'de-DE', label: 'Deutsch', shortCode: 'de', flag: '🇩🇪' },
|
|
||||||
{ code: 'de-CH', label: 'Deutsch (Schweiz)', shortCode: 'de', flag: '🇨🇭' },
|
|
||||||
{ code: 'en-US', label: 'English (US)', shortCode: 'en', flag: '🇺🇸' },
|
|
||||||
{ code: 'en-GB', label: 'English (UK)', shortCode: 'en', flag: '🇬🇧' },
|
|
||||||
{ code: 'fr-FR', label: 'Français', shortCode: 'fr', flag: '🇫🇷' },
|
|
||||||
{ code: 'fr-CH', label: 'Français (Suisse)', shortCode: 'fr', flag: '🇨🇭' },
|
|
||||||
{ code: 'it-IT', label: 'Italiano', shortCode: 'it', flag: '🇮🇹' },
|
|
||||||
{ code: 'it-CH', label: 'Italiano (Svizzera)', shortCode: 'it', flag: '🇨🇭' },
|
|
||||||
{ code: 'es-ES', label: 'Español', shortCode: 'es', flag: '🇪🇸' },
|
|
||||||
{ code: 'pt-BR', label: 'Português', shortCode: 'pt', flag: '🇧🇷' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map user profile language (short code) to default voice language (full code)
|
|
||||||
const profileToVoiceLanguage: Record<string, string> = {
|
|
||||||
'de': 'de-DE',
|
|
||||||
'en': 'en-US',
|
|
||||||
'fr': 'fr-FR',
|
|
||||||
'it': 'it-IT',
|
|
||||||
'es': 'es-ES',
|
|
||||||
'pt': 'pt-BR',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface VoiceLanguageSelectProps {
|
export interface VoiceLanguageSelectProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (languageCode: string) => void;
|
onChange: (languageCode: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
compact?: boolean; // Compact mode shows only flag/short code
|
compact?: boolean;
|
||||||
showFlags?: boolean; // Show flag emojis
|
showFlags?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default voice language based on user's profile language
|
|
||||||
*/
|
|
||||||
export const getDefaultVoiceLanguage = (profileLanguage?: string): string => {
|
|
||||||
if (profileLanguage && profileToVoiceLanguage[profileLanguage]) {
|
|
||||||
return profileToVoiceLanguage[profileLanguage];
|
|
||||||
}
|
|
||||||
return 'de-DE'; // Default fallback
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -71,23 +33,25 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
title = 'Sprache für Spracherkennung',
|
title = 'Sprache für Spracherkennung',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { languages, isLoading } = useVoiceCatalog();
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
onChange(e.target.value);
|
onChange(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
|
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
|
||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={disabled}
|
disabled={disabled || isLoading}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{voiceLanguages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<option key={lang.code} value={lang.code}>
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
{showFlags && lang.flag ? `${lang.flag} ` : ''}
|
{showFlags && lang.flag ? `${lang.flag} ` : ''}
|
||||||
{compact ? lang.code.split('-')[0].toUpperCase() : lang.label}
|
{compact ? lang.iso.toUpperCase() : lang.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -96,37 +60,34 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to manage voice language state with user profile default
|
* Hook to manage voice language state with user profile default.
|
||||||
|
* Initial value falls back to the catalog-derived default for the profile language.
|
||||||
*/
|
*/
|
||||||
export const useVoiceLanguage = (initialValue?: string) => {
|
export const useVoiceLanguage = (initialValue?: string) => {
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
|
const { languages } = useVoiceCatalog();
|
||||||
// Track if user has manually changed the language
|
const defaultLocale = useDefaultVoiceLocale(currentLanguage);
|
||||||
|
|
||||||
const hasManuallyChanged = React.useRef(false);
|
const hasManuallyChanged = React.useRef(false);
|
||||||
|
|
||||||
// Initialize with user's profile language (or provided initial value)
|
|
||||||
const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
|
const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
|
||||||
initialValue || getDefaultVoiceLanguage(currentLanguage)
|
initialValue || defaultLocale,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update voice language when user profile language changes (only if not manually set)
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!initialValue && !hasManuallyChanged.current) {
|
if (!initialValue && !hasManuallyChanged.current) {
|
||||||
const newDefault = getDefaultVoiceLanguage(currentLanguage);
|
setVoiceLanguage(defaultLocale);
|
||||||
setVoiceLanguage(newDefault);
|
|
||||||
}
|
}
|
||||||
}, [currentLanguage, initialValue]);
|
}, [defaultLocale, initialValue]);
|
||||||
|
|
||||||
// Wrapper to track manual changes
|
|
||||||
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
|
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
|
||||||
hasManuallyChanged.current = true;
|
hasManuallyChanged.current = true;
|
||||||
setVoiceLanguage(newLanguage);
|
setVoiceLanguage(newLanguage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
voiceLanguage,
|
voiceLanguage,
|
||||||
setVoiceLanguage: handleSetVoiceLanguage,
|
setVoiceLanguage: handleSetVoiceLanguage,
|
||||||
voiceLanguages,
|
voiceLanguages: languages,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
export {
|
export {
|
||||||
VoiceLanguageSelect,
|
VoiceLanguageSelect,
|
||||||
useVoiceLanguage,
|
useVoiceLanguage,
|
||||||
getDefaultVoiceLanguage,
|
|
||||||
voiceLanguages,
|
|
||||||
type VoiceLanguageOption,
|
type VoiceLanguageOption,
|
||||||
type VoiceLanguageSelectProps
|
type VoiceLanguageSelectProps,
|
||||||
} from './VoiceLanguageSelect';
|
} from './VoiceLanguageSelect';
|
||||||
|
|
|
||||||
151
src/contexts/VoiceCatalogContext.tsx
Normal file
151
src/contexts/VoiceCatalogContext.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* VoiceCatalogContext
|
||||||
|
*
|
||||||
|
* Loads the central voice/language catalog from the backend exactly once and
|
||||||
|
* makes it available to every component via `useVoiceCatalog()`.
|
||||||
|
*
|
||||||
|
* Provides convenience helpers for ISO ⇄ BCP-47 lookups and curated default
|
||||||
|
* voices, mirroring the backend `voiceCatalog` API. Components MUST NOT keep
|
||||||
|
* their own static language lists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { fetchVoiceCatalog, VoiceLanguage } from '../api/voiceCatalogApi';
|
||||||
|
|
||||||
|
interface VoiceCatalogContextType {
|
||||||
|
languages: VoiceLanguage[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
getByBcp47: (code: string | null | undefined) => VoiceLanguage | undefined;
|
||||||
|
getByIso: (iso: string | null | undefined) => VoiceLanguage | undefined;
|
||||||
|
isoToBcp47: (iso: string | null | undefined) => string | undefined;
|
||||||
|
getDefaultVoice: (bcp47: string | null | undefined) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoiceCatalogContext = createContext<VoiceCatalogContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface VoiceCatalogProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceCatalogProvider: React.FC<VoiceCatalogProviderProps> = ({ children }) => {
|
||||||
|
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchVoiceCatalog();
|
||||||
|
if (!cancelled) {
|
||||||
|
setLanguages(data);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err?.message || 'Failed to load voice catalog');
|
||||||
|
setLanguages([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const byBcp47 = useMemo(() => {
|
||||||
|
const map = new Map<string, VoiceLanguage>();
|
||||||
|
for (const v of languages) map.set(v.bcp47.toLowerCase(), v);
|
||||||
|
return map;
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
const byIso = useMemo(() => {
|
||||||
|
const map = new Map<string, VoiceLanguage>();
|
||||||
|
for (const v of languages) {
|
||||||
|
if (!map.has(v.iso.toLowerCase())) map.set(v.iso.toLowerCase(), v);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
const getByBcp47 = useCallback(
|
||||||
|
(code: string | null | undefined) =>
|
||||||
|
code ? byBcp47.get(code.trim().toLowerCase()) : undefined,
|
||||||
|
[byBcp47],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getByIso = useCallback(
|
||||||
|
(iso: string | null | undefined) =>
|
||||||
|
iso ? byIso.get(iso.trim().toLowerCase()) : undefined,
|
||||||
|
[byIso],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isoToBcp47 = useCallback(
|
||||||
|
(iso: string | null | undefined): string | undefined => {
|
||||||
|
if (!iso) return undefined;
|
||||||
|
const trimmed = iso.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed.includes('-')) {
|
||||||
|
const canonical = byBcp47.get(trimmed.toLowerCase());
|
||||||
|
return canonical ? canonical.bcp47 : trimmed;
|
||||||
|
}
|
||||||
|
const entry = byIso.get(trimmed.toLowerCase());
|
||||||
|
if (entry) return entry.bcp47;
|
||||||
|
return `${trimmed.toLowerCase()}-${trimmed.toUpperCase()}`;
|
||||||
|
},
|
||||||
|
[byBcp47, byIso],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDefaultVoice = useCallback(
|
||||||
|
(bcp47: string | null | undefined): string | null => {
|
||||||
|
const entry = getByBcp47(bcp47);
|
||||||
|
return entry?.defaultVoice ?? null;
|
||||||
|
},
|
||||||
|
[getByBcp47],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<VoiceCatalogContextType>(
|
||||||
|
() => ({
|
||||||
|
languages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
getByBcp47,
|
||||||
|
getByIso,
|
||||||
|
isoToBcp47,
|
||||||
|
getDefaultVoice,
|
||||||
|
}),
|
||||||
|
[languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VoiceCatalogContext.Provider value={value}>{children}</VoiceCatalogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVoiceCatalog = (): VoiceCatalogContextType => {
|
||||||
|
const ctx = useContext(VoiceCatalogContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useVoiceCatalog must be used within VoiceCatalogProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a profile language (ISO short code) to a default voice locale.
|
||||||
|
* Returns the catalog's BCP-47 for the ISO if available, else falls back to
|
||||||
|
* `de-DE` so the UI always has a deterministic starting value.
|
||||||
|
*/
|
||||||
|
export const useDefaultVoiceLocale = (profileLanguage?: string | null): string => {
|
||||||
|
const { isoToBcp47 } = useVoiceCatalog();
|
||||||
|
return isoToBcp47(profileLanguage) || 'de-DE';
|
||||||
|
};
|
||||||
|
|
@ -11,6 +11,7 @@ import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
||||||
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||||
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
|
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
|
||||||
import styles from './Settings.module.css';
|
import styles from './Settings.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -92,6 +93,7 @@ interface VoiceMapEntry { language: string; voiceName: string; }
|
||||||
const VoiceSettingsTab: React.FC = () => {
|
const VoiceSettingsTab: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const { languages: voiceCatalog } = useVoiceCatalog();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -100,7 +102,6 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
||||||
const [languages, setLanguages] = useState<any[]>([]);
|
|
||||||
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||||
|
|
||||||
const [addLanguage, setAddLanguage] = useState('de-DE');
|
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||||
|
|
@ -111,13 +112,7 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
const _loadSettings = useCallback(async () => {
|
const _loadSettings = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [prefsData, languagesData] = await Promise.all([
|
const prefsData = await request({ url: '/api/voice/preferences', method: 'get' });
|
||||||
request({ url: '/api/voice/preferences', method: 'get' }),
|
|
||||||
request({ url: '/api/voice/languages', method: 'get' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const langList = (languagesData as any)?.languages || [];
|
|
||||||
setLanguages(langList);
|
|
||||||
|
|
||||||
const prefs = prefsData as any;
|
const prefs = prefsData as any;
|
||||||
setSttLanguage(prefs?.sttLanguage || 'de-DE');
|
setSttLanguage(prefs?.sttLanguage || 'de-DE');
|
||||||
|
|
@ -203,16 +198,9 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const _getLanguageName = useCallback((code: string) => {
|
const _getLanguageName = useCallback((code: string) => {
|
||||||
const found = languages.find((l: any) => (l.code || l) === code);
|
const entry = voiceCatalog.find(l => l.bcp47.toLowerCase() === code.toLowerCase());
|
||||||
return found?.name || found?.code || code;
|
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
|
||||||
}, [languages]);
|
}, [voiceCatalog]);
|
||||||
|
|
||||||
const _defaultLangs = [
|
|
||||||
{ code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' },
|
|
||||||
{ code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' },
|
|
||||||
{ code: 'es-ES', name: 'Espanol' },
|
|
||||||
];
|
|
||||||
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
|
|
||||||
|
|
||||||
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
|
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
|
||||||
|
|
||||||
|
|
@ -230,8 +218,10 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.settingControl}>
|
<div className={styles.settingControl}>
|
||||||
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
||||||
{_displayLanguages.map((lang: any) => (
|
{voiceCatalog.map(lang => (
|
||||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,8 +264,10 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label>
|
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label>
|
||||||
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
||||||
{_displayLanguages.map((lang: any) => (
|
{voiceCatalog.map(lang => (
|
||||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
||||||
|
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||||
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||||
import styles from './Teamsbot.module.css';
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
|
@ -36,8 +37,8 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
||||||
|
|
||||||
// Dynamic voice data from Google TTS API
|
// Voice catalog (single source of truth) + dynamic voices for the selected language
|
||||||
const [languages, setLanguages] = useState<string[]>([]);
|
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
|
||||||
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
||||||
const [loadingVoices, setLoadingVoices] = useState(false);
|
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||||
|
|
||||||
|
|
@ -247,19 +248,13 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
value={formData.language || 'de-DE'}
|
value={formData.language || 'de-DE'}
|
||||||
onChange={(e) => _handleLanguageChange(e.target.value)}
|
onChange={(e) => _handleLanguageChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{languages.length > 0 ? (
|
{languages.map(lang => (
|
||||||
languages.map((langCode, idx) => (
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
<option key={`${langCode}-${idx}`} value={langCode}>{langCode}</option>
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
|
||||||
))
|
</option>
|
||||||
) : (
|
))}
|
||||||
<>
|
|
||||||
<option value="de-DE">{t('Deutsch (Deutschland)')}</option>
|
|
||||||
<option value="en-US">{t('Englisch (US)')}</option>
|
|
||||||
<option value="fr-FR">Francais</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
<span className={styles.hint}>Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar)</span>
|
<span className={styles.hint}>{t('Sprache für Captions und Sprachausgabe')} ({languages.length} {t('Sprachen verfügbar')})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,7 @@ import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext';
|
||||||
const _STT_LANGUAGES = [
|
|
||||||
{ code: 'de-DE', label: 'Deutsch' },
|
|
||||||
{ code: 'en-US', label: 'English (US)' },
|
|
||||||
{ code: 'en-GB', label: 'English (UK)' },
|
|
||||||
{ code: 'fr-FR', label: 'Francais' },
|
|
||||||
{ code: 'it-IT', label: 'Italiano' },
|
|
||||||
{ code: 'es-ES', label: 'Espanol' },
|
|
||||||
{ code: 'pt-BR', label: 'Portugues' },
|
|
||||||
{ code: 'nl-NL', label: 'Nederlands' },
|
|
||||||
{ code: 'pl-PL', label: 'Polski' },
|
|
||||||
{ code: 'ru-RU', label: 'Russkij' },
|
|
||||||
{ code: 'ja-JP', label: 'Japanese' },
|
|
||||||
{ code: 'zh-CN', label: 'Chinese' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface PendingFile {
|
interface PendingFile {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
|
|
@ -88,6 +74,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
onDraftAppendConsumed,
|
onDraftAppendConsumed,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||||
|
|
@ -617,23 +604,23 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||||
maxHeight: 240, overflowY: 'auto', minWidth: 160,
|
maxHeight: 240, overflowY: 'auto', minWidth: 160,
|
||||||
}}>
|
}}>
|
||||||
{_STT_LANGUAGES.map(lang => (
|
{voiceCatalogLanguages.map(lang => (
|
||||||
<div
|
<div
|
||||||
key={lang.code}
|
key={lang.bcp47}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVoiceLanguage(lang.code);
|
setVoiceLanguage(lang.bcp47);
|
||||||
setShowLangPicker(false);
|
setShowLangPicker(false);
|
||||||
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
|
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.bcp47 }) }).catch(() => {});
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||||
background: lang.code === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
|
background: lang.bcp47 === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
|
||||||
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
|
color: lang.bcp47 === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
|
onMouseEnter={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
|
||||||
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
|
onMouseLeave={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = ''; }}
|
||||||
>
|
>
|
||||||
{lang.label} ({lang.code})
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue