feat: add voice language selection in settings

Add support for users to select voice language preference, which is passed through to ElevenLabs agents via platform settings overrides. Includes new language mapping utilities, UI controls in settings page, and updated voice session initialization.
This commit is contained in:
weishu
2026-01-20 00:57:01 +08:00
parent 7a5946731d
commit 15d550d39a
9 changed files with 263 additions and 8 deletions
+21
View File
@@ -201,6 +201,16 @@ export interface VoiceAgentConfig {
speed: number
}
}
platform_settings?: {
overrides?: {
conversation_config_override?: {
agent?: {
language?: boolean
first_message?: boolean
}
}
}
}
}
/**
@@ -231,6 +241,17 @@ export function buildVoiceAgentConfig(): VoiceAgentConfig {
model_id: 'eleven_flash_v2',
speed: 1.1
}
},
// Enable runtime overrides for language selection
// See: https://elevenlabs.io/docs/agents-platform/customization/personalization/overrides
platform_settings: {
overrides: {
conversation_config_override: {
agent: {
language: true
}
}
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// ElevenLabs supported language codes
export type ElevenLabsLanguage = "en" | "ja" | "zh" | "de" | "hi" | "fr" | "ko" |
"pt" | "pt-br" | "it" | "es" | "id" | "nl" | "tr" | "pl" | "sv" | "bg" |
"ro" | "ar" | "cs" | "el" | "fi" | "ms" | "da" | "ta" | "uk" | "ru" |
"hu" | "hr" | "sk" | "no" | "vi" | "tl";
// Language type definition
export interface Language {
code: string | null; // null for autodetect
name: string;
nativeName: string;
region?: string;
elevenLabsCode?: ElevenLabsLanguage; // ElevenLabs language code mapping
}
// Comprehensive language list with locale codes, names, and regions
// First option is autodetect (null value)
export const LANGUAGES: Language[] = [
{ code: null, name: 'Auto-detect', nativeName: 'Auto-detect' },
{ code: 'en-US', name: 'English', nativeName: 'English', region: 'United States', elevenLabsCode: 'en' },
{ code: 'en-GB', name: 'English', nativeName: 'English', region: 'United Kingdom', elevenLabsCode: 'en' },
{ code: 'en-AU', name: 'English', nativeName: 'English', region: 'Australia', elevenLabsCode: 'en' },
{ code: 'en-CA', name: 'English', nativeName: 'English', region: 'Canada', elevenLabsCode: 'en' },
{ code: 'es-ES', name: 'Spanish', nativeName: 'Español', region: 'Spain', elevenLabsCode: 'es' },
{ code: 'es-MX', name: 'Spanish', nativeName: 'Español', region: 'Mexico', elevenLabsCode: 'es' },
{ code: 'es-AR', name: 'Spanish', nativeName: 'Español', region: 'Argentina', elevenLabsCode: 'es' },
{ code: 'fr-FR', name: 'French', nativeName: 'Français', region: 'France', elevenLabsCode: 'fr' },
{ code: 'fr-CA', name: 'French', nativeName: 'Français', region: 'Canada', elevenLabsCode: 'fr' },
{ code: 'de-DE', name: 'German', nativeName: 'Deutsch', region: 'Germany', elevenLabsCode: 'de' },
{ code: 'de-AT', name: 'German', nativeName: 'Deutsch', region: 'Austria', elevenLabsCode: 'de' },
{ code: 'it-IT', name: 'Italian', nativeName: 'Italiano', elevenLabsCode: 'it' },
{ code: 'pt-BR', name: 'Portuguese', nativeName: 'Português', region: 'Brazil', elevenLabsCode: 'pt-br' },
{ code: 'pt-PT', name: 'Portuguese', nativeName: 'Português', region: 'Portugal', elevenLabsCode: 'pt' },
{ code: 'ru-RU', name: 'Russian', nativeName: 'Русский', elevenLabsCode: 'ru' },
{ code: 'zh-CN', name: 'Chinese', nativeName: '中文', region: 'Simplified', elevenLabsCode: 'zh' },
{ code: 'zh-TW', name: 'Chinese', nativeName: '中文', region: 'Traditional', elevenLabsCode: 'zh' },
{ code: 'ja-JP', name: 'Japanese', nativeName: '日本語', elevenLabsCode: 'ja' },
{ code: 'ko-KR', name: 'Korean', nativeName: '한국어', elevenLabsCode: 'ko' },
{ code: 'ar-SA', name: 'Arabic', nativeName: 'العربية', elevenLabsCode: 'ar' },
{ code: 'hi-IN', name: 'Hindi', nativeName: 'हिन्दी', elevenLabsCode: 'hi' },
{ code: 'nl-NL', name: 'Dutch', nativeName: 'Nederlands', elevenLabsCode: 'nl' },
{ code: 'sv-SE', name: 'Swedish', nativeName: 'Svenska', elevenLabsCode: 'sv' },
{ code: 'no-NO', name: 'Norwegian', nativeName: 'Norsk', elevenLabsCode: 'no' },
{ code: 'da-DK', name: 'Danish', nativeName: 'Dansk', elevenLabsCode: 'da' },
{ code: 'fi-FI', name: 'Finnish', nativeName: 'Suomi', elevenLabsCode: 'fi' },
{ code: 'pl-PL', name: 'Polish', nativeName: 'Polski', elevenLabsCode: 'pl' },
{ code: 'tr-TR', name: 'Turkish', nativeName: 'Türkçe', elevenLabsCode: 'tr' },
{ code: 'he-IL', name: 'Hebrew', nativeName: 'עברית' }, // Not supported by ElevenLabs
{ code: 'th-TH', name: 'Thai', nativeName: 'ไทย' }, // Not supported by ElevenLabs
{ code: 'vi-VN', name: 'Vietnamese', nativeName: 'Tiếng Việt', elevenLabsCode: 'vi' },
{ code: 'id-ID', name: 'Indonesian', nativeName: 'Bahasa Indonesia', elevenLabsCode: 'id' },
{ code: 'ms-MY', name: 'Malay', nativeName: 'Bahasa Melayu', elevenLabsCode: 'ms' },
{ code: 'tl-PH', name: 'Tagalog', nativeName: 'Tagalog', elevenLabsCode: 'tl' },
{ code: 'uk-UA', name: 'Ukrainian', nativeName: 'Українська', elevenLabsCode: 'uk' },
{ code: 'cs-CZ', name: 'Czech', nativeName: 'Čeština', elevenLabsCode: 'cs' },
{ code: 'hu-HU', name: 'Hungarian', nativeName: 'Magyar', elevenLabsCode: 'hu' },
{ code: 'ro-RO', name: 'Romanian', nativeName: 'Română', elevenLabsCode: 'ro' },
{ code: 'bg-BG', name: 'Bulgarian', nativeName: 'Български', elevenLabsCode: 'bg' },
{ code: 'el-GR', name: 'Greek', nativeName: 'Ελληνικά', elevenLabsCode: 'el' },
{ code: 'hr-HR', name: 'Croatian', nativeName: 'Hrvatski', elevenLabsCode: 'hr' },
{ code: 'sk-SK', name: 'Slovak', nativeName: 'Slovenčina', elevenLabsCode: 'sk' },
{ code: 'sl-SI', name: 'Slovenian', nativeName: 'Slovenščina' }, // Not supported by ElevenLabs
{ code: 'et-EE', name: 'Estonian', nativeName: 'Eesti' }, // Not supported by ElevenLabs
{ code: 'lv-LV', name: 'Latvian', nativeName: 'Latviešu' }, // Not supported by ElevenLabs
{ code: 'lt-LT', name: 'Lithuanian', nativeName: 'Lietuvių' }, // Not supported by ElevenLabs
];
/**
* Format display name for a language
*/
export const getLanguageDisplayName = (language: Language) => {
const parts = [];
if (language.name !== language.nativeName) {
parts.push(`${language.name} (${language.nativeName})`);
} else {
parts.push(language.name);
}
if (language.region) {
parts.push(language.region);
}
return parts.join(' - ');
};
/**
* Find a language by its code (including null for autodetect)
*/
export const findLanguageByCode = (code: string | null): Language | undefined => {
return LANGUAGES.find(lang => lang.code === code);
};
/**
* Get the ElevenLabs language code for a given language
*/
export const getElevenLabsCode = (language: Language): ElevenLabsLanguage | undefined => {
return language.elevenLabsCode;
};
/**
* Get ElevenLabs code from user's language preference (handles null/autodetect)
*/
export const getElevenLabsCodeFromPreference = (
languageCode: string | null
): ElevenLabsLanguage | undefined => {
if (!languageCode) return undefined; // Auto-detect case
const language = findLanguageByCode(languageCode);
return language?.elevenLabsCode;
};
/**
* Get all languages that support ElevenLabs (including auto-detect)
*/
export const getElevenLabsSupportedLanguages = (): Language[] => {
return LANGUAGES.filter(lang => lang.code === null || lang.elevenLabsCode !== undefined);
};
+3
View File
@@ -229,6 +229,9 @@ export default {
'settings.title': 'Settings',
'settings.language.title': 'Language',
'settings.language.label': 'Language',
'settings.voice.title': 'Voice Assistant',
'settings.voice.language': 'Voice Language',
'settings.voice.autoDetect': 'Auto-detect',
// Misc
'misc.noMachines': 'No machines available',
+3
View File
@@ -231,6 +231,9 @@ export default {
'settings.title': '设置',
'settings.language.title': '语言',
'settings.language.label': '语言',
'settings.voice.title': '语音助手',
'settings.voice.language': '语音语言',
'settings.voice.autoDetect': '自动检测',
// Misc
'misc.noMachines': '无可用机器',
+7 -1
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import type { ConversationStatus, StatusCallback } from '@/realtime/types'
import { startRealtimeSession, stopRealtimeSession, voiceHooks } from '@/realtime'
import { getElevenLabsCodeFromPreference } from '@/lib/languages'
interface VoiceContextValue {
status: ConversationStatus
@@ -38,7 +39,12 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
const startVoice = useCallback(async (sessionId: string) => {
setCurrentSessionId(sessionId)
const initialContext = voiceHooks.onVoiceStarted(sessionId)
await startRealtimeSession(sessionId, initialContext)
// Read voice language preference from localStorage
const voiceLang = localStorage.getItem('hapi-voice-lang')
const elevenLabsLang = getElevenLabsCodeFromPreference(voiceLang)
await startRealtimeSession(sessionId, initialContext, elevenLabsLang)
}, [])
const stopVoice = useCallback(async () => {
+8 -2
View File
@@ -1,10 +1,15 @@
import type { VoiceSession } from './types'
import type { ElevenLabsLanguage } from '@/lib/languages'
let voiceSession: VoiceSession | null = null
let voiceSessionStarted = false
let currentSessionId: string | null = null
export async function startRealtimeSession(sessionId: string, initialContext?: string) {
export async function startRealtimeSession(
sessionId: string,
initialContext?: string,
language?: ElevenLabsLanguage
) {
if (!voiceSession) {
console.warn('[Voice] No voice session registered')
return
@@ -14,7 +19,8 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s
currentSessionId = sessionId
await voiceSession.startSession({
sessionId,
initialContext
initialContext,
language
})
voiceSessionStarted = true
} catch (error) {
@@ -70,6 +70,13 @@ class RealtimeVoiceSessionImpl implements VoiceSession {
dynamicVariables: {
sessionId: config.sessionId,
initialConversationContext: config.initialContext || ''
},
// Language override - requires agent to have platform_settings.overrides enabled
// See: https://elevenlabs.io/docs/agents-platform/customization/personalization/overrides
overrides: {
agent: {
language: config.language
}
}
})
+3
View File
@@ -1,6 +1,9 @@
import type { ElevenLabsLanguage } from '@/lib/languages'
export interface VoiceSessionConfig {
sessionId: string
initialContext?: string
language?: ElevenLabsLanguage
}
export interface VoiceSession {
+94 -5
View File
@@ -1,12 +1,15 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation, type Locale } from '@/lib/use-translation'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages'
const locales: { value: Locale; nativeLabel: string }[] = [
{ value: 'en', nativeLabel: 'English' },
{ value: 'zh-CN', nativeLabel: '简体中文' },
]
const voiceLanguages = getElevenLabsSupportedLanguages()
function BackIcon(props: { className?: string }) {
return (
<svg
@@ -68,42 +71,64 @@ export default function SettingsPage() {
const { t, locale, setLocale } = useTranslation()
const goBack = useAppGoBack()
const [isOpen, setIsOpen] = useState(false)
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
// Voice language state - read from localStorage
const [voiceLanguage, setVoiceLanguage] = useState<string | null>(() => {
return localStorage.getItem('hapi-voice-lang')
})
const currentLocale = locales.find((loc) => loc.value === locale)
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
const handleLocaleChange = (newLocale: Locale) => {
setLocale(newLocale)
setIsOpen(false)
}
const handleVoiceLanguageChange = (language: Language) => {
setVoiceLanguage(language.code)
if (language.code === null) {
localStorage.removeItem('hapi-voice-lang')
} else {
localStorage.setItem('hapi-voice-lang', language.code)
}
setIsVoiceOpen(false)
}
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen) return
if (!isOpen && !isVoiceOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) {
setIsVoiceOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
}, [isOpen, isVoiceOpen])
// Close on escape key
useEffect(() => {
if (!isOpen) return
if (!isOpen && !isVoiceOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
setIsVoiceOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
}, [isOpen, isVoiceOpen])
return (
<div className="flex h-full flex-col">
@@ -176,6 +201,70 @@ export default function SettingsPage() {
)}
</div>
</div>
{/* Voice Assistant section */}
<div className="border-b border-[var(--app-divider)]">
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">
{t('settings.voice.title')}
</div>
<div ref={voiceContainerRef} className="relative">
<button
type="button"
onClick={() => setIsVoiceOpen(!isVoiceOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isVoiceOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.voice.language')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>
{currentVoiceLanguage
? currentVoiceLanguage.code === null
? t('settings.voice.autoDetect')
: getLanguageDisplayName(currentVoiceLanguage)
: t('settings.voice.autoDetect')}
</span>
<ChevronDownIcon className={`transition-transform ${isVoiceOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isVoiceOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[200px] max-h-[300px] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg z-50"
role="listbox"
aria-label={t('settings.voice.title')}
>
{voiceLanguages.map((lang) => {
const isSelected = voiceLanguage === lang.code
const displayName = lang.code === null
? t('settings.voice.autoDetect')
: getLanguageDisplayName(lang)
return (
<button
key={lang.code ?? 'auto'}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleVoiceLanguageChange(lang)}
className={`flex items-center justify-between w-full px-3 py-2 text-sm text-left transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<span>{displayName}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>