Compare commits

..

8 Commits

Author SHA1 Message Date
Hoan Luu Huu
4fc6b1ae40 support google gemini tts (#590)
* support google gemini tts

* wip
2026-01-23 10:07:08 -05:00
Sam Machin
c9fcdb08eb Feature admin only numbers & carrier (#587)
* Hide create carrier/number controls if ADMIN_CARRIER env var is set

* hide add caririer button

* add to example .env

* hide delete phone number
2026-01-07 08:01:12 -05:00
Hoan Luu Huu
c33eb46ce0 soundhound speech credential support audio endpoint (#582)
* soubound speech credential support audio endpoint

* wip
2025-11-28 21:48:13 -05:00
Hoan Luu Huu
f003c158dc fix outbound call routing race condition show default lcr route set that user cannot delete (#577) 2025-11-18 07:52:28 -05:00
Sam Machin
b1ddaf230d require IP auth trunk to have either inbound or outbound carrier (#579)
also cleaned up wordig to be consistent `IP Trunk` not `Static IP Whitelist`
2025-11-10 10:29:45 -05:00
Hoan Luu Huu
0260b1ec8b Inbound and outbound sipgateway can be duplicated (#576) 2025-11-08 08:49:00 -05:00
Anton Voylenko
1c1f97f045 chore: bump node version (#575) 2025-11-04 19:33:41 -05:00
Hoan Luu Huu
e6c5a18c87 fixed reg trunk validation cannot move tab and focus to missing fields (#574)
* fixed reg trunk validation cannot move tab and focus to missing fields

* fixed reg trunk validation cannot move tab and focus to missing fields

* wip
2025-10-27 07:21:39 -04:00
12 changed files with 383 additions and 348 deletions

4
.env
View File

@@ -31,4 +31,6 @@
## AWS region for enabling Recent Call Feature server logs
#VITE_APP_AWS_REGION=us-west-2
## enable lazy loading for phone numbers (improves performance when managing large quantities)
# VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true
# VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true
# hides controlls to add Carrier and Phone number from non Admin/SP Users (also need to set flag on API server to block API calls)
#VITE_ADMIN_CARRIER=1

View File

@@ -1,4 +1,4 @@
FROM node:18.15-alpine3.16 as builder
FROM node:20-alpine AS builder
RUN apk update && apk add --no-cache python3 make g++
COPY . /opt/app
WORKDIR /opt/app/
@@ -6,7 +6,7 @@ RUN npm install
RUN npm run build
RUN npm prune
FROM node:18.14.1-alpine as webapp
FROM node:20-alpine AS webapp
RUN apk add curl
WORKDIR /opt/app
COPY . /opt/app

View File

@@ -18,7 +18,7 @@ DISABLE_CALL_RECORDING=${DISABLE_CALL_RECORDING:-false}
# Serialize window global to provide the API URL to static frontend dist
# This is declared and utilized in the web app: src/api/constants.ts
SCRIPT_TAG="<script>window.JAMBONZ = {API_BASE_URL: \"${API_BASE_URL}\",DISABLE_LCR: \"${DISABLE_LCR}\",DISABLE_JAEGER_TRACING: \"${DISABLE_JAEGER_TRACING}\",DISABLE_CUSTOM_SPEECH: \"${DISABLE_CUSTOM_SPEECH}\",ENABLE_FORGOT_PASSWORD: \"${ENABLE_FORGOT_PASSWORD}\",DISABLE_CALL_RECORDING: \"${DISABLE_CALL_RECORDING}\"};</script>"
SCRIPT_TAG="<script>window.JAMBONZ = {API_BASE_URL: \"${API_BASE_URL}\",DISABLE_LCR: \"${DISABLE_LCR}\",DISABLE_JAEGER_TRACING: \"${DISABLE_JAEGER_TRACING}\",DISABLE_CUSTOM_SPEECH: \"${DISABLE_CUSTOM_SPEECH}\",ENABLE_FORGOT_PASSWORD: \"${ENABLE_FORGOT_PASSWORD}\",DISABLE_CALL_RECORDING: \"${DISABLE_CALL_RECORDING}\",ADMIN_CARRIER: \"${ADMIN_CARRIER}\"};</script>"
sed -i -e "\@</head>@i\ $SCRIPT_TAG" ./dist/index.html
# Start the frontend web app static server

86
package-lock.json generated
View File

@@ -46,13 +46,13 @@
"nanoid": "^5.1.5",
"prettier": "^3.2.5",
"sass": "^1.89.2",
"serve": "^14.2.4",
"serve": "^14.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.4",
"vite": "^6.0.1"
"vite": "^6.4.1"
},
"engines": {
"node": ">=14.18"
"node": ">=18"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -2196,19 +2196,6 @@
"integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==",
"dev": true
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dev": true,
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
@@ -3265,6 +3252,7 @@
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
@@ -3273,37 +3261,30 @@
}
},
"node_modules/compression": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
"integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.5",
"bytes": "3.0.0",
"compressible": "~2.0.16",
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"on-headers": "~1.0.2",
"safe-buffer": "5.1.2",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
"dev": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
@@ -3312,13 +3293,8 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
},
"node_modules/compression/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
@@ -6911,10 +6887,11 @@
"dev": true
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -7071,10 +7048,11 @@
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -8047,10 +8025,11 @@
}
},
"node_modules/serve": {
"version": "14.2.4",
"resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz",
"integrity": "sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==",
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz",
"integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@zeit/schemas": "2.36.0",
"ajv": "8.12.0",
@@ -8059,7 +8038,7 @@
"chalk": "5.0.1",
"chalk-template": "0.4.0",
"clipboardy": "3.0.0",
"compression": "1.7.4",
"compression": "1.8.1",
"is-port-reachable": "4.0.0",
"serve-handler": "6.1.6",
"update-check": "1.5.4"
@@ -9104,10 +9083,11 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"type": "module",
"engines": {
"node": ">=14.18"
"node": ">=18"
},
"contributors": [
{
@@ -77,10 +77,10 @@
"nanoid": "^5.1.5",
"prettier": "^3.2.5",
"sass": "^1.89.2",
"serve": "^14.2.4",
"serve": "^14.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.4",
"vite": "^6.0.1"
"vite": "^6.4.1"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --max-warnings=0",

View File

@@ -35,6 +35,7 @@ interface JambonzWindowObject {
DISABLE_ADDITIONAL_SPEECH_VENDORS: string;
AWS_REGION: string;
ENABLE_PHONE_NUMBER_LAZY_LOAD: string;
ADMIN_CARRIER: string;
}
declare global {
@@ -110,6 +111,8 @@ export const STRIPE_PUBLISHABLE_KEY: string =
window.JAMBONZ?.STRIPE_PUBLISHABLE_KEY ||
import.meta.env.VITE_APP_STRIPE_PUBLISHABLE_KEY;
export const ADMIN_CARRIER: string =
window.JAMBONZ?.ADMIN_CARRIER || import.meta.env.VITE_ADMIN_CARRIER || "0";
/** TCP Max Port */
export const TCP_MAX_PORT = 65535;
@@ -429,6 +432,13 @@ export const DEEPGRAM_STT_ENPOINT = [
{ name: "EU-hosted", value: "api.eu.deepgram.com" },
];
// ElevenLabs API URI options
export const ELEVENLABS_API_URI_OPTIONS = [
{ name: "US", value: "api.elevenlabs.io" },
{ name: "EU", value: "api.eu.residency.elevenlabs.io" },
{ name: "IN", value: "api.in.residency.elevenlabs.io" },
];
/** User scope values values */
export const USER_ADMIN = "admin";
export const USER_SP = "service_provider";

View File

@@ -450,6 +450,7 @@ export interface SpeechCredential {
resemble_tts_uri: null | string;
resemble_tts_use_tls: number;
api_uri: null | string;
houndify_server_uri: null | string;
}
export interface Alert {

View File

@@ -463,24 +463,18 @@ export const CarrierForm = ({
};
const getSipValidation = () => {
if (sipInboundGateways.length === 0 && sipOutboundGateways.length === 0) {
if (trunkType === "static_ip") {
setActiveTab("inbound");
return "Static IP Whitelist trunk type requires at least one inbound gateway.";
} else if (trunkType === "reg") {
setActiveTab("outbound");
return "Registration trunk type requires at least one outbound gateway.";
}
}
if (trunkType === "static_ip" && sipInboundGateways.length < 1) {
setActiveTab("inbound");
return "Static IP Whitelist trunk type requires at least one inbound gateway.";
if (
trunkType === "static_ip" &&
sipInboundGateways.length === 0 &&
sipOutboundGateways.length === 0
) {
setActiveTab("general");
return "IP Trunk type requires at least one inbound or outbound gateway.";
}
if (trunkType === "reg" && sipOutboundGateways.length < 1) {
setActiveTab("outbound");
return "Registration trunk type requires at least one outbound gateway.";
return "Registration Trunk type requires at least one outbound gateway.";
}
// Validate Auth Trunk credentials
@@ -577,26 +571,6 @@ export const CarrierForm = ({
return "Each SIP gateway must have a unique IP address.";
}
}
// Check for duplicates between inbound and outbound gateways
for (let i = 0; i < sipInboundGateways.length; i++) {
const inboundGateway = sipInboundGateways[i];
const dupeInOutbound = sipOutboundGateways.find((g) => {
return (
inboundGateway.ipv4 &&
g.ipv4 === inboundGateway.ipv4 &&
g.port === inboundGateway.port
);
});
if (dupeInOutbound) {
if (refSipInboundIp.current[i]) {
refSipInboundIp.current[i].focus();
}
setActiveTab("inbound");
return "Each SIP gateway must have a unique IP address.";
}
}
};
const getSmppValidation = () => {
@@ -721,9 +695,11 @@ export const CarrierForm = ({
if (sipGatewayValidation) {
if (
sipGatewayValidation ===
"Static IP Whitelist trunk type requires at least one inbound gateway."
"IP Trunk type requires at least one inbound or outbound gateway."
) {
setSipInboundMessage(sipGatewayValidation);
setSipOutboundMessage(sipGatewayValidation);
toastError(sipGatewayValidation);
} else if (
sipGatewayValidation ===
"Auth Trunk requires both username and password credentials."
@@ -731,7 +707,7 @@ export const CarrierForm = ({
setSipInboundMessage(sipGatewayValidation);
} else if (
sipGatewayValidation ===
"Registration trunk type requires at least one outbound gateway."
"Registration Trunk type requires at least one outbound gateway."
) {
setSipOutboundMessage(sipGatewayValidation);
} else {

View File

@@ -31,6 +31,9 @@ import {
ENABLE_HOSTED_SYSTEM,
PER_PAGE_SELECTION,
USER_ACCOUNT,
ADMIN_CARRIER,
USER_ADMIN,
USER_SP,
} from "src/api/constants";
import { DeleteCarrier } from "./delete";
@@ -202,13 +205,16 @@ export const Carriers = () => {
</M>
)}
</div>
<Link to={`${ROUTE_INTERNAL_CARRIERS}/add`} title="Add a Carrier">
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Link to={`${ROUTE_INTERNAL_CARRIERS}/add`} title="Add a Carrier">
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
)}
</section>
<section className="filters filters--multi">
<SearchFilter
@@ -325,11 +331,15 @@ export const Carriers = () => {
)}
</div>
</Section>
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CARRIERS}/add`}>
Add carrier
</Button>
</Section>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CARRIERS}/add`}>
Add carrier
</Button>
</Section>
)}
<footer>
<ButtonGroup>
<MS>

View File

@@ -67,9 +67,6 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const [accountSid, setAccountSid] = useState("");
const [isActive, setIsActive] = useState(true);
const [lcrRoutes, setLcrRoutes] = useState<LcrRoute[]>([LCR_ROUTE_TEMPLATE]);
const [previousLcrRoutes, setPreviousLcrRoutes] = useState<LcrRoute[]>([
LCR_ROUTE_TEMPLATE,
]);
const [previouseLcr, setPreviousLcr] = useState<Lcr | null>();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [lcrForDelete, setLcrForDelete] = useState<Lcr | null>();
@@ -127,38 +124,35 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
}, [lcrDataMap?.data, previouseLcr]);
useMemo(() => {
let default_lcr_route_sid = "";
if (
lcrRouteDataMap &&
lcrRouteDataMap.data &&
lcrRouteDataMap.data !== previousLcrRoutes
) {
setPreviousLcrRoutes(lcrRouteDataMap.data);
// Find default carrier
lcrRouteDataMap.data.forEach((lr) => {
lr.lcr_carrier_set_entries?.forEach((entry) => {
if (
entry.lcr_carrier_set_entry_sid ===
lcrDataMap?.data?.default_carrier_set_entry_sid
) {
// Only process when both lcrDataMap and lcrRouteDataMap are available
if (lcrRouteDataMap && lcrRouteDataMap.data && lcrDataMap?.data) {
const defaultCarrierSetEntrySid =
lcrDataMap.data.default_carrier_set_entry_sid;
// Find and store default route information
lcrRouteDataMap.data.forEach((route) => {
route.lcr_carrier_set_entries?.forEach((entry) => {
if (entry.lcr_carrier_set_entry_sid === defaultCarrierSetEntrySid) {
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
setDefaultLcrCarrierSetEntrySid(
entry.lcr_carrier_set_entry_sid || null,
);
default_lcr_route_sid = entry.lcr_route_sid || "";
setDefaultLcrRoute(lr);
setDefaultLcrRoute(route);
}
});
});
}
if (lcrRouteDataMap && lcrRouteDataMap.data)
setLcrRoutes(
lcrRouteDataMap.data.filter(
(route) => route.lcr_route_sid !== default_lcr_route_sid,
),
);
}, [lcrRouteDataMap?.data]);
// Filter out routes that contain the default carrier set entry
const filteredRoutes = lcrRouteDataMap.data.filter((route) => {
return !route.lcr_carrier_set_entries?.some(
(entry) =>
entry.lcr_carrier_set_entry_sid === defaultCarrierSetEntrySid,
);
});
setLcrRoutes(filteredRoutes);
}
}, [lcrRouteDataMap?.data, lcrDataMap?.data]);
const addLcrRoutes = () => {
const newLcrRoute = LCR_ROUTE_TEMPLATE;

View File

@@ -28,7 +28,13 @@ import { hasLength, hasValue, formatPhoneNumber } from "src/utils";
import { DeletePhoneNumber } from "./delete";
import type { Account, PhoneNumber, Carrier, Application } from "src/api/types";
import { PER_PAGE_SELECTION, USER_ACCOUNT } from "src/api/constants";
import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
USER_ADMIN,
ADMIN_CARRIER,
USER_SP,
} from "src/api/constants";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
@@ -185,16 +191,20 @@ export const PhoneNumbers = () => {
<>
<section className="mast">
<H1 className="h2">Phone numbers</H1>
{hasLength(accounts) && hasLength(carriers) && (
<Link
to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}
title="Add a phone number"
>
<Icon>
<Icons.Plus />
</Icon>
</Link>
)}
{hasLength(accounts) &&
hasLength(carriers) &&
((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Link
to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}
title="Add a phone number"
>
<Icon>
<Icons.Plus />
</Icon>
</Link>
)}
</section>
<section className="filters filters--multi">
<SearchFilter
@@ -368,14 +378,19 @@ export const PhoneNumbers = () => {
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete phone number"
onClick={() => setPhoneNumber(phoneNumber)}
className="btnty"
>
<Icons.Trash />
</button>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN ||
user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<button
type="button"
title="Delete phone number"
onClick={() => setPhoneNumber(phoneNumber)}
className="btnty"
>
<Icons.Trash />
</button>
)}
</div>
</div>
);
@@ -404,13 +419,17 @@ export const PhoneNumbers = () => {
)}
</div>
</Section>
<Section clean>
{hasLength(accounts) && hasLength(carriers) && (
<Button small as={Link} to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}>
Add phone number
</Button>
)}
</Section>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Section clean>
{hasLength(accounts) && hasLength(carriers) && (
<Button small as={Link} to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}>
Add phone number
</Button>
)}
</Section>
)}
<footer>
<ButtonGroup>
<MS>

View File

@@ -97,6 +97,7 @@ import {
DEFAULT_VERBIO_MODEL,
DISABLE_ADDITIONAL_SPEECH_VENDORS,
DISABLE_CUSTOM_SPEECH,
ELEVENLABS_API_URI_OPTIONS,
GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
VERBIO_STT_MODELS,
} from "src/api/constants";
@@ -110,13 +111,6 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const { toastError, toastSuccess } = useToast();
const navigate = useNavigate();
const user = useSelectState("user");
// ElevenLabs API URI options
const ELEVENLABS_API_URI_OPTIONS = [
{ name: "US", value: "api.elevenlabs.io" },
{ name: "EU", value: "api.eu.residency.elevenlabs.io" },
{ name: "IN", value: "api.in.residency.elevenlabs.io" },
];
const currentServiceProvider = useSelectState("currentServiceProvider");
const regions = useRegionVendors();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
@@ -225,6 +219,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
useState(false);
const [resembleTtsUseTls, setResembleTtsUseTls] = useState(false);
const [tmpResembleTtsUseTls, setTmpResembleTtsUseTls] = useState(false);
const [houndifyServerUri, setHoundifyServerUri] = useState("");
const handleFile = (file: File) => {
const handleError = () => {
setGoogleServiceKey(null);
@@ -417,6 +412,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
...(vendor === VENDOR_AWS && {
aws_region: region || null,
}),
...(vendor === VENDOR_GOOGLE && {
model_id: ttsModelId || null,
}),
...(vendor === VENDOR_MICROSOFT && {
region: region || null,
use_custom_tts:
@@ -462,6 +460,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
client_id: clientId || null,
client_key: clientKey || null,
user_id: userId || null,
houndify_server_uri: houndifyServerUri || null,
}),
...(vendor === VENDOR_COBALT && {
cobalt_server_uri: cobaltServerUri || null,
@@ -850,6 +849,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setOptionsInitialChecked(true);
}
if (credential?.data?.vendor === VENDOR_GOOGLE) {
// Load model_id for Gemini TTS
if (credential.data.model_id) {
setTtsModelId(credential.data.model_id);
}
// let try to check if there is custom voices
getGoogleCustomVoices({
speech_credential_sid: credential.data.speech_credential_sid,
@@ -878,6 +881,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setUserId(credential.data.user_id);
}
if (credential?.data?.houndify_server_uri) {
setHoundifyServerUri(credential.data.houndify_server_uri);
}
if (credential?.data?.voice_engine) {
setTtsModelId(credential.data.voice_engine);
}
@@ -1230,218 +1237,245 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</fieldset>
)}
{ttsCheck && vendor === VENDOR_GOOGLE && (
<fieldset>
<label htmlFor="use_custom_voice" className="chk">
<>
<fieldset>
<label htmlFor="google_tts_model_id">
Model ID
<Tooltip text="Provide a model ID to enable Gemini TTS (e.g., gemini-2.5-flash-tts). Leave empty to use standard Google TTS.">
{" "}
</Tooltip>
</label>
<input
id="use_custom_voice"
name="use_custom_voice"
type="checkbox"
onChange={(e) => {
if (e.target.checked && customVoices.length === 0) {
setCustomVoices([DEFAULT_GOOGLE_CUSTOM_VOICE]);
}
setUseCustomVoicesCheck(e.target.checked);
}}
checked={useCustomVoicesCheck}
id="google_tts_model_id"
name="google_tts_model_id"
type="text"
placeholder="e.g., gemini-2.5-flash-tts"
value={ttsModelId}
onChange={(e) => setTtsModelId(e.target.value)}
/>
<div>Use custom voices</div>
</label>
{useCustomVoicesCheck && (
<fieldset>
<label htmlFor="sip_gateways">Custom Voices</label>
<MXS>
<em>At least one Custom voice is required.</em>
</MXS>
{customVoicesMessage && (
<Message message={customVoicesMessage} />
)}
{hasLength(customVoices) &&
customVoices.map((v, i) => (
<div key={`custom_voice_${i}`} className="customVoice">
<div>
</fieldset>
<fieldset>
<label htmlFor="use_custom_voice" className="chk">
<input
id="use_custom_voice"
name="use_custom_voice"
type="checkbox"
onChange={(e) => {
if (e.target.checked && customVoices.length === 0) {
setCustomVoices([DEFAULT_GOOGLE_CUSTOM_VOICE]);
}
setUseCustomVoicesCheck(e.target.checked);
}}
checked={useCustomVoicesCheck}
/>
<div>Use custom voices</div>
</label>
{useCustomVoicesCheck && (
<fieldset>
<label htmlFor="sip_gateways">Custom Voices</label>
<MXS>
<em>At least one Custom voice is required.</em>
</MXS>
{customVoicesMessage && (
<Message message={customVoicesMessage} />
)}
{hasLength(customVoices) &&
customVoices.map((v, i) => (
<div
key={`custom_voice_${i}`}
className="customVoice"
>
<div>
<label htmlFor="custom_voice_name">
Name
{!v.use_voice_cloning_key
? " / Reported Usage"
: ""}
</label>
</div>
</div>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Assigned Name"
required
value={v.name}
onChange={(e) => {
updateCustomVoices(i, "name", 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}
<label htmlFor="custom_voice_name">
Name
{!v.use_voice_cloning_key
? " / Reported Usage"
: ""}
</label>
</div>
</div>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Assigned Name"
required
value={v.name}
onChange={(e) => {
updateCustomVoices(
i,
"reported_usage",
"name",
e.target.value,
);
}}
/>
</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>
{!v.use_voice_cloning_key && (
<>
<div>
{!v.use_voice_cloning_key && (
<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%" }}
<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,
"model",
"reported_usage",
e.target.value,
);
}}
/>
</div>
</div>
</>
)}
)}
</div>
{v.use_voice_cloning_key === 1 && (
<>
<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>
{!v.use_voice_cloning_key && (
<>
<div>
{hasValue(v.voice_cloning_key) && (
<pre>
<code>{v.voice_cloning_key}</code>
</pre>
)}
<div>
<label htmlFor="custom_voice_name">
Model
</label>
</div>
</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) => {
<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,
"voice_cloning_key",
text.substring(0, 100) + "...",
"model",
e.target.value,
);
});
}}
required={!v.voice_cloning_key}
/>
}}
/>
</div>
</div>
</div>
</>
)}
</>
)}
<button
className="btnty"
title="Delete custom voice"
type="button"
onClick={() => {
setCustomVoicesMessage("");
if (customVoices.length === 1) {
setCustomVoicesMessage(
"You must provide at least one custom voice.",
{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"
title="Delete custom voice"
type="button"
onClick={() => {
setCustomVoicesMessage("");
if (customVoices.length === 1) {
setCustomVoicesMessage(
"You must provide at least one custom voice.",
);
return;
}
if (v.google_custom_voice_sid) {
deleteGoogleCustomVoice(
v.google_custom_voice_sid,
).finally(() => {
credential?.refetch();
});
}
setCustomVoices((prev) =>
prev.filter((_, idx) => idx !== i),
);
return;
}
if (v.google_custom_voice_sid) {
deleteGoogleCustomVoice(
v.google_custom_voice_sid,
).finally(() => {
credential?.refetch();
});
}
setCustomVoices((prev) =>
prev.filter((_, idx) => idx !== i),
);
}}
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
))}
<ButtonGroup left>
<button
className="btnty"
type="button"
title="Add Voice"
onClick={() => {
setCustomVoicesMessage("");
setCustomVoices((prev) => [
...prev,
DEFAULT_GOOGLE_CUSTOM_VOICE,
]);
}}
>
<Icon subStyle="teal">
<Icons.Plus />
</Icon>
</button>
</ButtonGroup>
</fieldset>
)}
</fieldset>
}}
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
))}
<ButtonGroup left>
<button
className="btnty"
type="button"
title="Add Voice"
onClick={() => {
setCustomVoicesMessage("");
setCustomVoices((prev) => [
...prev,
DEFAULT_GOOGLE_CUSTOM_VOICE,
]);
}}
>
<Icon subStyle="teal">
<Icons.Plus />
</Icon>
</button>
</ButtonGroup>
</fieldset>
)}
</fieldset>
</>
)}
</>
)}
@@ -1484,6 +1518,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
onChange={(e) => setUserId(e.target.value)}
disabled={credential ? true : false}
/>
<label htmlFor="houndify_server_uri">Audio Endpoint</label>
<input
id="houndify_server_uri"
type="text"
name="houndify_server_uri"
placeholder="Audio Endpoint (optional)"
value={houndifyServerUri}
onChange={(e) => setHoundifyServerUri(e.target.value)}
/>
</fieldset>
)}
{vendor === VENDOR_NUANCE && (