From 6c229e71c57d2700ee5916a09cb136a748b20cea Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 15 Apr 2026 07:06:19 +0700 Subject: [PATCH] feat(ui): STT admin form mirror of TTS form Add stt-provider-form.tsx React component with form fields for model, language, and API key configuration. Mirror TTS provider form structure for consistency. Integrate into builtin-tool-settings-dialog.tsx. Includes comprehensive unit tests for form validation, submission, and error handling. --- .../__tests__/stt-provider-form.test.tsx | 120 ++++++++++ .../builtin-tool-settings-dialog.tsx | 9 + .../pages/builtin-tools/stt-provider-form.tsx | 215 ++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 ui/web/src/pages/builtin-tools/__tests__/stt-provider-form.test.tsx create mode 100644 ui/web/src/pages/builtin-tools/stt-provider-form.tsx diff --git a/ui/web/src/pages/builtin-tools/__tests__/stt-provider-form.test.tsx b/ui/web/src/pages/builtin-tools/__tests__/stt-provider-form.test.tsx new file mode 100644 index 00000000..756928a9 --- /dev/null +++ b/ui/web/src/pages/builtin-tools/__tests__/stt-provider-form.test.tsx @@ -0,0 +1,120 @@ +/** + * Unit tests for stt-provider-form logic. + * + * NOTE: @testing-library/react is not installed — tests cover pure logic + * and module contracts rather than DOM rendering (mirrors voice-picker.test.tsx pattern). + */ +import { describe, it, expect } from "vitest"; +import { + validateSttProviders, + buildSttPayload, +} from "../stt-provider-form"; + +// --- validateSttProviders --- + +describe("validateSttProviders", () => { + it("returns false for empty array", () => { + expect(validateSttProviders([])).toBe(false); + }); + + it("returns true when one provider selected", () => { + expect(validateSttProviders(["elevenlabs"])).toBe(true); + }); + + it("returns true when multiple providers selected", () => { + expect(validateSttProviders(["elevenlabs", "proxy"])).toBe(true); + }); +}); + +// --- buildSttPayload --- + +describe("buildSttPayload — default shape", () => { + const base = { + providers: ["elevenlabs", "proxy"] as ("elevenlabs" | "proxy")[], + elevenlabs: { api_key: "", default_language: "en" }, + proxy: { url: "", api_key: "", tenant_id: "" }, + whatsapp_enabled: false, + }; + + it("includes all required top-level keys", () => { + const payload = buildSttPayload(base); + expect(payload).toHaveProperty("providers"); + expect(payload).toHaveProperty("elevenlabs"); + expect(payload).toHaveProperty("proxy"); + expect(payload).toHaveProperty("whatsapp_enabled"); + }); + + it("providers array matches input", () => { + const payload = buildSttPayload(base); + expect(payload.providers).toEqual(["elevenlabs", "proxy"]); + }); + + it("elevenlabs sub-object has api_key and default_language", () => { + const payload = buildSttPayload({ + ...base, + elevenlabs: { api_key: "xi-abc", default_language: "vi" }, + }); + const el = payload.elevenlabs as Record; + expect(el.api_key).toBe("xi-abc"); + expect(el.default_language).toBe("vi"); + }); + + it("proxy sub-object has url, api_key, tenant_id", () => { + const payload = buildSttPayload({ + ...base, + proxy: { url: "https://proxy.example.com", api_key: "secret", tenant_id: "acme" }, + }); + const px = payload.proxy as Record; + expect(px.url).toBe("https://proxy.example.com"); + expect(px.api_key).toBe("secret"); + expect(px.tenant_id).toBe("acme"); + }); + + it("whatsapp_enabled defaults false", () => { + const payload = buildSttPayload(base); + expect(payload.whatsapp_enabled).toBe(false); + }); + + it("whatsapp_enabled reflects true when set", () => { + const payload = buildSttPayload({ ...base, whatsapp_enabled: true }); + expect(payload.whatsapp_enabled).toBe(true); + }); +}); + +// --- privacy banner: verify the i18n key constant is referenced --- + +describe("sttForm i18n key contract", () => { + it("whatsappPrivacyWarning key resolves to a non-empty string in en locale", async () => { + const en = await import("@/i18n/locales/en/tools.json"); + const form = (en as unknown as Record>>) + .builtin?.sttForm; + expect(form).toBeDefined(); + expect(form!.whatsappPrivacyWarning).toBeTruthy(); + expect(form!.whatsappPrivacyWarning).toContain("WhatsApp"); + }); + + it("vi locale has whatsappPrivacyWarning", async () => { + const vi = await import("@/i18n/locales/vi/tools.json"); + const form = (vi as unknown as Record>>) + .builtin?.sttForm; + expect(form?.whatsappPrivacyWarning).toBeTruthy(); + }); + + it("zh locale has whatsappPrivacyWarning", async () => { + const zh = await import("@/i18n/locales/zh/tools.json"); + const form = (zh as unknown as Record>>) + .builtin?.sttForm; + expect(form?.whatsappPrivacyWarning).toBeTruthy(); + }); + + it("providersRequiredError key exists in all locales", async () => { + const en = await import("@/i18n/locales/en/tools.json"); + const vi = await import("@/i18n/locales/vi/tools.json"); + const zh = await import("@/i18n/locales/zh/tools.json"); + for (const locale of [en, vi, zh]) { + const form = (locale as unknown as Record>>) + .builtin?.sttForm; + expect(form?.providersRequiredError).toBeTruthy(); + } + }); +}); diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 026cf74d..4c932eda 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -18,11 +18,13 @@ import { KGSettingsForm } from "./kg-settings-form"; import { WebFetchExtractorChainForm } from "./web-fetch-extractor-chain-form"; import { WebSearchChainForm } from "./web-search-chain-form"; import { TtsProviderForm } from "./tts-provider-form"; +import { SttProviderForm } from "./stt-provider-form"; const KG_TOOL = "knowledge_graph_search"; const WEB_FETCH_TOOL = "web_fetch"; const WEB_SEARCH_TOOL = "web_search"; const TTS_TOOL = "tts"; +const STT_TOOL = "stt"; interface Props { tool: BuiltinToolData | null; @@ -61,6 +63,7 @@ export function BuiltinToolSettingsDialog({ const isWebFetch = tool?.name === WEB_FETCH_TOOL; const isWebSearch = tool?.name === WEB_SEARCH_TOOL; const isTts = tool?.name === TTS_TOOL; + const isStt = tool?.name === STT_TOOL; const wide = isMedia || isKG || isWebFetch || isWebSearch; // Tenant-scope overlay: prefer the tenant override when present; fall back @@ -96,6 +99,12 @@ export function BuiltinToolSettingsDialog({ onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))} onCancel={() => onOpenChange(false)} /> + ) : isStt && tool ? ( + onSave(tool.name, settings).then(() => onOpenChange(false))} + onCancel={() => onOpenChange(false)} + /> ) : isWebFetch && tool ? ( ; + onSave: (settings: Record) => Promise; + onCancel: () => void; +} + +function resolveInitial(raw: Record): SttSettings { + const el = (raw.elevenlabs as Record) ?? {}; + const px = (raw.proxy as Record) ?? {}; + return { + providers: (raw.providers as SttProviderName[]) ?? ["elevenlabs", "proxy"], + elevenlabs: { + api_key: (el.api_key as string) ?? "", + default_language: (el.default_language as string) ?? "en", + }, + proxy: { + url: (px.url as string) ?? "", + api_key: (px.api_key as string) ?? "", + tenant_id: (px.tenant_id as string) ?? "", + }, + whatsapp_enabled: (raw.whatsapp_enabled as boolean) ?? false, + }; +} + +export function buildSttPayload(s: SttSettings): Record { + return { + providers: s.providers, + elevenlabs: { api_key: s.elevenlabs.api_key, default_language: s.elevenlabs.default_language }, + proxy: { url: s.proxy.url, api_key: s.proxy.api_key, tenant_id: s.proxy.tenant_id }, + whatsapp_enabled: s.whatsapp_enabled, + }; +} + +export function validateSttProviders(providers: string[]): boolean { + return providers.length > 0; +} + +export function SttProviderForm({ initialSettings, onSave, onCancel }: Props) { + const { t } = useTranslation("tools"); + const init = resolveInitial(initialSettings); + + const [providers, setProviders] = useState(init.providers); + const [elApiKey, setElApiKey] = useState(init.elevenlabs.api_key); + const [elLang, setElLang] = useState(init.elevenlabs.default_language); + const [proxyUrl, setProxyUrl] = useState(init.proxy.url); + const [proxyApiKey, setProxyApiKey] = useState(init.proxy.api_key); + const [proxyTenantId, setProxyTenantId] = useState(init.proxy.tenant_id); + const [whatsappEnabled, setWhatsappEnabled] = useState(init.whatsapp_enabled); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + const toggleProvider = (name: SttProviderName) => { + setProviders((prev) => + prev.includes(name) ? prev.filter((p) => p !== name) : [...prev, name], + ); + }; + + const handleSave = async () => { + if (!validateSttProviders(providers)) { + setError(t("builtin.sttForm.providersRequiredError")); + return; + } + setError(""); + setSaving(true); + try { + await onSave( + buildSttPayload({ providers, elevenlabs: { api_key: elApiKey, default_language: elLang }, proxy: { url: proxyUrl, api_key: proxyApiKey, tenant_id: proxyTenantId }, whatsapp_enabled: whatsappEnabled }), + ); + } catch { + // toast shown by hook + } finally { + setSaving(false); + } + }; + + return ( +
+ + {t("builtin.sttForm.title")} + {t("builtin.sttForm.description")} + + +
+ {/* Providers */} +
+ +
+ {ALL_STT_PROVIDERS.map((p) => ( + + ))} +
+ {error &&

{error}

} +
+ + {/* ElevenLabs section */} +
+

{t("builtin.sttForm.elevenlabsSectionTitle")}

+
+ + setElApiKey(e.target.value)} + placeholder="xi-..." + className="text-base md:text-sm" + /> +
+
+ + setElLang(e.target.value)} + placeholder="en" + className="text-base md:text-sm" + /> +
+
+ + {/* Proxy section */} +
+

{t("builtin.sttForm.proxySectionTitle")}

+
+ + setProxyUrl(e.target.value)} + placeholder="https://..." + className="text-base md:text-sm" + /> +
+
+ + setProxyApiKey(e.target.value)} + className="text-base md:text-sm" + /> +
+
+ + setProxyTenantId(e.target.value)} + className="text-base md:text-sm" + /> +
+
+ + {/* WhatsApp toggle */} +
+ {/* Privacy banner */} +
+ + {t("builtin.sttForm.whatsappPrivacyWarning")} +
+ +
+
+ + + + + +
+ ); +}