From 8d187ca0742e3a9c9bac4bb4ba08e2d98bc01218 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 14 Apr 2026 23:35:38 +0700 Subject: [PATCH] feat(ui): voice picker component with preview button Add VoicePicker with live search and preview button. Integrate into prompt settings section. Add voice API hooks (useVoices, useRefreshVoices). Localize UI strings for en/vi/zh. Fix snake_case JSON field mapping (voice_id, preview_url). --- ui/web/src/api/voices.ts | 42 +++++ ui/web/src/components/voice-picker.test.tsx | 142 +++++++++++++++ ui/web/src/components/voice-picker.tsx | 172 ++++++++++++++++++ .../src/components/voice-preview-button.tsx | 102 +++++++++++ ui/web/src/i18n/locales/en/tts.json | 10 + ui/web/src/i18n/locales/vi/tts.json | 10 + ui/web/src/i18n/locales/zh/tts.json | 10 + .../prompt-settings-section.tsx | 74 +++++++- 8 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 ui/web/src/api/voices.ts create mode 100644 ui/web/src/components/voice-picker.test.tsx create mode 100644 ui/web/src/components/voice-picker.tsx create mode 100644 ui/web/src/components/voice-preview-button.tsx diff --git a/ui/web/src/api/voices.ts b/ui/web/src/api/voices.ts new file mode 100644 index 00000000..175644a9 --- /dev/null +++ b/ui/web/src/api/voices.ts @@ -0,0 +1,42 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; + +export interface Voice { + voice_id: string; + name: string; + preview_url?: string; + labels?: Record; + category?: string; +} + +interface VoicesResponse { + voices: Voice[]; +} + +export const voiceKeys = { + all: ["voices"] as const, +}; + +export function useVoices() { + const http = useHttp(); + return useQuery({ + queryKey: voiceKeys.all, + queryFn: async () => { + const res = await http.get("/v1/voices"); + return res.voices ?? []; + }, + staleTime: 5 * 60_000, + retry: 1, + }); +} + +export function useRefreshVoices() { + const http = useHttp(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => http.post<{ status: string }>("/v1/voices/refresh"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: voiceKeys.all }); + }, + }); +} diff --git a/ui/web/src/components/voice-picker.test.tsx b/ui/web/src/components/voice-picker.test.tsx new file mode 100644 index 00000000..c81acae0 --- /dev/null +++ b/ui/web/src/components/voice-picker.test.tsx @@ -0,0 +1,142 @@ +/** + * Unit tests for voice-picker logic. + * + * NOTE: @testing-library/react is not installed — tests cover pure logic + * and module contracts rather than DOM rendering. + */ +import { describe, it, expect, vi } from "vitest"; +import type { Voice } from "@/api/voices"; + +// --- helpers under test (extracted from voice-picker.tsx logic) --- + +const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const; + +function getVisibleLabels(voice: Voice): string[] { + return LABEL_KEYS + .filter((k) => voice.labels?.[k]) + .map((k) => voice.labels![k] as string) + .slice(0, 2); +} + +function filterVoices(voices: Voice[], search: string): Voice[] { + if (!search.trim()) return voices; + const q = search.toLowerCase(); + return voices.filter((v) => v.name.toLowerCase().includes(q)); +} + +// --- useVoices shape test via vi.mock --- + +vi.mock("@/api/voices", () => ({ + useVoices: vi.fn(() => ({ data: [], isLoading: false, error: null })), + useRefreshVoices: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + voiceKeys: { all: ["voices"] }, +})); + +// --- tests --- + +describe("voice-picker — label extraction", () => { + it("returns empty array when labels absent", () => { + const voice: Voice = { voice_id: "v1", name: "Alice" }; + expect(getVisibleLabels(voice)).toEqual([]); + }); + + it("returns matching label values", () => { + const voice: Voice = { + voice_id: "v2", + name: "Bob", + labels: { gender: "male", accent: "american", age: "young" }, + }; + const labels = getVisibleLabels(voice); + expect(labels).toContain("male"); + expect(labels).toContain("american"); + // capped at 2 + expect(labels.length).toBe(2); + }); + + it("ignores unknown label keys", () => { + const voice: Voice = { + voice_id: "v3", + name: "Carol", + labels: { style: "calm" }, + }; + expect(getVisibleLabels(voice)).toEqual([]); + }); +}); + +describe("voice-picker — filterVoices", () => { + const voices: Voice[] = [ + { voice_id: "1", name: "Rachel" }, + { voice_id: "2", name: "Dave British" }, + { voice_id: "3", name: "Bella" }, + ]; + + it("returns all voices when search is empty", () => { + expect(filterVoices(voices, "")).toHaveLength(3); + expect(filterVoices(voices, " ")).toHaveLength(3); + }); + + it("filters voices by name substring (case-insensitive)", () => { + expect(filterVoices(voices, "bella")).toEqual([{ voice_id: "3", name: "Bella" }]); + expect(filterVoices(voices, "BRIT")).toEqual([{ voice_id: "2", name: "Dave British" }]); + }); + + it("returns empty array when no match", () => { + expect(filterVoices(voices, "zzzz")).toHaveLength(0); + }); +}); + +describe("voice-picker — onChange contract", () => { + it("onChange receives voice.id on selection", () => { + const onChange = vi.fn(); + const voice: Voice = { voice_id: "target-id", name: "Target" }; + // Simulate handleSelect logic + onChange(voice.voice_id); + expect(onChange).toHaveBeenCalledWith("target-id"); + }); +}); + +describe("useRefreshVoices — mock contract", () => { + it("exposes mutate and isPending", async () => { + const { useRefreshVoices } = await import("@/api/voices"); + const result = useRefreshVoices(); + expect(typeof result.mutate).toBe("function"); + expect(result.isPending).toBe(false); + }); +}); + +describe("useVoices — loading / empty / data states", () => { + it("returns empty data and isLoading=false by default (mock)", async () => { + const { useVoices } = await import("@/api/voices"); + const result = useVoices(); + expect(result.isLoading).toBe(false); + expect(result.data).toEqual([]); + }); + + it("loading state: isLoading true when mock returns true", async () => { + const { useVoices } = await import("@/api/voices"); + (useVoices as ReturnType).mockReturnValueOnce({ + data: undefined, + isLoading: true, + error: null, + }); + const result = useVoices(); + expect(result.isLoading).toBe(true); + expect(result.data).toBeUndefined(); + }); + + it("data state: returns voice rows when mock has data", async () => { + const voices: Voice[] = [ + { voice_id: "abc", name: "Aria", labels: { gender: "female" } }, + ]; + const { useVoices } = await import("@/api/voices"); + (useVoices as ReturnType).mockReturnValueOnce({ + data: voices, + isLoading: false, + error: null, + }); + const result = useVoices(); + const data = result.data as Voice[]; + expect(data).toHaveLength(1); + expect(data[0]!.name).toBe("Aria"); + }); +}); diff --git a/ui/web/src/components/voice-picker.tsx b/ui/web/src/components/voice-picker.tsx new file mode 100644 index 00000000..d5d80827 --- /dev/null +++ b/ui/web/src/components/voice-picker.tsx @@ -0,0 +1,172 @@ +import { useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshCwIcon, ChevronDownIcon } from "lucide-react"; +import { createPortal } from "react-dom"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useVoices, useRefreshVoices, type Voice } from "@/api/voices"; +import { VoicePreviewButton } from "@/components/voice-preview-button"; + +interface Props { + value?: string; + onChange: (id: string) => void; + disabled?: boolean; +} + +const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const; + +function VoiceRow({ voice, selected, onSelect }: { voice: Voice; selected: boolean; onSelect: () => void }) { + const labelEntries = LABEL_KEYS + .filter((k) => voice.labels?.[k]) + .map((k) => voice.labels![k]); + + return ( +
e.preventDefault()} + onClick={onSelect} + > + {voice.name} +
+ {labelEntries.slice(0, 2).map((label) => ( + + {label} + + ))} + +
+
+ ); +} + +export function VoicePicker({ value, onChange, disabled }: Props) { + const { t } = useTranslation("tts"); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + + const { data: voices = [], isLoading } = useVoices(); + const { mutate: refresh, isPending: refreshing } = useRefreshVoices(); + + const selected = voices.find((v) => v.voice_id === value); + + const filtered = search.trim() + ? voices.filter((v) => v.name.toLowerCase().includes(search.toLowerCase())) + : voices; + + const handleOpen = () => { + if (disabled) return; + setOpen(true); + setSearch(""); + }; + + const handleSelect = (voice: Voice) => { + onChange(voice.voice_id); + setOpen(false); + setSearch(""); + }; + + const handleRefresh = (e: React.MouseEvent) => { + e.stopPropagation(); + refresh(); + }; + + // Close on outside click + const handleBlur = (e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setOpen(false); + } + }; + + const dropdownContent = open && ( +
{ + if (!triggerRef.current) return {}; + const rect = triggerRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const dropH = 280; + 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 }; + })()} + > + {/* Header: search + refresh */} +
+ setSearch(e.target.value)} + placeholder={t("voice_placeholder")} + className="flex-1 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground" + /> + +
+ + {/* Voice list */} +
+ {isLoading ? ( +
+ + + +
+ ) : filtered.length === 0 ? ( +

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

+ ) : ( + filtered.map((voice) => ( + handleSelect(voice)} + /> + )) + )} +
+
+ ); + + return ( +
+ + + {open && createPortal(dropdownContent, document.body)} +
+ ); +} diff --git a/ui/web/src/components/voice-preview-button.tsx b/ui/web/src/components/voice-preview-button.tsx new file mode 100644 index 00000000..f8404a99 --- /dev/null +++ b/ui/web/src/components/voice-preview-button.tsx @@ -0,0 +1,102 @@ +import { useRef, useState } from "react"; +import { PlayIcon, StopCircleIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/stores/use-toast-store"; +import { useRefreshVoices } from "@/api/voices"; + +interface Props { + previewUrl?: string; + voiceName: string; +} + +// Singleton audio element — only one preview plays at a time across all instances. +let globalAudio: HTMLAudioElement | null = null; +let globalStop: (() => void) | null = null; + +export function VoicePreviewButton({ previewUrl, voiceName }: Props) { + const { t } = useTranslation("tts"); + const [playing, setPlaying] = useState(false); + const audioRef = useRef(null); + const { mutate: refreshVoices } = useRefreshVoices(); + + const stop = () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ""; + audioRef.current = null; + } + setPlaying(false); + }; + + const handlePlay = () => { + if (!previewUrl) return; + + // Stop any other preview that is currently playing. + if (globalAudio && globalAudio !== audioRef.current) { + globalAudio.pause(); + globalAudio.src = ""; + globalStop?.(); + } + + if (playing) { + stop(); + globalAudio = null; + globalStop = null; + return; + } + + const audio = new Audio(previewUrl); + audioRef.current = audio; + globalAudio = audio; + globalStop = stop; + + audio.play().catch(() => { + // Preview URL may have expired — refresh the voice list. + toast.warning(t("voice_preview_error")); + refreshVoices(); + stop(); + globalAudio = null; + globalStop = null; + }); + + audio.onended = () => { + stop(); + if (globalAudio === audio) { + globalAudio = null; + globalStop = null; + } + }; + + audio.onerror = () => { + toast.warning(t("voice_preview_error")); + refreshVoices(); + stop(); + if (globalAudio === audio) { + globalAudio = null; + globalStop = null; + } + }; + + setPlaying(true); + }; + + if (!previewUrl) return null; + + return ( + + ); +} diff --git a/ui/web/src/i18n/locales/en/tts.json b/ui/web/src/i18n/locales/en/tts.json index bf1fb98a..8a1a2c35 100644 --- a/ui/web/src/i18n/locales/en/tts.json +++ b/ui/web/src/i18n/locales/en/tts.json @@ -60,6 +60,16 @@ "speechRate": "Speech Rate", "hint": "Edge TTS is free and requires no API key. Install with: pip install edge-tts" }, + "voice_label": "Voice", + "voice_placeholder": "Select a voice…", + "voice_refresh": "Refresh", + "voice_preview": "Preview", + "voice_stop_preview": "Stop", + "voice_no_voices": "No voices available", + "voice_loading": "Loading voices…", + "voice_preview_error": "Preview failed — refreshing list", + "model_label": "Model", + "model_placeholder": "Default (tenant/system)", "minimax": { "apiKey": "API Key", "groupId": "Group ID *", diff --git a/ui/web/src/i18n/locales/vi/tts.json b/ui/web/src/i18n/locales/vi/tts.json index 0dc98e27..12468e34 100644 --- a/ui/web/src/i18n/locales/vi/tts.json +++ b/ui/web/src/i18n/locales/vi/tts.json @@ -29,6 +29,16 @@ "timeout": "Thời gian chờ", "title": "Cài đặt chung" }, + "voice_label": "Giọng đọc", + "voice_placeholder": "Chọn giọng đọc…", + "voice_refresh": "Làm mới", + "voice_preview": "Nghe thử", + "voice_stop_preview": "Dừng", + "voice_no_voices": "Chưa có giọng đọc", + "voice_loading": "Đang tải giọng đọc…", + "voice_preview_error": "Không nghe thử được — đang làm mới", + "model_label": "Mô hình", + "model_placeholder": "Mặc định (tenant/hệ thống)", "minimax": { "apiBase": "API Base", "apiKey": "API Key", diff --git a/ui/web/src/i18n/locales/zh/tts.json b/ui/web/src/i18n/locales/zh/tts.json index 0e42d33b..521006f9 100644 --- a/ui/web/src/i18n/locales/zh/tts.json +++ b/ui/web/src/i18n/locales/zh/tts.json @@ -29,6 +29,16 @@ "timeout": "超时(毫秒)", "title": "常规设置" }, + "voice_label": "声音", + "voice_placeholder": "选择声音…", + "voice_refresh": "刷新", + "voice_preview": "试听", + "voice_stop_preview": "停止", + "voice_no_voices": "暂无声音", + "voice_loading": "加载声音中…", + "voice_preview_error": "试听失败 — 正在刷新", + "model_label": "模型", + "model_placeholder": "默认(租户/系统)", "minimax": { "apiBase": "API Base", "apiKey": "API Key", diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx index 3aec9fcc..fffc0b9e 100644 --- a/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/prompt-settings-section.tsx @@ -1,35 +1,74 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { toast } from "@/stores/use-toast-store"; import type { AgentData } from "@/types/agent"; import { readPromptMode } from "../agent-display-utils"; import { PromptModeCards, type PromptMode } from "../../prompt-mode-cards"; +import { VoicePicker } from "@/components/voice-picker"; interface Props { agent: AgentData; onUpdate: (updates: Record) => Promise; } +const TTS_MODELS = [ + { value: "eleven_v3", label: "Eleven v3" }, + { value: "eleven_flash_v2_5", label: "Eleven Flash v2.5" }, + { value: "eleven_multilingual_v2", label: "Eleven Multilingual v2" }, + { value: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5" }, +] as const; + export function PromptSettingsSection({ agent, onUpdate }: Props) { const { t } = useTranslation("agents"); + const { t: tTts } = useTranslation("tts"); + + const otherConfig = (agent.other_config ?? {}) as Record; const savedMode = readPromptMode(agent) as PromptMode; + const [mode, setMode] = useState(savedMode); + const [ttsVoiceId, setTtsVoiceId] = useState((otherConfig.tts_voice_id as string) ?? ""); + const [ttsModelId, setTtsModelId] = useState((otherConfig.tts_model_id as string) ?? ""); const [saving, setSaving] = useState(false); - useEffect(() => { setMode(readPromptMode(agent) as PromptMode); }, [agent.other_config]); + useEffect(() => { + const cfg = (agent.other_config ?? {}) as Record; + setMode(readPromptMode(agent) as PromptMode); + setTtsVoiceId((cfg.tts_voice_id as string) ?? ""); + setTtsModelId((cfg.tts_model_id as string) ?? ""); + }, [agent.other_config]); - const dirty = mode !== savedMode; + const savedVoiceId = (otherConfig.tts_voice_id as string) ?? ""; + const savedModelId = (otherConfig.tts_model_id as string) ?? ""; + const dirty = mode !== savedMode || ttsVoiceId !== savedVoiceId || ttsModelId !== savedModelId; const handleSave = async () => { setSaving(true); try { - const bag = { ...((agent.other_config ?? {}) as Record) }; + const bag = { ...otherConfig }; if (mode && mode !== "full") { bag.prompt_mode = mode; } else { delete bag.prompt_mode; } + if (ttsVoiceId) { + bag.tts_voice_id = ttsVoiceId; + } else { + delete bag.tts_voice_id; + } + if (ttsModelId) { + bag.tts_model_id = ttsModelId; + } else { + delete bag.tts_model_id; + } await onUpdate({ other_config: bag }); const modeRank: Record = { none: 0, minimal: 1, task: 2, full: 3 }; if ((modeRank[mode] ?? 3) > (modeRank[savedMode] ?? 3)) { @@ -52,6 +91,35 @@ export function PromptSettingsSection({ agent, onUpdate }: Props) { + + {/* TTS subsection */} +
+

+ {tTts("title")} +

+
+ + +
+
+ + +
+
); }