mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
feat(tts): extract PortalVoicePicker and route Gemini through it
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).
This commit is contained in:
1 parent
ee57ec0fdc
commit
bd95022efe
3 files changed
+490
-94
No files matched your search
@@ -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<string, string>
|
||||
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 (
|
||||
<PortalVoicePicker
|
||||
voices={staticVoices.map(mapCapVoiceToPortal)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (providerCaps && !voicesDynamic && staticVoices.length > 0) {
|
||||
return (
|
||||
<StaticVoicePicker
|
||||
@@ -139,6 +171,173 @@ function FreeTextVoicePicker({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PortalVoicePicker (desktop) — search + scrollable list rendered via portal.
|
||||
*
|
||||
* Used by:
|
||||
* - DynamicVoicePicker (ElevenLabs, MiniMax) — voices from useVoices()
|
||||
* - VoicePicker dispatcher for Gemini — capability static voices
|
||||
*
|
||||
* Owns: open/search state, triggerRef/dropdownRef, usePortalDropdownClose, createPortal.
|
||||
*/
|
||||
export function PortalVoicePicker({
|
||||
voices,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
}: {
|
||||
voices: PortalVoice[]
|
||||
value: string | null
|
||||
onChange: (voice_id: string) => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
onRefresh?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('tts')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(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 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={t('voice_placeholder')}
|
||||
className="pointer-events-auto z-50 min-w-[220px] rounded border border-border bg-surface-primary text-text-primary shadow-lg"
|
||||
style={(() => {
|
||||
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 }
|
||||
})()}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('voice_placeholder')}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('voice_refresh')}
|
||||
disabled={isLoading}
|
||||
onClick={(e) => { e.stopPropagation(); onRefresh() }}
|
||||
className="shrink-0 p-1 rounded hover:bg-surface-tertiary transition-colors text-text-muted hover:text-text-primary disabled:opacity-50"
|
||||
aria-label={t('voice_refresh')}
|
||||
>
|
||||
<svg
|
||||
className={['w-3 h-3', isLoading ? 'animate-spin' : ''].join(' ')}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-52 overflow-y-auto p-1">
|
||||
{isLoading ? (
|
||||
<p className="py-3 text-center text-[11px] text-text-muted">{t('voice_loading')}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-3 text-center text-[11px] text-text-muted">
|
||||
{voices.length === 0 ? t('voice_save_config_first') : search ? t('voice_no_voices') : t('voice_loading')}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((voice) => (
|
||||
<div
|
||||
key={voice.voice_id}
|
||||
role="option"
|
||||
aria-selected={voice.voice_id === value}
|
||||
className={[
|
||||
'flex items-center gap-1 rounded px-2 py-1 cursor-pointer text-xs',
|
||||
voice.voice_id === value ? 'bg-surface-tertiary text-accent' : 'hover:bg-surface-secondary',
|
||||
].join(' ')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(voice)}
|
||||
>
|
||||
<VoiceOption voice={voice} selected={voice.voice_id === value} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
className={[
|
||||
'flex h-8 w-full items-center justify-between gap-1 rounded border border-border bg-surface-secondary px-2 text-xs',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
!selected ? 'text-text-muted' : 'text-text-primary',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{isLoading ? t('voice_loading') : selected?.name ?? t('voice_placeholder')}
|
||||
</span>
|
||||
<svg className="w-3 h-3 shrink-0 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && createPortal(dropdownContent, document.body)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <FreeTextVoicePicker value={value} onChange={onChange} disabled={disabled} />
|
||||
}
|
||||
|
||||
const options = voices.map((v) => ({ value: v.voice_id, label: v.name }))
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
placeholder={isLoading ? t('voice_loading') : t('voice_placeholder')}
|
||||
loading={isLoading}
|
||||
allowCustom={false}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title={t('voice_refresh')}
|
||||
disabled={refreshing || isLoading}
|
||||
onClick={() => refresh()}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-surface-tertiary transition-colors text-text-muted hover:text-text-primary disabled:opacity-50"
|
||||
aria-label={t('voice_refresh')}
|
||||
>
|
||||
<svg
|
||||
className={['w-3.5 h-3.5', refreshing ? 'animate-spin' : ''].join(' ')}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{value && voices.find((v) => v.voice_id === value) && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-text-muted">
|
||||
<VoicePreviewButton
|
||||
previewUrl={voices.find((v) => v.voice_id === value)?.preview_url}
|
||||
voiceName={voices.find((v) => v.voice_id === value)?.name ?? ''}
|
||||
/>
|
||||
<span className="truncate">{voices.find((v) => v.voice_id === value)?.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && voices.length === 0 && (
|
||||
<p className="text-[11px] text-text-muted">{t('voice_no_voices')}</p>
|
||||
)}
|
||||
</div>
|
||||
<PortalVoicePicker
|
||||
voices={voices}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
isLoading={isLoading || refreshing}
|
||||
onRefresh={() => refresh()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
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 (
|
||||
<PortalVoicePicker
|
||||
voices={staticVoices.map(mapCapVoiceToPortal)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (providerCaps && !voicesDynamic && staticVoices.length > 0) {
|
||||
// Static catalog available from capabilities
|
||||
// Static catalog available from capabilities (OpenAI path → Radix Select)
|
||||
return (
|
||||
<StaticVoicePicker
|
||||
value={value}
|
||||
@@ -202,16 +231,33 @@ function FreeTextVoicePicker({
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicVoicePicker({
|
||||
/**
|
||||
* PortalVoicePicker — search + scrollable VoiceRow list rendered via portal.
|
||||
*
|
||||
* Used by:
|
||||
* - DynamicVoicePicker (ElevenLabs, MiniMax) — passes voices from useVoices()
|
||||
* - VoicePicker dispatcher for Gemini — passes capability static voices
|
||||
*
|
||||
* Owns: open/search state, triggerRef/dropdownRef, usePortalDropdownClose, createPortal.
|
||||
*/
|
||||
export function PortalVoicePicker({
|
||||
voices,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
allowFreeText,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
allowFreeText: _allowFreeText,
|
||||
placeholder,
|
||||
}: {
|
||||
voices: PortalVoice[];
|
||||
value?: string;
|
||||
onChange: (id: string) => 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<HTMLDivElement>(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 <FreeTextVoicePicker value={value} onChange={onChange} disabled={disabled} />;
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={t("voice_refresh")}
|
||||
disabled={refreshing}
|
||||
onClick={handleRefresh}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={t("voice_refresh")}
|
||||
disabled={isLoading}
|
||||
onClick={handleRefresh}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
@@ -309,7 +349,7 @@ function DynamicVoicePicker({
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{voices.length === 0 ? t("voice_save_config_first") : search ? t("voice_no_voices") : t("voice_loading")}
|
||||
</p>
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((voice) => (
|
||||
<VoiceRow
|
||||
@@ -341,7 +381,7 @@ function DynamicVoicePicker({
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{isLoading ? t("voice_loading") : selected?.name ?? t("voice_placeholder")}
|
||||
{isLoading ? t("voice_loading") : selected?.name ?? (placeholder ?? t("voice_placeholder"))}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
@@ -350,3 +390,39 @@ function DynamicVoicePicker({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <FreeTextVoicePicker value={value} onChange={onChange} disabled={disabled} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalVoicePicker
|
||||
voices={voices}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
isLoading={isLoading || refreshing}
|
||||
onRefresh={() => refresh()}
|
||||
allowFreeText={allowFreeText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user