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).
This commit is contained in:
viettranx committed 2026-04-15 11:24:57 +07:00
1 parent 28a70fd4f0
commit 8d187ca074
8 files changed
+559 -3

No files matched your search

+42
View File
@@ -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<string, string>;
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<VoicesResponse>("/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 });
},
});
}
+142
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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");
});
});
+172
View File
@@ -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 (
<div
className={cn(
"flex items-center gap-2 rounded-sm px-2 py-1.5 cursor-pointer hover:bg-accent hover:text-accent-foreground",
selected && "bg-accent/60",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={onSelect}
>
<span className="flex-1 truncate text-sm">{voice.name}</span>
<div className="flex shrink-0 items-center gap-1">
{labelEntries.slice(0, 2).map((label) => (
<Badge key={label} variant="outline" className="text-xs px-1 py-0">
{label}
</Badge>
))}
<VoicePreviewButton previewUrl={voice.preview_url} voiceName={voice.name} />
</div>
</div>
);
}
export function VoicePicker({ value, onChange, disabled }: Props) {
const { t } = useTranslation("tts");
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const triggerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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 && (
<div
ref={dropdownRef}
className="pointer-events-auto z-50 min-w-[280px] rounded-md border bg-popover text-popover-foreground shadow-md"
style={(() => {
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 */}
<div className="flex items-center gap-1 border-b 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-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>
</div>
{/* Voice list */}
<div className="max-h-60 overflow-y-auto p-1">
{isLoading ? (
<div className="space-y-1 p-1">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : filtered.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{voices.length === 0 ? t("voice_no_voices") : search ? t("voice_no_voices") : t("voice_loading")}
</p>
) : (
filtered.map((voice) => (
<VoiceRow
key={voice.voice_id}
voice={voice}
selected={voice.voice_id === value}
onSelect={() => handleSelect(voice)}
/>
))
)}
</div>
</div>
);
return (
<div ref={triggerRef} className="relative" onBlur={handleBlur}>
<button
type="button"
disabled={disabled}
onClick={handleOpen}
className={cn(
"border-input dark:bg-input/30 flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1",
"disabled:cursor-not-allowed disabled:opacity-50",
!selected && "text-muted-foreground",
)}
>
<span className="truncate">
{isLoading ? t("voice_loading") : selected?.name ?? t("voice_placeholder")}
</span>
<ChevronDownIcon className="size-4 shrink-0 opacity-50" />
</button>
{open && createPortal(dropdownContent, document.body)}
</div>
);
}
@@ -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<HTMLAudioElement | null>(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 (
<Button
type="button"
variant="ghost"
size="icon-sm"
title={playing ? t("voice_stop_preview") : t("voice_preview", { name: voiceName })}
onClick={handlePlay}
className="shrink-0"
>
{playing ? (
<StopCircleIcon className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
);
}
+10
View File
@@ -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 *",
+10
View File
@@ -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",
+10
View File
@@ -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",
@@ -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<string, unknown>) => Promise<void>;
}
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<string, unknown>;
const savedMode = readPromptMode(agent) as PromptMode;
const [mode, setMode] = useState<PromptMode>(savedMode);
const [ttsVoiceId, setTtsVoiceId] = useState<string>((otherConfig.tts_voice_id as string) ?? "");
const [ttsModelId, setTtsModelId] = useState<string>((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<string, unknown>;
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<string, unknown>) };
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<string, number> = { 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) {
</div>
<PromptModeCards value={mode} onChange={setMode} />
{/* TTS subsection */}
<div className="space-y-3 border-t pt-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{tTts("title")}
</h4>
<div className="space-y-2">
<Label className="text-sm">{tTts("voice_label")}</Label>
<VoicePicker
value={ttsVoiceId || undefined}
onChange={setTtsVoiceId}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">{tTts("model_label")}</Label>
<Select value={ttsModelId} onValueChange={setTtsModelId}>
<SelectTrigger className="w-full text-base md:text-sm">
<SelectValue placeholder={tTts("model_placeholder")} />
</SelectTrigger>
<SelectContent>
{TTS_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>
);
}