mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mascot): locale-aware voice + multilingual LLM replies (#2115)
This commit is contained in:
@@ -1,21 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BackendMascot } from '../../../features/human/Mascot/backend/BackendMascot';
|
||||
import type { MascotDetail, MascotSummary } from '../../../features/human/Mascot/backend/types';
|
||||
import { getMascotPalette, type MascotColor } from '../../../features/human/Mascot/mascotPalette';
|
||||
import { synthesizeSpeech } from '../../../features/human/voice/ttsClient';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { fetchMascotList, getCachedMascotDetail } from '../../../services/mascotService';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
DEFAULT_MASCOT_COLOR,
|
||||
type MascotVoiceGender,
|
||||
selectEffectiveMascotVoiceId,
|
||||
selectMascotColor,
|
||||
selectMascotVoiceGender,
|
||||
selectMascotVoiceId,
|
||||
selectMascotVoiceUseLocaleDefault,
|
||||
selectSelectedMascotId,
|
||||
setMascotColor,
|
||||
setMascotVoiceGender,
|
||||
setMascotVoiceId,
|
||||
setMascotVoiceUseLocaleDefault,
|
||||
setSelectedMascotId,
|
||||
SUPPORTED_MASCOT_COLORS,
|
||||
} from '../../../store/mascotSlice';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import {
|
||||
defaultVoiceIdForLocale,
|
||||
ELEVENLABS_VOICE_PRESETS,
|
||||
isCuratedVoicePreset,
|
||||
} from './elevenlabsVoicePresets';
|
||||
|
||||
interface ColorOption {
|
||||
id: MascotColor;
|
||||
@@ -31,11 +45,15 @@ const COLOR_OPTIONS: ColorOption[] = [
|
||||
];
|
||||
|
||||
const MascotPanel = () => {
|
||||
const { t } = useT();
|
||||
const { t, locale } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const dispatch = useAppDispatch();
|
||||
const storedColor = useAppSelector(selectMascotColor);
|
||||
const selectedMascotId = useAppSelector(selectSelectedMascotId);
|
||||
const storedVoiceId = useAppSelector(selectMascotVoiceId);
|
||||
const voiceGender = useAppSelector(selectMascotVoiceGender);
|
||||
const useLocaleDefault = useAppSelector(selectMascotVoiceUseLocaleDefault);
|
||||
const effectiveVoiceId = useAppSelector(selectEffectiveMascotVoiceId);
|
||||
|
||||
// Backend mascot library (PR tinyhumansai/backend#770). The list endpoint
|
||||
// is cheap (no SVG bytes); per-id detail is fetched on demand so the
|
||||
@@ -45,6 +63,21 @@ const MascotPanel = () => {
|
||||
const [activeDetail, setActiveDetail] = useState<MascotDetail | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
// Voice picker state — paste-mode is sticky because we can't derive it
|
||||
// from the stored value alone (a curated preset id and "user is
|
||||
// mid-paste" both leave `storedVoiceId` looking like a known id).
|
||||
const [voiceDraft, setVoiceDraft] = useState<string>(storedVoiceId ?? '');
|
||||
const [voicePasteMode, setVoicePasteMode] = useState<boolean>(false);
|
||||
const [isPreviewingVoice, setIsPreviewingVoice] = useState(false);
|
||||
const [voicePreviewError, setVoicePreviewError] = useState<string | null>(null);
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
// Monotonically-bumped preview-request id. Unmount + each new preview
|
||||
// both increment it so any in-flight `synthesizeSpeech(...)` whose
|
||||
// resolve loses the race is detected and bails out before touching
|
||||
// refs / state — covers the "user navigates away mid-fetch" case the
|
||||
// earlier audio-only cleanup missed.
|
||||
const previewRequestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchMascotList()
|
||||
@@ -87,6 +120,20 @@ const MascotPanel = () => {
|
||||
};
|
||||
}, [selectedMascotId]);
|
||||
|
||||
// Stop any in-flight preview audio when the panel unmounts. Also
|
||||
// bump the preview request id so a `synthesizeSpeech(...)` that
|
||||
// resolves after unmount can detect the staleness and bail.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewRequestIdRef.current += 1;
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectBackend = (id: string | null) => {
|
||||
dispatch(setSelectedMascotId(id));
|
||||
};
|
||||
@@ -109,6 +156,93 @@ const MascotPanel = () => {
|
||||
dispatch(setMascotColor(color));
|
||||
};
|
||||
|
||||
// ── Voice picker handlers ────────────────────────────────────────
|
||||
// Presets the dropdown should expose. Always include the default
|
||||
// mascot voice (regardless of its gender) so the user can fall back
|
||||
// without untoggling the gender filter first. Also always include
|
||||
// the currently-active preset id — otherwise flipping the gender
|
||||
// filter leaves the controlled `<select>` pointing at an id with
|
||||
// no matching `<option>`, and the picker stops reflecting the real
|
||||
// selection.
|
||||
const visiblePresets = ELEVENLABS_VOICE_PRESETS.filter(
|
||||
p => p.id === effectiveVoiceId || p.gender === voiceGender || p.locales.includes('*')
|
||||
);
|
||||
|
||||
const onGenderChange = (next: MascotVoiceGender) => {
|
||||
dispatch(setMascotVoiceGender(next));
|
||||
};
|
||||
|
||||
const onLocaleDefaultToggle = (next: boolean) => {
|
||||
dispatch(setMascotVoiceUseLocaleDefault(next));
|
||||
};
|
||||
|
||||
// All slice writes flow through this component, so the local draft +
|
||||
// preview-error state can be reset inside the same handler that
|
||||
// dispatches `setMascotVoiceId(...)` — no `useEffect` mirror needed
|
||||
// (and the rule `react-hooks/set-state-in-effect` flags effect-based
|
||||
// mirrors as a smell).
|
||||
const onPresetChange = (next: string) => {
|
||||
if (next === '__custom__') {
|
||||
setVoicePasteMode(true);
|
||||
setVoiceDraft(storedVoiceId ?? '');
|
||||
return;
|
||||
}
|
||||
setVoicePasteMode(false);
|
||||
setVoicePreviewError(null);
|
||||
setVoiceDraft(next);
|
||||
dispatch(setMascotVoiceId(next));
|
||||
};
|
||||
|
||||
const onSavePaste = () => {
|
||||
setVoicePreviewError(null);
|
||||
const trimmed = voiceDraft.trim();
|
||||
setVoiceDraft(trimmed);
|
||||
dispatch(setMascotVoiceId(trimmed.length > 0 ? trimmed : null));
|
||||
};
|
||||
|
||||
const onVoiceReset = () => {
|
||||
setVoicePreviewError(null);
|
||||
setVoicePasteMode(false);
|
||||
setVoiceDraft('');
|
||||
dispatch(setMascotVoiceId(null));
|
||||
};
|
||||
|
||||
const onVoicePreview = async () => {
|
||||
// Each click reserves a fresh request id; the unmount cleanup and
|
||||
// every subsequent click bump the ref, so a stale `synthesizeSpeech`
|
||||
// resolve can detect that the user has moved on before it mutates
|
||||
// state or starts audio for a preview that's no longer wanted.
|
||||
const requestId = ++previewRequestIdRef.current;
|
||||
setIsPreviewingVoice(true);
|
||||
setVoicePreviewError(null);
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
try {
|
||||
const tts = await synthesizeSpeech("Hi, I'm your assistant. This is a voice preview.", {
|
||||
voiceId: effectiveVoiceId,
|
||||
});
|
||||
if (previewRequestIdRef.current !== requestId) return;
|
||||
const src = `data:${tts.audio_mime || 'audio/mpeg'};base64,${tts.audio_base64}`;
|
||||
const audio = new window.Audio(src);
|
||||
previewAudioRef.current = audio;
|
||||
await audio.play();
|
||||
} catch (err) {
|
||||
if (previewRequestIdRef.current !== requestId) return;
|
||||
const message = err instanceof Error ? err.message : 'Voice preview failed';
|
||||
setVoicePreviewError(message);
|
||||
} finally {
|
||||
if (previewRequestIdRef.current === requestId) setIsPreviewingVoice(false);
|
||||
}
|
||||
};
|
||||
|
||||
const localeDefaultVoiceId = defaultVoiceIdForLocale(locale, voiceGender);
|
||||
const presetPickerDisabled = useLocaleDefault;
|
||||
const isCustomVoice =
|
||||
!presetPickerDisabled && (voicePasteMode || !isCuratedVoicePreset(effectiveVoiceId));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
@@ -172,6 +306,150 @@ const MascotPanel = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.mascot.voice.heading')}
|
||||
</h3>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.mascot.voice.genderHeading')}
|
||||
className="space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('settings.mascot.voice.genderHeading')}
|
||||
</span>
|
||||
<div className="flex gap-2 pt-1">
|
||||
{(['female', 'male'] as const).map(g => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={voiceGender === g}
|
||||
data-testid={`mascot-voice-gender-${g}`}
|
||||
onClick={() => onGenderChange(g)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md border transition-colors ${
|
||||
voiceGender === g
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/20 text-primary-700 dark:text-primary-200'
|
||||
: 'border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:border-stone-300 dark:hover:border-neutral-700'
|
||||
}`}>
|
||||
{t(
|
||||
g === 'female'
|
||||
? 'settings.mascot.voice.genderFemale'
|
||||
: 'settings.mascot.voice.genderMale'
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-stone-700 dark:text-neutral-200 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="mascot-voice-locale-default"
|
||||
checked={useLocaleDefault}
|
||||
onChange={e => onLocaleDefaultToggle(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-300 dark:border-neutral-700 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span>{t('settings.mascot.voice.useLocaleDefault')}</span>
|
||||
<span className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.mascot.voice.useLocaleDefaultDesc')}{' '}
|
||||
<code className="font-mono">{locale}</code> →{' '}
|
||||
<code className="font-mono">{localeDefaultVoiceId}</code>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className={`block space-y-1 ${presetPickerDisabled ? 'opacity-50' : ''}`}>
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('settings.mascot.voice.presetHeading')}
|
||||
</span>
|
||||
<select
|
||||
aria-label={t('settings.mascot.voice.presetHeading')}
|
||||
data-testid="mascot-voice-select"
|
||||
disabled={presetPickerDisabled}
|
||||
value={isCustomVoice ? '__custom__' : effectiveVoiceId}
|
||||
onChange={e => onPresetChange(e.target.value)}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed">
|
||||
{visiblePresets.map(v => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">{t('settings.mascot.voice.customOption')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{isCustomVoice && (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('settings.mascot.voice.customHeading')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t('settings.mascot.voice.customHeading')}
|
||||
data-testid="mascot-voice-input"
|
||||
value={voiceDraft}
|
||||
placeholder="e.g. 21m00Tcm4TlvDq8ikWAM"
|
||||
onChange={e => setVoiceDraft(e.target.value)}
|
||||
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-voice-save-paste"
|
||||
onClick={onSavePaste}
|
||||
disabled={voiceDraft.trim() === (storedVoiceId ?? '').trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.mascot.voice.customDesc')}
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-voice-preview"
|
||||
onClick={() => void onVoicePreview()}
|
||||
disabled={isPreviewingVoice}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
|
||||
{isPreviewingVoice
|
||||
? t('settings.mascot.voice.previewing')
|
||||
: t('settings.mascot.voice.preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-voice-reset"
|
||||
onClick={onVoiceReset}
|
||||
disabled={storedVoiceId == null}
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
|
||||
{t('settings.mascot.voice.reset')}
|
||||
</button>
|
||||
<span
|
||||
data-testid="mascot-voice-current"
|
||||
className="ml-1 text-[11px] text-stone-500 dark:text-neutral-400 truncate max-w-[18rem]"
|
||||
title={effectiveVoiceId}>
|
||||
{t('settings.mascot.voice.current')}:{' '}
|
||||
<code className="font-mono">{effectiveVoiceId}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{voicePreviewError && (
|
||||
<div
|
||||
data-testid="mascot-voice-preview-error"
|
||||
className="rounded-md border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3 text-xs text-amber-800 dark:text-amber-200">
|
||||
{t('settings.mascot.voice.previewError')}: {voicePreviewError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
|
||||
{t('settings.mascot.voice.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
|
||||
{t('settings.mascot.characterHeading')}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { synthesizeSpeech } from '../../../features/human/voice/ttsClient';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
installPiper,
|
||||
@@ -10,8 +8,6 @@ import {
|
||||
type VoiceInstallStatus,
|
||||
whisperInstallStatus,
|
||||
} from '../../../services/api/voiceInstallApi';
|
||||
import { selectMascotVoiceId, setMascotVoiceId } from '../../../store/mascotSlice';
|
||||
import { MASCOT_VOICE_ID } from '../../../utils/config';
|
||||
import {
|
||||
openhumanGetVoiceServerSettings,
|
||||
openhumanLocalAiAssetsStatus,
|
||||
@@ -28,7 +24,6 @@ import {
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import { ELEVENLABS_VOICE_PRESETS, isCuratedVoicePreset } from './elevenlabsVoicePresets';
|
||||
|
||||
// Curated Piper voice presets — a handful of well-known English voices
|
||||
// covering male/female and US/GB accents at the recommended `medium`
|
||||
@@ -56,26 +51,6 @@ interface VoicePanelProps {
|
||||
const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
||||
const dispatch = useDispatch();
|
||||
// Issue #1762 — user-selected ElevenLabs voice id for the mascot's
|
||||
// reply speech. `null` means "use the build-time default", which is
|
||||
// exactly what `synthesizeSpeech` falls back to when called without a
|
||||
// voiceId override. Stored in mascotSlice + persisted via redux-
|
||||
// persist so the choice survives restart.
|
||||
const storedMascotVoiceId = useSelector(selectMascotVoiceId);
|
||||
// Local edit buffer for the custom-paste field. Mirrors the Piper
|
||||
// voice editor pattern below — typing does not commit, only the
|
||||
// explicit Save / Apply paths dispatch into the slice so a half-typed
|
||||
// id can never reach the TTS payload.
|
||||
const [mascotVoiceDraft, setMascotVoiceDraft] = useState<string>(storedMascotVoiceId ?? '');
|
||||
// Sticky paste-mode flag: when the user picks "Other (paste voice id)"
|
||||
// we need the input to appear even though `storedMascotVoiceId` is
|
||||
// still null (or a curated id). Deriving paste-mode purely from the
|
||||
// stored value can't model that intent.
|
||||
const [mascotVoicePasteMode, setMascotVoicePasteMode] = useState<boolean>(false);
|
||||
const [isPreviewingMascotVoice, setIsPreviewingMascotVoice] = useState(false);
|
||||
const [mascotVoicePreviewError, setMascotVoicePreviewError] = useState<string | null>(null);
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
|
||||
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
|
||||
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(null);
|
||||
@@ -340,89 +315,10 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
|
||||
void persistProviders({ tts_provider: next });
|
||||
};
|
||||
|
||||
// ── Mascot voice picker (issue #1762) ──────────────────────────────
|
||||
// Keep the local edit buffer aligned with the slice when the slice
|
||||
// changes from outside this component (e.g. another tab updates it,
|
||||
// or a Reset action clears it). Without this the paste editor would
|
||||
// strand the previous value after a Reset.
|
||||
useEffect(() => {
|
||||
setMascotVoiceDraft(storedMascotVoiceId ?? '');
|
||||
setMascotVoicePreviewError(null);
|
||||
}, [storedMascotVoiceId]);
|
||||
|
||||
// Stop any in-flight preview audio when the panel unmounts so a user
|
||||
// who navigates away mid-clip doesn't keep hearing the sample.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Effective mascot voice id sent to the TTS RPC: the user override
|
||||
* if set, otherwise the build-time default. Used by the dropdown's
|
||||
* `value=` so the UI always reflects the actual id the next reply
|
||||
* will be synthesised with.
|
||||
*/
|
||||
const effectiveMascotVoiceId: string = storedMascotVoiceId ?? MASCOT_VOICE_ID;
|
||||
const isCustomMascotVoice = mascotVoicePasteMode || !isCuratedVoicePreset(effectiveMascotVoiceId);
|
||||
|
||||
const onMascotVoicePresetChange = (next: string) => {
|
||||
if (next === '__custom__') {
|
||||
// Switch into paste mode without committing. The text input below
|
||||
// becomes the editor; an explicit Save click writes through.
|
||||
setMascotVoicePasteMode(true);
|
||||
setMascotVoiceDraft(storedMascotVoiceId ?? '');
|
||||
return;
|
||||
}
|
||||
setMascotVoicePasteMode(false);
|
||||
setMascotVoicePreviewError(null);
|
||||
dispatch(setMascotVoiceId(next));
|
||||
};
|
||||
|
||||
const onMascotVoiceSavePaste = () => {
|
||||
setMascotVoicePreviewError(null);
|
||||
const trimmed = mascotVoiceDraft.trim();
|
||||
dispatch(setMascotVoiceId(trimmed.length > 0 ? trimmed : null));
|
||||
};
|
||||
|
||||
const onMascotVoiceReset = () => {
|
||||
setMascotVoicePreviewError(null);
|
||||
setMascotVoicePasteMode(false);
|
||||
dispatch(setMascotVoiceId(null));
|
||||
};
|
||||
|
||||
const onMascotVoicePreview = async () => {
|
||||
setIsPreviewingMascotVoice(true);
|
||||
setMascotVoicePreviewError(null);
|
||||
// Stop any prior playback so rapid clicks don't stack samples.
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
try {
|
||||
// Short sample — ElevenLabs charges per character, and the panel
|
||||
// is interactive so users may click Preview repeatedly. Keep this
|
||||
// string in lockstep with the test fixture in VoicePanel.test.tsx.
|
||||
const tts = await synthesizeSpeech("Hi, I'm your assistant. This is a voice preview.", {
|
||||
voiceId: effectiveMascotVoiceId,
|
||||
});
|
||||
const src = `data:${tts.audio_mime || 'audio/mpeg'};base64,${tts.audio_base64}`;
|
||||
const audio = new window.Audio(src);
|
||||
previewAudioRef.current = audio;
|
||||
await audio.play();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Voice preview failed';
|
||||
setMascotVoicePreviewError(message);
|
||||
} finally {
|
||||
setIsPreviewingMascotVoice(false);
|
||||
}
|
||||
};
|
||||
// Mascot voice picker moved to MascotPanel — see
|
||||
// `app/src/components/settings/panels/MascotPanel.tsx`. The voice id,
|
||||
// gender, and locale-default toggle all live in `mascotSlice`; this
|
||||
// panel only handles Piper / Whisper / dictation now.
|
||||
|
||||
/**
|
||||
* Map an install status snapshot to a button label. Single source of
|
||||
@@ -741,117 +637,24 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mascot Voice picker (issue #1762) — only meaningful when the
|
||||
cloud (ElevenLabs proxy) TTS provider is selected. Local
|
||||
Piper has its own voice picker above; bundling them would
|
||||
confuse "which provider does this id belong to?". The check
|
||||
is inclusive of the unseeded initial state (empty string)
|
||||
so the picker shows on first paint instead of popping in
|
||||
after the first voice-status poll resolves — `cloud` is the
|
||||
shipped default. */}
|
||||
{/* Mascot voice picker now lives in Mascot settings. Link
|
||||
kept here so users hunting in Voice settings can find it. */}
|
||||
{ttsProvider !== 'piper' && (
|
||||
<section className="space-y-3" data-testid="mascot-voice-section">
|
||||
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Mascot Voice
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
|
||||
Pick the ElevenLabs voice the mascot uses for spoken replies. Switch among the
|
||||
curated presets, paste any voice id you have access to under{' '}
|
||||
<strong>Other…</strong>, or hit <strong>Reset</strong> to fall back to the shipped
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
Voice preset
|
||||
</span>
|
||||
<select
|
||||
aria-label="Mascot voice preset"
|
||||
data-testid="mascot-voice-select"
|
||||
value={isCustomMascotVoice ? '__custom__' : effectiveMascotVoiceId}
|
||||
onChange={e => onMascotVoicePresetChange(e.target.value)}
|
||||
className="w-full rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-primary-400">
|
||||
{ELEVENLABS_VOICE_PRESETS.map(v => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">Other (paste voice id)…</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{isCustomMascotVoice && (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
Custom voice id
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label="Custom ElevenLabs voice id"
|
||||
data-testid="mascot-voice-input"
|
||||
value={mascotVoiceDraft}
|
||||
placeholder="e.g. 21m00Tcm4TlvDq8ikWAM"
|
||||
onChange={e => setMascotVoiceDraft(e.target.value)}
|
||||
className="flex-1 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-voice-save-paste"
|
||||
onClick={onMascotVoiceSavePaste}
|
||||
disabled={mascotVoiceDraft.trim() === (storedMascotVoiceId ?? '').trim()}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
Find voice ids at <code className="font-mono">api.elevenlabs.io/v1/voices</code>{' '}
|
||||
or your ElevenLabs dashboard. Only the id is stored — your API key stays on the
|
||||
backend.
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<section className="space-y-3" data-testid="mascot-voice-link">
|
||||
<div className="bg-stone-50 dark:bg-neutral-800/60 rounded-lg border border-stone-200 dark:border-neutral-800 p-4">
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
Mascot Voice
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
|
||||
The ElevenLabs voice the mascot uses for spoken replies is configured under{' '}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-voice-preview"
|
||||
onClick={() => void onMascotVoicePreview()}
|
||||
disabled={isPreviewingMascotVoice}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
|
||||
{isPreviewingMascotVoice ? 'Previewing…' : 'Preview voice'}
|
||||
onClick={() => navigateToSettings('mascot')}
|
||||
className="underline text-primary-600 dark:text-primary-300 hover:text-primary-700 dark:hover:text-primary-200">
|
||||
Mascot settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mascot-voice-reset"
|
||||
onClick={onMascotVoiceReset}
|
||||
disabled={storedMascotVoiceId == null}
|
||||
title={
|
||||
storedMascotVoiceId == null
|
||||
? 'Already using the shipped default voice'
|
||||
: 'Restore the shipped default mascot voice'
|
||||
}
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 dark:border-neutral-700 hover:border-stone-400 dark:hover:border-neutral-600 disabled:opacity-60 text-stone-700 dark:text-neutral-200">
|
||||
Reset to default
|
||||
</button>
|
||||
<span
|
||||
data-testid="mascot-voice-current"
|
||||
className="ml-1 text-[11px] text-stone-500 dark:text-neutral-400 truncate max-w-[18rem]"
|
||||
title={effectiveMascotVoiceId}>
|
||||
current: <code className="font-mono">{effectiveMascotVoiceId}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mascotVoicePreviewError && (
|
||||
<div
|
||||
data-testid="mascot-voice-preview-error"
|
||||
className="rounded-md border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3 text-xs text-amber-800 dark:text-amber-200">
|
||||
Voice preview failed: {mascotVoicePreviewError}. Reply speech will fall back to
|
||||
the default voice on the next reply.
|
||||
</div>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -510,142 +510,4 @@ describe('VoicePanel', () => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Issue #1762 — Mascot Voice picker tests. Nested inside the outer
|
||||
// describe so the runtime mocks (openhumanVoiceStatus seeded with
|
||||
// tts_provider='cloud', etc.) are inherited. The section only renders
|
||||
// when the cloud (ElevenLabs proxy) TTS provider is active; local
|
||||
// Piper has its own picker above. The slice handles validation +
|
||||
// persistence; these tests pin the UI surface that drives it.
|
||||
describe('Mascot Voice picker (#1762)', () => {
|
||||
beforeEach(async () => {
|
||||
// Stub a fast successful TTS so the Preview happy-path doesn't
|
||||
// wedge on a hanging promise. Individual tests override per case.
|
||||
const { synthesizeSpeech } = await import('../../../../features/human/voice/ttsClient');
|
||||
vi.mocked(synthesizeSpeech).mockResolvedValue({
|
||||
audio_base64: 'AAAA',
|
||||
audio_mime: 'audio/mpeg',
|
||||
visemes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the Mascot Voice section when TTS provider is piper', async () => {
|
||||
// Bias the voice status snapshot so the panel mounts in piper mode
|
||||
// — the section should be hidden in that case (local voices use the
|
||||
// Piper picker above, not the ElevenLabs one).
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
vi.mocked(openhumanVoiceStatus).mockResolvedValueOnce({
|
||||
stt_available: true,
|
||||
tts_available: true,
|
||||
stt_model_id: 'ggml-tiny-q5_1.bin',
|
||||
tts_voice_id: 'en_US-lessac-medium',
|
||||
whisper_binary: null,
|
||||
piper_binary: null,
|
||||
stt_model_path: '/tmp/stt.bin',
|
||||
tts_voice_path: '/tmp/tts.onnx',
|
||||
whisper_in_process: true,
|
||||
llm_cleanup_enabled: true,
|
||||
stt_provider: 'cloud',
|
||||
tts_provider: 'piper',
|
||||
});
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
// Give the panel a tick to read provider state before asserting.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mascot-voice-section')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Mascot Voice section under cloud TTS with the default voice selected', async () => {
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
// Wait for the panel to load and seed ttsProvider so the section
|
||||
// appears. The dropdown is the gate-keeper of the section.
|
||||
const section = await screen.findByTestId('mascot-voice-section');
|
||||
// DEBUG: full DOM if section appears empty
|
||||
|
||||
console.log('SECTION HTML:', section.outerHTML.slice(0, 2000));
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
// With no override stored, the picker reflects the build-time default.
|
||||
expect(select.value).toBe('ljX1ZrXuDIIRVcmiVSyR');
|
||||
const reset = await screen.findByTestId('mascot-voice-reset');
|
||||
expect(reset).toBeDisabled();
|
||||
});
|
||||
|
||||
it('switching to a preset voice updates the picker + enables Reset', async () => {
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: '21m00Tcm4TlvDq8ikWAM' } });
|
||||
await waitFor(() => expect(select.value).toBe('21m00Tcm4TlvDq8ikWAM'));
|
||||
expect(screen.getByTestId('mascot-voice-reset')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('mascot-voice-current').textContent).toContain(
|
||||
'21m00Tcm4TlvDq8ikWAM'
|
||||
);
|
||||
});
|
||||
|
||||
it('selecting "Other (paste voice id)" reveals the custom paste input', async () => {
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: '__custom__' } });
|
||||
expect(await screen.findByTestId('mascot-voice-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save commits the pasted voice id and surfaces it as current', async () => {
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: '__custom__' } });
|
||||
const input = (await screen.findByTestId('mascot-voice-input')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'custom-paste-id' } });
|
||||
fireEvent.click(screen.getByTestId('mascot-voice-save-paste'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('mascot-voice-current').textContent).toContain('custom-paste-id')
|
||||
);
|
||||
});
|
||||
|
||||
it('Preview calls synthesizeSpeech with the effective voice id', async () => {
|
||||
const { synthesizeSpeech } = await import('../../../../features/human/voice/ttsClient');
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pNInz6obpgDQGcFmaJgB' } }); // Adam
|
||||
fireEvent.click(screen.getByTestId('mascot-voice-preview'));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(synthesizeSpeech)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ voiceId: 'pNInz6obpgDQGcFmaJgB' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('Preview failure surfaces a recoverable error banner without dropping the selection', async () => {
|
||||
const { synthesizeSpeech } = await import('../../../../features/human/voice/ttsClient');
|
||||
vi.mocked(synthesizeSpeech).mockRejectedValueOnce(new Error('Backend unreachable'));
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'EXAVITQu4vr4xnSDxMaL' } }); // Bella
|
||||
fireEvent.click(screen.getByTestId('mascot-voice-preview'));
|
||||
const banner = await screen.findByTestId('mascot-voice-preview-error');
|
||||
expect(banner.textContent).toContain('Backend unreachable');
|
||||
expect(banner.textContent).toContain('fall back');
|
||||
// Selection survived the failed preview — fallback only applies to
|
||||
// the next reply if the chosen voice itself proves unavailable.
|
||||
expect((screen.getByTestId('mascot-voice-select') as HTMLSelectElement).value).toBe(
|
||||
'EXAVITQu4vr4xnSDxMaL'
|
||||
);
|
||||
});
|
||||
|
||||
it('Reset clears the override and reflects the build-time default in the picker', async () => {
|
||||
const { default: VoicePanel } = await import('../VoicePanel');
|
||||
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pNInz6obpgDQGcFmaJgB' } });
|
||||
await waitFor(() => expect(select.value).toBe('pNInz6obpgDQGcFmaJgB'));
|
||||
fireEvent.click(screen.getByTestId('mascot-voice-reset'));
|
||||
await waitFor(() => expect(select.value).toBe('ljX1ZrXuDIIRVcmiVSyR'));
|
||||
expect(screen.getByTestId('mascot-voice-reset')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
* we hard-code rather than fetch at runtime (an extra round trip per
|
||||
* panel mount, plus offline-first considerations, both argue against a
|
||||
* runtime fetch for what is effectively a static menu).
|
||||
*
|
||||
* All presets render through the `eleven_multilingual_v2` model on the
|
||||
* backend, so each voice can speak any of the locales we ship — the
|
||||
* `locales` field below is a "natively fluent" hint used to pick a
|
||||
* default when the user opts into locale-based voice selection.
|
||||
*/
|
||||
import type { Locale } from '../../../lib/i18n/types';
|
||||
|
||||
export interface ElevenLabsVoicePreset {
|
||||
/** ElevenLabs voice id — opaque alphanumeric, sent verbatim to the TTS RPC. */
|
||||
id: string;
|
||||
@@ -22,25 +29,98 @@ export interface ElevenLabsVoicePreset {
|
||||
label: string;
|
||||
/** Coarse gender bucket for filtering / a11y descriptions. */
|
||||
gender: 'male' | 'female';
|
||||
/**
|
||||
* Locales this voice sounds natural in. Used only to power the
|
||||
* "auto-pick voice from app locale" toggle; the voice still works for
|
||||
* other locales through `eleven_multilingual_v2`, it just may not
|
||||
* carry a native accent. `'*'` means "good fallback for any locale".
|
||||
*/
|
||||
locales: readonly (Locale | '*')[];
|
||||
}
|
||||
|
||||
export const ELEVENLABS_VOICE_PRESETS: readonly ElevenLabsVoicePreset[] = [
|
||||
// Default mascot voice — keep first so the picker always offers a
|
||||
// path back to the shipped behaviour even when the env override is
|
||||
// unset. Matches `MASCOT_VOICE_ID` in `app/src/utils/config.ts`.
|
||||
{ id: 'ljX1ZrXuDIIRVcmiVSyR', label: 'Default mascot voice', gender: 'female' },
|
||||
// George (multilingual). `locales: ['*']` keeps it visible in both
|
||||
// the female- and male-filtered dropdowns so it's always one click
|
||||
// away as a "safe fallback".
|
||||
{
|
||||
id: 'JBFqnCBsd6RMkjVDRZzb',
|
||||
label: 'George · multilingual (male)',
|
||||
gender: 'male',
|
||||
locales: ['*'],
|
||||
},
|
||||
// Public ElevenLabs library voices — stable ids, mix of accents.
|
||||
{ id: '21m00Tcm4TlvDq8ikWAM', label: 'Rachel · US (female)', gender: 'female' },
|
||||
{ id: 'EXAVITQu4vr4xnSDxMaL', label: 'Bella · US (female)', gender: 'female' },
|
||||
{ id: 'AZnzlk1XvdvUeBnXmlld', label: 'Domi · US (female)', gender: 'female' },
|
||||
{ id: 'MF3mGyEYCl7XYWbV9V6O', label: 'Elli · US (female, young)', gender: 'female' },
|
||||
{ id: 'jsCqWAovK2LkecY7zXl4', label: 'Freya · US (female, expressive)', gender: 'female' },
|
||||
{ id: 'pNInz6obpgDQGcFmaJgB', label: 'Adam · US (male)', gender: 'male' },
|
||||
{ id: 'ErXwobaYiN019PkySvjV', label: 'Antoni · US (male)', gender: 'male' },
|
||||
{ id: 'VR6AewLTigWG4xSOukaG', label: 'Arnold · US (male, mature)', gender: 'male' },
|
||||
{ id: 'TxGEqnHWrfWFTfGW9XjX', label: 'Josh · US (male, deep)', gender: 'male' },
|
||||
{ id: '21m00Tcm4TlvDq8ikWAM', label: 'Rachel · US (female)', gender: 'female', locales: ['en'] },
|
||||
{ id: 'EXAVITQu4vr4xnSDxMaL', label: 'Bella · US (female)', gender: 'female', locales: ['en'] },
|
||||
{ id: 'AZnzlk1XvdvUeBnXmlld', label: 'Domi · US (female)', gender: 'female', locales: ['en'] },
|
||||
{
|
||||
id: 'MF3mGyEYCl7XYWbV9V6O',
|
||||
label: 'Elli · US (female, young)',
|
||||
gender: 'female',
|
||||
locales: ['en'],
|
||||
},
|
||||
{
|
||||
id: 'jsCqWAovK2LkecY7zXl4',
|
||||
label: 'Freya · US (female, expressive)',
|
||||
gender: 'female',
|
||||
locales: ['en'],
|
||||
},
|
||||
{ id: 'pNInz6obpgDQGcFmaJgB', label: 'Adam · US (male)', gender: 'male', locales: ['en'] },
|
||||
{ id: 'ErXwobaYiN019PkySvjV', label: 'Antoni · US (male)', gender: 'male', locales: ['en'] },
|
||||
{
|
||||
id: 'VR6AewLTigWG4xSOukaG',
|
||||
label: 'Arnold · US (male, mature)',
|
||||
gender: 'male',
|
||||
locales: ['en'],
|
||||
},
|
||||
{ id: 'TxGEqnHWrfWFTfGW9XjX', label: 'Josh · US (male, deep)', gender: 'male', locales: ['en'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-locale default voice id, keyed by gender. Used by the "default
|
||||
* voice from app locale" toggle in the Mascot settings panel — when
|
||||
* enabled, the mascot speaks with this voice regardless of any manual
|
||||
* `voiceId` override.
|
||||
*
|
||||
* Every voice in the curated preset list renders through ElevenLabs'
|
||||
* `eleven_multilingual_v2` model, so the same id works in any locale;
|
||||
* we still curate per-locale picks here so a future expansion of the
|
||||
* preset list (e.g. adding a French-native voice) only needs to flip
|
||||
* the entry below, not every call site.
|
||||
*
|
||||
* `en` covers the default. Other locales fall back to it via
|
||||
* `defaultVoiceIdForLocale()` when a specific entry is missing.
|
||||
*/
|
||||
export const DEFAULT_VOICE_BY_LOCALE: Readonly<
|
||||
Partial<Record<Locale, Readonly<Record<'female' | 'male', string>>>>
|
||||
> = {
|
||||
// Female default: Rachel — neutral, widely-used. Male default: Adam.
|
||||
en: { female: '21m00Tcm4TlvDq8ikWAM', male: 'pNInz6obpgDQGcFmaJgB' },
|
||||
'zh-CN': { female: 'EXAVITQu4vr4xnSDxMaL', male: 'pNInz6obpgDQGcFmaJgB' },
|
||||
hi: { female: 'EXAVITQu4vr4xnSDxMaL', male: 'ErXwobaYiN019PkySvjV' },
|
||||
es: { female: 'jsCqWAovK2LkecY7zXl4', male: 'ErXwobaYiN019PkySvjV' },
|
||||
ar: { female: 'AZnzlk1XvdvUeBnXmlld', male: 'VR6AewLTigWG4xSOukaG' },
|
||||
fr: { female: 'jsCqWAovK2LkecY7zXl4', male: 'ErXwobaYiN019PkySvjV' },
|
||||
bn: { female: 'EXAVITQu4vr4xnSDxMaL', male: 'ErXwobaYiN019PkySvjV' },
|
||||
pt: { female: 'AZnzlk1XvdvUeBnXmlld', male: 'ErXwobaYiN019PkySvjV' },
|
||||
ru: { female: 'AZnzlk1XvdvUeBnXmlld', male: 'VR6AewLTigWG4xSOukaG' },
|
||||
id: { female: 'EXAVITQu4vr4xnSDxMaL', male: 'pNInz6obpgDQGcFmaJgB' },
|
||||
it: { female: 'jsCqWAovK2LkecY7zXl4', male: 'ErXwobaYiN019PkySvjV' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the locale-default voice id for a given gender. Falls back to
|
||||
* English when a locale has no explicit entry — every entry in the
|
||||
* preset list works through `eleven_multilingual_v2`, so the English
|
||||
* default still produces correct (if accented) audio.
|
||||
*/
|
||||
export function defaultVoiceIdForLocale(locale: Locale, gender: 'male' | 'female'): string {
|
||||
const entry = DEFAULT_VOICE_BY_LOCALE[locale] ?? DEFAULT_VOICE_BY_LOCALE.en;
|
||||
return entry![gender];
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `id` matches one of the curated presets above. Used by the
|
||||
* panel to decide whether to render the dropdown selection or fall
|
||||
|
||||
@@ -562,10 +562,10 @@ describe('useHumanMascot TTS playback', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Second-arg is undefined → synthesizeSpeech falls through to its
|
||||
// own MASCOT_VOICE_ID default. Locks the no-regression contract
|
||||
// for users who never opened the picker.
|
||||
expect(synthesizeSpeech).toHaveBeenCalledWith('hello', undefined);
|
||||
// Selector now resolves the build-time `MASCOT_VOICE_ID` default
|
||||
// eagerly so the call site never has to fall back. Locks the
|
||||
// no-regression contract for users who never opened the picker.
|
||||
expect(synthesizeSpeech).toHaveBeenCalledWith('hello', { voiceId: 'JBFqnCBsd6RMkjVDRZzb' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { subscribeChatEvents } from '../../services/chatService';
|
||||
import { selectMascotVoiceId } from '../../store/mascotSlice';
|
||||
import { selectEffectiveMascotVoiceId } from '../../store/mascotSlice';
|
||||
import type { MascotFace } from './Mascot';
|
||||
import { lerpViseme, VISEMES, type VisemeShape } from './Mascot/visemes';
|
||||
import { type PlaybackHandle, playBase64Audio, swallowAudioStop } from './voice/audioPlayer';
|
||||
@@ -113,13 +113,14 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
|
||||
const speakRef = useRef(speakReplies);
|
||||
speakRef.current = speakReplies;
|
||||
|
||||
// Issue #1762 — user-selected mascot voice id (or `null` for the
|
||||
// build-time default). Mirrored into a ref so the inner
|
||||
// `startTtsPlayback` closure always reads the latest value without
|
||||
// having to re-create the callback on every re-render.
|
||||
const storedMascotVoiceId = useSelector(selectMascotVoiceId);
|
||||
const mascotVoiceIdRef = useRef<string | null>(storedMascotVoiceId);
|
||||
mascotVoiceIdRef.current = storedMascotVoiceId;
|
||||
// Effective mascot voice id: resolves the manual override, the
|
||||
// locale-default toggle, and the build-time fallback into a single
|
||||
// string (see `selectEffectiveMascotVoiceId`). Mirrored into a ref so
|
||||
// the inner `startTtsPlayback` closure always reads the latest value
|
||||
// without having to re-create the callback on every re-render.
|
||||
const effectiveMascotVoiceId = useSelector(selectEffectiveMascotVoiceId);
|
||||
const mascotVoiceIdRef = useRef<string>(effectiveMascotVoiceId);
|
||||
mascotVoiceIdRef.current = effectiveMascotVoiceId;
|
||||
|
||||
const [face, setFace] = useState<MascotFace>('idle');
|
||||
const targetRef = useRef<VisemeShape>(VISEMES.REST);
|
||||
@@ -244,12 +245,11 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas
|
||||
setFace('thinking');
|
||||
let tts;
|
||||
try {
|
||||
// Pass the user-selected mascot voice id when one is stored
|
||||
// (issue #1762); `undefined` falls through to the existing
|
||||
// `MASCOT_VOICE_ID` default inside `synthesizeSpeech`, so this
|
||||
// change is a no-op for users who have never opened the picker.
|
||||
const voiceOverride = mascotVoiceIdRef.current;
|
||||
tts = await synthesizeSpeech(text, voiceOverride ? { voiceId: voiceOverride } : undefined);
|
||||
// Always pass the effective voice id — the selector already
|
||||
// resolves manual override / locale default / build-time
|
||||
// fallback to a single string, so `synthesizeSpeech` doesn't
|
||||
// need its own fallback branch here.
|
||||
tts = await synthesizeSpeech(text, { voiceId: mascotVoiceIdRef.current });
|
||||
} catch (err) {
|
||||
// Voice path unavailable — degrade cleanly to text-only behavior.
|
||||
if (isStillCurrent()) degraded = true;
|
||||
|
||||
@@ -27,13 +27,13 @@ describe('synthesizeSpeech (core RPC)', () => {
|
||||
expect(r.visemes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to the configured mascot voice when no voiceId is given', async () => {
|
||||
it('falls back to the configured mascot voice + multilingual model when no overrides are given', async () => {
|
||||
const mock = callCoreRpc as ReturnType<typeof vi.fn>;
|
||||
mock.mockResolvedValueOnce({ audio_base64: 'BBB=', audio_mime: 'audio/mpeg', visemes: [] });
|
||||
await synthesizeSpeech('hi');
|
||||
expect(mock).toHaveBeenCalledWith({
|
||||
method: 'openhuman.voice_reply_synthesize',
|
||||
params: { text: 'hi.', voice_id: 'ljX1ZrXuDIIRVcmiVSyR' },
|
||||
params: { text: 'hi.', voice_id: 'JBFqnCBsd6RMkjVDRZzb', model_id: 'eleven_multilingual_v2' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import { MASCOT_VOICE_ID } from '../../../utils/config';
|
||||
import { MASCOT_VOICE_ID, MASCOT_VOICE_MODEL_ID } from '../../../utils/config';
|
||||
|
||||
const ttsLog = debug('human:tts');
|
||||
|
||||
@@ -47,6 +47,10 @@ export interface TtsOptions {
|
||||
*/
|
||||
export async function synthesizeSpeech(text: string, opts: TtsOptions = {}): Promise<TtsResponse> {
|
||||
const voiceId = opts.voiceId ?? MASCOT_VOICE_ID;
|
||||
// Default model is `eleven_multilingual_v2` so non-English locales
|
||||
// render their native script instead of being phoneticised by an
|
||||
// English-only model. Callers can still override via `modelId`.
|
||||
const modelId = opts.modelId ?? MASCOT_VOICE_MODEL_ID;
|
||||
// `prepareForSpeech` collapses to '' on replies that are pure code/markdown
|
||||
// formatting. The core RPC rejects empty text, which would propagate as a
|
||||
// visible error for what was effectively a no-op reply. Fall back to the
|
||||
@@ -55,7 +59,7 @@ export async function synthesizeSpeech(text: string, opts: TtsOptions = {}): Pro
|
||||
const spoken = prepareForSpeech(text) || text.trim() || '...';
|
||||
const params: Record<string, unknown> = { text: spoken };
|
||||
if (voiceId) params.voice_id = voiceId;
|
||||
if (opts.modelId) params.model_id = opts.modelId;
|
||||
if (modelId) params.model_id = modelId;
|
||||
if (opts.outputFormat) params.output_format = opts.outputFormat;
|
||||
ttsLog('synthesize chars=%d (raw=%d) voice=%s', spoken.length, text.length, voiceId ?? 'default');
|
||||
|
||||
|
||||
@@ -170,6 +170,25 @@ const ar5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'OpenHuman المحلي (افتراضي)',
|
||||
'settings.mascot.noCharacters': 'لا توجد شخصيات OpenHuman متاحة بعد',
|
||||
'settings.mascot.noColorVariants': 'لا توجد ألوان متاحة',
|
||||
'settings.mascot.voice.current': 'الحالي',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'ابحث عن معرّفات الصوت في api.elevenlabs.io/v1/voices أو لوحة تحكم ElevenLabs الخاصة بك. يُخزَّن المعرّف فقط — يبقى مفتاح API الخاص بك على الخادم.',
|
||||
'settings.mascot.voice.customHeading': 'معرّف صوت مخصص',
|
||||
'settings.mascot.voice.customOption': 'آخر (لصق معرّف الصوت)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'اختر صوت ElevenLabs الذي تستخدمه الشخصية للردود المنطوقة. صفِّ حسب الجنس، اختر من القائمة المنسّقة، الصق معرّفاً مخصصاً، أو دع التطبيق يختار صوتاً يطابق لغة الواجهة.',
|
||||
'settings.mascot.voice.genderFemale': 'أنثى',
|
||||
'settings.mascot.voice.genderHeading': 'جنس الصوت',
|
||||
'settings.mascot.voice.genderMale': 'ذكر',
|
||||
'settings.mascot.voice.heading': 'الصوت',
|
||||
'settings.mascot.voice.preset': 'إعداد الصوت المسبق',
|
||||
'settings.mascot.voice.presetHeading': 'إعداد الصوت المسبق',
|
||||
'settings.mascot.voice.preview': 'معاينة الصوت',
|
||||
'settings.mascot.voice.previewError': 'تعذّرت معاينة الصوت',
|
||||
'settings.mascot.voice.previewing': 'جاري المعاينة…',
|
||||
'settings.mascot.voice.reset': 'إعادة تعيين إلى الافتراضي',
|
||||
'settings.mascot.voice.useLocaleDefault': 'مطابقة لغة التطبيق',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc': 'اختيار صوت تلقائياً للغة الواجهة الحالية.',
|
||||
'settings.memoryWindow.balanced.badge': 'موصى به',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'افتراضي معقول — استمرارية جيدة دون استهلاك رموز إضافية في كل تشغيل.',
|
||||
|
||||
@@ -173,6 +173,26 @@ const bn5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'লোকাল OpenHuman (ডিফল্ট)',
|
||||
'settings.mascot.noCharacters': 'কোনো OpenHuman ক্যারেক্টার এখনও উপলব্ধ নেই',
|
||||
'settings.mascot.noColorVariants': 'কোনো রঙের ভেরিয়েন্ট নেই',
|
||||
'settings.mascot.voice.current': 'বর্তমান',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'ভয়েস আইডি খুঁজুন api.elevenlabs.io/v1/voices বা আপনার ElevenLabs ড্যাশবোর্ডে। শুধু আইডি সংরক্ষিত থাকে — আপনার API কী ব্যাকএন্ডে থাকে।',
|
||||
'settings.mascot.voice.customHeading': 'কাস্টম ভয়েস আইডি',
|
||||
'settings.mascot.voice.customOption': 'অন্যান্য (ভয়েস আইডি পেস্ট করুন)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'স্পোকেন উত্তরের জন্য ম্যাসকট যে ElevenLabs ভয়েস ব্যবহার করে তা বেছে নিন। জেন্ডার অনুযায়ী ফিল্টার করুন, কিউরেটেড তালিকা থেকে বাছাই করুন, কাস্টম আইডি পেস্ট করুন, অথবা ইন্টারফেস ভাষার সাথে মেলে এমন ভয়েস অ্যাপটিকে বেছে নিতে দিন।',
|
||||
'settings.mascot.voice.genderFemale': 'মহিলা',
|
||||
'settings.mascot.voice.genderHeading': 'ভয়েস জেন্ডার',
|
||||
'settings.mascot.voice.genderMale': 'পুরুষ',
|
||||
'settings.mascot.voice.heading': 'ভয়েস',
|
||||
'settings.mascot.voice.preset': 'ভয়েস প্রিসেট',
|
||||
'settings.mascot.voice.presetHeading': 'ভয়েস প্রিসেট',
|
||||
'settings.mascot.voice.preview': 'ভয়েস প্রিভিউ',
|
||||
'settings.mascot.voice.previewError': 'ভয়েস প্রিভিউ ব্যর্থ হয়েছে',
|
||||
'settings.mascot.voice.previewing': 'প্রিভিউ চলছে…',
|
||||
'settings.mascot.voice.reset': 'ডিফল্টে রিসেট করুন',
|
||||
'settings.mascot.voice.useLocaleDefault': 'অ্যাপের ভাষার সাথে মিলান',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'বর্তমান ইন্টারফেস ভাষার জন্য স্বয়ংক্রিয়ভাবে একটি ভয়েস বেছে নিন।',
|
||||
'settings.memoryWindow.balanced.badge': 'প্রস্তাবিত',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'যৌক্তিক ডিফল্ট — প্রতিটি রানে অতিরিক্ত টোকেন না পুড়িয়ে ভাল ধারাবাহিকতা।',
|
||||
|
||||
@@ -172,6 +172,26 @@ const en5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'Local OpenHuman (default)',
|
||||
'settings.mascot.noCharacters': 'No OpenHuman characters are available yet',
|
||||
'settings.mascot.noColorVariants': 'No color variants',
|
||||
'settings.mascot.voice.current': 'current',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'Find voice ids at api.elevenlabs.io/v1/voices or your ElevenLabs dashboard. Only the id is stored — your API key stays on the backend.',
|
||||
'settings.mascot.voice.customHeading': 'Custom voice id',
|
||||
'settings.mascot.voice.customOption': 'Other (paste voice id)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'Pick the ElevenLabs voice the mascot uses for spoken replies. Filter by gender, pick from the curated list, paste a custom id, or let the app pick a voice that matches your interface language.',
|
||||
'settings.mascot.voice.genderFemale': 'Female',
|
||||
'settings.mascot.voice.genderHeading': 'Voice gender',
|
||||
'settings.mascot.voice.genderMale': 'Male',
|
||||
'settings.mascot.voice.heading': 'Voice',
|
||||
'settings.mascot.voice.preset': 'Voice preset',
|
||||
'settings.mascot.voice.presetHeading': 'Voice preset',
|
||||
'settings.mascot.voice.preview': 'Preview voice',
|
||||
'settings.mascot.voice.previewError': 'Voice preview failed',
|
||||
'settings.mascot.voice.previewing': 'Previewing…',
|
||||
'settings.mascot.voice.reset': 'Reset to default',
|
||||
'settings.mascot.voice.useLocaleDefault': 'Match the app language',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Auto-pick a voice for the current interface language.',
|
||||
'settings.memoryWindow.balanced.badge': 'Recommended',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Sensible default — good continuity without burning extra tokens on every run.',
|
||||
|
||||
@@ -176,6 +176,26 @@ const es5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'OpenHuman local (predeterminado)',
|
||||
'settings.mascot.noCharacters': 'Aún no hay personajes de OpenHuman disponibles',
|
||||
'settings.mascot.noColorVariants': 'Sin variantes de color',
|
||||
'settings.mascot.voice.current': 'actual',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'Encuentra los ID de voz en api.elevenlabs.io/v1/voices o en tu panel de ElevenLabs. Solo se almacena el ID — tu clave de API permanece en el backend.',
|
||||
'settings.mascot.voice.customHeading': 'ID de voz personalizado',
|
||||
'settings.mascot.voice.customOption': 'Otro (pegar ID de voz)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'Elige la voz de ElevenLabs que la mascota usa para las respuestas habladas. Filtra por género, elige de la lista curada, pega un ID personalizado, o deja que la app elija una voz que coincida con el idioma de la interfaz.',
|
||||
'settings.mascot.voice.genderFemale': 'Femenino',
|
||||
'settings.mascot.voice.genderHeading': 'Género de la voz',
|
||||
'settings.mascot.voice.genderMale': 'Masculino',
|
||||
'settings.mascot.voice.heading': 'Voz',
|
||||
'settings.mascot.voice.preset': 'Preajuste de voz',
|
||||
'settings.mascot.voice.presetHeading': 'Preajuste de voz',
|
||||
'settings.mascot.voice.preview': 'Vista previa de voz',
|
||||
'settings.mascot.voice.previewError': 'Falló la vista previa de voz',
|
||||
'settings.mascot.voice.previewing': 'Reproduciendo vista previa…',
|
||||
'settings.mascot.voice.reset': 'Restablecer al predeterminado',
|
||||
'settings.mascot.voice.useLocaleDefault': 'Coincidir con el idioma de la app',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Elegir automáticamente una voz para el idioma de la interfaz actual.',
|
||||
'settings.memoryWindow.balanced.badge': 'Recomendado',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Predeterminado sensato — buena continuidad sin quemar tokens extra en cada ejecución.',
|
||||
|
||||
@@ -178,6 +178,26 @@ const fr5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'OpenHuman local (par défaut)',
|
||||
'settings.mascot.noCharacters': "Aucun personnage OpenHuman n'est encore disponible",
|
||||
'settings.mascot.noColorVariants': 'Aucune variante de couleur',
|
||||
'settings.mascot.voice.current': 'actuel',
|
||||
'settings.mascot.voice.customDesc':
|
||||
"Trouvez les identifiants vocaux sur api.elevenlabs.io/v1/voices ou dans votre tableau de bord ElevenLabs. Seul l'identifiant est stocké — votre clé API reste sur le backend.",
|
||||
'settings.mascot.voice.customHeading': 'Identifiant vocal personnalisé',
|
||||
'settings.mascot.voice.customOption': "Autre (coller l'identifiant vocal)…",
|
||||
'settings.mascot.voice.desc':
|
||||
"Choisissez la voix ElevenLabs utilisée par la mascotte pour les réponses parlées. Filtrez par genre, choisissez dans la liste sélectionnée, collez un identifiant personnalisé, ou laissez l'application choisir une voix qui correspond à la langue de l'interface.",
|
||||
'settings.mascot.voice.genderFemale': 'Féminin',
|
||||
'settings.mascot.voice.genderHeading': 'Genre de la voix',
|
||||
'settings.mascot.voice.genderMale': 'Masculin',
|
||||
'settings.mascot.voice.heading': 'Voix',
|
||||
'settings.mascot.voice.preset': 'Préréglage vocal',
|
||||
'settings.mascot.voice.presetHeading': 'Préréglage vocal',
|
||||
'settings.mascot.voice.preview': 'Aperçu de la voix',
|
||||
'settings.mascot.voice.previewError': "Échec de l'aperçu de la voix",
|
||||
'settings.mascot.voice.previewing': 'Aperçu en cours…',
|
||||
'settings.mascot.voice.reset': 'Réinitialiser à la valeur par défaut',
|
||||
'settings.mascot.voice.useLocaleDefault': "Faire correspondre à la langue de l'application",
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
"Choisir automatiquement une voix pour la langue de l'interface actuelle.",
|
||||
'settings.memoryWindow.balanced.badge': 'Recommandé',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Valeur par défaut raisonnable — bonne continuité sans consommer de jetons supplémentaires à chaque exécution.',
|
||||
|
||||
@@ -174,6 +174,26 @@ const hi5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'लोकल OpenHuman (डिफ़ॉल्ट)',
|
||||
'settings.mascot.noCharacters': 'अभी तक कोई OpenHuman कैरेक्टर उपलब्ध नहीं है',
|
||||
'settings.mascot.noColorVariants': 'कोई कलर वेरिएंट नहीं',
|
||||
'settings.mascot.voice.current': 'वर्तमान',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'वॉइस आईडी api.elevenlabs.io/v1/voices या अपने ElevenLabs डैशबोर्ड पर खोजें। केवल आईडी संग्रहीत होती है — आपकी API कुंजी बैकएंड पर रहती है।',
|
||||
'settings.mascot.voice.customHeading': 'कस्टम वॉइस आईडी',
|
||||
'settings.mascot.voice.customOption': 'अन्य (वॉइस आईडी पेस्ट करें)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'बोले गए उत्तरों के लिए मैस्कॉट जो ElevenLabs वॉइस उपयोग करता है उसे चुनें। लिंग के अनुसार फ़िल्टर करें, क्यूरेटेड सूची से चुनें, कस्टम आईडी पेस्ट करें, या ऐप को इंटरफ़ेस भाषा से मेल खाने वाली आवाज़ चुनने दें।',
|
||||
'settings.mascot.voice.genderFemale': 'स्त्री',
|
||||
'settings.mascot.voice.genderHeading': 'वॉइस लिंग',
|
||||
'settings.mascot.voice.genderMale': 'पुरुष',
|
||||
'settings.mascot.voice.heading': 'वॉइस',
|
||||
'settings.mascot.voice.preset': 'वॉइस प्रीसेट',
|
||||
'settings.mascot.voice.presetHeading': 'वॉइस प्रीसेट',
|
||||
'settings.mascot.voice.preview': 'वॉइस पूर्वावलोकन',
|
||||
'settings.mascot.voice.previewError': 'वॉइस पूर्वावलोकन विफल हुआ',
|
||||
'settings.mascot.voice.previewing': 'पूर्वावलोकन हो रहा है…',
|
||||
'settings.mascot.voice.reset': 'डिफ़ॉल्ट पर रीसेट करें',
|
||||
'settings.mascot.voice.useLocaleDefault': 'ऐप भाषा से मिलाएँ',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'वर्तमान इंटरफ़ेस भाषा के लिए स्वचालित रूप से एक आवाज़ चुनें।',
|
||||
'settings.memoryWindow.balanced.badge': 'अनुशंसित',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'समझदारी भरा डिफ़ॉल्ट — हर रन पर अतिरिक्त टोकन खर्च किए बिना अच्छी निरंतरता।',
|
||||
|
||||
@@ -174,6 +174,26 @@ const id5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'OpenHuman Lokal (default)',
|
||||
'settings.mascot.noCharacters': 'Belum ada karakter OpenHuman yang tersedia',
|
||||
'settings.mascot.noColorVariants': 'Tidak ada varian warna',
|
||||
'settings.mascot.voice.current': 'saat ini',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'Temukan ID suara di api.elevenlabs.io/v1/voices atau dasbor ElevenLabs Anda. Hanya ID yang disimpan — kunci API Anda tetap di backend.',
|
||||
'settings.mascot.voice.customHeading': 'ID suara kustom',
|
||||
'settings.mascot.voice.customOption': 'Lainnya (tempel ID suara)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'Pilih suara ElevenLabs yang digunakan maskot untuk balasan lisan. Filter berdasarkan jenis kelamin, pilih dari daftar pilihan, tempel ID kustom, atau biarkan aplikasi memilih suara yang sesuai dengan bahasa antarmuka.',
|
||||
'settings.mascot.voice.genderFemale': 'Wanita',
|
||||
'settings.mascot.voice.genderHeading': 'Jenis kelamin suara',
|
||||
'settings.mascot.voice.genderMale': 'Pria',
|
||||
'settings.mascot.voice.heading': 'Suara',
|
||||
'settings.mascot.voice.preset': 'Pratinjau suara',
|
||||
'settings.mascot.voice.presetHeading': 'Pratinjau suara',
|
||||
'settings.mascot.voice.preview': 'Pratinjau suara',
|
||||
'settings.mascot.voice.previewError': 'Pratinjau suara gagal',
|
||||
'settings.mascot.voice.previewing': 'Memuat pratinjau…',
|
||||
'settings.mascot.voice.reset': 'Atur ulang ke default',
|
||||
'settings.mascot.voice.useLocaleDefault': 'Cocokkan bahasa aplikasi',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Pilih otomatis suara untuk bahasa antarmuka saat ini.',
|
||||
'settings.memoryWindow.balanced.badge': 'Direkomendasikan',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Default yang masuk akal — kontinuitas yang baik tanpa membakar token tambahan di setiap run.',
|
||||
|
||||
@@ -176,6 +176,26 @@ const it5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'OpenHuman locale (predefinito)',
|
||||
'settings.mascot.noCharacters': 'Nessun personaggio OpenHuman disponibile',
|
||||
'settings.mascot.noColorVariants': 'Nessuna variante di colore',
|
||||
'settings.mascot.voice.current': 'attuale',
|
||||
'settings.mascot.voice.customDesc':
|
||||
"Trova gli ID vocali su api.elevenlabs.io/v1/voices o nel tuo dashboard ElevenLabs. Viene salvato solo l'ID — la tua chiave API rimane sul backend.",
|
||||
'settings.mascot.voice.customHeading': 'ID vocale personalizzato',
|
||||
'settings.mascot.voice.customOption': 'Altro (incolla ID vocale)…',
|
||||
'settings.mascot.voice.desc':
|
||||
"Scegli la voce ElevenLabs che la mascotte usa per le risposte parlate. Filtra per genere, scegli dall'elenco curato, incolla un ID personalizzato, oppure lascia che l'app scelga una voce che corrisponda alla lingua dell'interfaccia.",
|
||||
'settings.mascot.voice.genderFemale': 'Femminile',
|
||||
'settings.mascot.voice.genderHeading': 'Genere della voce',
|
||||
'settings.mascot.voice.genderMale': 'Maschile',
|
||||
'settings.mascot.voice.heading': 'Voce',
|
||||
'settings.mascot.voice.preset': 'Preimpostazione voce',
|
||||
'settings.mascot.voice.presetHeading': 'Preimpostazione voce',
|
||||
'settings.mascot.voice.preview': 'Anteprima voce',
|
||||
'settings.mascot.voice.previewError': 'Anteprima voce non riuscita',
|
||||
'settings.mascot.voice.previewing': 'Anteprima in corso…',
|
||||
'settings.mascot.voice.reset': 'Ripristina ai valori predefiniti',
|
||||
'settings.mascot.voice.useLocaleDefault': "Abbina la lingua dell'app",
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
"Scegli automaticamente una voce per la lingua dell'interfaccia corrente.",
|
||||
'settings.memoryWindow.balanced.badge': 'Consigliato',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Predefinito sensato — buona continuità senza bruciare token extra a ogni esecuzione.',
|
||||
|
||||
@@ -177,6 +177,26 @@ const pt5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'OpenHuman local (padrão)',
|
||||
'settings.mascot.noCharacters': 'Nenhum personagem do OpenHuman disponível ainda',
|
||||
'settings.mascot.noColorVariants': 'Sem variantes de cor',
|
||||
'settings.mascot.voice.current': 'atual',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'Encontre IDs de voz em api.elevenlabs.io/v1/voices ou no seu painel da ElevenLabs. Apenas o ID é armazenado — sua chave de API permanece no backend.',
|
||||
'settings.mascot.voice.customHeading': 'ID de voz personalizado',
|
||||
'settings.mascot.voice.customOption': 'Outro (colar ID de voz)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'Escolha a voz da ElevenLabs que o mascote usa para respostas faladas. Filtre por gênero, escolha na lista curada, cole um ID personalizado, ou deixe o app escolher uma voz que combine com o idioma da interface.',
|
||||
'settings.mascot.voice.genderFemale': 'Feminino',
|
||||
'settings.mascot.voice.genderHeading': 'Gênero da voz',
|
||||
'settings.mascot.voice.genderMale': 'Masculino',
|
||||
'settings.mascot.voice.heading': 'Voz',
|
||||
'settings.mascot.voice.preset': 'Predefinição de voz',
|
||||
'settings.mascot.voice.presetHeading': 'Predefinição de voz',
|
||||
'settings.mascot.voice.preview': 'Pré-visualização da voz',
|
||||
'settings.mascot.voice.previewError': 'Falha na pré-visualização da voz',
|
||||
'settings.mascot.voice.previewing': 'Pré-visualizando…',
|
||||
'settings.mascot.voice.reset': 'Redefinir para o padrão',
|
||||
'settings.mascot.voice.useLocaleDefault': 'Corresponder ao idioma do app',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Escolher automaticamente uma voz para o idioma atual da interface.',
|
||||
'settings.memoryWindow.balanced.badge': 'Recomendado',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Padrão sensato — boa continuidade sem queimar tokens extras em cada execução.',
|
||||
|
||||
@@ -174,6 +174,26 @@ const ru5: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'Локальный OpenHuman (по умолчанию)',
|
||||
'settings.mascot.noCharacters': 'Персонажи OpenHuman пока недоступны',
|
||||
'settings.mascot.noColorVariants': 'Нет цветовых вариантов',
|
||||
'settings.mascot.voice.current': 'текущий',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'Идентификаторы голосов можно найти на api.elevenlabs.io/v1/voices или в вашей панели ElevenLabs. Сохраняется только идентификатор — ваш API-ключ остаётся на бэкенде.',
|
||||
'settings.mascot.voice.customHeading': 'Пользовательский идентификатор голоса',
|
||||
'settings.mascot.voice.customOption': 'Другое (вставить идентификатор голоса)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'Выберите голос ElevenLabs, который маскот использует для устных ответов. Фильтруйте по полу, выбирайте из подобранного списка, вставьте свой идентификатор или позвольте приложению выбрать голос, соответствующий языку интерфейса.',
|
||||
'settings.mascot.voice.genderFemale': 'Женский',
|
||||
'settings.mascot.voice.genderHeading': 'Пол голоса',
|
||||
'settings.mascot.voice.genderMale': 'Мужской',
|
||||
'settings.mascot.voice.heading': 'Голос',
|
||||
'settings.mascot.voice.preset': 'Предустановка голоса',
|
||||
'settings.mascot.voice.presetHeading': 'Предустановка голоса',
|
||||
'settings.mascot.voice.preview': 'Предпросмотр голоса',
|
||||
'settings.mascot.voice.previewError': 'Не удалось воспроизвести предпросмотр голоса',
|
||||
'settings.mascot.voice.previewing': 'Воспроизведение предпросмотра…',
|
||||
'settings.mascot.voice.reset': 'Сбросить к значениям по умолчанию',
|
||||
'settings.mascot.voice.useLocaleDefault': 'Соответствовать языку приложения',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Автоматически выбрать голос для текущего языка интерфейса.',
|
||||
'settings.memoryWindow.balanced.badge': 'Рекомендуется',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Разумное значение по умолчанию — хорошая непрерывность без лишних трат токенов на каждом запуске.',
|
||||
|
||||
@@ -168,6 +168,25 @@ const zhCN5: TranslationMap = {
|
||||
'settings.mascot.localDefault': '本地 OpenHuman(默认)',
|
||||
'settings.mascot.noCharacters': '暂无可用的 OpenHuman 角色',
|
||||
'settings.mascot.noColorVariants': '无颜色变体',
|
||||
'settings.mascot.voice.current': '当前',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'在 api.elevenlabs.io/v1/voices 或您的 ElevenLabs 仪表板中查找语音 ID。仅存储 ID — 您的 API 密钥保留在后端。',
|
||||
'settings.mascot.voice.customHeading': '自定义语音 ID',
|
||||
'settings.mascot.voice.customOption': '其他(粘贴语音 ID)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'选择吉祥物用于语音回复的 ElevenLabs 声音。按性别筛选,从精选列表中选择,粘贴自定义 ID,或让应用根据您的界面语言自动选择声音。',
|
||||
'settings.mascot.voice.genderFemale': '女声',
|
||||
'settings.mascot.voice.genderHeading': '声音性别',
|
||||
'settings.mascot.voice.genderMale': '男声',
|
||||
'settings.mascot.voice.heading': '声音',
|
||||
'settings.mascot.voice.preset': '声音预设',
|
||||
'settings.mascot.voice.presetHeading': '声音预设',
|
||||
'settings.mascot.voice.preview': '试听声音',
|
||||
'settings.mascot.voice.previewError': '声音试听失败',
|
||||
'settings.mascot.voice.previewing': '试听中…',
|
||||
'settings.mascot.voice.reset': '重置为默认值',
|
||||
'settings.mascot.voice.useLocaleDefault': '匹配应用语言',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc': '为当前界面语言自动选择一个声音。',
|
||||
'settings.memoryWindow.balanced.badge': '推荐',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'合理的默认值——在不为每次运行消耗额外 token 的情况下保持良好的连贯性。',
|
||||
|
||||
@@ -1841,6 +1841,26 @@ const en: TranslationMap = {
|
||||
'settings.mascot.localDefault': 'Local OpenHuman (default)',
|
||||
'settings.mascot.noCharacters': 'No OpenHuman characters are available yet',
|
||||
'settings.mascot.noColorVariants': 'No color variants',
|
||||
'settings.mascot.voice.current': 'current',
|
||||
'settings.mascot.voice.customDesc':
|
||||
'Find voice ids at api.elevenlabs.io/v1/voices or your ElevenLabs dashboard. Only the id is stored — your API key stays on the backend.',
|
||||
'settings.mascot.voice.customHeading': 'Custom voice id',
|
||||
'settings.mascot.voice.customOption': 'Other (paste voice id)…',
|
||||
'settings.mascot.voice.desc':
|
||||
'Pick the ElevenLabs voice the mascot uses for spoken replies. Filter by gender, pick from the curated list, paste a custom id, or let the app pick a voice that matches your interface language.',
|
||||
'settings.mascot.voice.genderFemale': 'Female',
|
||||
'settings.mascot.voice.genderHeading': 'Voice gender',
|
||||
'settings.mascot.voice.genderMale': 'Male',
|
||||
'settings.mascot.voice.heading': 'Voice',
|
||||
'settings.mascot.voice.preset': 'Voice preset',
|
||||
'settings.mascot.voice.presetHeading': 'Voice preset',
|
||||
'settings.mascot.voice.preview': 'Preview voice',
|
||||
'settings.mascot.voice.previewError': 'Voice preview failed',
|
||||
'settings.mascot.voice.previewing': 'Previewing…',
|
||||
'settings.mascot.voice.reset': 'Reset to default',
|
||||
'settings.mascot.voice.useLocaleDefault': 'Match the app language',
|
||||
'settings.mascot.voice.useLocaleDefaultDesc':
|
||||
'Auto-pick a voice for the current interface language.',
|
||||
'settings.memoryWindow.balanced.badge': 'Recommended',
|
||||
'settings.memoryWindow.balanced.hint':
|
||||
'Sensible default — good continuity without burning extra tokens on every run.',
|
||||
|
||||
@@ -256,6 +256,11 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
const socketStatus = useAppSelector(selectSocketStatus);
|
||||
const agentProfiles = useAppSelector(selectAgentProfiles);
|
||||
const selectedAgentProfileId = useAppSelector(selectActiveAgentProfileId);
|
||||
// Optional chain because narrow test stores (e.g. Conversations.test
|
||||
// bootstraps without the locale slice) shouldn't crash here. `'en'`
|
||||
// matches the no-locale-directive branch in the core, so legacy
|
||||
// behaviour stays intact.
|
||||
const uiLocale = useAppSelector(state => state.locale?.current ?? 'en');
|
||||
const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread);
|
||||
const taskBoardByThread = useAppSelector(state => state.chatRuntime.taskBoardByThread);
|
||||
const inferenceStatusByThread = useAppSelector(
|
||||
@@ -758,6 +763,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
message: trimmed,
|
||||
model: CHAT_MODEL_ID,
|
||||
profileId: selectedAgentProfileId,
|
||||
locale: uiLocale,
|
||||
});
|
||||
trackEvent('chat_message_sent');
|
||||
|
||||
|
||||
@@ -714,6 +714,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
message: 'hello cloud',
|
||||
model: 'reasoning-v1',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -847,6 +848,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
message: 'enter send',
|
||||
model: 'reasoning-v1',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -921,6 +923,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
message: '안녕',
|
||||
model: 'reasoning-v1',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -627,6 +627,13 @@ export interface ChatSendParams {
|
||||
message: string;
|
||||
model?: string;
|
||||
profileId?: string | null;
|
||||
/**
|
||||
* BCP-47 UI locale (e.g. `'ar'`, `'zh-CN'`) — drives the core's
|
||||
* "reply in this language" system-prompt directive. Optional so
|
||||
* callers that don't have a locale handy (legacy paths, tests) keep
|
||||
* working unchanged.
|
||||
*/
|
||||
locale?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,6 +658,7 @@ export async function chatSend(params: ChatSendParams): Promise<void> {
|
||||
message: params.message,
|
||||
model_override: params.model ?? undefined,
|
||||
profile_id: params.profileId ?? undefined,
|
||||
locale: params.locale ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -960,7 +960,12 @@ async function handoffToOrchestrator(
|
||||
try {
|
||||
const thread = await threadApi.createNewThread();
|
||||
log('meet: created orchestrator thread %s for code=%s', thread.id, session.code);
|
||||
await chatSend({ threadId: thread.id, message: prompt, model: MEET_ORCHESTRATOR_MODEL });
|
||||
await chatSend({
|
||||
threadId: thread.id,
|
||||
message: prompt,
|
||||
model: MEET_ORCHESTRATOR_MODEL,
|
||||
locale: store.getState().locale.current,
|
||||
});
|
||||
log('meet: handed off to orchestrator thread=%s code=%s', thread.id, session.code);
|
||||
store.dispatch(
|
||||
appendLog({
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { REHYDRATE } from 'redux-persist';
|
||||
|
||||
import {
|
||||
defaultVoiceIdForLocale,
|
||||
ELEVENLABS_VOICE_PRESETS,
|
||||
} from '../components/settings/panels/elevenlabsVoicePresets';
|
||||
import type { MascotColor } from '../features/human/Mascot/mascotPalette';
|
||||
import type { Locale } from '../lib/i18n/types';
|
||||
import { MASCOT_VOICE_ID } from '../utils/config';
|
||||
import { resetUserScopedState } from './resetActions';
|
||||
|
||||
export const SUPPORTED_MASCOT_COLORS: readonly MascotColor[] = [
|
||||
@@ -14,6 +20,16 @@ export const SUPPORTED_MASCOT_COLORS: readonly MascotColor[] = [
|
||||
|
||||
export const DEFAULT_MASCOT_COLOR: MascotColor = 'yellow';
|
||||
|
||||
export type MascotVoiceGender = 'male' | 'female';
|
||||
|
||||
/**
|
||||
* Default gender for the mascot's reply voice. Matches the default
|
||||
* voice id (`MASCOT_VOICE_ID` — George, a male multilingual ElevenLabs
|
||||
* voice) so new users see consistent state in the Mascot settings
|
||||
* panel without any extra writes.
|
||||
*/
|
||||
export const DEFAULT_MASCOT_VOICE_GENDER: MascotVoiceGender = 'male';
|
||||
|
||||
/**
|
||||
* Maximum length of a stored mascot voice id. ElevenLabs voice ids are
|
||||
* short opaque alphanumeric strings (typically 20 chars); the cap exists
|
||||
@@ -41,6 +57,10 @@ function isMascotVoiceId(value: unknown): value is string {
|
||||
);
|
||||
}
|
||||
|
||||
function isMascotVoiceGender(value: unknown): value is MascotVoiceGender {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
export interface MascotState {
|
||||
color: MascotColor;
|
||||
/**
|
||||
@@ -52,6 +72,21 @@ export interface MascotState {
|
||||
* reset is just `setMascotVoiceId(null)`.
|
||||
*/
|
||||
voiceId: string | null;
|
||||
/**
|
||||
* Coarse gender bucket used by the Mascot settings panel to filter
|
||||
* the voice preset dropdown and to drive the "default voice from app
|
||||
* locale" toggle (combined with the current locale to pick a single
|
||||
* voice id). Independent of `voiceId` — the user can keep a manual
|
||||
* override and still flip gender for the locale-default branch.
|
||||
*/
|
||||
voiceGender: MascotVoiceGender;
|
||||
/**
|
||||
* When true, ignore `voiceId` and pick the voice from the active
|
||||
* locale (+ `voiceGender`) via `defaultVoiceIdForLocale`. Lets users
|
||||
* say "speak in my UI language" once and have the mascot follow
|
||||
* locale changes without re-opening settings.
|
||||
*/
|
||||
voiceUseLocaleDefault: boolean;
|
||||
/**
|
||||
* Server-side mascot id selected from the backend mascot library
|
||||
* (PR tinyhumansai/backend#770). `null` keeps the local YellowMascot
|
||||
@@ -66,6 +101,8 @@ export interface MascotState {
|
||||
const initialState: MascotState = {
|
||||
color: DEFAULT_MASCOT_COLOR,
|
||||
voiceId: null,
|
||||
voiceGender: DEFAULT_MASCOT_VOICE_GENDER,
|
||||
voiceUseLocaleDefault: false,
|
||||
selectedMascotId: null,
|
||||
};
|
||||
|
||||
@@ -84,12 +121,6 @@ const mascotSlice = createSlice({
|
||||
state.color = action.payload;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set or clear the user-selected mascot voice id. Whitespace is
|
||||
* trimmed; empty / oversize / non-string values clear the override
|
||||
* (falling back to the build-time default voice). Pass `null` from
|
||||
* the UI's Reset button to explicitly drop the override.
|
||||
*/
|
||||
/**
|
||||
* Select a backend mascot by id. Trimmed; empty / oversize / null
|
||||
* clears the override and falls back to the local YellowMascot.
|
||||
@@ -105,6 +136,12 @@ const mascotSlice = createSlice({
|
||||
state.selectedMascotId = null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set or clear the user-selected mascot voice id. Whitespace is
|
||||
* trimmed; empty / oversize / non-string values clear the override
|
||||
* (falling back to the build-time default voice). Pass `null` from
|
||||
* the UI's Reset button to explicitly drop the override.
|
||||
*/
|
||||
setMascotVoiceId(state, action: PayloadAction<string | null>) {
|
||||
if (action.payload == null) {
|
||||
state.voiceId = null;
|
||||
@@ -119,6 +156,14 @@ const mascotSlice = createSlice({
|
||||
state.voiceId = null;
|
||||
}
|
||||
},
|
||||
setMascotVoiceGender(state, action: PayloadAction<MascotVoiceGender>) {
|
||||
if (isMascotVoiceGender(action.payload)) {
|
||||
state.voiceGender = action.payload;
|
||||
}
|
||||
},
|
||||
setMascotVoiceUseLocaleDefault(state, action: PayloadAction<boolean>) {
|
||||
state.voiceUseLocaleDefault = Boolean(action.payload);
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(resetUserScopedState, () => initialState);
|
||||
@@ -128,7 +173,13 @@ const mascotSlice = createSlice({
|
||||
const rehydrateAction = action as {
|
||||
type: typeof REHYDRATE;
|
||||
key: string;
|
||||
payload?: { color?: unknown; voiceId?: unknown; selectedMascotId?: unknown };
|
||||
payload?: {
|
||||
color?: unknown;
|
||||
voiceId?: unknown;
|
||||
voiceGender?: unknown;
|
||||
voiceUseLocaleDefault?: unknown;
|
||||
selectedMascotId?: unknown;
|
||||
};
|
||||
};
|
||||
if (rehydrateAction.key !== 'mascot') return;
|
||||
const restoredColor = rehydrateAction.payload?.color;
|
||||
@@ -151,11 +202,25 @@ const mascotSlice = createSlice({
|
||||
: isMascotVoiceId(restoredVoiceId)
|
||||
? (restoredVoiceId as string).trim()
|
||||
: null;
|
||||
const restoredGender = rehydrateAction.payload?.voiceGender;
|
||||
state.voiceGender = isMascotVoiceGender(restoredGender)
|
||||
? restoredGender
|
||||
: DEFAULT_MASCOT_VOICE_GENDER;
|
||||
state.voiceUseLocaleDefault =
|
||||
typeof rehydrateAction.payload?.voiceUseLocaleDefault === 'boolean'
|
||||
? rehydrateAction.payload.voiceUseLocaleDefault
|
||||
: false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setMascotColor, setMascotVoiceId, setSelectedMascotId } = mascotSlice.actions;
|
||||
export const {
|
||||
setMascotColor,
|
||||
setMascotVoiceId,
|
||||
setMascotVoiceGender,
|
||||
setMascotVoiceUseLocaleDefault,
|
||||
setSelectedMascotId,
|
||||
} = mascotSlice.actions;
|
||||
|
||||
export const selectMascotColor = (state: { mascot: MascotState }): MascotColor =>
|
||||
state.mascot.color;
|
||||
@@ -163,8 +228,50 @@ export const selectMascotColor = (state: { mascot: MascotState }): MascotColor =
|
||||
export const selectMascotVoiceId = (state: { mascot: MascotState }): string | null =>
|
||||
state.mascot.voiceId;
|
||||
|
||||
export const selectMascotVoiceGender = (state: { mascot: MascotState }): MascotVoiceGender =>
|
||||
state.mascot.voiceGender;
|
||||
|
||||
export const selectMascotVoiceUseLocaleDefault = (state: { mascot: MascotState }): boolean =>
|
||||
state.mascot.voiceUseLocaleDefault;
|
||||
|
||||
export const selectSelectedMascotId = (state: { mascot: MascotState }): string | null =>
|
||||
state.mascot.selectedMascotId;
|
||||
|
||||
/**
|
||||
* Resolve the voice id the next reply will be synthesised with, taking
|
||||
* into account every mascot-voice setting plus the active locale. This
|
||||
* is the single source of truth read by both UI ("what does the picker
|
||||
* show as current?") and the TTS hook ("what voice should I pass to
|
||||
* synthesizeSpeech?"), so they can never drift.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `voiceUseLocaleDefault` on → locale-default for `voiceGender`.
|
||||
* 2. Manual `voiceId` set → that id.
|
||||
* 3. Otherwise → `MASCOT_VOICE_ID` (the build-time default).
|
||||
*
|
||||
* The first branch deliberately wins over a manual override so the
|
||||
* "speak in my UI language" toggle behaves predictably — flipping it on
|
||||
* without first clearing a stale override would otherwise silently do
|
||||
* nothing. The UI in `MascotPanel` makes this precedence visible by
|
||||
* disabling the manual picker while the toggle is on.
|
||||
*/
|
||||
export const selectEffectiveMascotVoiceId = (state: {
|
||||
mascot: MascotState;
|
||||
locale?: { current: Locale };
|
||||
}): string => {
|
||||
if (state.mascot.voiceUseLocaleDefault) {
|
||||
// `locale` slice may be absent in narrow test harnesses (e.g.
|
||||
// MascotPanel.test wires only the mascot reducer). Default to `en`
|
||||
// so the resolver still produces a usable id rather than throwing.
|
||||
const current = state.locale?.current ?? 'en';
|
||||
return defaultVoiceIdForLocale(current, state.mascot.voiceGender);
|
||||
}
|
||||
if (state.mascot.voiceId) return state.mascot.voiceId;
|
||||
// Belt-and-braces: if the build-time default ever drops out of the
|
||||
// curated preset list, fall back to the first preset rather than a
|
||||
// bogus empty string.
|
||||
return MASCOT_VOICE_ID || ELEVENLABS_VOICE_PRESETS[0].id;
|
||||
};
|
||||
|
||||
export { mascotSlice };
|
||||
export default mascotSlice.reducer;
|
||||
|
||||
@@ -166,7 +166,8 @@ vi.mock('../utils/config', () => ({
|
||||
LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest',
|
||||
APP_VERSION: '0.0.0-test',
|
||||
DEV_JWT_TOKEN: undefined,
|
||||
MASCOT_VOICE_ID: 'ljX1ZrXuDIIRVcmiVSyR',
|
||||
MASCOT_VOICE_ID: 'JBFqnCBsd6RMkjVDRZzb',
|
||||
MASCOT_VOICE_MODEL_ID: 'eleven_multilingual_v2',
|
||||
}));
|
||||
|
||||
vi.mock('../services/backendUrl', () => ({
|
||||
|
||||
+17
-4
@@ -181,9 +181,22 @@ export const LATEST_APP_DOWNLOAD_URL =
|
||||
export const SENTRY_SMOKE_TEST = import.meta.env.VITE_SENTRY_SMOKE_TEST === 'true';
|
||||
|
||||
/**
|
||||
* ElevenLabs voice ID used for the mascot's reply speech. Picked to sound
|
||||
* like a friendly cartoon character rather than a human narrator. Override
|
||||
* with `VITE_MASCOT_VOICE_ID` to A/B alternative voices without a code change.
|
||||
* ElevenLabs voice ID used for the mascot's reply speech. `JBFqnCBsd6RMkjVDRZzb`
|
||||
* is "George" — a warm multilingual voice that pairs cleanly with the
|
||||
* `eleven_multilingual_v2` model (`MASCOT_VOICE_MODEL_ID` below) so the
|
||||
* mascot can speak any locale we ship without a voice swap. Override with
|
||||
* `VITE_MASCOT_VOICE_ID` to A/B alternatives without a code change.
|
||||
*/
|
||||
export const MASCOT_VOICE_ID =
|
||||
(import.meta.env.VITE_MASCOT_VOICE_ID as string | undefined)?.trim() || 'ljX1ZrXuDIIRVcmiVSyR';
|
||||
(import.meta.env.VITE_MASCOT_VOICE_ID as string | undefined)?.trim() || 'JBFqnCBsd6RMkjVDRZzb';
|
||||
|
||||
/**
|
||||
* ElevenLabs model used for mascot reply speech. `eleven_multilingual_v2`
|
||||
* speaks every locale we ship; the older `eleven_monolingual_v1` would
|
||||
* choke on non-Latin scripts. Override with `VITE_MASCOT_VOICE_MODEL_ID`
|
||||
* to pin a different model (e.g. `eleven_turbo_v2_5` for lower latency
|
||||
* at the cost of accent fidelity).
|
||||
*/
|
||||
export const MASCOT_VOICE_MODEL_ID =
|
||||
(import.meta.env.VITE_MASCOT_VOICE_MODEL_ID as string | undefined)?.trim() ||
|
||||
'eleven_multilingual_v2';
|
||||
|
||||
@@ -157,6 +157,8 @@ struct ChatStartPayload {
|
||||
temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -256,6 +258,7 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) {
|
||||
model_override,
|
||||
payload.temperature,
|
||||
payload.profile_id,
|
||||
payload.locale,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ impl EventHandler for ChannelInboundSubscriber {
|
||||
crate::openhuman::channels::providers::web::subscribe_web_channel_events();
|
||||
|
||||
let request_id = match crate::openhuman::channels::providers::web::start_chat(
|
||||
&client_id, &thread_id, message, None, None, None,
|
||||
&client_id, &thread_id, message, None, None, None, None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -333,6 +333,7 @@ pub async fn start_chat(
|
||||
model_override: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
profile_id: Option<String>,
|
||||
locale: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let client_id = client_id.trim().to_string();
|
||||
let thread_id = thread_id.trim().to_string();
|
||||
@@ -430,6 +431,7 @@ pub async fn start_chat(
|
||||
model_override,
|
||||
temperature,
|
||||
profile_id,
|
||||
locale,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -647,6 +649,7 @@ async fn run_chat_task(
|
||||
model_override: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
profile_id: Option<String>,
|
||||
locale: Option<String>,
|
||||
) -> Result<WebChatTaskResult, String> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -723,6 +726,7 @@ async fn run_chat_task(
|
||||
&profile,
|
||||
model_override.clone(),
|
||||
temperature,
|
||||
locale.as_deref(),
|
||||
)?,
|
||||
true,
|
||||
)
|
||||
@@ -736,6 +740,7 @@ async fn run_chat_task(
|
||||
&profile,
|
||||
model_override.clone(),
|
||||
temperature,
|
||||
locale.as_deref(),
|
||||
)?,
|
||||
true,
|
||||
),
|
||||
@@ -1352,6 +1357,7 @@ fn build_session_agent(
|
||||
profile: &AgentProfile,
|
||||
model_override: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
locale: Option<&str>,
|
||||
) -> Result<Agent, String> {
|
||||
let mut effective = config.clone();
|
||||
if let Some(model) = model_override {
|
||||
@@ -1415,11 +1421,32 @@ fn build_session_agent(
|
||||
);
|
||||
}
|
||||
|
||||
// Compose the locale-directive (e.g. "Respond in Arabic") with the
|
||||
// profile's own suffix so the agent always reads the user's
|
||||
// preferred reply language alongside any profile-level rules. The
|
||||
// directive is emitted only for non-English locales — English
|
||||
// matches the agent's default, so injecting it would just be noise
|
||||
// for the LLM and a regression risk for cached/seeded transcripts.
|
||||
let locale_directive = locale.and_then(locale_reply_directive);
|
||||
let composed_suffix = compose_system_prompt_suffix(
|
||||
locale_directive.as_deref(),
|
||||
profile.system_prompt_suffix.as_deref(),
|
||||
);
|
||||
if let Some(s) = locale_directive.as_deref() {
|
||||
log::info!(
|
||||
"[web-channel] injecting locale directive client={} thread={} locale={} directive={:?}",
|
||||
client_id,
|
||||
thread_id,
|
||||
locale.unwrap_or(""),
|
||||
s
|
||||
);
|
||||
}
|
||||
|
||||
let agent_result = Agent::from_config_for_agent_with_profile(
|
||||
&effective,
|
||||
target_agent_id,
|
||||
reflection_chunks,
|
||||
profile.system_prompt_suffix.clone(),
|
||||
composed_suffix,
|
||||
);
|
||||
|
||||
agent_result
|
||||
@@ -1500,6 +1527,12 @@ struct WebChatParams {
|
||||
model_override: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
profile_id: Option<String>,
|
||||
/// BCP-47 locale of the frontend UI (e.g. `ar`, `zh-CN`). When set
|
||||
/// and not English, the system prompt is augmented to ask the
|
||||
/// agent to reply in that language. `None` keeps the agent's
|
||||
/// default language (English) so existing integrations don't
|
||||
/// silently change behaviour.
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1515,6 +1548,7 @@ pub async fn channel_web_chat(
|
||||
model_override: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
profile_id: Option<String>,
|
||||
locale: Option<String>,
|
||||
) -> Result<RpcOutcome<Value>, String> {
|
||||
let request_id = start_chat(
|
||||
client_id,
|
||||
@@ -1523,6 +1557,7 @@ pub async fn channel_web_chat(
|
||||
model_override,
|
||||
temperature,
|
||||
profile_id,
|
||||
locale,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1584,6 +1619,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
optional_string("model_override", "Optional model override."),
|
||||
optional_f64("temperature", "Optional temperature override."),
|
||||
optional_string("profile_id", "Optional agent profile id."),
|
||||
optional_string(
|
||||
"locale",
|
||||
"Optional BCP-47 UI locale (e.g. 'ar', 'zh-CN'). Drives the \"reply in this language\" system-prompt directive.",
|
||||
),
|
||||
],
|
||||
outputs: vec![json_output("ack", "Acceptance payload.")],
|
||||
},
|
||||
@@ -1623,12 +1662,60 @@ fn handle_chat(params: Map<String, Value>) -> ControllerFuture {
|
||||
p.model_override,
|
||||
p.temperature,
|
||||
p.profile_id,
|
||||
p.locale,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a frontend BCP-47 locale tag to a system-prompt directive
|
||||
/// instructing the agent to reply in that language. Returns `None`
|
||||
/// for English (the agent's default — adding "Respond in English"
|
||||
/// is a no-op for the LLM but risks invalidating cached prefixes)
|
||||
/// and for unknown tags so the agent falls through to its default
|
||||
/// behaviour instead of seeing a half-built directive.
|
||||
pub(crate) fn locale_reply_directive(locale: &str) -> Option<String> {
|
||||
let language = match locale.trim() {
|
||||
// Keep this table in lockstep with `Locale` in
|
||||
// `app/src/lib/i18n/types.ts` — every locale the frontend can
|
||||
// ship should resolve to a language name here.
|
||||
"ar" => "Arabic",
|
||||
"bn" => "Bengali",
|
||||
"es" => "Spanish",
|
||||
"fr" => "French",
|
||||
"hi" => "Hindi",
|
||||
"id" => "Indonesian",
|
||||
"it" => "Italian",
|
||||
"pt" => "Portuguese",
|
||||
"ru" => "Russian",
|
||||
"zh-CN" | "zh" => "Simplified Chinese",
|
||||
// English (and any unrecognised tag) → no directive.
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"User language: the user's interface is set to {language}. \
|
||||
Respond in {language} unless the user explicitly asks for a different language. \
|
||||
Keep proper nouns, code, and command names untranslated."
|
||||
))
|
||||
}
|
||||
|
||||
/// Stitch the locale directive (if any) onto the profile's own
|
||||
/// system-prompt suffix. The directive comes first so it shows up
|
||||
/// near the top of the appended block — easier for the LLM to honour
|
||||
/// than language guidance buried after profile-specific rules.
|
||||
pub(crate) fn compose_system_prompt_suffix(
|
||||
locale_directive: Option<&str>,
|
||||
profile_suffix: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match (locale_directive, profile_suffix) {
|
||||
(None, None) => None,
|
||||
(Some(d), None) => Some(d.to_string()),
|
||||
(None, Some(p)) => Some(p.to_string()),
|
||||
(Some(d), Some(p)) => Some(format!("{d}\n\n{p}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cancel(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<WebCancelParams>(params)?;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::{
|
||||
all_web_channel_controller_schemas, all_web_channel_registered_controllers, cancel_chat,
|
||||
classify_inference_error, event_session_id_for, extract_provider_error_detail,
|
||||
generic_inference_error_user_message, inference_budget_exceeded_user_message,
|
||||
is_inference_budget_exceeded_error, json_output, key_for, normalize_model_override,
|
||||
optional_f64, optional_string, provider_role_for_model_override, required_string, schemas,
|
||||
classify_inference_error, compose_system_prompt_suffix, event_session_id_for,
|
||||
extract_provider_error_detail, generic_inference_error_user_message,
|
||||
inference_budget_exceeded_user_message, is_inference_budget_exceeded_error, json_output,
|
||||
key_for, locale_reply_directive, normalize_model_override, optional_f64, optional_string,
|
||||
provider_role_for_model_override, required_string, schemas,
|
||||
set_test_forced_run_chat_task_error, start_chat, subscribe_web_channel_events,
|
||||
};
|
||||
use crate::core::TypeSchema;
|
||||
@@ -23,17 +24,17 @@ impl Drop for TestForcedRunChatTaskErrorGuard {
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_chat_validates_required_fields() {
|
||||
let err = start_chat("", "thread", "hello", None, None, None)
|
||||
let err = start_chat("", "thread", "hello", None, None, None, None)
|
||||
.await
|
||||
.expect_err("client id should be required");
|
||||
assert!(err.contains("client_id is required"));
|
||||
|
||||
let err = start_chat("client", "", "hello", None, None, None)
|
||||
let err = start_chat("client", "", "hello", None, None, None, None)
|
||||
.await
|
||||
.expect_err("thread id should be required");
|
||||
assert!(err.contains("thread_id is required"));
|
||||
|
||||
let err = start_chat("client", "thread", " ", None, None, None)
|
||||
let err = start_chat("client", "thread", " ", None, None, None, None)
|
||||
.await
|
||||
.expect_err("message should be required");
|
||||
assert!(err.contains("message is required"));
|
||||
@@ -48,6 +49,7 @@ async fn start_chat_rejects_prompt_injection_payload() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("prompt-injection payload should be rejected");
|
||||
@@ -89,6 +91,7 @@ async fn start_chat_emits_sanitized_chat_error_on_inference_failure() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("start_chat should accept valid request");
|
||||
@@ -410,3 +413,47 @@ fn fingerprint_model_override_and_temperature_participate() {
|
||||
"per-message temperature must invalidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_reply_directive_returns_none_for_english() {
|
||||
assert!(locale_reply_directive("en").is_none());
|
||||
// Unrecognised tags fall through too — the agent's default is fine.
|
||||
assert!(locale_reply_directive("xx").is_none());
|
||||
assert!(locale_reply_directive("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_reply_directive_renders_known_locales() {
|
||||
let ar = locale_reply_directive("ar").expect("arabic directive expected");
|
||||
assert!(
|
||||
ar.contains("Arabic"),
|
||||
"directive must name the language: {ar}"
|
||||
);
|
||||
assert!(
|
||||
ar.contains("Respond in Arabic"),
|
||||
"directive must instruct the agent: {ar}"
|
||||
);
|
||||
let zh = locale_reply_directive("zh-CN").expect("zh-CN directive expected");
|
||||
assert!(zh.contains("Simplified Chinese"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_system_prompt_suffix_combines_locale_and_profile() {
|
||||
// Both present → locale first, blank line, then profile suffix.
|
||||
let combined = compose_system_prompt_suffix(Some("LOCALE"), Some("PROFILE"))
|
||||
.expect("Some output expected when either input is set");
|
||||
assert_eq!(combined, "LOCALE\n\nPROFILE");
|
||||
|
||||
// Only locale.
|
||||
assert_eq!(
|
||||
compose_system_prompt_suffix(Some("LOCALE"), None).as_deref(),
|
||||
Some("LOCALE")
|
||||
);
|
||||
// Only profile.
|
||||
assert_eq!(
|
||||
compose_system_prompt_suffix(None, Some("PROFILE")).as_deref(),
|
||||
Some("PROFILE")
|
||||
);
|
||||
// Both absent → None preserves the agent's vanilla prompt.
|
||||
assert!(compose_system_prompt_suffix(None, None).is_none());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user