mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 00:14:57 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user