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.
This commit is contained in:
viettranx committed 2026-04-15 11:24:57 +07:00
1 parent b7002a67d1
commit 6c229e71c5
3 files changed
+344

No files matched your search

@@ -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<string, unknown>;
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<string, unknown>;
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<string, Record<string, Record<string, string>>>)
.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<string, Record<string, Record<string, string>>>)
.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<string, Record<string, Record<string, string>>>)
.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<string, Record<string, Record<string, string>>>)
.builtin?.sttForm;
expect(form?.providersRequiredError).toBeTruthy();
}
});
});
@@ -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 ? (
<SttProviderForm
initialSettings={initialSettings}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
) : isWebFetch && tool ? (
<WebFetchExtractorChainForm
initialSettings={initialSettings}
@@ -0,0 +1,215 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
type SttProviderName = "elevenlabs" | "proxy";
const ALL_STT_PROVIDERS: SttProviderName[] = ["elevenlabs", "proxy"];
interface SttSettings {
providers: SttProviderName[];
elevenlabs: { api_key: string; default_language: string };
proxy: { url: string; api_key: string; tenant_id: string };
whatsapp_enabled: boolean;
}
interface Props {
initialSettings: Record<string, unknown>;
onSave: (settings: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
function resolveInitial(raw: Record<string, unknown>): SttSettings {
const el = (raw.elevenlabs as Record<string, unknown>) ?? {};
const px = (raw.proxy as Record<string, unknown>) ?? {};
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<string, unknown> {
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<SttProviderName[]>(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 (
<div>
<DialogHeader>
<DialogTitle>{t("builtin.sttForm.title")}</DialogTitle>
<DialogDescription>{t("builtin.sttForm.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-5 my-4">
{/* Providers */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.sttForm.providersLabel")}</Label>
<div className="flex flex-wrap gap-2">
{ALL_STT_PROVIDERS.map((p) => (
<label key={p} className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={providers.includes(p)}
onChange={() => toggleProvider(p)}
className="h-4 w-4"
/>
<span className="text-base md:text-sm capitalize">{p}</span>
</label>
))}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
{/* ElevenLabs section */}
<div className="space-y-3">
<p className="text-sm font-semibold">{t("builtin.sttForm.elevenlabsSectionTitle")}</p>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.sttForm.elevenlabsApiKeyLabel")}</Label>
<Input
type="password"
value={elApiKey}
onChange={(e) => setElApiKey(e.target.value)}
placeholder="xi-..."
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.sttForm.elevenlabsDefaultLanguageLabel")}</Label>
<Input
type="text"
value={elLang}
onChange={(e) => setElLang(e.target.value)}
placeholder="en"
className="text-base md:text-sm"
/>
</div>
</div>
{/* Proxy section */}
<div className="space-y-3">
<p className="text-sm font-semibold">{t("builtin.sttForm.proxySectionTitle")}</p>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.sttForm.proxyUrlLabel")}</Label>
<Input
type="url"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
placeholder="https://..."
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.sttForm.proxyApiKeyLabel")}</Label>
<Input
type="password"
value={proxyApiKey}
onChange={(e) => setProxyApiKey(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t("builtin.sttForm.proxyTenantIdLabel")}</Label>
<Input
type="text"
value={proxyTenantId}
onChange={(e) => setProxyTenantId(e.target.value)}
className="text-base md:text-sm"
/>
</div>
</div>
{/* WhatsApp toggle */}
<div className="space-y-2">
{/* Privacy banner */}
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span data-testid="whatsapp-privacy-banner">{t("builtin.sttForm.whatsappPrivacyWarning")}</span>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={whatsappEnabled}
onChange={(e) => setWhatsappEnabled(e.target.checked)}
className="h-4 w-4"
/>
<span className="text-base md:text-sm font-medium">{t("builtin.sttForm.whatsappEnabledLabel")}</span>
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
{t("builtin.sttForm.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? t("builtin.sttForm.saving") : t("builtin.sttForm.save")}
</Button>
</DialogFooter>
</div>
);
}