From bd95022efebbd2ee826f1a0703a897375d8209ea Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 20 Apr 2026 06:23:09 +0700 Subject: [PATCH] feat(tts): extract PortalVoicePicker and route Gemini through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the TTS params/layout/agent-override plan. Web (`ui/web/src/components/voice-picker.tsx`): - Extract `PortalVoicePicker` (search + VoiceRow + portal dropdown) from the `DynamicVoicePicker` inner body. - Introduce `PortalVoice` shape + `mapCapVoiceToPortal` adapter so capability `VoiceOption` and `useVoices()` results both feed in. - Dispatcher gains a Gemini route: static voices + provider==="gemini" → PortalVoicePicker with the static catalog. Other static providers (OpenAI) continue to use `StaticVoicePicker` (Radix Select). - `DynamicVoicePicker` reduced to a thin fetch wrapper around `useVoices()` + `useRefreshVoices()`. Desktop mirror (`ui/desktop/frontend/src/components/agents/voice-picker.tsx`): - Same extraction + Gemini route. OpenAI keeps the Combobox-driven static path to preserve native UX. Tests: 9 new cases covering Gemini→Portal dispatch, mapping shape, absent `preview_url`/`labels`, plus the unchanged ElevenLabs/MiniMax/ OpenAI paths. 179/179 web tests pass. No backend change. No new i18n keys (existing voice_picker.* reused). --- .../src/components/agents/voice-picker.tsx | 268 ++++++++++++++---- ui/web/src/components/voice-picker.test.tsx | 164 +++++++++++ ui/web/src/components/voice-picker.tsx | 152 +++++++--- 3 files changed, 490 insertions(+), 94 deletions(-) diff --git a/ui/desktop/frontend/src/components/agents/voice-picker.tsx b/ui/desktop/frontend/src/components/agents/voice-picker.tsx index deee9fd6..94882260 100644 --- a/ui/desktop/frontend/src/components/agents/voice-picker.tsx +++ b/ui/desktop/frontend/src/components/agents/voice-picker.tsx @@ -1,18 +1,22 @@ /** - * Desktop VoicePicker — capabilities-driven dispatch (Phase C mirror). + * Desktop VoicePicker — capabilities-driven dispatch (Phase 2 mirror). * Mirrors web voice-picker.tsx dispatch logic with desktop-native UI. * * - "" provider → disabled empty-state - * - voices_dynamic=true in custom_features → DynamicVoicePicker - * - static voices[] in capabilities → StaticVoicePicker (Combobox) + * - gemini + static voices → PortalVoicePicker (search + row UI) + * - other static voices[] + !voicesDynamic → StaticVoicePicker (Combobox) + * - voices_dynamic=true OR minimax → DynamicVoicePicker → PortalVoicePicker * - MiniMax first-fetch failure → FreeTextPicker fallback */ +import { useId, useState, useRef } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useVoices, useRefreshVoices } from '../../hooks/use-voices' import { useTtsCapabilities } from '../../hooks/use-tts-capabilities' import { VoicePreviewButton } from './voice-preview-button' import { Combobox } from '../common/Combobox' -import type { Voice } from '../../services/voices' +import { usePortalDropdownClose } from '../../hooks/use-portal-dropdown-close' +import type { VoiceOption } from '../../api/tts-capabilities' import type { TtsProviderId } from '@/data/tts-providers' interface Props { @@ -22,7 +26,23 @@ interface Props { provider?: TtsProviderId | '' } -function VoiceOption({ voice, selected }: { voice: Voice; selected: boolean }) { +/** + * Unified voice shape for PortalVoicePicker (desktop). + * Matches web PortalVoice — voice_id + name; labels/preview_url optional. + */ +export interface PortalVoice { + voice_id: string + name: string + labels?: Record + preview_url?: string +} + +/** Maps a capability VoiceOption to PortalVoice (no labels/preview for Gemini). */ +function mapCapVoiceToPortal(v: VoiceOption): PortalVoice { + return { voice_id: v.voice_id, name: v.name } +} + +function VoiceOption({ voice, selected }: { voice: PortalVoice; selected: boolean }) { const labelEntries = ['gender', 'accent', 'age', 'use_case'] .filter((k) => voice.labels?.[k]) .map((k) => voice.labels![k]) @@ -51,6 +71,18 @@ export function VoicePicker({ value, onChange, disabled, provider }: Props) { const voicesDynamic = providerCaps?.custom_features?.['voices_dynamic'] === true const staticVoices = providerCaps?.voices ?? [] + // Gemini: static voices rendered through portal picker (search + row UI) + if (provider === 'gemini' && staticVoices.length > 0) { + return ( + + ) + } + if (providerCaps && !voicesDynamic && staticVoices.length > 0) { return ( void + disabled?: boolean + isLoading?: boolean + onRefresh?: () => void +}) { + const { t } = useTranslation('tts') + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const listboxId = useId() + + const selected = voices.find((v) => v.voice_id === value) + + const filtered = search.trim() + ? voices.filter((v) => v.name.toLowerCase().includes(search.toLowerCase())) + : voices + + const handleToggle = () => { + if (disabled) return + setOpen((prev) => { + if (prev) return false + setSearch('') + return true + }) + } + + const handleSelect = (voice: PortalVoice) => { + onChange(voice.voice_id) + setOpen(false) + setSearch('') + } + + usePortalDropdownClose({ + open, + onClose: () => setOpen(false), + ignore: [triggerRef, dropdownRef], + }) + + const dropdownContent = open && ( +
{ + if (!triggerRef.current) return {} + const rect = triggerRef.current.getBoundingClientRect() + const spaceBelow = window.innerHeight - rect.bottom + const dropH = 240 + if (spaceBelow < dropH && rect.top > dropH) { + return { position: 'fixed' as const, bottom: window.innerHeight - rect.top + 4, left: rect.left, width: rect.width } + } + return { position: 'fixed' as const, top: rect.bottom + 4, left: rect.left, width: rect.width } + })()} + > +
+ setSearch(e.target.value)} + placeholder={t('voice_placeholder')} + className="flex-1 bg-transparent text-xs outline-none placeholder:text-text-muted" + /> + {onRefresh && ( + + )} +
+ +
+ {isLoading ? ( +

{t('voice_loading')}

+ ) : filtered.length === 0 ? ( +

+ {voices.length === 0 ? t('voice_save_config_first') : search ? t('voice_no_voices') : t('voice_loading')} +

+ ) : ( + filtered.map((voice) => ( +
e.preventDefault()} + onClick={() => handleSelect(voice)} + > + +
+ )) + )} +
+
+ ) + + return ( +
+ + + {open && createPortal(dropdownContent, document.body)} +
+ ) +} + +/** + * DynamicVoicePicker (desktop) — thin wrapper: fetches voices, delegates to PortalVoicePicker. + * Handles free-text fallback for MiniMax on fetch error. + */ function DynamicVoicePicker({ value, onChange, @@ -150,7 +349,6 @@ function DynamicVoicePicker({ disabled?: boolean allowFreeText: boolean }) { - const { t } = useTranslation('tts') const { data: voices = [], isLoading, error } = useVoices() const { mutate: refresh, isPending: refreshing } = useRefreshVoices() @@ -159,57 +357,15 @@ function DynamicVoicePicker({ return } - const options = voices.map((v) => ({ value: v.voice_id, label: v.name })) - return ( -
-
-
- -
- -
- - {value && voices.find((v) => v.voice_id === value) && ( -
- v.voice_id === value)?.preview_url} - voiceName={voices.find((v) => v.voice_id === value)?.name ?? ''} - /> - {voices.find((v) => v.voice_id === value)?.name} -
- )} - - {!isLoading && voices.length === 0 && ( -

{t('voice_no_voices')}

- )} -
+ refresh()} + /> ) } diff --git a/ui/web/src/components/voice-picker.test.tsx b/ui/web/src/components/voice-picker.test.tsx index c81acae0..61e89da9 100644 --- a/ui/web/src/components/voice-picker.test.tsx +++ b/ui/web/src/components/voice-picker.test.tsx @@ -3,9 +3,15 @@ * * NOTE: @testing-library/react is not installed — tests cover pure logic * and module contracts rather than DOM rendering. + * + * Phase 2 additions: + * - Gemini dispatch now routes to PortalVoicePicker (was StaticVoicePicker). + * - PortalVoice shape: voice_id + name; labels/preview_url optional. + * - VoiceRow gracefully handles absent preview_url + absent labels for Gemini. */ import { describe, it, expect, vi } from "vitest"; import type { Voice } from "@/api/voices"; +import type { PortalVoice } from "@/components/voice-picker"; // --- helpers under test (extracted from voice-picker.tsx logic) --- @@ -24,6 +30,14 @@ function filterVoices(voices: Voice[], search: string): Voice[] { return voices.filter((v) => v.name.toLowerCase().includes(q)); } +/** Mirrors getVisibleLabels for PortalVoice (used by Gemini path). */ +function getPortalVoiceLabels(voice: PortalVoice): string[] { + return LABEL_KEYS + .filter((k) => voice.labels?.[k]) + .map((k) => voice.labels![k] as string) + .slice(0, 2); +} + // --- useVoices shape test via vi.mock --- vi.mock("@/api/voices", () => ({ @@ -140,3 +154,153 @@ describe("useVoices — loading / empty / data states", () => { expect(data[0]!.name).toBe("Aria"); }); }); + +// --- Phase 2: Gemini dispatch + PortalVoice shape tests --- + +describe("PortalVoice — Gemini static voice shape", () => { + it("Gemini voice has only voice_id + name (no labels, no preview_url)", () => { + // Mirrors mapCapVoiceToPortal: VoiceOption → PortalVoice + const geminiVoice: PortalVoice = { voice_id: "Aoede", name: "Aoede" }; + expect(geminiVoice.labels).toBeUndefined(); + expect(geminiVoice.preview_url).toBeUndefined(); + }); + + it("getPortalVoiceLabels returns empty array for Gemini voice (no labels field)", () => { + const geminiVoice: PortalVoice = { voice_id: "Kore", name: "Kore" }; + expect(getPortalVoiceLabels(geminiVoice)).toEqual([]); + }); + + it("filterVoices works on PortalVoice array (Gemini search)", () => { + const geminiVoices: PortalVoice[] = [ + { voice_id: "Aoede", name: "Aoede" }, + { voice_id: "Kore", name: "Kore" }, + { voice_id: "Charon", name: "Charon" }, + ]; + const filtered = geminiVoices.filter((v) => + v.name.toLowerCase().includes("ao") + ); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.voice_id).toBe("Aoede"); + }); + + it("filterVoices is case-insensitive for Gemini voices", () => { + const geminiVoices: PortalVoice[] = [ + { voice_id: "Charon", name: "Charon" }, + { voice_id: "Fenrir", name: "Fenrir" }, + ]; + const filtered = geminiVoices.filter((v) => + v.name.toLowerCase().includes("char") + ); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.name).toBe("Charon"); + }); +}); + +describe("PortalVoicePicker dispatch — Gemini routes to PortalVoicePicker (Phase 2)", () => { + /** + * Characterization: Gemini has static voices + no voices_dynamic flag. + * Before Phase 2: routed to StaticVoicePicker (Radix Select). + * After Phase 2: routes to PortalVoicePicker (search + row UI). + * + * We verify the dispatch rule via the capability shape check rather than DOM render + * (no @testing-library/react available). + */ + it("Gemini capabilities: no voices_dynamic flag + voices array present", () => { + // Simulates the capability shape that triggers the Gemini portal route + const geminiCaps = { + provider: "gemini", + display_name: "Google Gemini", + requires_api_key: true, + voices: [ + { voice_id: "Aoede", name: "Aoede" }, + { voice_id: "Kore", name: "Kore" }, + ], + // custom_features does NOT contain voices_dynamic for Gemini + custom_features: { multi_speaker: true, audio_tags: true } as Record, + }; + + const voicesDynamic = geminiCaps.custom_features?.["voices_dynamic"] === true; + const staticVoices = geminiCaps.voices ?? []; + + // Gemini route condition: provider === "gemini" AND staticVoices.length > 0 + const routesToPortal = geminiCaps.provider === "gemini" && staticVoices.length > 0; + // Static route (OpenAI) condition: NOT gemini AND has caps AND !dynamic AND has voices + const routesToStatic = geminiCaps.provider !== "gemini" && !voicesDynamic && staticVoices.length > 0; + + expect(voicesDynamic).toBe(false); + expect(staticVoices).toHaveLength(2); + expect(routesToPortal).toBe(true); + expect(routesToStatic).toBe(false); + }); + + it("OpenAI capabilities: no voices_dynamic + voices present → still routes to StaticVoicePicker", () => { + const openaiCaps = { + provider: "openai", + display_name: "OpenAI", + requires_api_key: true, + voices: [ + { voice_id: "alloy", name: "Alloy" }, + { voice_id: "echo", name: "Echo" }, + ], + custom_features: null, + }; + + const voicesDynamic = openaiCaps.custom_features?.["voices_dynamic"] === true; + const staticVoices = openaiCaps.voices ?? []; + + const routesToPortal = openaiCaps.provider === "gemini" && staticVoices.length > 0; + const routesToStatic = openaiCaps.provider !== "gemini" && !voicesDynamic && staticVoices.length > 0; + + expect(routesToPortal).toBe(false); + expect(routesToStatic).toBe(true); + }); + + it("ElevenLabs: no static voices + no voices_dynamic → routes to DynamicVoicePicker", () => { + const elevenlabsCaps = { + provider: "elevenlabs", + display_name: "ElevenLabs", + requires_api_key: true, + voices: [], + custom_features: null, + }; + + const voicesDynamic = elevenlabsCaps.custom_features?.["voices_dynamic"] === true; + const staticVoices = elevenlabsCaps.voices ?? []; + + const routesToPortal = elevenlabsCaps.provider === "gemini" && staticVoices.length > 0; + const routesToStatic = elevenlabsCaps.provider !== "gemini" && !voicesDynamic && staticVoices.length > 0; + const routesToDynamic = !routesToPortal && !routesToStatic; + + expect(routesToDynamic).toBe(true); + }); + + it("MiniMax: voices_dynamic=true → routes to DynamicVoicePicker with allowFreeText", () => { + const minimaxCaps = { + provider: "minimax", + display_name: "MiniMax", + requires_api_key: true, + voices: [], + custom_features: { voices_dynamic: true }, + }; + + const voicesDynamic = minimaxCaps.custom_features?.["voices_dynamic"] === true; + expect(voicesDynamic).toBe(true); + + // minimax provider → DynamicVoicePicker with allowFreeText=true + const allowFreeText = minimaxCaps.provider === "minimax"; + expect(allowFreeText).toBe(true); + }); +}); + +describe("mapCapVoiceToPortal — Gemini voice mapping", () => { + it("maps VoiceOption to PortalVoice with only voice_id + name", () => { + // Mirrors the mapCapVoiceToPortal function in voice-picker.tsx + const capVoice = { voice_id: "Aoede", name: "Aoede", language: "en-US", gender: "female" }; + const portalVoice: PortalVoice = { voice_id: capVoice.voice_id, name: capVoice.name }; + + expect(portalVoice.voice_id).toBe("Aoede"); + expect(portalVoice.name).toBe("Aoede"); + expect(portalVoice.labels).toBeUndefined(); + expect(portalVoice.preview_url).toBeUndefined(); + }); +}); diff --git a/ui/web/src/components/voice-picker.tsx b/ui/web/src/components/voice-picker.tsx index e1c06bb0..373d1187 100644 --- a/ui/web/src/components/voice-picker.tsx +++ b/ui/web/src/components/voice-picker.tsx @@ -1,12 +1,12 @@ /** * VoicePicker — provider-aware voice selection component. * - * Dispatch logic (Phase C): + * Dispatch logic (Phase 2): * - "" provider → disabled empty-state - * - provider whose capabilities include voices_dynamic=true OR has no static voices - * → DynamicVoicePicker (fetches /v1/voices) - * - provider with static voices[] in capabilities → StaticVoicePicker - * - MiniMax with voices_dynamic=true and first-fetch failure → FreeTextPicker fallback + * - gemini + static voices → PortalVoicePicker (search + row UI) + * - other provider with static voices[] + !voicesDynamic → StaticVoicePicker (Radix) + * - voices_dynamic=true OR minimax → DynamicVoicePicker → PortalVoicePicker + * - MiniMax first-fetch failure → FreeTextPicker fallback * * useTtsCapabilities() drives the dispatch; falls back to ElevenLabs-dynamic behavior * when capabilities are not yet loaded (avoids flash of wrong picker). @@ -26,9 +26,9 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { useVoices, useRefreshVoices, type Voice } from "@/api/voices"; +import { useVoices, useRefreshVoices } from "@/api/voices"; import { VoicePreviewButton } from "@/components/voice-preview-button"; -import { useTtsCapabilities } from "@/api/tts-capabilities"; +import { useTtsCapabilities, type VoiceOption } from "@/api/tts-capabilities"; import type { TtsProviderId } from "@/data/tts-providers"; import { usePortalDropdownClose } from "@/hooks/use-portal-dropdown-close"; @@ -48,7 +48,23 @@ interface Props { const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const; -function VoiceRow({ voice, selected, onSelect }: { voice: Voice; selected: boolean; onSelect: () => void }) { +/** + * Unified voice shape for PortalVoicePicker. + * Static callers (Gemini) populate only voice_id + name; labels/preview_url optional. + */ +export interface PortalVoice { + voice_id: string; + name: string; + labels?: Record; + preview_url?: string; +} + +/** Maps a capability VoiceOption to PortalVoice (labels/preview absent for Gemini). */ +function mapCapVoiceToPortal(v: VoiceOption): PortalVoice { + return { voice_id: v.voice_id, name: v.name }; +} + +function VoiceRow({ voice, selected, onSelect }: { voice: PortalVoice; selected: boolean; onSelect: () => void }) { const labelEntries = LABEL_KEYS .filter((k) => voice.labels?.[k]) .map((k) => voice.labels![k]); @@ -93,8 +109,21 @@ export function VoicePicker({ value, onChange, disabled, provider, placeholder } // Static voices available in capabilities const staticVoices = providerCaps?.voices ?? []; + // Gemini: static voices rendered through portal picker (search + row UI) + if (provider === "gemini" && staticVoices.length > 0) { + return ( + + ); + } + if (providerCaps && !voicesDynamic && staticVoices.length > 0) { - // Static catalog available from capabilities + // Static catalog available from capabilities (OpenAI path → Radix Select) return ( void; + onChange: (voice_id: string) => void; disabled?: boolean; - allowFreeText: boolean; + isLoading?: boolean; + onRefresh?: () => void; + allowFreeText?: boolean; + placeholder?: string; }) { const { t } = useTranslation("tts"); const [open, setOpen] = useState(false); @@ -220,14 +266,6 @@ function DynamicVoicePicker({ const dropdownRef = useRef(null); const listboxId = useId(); - const { data: voices = [], isLoading, isError } = useVoices(); - const { mutate: refresh, isPending: refreshing } = useRefreshVoices(); - - // Fall back to free-text input when MiniMax voices fetch failed and list is empty - if (allowFreeText && isError && voices.length === 0) { - return ; - } - const selected = voices.find((v) => v.voice_id === value); const filtered = search.trim() @@ -243,7 +281,7 @@ function DynamicVoicePicker({ }); }; - const handleSelect = (voice: Voice) => { + const handleSelect = (voice: PortalVoice) => { onChange(voice.voice_id); setOpen(false); setSearch(""); @@ -251,7 +289,7 @@ function DynamicVoicePicker({ const handleRefresh = (e: React.MouseEvent) => { e.stopPropagation(); - refresh(); + onRefresh?.(); }; usePortalDropdownClose({ @@ -265,7 +303,7 @@ function DynamicVoicePicker({ ref={dropdownRef} id={listboxId} role="listbox" - aria-label={t("voice_placeholder")} + aria-label={placeholder ?? t("voice_placeholder")} className="pointer-events-auto z-50 min-w-[280px] rounded-md border bg-popover text-popover-foreground shadow-md" style={(() => { if (!triggerRef.current) return {}; @@ -283,20 +321,22 @@ function DynamicVoicePicker({ autoFocus value={search} onChange={(e) => setSearch(e.target.value)} - placeholder={t("voice_placeholder")} + placeholder={placeholder ?? t("voice_placeholder")} className="flex-1 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground" /> - + {onRefresh && ( + + )}
@@ -309,7 +349,7 @@ function DynamicVoicePicker({ ) : filtered.length === 0 ? (

{voices.length === 0 ? t("voice_save_config_first") : search ? t("voice_no_voices") : t("voice_loading")} -

+

) : ( filtered.map((voice) => ( - {isLoading ? t("voice_loading") : selected?.name ?? t("voice_placeholder")} + {isLoading ? t("voice_loading") : selected?.name ?? (placeholder ?? t("voice_placeholder"))} @@ -350,3 +390,39 @@ function DynamicVoicePicker({
); } + +/** + * DynamicVoicePicker — thin wrapper that fetches voices and delegates to PortalVoicePicker. + * Handles free-text fallback for MiniMax on fetch error. + */ +function DynamicVoicePicker({ + value, + onChange, + disabled, + allowFreeText, +}: { + value?: string; + onChange: (id: string) => void; + disabled?: boolean; + allowFreeText: boolean; +}) { + const { data: voices = [], isLoading, isError } = useVoices(); + const { mutate: refresh, isPending: refreshing } = useRefreshVoices(); + + // Fall back to free-text input when MiniMax voices fetch failed and list is empty + if (allowFreeText && isError && voices.length === 0) { + return ; + } + + return ( + refresh()} + allowFreeText={allowFreeText} + /> + ); +}