centralized language catalog

This commit is contained in:
ValueOn AG 2026-04-19 00:36:42 +02:00
parent d814a76660
commit 13f4574098
9 changed files with 277 additions and 157 deletions

View file

@ -31,6 +31,7 @@ import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext';
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
import { FileProvider } from './contexts/FileContext';
import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext';
import { MainLayout } from './layouts/MainLayout';
import { FeatureLayout } from './layouts/FeatureLayout';
import { DashboardPage } from './pages/Dashboard';
@ -72,6 +73,7 @@ function App() {
<LanguageProvider>
<AuthProvider>
<ToastProvider>
<VoiceCatalogProvider>
<WorkflowSelectionProvider>
<Router>
<Routes>
@ -231,6 +233,7 @@ function App() {
</Routes>
</Router>
</WorkflowSelectionProvider>
</VoiceCatalogProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>

View file

@ -102,18 +102,10 @@ export interface ConfigUpdateRequest {
debugMode?: boolean;
}
// Voice/Language Types (from Google TTS API)
export interface VoiceLanguage {
code: string;
name: string;
}
export interface VoiceOption {
name: string;
languageCodes: string[];
ssmlGender: string;
naturalSampleRateHertz: number;
}
// Voice option type re-exported from the central voice catalog API.
// The legacy teamsbot-specific {code,name} language type is gone — consumers
// should use VoiceLanguage from voiceCatalogApi (catalog SSOT).
export type { VoiceOption } from './voiceCatalogApi';
// Auth Detection Test Types
export interface StepScreenshot {
@ -313,25 +305,19 @@ export async function testVoice(
}
/**
* Fetch available TTS languages from Google Cloud.
* Returns array of language codes (e.g. ["de-DE", "en-US", ...])
* Fetch the curated voice/language catalog (single source of truth).
* Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy
* teamsbot consumers stay on one import surface.
*/
export async function fetchLanguages(): Promise<string[]> {
try {
const response = await api.get('/voice-google/languages');
return response.data?.languages || [];
} catch {
return [];
}
}
export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi';
/**
* Fetch available TTS voices for a language from Google Cloud.
*/
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
try {
const response = await api.get('/voice-google/voices', {
params: { languageCode },
const response = await api.get('/api/voice/voices', {
params: { language: languageCode },
});
return response.data?.voices || [];
} catch {

View 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 ?? [];
}

View file

@ -1,67 +1,29 @@
/**
* VoiceLanguageSelect
*
* Reusable component for selecting voice/speech recognition language.
* Defaults to user's profile language.
* Can be used for speech-to-text, text-to-speech, and translation features.
*
* Reusable picker for voice/speech-recognition language. Reads the language
* list from the central VoiceCatalog (single source of truth) never
* hard-coded here.
*/
import React from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useVoiceCatalog, useDefaultVoiceLocale } from '../../../contexts/VoiceCatalogContext';
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
import styles from './VoiceLanguageSelect.module.css';
// Voice language options with full locale codes for Google Cloud Speech
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 type VoiceLanguageOption = VoiceLanguage;
export interface VoiceLanguageSelectProps {
value: string;
onChange: (languageCode: string) => void;
disabled?: boolean;
compact?: boolean; // Compact mode shows only flag/short code
showFlags?: boolean; // Show flag emojis
compact?: boolean;
showFlags?: boolean;
className?: 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> = ({
value,
onChange,
@ -71,23 +33,25 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
className = '',
title = 'Sprache für Spracherkennung',
}) => {
const { languages, isLoading } = useVoiceCatalog();
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value);
};
return (
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
<select
className={styles.select}
value={value}
onChange={handleChange}
disabled={disabled}
disabled={disabled || isLoading}
title={title}
>
{voiceLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{languages.map((lang) => (
<option key={lang.bcp47} value={lang.bcp47}>
{showFlags && lang.flag ? `${lang.flag} ` : ''}
{compact ? lang.code.split('-')[0].toUpperCase() : lang.label}
{compact ? lang.iso.toUpperCase() : lang.label}
</option>
))}
</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) => {
const { currentLanguage } = useLanguage();
// Track if user has manually changed the language
const { languages } = useVoiceCatalog();
const defaultLocale = useDefaultVoiceLocale(currentLanguage);
const hasManuallyChanged = React.useRef(false);
// Initialize with user's profile language (or provided initial value)
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(() => {
if (!initialValue && !hasManuallyChanged.current) {
const newDefault = getDefaultVoiceLanguage(currentLanguage);
setVoiceLanguage(newDefault);
setVoiceLanguage(defaultLocale);
}
}, [currentLanguage, initialValue]);
// Wrapper to track manual changes
}, [defaultLocale, initialValue]);
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
hasManuallyChanged.current = true;
setVoiceLanguage(newLanguage);
}, []);
return {
voiceLanguage,
setVoiceLanguage: handleSetVoiceLanguage,
voiceLanguages,
voiceLanguages: languages,
};
};

View file

@ -1,8 +1,6 @@
export {
VoiceLanguageSelect,
useVoiceLanguage,
getDefaultVoiceLanguage,
voiceLanguages,
export {
VoiceLanguageSelect,
useVoiceLanguage,
type VoiceLanguageOption,
type VoiceLanguageSelectProps
type VoiceLanguageSelectProps,
} from './VoiceLanguageSelect';

View 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';
};

View file

@ -11,6 +11,7 @@ import { setUserDataCache, getUserDataCache } from '../utils/userCache';
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import { useApiRequest } from '../hooks/useApi';
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
import styles from './Settings.module.css';
// =============================================================================
@ -92,6 +93,7 @@ interface VoiceMapEntry { language: string; voiceName: string; }
const VoiceSettingsTab: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { languages: voiceCatalog } = useVoiceCatalog();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -100,7 +102,6 @@ const VoiceSettingsTab: React.FC = () => {
const [success, setSuccess] = useState<string | null>(null);
const [sttLanguage, setSttLanguage] = useState('de-DE');
const [languages, setLanguages] = useState<any[]>([]);
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
const [addLanguage, setAddLanguage] = useState('de-DE');
@ -111,13 +112,7 @@ const VoiceSettingsTab: React.FC = () => {
const _loadSettings = useCallback(async () => {
setLoading(true);
try {
const [prefsData, languagesData] = await Promise.all([
request({ url: '/api/voice/preferences', method: 'get' }),
request({ url: '/api/voice/languages', method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
setLanguages(langList);
const prefsData = await request({ url: '/api/voice/preferences', method: 'get' });
const prefs = prefsData as any;
setSttLanguage(prefs?.sttLanguage || 'de-DE');
@ -203,16 +198,9 @@ const VoiceSettingsTab: React.FC = () => {
}, [request]);
const _getLanguageName = useCallback((code: string) => {
const found = languages.find((l: any) => (l.code || l) === code);
return found?.name || found?.code || code;
}, [languages]);
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;
const entry = voiceCatalog.find(l => l.bcp47.toLowerCase() === code.toLowerCase());
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
}, [voiceCatalog]);
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
@ -230,8 +218,10 @@ const VoiceSettingsTab: React.FC = () => {
</div>
<div className={styles.settingControl}>
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
{voiceCatalog.map(lang => (
<option key={lang.bcp47} value={lang.bcp47}>
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
</option>
))}
</select>
</div>
@ -274,8 +264,10 @@ const VoiceSettingsTab: React.FC = () => {
<div>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label>
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
{voiceCatalog.map(lang => (
<option key={lang.bcp47} value={lang.bcp47}>
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
</option>
))}
</select>
</div>

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi 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 styles from './Teamsbot.module.css';
@ -36,8 +37,8 @@ export const TeamsbotSettingsView: React.FC = () => {
// Form state
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
// Dynamic voice data from Google TTS API
const [languages, setLanguages] = useState<string[]>([]);
// Voice catalog (single source of truth) + dynamic voices for the selected language
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false);
@ -247,19 +248,13 @@ export const TeamsbotSettingsView: React.FC = () => {
value={formData.language || 'de-DE'}
onChange={(e) => _handleLanguageChange(e.target.value)}
>
{languages.length > 0 ? (
languages.map((langCode, idx) => (
<option key={`${langCode}-${idx}`} value={langCode}>{langCode}</option>
))
) : (
<>
<option value="de-DE">{t('Deutsch (Deutschland)')}</option>
<option value="en-US">{t('Englisch (US)')}</option>
<option value="fr-FR">Francais</option>
</>
)}
{languages.map(lang => (
<option key={lang.bcp47} value={lang.bcp47}>
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
</option>
))}
</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 className={styles.formGroup}>

View file

@ -11,21 +11,7 @@ import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
import { useLanguage } from '../../../providers/language/LanguageContext';
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' },
];
import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext';
interface PendingFile {
fileId: string;
@ -88,6 +74,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onDraftAppendConsumed,
}) => {
const { t } = useLanguage();
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
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,
maxHeight: 240, overflowY: 'auto', minWidth: 160,
}}>
{_STT_LANGUAGES.map(lang => (
{voiceCatalogLanguages.map(lang => (
<div
key={lang.code}
key={lang.bcp47}
onClick={() => {
setVoiceLanguage(lang.code);
setVoiceLanguage(lang.bcp47);
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={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.code === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
background: lang.bcp47 === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
color: lang.bcp47 === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}}
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
onMouseEnter={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
onMouseLeave={e => { if (lang.bcp47 !== voiceLanguage) e.currentTarget.style.background = ''; }}
>
{lang.label} ({lang.code})
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
</div>
))}
</div>