Compare commits

...

18 Commits

Author SHA1 Message Date
snyk-bot
eba7e0238d fix: Dockerfile to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-5741793
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-5741895
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-5843454
- https://snyk.io/vuln/SNYK-ALPINE317-OPENSSL-3368755
- https://snyk.io/vuln/SNYK-ALPINE317-OPENSSL-6032385
2025-02-01 07:23:04 +00:00
Hoan Luu Huu
48b3e14076 support recents call dropdown filter to have yesterday option (#463)
* support recents call dropdown filter to have yesterday option

* fix review comment
2025-01-17 09:39:47 -05:00
rammohan-y
db08badb9b Feat/473: TypeaheadSelector component (#474)
* feat/473: introduced typeadhed-selector component and used in ApplicationSelect, AccountSelect components

* making selectedIndex as 0 if it is below 0

* feat/473: fixed styles

* made carrier selector as typeahead selector

* converted account-filter to use typeahead-selector

* styles refactoring

* updated test cases

* added typeahead test case

* added more test cases for typeahead account filter

* feat/473: introduced typeadhed-selector component and used in ApplicationSelect, AccountSelect components

* making selectedIndex as 0 if it is below 0

* feat/473: fixed styles

* made carrier selector as typeahead selector

* converted account-filter to use typeahead-selector

* styles refactoring

* updated test cases

* added typeahead test case

* added more test cases for typeahead account filter
2025-01-16 08:18:40 -05:00
Dave Horton
423c8de513 update deps 2025-01-14 10:47:51 -05:00
Hoan Luu Huu
668a642b09 support custom tts streaming (#476)
* support custom tts streaming

* support custom tts streaming vendor

* fix review comment
2025-01-14 08:36:46 -05:00
Hoan Luu Huu
411eb4ece8 support tts cartesia (#471) 2024-12-19 18:41:53 -05:00
Hoan Luu Huu
8d4ffddddc support carrier dtmf type selection (#465)
* support carrier dtmf type selection

* fix review comments
2024-11-26 20:24:26 -05:00
rammohan-y
294b7b2058 feat/940: Removed last_used column from speech services (#460) 2024-11-22 07:59:13 -05:00
Hoan Luu Huu
e32664d0e0 fixed recents call show call from another account (#467) 2024-11-13 21:25:48 -05:00
Hoan Luu Huu
ae8b4ae124 support add google voice cloning key (#461)
* support add google voice cloning key

* fix bugs on google voice cloning

* wip

* fixed google custom voice
2024-11-04 07:10:59 -05:00
Hoan Luu Huu
a586771ea6 support playht3.0 languages (#459)
* support playht3.0 languages

* wip
2024-10-16 07:21:36 -04:00
Hoan Luu Huu
7aaea04d3c support speechmatics speechcredential (#458)
* support speechmatics

* support speechmatics regions

* add env VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS
2024-10-11 08:58:23 -04:00
Hoan Luu Huu
f1d2ed8abd allow system information contains log level and account has enable_debug_log (#457)
* sip gateways support inbound pad crypto

* allow system information contains log level and account has enable_debug_log

* only admin can set account log level
2024-10-07 09:51:38 -04:00
Hoan Luu Huu
d7d61a769d sorting application name in applications index, phone number application selection (#455)
* sip gateways support inbound pad crypto

* sorting application name in applications index, phone number application selection
2024-10-03 10:23:17 -04:00
Hoan Luu Huu
c9da7946f3 sip gateways support inbound pad crypto (#452) 2024-09-11 09:37:02 +01:00
Hoan Luu Huu
5755cd8886 support admin settings private network CIDR (#447)
* support admin settings private network CIDR

* support admin settings private network CIDR

* fix review comment
2024-08-14 18:30:24 -04:00
Hoan Luu Huu
786327a0b9 support deepgram on-prem (#444) 2024-08-07 07:24:35 -04:00
Hoan Luu Huu
2c390715d8 Feat/rid of env (#443)
* Get rid of VITE_API_BASE_URL

* wip

* wip
2024-07-15 08:58:22 -04:00
29 changed files with 1496 additions and 289 deletions

6
.env
View File

@@ -1,5 +1,5 @@
VITE_API_BASE_URL=http://127.0.0.1:3000/v1
VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
#VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## enables choosing units and lisenced account call limits
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
@@ -25,4 +25,6 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## Base url for jambomz webapp
#VITE_APP_BASE_URL="http://jambonz.one"
## Strip publishable key
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
## ignore some specific speech vendors, defined by ADDITIONAL_SPEECH_VENDORS constant
# VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS=true

View File

@@ -6,7 +6,7 @@ RUN npm install
RUN npm run build
RUN npm prune
FROM node:18.14.1-alpine as webapp
FROM node:18.20.6-alpine as webapp
RUN apk add curl
WORKDIR /opt/app
COPY . /opt/app

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jambonz-webapp",
"version": "0.9.0",
"version": "0.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jambonz-webapp",
"version": "0.9.0",
"version": "0.9.3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "0.9.0",
"version": "0.9.3",
"license": "MIT",
"type": "module",
"engines": {

View File

@@ -1,6 +1,9 @@
import { hasValue } from "src/utils";
import type {
CartesiaOptions,
Currency,
ElevenLabsOptions,
GoogleCustomVoice,
LimitField,
LimitUnitOption,
PasswordSettings,
@@ -12,6 +15,7 @@ import type {
WebHook,
WebhookOption,
} from "./types";
import { Vendor } from "src/vendor/types";
/** This window object is serialized and injected at docker runtime */
/** The API url is constructed with the docker containers `ip:port` */
@@ -28,6 +32,7 @@ interface JambonzWindowObject {
BASE_URL: string;
DEFAULT_SERVICE_PROVIDER_SID: string;
STRIPE_PUBLISHABLE_KEY: string;
DISABLE_ADDITIONAL_SPEECH_VENDORS: string;
}
declare global {
@@ -37,8 +42,11 @@ declare global {
}
/** https://vitejs.dev/guide/env-and-mode.html#env-files */
export const API_BASE_URL =
const CONFIGURED_API_BASE_URL =
window.JAMBONZ?.API_BASE_URL || import.meta.env.VITE_API_BASE_URL;
export const API_BASE_URL = hasValue(CONFIGURED_API_BASE_URL)
? CONFIGURED_API_BASE_URL
: `${window.location.protocol}//${window.location.hostname}/api/v1`;
/** Serves mock API responses from a local dev API server */
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
@@ -72,6 +80,13 @@ export const DISABLE_CALL_RECORDING: boolean =
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
/** Disable additional speech vendors */
export const DISABLE_ADDITIONAL_SPEECH_VENDORS: boolean =
window.JAMBONZ?.DISABLE_ADDITIONAL_SPEECH_VENDORS === "true" ||
JSON.parse(
import.meta.env.VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS || "false",
);
export const DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
@@ -201,6 +216,17 @@ export const AUDIO_FORMAT_OPTIONS = [
},
];
export const LOG_LEVEL_OPTIONS = [
{
name: "Info",
value: "info",
},
{
name: "Debug",
value: "debug",
},
];
export const DEFAULT_ELEVENLABS_MODEL = "eleven_multilingual_v2";
export const DEFAULT_WHISPER_MODEL = "tts-1";
@@ -213,6 +239,8 @@ export const VERBIO_STT_MODELS = [
export const DEFAULT_VERBIO_MODEL = "V1";
export const ADDITIONAL_SPEECH_VENDORS: Lowercase<Vendor>[] = ["speechmatics"];
// Google Custom Voice reported usage options
export const DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = "REALTIME";
@@ -221,6 +249,13 @@ export const GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = [
{ name: "REALTIME", value: "REALTIME" },
{ name: "OFFLINE", value: "OFFLINE" },
];
export const DEFAULT_GOOGLE_CUSTOM_VOICE: GoogleCustomVoice = {
name: "",
reported_usage: DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
use_voice_cloning_key: 0,
voice_cloning_key_file: null,
};
// ElevenLabs options
export const DEFAULT_ELEVENLABS_OPTIONS: Partial<ElevenLabsOptions> = {
optimize_streaming_latency: 3,
@@ -248,6 +283,12 @@ export const DEFAULT_PLAYHT_OPTIONS: Partial<PlayHTOptions> = {
style_guidance: 20,
text_guidance: 1,
};
// Cartesia options
export const DEFAULT_CARTESIA_OPTIONS: Partial<CartesiaOptions> = {
speed: 0.0,
emotion: "positivity:high",
};
/** Password Length options */
export const PASSWORD_MIN = 8;
@@ -261,6 +302,7 @@ export const PASSWORD_LENGTHS_OPTIONS = Array(13)
/** List view filters */
export const DATE_SELECTION = [
{ name: "today", value: "today" },
{ name: "yesterday", value: "yesterday" },
{ name: "last 7d", value: "7" },
{ name: "last 14d", value: "14" },
{ name: "last 30d", value: "30" },
@@ -279,6 +321,11 @@ export const USER_SCOPE_SELECTION: SelectorOptions[] = [
{ name: "Account", value: "account" },
];
export const DTMF_TYPE_SELECTION: SelectorOptions[] = [
{ name: "RFC 2833", value: "rfc2833" },
{ name: "Tones", value: "tones" },
];
/** Available webhook methods */
export const WEBHOOK_METHODS: WebhookOption[] = [
{

View File

@@ -225,6 +225,16 @@ export const getBlob = (url: string) => {
});
};
export const postBlobFetch = <Type>(url: string, formdata?: FormData) => {
return fetchTransport<Type>(url, {
method: "POST",
body: formdata,
headers: {
Authorization: `Bearer ${getToken()}`,
},
});
};
/** Simple wrappers for fetchTransport calls to any API, :GET, :POST, :PUT, :DELETE */
export const getFetch = <Type>(url: string) => {
@@ -492,6 +502,15 @@ export const postGoogleCustomVoice = (payload: Partial<GoogleCustomVoice>) => {
payload,
);
};
export const postGoogleVoiceCloningKey = (sid: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return postBlobFetch<EmptyResponse>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}/VoiceCloningKey`,
formData,
);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
@@ -875,6 +894,7 @@ export const getSpeechSupportedLanguagesAndVoices = (
sid: string | undefined,
vendor: string,
label: string,
create_new: boolean = false,
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -883,7 +903,7 @@ export const getSpeechSupportedLanguagesAndVoices = (
: `${API_SERVICE_PROVIDERS}/${sid}`) +
`/SpeechCredentials/speech/supportedLanguagesAndVoices?vendor=${vendor}${
label ? `&label=${label}` : ""
}`;
}${create_new ? "&create_new=true" : ""}`;
return getFetch<SpeechSupportedLanguagesAndVoices>(apiUrl);
};

View File

@@ -26,6 +26,8 @@ export interface LimitUnitOption {
/** User roles / permissions */
export type UserScopes = "admin" | "service_provider" | "account";
export type LogLevel = "info" | "debug";
export type UserPermissions =
| "VIEW_ONLY"
| "PROVISION_SERVICES"
@@ -122,9 +124,11 @@ export interface ForgotPassword {
}
export interface SystemInformation {
domain_name: string;
sip_domain_name: string;
monitoring_domain_name: string;
domain_name: null | string;
sip_domain_name: null | string;
monitoring_domain_name: null | string;
private_network_cidr: null | string;
log_level: LogLevel;
}
export interface TtsCache {
@@ -261,6 +265,7 @@ export interface Account {
device_to_call_ratio?: number;
trial_end_date?: null | string;
is_active: boolean;
enable_debug_log: boolean;
}
export interface Product {
@@ -376,7 +381,10 @@ export interface GoogleCustomVoice {
speech_credential_sid?: string;
name: string;
reported_usage: string;
model: string;
model?: string;
use_voice_cloning_key: number;
voice_cloning_key?: string | null;
voice_cloning_key_file?: File | null;
}
export interface SpeechCredential {
@@ -415,6 +423,7 @@ export interface SpeechCredential {
auth_token: null | string;
custom_stt_url: null | string;
custom_tts_url: null | string;
custom_tts_streaming_url: null | string;
label: null | string;
cobalt_server_uri: null | string;
model_id: null | string;
@@ -423,7 +432,9 @@ export interface SpeechCredential {
model: null | string;
options: null | string;
deepgram_stt_uri: null | string;
deepgram_tts_uri: null | string;
deepgram_stt_use_tls: number;
speechmatics_stt_uri: null | string;
}
export interface Alert {
@@ -441,6 +452,8 @@ export interface CarrierRegisterStatus {
callId: null | string;
}
export type DtmfType = "rfc2833" | "tones" | "info";
export interface Carrier {
voip_carrier_sid: string;
name: string;
@@ -467,6 +480,7 @@ export interface Carrier {
smpp_inbound_password: null | string;
smpp_enquire_link_interval: number;
register_status: CarrierRegisterStatus;
dtmf_type: DtmfType;
}
export interface PredefinedCarrier extends Carrier {
@@ -741,3 +755,26 @@ export interface RimelabsOptions {
speedAlpha: number;
reduceLatency: boolean;
}
export type CartesiaEmotions =
| "anger:lowest"
| "anger:low"
| "anger:high"
| "anger:highest"
| "positivity:lowest"
| "positivity:low"
| "positivity:high"
| "positivity:highest"
| "surprise:lowest"
| "surprise:high"
| "surprise:highest"
| "sadness:lowest"
| "sadness:low"
| "curiosity:low"
| "curiosity:high"
| "curiosity:highest";
export interface CartesiaOptions {
speed: number;
emotion: CartesiaEmotions;
}

View File

@@ -43,32 +43,63 @@ describe("<AccountFilter>", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Default value is properly set to first option */
cy.get("select").should("have.value", accountsSorted[0].account_sid);
cy.get("input").should("have.value", accountsSorted[0].name);
});
it("updates value onChange", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Assert onChange value updates */
cy.get("select").select(accountsSorted[1].account_sid);
cy.get("select").should("have.value", accountsSorted[1].account_sid);
cy.get("input").clear();
cy.get("input").type(accountsSorted[1].name);
cy.get("input").should("have.value", accountsSorted[1].name);
});
it("manages the focused state", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Test the `focused` state className (applied onFocus) */
cy.get("select").select(accountsSorted[1].account_sid);
cy.get(".account-filter").should("have.class", "focused");
cy.get("select").blur();
cy.get(".account-filter").should("not.have.class", "focused");
cy.get("input").clear();
cy.get("input").type(accountsSorted[1].name);
cy.get("input").parent().should("have.class", "focused");
cy.get("input").blur();
cy.get("input").parent().should("not.have.class", "focused");
});
it("renders with default option", () => {
/** Test with the `defaultOption` prop */
cy.mount(<AccountFilterTestWrapper defaultOption />);
/** No default value is set when this prop is present */
cy.get("select").should("have.value", "");
cy.get("input").should("have.value", "All accounts");
});
it("verify the typeahead dropdown", () => {
/** Test by typing cus then custom account is selected */
cy.mount(<AccountFilterTestWrapper defaultOption />);
cy.get("input").clear();
cy.get("input").type("cus");
cy.get("div#account_filter-option-1").should("have.text", "custom account");
});
it("handles Enter key press", () => {
cy.mount(<AccountFilterTestWrapper />);
cy.get("input").clear();
cy.get("input").type("cus{enter}");
cy.get("input").should("have.value", "custom account");
});
it("navigates down and up with arrow keys", () => {
cy.mount(<AccountFilterTestWrapper />);
cy.get("input").clear();
// Press arrow down to move to the first option
cy.get("input").type("{downarrow}");
cy.get("input").type("{enter}");
cy.get("input").should("have.value", "default account");
// Press up to move to the previous option
cy.get("input").type("{uparrow}");
cy.get("input").type("{uparrow}");
cy.get("input").type("{enter}");
cy.get("input").should("have.value", "custom account");
});
});

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
import { TypeaheadSelector } from "src/components/forms";
import type { Account } from "src/api/types";
import { hasLength, sortLocaleName } from "src/utils";
@@ -22,12 +22,10 @@ export const AccountFilter = ({
accounts,
defaultOption,
}: AccountFilterProps) => {
const [focus, setFocus] = useState(false);
const classes = {
smsel: true,
"smsel--filter": true,
"account-filter": true,
focused: focus,
};
useEffect(() => {
@@ -36,41 +34,30 @@ export const AccountFilter = ({
}
}, [accounts, defaultOption, setAccountSid]);
const options = [
...(defaultOption ? [{ name: "All accounts", value: "" }] : []),
...(hasLength(accounts)
? accounts.sort(sortLocaleName).map((acct) => ({
name: acct.name,
value: acct.account_sid,
}))
: []),
];
return (
<div className={classNames(classes)}>
{label && <label htmlFor="account_filter">{label}:</label>}
<div>
<select
id="account_filter"
name="account_filter"
value={accountSid}
onChange={(e) => {
setAccountSid(e.target.value);
setAccountFilter(e.target.value);
}}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
>
{defaultOption ? (
<option value="">All accounts</option>
) : (
accounts &&
!accounts.length && <option value="">No accounts</option>
)}
{hasLength(accounts) &&
accounts.sort(sortLocaleName).map((acct) => {
return (
<option key={acct.account_sid} value={acct.account_sid}>
{acct.name}
</option>
);
})}
</select>
<span>
<Icons.ChevronUp />
<Icons.ChevronDown />
</span>
</div>
<TypeaheadSelector
id="account_filter"
name="account_filter"
value={accountSid}
options={options}
className="small"
onChange={(e) => {
setAccountSid(e.target.value);
setAccountFilter(e.target.value);
}}
/>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { Selector } from "src/components/forms";
import { TypeaheadSelector } from "src/components/forms";
import type { Account } from "src/api/types";
import { hasLength } from "src/utils";
@@ -16,7 +16,7 @@ type AccountSelectProps = {
disabled?: boolean;
};
type SelectorRef = HTMLSelectElement;
type SelectorRef = HTMLInputElement;
export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
(
@@ -41,7 +41,7 @@ export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
<label htmlFor="account_sid">
{label} {required && <span>*</span>}
</label>
<Selector
<TypeaheadSelector
ref={ref}
id="account_sid"
name="account_sid"

View File

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { Selector } from "src/components/forms";
import { TypeaheadSelector } from "src/components/forms";
import { hasLength } from "src/utils";
import type { Application } from "src/api/types";
@@ -18,7 +18,7 @@ type ApplicationSelectProps = {
disabled?: boolean;
};
type SelectorRef = HTMLSelectElement;
type SelectorRef = HTMLInputElement;
export const ApplicationSelect = forwardRef<
SelectorRef,
@@ -47,7 +47,7 @@ export const ApplicationSelect = forwardRef<
<label htmlFor={id}>
{label} {required && <span>*</span>}
</label>
<Selector
<TypeaheadSelector
ref={ref}
id={id}
name={id}

View File

@@ -6,6 +6,7 @@ import { FileUpload } from "./file-upload";
import { AccountSelect } from "./account-select";
import { ApplicationSelect } from "./application-select";
import { LocalLimits, useLocalLimitsRef } from "./local-limits";
import { TypeaheadSelector } from "./typeahead-selector";
export {
Passwd,
@@ -17,4 +18,5 @@ export {
ApplicationSelect,
LocalLimits,
useLocalLimitsRef,
TypeaheadSelector,
};

View File

@@ -0,0 +1,391 @@
import React, { useState, forwardRef, useEffect } from "react";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
import "./styles.scss";
/**
* Represents an option in the typeahead selector dropdown
* @interface TypeaheadOption
* @property {string} name - The display text shown in the dropdown
* @property {string} value - The underlying value used when the option is selected
*/
export interface TypeaheadOption {
name: string;
value: string;
}
/**
* Props for the TypeaheadSelector component
* @extends {JSX.IntrinsicElements["input"]} - Inherits all standard HTML input props
* @typedef TypeaheadSelectorProps
* @property {TypeaheadOption[]} options - Array of selectable options to display in the dropdown
* @property {string} [className] - Optional CSS class name to apply to the component
*/
type TypeaheadSelectorProps = JSX.IntrinsicElements["input"] & {
options: TypeaheadOption[];
className?: string;
};
type TypeaheadSelectorRef = HTMLInputElement;
/**
* TypeaheadSelector - A searchable dropdown component with keyboard navigation
*
* @component
* @param {Object} props
* @param {string} props.id - Unique identifier for the input
* @param {string} props.name - Form field name
* @param {string} props.value - Currently selected value
* @param {TypeaheadOption[]} props.options - Array of selectable options
* @param {boolean} props.disabled - Whether the input is disabled
* @param {Function} props.onChange - Callback when selection changes
* @param {Ref} ref - Forwarded ref for the input element
*
* Features:
* - Keyboard navigation (up/down arrows, enter to select, escape to close)
* - Auto-scroll selected option into view
* - Filtering options by typing
* - Click or keyboard selection
* - Maintains value synchronization with parent component
* - Accessibility support with ARIA attributes
*/
export const TypeaheadSelector = forwardRef<
TypeaheadSelectorRef,
TypeaheadSelectorProps
>(
(
{
id,
name,
value = "",
options,
disabled,
onChange,
className,
...restProps
}: TypeaheadSelectorProps,
ref,
) => {
const [inputValue, setInputValue] = useState("");
const [filteredOptions, setFilteredOptions] = useState(options);
const [isOpen, setIsOpen] = useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const classes = {
"typeahead-selector": true,
[`typeahead-selector${className}`]: true,
focused: isOpen,
disabled: !!disabled,
};
const [activeIndex, setActiveIndex] = useState(-1);
/**
* Synchronizes the input field with external value changes
* - Updates the input value when the selected value changes externally
* - Sets the input text to the name of the selected option
* - Updates the active index to match the selected option
* - Runs when either the value prop or options array changes
*/
useEffect(() => {
let selectedIndex = options.findIndex((opt) => opt.value === value);
selectedIndex = selectedIndex < 0 ? 0 : selectedIndex;
const selected = options[selectedIndex];
setInputValue(selected?.name ?? "");
setActiveIndex(selectedIndex);
}, [value, options]);
/**
* Handles changes to the input field value
* @param {React.ChangeEvent<HTMLInputElement>} e - Input change event
*
* - Updates the input field with user's typed value
* - Opens the dropdown menu
* - Shows all available options (unfiltered)
* - Finds and highlights the first option that starts with the input text
* - Scrolls the highlighted option into view
*/
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
setInputValue(input);
setIsOpen(true);
setFilteredOptions(options);
const currentIndex = options.findIndex((opt) =>
opt.name.toLowerCase().startsWith(input.toLowerCase()),
);
setActiveIndex(currentIndex);
// Wait for dropdown to render, then scroll to the selected option
setTimeout(() => {
scrollActiveOptionIntoView(currentIndex);
}, 0);
};
/**
* Scrolls the option at the specified index into view within the dropdown
* @param {number} index - The index of the option to scroll into view
*
* - Uses the option's ID to find its DOM element
* - Smoothly scrolls the option into view if found
* - Does nothing if the option element doesn't exist
*/
const scrollActiveOptionIntoView = (index: number) => {
const optionElement = document.getElementById(`${id}-option-${index}`);
if (optionElement) {
optionElement.scrollIntoView({ block: "nearest" });
}
};
/**
* Handles keyboard navigation and selection within the dropdown
* @param {React.KeyboardEvent<HTMLInputElement>} e - Keyboard event
*
* Keyboard controls:
* - ArrowDown/ArrowUp: Opens dropdown if closed, otherwise navigates options
* - Enter: Selects the currently highlighted option
* - Escape: Closes the dropdown
*
* Features:
* - Prevents default arrow key scrolling behavior
* - Auto-scrolls the active option into view
* - Wraps navigation within available options
* - Maintains current selection if at list boundaries
*/
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
setIsOpen(true);
setFilteredOptions(options);
return;
}
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) => {
const newIndex =
prev < filteredOptions.length - 1 ? prev + 1 : prev;
scrollActiveOptionIntoView(newIndex);
return newIndex;
});
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : prev;
scrollActiveOptionIntoView(newIndex);
return newIndex;
});
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
handleOptionSelect(filteredOptions[activeIndex], e);
}
break;
case "Escape":
setIsOpen(false);
break;
}
};
/**
* Handles the selection of an option from the dropdown
* @param {TypeaheadOption} option - The selected option object
* @param {React.MouseEvent | React.KeyboardEvent} e - Optional event object
*
* - Updates the input field with the selected option's name
* - Closes the dropdown
* - Triggers the onChange callback with a synthetic event containing the selected value
*/
const handleOptionSelect = (
option: TypeaheadOption,
e?: React.MouseEvent | React.KeyboardEvent,
) => {
e?.preventDefault();
setInputValue(option.name);
setIsOpen(false);
if (onChange) {
const syntheticEvent = {
target: { value: option.value, name },
} as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
/**
* Handles the input focus event
*
* - Opens the dropdown menu
* - Shows all available options (unfiltered)
* - Finds and highlights the currently selected option based on value or input text
* - Scrolls the highlighted option into view after dropdown renders
*
* Note: Uses setTimeout to ensure the dropdown is rendered before attempting to scroll
*/
const handleFocus = () => {
setIsOpen(true);
setFilteredOptions(options);
// Find and highlight the current value in the dropdown
const currentIndex = options.findIndex(
(opt) => opt.value === value || opt.name === inputValue,
);
setActiveIndex(currentIndex);
// Wait for dropdown to render, then scroll to the selected option
setTimeout(() => {
scrollActiveOptionIntoView(currentIndex);
}, 0);
};
/**
* Handles the input blur (focus loss) event
* @param {React.FocusEvent} e - The blur event object
*
* - Checks if focus is moving outside the component
* - If focus leaves component:
* - Validates current input value against available options
* - Resets input to last valid selection if no match found
* - Closes the dropdown menu
* - Preserves focus state if clicking within component (e.g., dropdown options)
*/
const handleBlur = (e: React.FocusEvent) => {
// Check if the new focus target is within our component
const relatedTarget = e.relatedTarget as Node;
const container = inputRef.current?.parentElement;
if (!container?.contains(relatedTarget)) {
// Reset value if it doesn't match any option
const matchingOption = options.find(
(opt) => opt.name.toLowerCase() === inputValue.toLowerCase(),
);
if (!matchingOption) {
const selected = options.find((opt) => opt.value === value);
setInputValue(selected?.name || "");
}
setIsOpen(false);
}
};
/**
* Renders a typeahead selector component with dropdown functionality.
*
* Key features:
* - Input field with autocomplete functionality
* - Dropdown toggle button with chevron icons
* - Dropdown list of filterable options
* - Keyboard navigation support
* - Accessibility attributes (ARIA)
*
* Component Structure:
* 1. Input field:
* - Handles text input, focus/blur events
* - Supports both function and object refs
* - Disables browser autocomplete features
*
* 2. Toggle button:
* - Opens/closes dropdown
* - Shows up/down chevron icons
* - Resets filtered options on click
* - Auto-scrolls to selected option
*
* 3. Dropdown menu:
* - Displays filtered options
* - Supports mouse and keyboard interaction
* - Highlights active option
* - Implements proper ARIA attributes for accessibility
*
* States managed:
* - isOpen: Controls dropdown visibility
* - activeIndex: Tracks currently focused option
* - inputValue: Current input text
* - filteredOptions: Available options based on input
*/
return (
<div className={classNames(classes)}>
<input
className={classNames({
active: isOpen,
disabled: !!disabled,
})}
ref={(node) => {
// Handle both refs
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
inputRef.current = node;
}}
id={id}
name={name}
value={inputValue}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
disabled={disabled}
{...restProps}
/>
<span
role="button"
tabIndex={0}
onBlur={handleBlur}
className={classNames({
active: isOpen,
disabled: !!disabled,
pointerevents: true,
})}
onClick={() => {
setIsOpen(!isOpen);
setFilteredOptions(options);
const currentIndex = options.findIndex(
(opt) => opt.value === value || opt.name === inputValue,
);
setActiveIndex(currentIndex);
// Wait for dropdown to render, then scroll to the selected option
setTimeout(() => {
scrollActiveOptionIntoView(currentIndex);
}, 0);
}}
onKeyDown={handleKeyDown}
>
<Icons.ChevronUp />
<Icons.ChevronDown />
</span>
{isOpen && (
<div
className="typeahead-dropdown"
role="listbox"
id={`${id}-listbox`}
>
{filteredOptions.map((option, index) => (
<div
key={`${id}_${option.value}`}
className={classNames({
"typeahead-option": true,
active: index === activeIndex,
})}
role="option"
id={`${id}-option-${index}`}
aria-selected={index === activeIndex}
tabIndex={-1}
onMouseDown={() => handleOptionSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.name}
</div>
))}
</div>
)}
</div>
);
},
);
TypeaheadSelector.displayName = "TypeaheadSelector";

View File

@@ -0,0 +1,182 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "@jambonz/ui-kit/src/styles/index";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
// ... imports remain the same ...
// Common mixins for shared styles
@mixin typeahead-base {
position: relative;
max-width: vars.$widthtypeaheadselector;
&.disabled {
@include mixins.disabled();
}
&.focused {
input {
border-color: ui-vars.$dark;
outline: 0;
}
span {
background-color: ui-vars.$dark;
}
}
}
@mixin typeahead-input {
@include ui-mixins.m();
appearance: none;
padding: ui-vars.$px01 ui-vars.$px02;
border-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
background-color: ui-vars.$white;
max-width: vars.$widthtypeaheadinput;
transition: border-color 0.2s ease;
font-family: inherit;
&:focus {
border-color: ui-vars.$dark;
outline: 0;
}
&[disabled] {
@include mixins.disabled();
}
}
@mixin typeahead-span {
height: 100%;
width: 50px;
background-color: ui-vars.$grey;
border-radius: 0 ui-vars.$px01 ui-vars.$px01 0;
position: absolute;
right: 0;
top: 0;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
transition: background-color 0.2s ease;
&.disabled {
@include mixins.disabled();
}
&.active {
background-color: ui-vars.$dark;
}
svg {
stroke: ui-vars.$white;
cursor: default;
&:first-child {
transform: translateY(5px);
}
&:last-child {
transform: translateY(-5px);
}
}
}
@mixin typeahead-dropdown {
@include ui-mixins.m();
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ui-vars.$white;
border: 1px solid ui-vars.$dark;
max-height: 200px;
overflow-y: auto;
}
@mixin typeahead-option {
cursor: pointer;
transition: all 0.2s ease;
font-weight: normal;
display: block;
padding-block-start: 0px;
padding-block-end: 1px;
min-block-size: 1.2em;
padding-inline: 2px;
white-space: nowrap;
padding-left: 16px;
font-family: inherit;
line-height: 30.4px;
&:hover,
&.active {
background-color: #006dff;
color: ui-vars.$white;
}
&.active {
cursor: default;
}
}
// Main classes using the mixins
.typeahead-selector {
@include typeahead-base();
width: 100%;
input {
@include typeahead-input();
width: 100%;
}
span {
@include typeahead-span();
}
.typeahead-dropdown {
@include typeahead-dropdown();
z-index: 1000;
}
.typeahead-option {
@include typeahead-option();
}
}
.typeahead-selectorsmall {
@include typeahead-base();
width: auto;
input {
@include typeahead-input();
height: 34px;
min-width: 370px;
font-size: var(--mxs-size);
}
span {
@include typeahead-span();
}
.typeahead-dropdown {
@include typeahead-dropdown();
width: 100%;
}
.typeahead-option {
@include typeahead-option();
font-size: var(--mxs-size);
}
.pointerevents {
pointer-events: all;
cursor: default;
}
}
.filters--multi {
overflow-x: visible !important;
white-space: nowrap;
grid-gap: 16px;
}

View File

@@ -14,7 +14,14 @@ import {
postAccountBucketCredentialTest,
deleteAccount,
} from "src/api";
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
import {
ClipBoard,
Icons,
Modal,
ScopedAccess,
Section,
Tooltip,
} from "src/components";
import {
Selector,
Checkzone,
@@ -67,6 +74,7 @@ import dayjs from "dayjs";
import { EditBoard } from "src/components/editboard";
import { ModalLoader } from "src/components/modal";
import { useAuth } from "src/router/auth";
import { Scope } from "src/store/types";
type AccountFormProps = {
apps?: Application[];
@@ -137,6 +145,7 @@ export const AccountForm = ({
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const [azureConnectionString, setAzureConnectionString] = useState("");
const [endpoint, setEndpoint] = useState("");
const [enableDebugLog, setEnableDebugLog] = useState(false);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -381,6 +390,7 @@ export const AccountForm = ({
if (account && account.data) {
putAccount(account.data.account_sid, {
name,
enable_debug_log: enableDebugLog,
...(!ENABLE_HOSTED_SYSTEM && { sip_realm: realm || null }),
webhook_secret: account.data.webhook_secret,
siprec_hook_sid: recId || null,
@@ -450,6 +460,7 @@ export const AccountForm = ({
queue_event_hook: queueHook || null,
registration_hook: regHook || null,
service_provider_sid: currentServiceProvider.service_provider_sid,
enable_debug_log: enableDebugLog,
})
.then(({ json }) => {
toastSuccess("Account created successfully");
@@ -465,6 +476,7 @@ export const AccountForm = ({
/** Set current account data values if applicable -- e.g. "edit mode" */
useEffect(() => {
if (account && account.data) {
setEnableDebugLog(account.data.enable_debug_log);
setName(account.data.name);
if (account.data.sip_realm) {
@@ -1021,6 +1033,22 @@ export const AccountForm = ({
} cached TTS prompts`}</MS>
</fieldset>
)}
<ScopedAccess scope={Scope.admin} user={user}>
<fieldset>
<label htmlFor="enable_debug_log" className="chk">
<input
id="enable_debug_log"
name="enable_debug_log"
type="checkbox"
onChange={(e) => setEnableDebugLog(e.target.checked)}
checked={enableDebugLog}
/>
<Tooltip text="You can enable debug log for calls only to this account">
Enable debug log for this account
</Tooltip>
</label>
</fieldset>
</ScopedAccess>
{!DISABLE_CALL_RECORDING && (
<>
<fieldset>

View File

@@ -113,59 +113,62 @@ export const Applications = () => {
{!hasValue(applications) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredApplications) ? (
filteredApplications.map((application) => {
return (
<div className="item" key={application.application_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
className="i"
>
<strong>{application.name}</strong>
<Icons.ArrowRight />
</Link>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${
application.account_sid ? "teal" : "grey"
}`}
filteredApplications
.sort((a, b) => a.name.localeCompare(b.name))
.map((application) => {
return (
<div className="item" key={application.application_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
className="i"
>
<Icons.Activity />
<span>
{
accounts?.find(
(acct) =>
acct.account_sid === application.account_sid,
)?.name
}
</span>
<strong>{application.name}</strong>
<Icons.ArrowRight />
</Link>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${
application.account_sid ? "teal" : "grey"
}`}
>
<Icons.Activity />
<span>
{
accounts?.find(
(acct) =>
acct.account_sid ===
application.account_sid,
)?.name
}
</span>
</div>
</div>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete application"
onClick={() => setApplication(application)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete application"
onClick={() => setApplication(application)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
);
})
);
})
) : accountSid ? (
<M>No applications.</M>
) : (

View File

@@ -28,6 +28,9 @@ import {
VENDOR_SONIOX,
VENDOR_WELLSAID,
VENDOR_WHISPER,
VENDOR_SPEECHMATICS,
VENDOR_PLAYHT,
VENDOR_CARTESIA,
} from "src/vendor";
import {
LabelOptions,
@@ -198,6 +201,19 @@ export const SpeechProviderSelection = ({
if (synthesisGoogleCustomVoiceOptions.length > 0) {
updateTtsVoice(synthesisGoogleCustomVoiceOptions[0].value);
}
}
// PlayHT3.0 all voices are listed under english language, all voices can be used for multiple languages
else if (
synthVendor === VENDOR_PLAYHT &&
synthesisSupportedLanguagesAndVoices.tts.some(
(l) => l.value === "english",
)
) {
setSynthesisVoiceOptions(
synthesisSupportedLanguagesAndVoices.tts.find(
(tts) => tts.value === "english",
)!.voices,
);
} else {
setSynthesisVoiceOptions(voicesOpts);
}
@@ -261,6 +277,20 @@ export const SpeechProviderSelection = ({
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_PLAYHT) {
const newLang = json.tts.find(
(lang) => lang.value === LANG_EN_US || lang.value === "english",
);
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_CARTESIA) {
const newLang = json.tts.find((lang) => lang.value === "en");
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
let newLang = json.tts.find((lang) => lang.value === synthLang);
@@ -360,8 +390,9 @@ export const SpeechProviderSelection = ({
value={synthVendor}
options={ttsVendorOptions.filter(
(vendor) =>
vendor.value != VENDOR_ASSEMBLYAI &&
vendor.value != VENDOR_SONIOX &&
vendor.value !== VENDOR_ASSEMBLYAI &&
vendor.value !== VENDOR_SONIOX &&
vendor.value !== VENDOR_SPEECHMATICS &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_COBALT,
)}
@@ -383,6 +414,7 @@ export const SpeechProviderSelection = ({
value={synthLabel}
options={ttsLabelOptions}
onChange={(e) => {
shouldUpdateTtsVoice.current = true;
setSynthLabel(e.target.value);
}}
/>
@@ -410,7 +442,9 @@ export const SpeechProviderSelection = ({
id="synthesis_lang"
name="synthesis_lang"
value={synthLang}
options={synthesisLanguageOptions}
options={synthesisLanguageOptions.sort((a, b) =>
a.name.localeCompare(b.name),
)}
onChange={(e) => {
shouldUpdateTtsVoice.current = true;
const language = e.target.value;
@@ -465,7 +499,9 @@ export const SpeechProviderSelection = ({
id="synthesis_voice"
name="synthesis_voice"
value={synthVoice}
options={synthesisVoiceOptions}
options={synthesisVoiceOptions.sort((a, b) =>
a.name.localeCompare(b.name),
)}
onChange={(e) => setSynthVoice(e.target.value)}
/>
)}

View File

@@ -19,6 +19,7 @@ import {
import {
DEFAULT_SIP_GATEWAY,
DEFAULT_SMPP_GATEWAY,
DTMF_TYPE_SELECTION,
FQDN,
FQDN_TOP_LEVEL,
INVALID,
@@ -29,7 +30,7 @@ import {
TECH_PREFIX_MINLENGTH,
USER_ACCOUNT,
} from "src/api/constants";
import { Icons, Section } from "src/components";
import { Icons, Section, Tooltip } from "src/components";
import {
Checkzone,
Message,
@@ -52,16 +53,17 @@ import {
isNotBlank,
} from "src/utils";
import type {
Account,
UseApiDataMap,
Carrier,
SipGateway,
SmppGateway,
PredefinedCarrier,
Sbc,
Smpp,
Application,
import {
type Account,
type UseApiDataMap,
type Carrier,
type SipGateway,
type SmppGateway,
type PredefinedCarrier,
type Sbc,
type Smpp,
type Application,
DtmfType,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { RegisterStatus } from "./register-status";
@@ -101,6 +103,7 @@ export const CarrierForm = ({
const [e164, setE164] = useState(false);
const [applicationSid, setApplicationSid] = useState("");
const [accountSid, setAccountSid] = useState("");
const [dtmfType, setDtmfType] = useState<DtmfType>("rfc2833");
const [sipRegister, setSipRegister] = useState(false);
const [sipUser, setSipUser] = useState("");
@@ -216,6 +219,9 @@ export const CarrierForm = ({
if (obj.smpp_inbound_password) {
setSmppInboundPass(obj.smpp_inbound_password);
}
if (obj.dtmf_type) {
setDtmfType(obj.dtmf_type);
}
}
};
@@ -524,6 +530,7 @@ export const CarrierForm = ({
smpp_password: smppPass.trim() || null,
smpp_inbound_system_id: smppInboundSystemId.trim() || null,
smpp_inbound_password: smppInboundPass.trim() || null,
dtmf_type: dtmfType,
};
if (carrier && carrier.data) {
@@ -764,6 +771,23 @@ export const CarrierForm = ({
: false
}
/>
<label htmlFor="dtmf_type">
<Tooltip
text={
"RFC 2833 is commonly used on VoIP networks. Do not change unless you are certain this carrier does not support it"
}
>
DTMF type
</Tooltip>
</label>
<Selector
id="dtmf_type"
name="dtmf_type"
value={dtmfType}
options={DTMF_TYPE_SELECTION}
onChange={(e) => setDtmfType(e.target.value as DtmfType)}
/>
{user &&
disableDefaultTrunkRouting(user?.scope) &&
accountSid &&
@@ -1096,29 +1120,24 @@ export const CarrierForm = ({
<div>Outbound</div>
</label>
</div>
{g.outbound > 0 && g.protocol === "tls/srtp" && (
<div>
<label
htmlFor={`sip_pad_crypto_${i}`}
className="chk"
>
<input
id={`sip_pad_crypto_${i}`}
name={`sip_pad_crypto_${i}`}
type="checkbox"
checked={g.pad_crypto ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"pad_crypto",
e.target.checked,
);
}}
/>
<div>Pad crypto</div>
</label>
</div>
)}
<div>
<label htmlFor={`sip_pad_crypto_${i}`} className="chk">
<input
id={`sip_pad_crypto_${i}`}
name={`sip_pad_crypto_${i}`}
type="checkbox"
checked={g.pad_crypto ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"pad_crypto",
e.target.checked,
);
}}
/>
<div>Pad crypto</div>
</label>
</div>
{Boolean(g.outbound) && (
<div>
<label

View File

@@ -10,9 +10,9 @@ import {
import { Section } from "src/components";
import {
Message,
Selector,
AccountSelect,
ApplicationSelect,
TypeaheadSelector,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -169,7 +169,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
<label htmlFor="sip_trunk">
Carrier <span>*</span>
</label>
<Selector
<TypeaheadSelector
id="sip_trunk"
name="sip_trunk"
required
@@ -200,9 +200,11 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
application={[applicationSid, setApplicationSid]}
applications={
applications
? applications.filter(
(application) => application.account_sid === accountSid,
)
? applications
.filter(
(application) => application.account_sid === accountSid,
)
.sort((a, b) => a.name.localeCompare(b.name))
: []
}
/>

View File

@@ -63,7 +63,12 @@ export const RecentCalls = () => {
count: Number(perPageFilter),
...(dateFilter === "today"
? { start: dayjs().startOf("date").toISOString() }
: { days: Number(dateFilter) }),
: dateFilter === "yesterday"
? {
start: dayjs().subtract(1, "day").startOf("day").toISOString(),
end: dayjs().subtract(1, "day").endOf("day").toISOString(),
}
: { days: Number(dateFilter) }),
...(statusFilter !== "all" && { answered: statusFilter }),
...(directionFilter !== "io" && { direction: directionFilter }),
...(filter && { filter }),
@@ -157,7 +162,10 @@ export const RecentCalls = () => {
{!hasValue(calls) && hasLength(accounts) ? (
<Spinner />
) : hasLength(calls) ? (
calls.map((call) => <DetailsItem key={call.call_sid} call={call} />)
//call.call_sid is null incase of failure, cannot be used as key
calls.map((call) => (
<DetailsItem key={call.sip_callid} call={call} />
))
) : (
<M>No data.</M>
)}

View File

@@ -7,11 +7,20 @@ import {
postSystemInformation,
deleteTtsCache,
} from "src/api";
import { PasswordSettings, SystemInformation, TtsCache } from "src/api/types";
import {
LogLevel,
PasswordSettings,
SystemInformation,
TtsCache,
} from "src/api/types";
import { toastError, toastSuccess } from "src/store";
import { Selector } from "src/components/forms";
import { hasValue } from "src/utils";
import { PASSWORD_LENGTHS_OPTIONS, PASSWORD_MIN } from "src/api/constants";
import { hasValue, isvalidIpv4OrCidr } from "src/utils";
import {
LOG_LEVEL_OPTIONS,
PASSWORD_LENGTHS_OPTIONS,
PASSWORD_MIN,
} from "src/api/constants";
import { Modal } from "src/components";
export const AdminSettings = () => {
@@ -25,9 +34,11 @@ export const AdminSettings = () => {
const [requireDigit, setRequireDigit] = useState(false);
const [requireSpecialCharacter, setRequireSpecialCharacter] = useState(false);
const [domainName, setDomainName] = useState("");
const [privateNetworkCidr, setPrivateNetworkCidr] = useState("");
const [sipDomainName, setSipDomainName] = useState("");
const [monitoringDomainName, setMonitoringDomainName] = useState("");
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
const [logLevel, setLogLevel] = useState<LogLevel>("info");
const handleClearCache = () => {
deleteTtsCache()
@@ -44,10 +55,22 @@ export const AdminSettings = () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (privateNetworkCidr) {
const cidrs = privateNetworkCidr.split(",");
for (const cidr of cidrs) {
if (cidr && !isvalidIpv4OrCidr(cidr)) {
toastError(`Invalid private network CIDR "${cidr}"`);
return;
}
}
}
const systemInformationPayload: Partial<SystemInformation> = {
domain_name: domainName,
sip_domain_name: sipDomainName,
monitoring_domain_name: monitoringDomainName,
domain_name: domainName || null,
sip_domain_name: sipDomainName || null,
monitoring_domain_name: monitoringDomainName || null,
private_network_cidr: privateNetworkCidr || null,
log_level: logLevel,
};
const passwordSettingsPayload: Partial<PasswordSettings> = {
min_password_length: minPasswordLength,
@@ -61,7 +84,7 @@ export const AdminSettings = () => {
.then(() => {
passwordSettingsFetcher();
systemInformationFetcher();
toastSuccess("Password settings successfully updated");
toastSuccess("Admin settings updated successfully");
})
.catch((error) => {
toastError(error.msg);
@@ -78,11 +101,21 @@ export const AdminSettings = () => {
setMinPasswordLength(passwordSettings.min_password_length);
}
}
if (hasValue(systemInformation)) {
if (systemInformation?.domain_name) {
setDomainName(systemInformation.domain_name);
}
if (systemInformation?.sip_domain_name) {
setSipDomainName(systemInformation.sip_domain_name);
}
if (systemInformation?.monitoring_domain_name) {
setMonitoringDomainName(systemInformation.monitoring_domain_name);
}
if (systemInformation?.private_network_cidr) {
setPrivateNetworkCidr(systemInformation.private_network_cidr);
}
if (systemInformation?.log_level) {
setLogLevel(systemInformation.log_level);
}
}, [passwordSettings, systemInformation]);
return (
@@ -107,6 +140,15 @@ export const AdminSettings = () => {
value={sipDomainName}
onChange={(e) => setSipDomainName(e.target.value)}
/>
<label htmlFor="name">Private Network CIDR</label>
<input
id="private_network_cidr"
type="text"
name="private_network_cidr"
placeholder="Private network CIDR"
value={privateNetworkCidr}
onChange={(e) => setPrivateNetworkCidr(e.target.value)}
/>
<label htmlFor="name">Monitoring Domain Name</label>
<input
id="monitor_domain_name"
@@ -116,6 +158,17 @@ export const AdminSettings = () => {
value={monitoringDomainName}
onChange={(e) => setMonitoringDomainName(e.target.value)}
/>
<label htmlFor="audio_format">Log Level</label>
<Selector
id={"audio_format"}
name={"audio_format"}
value={logLevel}
options={LOG_LEVEL_OPTIONS}
onChange={(e) => {
setLogLevel(e.target.value as LogLevel);
}}
/>
</fieldset>
<fieldset>
<label htmlFor="min_password_length">Min password length</label>

View File

@@ -18,6 +18,7 @@ import {
getGoogleCustomVoices,
getSpeechSupportedLanguagesAndVoices,
postGoogleCustomVoice,
postGoogleVoiceCloningKey,
postSpeechService,
putGoogleCustomVoice,
putSpeechService,
@@ -47,6 +48,8 @@ import {
AWS_CREDENTIAL_ACCESS_KEY,
AWS_INSTANCE_PROFILE,
VENDOR_VERBIO,
VENDOR_SPEECHMATICS,
VENDOR_CARTESIA,
} from "src/vendor";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -74,11 +77,14 @@ import type {
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import {
ADDITIONAL_SPEECH_VENDORS,
DEFAULT_CARTESIA_OPTIONS,
DEFAULT_ELEVENLABS_OPTIONS,
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
DEFAULT_GOOGLE_CUSTOM_VOICE,
DEFAULT_PLAYHT_OPTIONS,
DEFAULT_RIMELABS_OPTIONS,
DEFAULT_VERBIO_MODEL,
DISABLE_ADDITIONAL_SPEECH_VENDORS,
DISABLE_CUSTOM_SPEECH,
GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
VERBIO_STT_MODELS,
@@ -139,6 +145,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [tmpCustomVendorTtsUrl, setTmpCustomVendorTtsUrl] = useState("");
const [customVendorTtsUrl, setCustomVendorTtsUrl] = useState("");
const [tmpCustomVendorSttUrl, setTmpCustomVendorSttUrl] = useState("");
const [customVendorTtsStreamingUrl, setCustomVendorTtsStreamingUrl] =
useState("");
const [tmpCustomVendorTtsStreamingUrl, setTmpCustomVendorTtsStreamingUrl] =
useState("");
const [customVendorSttUrl, setCustomVendorSttUrl] = useState("");
const [initialOnPremNuanceTtsCheck, setInitialOnPremNuanceTtsCheck] =
useState(false);
@@ -160,12 +170,20 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [options, setOptions] = useState("");
const [tmpOptions, setTmpOptions] = useState("");
const [deepgramSttUri, setDeepgramSttUri] = useState("");
const [deepgramTtsUri, setDeepgramTtsUri] = useState("");
const [tmpDeepgramSttUri, setTmpDeepgramSttUri] = useState("");
const [tmpDeepgramTtsUri, setTmpDeepgramTtsUri] = useState("");
const [deepgramSttUseTls, setDeepgramSttUseTls] = useState(false);
const [tmpDeepgramSttUseTls, setTmpDeepgramSttUseTls] = useState(false);
const [initialDeepgramOnpremCheck, setInitialDeepgramOnpremCheck] =
useState(false);
const [isDeepgramOnpremEnabled, setIsDeepgramOnpremEnabled] = useState(false);
const [initialSpeechmaticsOnpremCheck, setInitialSpeechMaticsOnpremCheck] =
useState(false);
const [speechmaticsEndpoint, setSpeechmaticsEndpoint] = useState("");
const [tmpHostedSpeechmaticsEndpoint, setTmpHostedSpeechmaticsEndpoint] =
useState("");
const [tmpOnpremSpeechmaticsEndpoint, setTmpOnpremSpeechmaticsEndpoint] =
useState("");
const [awsCredentialType, setAwsCredentialType] = useState(
AWS_CREDENTIAL_ACCESS_KEY,
);
@@ -206,6 +224,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
return DEFAULT_PLAYHT_OPTIONS;
case VENDOR_RIMELABS:
return DEFAULT_RIMELABS_OPTIONS;
case VENDOR_CARTESIA:
return DEFAULT_CARTESIA_OPTIONS;
}
}
return "";
@@ -220,6 +240,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
return "https://docs.play.ht/reference/api-generate-tts-audio-stream";
case VENDOR_RIMELABS:
return "https://rimelabs.mintlify.app/api-reference/endpoint/streaming-mp3#variable-parameters";
case VENDOR_CARTESIA:
return "https://docs.cartesia.ai/api-reference/tts/bytes";
}
}
return "";
@@ -232,14 +254,43 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (useCustomVoicesCheck) {
Promise.all(
customVoices.map((v) => {
// voice cloning key is 200kb file, the content should be uploaded in separated api
const voice_cloning_key = v.voice_cloning_key_file;
delete v.voice_cloning_key_file;
delete v.voice_cloning_key;
const uploadVoiceCloningKey = (sid: string) => {
if (voice_cloning_key) {
return postGoogleVoiceCloningKey(sid, voice_cloning_key);
}
};
if (v.google_custom_voice_sid) {
const sid = v.google_custom_voice_sid;
delete v.google_custom_voice_sid;
return putGoogleCustomVoice(sid, v);
return new Promise((res, rej) => {
putGoogleCustomVoice(sid, v)
.then((resp) => {
if (!voice_cloning_key) {
return res(resp);
}
uploadVoiceCloningKey(sid)?.then(res).catch(rej);
})
.catch(rej);
});
} else {
return postGoogleCustomVoice({
...v,
speech_credential_sid: credential.data?.speech_credential_sid,
return new Promise((res, rej) => {
postGoogleCustomVoice({
...v,
speech_credential_sid: credential.data?.speech_credential_sid,
})
.then(({ json }) => {
if (!voice_cloning_key) {
return res(json);
}
uploadVoiceCloningKey(json.sid)?.then(res).catch(rej);
})
.catch(rej);
});
}
}),
@@ -254,7 +305,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
.catch((error) => {
toastError(error.msg);
});
} else if (useCustomVoicesCheck && customVoices.length > 0) {
} else if (!useCustomVoicesCheck && customVoices.length > 0) {
Promise.all(
customVoices.map((v) => {
if (v.google_custom_voice_sid) {
@@ -323,11 +374,16 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
...(vendor === VENDOR_NVIDIA && {
riva_server_uri: rivaServerUri || null,
}),
...(vendor === VENDOR_CARTESIA && {
model_id: ttsModelId || null,
options: options || null,
}),
...(vendor === VENDOR_CUSTOM && {
vendor: (vendor + ":" + customVendorName) as Lowercase<Vendor>,
use_for_tts: ttsCheck ? 1 : 0,
use_for_stt: sttCheck ? 1 : 0,
custom_tts_url: customVendorTtsUrl || null,
custom_tts_streaming_url: customVendorTtsStreamingUrl || null,
custom_stt_url: customVendorSttUrl || null,
auth_token: customVendorAuthToken || null,
}),
@@ -356,8 +412,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}),
...(vendor === VENDOR_DEEPGRAM && {
deepgram_stt_uri: deepgramSttUri || null,
deepgram_tts_uri: deepgramTtsUri || null,
deepgram_stt_use_tls: deepgramSttUseTls ? 1 : 0,
}),
...(vendor === VENDOR_SPEECHMATICS && {
speechmatics_stt_uri: speechmaticsEndpoint || null,
}),
...(vendor === VENDOR_VERBIO && {
engine_version: engineVersion,
}),
@@ -402,10 +462,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_DEEPGRAM ||
vendor === VENDOR_ASSEMBLYAI ||
vendor === VENDOR_SONIOX ||
vendor === VENDOR_SPEECHMATICS ||
vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_WHISPER
vendor === VENDOR_WHISPER ||
vendor === VENDOR_CARTESIA
? apiKey
: null,
}),
@@ -422,12 +484,32 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
.then(({ json }) => {
if (vendor === VENDOR_GOOGLE && useCustomVoicesCheck) {
Promise.all(
customVoices.map((v) =>
postGoogleCustomVoice({
...v,
speech_credential_sid: json.sid,
}),
),
customVoices.map((v) => {
// voice cloning key is 200kb file, the content should be uploaded in separated api
const voice_cloning_key = v.voice_cloning_key_file;
delete v.voice_cloning_key_file;
delete v.voice_cloning_key;
const uploadVoiceCloningKey = (sid: string) => {
if (voice_cloning_key) {
return postGoogleVoiceCloningKey(sid, voice_cloning_key);
}
};
return new Promise((res, rej) => {
postGoogleCustomVoice({
...v,
speech_credential_sid: json.sid,
})
.then(({ json }) => {
if (!voice_cloning_key) {
res(json);
}
uploadVoiceCloningKey(json.sid)?.then(res).catch(rej);
})
.catch(rej);
});
}),
).then(() => {
toastSuccess("Speech credential created successfully");
navigate(ROUTE_INTERNAL_SPEECH);
@@ -451,12 +533,14 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_CARTESIA
) {
getSpeechSupportedLanguagesAndVoices(
currentServiceProvider?.service_provider_sid,
vendor,
"",
credential ? false : true,
).then(({ json }) => {
if (json.models) {
setTtsModels(json.models);
@@ -606,6 +690,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setTmpCustomVendorSttUrl(credential.data.custom_stt_url || "");
setCustomVendorTtsUrl(credential.data.custom_tts_url || "");
setTmpCustomVendorTtsUrl(credential.data.custom_tts_url || "");
setCustomVendorTtsStreamingUrl(
credential.data.custom_tts_streaming_url || "",
);
setTmpCustomVendorTtsStreamingUrl(
credential.data.custom_tts_streaming_url || "",
);
if (credential.data.label) {
setLabel(credential.data.label);
}
@@ -632,13 +722,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential?.data?.deepgram_stt_uri) {
setDeepgramSttUri(credential.data.deepgram_stt_uri);
}
if (credential?.data?.deepgram_tts_uri) {
setDeepgramTtsUri(credential.data.deepgram_tts_uri);
}
if (credential?.data?.deepgram_stt_use_tls) {
setDeepgramSttUseTls(
credential?.data?.deepgram_stt_use_tls > 0 ? true : false,
);
}
setInitialDeepgramOnpremCheck(hasValue(credential?.data?.deepgram_stt_uri));
setIsDeepgramOnpremEnabled(hasValue(credential?.data?.deepgram_stt_uri));
if (credential?.data?.user_id) {
setUserId(credential.data.user_id);
@@ -668,6 +760,13 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential?.data?.engine_version) {
setEngineVersion(credential.data.engine_version);
}
if (credential?.data?.speechmatics_stt_uri) {
setInitialSpeechMaticsOnpremCheck(
!credential.data.speechmatics_stt_uri?.includes("speechmatics.com"),
);
setSpeechmaticsEndpoint(credential.data.speechmatics_stt_uri);
}
}, [credential]);
const updateCustomVoices = (
@@ -720,7 +819,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
]
.concat(vendors)
.filter(
(v) => !DISABLE_CUSTOM_SPEECH || v.value !== VENDOR_CUSTOM,
(v) =>
(!DISABLE_CUSTOM_SPEECH || v.value !== VENDOR_CUSTOM) &&
(!DISABLE_ADDITIONAL_SPEECH_VENDORS ||
!ADDITIONAL_SPEECH_VENDORS.includes(
v.value as Lowercase<Vendor>,
)),
)}
onChange={(e) => {
setVendor(e.target.value as Lowercase<Vendor>);
@@ -779,6 +883,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
{vendor !== VENDOR_ASSEMBLYAI &&
vendor !== VENDOR_COBALT &&
vendor !== VENDOR_SONIOX &&
vendor !== VENDOR_SPEECHMATICS &&
vendor != VENDOR_CUSTOM && (
<label htmlFor="use_for_tts" className="chk">
<input
@@ -796,6 +901,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor !== VENDOR_WHISPER &&
vendor !== VENDOR_PLAYHT &&
vendor !== VENDOR_RIMELABS &&
vendor !== VENDOR_CARTESIA &&
vendor !== VENDOR_ELEVENLABS && (
<label htmlFor="use_for_stt" className="chk">
<input
@@ -819,26 +925,47 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setTtsCheck(e.target.checked);
if (!e.target.checked) {
setTmpCustomVendorTtsUrl(customVendorTtsUrl);
setTmpCustomVendorTtsStreamingUrl(
customVendorTtsStreamingUrl,
);
setCustomVendorTtsUrl("");
setCustomVendorTtsStreamingUrl("");
} else {
setCustomVendorTtsUrl(tmpCustomVendorTtsUrl);
setCustomVendorTtsStreamingUrl(
tmpCustomVendorTtsStreamingUrl,
);
}
}}
>
<label htmlFor="custom_vendor_use_for_tts">
TTS HTTP URL<span>*</span>
Http URL (non-streaming)
</label>
<input
id="custom_vendor_use_for_tts"
type="text"
name="custom_vendor_use_for_tts"
placeholder="Required"
required={ttsCheck}
required={ttsCheck && !customVendorTtsStreamingUrl}
value={customVendorTtsUrl}
onChange={(e) => {
setCustomVendorTtsUrl(e.target.value);
}}
/>
<label htmlFor="custom_vendor_use_for_tts_streaming_ws">
Ws URL (streaming)
</label>
<input
id="custom_vendor_use_for_tts_streaming_ws"
type="text"
name="custom_vendor_use_for_tts_streaming_ws"
placeholder="Required"
required={ttsCheck && !customVendorTtsUrl}
value={customVendorTtsStreamingUrl}
onChange={(e) => {
setCustomVendorTtsStreamingUrl(e.target.value);
}}
/>
</Checkzone>
<Checkzone
@@ -946,15 +1073,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
name="use_custom_voice"
type="checkbox"
onChange={(e) => {
if (customVoices.length === 0) {
setCustomVoices([
{
name: "",
reported_usage:
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
},
]);
if (e.target.checked && customVoices.length === 0) {
setCustomVoices([DEFAULT_GOOGLE_CUSTOM_VOICE]);
}
setUseCustomVoicesCheck(e.target.checked);
}}
@@ -977,7 +1097,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
<div>
<div>
<label htmlFor="custom_voice_name">
Name / Reported Usage
Name
{!v.use_voice_cloning_key
? " / Reported Usage"
: ""}
</label>
</div>
</div>
@@ -997,49 +1120,112 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</div>
<div>
<Selector
id={"google_custom_voices_reported_usage"}
name={"google_custom_voices_reported_usage"}
value={v.reported_usage}
options={GOOGLE_CUSTOM_VOICES_REPORTED_USAGE}
onChange={(e) => {
updateCustomVoices(
i,
"reported_usage",
e.target.value,
);
}}
/>
</div>
{!v.use_voice_cloning_key && (
<div>
<Selector
id={"google_custom_voices_reported_usage"}
name={"google_custom_voices_reported_usage"}
value={v.reported_usage}
options={GOOGLE_CUSTOM_VOICES_REPORTED_USAGE}
onChange={(e) => {
updateCustomVoices(
i,
"reported_usage",
e.target.value,
);
}}
/>
</div>
)}
</div>
<div>
<div>
<label htmlFor="custom_voice_name">Model</label>
</div>
</div>
<label
htmlFor={`use_voice_cloning_key_${i}`}
className="chk"
>
<input
id={`use_voice_cloning_key_${i}`}
name={`use_voice_cloning_key_${i}`}
type="checkbox"
onChange={(e) => {
updateCustomVoices(
i,
"use_voice_cloning_key",
e.target.checked ? 1 : 0,
);
}}
checked={v.use_voice_cloning_key ? true : false}
/>
<div>Use voice cloning key</div>
</label>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Model"
required
value={v.model}
style={{ maxWidth: "100%" }}
onChange={(e) => {
updateCustomVoices(
i,
"model",
e.target.value,
);
}}
/>
</div>
</div>
{!v.use_voice_cloning_key && (
<>
<div>
<div>
<label htmlFor="custom_voice_name">
Model
</label>
</div>
</div>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Model"
required
value={v.model}
style={{ maxWidth: "100%" }}
onChange={(e) => {
updateCustomVoices(
i,
"model",
e.target.value,
);
}}
/>
</div>
</div>
</>
)}
{v.use_voice_cloning_key === 1 && (
<>
<div>
<div>
{hasValue(v.voice_cloning_key) && (
<pre>
<code>{v.voice_cloning_key}</code>
</pre>
)}
</div>
<div>
<FileUpload
id={`google_voice_cloning_key_${i}`}
name={`google_voice_cloning_key_${i}`}
handleFile={(file) => {
updateCustomVoices(
i,
"voice_cloning_key_file",
file,
);
file.text().then((text) => {
updateCustomVoices(
i,
"voice_cloning_key",
text.substring(0, 100) + "...",
);
});
}}
required={!v.voice_cloning_key}
/>
</div>
</div>
</>
)}
<button
className="btnty"
@@ -1080,12 +1266,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setCustomVoicesMessage("");
setCustomVoices((prev) => [
...prev,
{
name: "",
reported_usage:
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
},
DEFAULT_GOOGLE_CUSTOM_VOICE,
]);
}}
>
@@ -1335,7 +1516,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_WHISPER ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_SONIOX) && (
vendor === VENDOR_SONIOX ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_SPEECHMATICS) && (
<fieldset>
{vendor === VENDOR_PLAYHT && (
<>
@@ -1384,19 +1567,17 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</fieldset>
)}
{vendor === VENDOR_DEEPGRAM && (
{vendor === VENDOR_CARTESIA && ttsModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_apikey`}>
API key{!isDeepgramOnpremEnabled && <span>*</span>}
</label>
<Passwd
id={`${vendor}_apikey`}
required={!isDeepgramOnpremEnabled}
name={`${vendor}_apikey`}
placeholder="API key"
value={apiKey ? getObscuredSecret(apiKey) : apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
<label htmlFor={`${vendor}_tts_model_id`}>Model Id</label>
<Selector
id={"tts_model_id"}
name={"tts_model_id"}
value={ttsModelId}
options={ttsModels}
onChange={(e) => {
setTtsModelId(e.target.value);
}}
/>
</fieldset>
)}
@@ -1419,6 +1600,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
)}
{(vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_RIMELABS) && (
<fieldset>
<Checkzone
@@ -1502,6 +1684,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
{regions &&
regions[vendor as keyof RegionVendors] &&
vendor !== VENDOR_IBM &&
vendor !== VENDOR_SPEECHMATICS &&
vendor !== VENDOR_MICROSOFT && (
<fieldset>
<label htmlFor="region">
@@ -1602,11 +1785,52 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
disabled={hasValue(credential)}
hidden
name="use_hosted_deepgram_service"
label="Use hosted Deepgram Service"
initialCheck={!initialDeepgramOnpremCheck}
handleChecked={(e) => {
setInitialDeepgramOnpremCheck(!e.target.checked);
if (e.target.checked) {
setTmpDeepgramSttUri(deepgramSttUri);
setDeepgramSttUri("");
setTmpDeepgramSttUseTls(deepgramSttUseTls);
setDeepgramSttUseTls(false);
setTmpDeepgramTtsUri(deepgramTtsUri);
setDeepgramTtsUri("");
} else {
if (tmpDeepgramSttUri) {
setDeepgramSttUri(tmpDeepgramSttUri);
}
if (tmpDeepgramSttUseTls) {
setDeepgramSttUseTls(tmpDeepgramSttUseTls);
}
if (tmpDeepgramTtsUri) {
setDeepgramTtsUri(tmpDeepgramTtsUri);
}
}
}}
>
<label htmlFor={`${vendor}_apikey`}>
API key<span>*</span>
</label>
<Passwd
id={`${vendor}_apikey`}
required
name={`${vendor}_apikey`}
placeholder="API key"
value={credential ? getObscuredSecret(apiKey) : apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
/>
</Checkzone>
<Checkzone
disabled={hasValue(credential)}
hidden
name="use_on-prem_deepgram_container"
label="Use on-prem Deepgram container"
initialCheck={initialDeepgramOnpremCheck}
handleChecked={(e) => {
// setInitialDeepgramOnpremCheck(!e.target.checked);
setIsDeepgramOnpremEnabled(e.target.checked);
setInitialDeepgramOnpremCheck(e.target.checked);
if (e.target.checked) {
if (tmpDeepgramSttUri) {
setDeepgramSttUri(tmpDeepgramSttUri);
@@ -1614,36 +1838,63 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (tmpDeepgramSttUseTls) {
setDeepgramSttUseTls(tmpDeepgramSttUseTls);
}
if (tmpDeepgramTtsUri) {
setDeepgramTtsUri(tmpDeepgramTtsUri);
}
} else {
setTmpDeepgramSttUri(deepgramSttUri);
setDeepgramSttUri("");
setTmpDeepgramSttUseTls(deepgramSttUseTls);
setDeepgramSttUseTls(false);
setTmpDeepgramTtsUri(deepgramTtsUri);
setDeepgramTtsUri("");
}
}}
>
<label htmlFor="deepgram_uri_for_stt">
STT Container URI<span>*</span>
</label>
<input
id="deepgram_uri_for_stt"
required
type="text"
name="deepgram_uri_for_stt"
placeholder="Container URI for STT"
value={deepgramSttUri}
onChange={(e) => setDeepgramSttUri(e.target.value)}
/>
<label htmlFor="deepgram_stt_use_tls" className="chk">
<input
id="deepgram_stt_use_tls"
name="deepgram_stt_use_tls"
type="checkbox"
onChange={(e) => setDeepgramSttUseTls(e.target.checked)}
defaultChecked={deepgramSttUseTls}
/>
<div>Use TLS for STT</div>
</label>
<label htmlFor="deepgram_uri_for_tts">
Container URI<span>*</span>
TTS Container URI<span>*</span>
</label>
<input
id="deepgram_uri_for_tts"
required
type="text"
name="deepgram_uri_for_tts"
placeholder="Container URI for TTS"
value={deepgramSttUri}
onChange={(e) => setDeepgramSttUri(e.target.value)}
placeholder="http://"
value={deepgramTtsUri}
onChange={(e) => setDeepgramTtsUri(e.target.value)}
/>
<label htmlFor={`${vendor}_apikey`}>Api key (if required)</label>
<Passwd
id={`${vendor}_apikey`}
name={`${vendor}_apikey`}
placeholder="API key"
value={credential ? getObscuredSecret(apiKey) : apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
/>
<label htmlFor="deepgram_tts_use_tls" className="chk">
<input
id="deepgram_tts_use_tls"
name="deepgram_tts_use_tls"
type="checkbox"
onChange={(e) => setDeepgramSttUseTls(e.target.checked)}
defaultChecked={deepgramSttUseTls}
/>
<div>Use TLS</div>
</label>
</Checkzone>
</fieldset>
)}
@@ -1848,6 +2099,73 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</fieldset>
</React.Fragment>
)}
{vendor === VENDOR_SPEECHMATICS &&
regions &&
regions[vendor as keyof RegionVendors] && (
<fieldset>
<Checkzone
disabled={hasValue(credential)}
hidden
name="use_hosted_speechmatics_service"
label="Use hosted Speechmatics Service"
initialCheck={!initialSpeechmaticsOnpremCheck}
handleChecked={(e) => {
setInitialSpeechMaticsOnpremCheck(!e.target.checked);
if (e.target.checked) {
setTmpOnpremSpeechmaticsEndpoint(speechmaticsEndpoint);
setSpeechmaticsEndpoint(tmpHostedSpeechmaticsEndpoint);
setTmpHostedSpeechmaticsEndpoint("");
}
}}
>
<label htmlFor="speechmatics_endpoint">
Endpoint {sttCheck && <span>*</span>}
</label>
<Selector
id="speechmatics_endpoint"
name="speechmatics_endpoint"
value={speechmaticsEndpoint}
required
options={[
{
name: "Select a endpoint",
value: "",
},
].concat(regions[vendor as keyof RegionVendors])}
onChange={(e) => setSpeechmaticsEndpoint(e.target.value)}
/>
</Checkzone>
<Checkzone
disabled={hasValue(credential)}
hidden
name="use_on-prem_speechmatics_container"
label="Use on-prem Speechmatics container"
initialCheck={initialSpeechmaticsOnpremCheck}
handleChecked={(e) => {
setInitialSpeechMaticsOnpremCheck(e.target.checked);
if (e.target.checked) {
setTmpHostedSpeechmaticsEndpoint(speechmaticsEndpoint);
setSpeechmaticsEndpoint(tmpOnpremSpeechmaticsEndpoint);
setTmpOnpremSpeechmaticsEndpoint("");
}
}}
>
<label htmlFor="speechmatics_uri_for_stt">
Endpoint URI<span>*</span>
</label>
<input
id="speechmatics_uri_for_stt"
required
type="text"
name="speechmatics_uri_for_stt"
placeholder="Speechmatics URI for STT"
value={speechmaticsEndpoint}
onChange={(e) => setSpeechmaticsEndpoint(e.target.value)}
/>
</Checkzone>
</fieldset>
)}
<fieldset>
<ButtonGroup left>

View File

@@ -12,7 +12,6 @@ import {
} from "src/api";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import {
getHumanDateTime,
isUserAccountScope,
hasLength,
hasValue,
@@ -178,24 +177,6 @@ export const SpeechServices = () => {
<span>{getUsage(credential)}</span>
</div>
</div>
<div>
<div
className={`i txt--${
credential.last_used ? "teal" : "grey"
}`}
>
{credential.last_used ? (
<Icons.CheckCircle />
) : (
<Icons.XCircle />
)}
<span>
{credential.last_used
? getHumanDateTime(credential.last_used)
: "Never used"}
</span>
</div>
</div>
<div>
<CredentialStatus cred={credential} />
</div>

View File

@@ -329,6 +329,11 @@ fieldset {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
&:nth-child(5) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
}
> button {

View File

@@ -12,6 +12,8 @@ $widthnavi: 280px;
$widthinput: 512px;
$widthbreak: 800px;
$widthsmall: 640px;
$widthtypeaheadselector: 512px;
$widthtypeaheadinput: 467px;
/** Used by api-keys layout */
$gridbreak1: 1070px;

View File

@@ -89,6 +89,22 @@ export const getIpValidationType = (ipv4: string): IpType => {
return type;
};
function isValidIPV4(ip: string): boolean {
const ipv4Pattern =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipv4Pattern.test(ip);
}
function isValidCIDR(cidr: string): boolean {
const cidrPattern =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
return cidrPattern.test(cidr);
}
export function isvalidIpv4OrCidr(input: string): boolean {
return isValidIPV4(input) || isValidCIDR(input);
}
export const getObscured = (str: string, sub = 4, char = "*") => {
const len = str.length - sub;
const obscured = str.substring(0, len).replace(/[a-zA-Z0-9]/g, char);

13
src/vendor/index.tsx vendored
View File

@@ -15,6 +15,7 @@ export const VENDOR_DEEPGRAM = "deepgram";
export const VENDOR_IBM = "ibm";
export const VENDOR_NVIDIA = "nvidia";
export const VENDOR_SONIOX = "soniox";
export const VENDOR_SPEECHMATICS = "speechmatics";
export const VENDOR_CUSTOM = "custom";
export const VENDOR_COBALT = "cobalt";
export const VENDOR_ELEVENLABS = "elevenlabs";
@@ -23,6 +24,7 @@ export const VENDOR_WHISPER = "whisper";
export const VENDOR_PLAYHT = "playht";
export const VENDOR_RIMELABS = "rimelabs";
export const VENDOR_VERBIO = "verbio";
export const VENDOR_CARTESIA = "cartesia";
export const vendors: VendorOptions[] = [
{
@@ -61,6 +63,10 @@ export const vendors: VendorOptions[] = [
name: "Soniox",
value: VENDOR_SONIOX,
},
{
name: "Speechmatics",
value: VENDOR_SPEECHMATICS,
},
{
name: "Custom",
value: VENDOR_CUSTOM,
@@ -93,6 +99,10 @@ export const vendors: VendorOptions[] = [
name: "Verbio",
value: VENDOR_VERBIO,
},
{
name: "Cartesia",
value: VENDOR_CARTESIA,
},
].sort((a, b) => a.name.localeCompare(b.name)) as VendorOptions[];
export const AWS_CREDENTIAL_ACCESS_KEY = "access_key";
@@ -124,17 +134,20 @@ export const useRegionVendors = () => {
import("./regions/aws-regions"),
import("./regions/ms-azure-regions"),
import("./regions/ibm-regions"),
import("./regions/speechmatics-regions"),
]).then(
([
{ default: awsRegions },
{ default: msRegions },
{ default: ibmRegions },
{ default: speechmaticsRegions },
]) => {
if (!ignore) {
setRegions({
aws: awsRegions,
microsoft: msRegions,
ibm: ibmRegions,
speechmatics: speechmaticsRegions,
});
}
},

View File

@@ -0,0 +1,18 @@
import type { Region } from "../types";
export const regions: Region[] = [
{
name: "EU (EU2 - On-demand)",
value: "eu2.rt.speechmatics.com",
},
{
name: "EU (EU1 - Enterprise)",
value: "neu.rt.speechmatics.com",
},
{
name: "US (US1 - Enterprise)",
value: "wus.rt.speechmatics.com",
},
];
export default regions;

8
src/vendor/types.ts vendored
View File

@@ -8,6 +8,7 @@ export type Vendor =
| "IBM"
| "Nvidia"
| "Soniox"
| "Speechmatics"
| "Cobalt"
| "Custom"
| "ElevenLabs"
@@ -15,7 +16,8 @@ export type Vendor =
| "whisper"
| "playht"
| "rimelabs"
| "verbio";
| "verbio"
| "Cartesia";
export interface VendorOptions {
name: Vendor;
@@ -71,6 +73,7 @@ export interface RegionVendors {
aws: Region[];
microsoft: Region[];
ibm: Region[];
speechmatics: Region[];
}
export interface TtsModels {
@@ -88,6 +91,7 @@ export interface RecognizerVendors {
ibm: Language[];
nvidia: Language[];
soniox: Language[];
speechmatics: Language[];
cobalt: Language[];
assemblyai: Language[];
}
@@ -103,6 +107,8 @@ export interface SynthesisVendors {
elevenlabs: VoiceLanguage[];
whisper: VoiceLanguage[];
deepgram: VoiceLanguage[];
playht: VoiceLanguage[];
cartesia: VoiceLanguage[];
}
export interface MSRawSpeech {